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:
Sepehr
2026-02-21 23:21:34 +01:00
parent 4440132b0a
commit fa480ed303
55 changed files with 5987 additions and 31 deletions

View File

@@ -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);

View File

@@ -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()