feat: implement mass balance validation for Story 7.1
- Added port_mass_flows to Component trait and implements for core components. - Added System::check_mass_balance and integrated it into the solver. - Restored connect methods for ExpansionValve, Compressor, and Pipe to fix integration tests. - Updated Python and C bindings for validation errors. - Updated sprint status and story documentation.
This commit is contained in:
@@ -652,6 +652,39 @@ impl Compressor<Disconnected> {
|
||||
pub fn set_operational_state(&mut self, state: OperationalState) {
|
||||
self.operational_state = state;
|
||||
}
|
||||
/// Connects the compressor to suction and discharge ports.
|
||||
///
|
||||
/// This consumes the disconnected compressor and returns a connected one,
|
||||
/// transitioning the state at compile time.
|
||||
pub fn connect(
|
||||
self,
|
||||
suction: Port<Disconnected>,
|
||||
discharge: Port<Disconnected>,
|
||||
) -> Result<Compressor<Connected>, ComponentError> {
|
||||
let (p_suction, _) = self
|
||||
.port_suction
|
||||
.connect(suction)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
let (p_discharge, _) = self
|
||||
.port_discharge
|
||||
.connect(discharge)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
|
||||
Ok(Compressor {
|
||||
model: self.model,
|
||||
port_suction: p_suction,
|
||||
port_discharge: p_discharge,
|
||||
speed_rpm: self.speed_rpm,
|
||||
displacement_m3_per_rev: self.displacement_m3_per_rev,
|
||||
mechanical_efficiency: self.mechanical_efficiency,
|
||||
calib: self.calib,
|
||||
calib_indices: self.calib_indices,
|
||||
fluid_id: self.fluid_id,
|
||||
circuit_id: self.circuit_id,
|
||||
operational_state: self.operational_state,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Compressor<Connected> {
|
||||
@@ -1217,6 +1250,22 @@ impl Component for Compressor<Connected> {
|
||||
2 // Mass flow residual and energy residual
|
||||
}
|
||||
|
||||
fn port_mass_flows(&self, state: &SystemState) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
if state.len() < 4 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 4,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
let m = entropyk_core::MassFlow::from_kg_per_s(state[0]);
|
||||
// Suction (inlet), Discharge (outlet), Oil (no flow modeled yet)
|
||||
Ok(vec![
|
||||
m,
|
||||
entropyk_core::MassFlow::from_kg_per_s(-m.to_kg_per_s()),
|
||||
entropyk_core::MassFlow::from_kg_per_s(0.0)
|
||||
])
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
// NOTE: This returns an empty slice due to lifetime constraints.
|
||||
// Use `get_ports_slice()` method on Compressor<Connected> for actual port access.
|
||||
|
||||
@@ -222,6 +222,36 @@ impl ExpansionValve<Disconnected> {
|
||||
pub fn is_effectively_off(&self) -> bool {
|
||||
is_effectively_off_impl(self.operational_state, self.opening)
|
||||
}
|
||||
/// Connects the expansion valve to inlet and outlet ports.
|
||||
///
|
||||
/// This consumes the disconnected valve and returns a connected one,
|
||||
/// transitioning the state at compile time.
|
||||
pub fn connect(
|
||||
self,
|
||||
inlet: Port<Disconnected>,
|
||||
outlet: Port<Disconnected>,
|
||||
) -> Result<ExpansionValve<Connected>, ComponentError> {
|
||||
let (p_in, _) = self
|
||||
.port_inlet
|
||||
.connect(inlet)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
let (p_out, _) = self
|
||||
.port_outlet
|
||||
.connect(outlet)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
|
||||
Ok(ExpansionValve {
|
||||
port_inlet: p_in,
|
||||
port_outlet: p_out,
|
||||
calib: self.calib,
|
||||
calib_indices: self.calib_indices,
|
||||
operational_state: self.operational_state,
|
||||
opening: self.opening,
|
||||
fluid_id: self.fluid_id,
|
||||
circuit_id: self.circuit_id,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase region at a thermodynamic state point.
|
||||
@@ -603,6 +633,18 @@ impl Component for ExpansionValve<Connected> {
|
||||
2
|
||||
}
|
||||
|
||||
fn port_mass_flows(&self, state: &SystemState) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
if state.len() < MIN_STATE_DIMENSIONS {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: MIN_STATE_DIMENSIONS,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
let m_in = entropyk_core::MassFlow::from_kg_per_s(state[0]);
|
||||
let m_out = entropyk_core::MassFlow::from_kg_per_s(-state[1]); // Negative because it's leaving
|
||||
Ok(vec![m_in, m_out])
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ pub use state_machine::{
|
||||
StateTransitionRecord,
|
||||
};
|
||||
|
||||
use entropyk_core::MassFlow;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during component operations.
|
||||
@@ -543,6 +544,23 @@ pub trait Component {
|
||||
0
|
||||
}
|
||||
|
||||
/// Returns the mass flow vector associated with the component's ports.
|
||||
///
|
||||
/// The returned vector matches the order of ports returned by `get_ports()`.
|
||||
/// Positive values indicate flow *into* the component, negative values flow *out*.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `state` - The global system state vector
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MassFlow>)` containing the mass flows if calculation is supported
|
||||
/// * `Err(ComponentError::NotImplemented)` by default
|
||||
fn port_mass_flows(&self, _state: &SystemState) -> Result<Vec<MassFlow>, ComponentError> {
|
||||
Err(ComponentError::CalculationFailed("Mass flow calculation not implemented for this component".to_string()))
|
||||
}
|
||||
|
||||
/// Injects control variable indices for calibration parameters into a component.
|
||||
///
|
||||
/// Called by the solver (e.g. `System::finalize()`) after matching `BoundedVariable`s
|
||||
|
||||
@@ -404,6 +404,36 @@ impl Pipe<Disconnected> {
|
||||
pub fn set_calib(&mut self, calib: Calib) {
|
||||
self.calib = calib;
|
||||
}
|
||||
/// Connects the pipe to inlet and outlet ports.
|
||||
///
|
||||
/// This consumes the disconnected pipe and returns a connected one,
|
||||
/// transitioning the state at compile time.
|
||||
pub fn connect(
|
||||
self,
|
||||
inlet: Port<Disconnected>,
|
||||
outlet: Port<Disconnected>,
|
||||
) -> Result<Pipe<Connected>, ComponentError> {
|
||||
let (p_in, _) = self
|
||||
.port_inlet
|
||||
.connect(inlet)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
let (p_out, _) = self
|
||||
.port_outlet
|
||||
.connect(outlet)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
|
||||
Ok(Pipe {
|
||||
geometry: self.geometry,
|
||||
port_inlet: p_in,
|
||||
port_outlet: p_out,
|
||||
fluid_density_kg_per_m3: self.fluid_density_kg_per_m3,
|
||||
fluid_viscosity_pa_s: self.fluid_viscosity_pa_s,
|
||||
calib: self.calib,
|
||||
circuit_id: self.circuit_id,
|
||||
operational_state: self.operational_state,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Pipe<Connected> {
|
||||
@@ -622,6 +652,17 @@ impl Component for Pipe<Connected> {
|
||||
1
|
||||
}
|
||||
|
||||
fn port_mass_flows(&self, state: &SystemState) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
if state.is_empty() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 1,
|
||||
actual: 0,
|
||||
});
|
||||
}
|
||||
let m = entropyk_core::MassFlow::from_kg_per_s(state[0]);
|
||||
Ok(vec![m, entropyk_core::MassFlow::from_kg_per_s(-m.to_kg_per_s())])
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
@@ -63,6 +63,15 @@ pub enum ThermoError {
|
||||
/// System was not finalized before an operation.
|
||||
#[error("System must be finalized before this operation")]
|
||||
NotFinalized,
|
||||
|
||||
/// Simulation validation error (e.g., mass/energy balance constraints violated)
|
||||
#[error("Validation failed: mass error = {mass_error:.3e} kg/s, energy error = {energy_error:.3e} W")]
|
||||
Validation {
|
||||
/// Mass balance error in kg/s
|
||||
mass_error: f64,
|
||||
/// Energy balance error in W
|
||||
energy_error: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl ThermoError {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#[cfg(feature = "coolprop")]
|
||||
use crate::damped_backend::DampedBackend;
|
||||
use crate::errors::{FluidError, FluidResult};
|
||||
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
|
||||
use crate::types::{CriticalPoint, FluidId, FluidState, Phase, Property};
|
||||
|
||||
#[cfg(feature = "coolprop")]
|
||||
use crate::mixture::Mixture;
|
||||
@@ -37,18 +37,79 @@ impl CoolPropBackend {
|
||||
let backend = CoolPropBackend {
|
||||
critical_cache: RwLock::new(HashMap::new()),
|
||||
available_fluids: vec![
|
||||
// HFC Refrigerants
|
||||
FluidId::new("R134a"),
|
||||
FluidId::new("R410A"),
|
||||
FluidId::new("R404A"),
|
||||
FluidId::new("R407C"),
|
||||
FluidId::new("R32"),
|
||||
FluidId::new("R125"),
|
||||
FluidId::new("R143a"),
|
||||
FluidId::new("R152A"),
|
||||
FluidId::new("R22"),
|
||||
FluidId::new("R23"),
|
||||
FluidId::new("R41"),
|
||||
FluidId::new("R245fa"),
|
||||
FluidId::new("R245ca"),
|
||||
// HFO/HFC Low-GWP Refrigerants
|
||||
FluidId::new("R1234yf"),
|
||||
FluidId::new("R1234ze(E)"),
|
||||
FluidId::new("R1234ze(Z)"),
|
||||
FluidId::new("R1233zd(E)"),
|
||||
FluidId::new("R1243zf"),
|
||||
FluidId::new("R1336mzz(E)"),
|
||||
FluidId::new("R513A"),
|
||||
FluidId::new("R454B"),
|
||||
FluidId::new("R452B"),
|
||||
FluidId::new("R32"),
|
||||
// Natural Refrigerants
|
||||
FluidId::new("R744"),
|
||||
FluidId::new("R290"),
|
||||
FluidId::new("R600"),
|
||||
FluidId::new("R600a"),
|
||||
FluidId::new("R1270"),
|
||||
FluidId::new("R717"),
|
||||
// Other Refrigerants
|
||||
FluidId::new("R11"),
|
||||
FluidId::new("R12"),
|
||||
FluidId::new("R13"),
|
||||
FluidId::new("R14"),
|
||||
FluidId::new("R113"),
|
||||
FluidId::new("R114"),
|
||||
FluidId::new("R115"),
|
||||
FluidId::new("R116"),
|
||||
FluidId::new("R123"),
|
||||
FluidId::new("R124"),
|
||||
FluidId::new("R141b"),
|
||||
FluidId::new("R142b"),
|
||||
FluidId::new("R218"),
|
||||
FluidId::new("R227EA"),
|
||||
FluidId::new("R236EA"),
|
||||
FluidId::new("R236FA"),
|
||||
FluidId::new("R365MFC"),
|
||||
FluidId::new("RC318"),
|
||||
FluidId::new("R507A"),
|
||||
// Non-Refrigerant Fluids
|
||||
FluidId::new("Water"),
|
||||
FluidId::new("Air"),
|
||||
FluidId::new("Ammonia"),
|
||||
FluidId::new("CO2"),
|
||||
FluidId::new("Nitrogen"),
|
||||
FluidId::new("Oxygen"),
|
||||
FluidId::new("Argon"),
|
||||
FluidId::new("Helium"),
|
||||
FluidId::new("Hydrogen"),
|
||||
FluidId::new("Methane"),
|
||||
FluidId::new("Ethane"),
|
||||
FluidId::new("Propane"),
|
||||
FluidId::new("Butane"),
|
||||
FluidId::new("Ethylene"),
|
||||
FluidId::new("Propylene"),
|
||||
FluidId::new("Ethanol"),
|
||||
FluidId::new("Methanol"),
|
||||
FluidId::new("Acetone"),
|
||||
FluidId::new("Benzene"),
|
||||
FluidId::new("Toluene"),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -66,20 +127,76 @@ impl CoolPropBackend {
|
||||
|
||||
/// Get the CoolProp internal name for a fluid.
|
||||
fn fluid_name(&self, fluid: &FluidId) -> String {
|
||||
// Map common names to CoolProp internal names
|
||||
match fluid.0.to_lowercase().as_str() {
|
||||
// HFC Refrigerants
|
||||
"r134a" => "R134a".to_string(),
|
||||
"r410a" => "R410A".to_string(),
|
||||
"r404a" => "R404A".to_string(),
|
||||
"r407c" => "R407C".to_string(),
|
||||
"r32" => "R32".to_string(),
|
||||
"r125" => "R125".to_string(),
|
||||
"co2" | "r744" => "CO2".to_string(),
|
||||
"r290" => "R290".to_string(),
|
||||
"r600" => "R600".to_string(),
|
||||
"r600a" => "R600A".to_string(),
|
||||
"r143a" => "R143a".to_string(),
|
||||
"r152a" | "r152a" => "R152A".to_string(),
|
||||
"r22" => "R22".to_string(),
|
||||
"r23" => "R23".to_string(),
|
||||
"r41" => "R41".to_string(),
|
||||
"r245fa" => "R245fa".to_string(),
|
||||
"r245ca" => "R245ca".to_string(),
|
||||
// HFO/HFC Low-GWP Refrigerants
|
||||
"r1234yf" => "R1234yf".to_string(),
|
||||
"r1234ze" | "r1234ze(e)" => "R1234ze(E)".to_string(),
|
||||
"r1234ze(z)" => "R1234ze(Z)".to_string(),
|
||||
"r1233zd" | "r1233zd(e)" => "R1233zd(E)".to_string(),
|
||||
"r1243zf" => "R1243zf".to_string(),
|
||||
"r1336mzz" | "r1336mzz(e)" => "R1336mzz(E)".to_string(),
|
||||
"r513a" => "R513A".to_string(),
|
||||
"r513b" => "R513B".to_string(),
|
||||
"r454b" => "R454B".to_string(),
|
||||
"r452b" => "R452B".to_string(),
|
||||
// Natural Refrigerants (aliases)
|
||||
"r744" | "co2" => "CO2".to_string(),
|
||||
"r290" | "propane" => "R290".to_string(),
|
||||
"r600" | "butane" | "n-butane" => "R600".to_string(),
|
||||
"r600a" | "isobutane" => "R600A".to_string(),
|
||||
"r1270" | "propylene" => "R1270".to_string(),
|
||||
"r717" | "ammonia" => "Ammonia".to_string(),
|
||||
// Other Refrigerants
|
||||
"r11" => "R11".to_string(),
|
||||
"r12" => "R12".to_string(),
|
||||
"r13" => "R13".to_string(),
|
||||
"r14" => "R14".to_string(),
|
||||
"r113" => "R113".to_string(),
|
||||
"r114" => "R114".to_string(),
|
||||
"r115" => "R115".to_string(),
|
||||
"r116" => "R116".to_string(),
|
||||
"r123" => "R123".to_string(),
|
||||
"r124" => "R124".to_string(),
|
||||
"r141b" => "R141b".to_string(),
|
||||
"r142b" => "R142b".to_string(),
|
||||
"r218" => "R218".to_string(),
|
||||
"r227ea" => "R227EA".to_string(),
|
||||
"r236ea" => "R236EA".to_string(),
|
||||
"r236fa" => "R236FA".to_string(),
|
||||
"r365mfc" => "R365MFC".to_string(),
|
||||
"rc318" => "RC318".to_string(),
|
||||
"r507a" => "R507A".to_string(),
|
||||
// Non-Refrigerant Fluids
|
||||
"water" => "Water".to_string(),
|
||||
"air" => "Air".to_string(),
|
||||
"nitrogen" => "Nitrogen".to_string(),
|
||||
"oxygen" => "Oxygen".to_string(),
|
||||
"argon" => "Argon".to_string(),
|
||||
"helium" => "Helium".to_string(),
|
||||
"hydrogen" => "Hydrogen".to_string(),
|
||||
"methane" => "Methane".to_string(),
|
||||
"ethane" => "Ethane".to_string(),
|
||||
"ethylene" => "Ethylene".to_string(),
|
||||
"ethanol" => "Ethanol".to_string(),
|
||||
"methanol" => "Methanol".to_string(),
|
||||
"acetone" => "Acetone".to_string(),
|
||||
"benzene" => "Benzene".to_string(),
|
||||
"toluene" => "Toluene".to_string(),
|
||||
// Pass through unknown names
|
||||
n => n.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -355,9 +472,14 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
|
||||
fn full_state(
|
||||
&self,
|
||||
fluid: FluidId,
|
||||
p: entropyk_core::Pressure,
|
||||
h: entropyk_core::Enthalpy,
|
||||
) -> FluidResult<crate::types::ThermoState> {
|
||||
let coolprop_fluid = self.fluid_name(&fluid);
|
||||
|
||||
|
||||
if !self.is_fluid_available(&fluid) {
|
||||
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
|
||||
}
|
||||
@@ -369,7 +491,10 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
let t_k = coolprop::props_si_ph("T", p_pa, h_j_kg, &coolprop_fluid);
|
||||
if t_k.is_nan() {
|
||||
return Err(FluidError::InvalidState {
|
||||
reason: format!("CoolProp returned NaN for Temperature at P={}, h={} for {}", p_pa, h_j_kg, fluid),
|
||||
reason: format!(
|
||||
"CoolProp returned NaN for Temperature at P={}, h={} for {}",
|
||||
p_pa, h_j_kg, fluid
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -378,7 +503,7 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
let q = coolprop::props_si_ph("Q", p_pa, h_j_kg, &coolprop_fluid);
|
||||
|
||||
let phase = self.phase(fluid.clone(), FluidState::from_ph(p, h))?;
|
||||
|
||||
|
||||
let quality = if (0.0..=1.0).contains(&q) {
|
||||
Some(crate::types::Quality::new(q))
|
||||
} else {
|
||||
@@ -395,7 +520,7 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
Some(crate::types::TemperatureDelta::new(t_bubble - t_k))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
@@ -408,7 +533,7 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
Some(crate::types::TemperatureDelta::new(t_k - t_dew))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
@@ -487,7 +612,12 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn full_state(&self, _fluid: FluidId, _p: entropyk_core::Pressure, _h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
|
||||
fn full_state(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_p: entropyk_core::Pressure,
|
||||
_h: entropyk_core::Enthalpy,
|
||||
) -> FluidResult<crate::types::ThermoState> {
|
||||
Err(FluidError::CoolPropError(
|
||||
"CoolProp not available. Enable 'coolprop' feature to use this backend.".to_string(),
|
||||
))
|
||||
@@ -624,17 +754,19 @@ mod tests {
|
||||
let pressure = Pressure::from_bar(1.0);
|
||||
let enthalpy = entropyk_core::Enthalpy::from_kilojoules_per_kg(415.0); // Superheated vapor region
|
||||
|
||||
let state = backend.full_state(fluid.clone(), pressure, enthalpy).unwrap();
|
||||
|
||||
let state = backend
|
||||
.full_state(fluid.clone(), pressure, enthalpy)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.fluid, fluid);
|
||||
assert_eq!(state.pressure, pressure);
|
||||
assert_eq!(state.enthalpy, enthalpy);
|
||||
|
||||
|
||||
// Temperature should be valid
|
||||
assert!(state.temperature.to_celsius() > -30.0);
|
||||
assert!(state.density > 0.0);
|
||||
assert!(state.entropy.to_joules_per_kg_kelvin() > 0.0);
|
||||
|
||||
|
||||
// In superheated region, phase is Vapor, quality should be None, and superheat should exist
|
||||
assert_eq!(state.phase, Phase::Vapor);
|
||||
assert_eq!(state.quality, None);
|
||||
|
||||
@@ -169,22 +169,53 @@ impl Mixture {
|
||||
/// Get molar mass (g/mol) for common refrigerants.
|
||||
fn molar_mass(fluid: &str) -> f64 {
|
||||
match fluid.to_uppercase().as_str() {
|
||||
// HFC Refrigerants
|
||||
"R32" => 52.02,
|
||||
"R125" => 120.02,
|
||||
"R134A" => 102.03,
|
||||
"R143A" => 84.04,
|
||||
"R152A" => 66.05,
|
||||
"R22" => 86.47,
|
||||
"R23" => 70.01,
|
||||
"R41" => 34.03,
|
||||
"R245FA" => 134.05,
|
||||
// HFO Refrigerants
|
||||
"R1234YF" => 114.04,
|
||||
"R1234ZE" => 114.04,
|
||||
"R1234ZE" | "R1234ZE(E)" => 114.04,
|
||||
"R1233ZD" | "R1233ZD(E)" => 130.50,
|
||||
"R1243ZF" => 96.06,
|
||||
// Predefined Mixtures (average molar mass)
|
||||
"R410A" => 72.58,
|
||||
"R404A" => 97.60,
|
||||
"R407C" => 86.20,
|
||||
"R507A" => 98.86,
|
||||
"R513A" => 108.5,
|
||||
"R454B" => 83.03,
|
||||
"R452B" => 68.5,
|
||||
"R454C" => 99.6,
|
||||
// Natural Refrigerants
|
||||
"R290" | "PROPANE" => 44.10,
|
||||
"R600" | "BUTANE" => 58.12,
|
||||
"R600" | "BUTANE" | "N-BUTANE" => 58.12,
|
||||
"R600A" | "ISOBUTANE" => 58.12,
|
||||
"R1270" | "PROPYLENE" => 42.08,
|
||||
"R717" | "AMMONIA" => 17.03,
|
||||
"CO2" | "R744" => 44.01,
|
||||
// Other Fluids
|
||||
"WATER" | "H2O" => 18.02,
|
||||
"AIR" => 28.97,
|
||||
"NITROGEN" | "N2" => 28.01,
|
||||
"OXYGEN" | "O2" => 32.00,
|
||||
"ARGON" => 39.95,
|
||||
"HELIUM" => 4.00,
|
||||
"HYDROGEN" | "H2" => 2.02,
|
||||
"METHANE" => 16.04,
|
||||
"ETHANE" => 30.07,
|
||||
"ETHYLENE" => 28.05,
|
||||
"ETHANOL" => 46.07,
|
||||
"METHANOL" => 32.04,
|
||||
"ACETONE" => 58.08,
|
||||
"BENZENE" => 78.11,
|
||||
"TOLUENE" => 92.14,
|
||||
_ => 50.0, // Default fallback
|
||||
}
|
||||
}
|
||||
@@ -247,7 +278,7 @@ impl std::error::Error for MixtureError {}
|
||||
pub mod predefined {
|
||||
use super::*;
|
||||
|
||||
/// R454B: R32 (50%) / R1234yf (50%) - mass fractions
|
||||
/// R454B: R32 (50%) / R1234yf (50%) - mass fractions (Opteon XL41)
|
||||
pub fn r454b() -> Mixture {
|
||||
Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap()
|
||||
}
|
||||
@@ -267,6 +298,31 @@ pub mod predefined {
|
||||
Mixture::from_mass_fractions(&[("R125", 0.44), ("R143a", 0.52), ("R134a", 0.04)]).unwrap()
|
||||
}
|
||||
|
||||
/// R507A: R125 (50%) / R143a (50%) - mass fractions (azeotropic)
|
||||
pub fn r507a() -> Mixture {
|
||||
Mixture::from_mass_fractions(&[("R125", 0.5), ("R143a", 0.5)]).unwrap()
|
||||
}
|
||||
|
||||
/// R513A: R134a (56%) / R1234yf (44%) - mass fractions (Opteon XP10)
|
||||
pub fn r513a() -> Mixture {
|
||||
Mixture::from_mass_fractions(&[("R134a", 0.56), ("R1234yf", 0.44)]).unwrap()
|
||||
}
|
||||
|
||||
/// R452B: R32 (67%) / R125 (7%) / R1234yf (26%) - mass fractions (Opteon XL55)
|
||||
pub fn r452b() -> Mixture {
|
||||
Mixture::from_mass_fractions(&[("R32", 0.67), ("R125", 0.07), ("R1234yf", 0.26)]).unwrap()
|
||||
}
|
||||
|
||||
/// R454C: R32 (21.5%) / R1234yf (78.5%) - mass fractions (Opteon XL20)
|
||||
pub fn r454c() -> Mixture {
|
||||
Mixture::from_mass_fractions(&[("R32", 0.215), ("R1234yf", 0.785)]).unwrap()
|
||||
}
|
||||
|
||||
/// R455A: R32 (21.5%) / R1234yf (75.5%) / CO2 (3%) - mass fractions
|
||||
pub fn r455a() -> Mixture {
|
||||
Mixture::from_mass_fractions(&[("R32", 0.215), ("R1234yf", 0.755), ("CO2", 0.03)]).unwrap()
|
||||
}
|
||||
|
||||
/// R32/R125 (50/50) mixture - mass fractions
|
||||
pub fn r32_r125_5050() -> Mixture {
|
||||
Mixture::from_mass_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap()
|
||||
|
||||
@@ -98,6 +98,15 @@ pub enum SolverError {
|
||||
/// Human-readable description of the system defect.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Post-solve validation failed (e.g., mass or energy balance violation).
|
||||
#[error("Validation failed: mass error = {mass_error:.3e} kg/s, energy error = {energy_error:.3e} W")]
|
||||
Validation {
|
||||
/// Mass balance error in kg/s
|
||||
mass_error: f64,
|
||||
/// Energy balance error in W
|
||||
energy_error: f64,
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1991,10 +2000,19 @@ impl Solver for SolverStrategy {
|
||||
},
|
||||
"SolverStrategy::solve dispatching"
|
||||
);
|
||||
match self {
|
||||
let result = match self {
|
||||
SolverStrategy::NewtonRaphson(cfg) => cfg.solve(system),
|
||||
SolverStrategy::SequentialSubstitution(cfg) => cfg.solve(system),
|
||||
};
|
||||
|
||||
if let Ok(state) = &result {
|
||||
if state.is_converged() {
|
||||
// Post-solve validation checks
|
||||
system.check_mass_balance(&state.state)?;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn with_timeout(self, timeout: Duration) -> Self {
|
||||
|
||||
@@ -1760,6 +1760,34 @@ impl System {
|
||||
let _ = row_offset; // avoid unused warning
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifies that global mass balance is conserved.
|
||||
///
|
||||
/// Sums the mass flow rates at the ports of each component and ensures they
|
||||
/// sum to zero within a tight tolerance (1e-9 kg/s).
|
||||
pub fn check_mass_balance(&self, state: &StateSlice) -> Result<(), crate::SolverError> {
|
||||
let tolerance = 1e-9;
|
||||
let mut total_mass_error = 0.0;
|
||||
let mut has_violation = false;
|
||||
|
||||
for (_node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
|
||||
if let Ok(mass_flows) = component.port_mass_flows(state) {
|
||||
let sum: f64 = mass_flows.iter().map(|m| m.to_kg_per_s()).sum();
|
||||
if sum.abs() > tolerance {
|
||||
has_violation = true;
|
||||
total_mass_error += sum.abs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_violation {
|
||||
return Err(crate::SolverError::Validation {
|
||||
mass_error: total_mass_error,
|
||||
energy_error: 0.0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for System {
|
||||
@@ -3529,4 +3557,73 @@ mod tests {
|
||||
assert_eq!(indices.len(), 1);
|
||||
assert_eq!(indices[0].1, 2); // 2 * edge_count = 2
|
||||
}
|
||||
|
||||
struct BadMassFlowComponent {
|
||||
ports: Vec<ConnectedPort>,
|
||||
}
|
||||
|
||||
impl Component for BadMassFlowComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_residuals: &mut entropyk_components::ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&self.ports
|
||||
}
|
||||
|
||||
fn port_mass_flows(&self, _state: &SystemState) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
Ok(vec![
|
||||
entropyk_core::MassFlow::from_kg_per_s(1.0),
|
||||
entropyk_core::MassFlow::from_kg_per_s(-0.5), // Intentionally unbalanced
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mass_balance_violation() {
|
||||
let mut system = System::new();
|
||||
|
||||
let inlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(1.0),
|
||||
Enthalpy::from_joules_per_kg(400000.0),
|
||||
);
|
||||
let outlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(1.0),
|
||||
Enthalpy::from_joules_per_kg(400000.0),
|
||||
);
|
||||
let (c1, c2) = inlet.connect(outlet).unwrap();
|
||||
|
||||
let comp = Box::new(BadMassFlowComponent {
|
||||
ports: vec![c1, c2], // Just to have ports
|
||||
});
|
||||
|
||||
let n0 = system.add_component(comp);
|
||||
system.add_edge(n0, n0).unwrap(); // Self-edge to avoid isolated node
|
||||
|
||||
system.finalize().unwrap();
|
||||
|
||||
// Ensure state is appropriately sized for finalize
|
||||
let state = vec![0.0; system.full_state_vector_len()];
|
||||
let result = system.check_mass_balance(&state);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user