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

@@ -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.

View File

@@ -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] {
&[]
}

View File

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

View File

@@ -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] {
&[]
}

View File

@@ -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 {

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

View File

@@ -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 {

View File

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