//! Condenser Component //! //! A heat exchanger configured for refrigerant condensation. //! The refrigerant (hot side) condenses from superheated vapor to //! subcooled liquid, releasing heat to the cold side. use super::exchanger::HeatExchanger; use super::flow_regularization::{smooth_mass_magnitude, DEFAULT_M_EPS_KG_S}; use super::lmtd::{FlowConfiguration, LmtdModel}; use crate::state_machine::{CircuitId, OperationalState, StateManageable}; use crate::{ Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice, }; use entropyk_core::Calib; use entropyk_core::Pressure; use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality}; use std::sync::Arc; /// Condenser heat exchanger. /// /// Uses the LMTD method for heat transfer calculation. /// The refrigerant condenses on the hot side, releasing heat /// to the cold side (typically water or air). /// /// # Configuration /// /// - Hot side: Refrigerant condensing (phase change) /// - Cold side: Heat sink (water, air, etc.) /// /// # Example /// /// ``` /// use entropyk_components::heat_exchanger::Condenser; /// use entropyk_components::Component; /// /// let condenser = Condenser::new(10_000.0); // UA = 10 kW/K /// assert_eq!(condenser.n_equations(), 3); /// ``` pub struct Condenser { inner: HeatExchanger, saturation_temp: f64, refrigerant_id: String, fluid_backend: Option>, inlet_p_idx: Option, /// Refrigerant inlet enthalpy state index (needed for the coupled energy balance). inlet_h_idx: Option, outlet_p_idx: Option, outlet_h_idx: Option, /// Mass-flow state index of the inlet edge (ṁ unknown, CM1.3). inlet_m_idx: Option, /// Mass-flow state index of the outlet edge (ṁ unknown, CM1.3). outlet_m_idx: Option, /// True when inlet and outlet share the same ṁ state index (CM1.4). same_branch_m: bool, /// Secondary (air/water) inlet temperature [K] for the coupled ε-NTU model. secondary_inlet_temp_k: Option, /// Secondary heat-capacity rate ṁ·cp [W/K] for the coupled ε-NTU model. secondary_capacity_rate: Option, /// When `true`, the condensing pressure is emergent: an extra outlet-closure /// residual pins the refrigerant outlet to saturated liquid minus `subcooling_k`, /// so P_cond is determined by the ε-NTU ↔ secondary balance instead of being /// imposed by the compressor. emergent_pressure: bool, /// Target sub-cooling below the condensing temperature [K] (emergent mode). subcooling_k: f64, /// Lumped quadratic coefficient `k` [Pa·s²/kg²] (`ΔP = k·ṁ·|ṁ|`) when no /// tube geometry is set. Prefer [`Self::with_tube_pressure_drop`] (MSH / /// Friedel). `None` is isobaric unless tube DP is configured. pressure_drop_coeff: Option, /// Tube-side two-phase correlation (MSH / Friedel). Requires geometry. tube_dp_correlation: Option, /// Tube channel geometry for frictional + acceleration ΔP. tube_dp_geometry: Option, /// When `true`, an air-cooled condenser fan modulates the secondary air /// capacity rate to hold a target condensing temperature (head-pressure /// control). The fan speed ratio is a free actuator `φ ∈ [φ_min, φ_max]` and /// the effective capacity rate becomes `C_sec = φ · C_nominal` (fan-affinity /// law: air flow ∝ speed). Requires `emergent_pressure` and a secondary /// stream (whose capacity rate is taken as the full-speed nominal). fan_head_pressure: bool, /// When `true`, a refrigerant-side condenser-flooding actuator holds a target /// condensing temperature (head-pressure control, e.g. Sporlan Head Master / /// ORI+ORD in low ambient). The flooded liquid level `λ ∈ [λ_min, λ_max]` /// backs liquid up over the tubes, deactivating part of the condensing area: /// the effective conductance becomes `UA_eff = (1 − λ)·UA`. Requires /// `emergent_pressure` and a secondary stream. Mutually exclusive with the fan /// actuator (both share the single generic actuator slot / head-pressure eq). flooded_head_pressure: bool, /// Target condensing (saturation) temperature [K] held by the fan actuator. head_pressure_target_k: Option, /// Free-actuator state index of the fan speed ratio `φ`, wired from the /// solver via `CalibIndices.actuator` in `finalize()`. fan_actuator_idx: Option, /// Secondary (water/air) **inlet** edge state indices (Modelica-style /// 4-port mode). Wired by `set_port_context` from local port 2. sec_in_idx: Option<(usize, usize, usize)>, /// Secondary (water/air) **outlet** edge state indices (local port 3). sec_out_idx: Option<(usize, usize, usize)>, /// Secondary fluid identifier for property queries ("Water", /// "INCOMP::MEG-30", "Air"…). Air uses the moist-air linear h↔T convention. secondary_fluid_id: String, /// Humidity ratio W [kg_v/kg_da] for the moist-air convention /// `h = 1006·T_c + W·(2 501 000 + 1860·T_c)` (matches `AirSource`). secondary_humidity_ratio: f64, /// Secondary (water/air) lumped quadratic ΔP coefficient `k` [Pa·s²/kg²]. /// `None` / 0 → isobaric secondary (`P_out = P_in`). Distinct from the /// refrigerant-side `pressure_drop_coeff` / tube MSH model. secondary_pressure_drop_coeff: Option, skip_pressure_eq: bool, } /// Pre-computed quantities shared between the secondary energy-balance /// Jacobian rows (4-port mode). All evaluated at the current state. #[derive(Debug, Clone, Copy)] struct SecondaryJacCtx { /// `g = ε·C_sec` [W/K]. g: f64, /// `g'(C_sec) = d(ε·C)/dC` [-]. g_prime: f64, /// Secondary cp [J/(kg·K)]. cp_sec: f64, /// `dT_sec,in/dh_sec,in = 1/cp` [K·kg/J]. dt_sec_dh: f64, /// Driving temperature difference `T_cond − T_sec,in` [K]. delta_t: f64, /// `dT_cond/dP_ref,in` [K/Pa] (central finite difference). dtcond_dp: f64, /// Refrigerant inlet-pressure state index. ref_p_in_idx: usize, /// `exp(−UA_eff/C_sec)` for the flooding-actuator entry. e_exp: f64, } /// Result of qualifying a condenser at a fixed refrigerant regime against a /// secondary (air/water) stream. All fields are solved from the ε-NTU balance. #[derive(Debug, Clone, Copy, PartialEq)] pub struct CondenserRating { /// Heat duty `Q = ε·C_sec·(T_cond − T_sec,in)` [W] rejected to the secondary fluid. pub q_w: f64, /// Effectiveness `ε = 1 − exp(−UA / C_sec)` [-]. pub effectiveness: f64, /// Condensing (saturation) temperature `T_sat(P)` [K]. pub t_cond_k: f64, /// Approach `T_cond − T_sec,in` [K]. pub approach_k: f64, /// Secondary outlet temperature `T_sec,in + Q/C_sec` [K]. pub secondary_outlet_k: f64, } impl std::fmt::Debug for Condenser { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Condenser") .field("saturation_temp", &self.saturation_temp) .field("refrigerant_id", &self.refrigerant_id) .field("fluid_backend_set", &self.fluid_backend.is_some()) .field("outlet_p_idx", &self.outlet_p_idx) .field("outlet_h_idx", &self.outlet_h_idx) .finish() } } impl Condenser { /// Creates a new condenser with the given UA value. /// /// # Arguments /// /// * `ua` - Overall heat transfer coefficient × Area (W/K) /// /// # Example /// /// ``` /// use entropyk_components::heat_exchanger::Condenser; /// /// let condenser = Condenser::new(15_000.0); /// ``` pub fn new(ua: f64) -> Self { let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow); Self { inner: HeatExchanger::new(model, "Condenser"), saturation_temp: 323.15, refrigerant_id: String::new(), fluid_backend: None, inlet_p_idx: None, inlet_h_idx: None, outlet_p_idx: None, outlet_h_idx: None, inlet_m_idx: None, outlet_m_idx: None, same_branch_m: false, secondary_inlet_temp_k: None, secondary_capacity_rate: None, emergent_pressure: false, subcooling_k: 0.0, pressure_drop_coeff: None, tube_dp_correlation: None, tube_dp_geometry: None, fan_head_pressure: false, flooded_head_pressure: false, head_pressure_target_k: None, fan_actuator_idx: None, sec_in_idx: None, sec_out_idx: None, secondary_fluid_id: String::new(), secondary_humidity_ratio: 0.0, secondary_pressure_drop_coeff: None, skip_pressure_eq: false, } } /// Sets the secondary-side quadratic pressure-drop coefficient /// `ΔP_sec = k·ṁ·|ṁ|` [Pa]. Pass `0` / clear for isobaric water/air path. pub fn with_secondary_pressure_drop_coeff(mut self, k_pa_s2_per_kg2: f64) -> Self { self.secondary_pressure_drop_coeff = if k_pa_s2_per_kg2 > 0.0 { Some(k_pa_s2_per_kg2) } else { None }; self } /// See [`with_secondary_pressure_drop_coeff`](Self::with_secondary_pressure_drop_coeff). pub fn set_secondary_pressure_drop_coeff(&mut self, k_pa_s2_per_kg2: f64) { self.secondary_pressure_drop_coeff = if k_pa_s2_per_kg2 > 0.0 { Some(k_pa_s2_per_kg2) } else { None }; } fn secondary_delta_p(&self, m_sec: f64) -> f64 { match self.secondary_pressure_drop_coeff { Some(k) if k > 0.0 => { crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_sec) } _ => 0.0, } } fn secondary_delta_p_dm(&self, m_sec: f64) -> f64 { match self.secondary_pressure_drop_coeff { Some(k) if k > 0.0 => { crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_sec) } _ => 0.0, } } /// Creates a condenser with a specific saturation temperature. pub fn with_saturation_temp(ua: f64, saturation_temp: f64) -> Self { let mut cond = Self::new(ua); cond.saturation_temp = saturation_temp; cond } /// Attaches a refrigerant identifier used for saturation-property queries. pub fn with_refrigerant(mut self, refrigerant: &str) -> Self { self.refrigerant_id = refrigerant.to_string(); self } /// Attaches a fluid backend used for saturation-property queries. pub fn with_fluid_backend(mut self, backend: Arc) -> Self { self.inner .set_fluid_backend_from_builder(Arc::clone(&backend)); self.fluid_backend = Some(backend); self } /// Returns the name of this condenser. pub fn name(&self) -> &str { self.inner.name() } /// Returns the UA value (effective: f_ua × UA_nominal). pub fn ua(&self) -> f64 { self.inner.ua() } /// Returns calibration factors (f_ua for condenser). pub fn calib(&self) -> &Calib { self.inner.calib() } /// Sets calibration factors. pub fn set_calib(&mut self, calib: Calib) { self.inner.set_calib(calib); } /// Defines the secondary (air/water) boundary stream that drives the coupled /// ε-NTU duty: inlet temperature [K] and heat-capacity rate ṁ·cp [W/K]. pub fn with_secondary_stream(mut self, inlet_temp_k: f64, capacity_rate_w_per_k: f64) -> Self { self.secondary_inlet_temp_k = Some(inlet_temp_k); self.secondary_capacity_rate = Some(capacity_rate_w_per_k.max(0.0)); self } /// Sets the secondary boundary stream (see [`with_secondary_stream`]). /// /// [`with_secondary_stream`]: Self::with_secondary_stream pub fn set_secondary_stream(&mut self, inlet_temp_k: f64, capacity_rate_w_per_k: f64) { self.secondary_inlet_temp_k = Some(inlet_temp_k); self.secondary_capacity_rate = Some(capacity_rate_w_per_k.max(0.0)); } /// Declares the secondary fluid for the Modelica-style 4-port mode /// ("Water", "INCOMP::MEG-30", "Air"…). When the secondary inlet/outlet /// edges are wired (ports 2 and 3), the coupled duty reads `T_sec,in` and /// `C_sec = ṁ_sec·cp` directly from the live edge state instead of fixed /// parameters. pub fn with_secondary_fluid(mut self, fluid: impl Into) -> Self { self.secondary_fluid_id = fluid.into(); self } /// Skips the pressure-equality equation (r0: P_out = P_in) in coupled mode. /// Use when both inlet and outlet pressures are imposed by boundary /// components (RefrigerantSource/Sink), making r0 redundant. Without this, /// the DoF is overdetermined by 1 in standalone HX configurations. pub fn with_skip_pressure_eq(mut self) -> Self { self.skip_pressure_eq = true; self } /// Sets the secondary fluid identifier (see [`with_secondary_fluid`]). /// /// [`with_secondary_fluid`]: Self::with_secondary_fluid pub fn set_secondary_fluid(&mut self, fluid: impl Into) { self.secondary_fluid_id = fluid.into(); } /// Sets the humidity ratio W [kg_v/kg_da] used by the moist-air h↔T /// convention on the secondary side (must match the upstream `AirSource`). pub fn set_secondary_humidity_ratio(&mut self, w: f64) { self.secondary_humidity_ratio = w.max(0.0); } /// `true` when the secondary fluid follows the moist-air linear convention /// (`h = 1006·T_c + W·(2 501 000 + 1860·T_c)`, as produced by `AirSource`). fn secondary_is_air(&self) -> bool { let f = self.secondary_fluid_id.trim(); f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair") } /// `true` when both secondary edges are wired: the exchanger runs in true /// Modelica-style 4-port mode (edge-driven secondary stream). fn secondary_edges_ready(&self) -> bool { self.sec_in_idx.is_some() && self.sec_out_idx.is_some() && !self.secondary_fluid_id.is_empty() } /// `true` when the secondary inlet and outlet edges share the same branch /// ṁ unknown (closed pumped loop) — the secondary mass-conservation row is /// then trivially zero and must be dropped. fn sec_same_branch(&self) -> bool { matches!( (self.sec_in_idx, self.sec_out_idx), (Some((m_in, _, _)), Some((m_out, _, _))) if m_in == m_out ) } /// Number of secondary-side residuals in 4-port mode. /// /// Always includes isobaric pressure closure `P_out − P_in = 0` so a /// Modelica `MassFlowSource_T` (Free P) + `Boundary_pT` sink pair is /// square: the sink anchors pressure and the HX propagates it to the /// source edge (ideal short secondary path; frictional ΔP is a later /// extension). Plus mass (dropped on a shared branch) and energy. fn n_secondary(&self) -> usize { if !self.secondary_edges_ready() { 0 } else if self.sec_same_branch() { 2 // P + energy } else { 3 // P + mass + energy } } /// Residual row index of the first secondary equation (after the thermo /// rows, the optional refrigerant mass row and the optional head-pressure /// row). fn sec_row_start(&self) -> usize { self.n_thermo() + usize::from(!self.same_branch_m) + usize::from(self.head_pressure_active()) } /// Secondary specific heat cp [J/(kg·K)] at the given edge state. fn sec_cp(&self, p_pa: f64, h_jkg: f64) -> Result { if self.secondary_is_air() { return Ok(1006.0 + 1860.0 * self.secondary_humidity_ratio); } let cp = self.query_secondary_property(Property::Cp, p_pa, h_jkg)?; if cp.is_finite() && cp > 0.0 { Ok(cp) } else { Err(ComponentError::CalculationFailed(format!( "Condenser secondary Cp is invalid: {}", cp ))) } } /// Secondary temperature [K] at the given edge state. Moist air uses the /// exact linear inversion of the psychrometric enthalpy; other fluids /// query the backend `T(P, h)`. fn sec_temperature(&self, p_pa: f64, h_jkg: f64) -> Result { if self.secondary_is_air() { let w = self.secondary_humidity_ratio; let t_c = (h_jkg - 2_501_000.0 * w) / (1006.0 + 1860.0 * w); return Ok(t_c + 273.15); } let t = self.query_secondary_property(Property::Temperature, p_pa, h_jkg)?; if t.is_finite() && t > 0.0 { Ok(t) } else { Err(ComponentError::CalculationFailed(format!( "Condenser secondary temperature is invalid: {}", t ))) } } fn query_secondary_property( &self, property: Property, p_pa: f64, h_jkg: f64, ) -> Result { if !p_pa.is_finite() || p_pa <= 0.0 { return Err(ComponentError::InvalidState(format!( "Condenser secondary side has invalid pressure: {} Pa", p_pa ))); } if !h_jkg.is_finite() { return Err(ComponentError::InvalidState(format!( "Condenser secondary side has invalid enthalpy: {} J/kg", h_jkg ))); } let backend = self.fluid_backend.as_ref().ok_or_else(|| { ComponentError::InvalidState( "Condenser secondary side requires a FluidBackend; no simulation fallback is allowed" .to_string(), ) })?; backend .property( FluidId::new(&self.secondary_fluid_id), property, FluidState::PressureEnthalpy( Pressure::from_pascals(p_pa), entropyk_core::Enthalpy::from_joules_per_kg(h_jkg), ), ) .map_err(|e| { ComponentError::CalculationFailed(format!( "Condenser failed to evaluate secondary property for fluid '{}': {}", self.secondary_fluid_id, e )) }) } /// Derivative `dT_sec/dh_sec` [K·kg/J] — exact `1/cp` for both the /// moist-air convention and (near-)incompressible liquids. fn sec_dt_dh(&self, p_pa: f64, h_jkg: f64) -> Result { Ok(1.0 / self.sec_cp(p_pa, h_jkg)?) } /// Secondary stream `(T_sec,in [K], C_sec [W/K])`. /// /// Prefers live 4-port edges; falls back to rating scalars when no edges. /// Fan actuator (if active) scales rating-mode `C_sec = φ · C_nominal`. fn live_secondary_stream(&self, state: &StateSlice) -> Result<(f64, f64), ComponentError> { if self.secondary_edges_ready() { let (m_s, p_s, h_s) = self.sec_in_idx.unwrap(); if m_s < state.len() && p_s < state.len() && h_s < state.len() { let cp = self.sec_cp(state[p_s], state[h_s])?; let t = self.sec_temperature(state[p_s], state[h_s])?; let m_mag = smooth_mass_magnitude(state[m_s], DEFAULT_M_EPS_KG_S); return Ok((t, m_mag * cp)); } } if self.rating_secondary_ready() { let t = self.secondary_inlet_temp_k.unwrap(); let c_nominal = self.secondary_capacity_rate.unwrap(); // Fan head-pressure: C_sec = φ · C_nominal when actuator is free. let c_sec = if self.fan_active() { if let Some(idx) = self.fan_actuator_idx { if idx < state.len() { state[idx].clamp(0.0, 1.5) * c_nominal } else { c_nominal } } else { c_nominal } } else { c_nominal }; return Ok((t, c_sec)); } Err(ComponentError::InvalidState( "Condenser requires live secondary edges (system mode) or rating scalars \ (secondary_inlet_temp_* + capacity rate / mass·cp)" .to_string(), )) } /// Appends the secondary-side residuals (4-port mode). /// /// * secondary momentum: `P_sec,out − P_sec,in + ΔP_sec(ṁ) = 0` /// (`ΔP_sec = 0` isobaric, or quadratic `k·ṁ·|ṁ|`) /// * mass conservation: `ṁ_sec,out − ṁ_sec,in = 0` (dropped on a shared branch) /// * energy balance: /// - physical (`q = Some(Q)`): `ṁ_sec·(h_out − h_in) − Q = 0` /// (the condenser rejects `Q` into the secondary stream) /// - seeding (`q = None`, transient non-physical refrigerant pressure): /// `h_out − h_in = 0` keeps the row well-posed and non-singular. fn secondary_residuals( &self, state: &StateSlice, residuals: &mut ResidualVector, q: Option, ) { if !self.secondary_edges_ready() { return; } let (m_in, p_in, h_in) = self.sec_in_idx.unwrap(); let (m_out, p_out, h_out) = self.sec_out_idx.unwrap(); let mut row = self.sec_row_start(); let m_sec = state[m_in]; residuals[row] = state[p_out] - state[p_in] + self.secondary_delta_p(m_sec); row += 1; if !self.sec_same_branch() { residuals[row] = state[m_out] - state[m_in]; row += 1; } residuals[row] = match q { // Use raw ṁ (not max(0)) so reverse-flow Newton trials stay antisymmetric. // C_sec already uses smooth |ṁ| so Q → 0 as ṁ_sec → 0. Some(q) => state[m_in] * (state[h_out] - state[h_in]) - q, None => state[h_out] - state[h_in], }; } /// Appends the secondary-side Jacobian entries matching /// [`secondary_residuals`](Self::secondary_residuals). /// /// In physical mode (`ctx = Some(…)`) the energy row carries the exact /// cross-derivatives to the refrigerant inlet pressure (through /// `T_cond(P)`), the secondary mass flow (through `C_sec = ṁ·cp`) and the /// secondary inlet enthalpy (through `T_sec,in(h)`). #[allow(clippy::too_many_arguments)] fn secondary_jacobian( &self, state: &StateSlice, jacobian: &mut JacobianBuilder, ctx: Option, ) { if !self.secondary_edges_ready() { return; } let (m_in, p_in, h_in) = self.sec_in_idx.unwrap(); let (m_out, p_out, h_out) = self.sec_out_idx.unwrap(); let mut row = self.sec_row_start(); let m_sec_p = state[m_in]; jacobian.add_entry(row, p_out, 1.0); jacobian.add_entry(row, p_in, -1.0); let ddp_dm = self.secondary_delta_p_dm(m_sec_p); if ddp_dm != 0.0 { jacobian.add_entry(row, m_in, ddp_dm); } row += 1; if !self.sec_same_branch() { jacobian.add_entry(row, m_out, 1.0); jacobian.add_entry(row, m_in, -1.0); row += 1; } match ctx { Some(c) => { let m_sec = state[m_in]; // r = ṁ_sec·(h_out − h_in) − Q(P_ref_in, ṁ_sec, h_sec_in) jacobian.add_entry(row, h_out, m_sec); // ∂r/∂h_in = −ṁ_sec − ∂Q/∂h_in, with ∂Q/∂h_in = −g·dT/dh. jacobian.add_entry(row, h_in, -m_sec + c.g * c.dt_sec_dh); // ∂r/∂ṁ_sec = Δh − ∂Q/∂ṁ_sec = Δh − g'(C_sec)·cp·ΔT. jacobian.add_entry( row, m_in, (state[h_out] - state[h_in]) - c.g_prime * c.cp_sec * c.delta_t, ); // ∂r/∂P_ref_in = −∂Q/∂P = −g·dT_cond/dP. jacobian.add_entry(row, c.ref_p_in_idx, -c.g * c.dtcond_dp); // Flooding actuator: ∂r/∂λ = −∂Q/∂λ = +UA_nom·ΔT·e. if let (true, Some(act_idx)) = (self.flood_ready(), self.fan_actuator_idx) { jacobian.add_entry(row, act_idx, self.ua() * c.delta_t * c.e_exp); } } None => { // Seeding row: r = h_out − h_in. jacobian.add_entry(row, h_out, 1.0); jacobian.add_entry(row, h_in, -1.0); } } } /// Enables a lumped quadratic refrigerant pressure drop `ΔP = k·ṁ·|ṁ|`. /// /// Prefer [`Self::with_tube_pressure_drop`] (MSH/Friedel) when tube geometry /// is known. Clears any tube-correlation mode. pub fn with_pressure_drop_coeff(mut self, k_pa_s2_per_kg2: f64) -> Self { self.pressure_drop_coeff = Some(k_pa_s2_per_kg2.max(0.0)); self.tube_dp_correlation = None; self.tube_dp_geometry = None; self } /// Idealised isobaric refrigerant path (`P_out = P_in`, `ΔP = 0`). pub fn with_isobaric(mut self) -> Self { self.pressure_drop_coeff = None; self.tube_dp_correlation = None; self.tube_dp_geometry = None; self } /// Tube-side two-phase ΔP: friction (MSH or Friedel at mean quality) + /// acceleration, per NIST EVAP-COND / literature tube models. pub fn with_tube_pressure_drop( mut self, correlation: crate::heat_exchanger::two_phase_dp::TwoPhaseDpCorrelation, geometry: crate::heat_exchanger::two_phase_dp::TubeChannelGeometry, ) -> Self { self.tube_dp_correlation = Some(correlation); self.tube_dp_geometry = Some(geometry); self.pressure_drop_coeff = None; self } /// Sets the lumped pressure-drop coefficient (see [`with_pressure_drop_coeff`]). pub fn set_pressure_drop_coeff(&mut self, k_pa_s2_per_kg2: f64) { self.pressure_drop_coeff = Some(k_pa_s2_per_kg2.max(0.0)); self.tube_dp_correlation = None; self.tube_dp_geometry = None; } /// Configures tube two-phase ΔP (see [`with_tube_pressure_drop`]). pub fn set_tube_pressure_drop( &mut self, correlation: crate::heat_exchanger::two_phase_dp::TwoPhaseDpCorrelation, geometry: crate::heat_exchanger::two_phase_dp::TubeChannelGeometry, ) { self.tube_dp_correlation = Some(correlation); self.tube_dp_geometry = Some(geometry); self.pressure_drop_coeff = None; } /// Enables air-cooled condenser fan head-pressure control. /// /// The fan speed ratio `φ` becomes a free actuator that scales the secondary /// air capacity rate (`C_sec = φ · C_nominal`, fan-affinity law) so the /// condensing temperature is held at `target_cond_temp_k`. This adds one /// equation `r = T_cond(P_in) − T_target` closed by the fan-speed unknown — /// genuine inverse head-pressure control, not a fixed design point. /// /// Requires a secondary stream (its capacity rate is the full-speed nominal) /// and forces emergent-pressure mode so `P_in` is free to be pinned. pub fn with_fan_head_pressure(mut self, target_cond_temp_k: f64) -> Self { self.fan_head_pressure = true; self.head_pressure_target_k = Some(target_cond_temp_k); self.emergent_pressure = true; self } /// Returns the fan head-pressure target condensing temperature [K], if set. pub fn head_pressure_target_k(&self) -> Option { self.head_pressure_target_k } /// Enables refrigerant-side condenser-flooding head-pressure control. /// /// The flooded liquid level `λ` becomes a free actuator that deactivates part /// of the condensing area (`UA_eff = (1 − λ)·UA`) so the condensing temperature /// is held at `target_cond_temp_k` even in low ambient. This adds one equation /// `r = T_cond(P_in) − T_target` closed by the level unknown — genuine inverse /// head-pressure control (Sporlan Head Master / ORI+ORD style), not a fixed /// design point. Mutually exclusive with the fan actuator (both share the /// single generic actuator slot and the head-pressure equation). /// /// Requires a secondary stream and forces emergent-pressure mode so `P_in` /// is free to be pinned by the balance. pub fn with_flooded_head_pressure(mut self, target_cond_temp_k: f64) -> Self { assert!( !self.fan_head_pressure, "Condenser: fan and flooded head-pressure control are mutually exclusive" ); self.flooded_head_pressure = true; self.head_pressure_target_k = Some(target_cond_temp_k); self.emergent_pressure = true; self } /// Static configuration test: fan head-pressure control is requested and all /// build-time prerequisites (target + nominal secondary capacity rate + /// emergent mode) are present. Used for a consistent `n_equations` before the /// actuator index is wired. fn fan_active(&self) -> bool { self.fan_head_pressure && self.emergent_pressure && self.head_pressure_target_k.is_some() && self.secondary_capacity_rate.is_some() } /// `true` when the fan actuator is fully wired (config + resolved actuator /// state index), so the head-pressure residual/Jacobian can act. fn fan_ready(&self) -> bool { self.fan_active() && self.fan_actuator_idx.is_some() } /// Static configuration test: condenser-flooding head-pressure control is /// requested with all build-time prerequisites present. fn flood_active(&self) -> bool { self.flooded_head_pressure && self.emergent_pressure && self.head_pressure_target_k.is_some() && self.secondary_capacity_rate.is_some() } /// `true` when the flooding actuator is fully wired (config + resolved /// actuator state index). fn flood_ready(&self) -> bool { self.flood_active() && self.fan_actuator_idx.is_some() } /// Either head-pressure actuator (fan or flooding) is configured. One extra /// equation `T_cond = T_target` is emitted in this case. fn head_pressure_active(&self) -> bool { self.fan_active() || self.flood_active() } /// Either head-pressure actuator is fully wired and may act. fn head_pressure_ready(&self) -> bool { self.fan_ready() || self.flood_ready() } /// Flooded liquid level `λ ∈ [0, 0.98]` read from the generic actuator slot /// (0.0 when no active/ready flooding actuator). Clamped below 1 so at least /// a sliver of condensing area (and thus a finite duty) always remains. fn flooded_level(&self, state: &StateSlice) -> f64 { match (self.flood_ready(), self.fan_actuator_idx) { (true, Some(idx)) if idx < state.len() => state[idx].clamp(0.0, 0.98), _ => 0.0, } } /// Effective conductance `UA_eff` [W/K] used by the coupled duty. With an /// active flooding actuator this is `(1 − λ)·UA_nominal`; otherwise the /// nominal `UA`. fn effective_ua(&self, state: &StateSlice) -> f64 { if self.flood_ready() { self.ua() * (1.0 - self.flooded_level(state)) } else { self.ua() } } /// Derivative `d(ε·C_sec)/dC_sec` for the phase-changing effectiveness /// `ε = 1 − exp(−UA/C_sec)`. `g(C) = C·(1 − exp(−UA/C))`, /// `g'(C) = (1 − e) − (UA/C)·e` with `e = exp(−UA/C)`. Used for the exact /// `∂Q/∂φ` fan-actuator Jacobian entry. fn d_eps_csec_d_csec(&self, c_sec: f64) -> f64 { let ua = self.ua(); if c_sec <= 1e-10 || ua <= 0.0 { return 0.0; } let e = (-ua / c_sec).exp(); (1.0 - e) - (ua / c_sec) * e } /// Resolves the mass-flow state index only when it maps to a genuine /// mass-flow slot (never falls back to a pressure column). #[inline] fn resolved_mass_idx(&self) -> Option { self.inlet_m_idx.or(self.outlet_m_idx) } /// Thermodynamic quality from (P, h) via saturation enthalpies (unclamped). fn quality_at_ph(&self, p_pa: f64, h: f64) -> Option { let backend = self.fluid_backend.as_ref()?; if self.refrigerant_id.is_empty() { return None; } let fluid = FluidId::new(&self.refrigerant_id); let p = Pressure::from_pascals(p_pa); let h_f = backend .property( fluid.clone(), Property::Enthalpy, FluidState::from_px(p, Quality::new(0.0)), ) .ok()?; let h_g = backend .property( fluid, Property::Enthalpy, FluidState::from_px(p, Quality::new(1.0)), ) .ok()?; if h_g <= h_f { return None; } Some((h - h_f) / (h_g - h_f)) } /// Saturated transport properties at `p_pa` for tube ΔP correlations. fn sat_transport_at_p( &self, p_pa: f64, ) -> Option { let backend = self.fluid_backend.as_ref()?; if self.refrigerant_id.is_empty() { return None; } let fluid = FluidId::new(&self.refrigerant_id); let p = Pressure::from_pascals(p_pa); let px = |x: f64, prop: Property| { backend.property( FluidId::new(&self.refrigerant_id), prop, FluidState::from_px(p, Quality::new(x)), ) }; let rho_liquid = px(0.0, Property::Density).ok()?; let rho_vapor = px(1.0, Property::Density).ok()?; let mu_liquid = px(0.0, Property::Viscosity).ok()?; let mu_vapor = px(1.0, Property::Viscosity).ok()?; let sigma = backend .property( fluid, Property::SurfaceTension, FluidState::from_px(p, Quality::new(0.5)), ) .unwrap_or(0.008); Some(crate::heat_exchanger::two_phase_dp::SatTransportProps { rho_liquid, rho_vapor, mu_liquid, mu_vapor, sigma, }) } /// Signed refrigerant ΔP [Pa]: tube MSH/Friedel (+ acceleration) if /// configured, else lumped quadratic, else 0. fn refrigerant_pressure_drop(&self, m_ref: f64, p_pa: f64, h_in: f64, h_out: f64) -> f64 { if self.resolved_mass_idx().is_none() { return 0.0; } if let (Some(corr), Some(geom)) = (self.tube_dp_correlation, self.tube_dp_geometry) { if let (Some(x_in), Some(x_out), Some(props)) = ( self.quality_at_ph(p_pa, h_in), self.quality_at_ph(p_pa, h_out), self.sat_transport_at_p(p_pa), ) { return crate::heat_exchanger::two_phase_dp::tube_two_phase_delta_p( corr, &geom, m_ref, x_in, x_out, &props, ); } } match self.pressure_drop_coeff { Some(k) if k > 0.0 => { crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref) } _ => 0.0, } } /// ∂ΔP/∂ṁ for the momentum residual (analytic quadratic, FD for tube). fn refrigerant_pressure_drop_dm(&self, m_ref: f64, p_pa: f64, h_in: f64, h_out: f64) -> f64 { if let (Some(_), Some(_)) = (self.tube_dp_correlation, self.tube_dp_geometry) { let eps = (1e-6 * m_ref.abs()).max(1e-8); let dp_p = self.refrigerant_pressure_drop(m_ref + eps, p_pa, h_in, h_out); let dp_m = self.refrigerant_pressure_drop(m_ref - eps, p_pa, h_in, h_out); return (dp_p - dp_m) / (2.0 * eps); } match self.pressure_drop_coeff { Some(k) if k > 0.0 => { crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_ref) } _ => 0.0, } } /// Enables the emergent-pressure mode with the given sub-cooling target [K]. /// /// In this mode an extra outlet-closure residual pins the refrigerant outlet /// enthalpy to `h(P, T_cond(P) − subcooling_k)`. Combined with the ε-NTU energy /// balance this makes the condensing pressure emerge from the secondary /// conditions rather than being imposed by the compressor. Requires a secondary /// stream ([`with_secondary_stream`]). /// /// [`with_secondary_stream`]: Self::with_secondary_stream pub fn with_emergent_pressure(mut self, subcooling_k: f64) -> Self { self.emergent_pressure = true; self.subcooling_k = subcooling_k.max(0.0); self } /// Number of thermodynamic residuals emitted (2 normally, 3 in emergent mode /// where the outlet-closure equation pins the condensing pressure). fn n_thermo(&self) -> usize { let base = if self.skip_pressure_eq { 1 } else { self.inner.n_equations() }; base + if self.emergent_pressure { 1 } else { 0 } } /// Residual row index of the head-pressure equation (fan or flooding), /// appended after the thermodynamic + optional mass-conservation rows. fn head_pressure_row(&self) -> usize { let thermo = self.n_thermo(); if self.same_branch_m { thermo } else { thermo + 1 } } /// Target outlet enthalpy `h(P, T_cond(P) − subcooling_k)` [J/kg] (emergent mode). fn emergent_outlet_enthalpy(&self, p_pa: f64) -> Result { let backend = self.fluid_backend.as_ref().ok_or_else(|| { ComponentError::CalculationFailed("Condenser: no fluid backend".to_string()) })?; let fluid = FluidId::new(&self.refrigerant_id); if self.subcooling_k <= 1e-9 { return self.h_sat_liq_at_p(backend.as_ref(), fluid, p_pa); } let t_cond = self.cond_temperature(p_pa)?; backend .property( fluid, Property::Enthalpy, FluidState::PressureTemperature( Pressure::from_pascals(p_pa), entropyk_core::Temperature::from_kelvin(t_cond - self.subcooling_k), ), ) .map_err(|e| ComponentError::CalculationFailed(e.to_string())) } /// Condensing (saturation) temperature of the refrigerant at pressure `p_pa` [K]. fn cond_temperature(&self, p_pa: f64) -> Result { let backend = self.fluid_backend.as_ref().ok_or_else(|| { ComponentError::CalculationFailed("Condenser: no fluid backend".to_string()) })?; backend .property( FluidId::new(&self.refrigerant_id), Property::Temperature, FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.5)), ) .map_err(|e| ComponentError::CalculationFailed(e.to_string())) } /// Measured liquid-line subcooling `T_cond(P_out) − T(P_out, h_out)` [K] /// from the solved state, for inverse control / `controls[]` loops. fn measured_subcooling(&self, state: &StateSlice) -> Option { let backend = self.fluid_backend.as_ref()?; if self.refrigerant_id.is_empty() { return None; } let p_idx = self.outlet_p_idx?; let h_idx = self.outlet_h_idx?; if p_idx >= state.len() || h_idx >= state.len() { return None; } let p_out = state[p_idx]; let h_out = state[h_idx]; if !p_out.is_finite() || !h_out.is_finite() || p_out <= 0.0 { return None; } let fluid = FluidId::new(&self.refrigerant_id); let t_out = backend .property( fluid, Property::Temperature, FluidState::PressureEnthalpy( Pressure::from_pascals(p_out), entropyk_core::Enthalpy::from_joules_per_kg(h_out), ), ) .ok()?; let t_sat = self.cond_temperature(p_out).ok()?; let sc = t_sat - t_out; sc.is_finite().then_some(sc) } /// Measured condensing (saturation) temperature SDT `T_sat(P_out)` [K] /// from the solved state, for head-pressure control loops. fn measured_saturation_temperature(&self, state: &StateSlice) -> Option { if self.fluid_backend.is_none() || self.refrigerant_id.is_empty() { return None; } let p_idx = self.outlet_p_idx.or(self.inlet_p_idx)?; if p_idx >= state.len() { return None; } let p = state[p_idx]; if !p.is_finite() || p <= 0.0 { return None; } let t_sat = self.cond_temperature(p).ok()?; t_sat.is_finite().then_some(t_sat) } /// Effectiveness for a phase-changing refrigerant (`C_min = C_sec`, `C_r → 0`): /// `ε = 1 − exp(−UA / C_sec)` at the nominal conductance. fn effectiveness(&self, c_sec: f64) -> f64 { self.effectiveness_ua(c_sec, self.ua()) } /// Effectiveness at an explicit conductance `ua` [W/K]: `ε = 1 − exp(−ua/C_sec)`. /// Used by the flooding actuator, which lowers the effective conductance /// (`UA_eff = (1 − λ)·UA`) to raise the condensing temperature. fn effectiveness_ua(&self, c_sec: f64, ua: f64) -> f64 { if c_sec <= 1e-10 || ua <= 0.0 { return 0.0; } 1.0 - (-ua / c_sec).exp() } /// Rating scalars present: `T_sec,in` and strictly positive `C_sec`. fn rating_secondary_ready(&self) -> bool { matches!( (self.secondary_inlet_temp_k, self.secondary_capacity_rate), (Some(t), Some(c)) if t.is_finite() && c.is_finite() && c > 0.0 ) } /// Returns `true` when all prerequisites for the coupled ε-NTU model are met: /// refrigerant indices + either live secondary edges (system) or rating scalars. fn coupled_ready(&self) -> bool { self.fluid_backend.is_some() && !self.refrigerant_id.is_empty() && self.inlet_p_idx.is_some() && self.inlet_h_idx.is_some() && self.outlet_p_idx.is_some() && self.outlet_h_idx.is_some() && (self.secondary_edges_ready() || self.rating_secondary_ready()) } /// Coupled condenser duty `Q = ε·C_sec·(T_cond(P_in) − T_sec,in)` in watts. /// /// Positive `Q` means heat flows from the refrigerant into the secondary fluid. #[cfg_attr(not(test), allow(dead_code))] fn coupled_duty(&self, p_in_pa: f64) -> Result { self.coupled_duty_with_csec(p_in_pa, self.secondary_capacity_rate.unwrap_or(0.0)) } /// Coupled duty at an explicit secondary capacity rate `c_sec` [W/K]. /// /// Used by the fan head-pressure actuator, which scales `C_sec` with the fan /// speed ratio (`C_sec = φ · C_nominal`). fn coupled_duty_with_csec(&self, p_in_pa: f64, c_sec: f64) -> Result { self.coupled_duty_full(p_in_pa, c_sec, self.ua()) } /// Coupled duty at an explicit capacity rate `c_sec` and conductance `ua` /// [W/K]: `Q = ε(c_sec, ua)·c_sec·(T_cond(P_in) − T_sec,in)`. The flooding /// actuator feeds `ua = (1 − λ)·UA_nominal`. fn coupled_duty_full(&self, p_in_pa: f64, c_sec: f64, ua: f64) -> Result { let t_sec_in = self.secondary_inlet_temp_k.unwrap_or(0.0); let t_cond = self.cond_temperature(p_in_pa)?; let eps = self.effectiveness_ua(c_sec, ua); Ok(eps * c_sec * (t_cond - t_sec_in)) } /// Rates the condenser at a fixed refrigerant regime (constant condensing /// pressure `p_in_pa`) against the configured secondary (air/water) stream. /// /// Qualification entry point: keep the refrigerant regime constant, vary the /// secondary inlet temperature and/or flow, and read the genuine ε-NTU response /// (`C_min = C_sec` for a phase-changing refrigerant). Nothing is imposed: /// /// - `q_w` : `Q = ε·C_sec·(T_cond − T_sec,in)` [W] rejected to the secondary /// - `effectiveness` : `ε = 1 − exp(−UA / C_sec)` [-] /// - `t_cond_k` : condensing temperature `T_sat(P)` [K] /// - `approach_k` : `T_cond − T_sec,in` [K] /// - `secondary_outlet_k` : `T_sec,in + Q/C_sec` [K] pub fn rate(&self, p_in_pa: f64) -> Result { let c_sec = self.secondary_capacity_rate.ok_or_else(|| { ComponentError::InvalidState( "Condenser::rate requires a secondary stream (set_secondary_stream)".to_string(), ) })?; let t_sec_in = self.secondary_inlet_temp_k.ok_or_else(|| { ComponentError::InvalidState( "Condenser::rate requires a secondary inlet temperature".to_string(), ) })?; let t_cond = self.cond_temperature(p_in_pa)?; let eps = self.effectiveness(c_sec); let q = eps * c_sec * (t_cond - t_sec_in); let secondary_outlet_k = if c_sec > 1e-10 { t_sec_in + q / c_sec } else { t_sec_in }; Ok(CondenserRating { q_w: q, effectiveness: eps, t_cond_k: t_cond, approach_k: t_cond - t_sec_in, secondary_outlet_k, }) } /// Returns the saturation temperature. pub fn saturation_temp(&self) -> f64 { self.saturation_temp } /// Sets the saturation temperature. pub fn set_saturation_temp(&mut self, temp: f64) { self.saturation_temp = temp; } /// Overrides the effective UA value [W/K] at runtime. /// /// Sets the UA scale factor so that `UA_nominal × scale = ua_value`. /// Used by `MchxCondenserCoil` to apply fan-speed and air-density corrections. pub fn set_ua(&mut self, ua_value: f64) { let ua_nominal = self.inner.ua_nominal(); let scale = if ua_nominal > 0.0 { ua_value / ua_nominal } else { 1.0 }; self.inner.set_ua_scale(scale.max(0.0)); } /// Validates that the outlet quality is <= 1 (fully condensed or subcooled). /// /// # Arguments /// /// * `outlet_enthalpy` - Outlet specific enthalpy (J/kg) /// * `h_liquid` - Saturated liquid enthalpy at condensing pressure /// * `h_vapor` - Saturated vapor enthalpy at condensing pressure /// /// # Returns /// /// Returns Ok(true) if fully condensed, Err otherwise pub fn validate_outlet_quality( &self, outlet_enthalpy: f64, h_liquid: f64, h_vapor: f64, ) -> Result { if h_vapor <= h_liquid { return Err(ComponentError::NumericalError( "Invalid saturation enthalpies".to_string(), )); } let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid); if quality <= 0.0 + 1e-6 { Ok(true) } else { Err(ComponentError::InvalidState(format!( "Condenser outlet quality {} > 0 (not fully condensed)", quality ))) } } /// Returns sat-liquid enthalpy at a given pressure [J/kg] (used for residuals). fn h_sat_liq_at_p( &self, backend: &dyn FluidBackend, fluid: FluidId, p_pa: f64, ) -> Result { backend .property( fluid, Property::Enthalpy, FluidState::PressureQuality(Pressure::from_pascals(p_pa), Quality(0.0)), ) .map_err(|e| ComponentError::CalculationFailed(e.to_string())) } /// Computes the full thermodynamic state at the hot (refrigerant) inlet. pub fn hot_inlet_state(&self) -> Result { self.inner.hot_inlet_state() } /// Computes the full thermodynamic state at the cold inlet. pub fn cold_inlet_state(&self) -> Result { self.inner.cold_inlet_state() } /// Returns the hot side fluid identifier, if set. pub fn hot_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> { self.inner.hot_fluid_id() } /// Sets the cold side boundary conditions. pub fn set_cold_conditions(&mut self, conditions: super::exchanger::HxSideConditions) { self.inner.set_cold_conditions(conditions); } /// Returns the cold side fluid identifier, if set. pub fn cold_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> { self.inner.cold_fluid_id() } } impl Component for Condenser { fn set_system_context( &mut self, _state_offset: usize, external_edge_state_indices: &[(usize, usize, usize)], ) { // external_edge_state_indices layout (from system.rs finalize): // [0..n_incoming]: incoming edges (hot refrigerant inlet from compressor) // [n_incoming..]: outgoing edges (hot refrigerant outlet to EXV) // For a typical single-circuit condenser: 1 incoming + 1 outgoing. // Triple: (m_idx, p_idx, h_idx) if !external_edge_state_indices.is_empty() { self.inlet_m_idx = Some(external_edge_state_indices[0].0); self.inlet_p_idx = Some(external_edge_state_indices[0].1); self.inlet_h_idx = Some(external_edge_state_indices[0].2); } if external_edge_state_indices.len() >= 2 { self.outlet_m_idx = Some(external_edge_state_indices[1].0); self.outlet_p_idx = Some(external_edge_state_indices[1].1); self.outlet_h_idx = Some(external_edge_state_indices[1].2); } // CM1.4: detect same-branch topology. self.same_branch_m = matches!( (self.inlet_m_idx, self.outlet_m_idx), (Some(m_in), Some(m_out)) if m_in == m_out ); self.inner .set_system_context(_state_offset, external_edge_state_indices); } fn compute_residuals( &self, state: &StateSlice, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { let n_thermo = self.n_thermo(); // 2, or 3 in emergent mode // Coupled ε-NTU path: when a secondary (water/air) stream is configured, the // condenser duty Q = ε·C_sec·(T_cond(P_in) − T_sec,in) is rejected to that // stream and the refrigerant outlet enthalpy follows the energy balance // ṁ·(h_in − h_out) = Q. The condenser load — and the degree of condensation / // subcooling — then react to the secondary inlet temperature and flow. if self.coupled_ready() { if residuals.len() < self.n_equations() { return Err(ComponentError::InvalidResidualDimensions { expected: self.n_equations(), actual: residuals.len(), }); } let inlet_p_idx = self.inlet_p_idx.unwrap(); let p_in = state[inlet_p_idx]; if p_in > 10_000.0 { let inlet_h_idx = self.inlet_h_idx.unwrap(); let outlet_p_idx = self.outlet_p_idx.unwrap(); let outlet_h_idx = self.outlet_h_idx.unwrap(); let m_idx = self .inlet_m_idx .or(self.outlet_m_idx) .ok_or_else(|| { ComponentError::InvalidState( "mass-flow state index not resolved (cannot fall back to a pressure index)" .into(), ) })?; let m_ref = state[m_idx]; let h_in = state[inlet_h_idx]; let h_out = state[outlet_h_idx]; // Live secondary stream: edge-driven in 4-port mode (Modelica). let (t_sec_in, c_sec) = self.live_secondary_stream(state)?; let ua_eff = self.effective_ua(state); let t_cond = self.cond_temperature(p_in)?; let eps = self.effectiveness_ua(c_sec, ua_eff); let q = eps * c_sec * (t_cond - t_sec_in); // r0: refrigerant pressure drop (tube MSH/Friedel + accel, or // lumped quadratic): P_out = P_in − ΔP. let dp_drop = self.refrigerant_pressure_drop(m_ref, p_in, h_in, h_out); let mut row = 0; if !self.skip_pressure_eq { residuals[row] = state[outlet_p_idx] - (p_in - dp_drop); row += 1; } residuals[row] = m_ref * (h_in - h_out) - q; row += 1; if self.emergent_pressure { residuals[row] = state[outlet_h_idx] - self.emergent_outlet_enthalpy(p_in)?; } if !self.same_branch_m { residuals[n_thermo] = match (self.inlet_m_idx, self.outlet_m_idx) { (Some(m_in), Some(m_out)) => state[m_out] - state[m_in], _ => 0.0, }; } // Head-pressure equation: T_cond(P_in) = T_target, closed by the // active free actuator (fan speed φ scales C_sec, or flooding level // λ scales UA_eff above). Same closure for both mechanisms. if self.head_pressure_active() { let row = self.head_pressure_row(); residuals[row] = if self.head_pressure_ready() { self.cond_temperature(p_in)? - self.head_pressure_target_k.unwrap() } else { 0.0 }; } // 4-port mode: secondary mass conservation + energy balance // (the secondary stream absorbs Q). self.secondary_residuals(state, residuals, Some(q)); return Ok(()); } else if self.emergent_pressure { // Transient seeding (P_in not yet physical): keep all three emergent // residuals defined so the residual/DoF count stays consistent. Seed // the pressure and outlet from the nominal saturation temperature. let backend = self.fluid_backend.as_ref().unwrap(); let fluid = FluidId::new(&self.refrigerant_id); let outlet_p_idx = self.outlet_p_idx.unwrap(); let outlet_h_idx = self.outlet_h_idx.unwrap(); let p_cond_sat = backend .saturation_pressure_t(fluid.clone(), self.saturation_temp) .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?; let h_sat_liq = backend .saturation_enthalpy_t(fluid, self.saturation_temp, 0.0) .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?; residuals[0] = state[outlet_p_idx] - p_cond_sat; residuals[1] = state[inlet_p_idx] - p_cond_sat; residuals[2] = state[outlet_h_idx] - h_sat_liq; if !self.same_branch_m { residuals[n_thermo] = match (self.inlet_m_idx, self.outlet_m_idx) { (Some(m_in), Some(m_out)) => state[m_out] - state[m_in], _ => 0.0, }; } // Transient head-pressure row: keep the actuator well-posed until // P_in becomes physical — drive the fan speed toward full (φ → 1) // or the flooding level toward empty (λ → 0), i.e. maximum area. if self.head_pressure_active() { let row = self.head_pressure_row(); let seed = if self.fan_active() { 1.0 } else { 0.0 }; residuals[row] = match (self.head_pressure_ready(), self.fan_actuator_idx) { (true, Some(idx)) => state[idx] - seed, _ => 0.0, }; } // 4-port seeding: keep the secondary rows well-posed // (mass conservation + h_out = h_in). self.secondary_residuals(state, residuals, None); return Ok(()); } } if let (Some(backend), Some(outlet_p_idx), Some(outlet_h_idx)) = (&self.fluid_backend, self.outlet_p_idx, self.outlet_h_idx) { if !self.refrigerant_id.is_empty() { if residuals.len() < self.n_equations() { return Err(ComponentError::InvalidResidualDimensions { expected: self.n_equations(), actual: residuals.len(), }); } if let Some(inlet_p_idx) = self.inlet_p_idx { let p_in = state[inlet_p_idx]; // Only use coupled model when inlet pressure is physically plausible. // During solver initialization (P≈0), fall back to fixed T_cond model. if p_in > 10_000.0 { let h_sat_liq = self.h_sat_liq_at_p( backend.as_ref(), FluidId::new(&self.refrigerant_id), p_in, )?; residuals[0] = state[outlet_p_idx] - p_in; residuals[1] = state[outlet_h_idx] - h_sat_liq; } else { let fluid = FluidId::new(&self.refrigerant_id); let p_cond_sat = backend .saturation_pressure_t(fluid.clone(), self.saturation_temp) .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?; let h_sat_liq = backend .saturation_enthalpy_t(fluid, self.saturation_temp, 0.0) .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?; residuals[0] = state[outlet_p_idx] - p_cond_sat; residuals[1] = state[outlet_h_idx] - h_sat_liq; } } else { // Fallback: fixed saturation-temperature model (no inlet coupling). let fluid = FluidId::new(&self.refrigerant_id); let p_cond_sat = backend .saturation_pressure_t(fluid.clone(), self.saturation_temp) .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?; let h_sat_liq = backend .saturation_enthalpy_t(fluid, self.saturation_temp, 0.0) .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?; residuals[0] = state[outlet_p_idx] - p_cond_sat; residuals[1] = state[outlet_h_idx] - h_sat_liq; } // r[n_thermo] = ṁ_outlet − ṁ_inlet = 0 (mass conservation, CM1.3) // CM1.4: skip when same_branch_m — trivially zero. if !self.same_branch_m { residuals[n_thermo] = match (self.inlet_m_idx, self.outlet_m_idx) { (Some(m_in), Some(m_out)) => state[m_out] - state[m_in], _ => 0.0, }; } // 4-port: secondary rows stay defined even on this transient path. self.secondary_residuals(state, residuals, None); return Ok(()); } } Err(ComponentError::InvalidState( "Condenser requires refrigerant inlet/outlet indices with backend/refrigerant, \ and either live secondary ports (system) or rating scalars \ (secondary_inlet_temp_* + capacity rate); refusing generic HX fallback" .to_string(), )) } fn jacobian_entries( &self, state: &StateSlice, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { let n_thermo = self.n_thermo(); // 2, or 3 in emergent mode // Coupled ε-NTU path: analytical Jacobian of the energy balance. if self.coupled_ready() { let inlet_p_idx = self.inlet_p_idx.unwrap(); let p_in = state[inlet_p_idx]; if p_in > 10_000.0 { let inlet_h_idx = self.inlet_h_idx.unwrap(); let outlet_p_idx = self.outlet_p_idx.unwrap(); let outlet_h_idx = self.outlet_h_idx.unwrap(); let m_idx = self .inlet_m_idx .or(self.outlet_m_idx) .ok_or_else(|| { ComponentError::InvalidState( "mass-flow state index not resolved (cannot fall back to a pressure index)" .into(), ) })?; let m_ref = state[m_idx]; let h_in = state[inlet_h_idx]; let h_out = state[outlet_h_idx]; let mut row = 0; if !self.skip_pressure_eq { jacobian.add_entry(row, outlet_p_idx, 1.0); jacobian.add_entry(row, inlet_p_idx, -1.0); if let Some(m_real) = self.resolved_mass_idx() { let dm = self.refrigerant_pressure_drop_dm(m_ref, p_in, h_in, h_out); if dm.abs() > 0.0 { // r0 = P_out − P_in + ΔP ⇒ ∂r0/∂ṁ = ∂ΔP/∂ṁ jacobian.add_entry(row, m_real, dm); } } row += 1; } jacobian.add_entry(row, inlet_h_idx, m_ref); jacobian.add_entry(row, outlet_h_idx, -m_ref); jacobian.add_entry(row, m_idx, h_in - h_out); // ∂r1/∂P_in = −∂Q/∂P_in = −ε·C_sec·dT_cond/dP_in (T_sec,in constant), // dT_cond/dP via central finite difference. ε uses the effective // conductance so a flooded level (UA_eff) is reflected exactly. let (t_sec_in, c_sec) = self.live_secondary_stream(state)?; let ua_eff = self.effective_ua(state); let eps = self.effectiveness_ua(c_sec, ua_eff); let g = eps * c_sec; let t_cond = self.cond_temperature(p_in)?; let dp = p_in * 1e-4 + 100.0; let t_plus = self.cond_temperature(p_in + dp)?; let t_minus = self.cond_temperature((p_in - dp).max(1.0))?; let dt_dp = (t_plus - t_minus) / (2.0 * dp); jacobian.add_entry(1, inlet_p_idx, -g * dt_dp); // 4-port cross-derivatives of r1 to the secondary edge state: // ∂r1/∂h_sec,in = −∂Q/∂h_sec,in = +g·dT_sec/dh (exact 1/cp), // ∂r1/∂ṁ_sec = −∂Q/∂ṁ_sec = −g'(C_sec)·cp·(T_cond − T_sec,in). let sec_ctx = if self.secondary_edges_ready() { let (m_s, p_s, h_s) = self.sec_in_idx.unwrap(); let cp_sec = self.sec_cp(state[p_s], state[h_s])?; let dt_dh = self.sec_dt_dh(state[p_s], state[h_s])?; let g_prime = { let ua = ua_eff; if c_sec <= 1e-10 || ua <= 0.0 { 0.0 } else { let e = (-ua / c_sec).exp(); (1.0 - e) - (ua / c_sec) * e } }; jacobian.add_entry(row, h_s, g * dt_dh); jacobian.add_entry(row, m_s, -g_prime * cp_sec * (t_cond - t_sec_in)); Some(SecondaryJacCtx { g, g_prime, cp_sec, dt_sec_dh: dt_dh, delta_t: t_cond - t_sec_in, dtcond_dp: dt_dp, ref_p_in_idx: inlet_p_idx, e_exp: if c_sec > 1e-10 { (-ua_eff / c_sec).exp() } else { 0.0 }, }) } else { None }; // ∂r1/∂φ = −∂Q/∂φ = −g'(C_sec)·C_nominal·(T_cond − T_sec,in), // with C_sec = φ·C_nominal and g(C) = ε(C)·C. Exact fan coupling // (parameter-based secondary stream only). if self.fan_ready() && !self.secondary_edges_ready() { if let (Some(fan_idx), Some(t_sec_param)) = (self.fan_actuator_idx, self.secondary_inlet_temp_k) { let c_nominal = self.secondary_capacity_rate.unwrap_or(0.0); let g_prime = self.d_eps_csec_d_csec(c_sec); jacobian.add_entry( row, fan_idx, -g_prime * c_nominal * (t_cond - t_sec_param), ); } } // ∂r1/∂λ = −∂Q/∂λ. With UA_eff = (1 − λ)·UA_nom and // ε = 1 − exp(−UA_eff/C_sec): ∂ε/∂UA_eff = e/C_sec (e = exp(−UA_eff/C_sec)), // ∂UA_eff/∂λ = −UA_nom, so ∂Q/∂λ = −UA_nom·(T_cond − T_sec,in)·e and // ∂r1/∂λ = +UA_nom·(T_cond − T_sec,in)·e. Exact flooding coupling. if self.flood_ready() { if let Some(lvl_idx) = self.fan_actuator_idx { let ua_nom = self.ua(); let e = if c_sec > 1e-10 { (-ua_eff / c_sec).exp() } else { 0.0 }; jacobian.add_entry(row, lvl_idx, ua_nom * (t_cond - t_sec_in) * e); } } // r2 (emergent) = H_out − h_target(P_in): ∂/∂H_out = 1, // ∂/∂P_in = −dh_target/dP via central finite difference. if self.emergent_pressure { let emergent_row = if self.skip_pressure_eq { 1 } else { 2 }; jacobian.add_entry(emergent_row, outlet_h_idx, 1.0); let hp = self.emergent_outlet_enthalpy(p_in + dp); let hm = self.emergent_outlet_enthalpy((p_in - dp).max(1.0)); if let (Ok(hp), Ok(hm)) = (hp, hm) { jacobian.add_entry(emergent_row, inlet_p_idx, -(hp - hm) / (2.0 * dp)); } } if !self.same_branch_m { if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) { jacobian.add_entry(n_thermo, m_out, 1.0); jacobian.add_entry(n_thermo, m_in, -1.0); } } // Head-pressure row: r = T_cond(P_in) − T_target. // ∂/∂P_in = dT_cond/dP_in (same FD slope as above), fan or flooding. if self.head_pressure_ready() { jacobian.add_entry(self.head_pressure_row(), inlet_p_idx, dt_dp); } // 4-port: secondary mass + energy rows (exact cross terms). self.secondary_jacobian(state, jacobian, sec_ctx); return Ok(()); } else if self.emergent_pressure { // Transient seeding Jacobian (diagonal): r0=P_out−const, // r1=P_in−const, r2=H_out−const. let outlet_p_idx = self.outlet_p_idx.unwrap(); let outlet_h_idx = self.outlet_h_idx.unwrap(); jacobian.add_entry(0, outlet_p_idx, 1.0); jacobian.add_entry(1, inlet_p_idx, 1.0); jacobian.add_entry(2, outlet_h_idx, 1.0); // Transient head-pressure row: r = actuator − seed, ∂/∂actuator = 1 // (keeps it non-singular). Fan seeds φ→1, flooding seeds λ→0. if self.head_pressure_ready() { if let Some(act_idx) = self.fan_actuator_idx { jacobian.add_entry(self.head_pressure_row(), act_idx, 1.0); } } if !self.same_branch_m { if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) { jacobian.add_entry(n_thermo, m_out, 1.0); jacobian.add_entry(n_thermo, m_in, -1.0); } } // 4-port seeding: diagonal secondary rows. self.secondary_jacobian(state, jacobian, None); return Ok(()); } } if let (Some(backend), Some(outlet_p_idx), Some(outlet_h_idx)) = (&self.fluid_backend, self.outlet_p_idx, self.outlet_h_idx) { if !self.refrigerant_id.is_empty() { if let Some(inlet_p_idx) = self.inlet_p_idx { let p_in = state[inlet_p_idx]; if p_in > 10_000.0 { // r0 = P_out - P_in → ∂r0/∂P_out = +1, ∂r0/∂P_in = -1 jacobian.add_entry(0, outlet_p_idx, 1.0); jacobian.add_entry(0, inlet_p_idx, -1.0); // r1 = H_out - H_sat_liq(P_in) → ∂r1/∂H_out = +1, ∂r1/∂P_in = -dH_liq/dP jacobian.add_entry(1, outlet_h_idx, 1.0); let dp = p_in * 1e-4 + 100.0; let fluid = FluidId::new(&self.refrigerant_id); let h_plus = self.h_sat_liq_at_p(backend.as_ref(), fluid.clone(), p_in + dp); let h_minus = self.h_sat_liq_at_p(backend.as_ref(), fluid, p_in - dp); if let (Ok(hp), Ok(hm)) = (h_plus, h_minus) { jacobian.add_entry(1, inlet_p_idx, -(hp - hm) / (2.0 * dp)); } } else { // Fallback: fixed T_cond model — diagonal only jacobian.add_entry(0, outlet_p_idx, 1.0); jacobian.add_entry(1, outlet_h_idx, 1.0); } } else { // Fallback: fixed T_cond model — diagonal only jacobian.add_entry(0, outlet_p_idx, 1.0); jacobian.add_entry(1, outlet_h_idx, 1.0); } // r[n_thermo] = ṁ_outlet − ṁ_inlet → exact analytic entries (CM1.3) // CM1.4: omit when same_branch_m (dropped from n_equations). if !self.same_branch_m { if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) { jacobian.add_entry(n_thermo, m_out, 1.0); jacobian.add_entry(n_thermo, m_in, -1.0); } } self.secondary_jacobian(state, jacobian, None); return Ok(()); } } Ok(()) } fn set_fluid_backend_from_builder(&mut self, backend: Arc) { self.fluid_backend = Some(Arc::clone(&backend)); self.inner.set_fluid_backend_from_builder(backend); } fn n_equations(&self) -> usize { // Thermodynamic residuals: 2 normally, 3 in emergent mode (outlet closure). let thermo = self.n_thermo(); // CM1.4: drop the conservation equation when in the same series branch. let core = if self.same_branch_m { thermo } else { thermo + 1 // +1 for mass conservation (CM1.3) }; // +1 for the head-pressure equation (T_cond = T_target) closed by the // active free actuator (fan speed or flooding level). Static config test // keeps DoF consistent before the actuator index is wired in finalize(). // + secondary rows in Modelica-style 4-port mode. core + if self.head_pressure_active() { 1 } else { 0 } + self.n_secondary() } fn equation_roles(&self) -> Vec { let mut roles = Vec::new(); if !self.skip_pressure_eq { roles.push(crate::EquationRole::MomentumOrPressureDrop { stream: "refrigerant", }); } roles.push(crate::EquationRole::EnergyBalance { stream: "refrigerant", }); if self.emergent_pressure { roles.push(crate::EquationRole::OutletClosure { kind: "subcooling", }); } if !self.same_branch_m { roles.push(crate::EquationRole::MassConservation { stream: "refrigerant", }); } if self.head_pressure_active() { roles.push(crate::EquationRole::ActuatorClosure { name: "head_pressure", }); } if self.secondary_edges_ready() { roles.push(crate::EquationRole::MomentumOrPressureDrop { stream: "secondary", }); if !self.sec_same_branch() { roles.push(crate::EquationRole::MassConservation { stream: "secondary", }); } roles.push(crate::EquationRole::EnergyBalance { stream: "secondary", }); } roles } fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) { // Deterministic 4-port wiring: // port 0 = refrigerant inlet, port 1 = refrigerant outlet, // port 2 = secondary inlet, port 3 = secondary outlet. if let Some(Some((m, p, h))) = port_edges.first() { self.inlet_m_idx = Some(*m); self.inlet_p_idx = Some(*p); self.inlet_h_idx = Some(*h); } if let Some(Some((m, p, h))) = port_edges.get(1) { self.outlet_m_idx = Some(*m); self.outlet_p_idx = Some(*p); self.outlet_h_idx = Some(*h); } if let Some(Some(triple)) = port_edges.get(2) { self.sec_in_idx = Some(*triple); } if let Some(Some(triple)) = port_edges.get(3) { self.sec_out_idx = Some(*triple); } self.same_branch_m = matches!( (self.inlet_m_idx, self.outlet_m_idx), (Some(m_in), Some(m_out)) if m_in == m_out ); } fn port_names(&self) -> Vec { vec![ "inlet".to_string(), "outlet".to_string(), "secondary_inlet".to_string(), "secondary_outlet".to_string(), ] } fn flow_paths(&self) -> Vec<(usize, usize)> { // Modelica-style internal series paths: refrigerant 0→1, secondary 2→3. // Each path shares one ṁ unknown across its inlet/outlet edges. vec![(0, 1), (2, 3)] } fn get_ports(&self) -> &[ConnectedPort] { self.inner.get_ports() } fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) { self.fan_actuator_idx = indices.actuator; self.inner.set_calib_indices(indices); } fn port_mass_flows( &self, state: &StateSlice, ) -> Result, ComponentError> { self.inner.port_mass_flows(state) } fn port_enthalpies( &self, state: &StateSlice, ) -> Result, ComponentError> { self.inner.port_enthalpies(state) } fn energy_transfers( &self, state: &StateSlice, ) -> Option<(entropyk_core::Power, entropyk_core::Power)> { // When coupled to an *external* secondary stream, the refrigerant rejects // its duty Q = ṁ·(h_in − h_out) to the environment. Report it as heat // leaving the component (negative), so the cycle-level performance can // recover the true heating/rejected capacity. Without a secondary stream // the exchanger is internal to a tracked two-circuit coupling and must // stay adiabatic to avoid double counting — delegate to the inner model. if self.coupled_ready() { if let (Some(m_idx), Some(in_h), Some(out_h)) = ( self.resolved_mass_idx(), self.inlet_h_idx, self.outlet_h_idx, ) { if m_idx < state.len() && in_h < state.len() && out_h < state.len() { let q_rej = state[m_idx] * (state[in_h] - state[out_h]); if q_rej.is_finite() { return Some(( entropyk_core::Power::from_watts(-q_rej), entropyk_core::Power::from_watts(0.0), )); } } } } self.inner.energy_transfers(state) } fn signature(&self) -> String { self.inner.signature() } fn to_params(&self) -> crate::ComponentParams { self.inner.to_params() } fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool { self.inner.update_calib_factor(factor, value) } fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option { use crate::MeasuredOutput; match kind { // Real liquid-line subcooling (replaces the solver's mock formula). MeasuredOutput::Subcooling => self.measured_subcooling(state), // Condensing saturation temperature (SDT) for head-pressure loops. MeasuredOutput::SaturationTemperature => self.measured_saturation_temperature(state), // Preserve the default Capacity/HeatTransferRate derivation. MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => self .energy_transfers(state) .map(|(heat, _work)| heat.to_watts().abs()), _ => None, } } } impl StateManageable for Condenser { fn state(&self) -> OperationalState { self.inner.state() } fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> { self.inner.set_state(state) } fn can_transition_to(&self, target: OperationalState) -> bool { self.inner.can_transition_to(target) } fn circuit_id(&self) -> &CircuitId { self.inner.circuit_id() } fn set_circuit_id(&mut self, circuit_id: CircuitId) { self.inner.set_circuit_id(circuit_id); } } #[cfg(test)] mod tests { use super::*; /// Wires secondary Air edges for unit tests that need `coupled_ready()`. /// Extends the refrigerant state with secondary air state at the given /// temperature [K] and capacity rate [W/K]. Returns the full state vector /// and calls `set_port_context` on the condenser. fn wire_secondary( cond: &mut Condenser, ref_state: &[f64], t_sec_k: f64, c_sec: f64, ref_in: (usize, usize, usize), ref_out: (usize, usize, usize), ) -> Vec { let w = 0.010_f64; let cp = 1006.0 + 1860.0 * w; // ≈1024.6 let t_c = t_sec_k - 273.15; let h_air = cp * t_c + 2_501_000.0 * w; let m_air = c_sec / cp; let n = ref_state.len(); let sec_in = (n, n + 1, n + 2); let sec_out = (n + 3, n + 4, n + 5); cond.set_secondary_fluid("Air"); cond.set_secondary_humidity_ratio(w); cond.set_port_context(&[Some(ref_in), Some(ref_out), Some(sec_in), Some(sec_out)]); let mut state = ref_state.to_vec(); state.extend_from_slice(&[ m_air, 101_325.0, h_air, m_air, 101_325.0, h_air + 5000.0, // outlet slightly warmer ]); state } #[test] fn test_condenser_creation() { let condenser = Condenser::new(10_000.0); assert_eq!(condenser.ua(), 10_000.0); // CM1.3: 2 thermo + 1 mass-flow conservation = 3 assert_eq!(condenser.n_equations(), 3); } #[test] fn test_condenser_with_saturation_temp() { let condenser = Condenser::with_saturation_temp(10_000.0, 323.15); assert_eq!(condenser.saturation_temp(), 323.15); } #[test] fn test_condenser_emergent_outlet_closure() { use std::sync::Arc; let backend = Arc::new(entropyk_fluids::TestBackend::new()); let edges = [(0usize, 1usize, 2usize), (0usize, 3usize, 4usize)]; let p_cond = 1_200_000.0_f64; let mut cond = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_emergent_pressure(0.0); cond.set_system_context(0, &edges); let h_liq = backend .property( entropyk_fluids::FluidId::new("R134a"), entropyk_fluids::Property::Enthalpy, entropyk_fluids::FluidState::PressureQuality( entropyk_core::Pressure::from_pascals(p_cond), entropyk_fluids::Quality(0.0), ), ) .unwrap(); let ref_state = vec![0.1, p_cond, 440_000.0, p_cond, h_liq]; let state = wire_secondary(&mut cond, &ref_state, 300.0, 3000.0, (0, 1, 2), (0, 3, 4)); let mut r = vec![0.0; cond.n_equations()]; cond.compute_residuals(&state, &mut r).unwrap(); let closure_row = if cond.skip_pressure_eq { 1 } else { 2 }; assert!( r[closure_row].abs() < 1.0, "outlet closure should vanish at sat-liquid: {}", r[closure_row] ); let mut state2 = state.clone(); state2[4] = h_liq + 20_000.0; cond.compute_residuals(&state2, &mut r).unwrap(); assert!( (r[closure_row] - 20_000.0).abs() < 1.0, "r2 must track outlet enthalpy: {}", r[closure_row] ); } // ---- Fan head-pressure actuator (arch-6) ---------------------------------- /// A fan-head-pressure condenser emits one extra equation (T_cond = T_target), /// closed by the fan-speed free actuator. The count must be static (present /// before the actuator index is wired) to keep DoF balanced. #[test] fn test_fan_head_pressure_adds_one_equation() { use std::sync::Arc; let backend = Arc::new(entropyk_fluids::TestBackend::new()); // Separate branches: inlet (0,1,2), outlet (3,4,5). let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)]; let mut cond = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_secondary_stream(305.0, 3000.0) .with_emergent_pressure(0.0) .with_fan_head_pressure(320.0); cond.set_system_context(0, &edges); // 3 thermo (emergent) + 1 mass conservation + 1 fan = 5. assert_eq!(cond.n_equations(), 5); assert_eq!(cond.head_pressure_row(), 4); // Static: the count holds even before the actuator index is wired. assert!(cond.fan_active()); assert!(!cond.fan_ready()); } /// The fan residual `T_cond(P_in) − T_target` reacts to the target, and the /// duty (energy-balance residual r1) reacts to the fan speed φ that scales the /// secondary air capacity rate. Genuine physics — no fixed design point. #[test] fn test_fan_head_pressure_residual_reacts_to_target_and_speed() { use std::sync::Arc; let backend = Arc::new(entropyk_fluids::TestBackend::new()); let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)]; let p_cond = 1_200_000.0_f64; let mut cond = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_secondary_stream(305.0, 3000.0) .with_emergent_pressure(0.0) .with_fan_head_pressure(320.0); cond.set_system_context(0, &edges); cond.set_calib_indices(entropyk_core::CalibIndices { actuator: Some(6), ..Default::default() }); assert!(cond.fan_ready()); // State: fan actuator φ at index 6, secondary Air edges start at index 7. let ref_state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, 1.0]; let mut state = wire_secondary(&mut cond, &ref_state, 305.0, 3000.0, (0, 1, 2), (3, 4, 5)); let t_cond = cond.cond_temperature(p_cond).unwrap(); let mut r = vec![0.0; cond.n_equations()]; cond.compute_residuals(&state, &mut r).unwrap(); // Fan residual = T_cond(P_in) − T_target. assert!( (r[cond.head_pressure_row()] - (t_cond - 320.0)).abs() < 1e-6, "fan residual must be T_cond − T_target: {}", r[cond.head_pressure_row()] ); // Halving the secondary air flow halves C_sec ⇒ less duty Q ⇒ r1 = ṁΔh − Q rises. let r1_full = r[1]; state[7] *= 0.5; // secondary inlet mass-flow slot (edge-driven C_sec) cond.compute_residuals(&state, &mut r).unwrap(); assert!( r[1] > r1_full + 1.0, "slower secondary flow ⇒ smaller duty ⇒ larger energy residual: {} !> {}", r[1], r1_full ); } /// Analytic Jacobian of the coupled fan path matches central finite differences, /// including the ∂r1/∂φ fan-coupling entry and the ∂r_fan/∂P_in entry. #[test] fn test_fan_head_pressure_jacobian_matches_finite_difference() { use std::sync::Arc; let backend = Arc::new(entropyk_fluids::TestBackend::new()); let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)]; let p_cond = 1_200_000.0_f64; let mut cond = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_secondary_stream(305.0, 3000.0) .with_emergent_pressure(0.0) .with_fan_head_pressure(320.0); cond.set_system_context(0, &edges); cond.set_calib_indices(entropyk_core::CalibIndices { actuator: Some(6), ..Default::default() }); let state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, 0.8]; let n_eq = cond.n_equations(); let n_var = state.len(); // Analytic Jacobian into a dense matrix. let mut jac = JacobianBuilder::new(); cond.jacobian_entries(&state, &mut jac).unwrap(); let mut analytic = vec![vec![0.0; n_var]; n_eq]; for &(row, col, val) in jac.entries() { if row < n_eq && col < n_var { analytic[row][col] += val; } } // Central finite differences of the residual. for col in 0..n_var { let h = (state[col].abs() * 1e-6).max(1e-3); let mut sp = state.clone(); let mut sm = state.clone(); sp[col] += h; sm[col] -= h; let mut rp = vec![0.0; n_eq]; let mut rm = vec![0.0; n_eq]; cond.compute_residuals(&sp, &mut rp).unwrap(); cond.compute_residuals(&sm, &mut rm).unwrap(); for row in 0..n_eq { let fd = (rp[row] - rm[row]) / (2.0 * h); let an = analytic[row][col]; let scale = 1.0 + fd.abs().max(an.abs()); assert!( (fd - an).abs() / scale < 1e-4, "J[{}][{}]: analytic {} vs FD {}", row, col, an, fd ); } } } /// A flooded-head-pressure condenser emits one extra equation (T_cond=T_target), /// closed by the flooding-level free actuator. The count must be static (present /// before the actuator index is wired) to keep DoF balanced. #[test] fn test_flooded_head_pressure_adds_one_equation() { use std::sync::Arc; let backend = Arc::new(entropyk_fluids::TestBackend::new()); let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)]; let mut cond = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend) .with_secondary_stream(305.0, 3000.0) .with_emergent_pressure(0.0) .with_flooded_head_pressure(320.0); cond.set_system_context(0, &edges); // 3 thermo (emergent) + 1 mass conservation + 1 flooding = 5. assert_eq!(cond.n_equations(), 5); assert_eq!(cond.head_pressure_row(), 4); // Static: active before the actuator index is wired, not yet ready. assert!(cond.flood_active()); assert!(!cond.flood_ready()); } /// The flooding residual `T_cond(P_in) − T_target` reacts to the target, and /// the duty (energy-balance residual r1) reacts to the flooded level λ that /// scales the effective conductance `UA_eff = (1 − λ)·UA` — genuine head- /// pressure control by area deactivation, not a fixed design point. #[test] fn test_flooded_head_pressure_residual_reacts_to_target_and_level() { use std::sync::Arc; let backend = Arc::new(entropyk_fluids::TestBackend::new()); let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)]; let p_cond = 1_200_000.0_f64; let mut cond = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend) .with_secondary_stream(305.0, 3000.0) .with_emergent_pressure(0.0) .with_flooded_head_pressure(320.0); cond.set_system_context(0, &edges); cond.set_calib_indices(entropyk_core::CalibIndices { actuator: Some(6), ..Default::default() }); assert!(cond.flood_ready()); // State: flooding level λ at index 6, secondary Air edges start at index 7. let ref_state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, 0.0]; let mut state = wire_secondary(&mut cond, &ref_state, 305.0, 3000.0, (0, 1, 2), (3, 4, 5)); let t_cond = cond.cond_temperature(p_cond).unwrap(); let mut r = vec![0.0; cond.n_equations()]; cond.compute_residuals(&state, &mut r).unwrap(); // Flooding residual = T_cond(P_in) − T_target. assert!( (r[cond.head_pressure_row()] - (t_cond - 320.0)).abs() < 1e-6, "flooding residual must be T_cond − T_target: {}", r[cond.head_pressure_row()] ); // Raising λ deactivates area ⇒ less duty Q ⇒ r1 = ṁΔh − Q rises. let r1_full = r[1]; state[6] = 0.5; cond.compute_residuals(&state, &mut r).unwrap(); assert!( r[1] > r1_full + 1.0, "more flooding (larger λ) ⇒ smaller duty ⇒ larger energy residual: {} !> {}", r[1], r1_full ); } /// Analytic Jacobian of the coupled flooding path matches central finite /// differences, including the ∂r1/∂λ flooding-coupling entry. #[test] fn test_flooded_head_pressure_jacobian_matches_finite_difference() { use std::sync::Arc; let backend = Arc::new(entropyk_fluids::TestBackend::new()); let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)]; let p_cond = 1_200_000.0_f64; let mut cond = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_secondary_stream(305.0, 3000.0) .with_emergent_pressure(0.0) .with_flooded_head_pressure(320.0); cond.set_system_context(0, &edges); cond.set_calib_indices(entropyk_core::CalibIndices { actuator: Some(6), ..Default::default() }); let state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, 0.35]; let n_eq = cond.n_equations(); let n_var = state.len(); let mut jac = JacobianBuilder::new(); cond.jacobian_entries(&state, &mut jac).unwrap(); let mut analytic = vec![vec![0.0; n_var]; n_eq]; for &(row, col, val) in jac.entries() { if row < n_eq && col < n_var { analytic[row][col] += val; } } for col in 0..n_var { let h = (state[col].abs() * 1e-6).max(1e-3); let mut sp = state.clone(); let mut sm = state.clone(); sp[col] += h; sm[col] -= h; let mut rp = vec![0.0; n_eq]; let mut rm = vec![0.0; n_eq]; cond.compute_residuals(&sp, &mut rp).unwrap(); cond.compute_residuals(&sm, &mut rm).unwrap(); for row in 0..n_eq { let fd = (rp[row] - rm[row]) / (2.0 * h); let an = analytic[row][col]; let scale = 1.0 + fd.abs().max(an.abs()); assert!( (fd - an).abs() / scale < 1e-4, "J[{}][{}]: analytic {} vs FD {}", row, col, an, fd ); } } } /// Condenser qualification: fixed refrigerant regime (constant condensing /// pressure), sweep the secondary (air/water) inlet temperature and flow. All /// outputs are solved from the real ε-NTU balance and must respond physically. #[test] fn test_condenser_qualification_sweep() { let backend = std::sync::Arc::new(entropyk_fluids::TestBackend::new()); let p_cond = 1_200_000.0_f64; // constant refrigerant regime (R134a ~ +46 °C) let rate_at = |t_sec_k: f64, c_sec: f64| { Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_secondary_stream(t_sec_k, c_sec) .rate(p_cond) .unwrap() }; // Sweep secondary inlet temperature at fixed flow (C_sec = 3000 W/K). // Hotter secondary ⇒ smaller ΔT ⇒ LESS heat rejected. let mut last_q = f64::INFINITY; for t_c in [20.0, 30.0, 40.0] { let r = rate_at(t_c + 273.15, 3000.0); assert!(r.effectiveness > 0.0 && r.effectiveness < 1.0); assert!( r.approach_k > 0.0, "T_cond must exceed secondary inlet temp" ); assert!(r.q_w > 0.0, "condenser must reject heat"); assert!( r.secondary_outlet_k > t_c + 273.15, "secondary must warm up" ); assert!( r.q_w < last_q - 1.0, "hotter secondary inlet must reduce duty: {} !< {}", r.q_w, last_q ); last_q = r.q_w; } // More secondary flow ⇒ effectiveness DOWN but duty UP (C_sec dominates). let t_s = 30.0 + 273.15; let r_low = rate_at(t_s, 1500.0); let r_high = rate_at(t_s, 6000.0); assert!(r_high.effectiveness < r_low.effectiveness); assert!(r_high.q_w > r_low.q_w + 1.0); // Refrigerant regime unchanged ⇒ condensing temperature constant. assert!((r_low.t_cond_k - r_high.t_cond_k).abs() < 1e-9); } /// Coupled residual path: in a full cycle the condenser duty must react to the /// secondary (air/water) inlet temperature and flow. Colder secondary ⇒ larger /// duty ⇒ the refrigerant energy-balance residual r1 = ṁ(h_in−h_out) − Q changes. #[test] fn test_condenser_coupled_residual_reacts_to_secondary() { use std::sync::Arc; // Refrigerant edges: inlet (m,p,h)=(0,1,2), outlet (m,p,h)=(3,4,5). let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)]; let p_cond = 1_200_000.0_f64; // ~46 °C condensing (R134a), in TestBackend range let mut state = vec![0.0; 6]; state[0] = 0.1; // ṁ_ref [kg/s] state[1] = p_cond; // P_in state[2] = 440_000.0; // h_in [J/kg] (superheated discharge) state[3] = 0.1; // ṁ_out state[4] = p_cond; // P_out state[5] = 260_000.0; // h_out [J/kg] let backend = Arc::new(entropyk_fluids::TestBackend::new()); let make = |t_sec_k: f64, c_sec: f64| -> (Condenser, Vec) { // Isolate secondary coupling from the default DX ΔP model. let mut cond = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_secondary_stream(t_sec_k, c_sec) .with_isobaric(); cond.set_system_context(0, &edges); let st = wire_secondary(&mut cond, &state, t_sec_k, c_sec, (0, 1, 2), (3, 4, 5)); (cond, st) }; // Cold secondary (air at 290 K) vs hot secondary (air at 310 K). let (cond_cold, state_cold) = make(290.0, 3000.0); let mut r_cold = vec![0.0; cond_cold.n_equations()]; cond_cold .compute_residuals(&state_cold, &mut r_cold) .unwrap(); let q_cold = cond_cold.coupled_duty(p_cond).unwrap(); let (cond_hot, state_hot) = make(310.0, 3000.0); let mut r_hot = vec![0.0; cond_hot.n_equations()]; cond_hot.compute_residuals(&state_hot, &mut r_hot).unwrap(); let q_hot = cond_hot.coupled_duty(p_cond).unwrap(); assert!( q_cold > 0.0, "condenser must reject heat to colder secondary" ); assert!( q_cold > q_hot + 1.0, "colder secondary must increase duty: q_cold={q_cold}, q_hot={q_hot}" ); // r1 = ṁ(h_in−h_out) − Q grows as Q drops (hotter secondary). assert!( r_hot[1] > r_cold[1] + 1.0, "energy-balance residual must change with secondary temp: r_hot={}, r_cold={}", r_hot[1], r_cold[1] ); // Pressure closure unaffected. assert!(r_cold[0].abs() < 1e-9 && r_hot[0].abs() < 1e-9); // More secondary flow ⇒ more duty (C_sec dominates). let (cond_highflow, _) = make(290.0, 9000.0); let q_highflow = cond_highflow.coupled_duty(p_cond).unwrap(); assert!( q_highflow > q_cold + 1.0, "more secondary flow must increase duty: q_highflow={q_highflow}, q_cold={q_cold}" ); } /// Opt-in two-phase pressure drop: with a drop coefficient the coupled r0 /// residual reflects P_out = P_in − k·ṁ·|ṁ|, and the analytic ∂r0/∂ṁ matches /// a central finite difference. Without a coefficient r0 is unchanged. #[test] fn test_condenser_pressure_drop_residual_and_jacobian() { use crate::JacobianBuilder; use std::sync::Arc; let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)]; let p_cond = 1_200_000.0_f64; let mut state = vec![0.0; 6]; state[0] = 0.12; // ṁ_ref state[1] = p_cond; // P_in state[2] = 440_000.0; // h_in state[3] = 0.12; // ṁ_out state[4] = p_cond; // P_out state[5] = 260_000.0; // h_out let backend = Arc::new(entropyk_fluids::TestBackend::new()); let k = 5.0e6; // Pa·s²/kg² ⇒ ΔP = k·ṁ² = 5e6·0.0144 = 72 kPa // Baseline (explicit isobaric): r0 = P_out − P_in = 0. let mut cond0 = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_isobaric(); cond0.set_system_context(0, &edges); let state0 = wire_secondary(&mut cond0, &state, 300.0, 3000.0, (0, 1, 2), (3, 4, 5)); let mut r0v = vec![0.0; cond0.n_equations()]; cond0.compute_residuals(&state0, &mut r0v).unwrap(); assert!(r0v[0].abs() < 1e-9, "no-drop r0 must be ~0, got {}", r0v[0]); // With drop: r0 = P_out − (P_in − ΔP) = ΔP = k·ṁ². let mut cond = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_pressure_drop_coeff(k); cond.set_system_context(0, &edges); let state = wire_secondary(&mut cond, &state, 300.0, 3000.0, (0, 1, 2), (3, 4, 5)); let mut r = vec![0.0; cond.n_equations()]; cond.compute_residuals(&state, &mut r).unwrap(); let expected_dp = k * state[0] * state[0]; assert!( (r[0] - expected_dp).abs() < 1.0, "r0 must equal ΔP={expected_dp}, got {}", r[0] ); // Analytic ∂r0/∂ṁ vs central finite difference on the mass-flow slot. let mut jb = JacobianBuilder::new(); cond.jacobian_entries(&state, &mut jb).unwrap(); let analytic: f64 = jb .entries() .iter() .filter(|(row, col, _)| *row == 0 && *col == 0) .map(|(_, _, v)| *v) .sum(); let eps = 1e-6; let mut sp = state.clone(); let mut sm = state.clone(); sp[0] += eps; sm[0] -= eps; let n_eq = cond.n_equations(); let (mut rp, mut rm) = (vec![0.0; n_eq], vec![0.0; n_eq]); cond.compute_residuals(&sp, &mut rp).unwrap(); cond.compute_residuals(&sm, &mut rm).unwrap(); let fd = (rp[0] - rm[0]) / (2.0 * eps); assert!( (analytic - fd).abs() < 1e-2 * fd.abs().max(1.0), "∂r0/∂ṁ analytic={analytic} vs fd={fd}" ); } #[test] fn test_validate_outlet_quality_fully_condensed() { let condenser = Condenser::new(10_000.0); let h_liquid = 200_000.0; let h_vapor = 400_000.0; let outlet_h = 200_000.0; let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor); assert!(result.is_ok()); } #[test] fn test_validate_outlet_quality_subcooled() { let condenser = Condenser::new(10_000.0); let h_liquid = 200_000.0; let h_vapor = 400_000.0; let outlet_h = 180_000.0; let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor); assert!(result.is_ok()); } #[test] fn test_validate_outlet_quality_two_phase() { let condenser = Condenser::new(10_000.0); let h_liquid = 200_000.0; let h_vapor = 400_000.0; let outlet_h = 300_000.0; let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor); assert!(result.is_err()); } #[test] fn test_validate_outlet_quality_superheated() { let condenser = Condenser::new(10_000.0); let h_liquid = 200_000.0; let h_vapor = 400_000.0; let outlet_h = 450_000.0; let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor); assert!(result.is_err()); } #[test] fn test_compute_residuals() { let condenser = Condenser::new(10_000.0); let state = vec![0.0; 10]; let mut residuals = vec![0.0; 3]; let result = condenser.compute_residuals(&state, &mut residuals); assert!(matches!(result, Err(ComponentError::InvalidState(_)))); } // ---- Modelica-style 4-port mode ---------------------------------------- /// Builds a 4-port condenser wired via `set_port_context`: /// refrigerant inlet (0,1,2) → outlet (3,4,5), /// secondary (moist air) inlet (6,7,8) → outlet (9,10,11). fn make_4port_condenser() -> Condenser { use std::sync::Arc; let backend = Arc::new(entropyk_fluids::TestBackend::new()); let mut cond = Condenser::new(10_000.0) .with_refrigerant("R134a") .with_fluid_backend(backend) .with_secondary_fluid("Air"); cond.set_secondary_humidity_ratio(0.010); cond.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]); cond.set_port_context(&[ Some((0, 1, 2)), Some((3, 4, 5)), Some((6, 7, 8)), Some((9, 10, 11)), ]); cond } /// Physically-plausible 12-slot state for the 4-port condenser. fn make_4port_state() -> Vec { let w = 0.010; let cp_air = 1006.0 + 1860.0 * w; let h_air = |t_c: f64| cp_air * t_c + 2_501_000.0 * w; vec![ 0.12, // 0: ṁ_ref,in 1_200_000.0, // 1: P_ref,in 440_000.0, // 2: h_ref,in (superheated discharge) 0.12, // 3: ṁ_ref,out 1_200_000.0, // 4: P_ref,out 260_000.0, // 5: h_ref,out (subcooled liquid) 2.5, // 6: ṁ_air,in 101_325.0, // 7: P_air,in h_air(30.0), // 8: h_air,in (30 °C) 2.5, // 9: ṁ_air,out 101_325.0, // 10: P_air,out h_air(38.0), // 11: h_air,out (38 °C) ] } /// 4-port mode adds 3 secondary rows (P + mass + energy) and exposes named ports. #[test] fn test_condenser_4port_equations_and_ports() { let cond = make_4port_condenser(); // 2 thermo + 1 refrigerant mass + 3 secondary = 6. assert_eq!(cond.n_equations(), 6); assert_eq!( cond.port_names(), vec!["inlet", "outlet", "secondary_inlet", "secondary_outlet"] ); } /// Secondary energy balance: ṁ_air·(h_out − h_in) = Q, with Q the coupled /// ε-NTU duty driven by the LIVE air-edge state (temperature + flow). #[test] fn test_condenser_4port_secondary_energy_balance() { let cond = make_4port_condenser(); let state = make_4port_state(); let mut r = vec![0.0; cond.n_equations()]; cond.compute_residuals(&state, &mut r).unwrap(); // Row 2: refrigerant mass conservation (indices 3 vs 0 → 0). assert!(r[2].abs() < 1e-12, "ref mass row: {}", r[2]); // Row 3: secondary isobaric pressure. assert!(r[3].abs() < 1e-12, "sec P row: {}", r[3]); // Row 4: secondary mass conservation. assert!(r[4].abs() < 1e-12, "sec mass row: {}", r[4]); // Row 5 must equal ṁ_air·Δh_air − Q where Q = ε·C_sec·(T_cond − T_air,in). let w = 0.010; let cp_air = 1006.0 + 1860.0 * w; let c_sec = state[6] * cp_air; let t_air_in = (state[8] - 2_501_000.0 * w) / cp_air + 273.15; let t_cond = cond.cond_temperature(state[1]).unwrap(); let eps = cond.effectiveness(c_sec); let q = eps * c_sec * (t_cond - t_air_in); assert!(q > 0.0, "condenser must reject heat: q={q}"); let expected = state[6] * (state[11] - state[8]) - q; assert!( (r[5] - expected).abs() < 1e-6 * expected.abs().max(1.0), "sec energy row: {} vs expected {}", r[5], expected ); // Refrigerant energy balance row must carry the SAME duty. let expected_r1 = state[0] * (state[2] - state[5]) - q; assert!( (r[1] - expected_r1).abs() < 1e-6 * expected_r1.abs().max(1.0), "ref energy row: {} vs expected {}", r[1], expected_r1 ); } /// Full Jacobian of the 4-port condenser vs central finite differences on /// every (row, column) pair — validates all analytic cross-derivatives /// (secondary ṁ/h into both energy rows, refrigerant P into the secondary row). #[test] fn test_condenser_4port_jacobian_vs_finite_differences() { use crate::JacobianBuilder; let cond = make_4port_condenser(); let state = make_4port_state(); let n_eq = cond.n_equations(); let n_state = state.len(); let mut jb = JacobianBuilder::new(); cond.jacobian_entries(&state, &mut jb).unwrap(); let mut analytic = vec![vec![0.0_f64; n_state]; n_eq]; for &(row, col, v) in jb.entries() { if row < n_eq && col < n_state { analytic[row][col] += v; } } for col in 0..n_state { // Scale-aware step: pressures need a large step, enthalpies medium. let eps = (state[col].abs() * 1e-6).max(1e-7); let (mut sp, mut sm) = (state.clone(), state.clone()); sp[col] += eps; sm[col] -= eps; let (mut rp, mut rm) = (vec![0.0; n_eq], vec![0.0; n_eq]); cond.compute_residuals(&sp, &mut rp).unwrap(); cond.compute_residuals(&sm, &mut rm).unwrap(); for row in 0..n_eq { let fd = (rp[row] - rm[row]) / (2.0 * eps); let a = analytic[row][col]; let tol = 1e-3 * fd.abs().max(a.abs()).max(1e-6); assert!( (a - fd).abs() <= tol.max(1e-6), "J[{row}][{col}]: analytic={a} vs fd={fd}" ); } } } }