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>
2645 lines
108 KiB
Rust
2645 lines
108 KiB
Rust
//! 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_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<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_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<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_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<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}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|