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

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

2645 lines
108 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<LmtdModel>,
saturation_temp: f64,
refrigerant_id: String,
fluid_backend: Option<Arc<dyn FluidBackend>>,
inlet_p_idx: Option<usize>,
/// Refrigerant inlet enthalpy state index (needed for the coupled energy balance).
inlet_h_idx: Option<usize>,
outlet_p_idx: Option<usize>,
outlet_h_idx: Option<usize>,
/// Mass-flow state index of the inlet edge (ṁ unknown, CM1.3).
inlet_m_idx: Option<usize>,
/// Mass-flow state index of the outlet edge (ṁ unknown, CM1.3).
outlet_m_idx: Option<usize>,
/// 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<f64>,
/// Secondary heat-capacity rate ṁ·cp [W/K] for the coupled ε-NTU model.
secondary_capacity_rate: Option<f64>,
/// 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<f64>,
/// Tube-side two-phase correlation (MSH / Friedel). Requires geometry.
tube_dp_correlation: Option<crate::heat_exchanger::two_phase_dp::TwoPhaseDpCorrelation>,
/// Tube channel geometry for frictional + acceleration ΔP.
tube_dp_geometry: Option<crate::heat_exchanger::two_phase_dp::TubeChannelGeometry>,
/// 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<f64>,
/// Free-actuator state index of the fan speed ratio `φ`, wired from the
/// solver via `CalibIndices.actuator` in `finalize()`.
fan_actuator_idx: Option<usize>,
/// 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<f64>,
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<dyn FluidBackend>) -> 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<String>) -> 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<String>) {
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<f64, ComponentError> {
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<f64, ComponentError> {
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<f64, ComponentError> {
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<f64, ComponentError> {
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<f64>,
) {
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<SecondaryJacCtx>,
) {
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<f64> {
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<usize> {
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<f64> {
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<crate::heat_exchanger::two_phase_dp::SatTransportProps> {
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<f64, ComponentError> {
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<f64, ComponentError> {
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<f64> {
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<f64> {
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<f64, ComponentError> {
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<f64, ComponentError> {
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<f64, ComponentError> {
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<CondenserRating, ComponentError> {
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<bool, ComponentError> {
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<f64, ComponentError> {
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<entropyk_fluids::ThermoState, ComponentError> {
self.inner.hot_inlet_state()
}
/// Computes the full thermodynamic state at the cold inlet.
pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
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_outconst,
// r1=P_inconst, r2=H_outconst.
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<dyn FluidBackend>) {
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<crate::EquationRole> {
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<String> {
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<Vec<entropyk_core::MassFlow>, ComponentError> {
self.inner.port_mass_flows(state)
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, 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<f64> {
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<f64> {
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_inh_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<f64>) {
// 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_inh_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<f64> {
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}"
);
}
}
}
}