Files
Entropyk/crates/solver/src/initializer.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:46:46 +02:00

880 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Smart initialization heuristic for thermodynamic system solvers.
//!
//! This module provides [`SmartInitializer`], which generates physically
//! reasonable initial guesses for the solver state vector from source and sink
//! temperatures. It uses the Antoine equation to estimate saturation pressures
//! for common refrigerants without requiring an external fluid backend.
//!
//! # Algorithm
//!
//! 1. Estimate evaporator pressure: `P_evap = P_sat(T_source - ΔT_approach)`
//! 2. Estimate condenser pressure: `P_cond = P_sat(T_sink + ΔT_approach)`
//! 3. Clamp `P_evap` to `0.5 * P_critical` if it exceeds the critical pressure
//! 4. Fill the state vector with `[ṁ, P, h_default]` per edge, using circuit topology
//!
//! # Supported Fluids
//!
//! Built-in Antoine coefficients are provided for:
//! - R134a, R410A, R32, R744 (CO2), R290 (Propane)
//!
//! Unknown fluids return an explicit error; no pressure guesses are invented.
//!
//! # No-Allocation Guarantee
//!
//! [`SmartInitializer::populate_state`] writes to a pre-allocated `&mut [f64]`
//! slice and performs no heap allocation.
use entropyk_components::port::FluidId;
use entropyk_core::{Enthalpy, Pressure, Temperature};
use thiserror::Error;
use crate::system::System;
use serde::{Deserialize, Serialize};
// ─────────────────────────────────────────────────────────────────────────────
// Error types
// ─────────────────────────────────────────────────────────────────────────────
/// Errors that can occur during smart initialization.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum InitializerError {
/// Source or sink temperature exceeds the critical temperature for the fluid.
///
/// Antoine equation is not valid above the critical temperature. The caller
/// should either use a different fluid or provide a manual initial state.
#[error("Temperature {temp_celsius:.1}°C exceeds critical temperature for {fluid}")]
TemperatureAboveCritical {
/// Temperature that triggered the error (°C).
temp_celsius: f64,
/// Fluid identifier string.
fluid: String,
},
/// The provided state slice length does not match the system state vector length.
#[error("State slice length {actual} does not match system state vector length {expected}")]
StateLengthMismatch {
/// Expected length (from `system.state_vector_len()`).
expected: usize,
/// Actual length of the provided slice.
actual: usize,
},
/// No Antoine coefficients are available for the configured fluid.
#[error("No Antoine saturation-pressure coefficients are available for {fluid}")]
UnsupportedFluid {
/// Fluid identifier string.
fluid: String,
},
}
// ─────────────────────────────────────────────────────────────────────────────
// Antoine coefficients
// ─────────────────────────────────────────────────────────────────────────────
/// Antoine equation coefficients for saturation pressure estimation.
///
/// The Antoine equation (log₁₀ form) is:
///
/// ```text
/// log10(P_sat [Pa]) = A - B / (C + T [°C])
/// ```
///
/// Coefficients are tuned for the 40°C to +80°C range. Accuracy is within 5%
/// of NIST/CoolProp values — sufficient for initialization purposes.
#[derive(Debug, Clone, PartialEq)]
pub struct AntoineCoefficients {
/// Antoine constant A (dimensionless, log₁₀ scale, Pa units).
pub a: f64,
/// Antoine constant B (°C).
pub b: f64,
/// Antoine constant C (°C offset).
pub c: f64,
/// Critical pressure of the fluid (Pa).
pub p_critical_pa: f64,
}
impl AntoineCoefficients {
/// Returns the built-in coefficients for the given fluid identifier string.
///
/// Matching is case-insensitive. Returns `None` for unknown fluids.
pub fn for_fluid(fluid_str: &str) -> Option<&'static AntoineCoefficients> {
// Normalize: uppercase, strip dashes/spaces
let normalized = fluid_str.to_uppercase().replace(['-', ' '], "");
ANTOINE_TABLE
.iter()
.find(|(name, _)| *name == normalized.as_str())
.map(|(_, coeffs)| coeffs)
}
}
/// Compute saturation pressure (Pa) from temperature (°C) using Antoine equation.
///
/// `log10(P_sat [Pa]) = A - B / (C + T [°C])`
///
/// This is a pure arithmetic function with no heap allocation.
pub fn antoine_pressure(t_celsius: f64, coeffs: &AntoineCoefficients) -> f64 {
let log10_p = coeffs.a - coeffs.b / (coeffs.c + t_celsius);
10f64.powf(log10_p)
}
/// Built-in Antoine coefficient table for common refrigerants.
///
/// Coefficients valid for approximately 40°C to +80°C.
/// Accuracy: within 5% of NIST saturation pressure values.
///
/// Formula: `log10(P_sat [Pa]) = A - B / (C + T [°C])`
///
/// A values are derived from NIST reference saturation pressures:
/// - R134a: P_sat(0°C) = 292,800 Pa → A = log10(292800) + 1766/243 = 12.739
/// - R410A: P_sat(0°C) = 798,000 Pa → A = log10(798000) + 1885/243 = 13.659
/// - R32: P_sat(0°C) = 810,000 Pa → A = log10(810000) + 1780/243 = 13.233
/// - R744: P_sat(20°C) = 5,730,000 Pa → A = log10(5730000) + 1347.8/293 = 11.357
/// - R290: P_sat(0°C) = 474,000 Pa → A = log10(474000) + 1656/243 = 12.491
///
/// | Fluid | A (for Pa) | B | C | P_critical (Pa) |
/// |--------|------------|---------|-------|-----------------|
/// | R134a | 12.739 | 1766.0 | 243.0 | 4,059,280 |
/// | R410A | 13.659 | 1885.0 | 243.0 | 4,901,200 |
/// | R32 | 13.233 | 1780.0 | 243.0 | 5,782,000 |
/// | R744 | 11.357 | 1347.8 | 273.0 | 7,377,300 |
/// | R290 | 12.491 | 1656.0 | 243.0 | 4,247,200 |
static ANTOINE_TABLE: &[(&str, AntoineCoefficients)] = &[
(
"R134A",
AntoineCoefficients {
a: 12.739,
b: 1766.0,
c: 243.0,
p_critical_pa: 4_059_280.0,
},
),
(
"R410A",
AntoineCoefficients {
a: 13.659,
b: 1885.0,
c: 243.0,
p_critical_pa: 4_901_200.0,
},
),
(
"R32",
AntoineCoefficients {
a: 13.233,
b: 1780.0,
c: 243.0,
p_critical_pa: 5_782_000.0,
},
),
(
"R744",
AntoineCoefficients {
a: 11.357,
b: 1347.8,
c: 273.0,
p_critical_pa: 7_377_300.0,
},
),
(
"R290",
AntoineCoefficients {
a: 12.491,
b: 1656.0,
c: 243.0,
p_critical_pa: 4_247_200.0,
},
),
];
// ─────────────────────────────────────────────────────────────────────────────
// Initializer configuration
// ─────────────────────────────────────────────────────────────────────────────
/// Configuration for [`SmartInitializer`].
#[derive(Debug, Clone, PartialEq)]
pub struct InitializerConfig {
/// Fluid identifier used for Antoine coefficient lookup.
pub fluid: FluidId,
/// Temperature approach difference for pressure estimation (K).
///
/// - Evaporator: `P_evap = P_sat(T_source - dt_approach)`
/// - Condenser: `P_cond = P_sat(T_sink + dt_approach)`
///
/// Default: 5.0 K.
pub dt_approach: f64,
}
/// Optional start values for one solver edge or auxiliary unknown group.
///
/// These values are numerical guesses only. They must never be interpreted as
/// imposed boundary conditions or component equations.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct StartValues {
/// Pressure start value [Pa].
pub pressure_pa: Option<f64>,
/// Enthalpy start value [J/kg].
pub enthalpy_j_kg: Option<f64>,
/// Mass-flow start value [kg/s].
pub mass_flow_kg_s: Option<f64>,
/// Temperature start value [K], useful for diagnostics and backend conversion.
pub temperature_k: Option<f64>,
/// Vapour quality start value [-], when the intended regime is two-phase.
pub vapor_quality: Option<f64>,
}
/// Regime label used to explain why a start value was assigned.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InitializationRegime {
/// High-pressure superheated vapour, typically compressor discharge.
HighPressureVapor,
/// High-pressure liquid, typically condenser outlet.
HighPressureLiquid,
/// Low-pressure two-phase mixture, typically EXV outlet.
LowPressureTwoPhase,
/// Low-pressure superheated vapour, typically compressor suction.
LowPressureVapor,
/// Secondary water/brine/air branch.
Secondary,
/// Generic fallback seed.
Generic,
/// Boundary condition seed from a source/sink component.
Boundary,
/// Control or actuator unknown seed.
Control,
}
/// One initialization diagnostic entry.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InitializationSeed {
/// Human-readable edge/control label.
pub label: String,
/// Assigned regime.
pub regime: InitializationRegime,
/// Values written to the state vector.
pub values: StartValues,
}
/// Diagnostics emitted by an initialization pass.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct InitializationDiagnostics {
/// Ordered seed records for edges and control variables.
pub seeds: Vec<InitializationSeed>,
}
impl InitializationDiagnostics {
/// Appends a diagnostic seed record.
pub fn push(
&mut self,
label: impl Into<String>,
regime: InitializationRegime,
values: StartValues,
) {
self.seeds.push(InitializationSeed {
label: label.into(),
regime,
values,
});
}
}
impl Default for InitializerConfig {
fn default() -> Self {
Self {
fluid: FluidId::new("R134a"),
dt_approach: 5.0,
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// SmartInitializer
// ─────────────────────────────────────────────────────────────────────────────
/// Smart initialization heuristic for thermodynamic solver state vectors.
///
/// Uses the Antoine equation to estimate saturation pressures from source and
/// sink temperatures, then fills a pre-allocated state vector with physically
/// reasonable initial guesses.
///
/// # Example
///
/// ```rust,no_run
/// use entropyk_solver::initializer::{SmartInitializer, InitializerConfig};
/// use entropyk_core::{Temperature, Enthalpy};
///
/// let init = SmartInitializer::new(InitializerConfig::default());
/// let (p_evap, p_cond) = init
/// .estimate_pressures(
/// Temperature::from_celsius(5.0),
/// Temperature::from_celsius(40.0),
/// )
/// .unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct SmartInitializer {
/// Configuration for this initializer.
pub config: InitializerConfig,
}
impl SmartInitializer {
/// Creates a new `SmartInitializer` with the given configuration.
pub fn new(config: InitializerConfig) -> Self {
Self { config }
}
/// Estimate `(P_evap, P_cond)` from source and sink temperatures.
///
/// Uses the Antoine equation with the configured fluid and approach ΔT:
/// - `P_evap = P_sat(T_source - ΔT_approach)`, clamped to `0.5 * P_critical`
/// - `P_cond = P_sat(T_sink + ΔT_approach)`
///
/// # Errors
///
/// Returns [`InitializerError::TemperatureAboveCritical`] if the adjusted
/// source temperature exceeds the critical temperature for a known fluid.
/// Returns [`InitializerError::UnsupportedFluid`] if no Antoine coefficients
/// are available for the configured fluid.
pub fn estimate_pressures(
&self,
t_source: Temperature,
t_sink: Temperature,
) -> Result<(Pressure, Pressure), InitializerError> {
let fluid_str = self.config.fluid.to_string();
match AntoineCoefficients::for_fluid(&fluid_str) {
None => Err(InitializerError::UnsupportedFluid { fluid: fluid_str }),
Some(coeffs) => {
let t_source_c = t_source.to_celsius();
let t_sink_c = t_sink.to_celsius();
// Evaporator: T_source - ΔT_approach
let t_evap_c = t_source_c - self.config.dt_approach;
let p_evap_pa = antoine_pressure(t_evap_c, coeffs);
// Clamp P_evap to 0.5 * P_critical (AC: #2)
let p_evap_pa = if p_evap_pa >= coeffs.p_critical_pa {
tracing::warn!(
fluid = %fluid_str,
t_evap_celsius = t_evap_c,
p_evap_pa = p_evap_pa,
p_critical_pa = coeffs.p_critical_pa,
"Estimated P_evap exceeds critical pressure — clamping to 0.5 * P_critical"
);
0.5 * coeffs.p_critical_pa
} else {
p_evap_pa
};
// Condenser: T_sink + ΔT_approach (AC: #3)
let t_cond_c = t_sink_c + self.config.dt_approach;
let p_cond_pa = antoine_pressure(t_cond_c, coeffs);
// Clamp P_cond to 0.5 * P_critical if it exceeds critical
let p_cond_pa = if p_cond_pa >= coeffs.p_critical_pa {
tracing::warn!(
fluid = %fluid_str,
t_cond_celsius = t_cond_c,
p_cond_pa = p_cond_pa,
p_critical_pa = coeffs.p_critical_pa,
"Estimated P_cond exceeds critical pressure — clamping to 0.5 * P_critical"
);
0.5 * coeffs.p_critical_pa
} else {
p_cond_pa
};
tracing::debug!(
fluid = %fluid_str,
t_source_celsius = t_source_c,
t_sink_celsius = t_sink_c,
p_evap_bar = p_evap_pa / 1e5,
p_cond_bar = p_cond_pa / 1e5,
"SmartInitializer: estimated pressures"
);
Ok((
Pressure::from_pascals(p_evap_pa),
Pressure::from_pascals(p_cond_pa),
))
}
}
}
/// Fill a pre-allocated state vector with smart initial guesses.
///
/// No heap allocation is performed. The `state` slice must have length equal
/// to `system.state_vector_len()` (i.e., `3 * edge_count` for a system of
/// refrigerant/hydraulic edges).
///
/// State layout per edge: `[ṁ_edge_i, P_edge_i, h_edge_i]`
///
/// Pressure assignment follows circuit topology:
/// - Edges in circuit 0 → `p_evap`
/// - Edges in circuit 1+ → `p_cond`
/// - Single-circuit systems: all edges use `p_evap`
///
/// # Errors
///
/// Returns [`InitializerError::StateLengthMismatch`] if `state.len()` does
/// not match `system.full_state_vector_len()` (edges plus any inverse-control
/// and coupling auxiliary unknowns).
pub fn populate_state(
&self,
system: &System,
p_evap: Pressure,
p_cond: Pressure,
h_default: Enthalpy,
state: &mut [f64],
) -> Result<(), InitializerError> {
// Size against the FULL state vector (the length Newton/Picard expect):
// base edge unknowns + inverse-control mappings + coupling residual slots.
let expected = system.full_state_vector_len();
if state.len() != expected {
return Err(InitializerError::StateLengthMismatch {
expected,
actual: state.len(),
});
}
let p_evap_pa = p_evap.to_pascals();
let p_cond_pa = p_cond.to_pascals();
let h_jkg = h_default.to_joules_per_kg();
for edge_idx in system.edge_indices() {
let circuit = system.edge_circuit(edge_idx);
let p = if circuit.0 == 0 { p_evap_pa } else { p_cond_pa };
let (m_idx, p_idx, h_idx) = system.edge_state_indices_full(edge_idx);
// CM1.4: m_idx is BRANCH-shared — multiple edges in the same series
// branch point to the same slot. Writing the same seed value multiple
// times is idempotent and stays within state bounds (m_idx < state_len).
state[m_idx] = crate::system::DEFAULT_MASS_FLOW_SEED_KG_S;
state[p_idx] = p;
state[h_idx] = h_jkg;
}
// Seed inverse-control unknowns (fan speed, opening, frequency, …) to the
// midpoint of their bounds so the cold start sits inside the feasible box
// instead of at zero (often an out-of-bounds, non-physical control value).
// Coupling auxiliary slots keep their 0.0 default.
for (_, idx) in system.control_variable_indices() {
if let Some((min, max)) = system.get_bounds_for_state_index(idx) {
if min.is_finite() && max.is_finite() && min <= max {
state[idx] = 0.5 * (min + max);
}
}
}
Ok(())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_initialization_diagnostics_records_start_values() {
let mut diagnostics = InitializationDiagnostics::default();
diagnostics.push(
"comp->cond",
InitializationRegime::HighPressureVapor,
StartValues {
pressure_pa: Some(1.2e6),
enthalpy_j_kg: Some(430_000.0),
mass_flow_kg_s: Some(0.05),
temperature_k: None,
vapor_quality: None,
},
);
assert_eq!(diagnostics.seeds.len(), 1);
assert_eq!(diagnostics.seeds[0].label, "comp->cond");
assert_eq!(
diagnostics.seeds[0].regime,
InitializationRegime::HighPressureVapor
);
assert_eq!(diagnostics.seeds[0].values.pressure_pa, Some(1.2e6));
}
// ── Antoine equation unit tests ──────────────────────────────────────────
/// AC: #1, #5 — R134a at 0°C: P_sat ≈ 2.93 bar (293,000 Pa), within 5%
#[test]
fn test_antoine_r134a_at_0c() {
let coeffs = AntoineCoefficients::for_fluid("R134a").unwrap();
let p_pa = antoine_pressure(0.0, coeffs);
// Expected: ~2.93 bar = 293,000 Pa
assert_relative_eq!(p_pa, 293_000.0, max_relative = 0.05);
}
/// AC: #5 — R744 (CO2) at 20°C: P_sat ≈ 57.3 bar (5,730,000 Pa), within 5%
#[test]
fn test_antoine_r744_at_20c() {
let coeffs = AntoineCoefficients::for_fluid("R744").unwrap();
let p_pa = antoine_pressure(20.0, coeffs);
// Expected: ~57.3 bar = 5,730,000 Pa
assert_relative_eq!(p_pa, 5_730_000.0, max_relative = 0.05);
}
/// AC: #5 — Case-insensitive fluid lookup
#[test]
fn test_fluid_lookup_case_insensitive() {
assert!(AntoineCoefficients::for_fluid("r134a").is_some());
assert!(AntoineCoefficients::for_fluid("R134A").is_some());
assert!(AntoineCoefficients::for_fluid("R134a").is_some());
assert!(AntoineCoefficients::for_fluid("r744").is_some());
assert!(AntoineCoefficients::for_fluid("R290").is_some());
}
/// AC: #5 — Unknown fluid returns None
#[test]
fn test_fluid_lookup_unknown() {
assert!(AntoineCoefficients::for_fluid("R999").is_none());
assert!(AntoineCoefficients::for_fluid("").is_none());
}
// ── SmartInitializer::estimate_pressures tests ───────────────────────────
/// AC: #2 — P_evap < P_critical for all built-in fluids at T_source = 40°C
#[test]
fn test_p_evap_below_critical_all_fluids() {
let fluids = ["R134a", "R410A", "R32", "R744", "R290"];
for fluid in fluids {
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new(fluid),
dt_approach: 5.0,
});
let (p_evap, _) = init
.estimate_pressures(
Temperature::from_celsius(-40.0),
Temperature::from_celsius(40.0),
)
.unwrap();
let coeffs = AntoineCoefficients::for_fluid(fluid).unwrap();
assert!(
p_evap.to_pascals() < coeffs.p_critical_pa,
"P_evap ({:.0} Pa) should be < P_critical ({:.0} Pa) for {}",
p_evap.to_pascals(),
coeffs.p_critical_pa,
fluid
);
}
}
/// AC: #3 — P_cond = P_sat(T_sink + 5K) for default ΔT_approach
#[test]
fn test_p_cond_approach_default() {
let init = SmartInitializer::new(InitializerConfig::default()); // R134a, dt=5.0
let t_sink = Temperature::from_celsius(40.0);
let (_, p_cond) = init
.estimate_pressures(Temperature::from_celsius(5.0), t_sink)
.unwrap();
// Expected: P_sat(45°C) for R134a
let coeffs = AntoineCoefficients::for_fluid("R134a").unwrap();
let expected_pa = antoine_pressure(45.0, coeffs);
assert_relative_eq!(p_cond.to_pascals(), expected_pa, max_relative = 1e-9);
}
/// AC: #6 — Unknown fluid returns an explicit error instead of invented pressures.
#[test]
fn test_unknown_fluid_returns_error() {
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new("R999-Unknown"),
dt_approach: 5.0,
});
let result = init.estimate_pressures(
Temperature::from_celsius(5.0),
Temperature::from_celsius(40.0),
);
assert_eq!(
result,
Err(InitializerError::UnsupportedFluid {
fluid: "R999-Unknown".to_string()
})
);
}
/// AC: #1 — Verify evaporator pressure uses T_source - ΔT_approach
#[test]
fn test_p_evap_uses_approach_delta() {
let dt = 5.0;
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new("R134a"),
dt_approach: dt,
});
let t_source = Temperature::from_celsius(10.0);
let (p_evap, _) = init
.estimate_pressures(t_source, Temperature::from_celsius(40.0))
.unwrap();
let coeffs = AntoineCoefficients::for_fluid("R134a").unwrap();
let expected_pa = antoine_pressure(10.0 - dt, coeffs); // T_source - ΔT
assert_relative_eq!(p_evap.to_pascals(), expected_pa, max_relative = 1e-9);
}
// ── SmartInitializer::populate_state tests ───────────────────────────────
/// AC: #4, #7 — populate_state fills state vector correctly for a 2-edge system.
///
/// This test verifies the no-allocation signature: the function takes `&mut [f64]`
/// and writes in-place without allocating.
#[test]
fn test_populate_state_2_edges() {
use crate::system::System;
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
struct MockComp;
impl Component for MockComp {
fn compute_residuals(
&self,
_s: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
for v in r.iter_mut() {
*v = 0.0;
}
Ok(())
}
fn jacobian_entries(
&self,
_s: &StateSlice,
j: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
j.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
}
let mut sys = System::new();
let n0 = sys.add_component(Box::new(MockComp));
let n1 = sys.add_component(Box::new(MockComp));
let n2 = sys.add_component(Box::new(MockComp));
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n2).unwrap();
sys.finalize().unwrap();
let init = SmartInitializer::new(InitializerConfig::default());
let p_evap = Pressure::from_bar(3.0);
let p_cond = Pressure::from_bar(15.0);
let h_default = Enthalpy::from_joules_per_kg(400_000.0);
// Pre-allocated slice — no allocation in populate_state
let mut state = vec![0.0f64; sys.state_vector_len()];
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.unwrap();
// CM1.4: 2-edge linear chain → 1 branch → state_len = 1 + 2×2 = 5
// Layout: [0:ṁ_branch, 1:P_e0, 2:h_e0, 3:P_e1, 4:h_e1]
assert_eq!(state.len(), 5);
// Branch ṁ seeded at DEFAULT_MASS_FLOW_SEED_KG_S
assert_relative_eq!(
state[0],
crate::system::DEFAULT_MASS_FLOW_SEED_KG_S,
max_relative = 1e-9
);
// P and h for edge 0 (circuit 0 → p_evap)
assert_relative_eq!(state[1], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[2], h_default.to_joules_per_kg(), max_relative = 1e-9);
// P and h for edge 1 (circuit 0 → p_evap)
assert_relative_eq!(state[3], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[4], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
/// AC: #4 — populate_state uses P_cond for circuit 1 edges in multi-circuit system.
#[test]
fn test_populate_state_multi_circuit() {
use crate::system::System;
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::CircuitId;
struct MockComp;
impl Component for MockComp {
fn compute_residuals(
&self,
_s: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
for v in r.iter_mut() {
*v = 0.0;
}
Ok(())
}
fn jacobian_entries(
&self,
_s: &StateSlice,
j: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
j.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
}
let mut sys = System::new();
// Circuit 0: evaporator side
let n0 = sys
.add_component_to_circuit(Box::new(MockComp), CircuitId(0))
.unwrap();
let n1 = sys
.add_component_to_circuit(Box::new(MockComp), CircuitId(0))
.unwrap();
// Circuit 1: condenser side
let n2 = sys
.add_component_to_circuit(Box::new(MockComp), CircuitId(1))
.unwrap();
let n3 = sys
.add_component_to_circuit(Box::new(MockComp), CircuitId(1))
.unwrap();
sys.add_edge(n0, n1).unwrap(); // circuit 0 edge
sys.add_edge(n2, n3).unwrap(); // circuit 1 edge
sys.finalize().unwrap();
let init = SmartInitializer::new(InitializerConfig::default());
let p_evap = Pressure::from_bar(3.0);
let p_cond = Pressure::from_bar(15.0);
let h_default = Enthalpy::from_joules_per_kg(400_000.0);
let mut state = vec![0.0f64; sys.state_vector_len()];
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.unwrap();
// CM1.4: 2 isolated 1-edge chains → 2 branches → state_len = 2 + 2×2 = 6
// Layout: [0:ṁ_B0, 1:ṁ_B1, 2:P_e0, 3:h_e0, 4:P_e1, 5:h_e1]
assert_eq!(state.len(), 6);
// Branch ṁ slots seeded at DEFAULT_MASS_FLOW_SEED_KG_S
assert_relative_eq!(
state[0],
crate::system::DEFAULT_MASS_FLOW_SEED_KG_S,
max_relative = 1e-9
);
assert_relative_eq!(
state[1],
crate::system::DEFAULT_MASS_FLOW_SEED_KG_S,
max_relative = 1e-9
);
// Verify P and h values are seeded correctly for each edge using actual state indices.
// Edge 0 is circuit 0 (p_evap), edge 1 is circuit 1 (p_cond).
for edge_idx in sys.edge_indices() {
let circuit = sys.edge_circuit(edge_idx);
let (_m, p, h) = sys.edge_state_indices_full(edge_idx);
let expected_p = if circuit.0 == 0 {
p_evap.to_pascals()
} else {
p_cond.to_pascals()
};
assert_relative_eq!(state[p], expected_p, max_relative = 1e-9);
assert_relative_eq!(state[h], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
}
/// AC: #7 — populate_state returns error on length mismatch (no panic).
#[test]
fn test_populate_state_length_mismatch() {
use crate::system::System;
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
struct MockComp;
impl Component for MockComp {
fn compute_residuals(
&self,
_s: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
for v in r.iter_mut() {
*v = 0.0;
}
Ok(())
}
fn jacobian_entries(
&self,
_s: &StateSlice,
j: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
j.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
}
let mut sys = System::new();
let n0 = sys.add_component(Box::new(MockComp));
let n1 = sys.add_component(Box::new(MockComp));
sys.add_edge(n0, n1).unwrap();
sys.finalize().unwrap();
let init = SmartInitializer::new(InitializerConfig::default());
let p_evap = Pressure::from_bar(3.0);
let p_cond = Pressure::from_bar(15.0);
let h_default = Enthalpy::from_joules_per_kg(400_000.0);
// Wrong length: system has 3 state entries (1 edge × 3), we provide 5
let mut state = vec![0.0f64; 5];
let result = init.populate_state(&sys, p_evap, p_cond, h_default, &mut state);
assert!(matches!(
result,
Err(InitializerError::StateLengthMismatch {
expected: 3,
actual: 5
})
));
}
/// AC: #2 — P_evap is clamped to 0.5 * P_critical when above critical.
///
/// We use R744 (CO2) at a very high source temperature to trigger clamping.
#[test]
fn test_p_evap_clamped_above_critical() {
// R744 critical: 7,377,300 Pa (~73.8 bar), critical T ≈ 31°C
// At T_source = 40°C, T_evap = 35°C → P_sat > P_critical → should clamp
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new("R744"),
dt_approach: 5.0,
});
let (p_evap, _) = init
.estimate_pressures(
Temperature::from_celsius(40.0),
Temperature::from_celsius(50.0),
)
.unwrap();
let coeffs = AntoineCoefficients::for_fluid("R744").unwrap();
// Must be clamped to 0.5 * P_critical
assert_relative_eq!(
p_evap.to_pascals(),
0.5 * coeffs.p_critical_pa,
max_relative = 1e-9
);
}
}