//! 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, /// Enthalpy start value [J/kg]. pub enthalpy_j_kg: Option, /// Mass-flow start value [kg/s]. pub mass_flow_kg_s: Option, /// Temperature start value [K], useful for diagnostics and backend conversion. pub temperature_k: Option, /// Vapour quality start value [-], when the intended regime is two-phase. pub vapor_quality: Option, } /// 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, } impl InitializationDiagnostics { /// Appends a diagnostic seed record. pub fn push( &mut self, label: impl Into, 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 ); } }