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>
880 lines
33 KiB
Rust
880 lines
33 KiB
Rust
//! 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
|
||
);
|
||
}
|
||
}
|