Files
Entropyk/crates/components/src/heat_exchanger/exchanger.rs
sepehr 44f793a583
Some checks failed
CI / check (push) Has been cancelled
Wire BPHX channel pressure drop on both sides with selectable correlations.
Replace isobaric 4-port closures with SimplifiedChannel (default) and Martin1996 DP models so z_dp and UI dp_correlation actually affect the Newton solve.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:52:25 +02:00

1558 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Generic Heat Exchanger Component
//!
//! A heat exchanger with 4 ports (hot inlet, hot outlet, cold inlet, cold outlet)
//! and a pluggable heat transfer model.
//!
//! ## Fluid Backend Integration (Story 5.1)
//!
//! `compute_residuals` requires live four-port edge state. Inlet-only boundary
//! conditions may be used for property inspection, but they are not enough to
//! synthesize outlet states.
use super::model::{FluidState, HeatTransferModel};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, DomainViolation, JacobianBuilder, ResidualVector,
StateSlice,
};
use entropyk_core::{Calib, MassFlow, Pressure, Temperature};
use entropyk_fluids::{FluidBackend, FluidId as FluidsFluidId, Property, ThermoState};
use std::marker::PhantomData;
use std::sync::Arc;
/// Builder for creating a heat exchanger with disconnected ports.
pub struct HeatExchangerBuilder<Model: HeatTransferModel> {
model: Model,
name: String,
circuit_id: CircuitId,
}
impl<Model: HeatTransferModel + 'static> HeatExchangerBuilder<Model> {
/// Creates a new builder.
pub fn new(model: Model) -> Self {
Self {
model,
name: String::from("HeatExchanger"),
circuit_id: CircuitId::default(),
}
}
/// Sets the name.
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
/// Sets the circuit identifier.
pub fn circuit_id(mut self, circuit_id: CircuitId) -> Self {
self.circuit_id = circuit_id;
self
}
/// Builds the heat exchanger. Topology is injected later by name/context.
pub fn build(self) -> HeatExchanger<Model> {
HeatExchanger::new(self.model, self.name).with_circuit_id(self.circuit_id)
}
}
/// Generic heat exchanger component with 4 ports.
///
/// Uses the Strategy Pattern for heat transfer calculations via the
/// `HeatTransferModel` trait.
///
/// # Type Parameters
///
/// * `Model` - The heat transfer model (LmtdModel, EpsNtuModel, etc.)
///
/// # Ports
///
/// - `hot_inlet`: Hot fluid inlet
/// - `hot_outlet`: Hot fluid outlet
/// - `cold_inlet`: Cold fluid inlet
/// - `cold_outlet`: Cold fluid outlet
///
/// # Equations
///
/// The heat exchanger contributes 3 residual equations:
/// 1. Hot side energy balance
/// 2. Cold side energy balance
/// 3. Energy conservation (Q_hot = Q_cold)
///
/// # Operational States
///
/// - **On**: Normal heat transfer operation
/// - **Off**: Zero mass flow on both sides, no heat transfer
/// - **Bypass**: Mass flow continues, no heat transfer (adiabatic)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::{HeatExchanger, LmtdModel, FlowConfiguration};
/// use entropyk_components::Component;
///
/// let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
/// let hx = HeatExchanger::new(model, "Condenser");
/// assert_eq!(hx.n_equations(), 2);
/// ```
/// Boundary conditions for one side of the heat exchanger.
///
/// Specifies the inlet state for a fluid stream: temperature, pressure, mass flow,
/// and the fluid identity used to query thermodynamic properties from the backend.
#[derive(Debug, Clone)]
pub struct HxSideConditions {
temperature_k: f64,
pressure_pa: f64,
mass_flow_kg_s: f64,
fluid_id: FluidsFluidId,
}
impl HxSideConditions {
/// Returns the inlet temperature in Kelvin.
pub fn temperature_k(&self) -> f64 {
self.temperature_k
}
/// Returns the inlet pressure in Pascals.
pub fn pressure_pa(&self) -> f64 {
self.pressure_pa
}
/// Returns the mass flow rate in kg/s.
pub fn mass_flow_kg_s(&self) -> f64 {
self.mass_flow_kg_s
}
/// Returns a reference to the fluid identifier.
pub fn fluid_id(&self) -> &FluidsFluidId {
&self.fluid_id
}
}
impl HxSideConditions {
/// Creates a new set of boundary conditions.
pub fn new(
temperature: Temperature,
pressure: Pressure,
mass_flow: MassFlow,
fluid_id: impl Into<String>,
) -> Result<Self, ComponentError> {
let t = temperature.to_kelvin();
let p = pressure.to_pascals();
let m = mass_flow.to_kg_per_s();
// Basic validation for physically plausible states
if t <= 0.0 {
return Err(ComponentError::InvalidState(
"Temperature must be greater than 0 K".to_string(),
));
}
if p <= 0.0 {
return Err(ComponentError::InvalidState(
"Pressure must be strictly positive".to_string(),
));
}
if m < 0.0 {
return Err(ComponentError::InvalidState(
"Mass flow must be non-negative".to_string(),
));
}
Ok(Self {
temperature_k: t,
pressure_pa: p,
mass_flow_kg_s: m,
fluid_id: FluidsFluidId::new(fluid_id),
})
}
}
/// Live four-port edge indices: each triple is `(ṁ_idx, P_idx, h_idx)`.
#[derive(Debug, Clone, Copy)]
pub(crate) struct FourPortEdgeIndices {
pub hot_in: (usize, usize, usize),
pub hot_out: (usize, usize, usize),
pub cold_in: (usize, usize, usize),
pub cold_out: (usize, usize, usize),
}
/// Generic heat exchanger component with 4 ports.
///
/// Uses the Strategy Pattern for heat transfer calculations via the
/// `HeatTransferModel` trait. When a `FluidBackend` is attached via
/// [`with_fluid_backend`](Self::with_fluid_backend), the `compute_residuals`
/// method queries real thermodynamic properties (Cp, h) from the live edge
/// state instead of using hardcoded placeholder values.
pub struct HeatExchanger<Model: HeatTransferModel> {
model: Model,
name: String,
/// Calibration: f_dp for refrigerant-side ΔP when modeled, f_ua for UA scaling
calib: Calib,
/// Indices for dynamically extracting calibration factors from the system state
calib_indices: entropyk_core::CalibIndices,
operational_state: OperationalState,
circuit_id: CircuitId,
/// Optional fluid property backend for real thermodynamic calculations (Story 5.1).
fluid_backend: Option<Arc<dyn FluidBackend>>,
/// Boundary conditions for the hot side inlet.
hot_conditions: Option<HxSideConditions>,
/// Boundary conditions for the cold side inlet.
cold_conditions: Option<HxSideConditions>,
// ── 4-port (Modelica-style) edge-driven mode ───────────────────────────
/// Hot inlet edge state indices (m, p, h). Wired by `set_port_context` port 0.
hot_in_idx: Option<(usize, usize, usize)>,
/// Hot outlet edge state indices. Wired by `set_port_context` port 1.
hot_out_idx: Option<(usize, usize, usize)>,
/// Cold inlet edge state indices. Wired by `set_port_context` port 2.
cold_in_idx: Option<(usize, usize, usize)>,
/// Cold outlet edge state indices. Wired by `set_port_context` port 3.
cold_out_idx: Option<(usize, usize, usize)>,
/// Hot-side fluid identifier ("Water", "Air", "INCOMP::MEG-30"…).
hot_fluid_id_str: String,
/// Cold-side fluid identifier.
cold_fluid_id_str: String,
/// Humidity ratio for moist-air hot side (0 = dry).
hot_humidity_ratio: f64,
/// Humidity ratio for moist-air cold side.
cold_humidity_ratio: f64,
_phantom: PhantomData<()>,
}
impl<Model: HeatTransferModel + std::fmt::Debug> std::fmt::Debug for HeatExchanger<Model> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HeatExchanger")
.field("name", &self.name)
.field("model", &self.model)
.field("calib", &self.calib)
.field("operational_state", &self.operational_state)
.field("circuit_id", &self.circuit_id)
.field("has_fluid_backend", &self.fluid_backend.is_some())
.finish()
}
}
impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
/// Creates a new heat exchanger with the given model.
pub fn new(mut model: Model, name: impl Into<String>) -> Self {
let calib = Calib::default();
model.set_ua_scale(calib.z_ua);
Self {
model,
name: name.into(),
calib,
calib_indices: entropyk_core::CalibIndices::default(),
operational_state: OperationalState::default(),
circuit_id: CircuitId::default(),
fluid_backend: None,
hot_conditions: None,
cold_conditions: None,
hot_in_idx: None,
hot_out_idx: None,
cold_in_idx: None,
cold_out_idx: None,
hot_fluid_id_str: String::new(),
cold_fluid_id_str: String::new(),
hot_humidity_ratio: 0.0,
cold_humidity_ratio: 0.0,
_phantom: PhantomData,
}
}
/// Attaches a `FluidBackend` so `compute_residuals` can query real thermodynamic properties.
///
/// # Example
///
/// ```no_run
/// use entropyk_components::heat_exchanger::{HeatExchanger, LmtdModel, FlowConfiguration, HxSideConditions};
/// use entropyk_fluids::{TestBackend, FluidId};
/// use entropyk_core::{Temperature, Pressure, MassFlow};
/// use std::sync::Arc;
///
/// let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
/// let hx = HeatExchanger::new(model, "Condenser")
/// .with_fluid_backend(Arc::new(TestBackend::new()))
/// .with_hot_conditions(HxSideConditions::new(
/// Temperature::from_celsius(60.0),
/// Pressure::from_bar(25.0),
/// MassFlow::from_kg_per_s(0.05),
/// "R410A",
/// ).unwrap())
/// .with_cold_conditions(HxSideConditions::new(
/// Temperature::from_celsius(30.0),
/// Pressure::from_bar(1.5),
/// MassFlow::from_kg_per_s(0.2),
/// "Water",
/// ).unwrap());
/// ```
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
self.fluid_backend = Some(backend);
self
}
/// Sets the hot side boundary conditions for fluid property queries.
pub fn with_hot_conditions(mut self, conditions: HxSideConditions) -> Self {
self.hot_conditions = Some(conditions);
self
}
/// Sets the cold side boundary conditions for fluid property queries.
pub fn with_cold_conditions(mut self, conditions: HxSideConditions) -> Self {
self.cold_conditions = Some(conditions);
self
}
/// Sets the hot side boundary conditions (mutable).
pub fn set_hot_conditions(&mut self, conditions: HxSideConditions) {
self.hot_conditions = Some(conditions);
}
/// Sets the cold side boundary conditions (mutable).
pub fn set_cold_conditions(&mut self, conditions: HxSideConditions) {
self.cold_conditions = Some(conditions);
}
/// Attaches a fluid backend (mutable).
pub fn set_fluid_backend(&mut self, backend: Arc<dyn FluidBackend>) {
self.fluid_backend = Some(backend);
}
/// Returns true if a real `FluidBackend` is attached.
pub fn has_fluid_backend(&self) -> bool {
self.fluid_backend.is_some()
}
/// Returns the hot side fluid identifier, if set.
pub fn hot_conditions(&self) -> Option<&HxSideConditions> {
self.hot_conditions.as_ref()
}
/// Documentation pending
pub fn cold_conditions(&self) -> Option<&HxSideConditions> {
self.cold_conditions.as_ref()
}
/// Documentation pending
pub fn hot_fluid_id(&self) -> Option<&FluidsFluidId> {
self.hot_conditions.as_ref().map(|c| c.fluid_id())
}
/// Returns the cold side fluid identifier, if set.
pub fn cold_fluid_id(&self) -> Option<&FluidsFluidId> {
self.cold_conditions.as_ref().map(|c| c.fluid_id())
}
/// Computes the full thermodynamic state at the hot inlet.
pub fn hot_inlet_state(&self) -> Result<ThermoState, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed("No FluidBackend configured".to_string())
})?;
let conditions = self.hot_conditions.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed("Hot conditions not set".to_string())
})?;
let h = self.query_enthalpy(conditions)?;
backend
.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"Failed to compute hot inlet state: {}",
e
))
})
}
/// Computes the full thermodynamic state at the cold inlet.
pub fn cold_inlet_state(&self) -> Result<ThermoState, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed("No FluidBackend configured".to_string())
})?;
let conditions = self.cold_conditions.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed("Cold conditions not set".to_string())
})?;
let h = self.query_enthalpy(conditions)?;
backend
.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"Failed to compute cold inlet state: {}",
e
))
})
}
/// Queries Cp (J/(kg·K)) from the backend for a given side.
#[allow(dead_code)]
fn query_cp(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
if let Some(backend) = &self.fluid_backend {
let state = entropyk_fluids::FluidState::from_pt(
Pressure::from_pascals(conditions.pressure_pa()),
Temperature::from_kelvin(conditions.temperature_k()),
);
backend
.property(conditions.fluid_id().clone(), Property::Cp, state) // Need to clone FluidId because trait signature requires it for now? Actually FluidId can be cloned cheaply depending on its implementation. We'll leave the clone if required by `property()`. Let's assume it is.
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"FluidBackend Cp query failed: {}",
e
))
})
} else {
Err(ComponentError::CalculationFailed(
"No FluidBackend configured".to_string(),
))
}
}
/// Queries specific enthalpy (J/kg) from the backend for a given side at (P, T).
fn query_enthalpy(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
if let Some(backend) = &self.fluid_backend {
let state = entropyk_fluids::FluidState::from_pt(
Pressure::from_pascals(conditions.pressure_pa()),
Temperature::from_kelvin(conditions.temperature_k()),
);
backend
.property(conditions.fluid_id().clone(), Property::Enthalpy, state)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"FluidBackend Enthalpy query failed: {}",
e
))
})
} else {
Err(ComponentError::CalculationFailed(
"No FluidBackend configured".to_string(),
))
}
}
/// Sets the circuit identifier and returns self.
pub fn with_circuit_id(mut self, circuit_id: CircuitId) -> Self {
self.circuit_id = circuit_id;
self
}
/// Returns the name of this heat exchanger.
pub fn name(&self) -> &str {
&self.name
}
/// Returns the effective UA value (f_ua × UA_nominal).
pub fn ua(&self) -> f64 {
self.model.effective_ua(None)
}
/// Returns the nominal (base) UA value [W/K] before any scaling.
pub fn ua_nominal(&self) -> f64 {
self.model.ua()
}
/// Sets the UA scale factor directly (UA_eff = scale × UA_nominal).
///
/// Used by `MchxCondenserCoil` to apply fan-speed and air-density corrections
/// without rebuilding the component.
pub fn set_ua_scale(&mut self, scale: f64) {
self.model.set_ua_scale(scale.max(0.0));
}
/// Returns the current operational state.
pub fn operational_state(&self) -> OperationalState {
self.operational_state
}
/// Sets the operational state.
pub fn set_operational_state(&mut self, state: OperationalState) {
self.operational_state = state;
}
/// Returns the circuit identifier.
pub fn circuit_id(&self) -> &CircuitId {
&self.circuit_id
}
/// Sets the circuit identifier.
pub fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.circuit_id = circuit_id;
}
/// Returns calibration factors (f_dp for refrigerant-side ΔP when modeled, f_ua for UA).
pub fn calib(&self) -> &Calib {
&self.calib
}
/// Sets calibration factors.
pub fn set_calib(&mut self, calib: Calib) {
self.model.set_ua_scale(calib.z_ua);
self.calib = calib;
}
// ── 4-port (Modelica-style) configuration ───────────────────────────────
/// Declares the hot-side fluid for edge-driven 4-port mode ("Water", "Air",
/// "INCOMP::MEG-30"…). When hot-side edges are wired (ports 0 and 1), the
/// HX reads T and cp from the live edge state via the backend.
pub fn with_hot_fluid(mut self, fluid: impl Into<String>) -> Self {
self.hot_fluid_id_str = fluid.into();
self
}
/// Declares the cold-side fluid for edge-driven 4-port mode.
pub fn with_cold_fluid(mut self, fluid: impl Into<String>) -> Self {
self.cold_fluid_id_str = fluid.into();
self
}
/// Sets the hot-side fluid identifier (see [`with_hot_fluid`]).
pub fn set_hot_fluid(&mut self, fluid: impl Into<String>) {
self.hot_fluid_id_str = fluid.into();
}
/// Sets the cold-side fluid identifier.
pub fn set_cold_fluid(&mut self, fluid: impl Into<String>) {
self.cold_fluid_id_str = fluid.into();
}
/// Sets the humidity ratio for the hot side (moist air).
pub fn set_hot_humidity_ratio(&mut self, w: f64) {
self.hot_humidity_ratio = w.max(0.0);
}
/// Sets the humidity ratio for the cold side (moist air).
pub fn set_cold_humidity_ratio(&mut self, w: f64) {
self.cold_humidity_ratio = w.max(0.0);
}
/// `true` when all 4 edges are wired (Modelica-style 4-port mode).
pub(crate) fn edges_ready(&self) -> bool {
self.hot_in_idx.is_some()
&& self.hot_out_idx.is_some()
&& self.cold_in_idx.is_some()
&& self.cold_out_idx.is_some()
&& !self.hot_fluid_id_str.is_empty()
&& !self.cold_fluid_id_str.is_empty()
}
/// Live four-port edge index triples `(ṁ, P, h)` when wired.
pub(crate) fn four_port_edges(&self) -> Option<FourPortEdgeIndices> {
if !self.edges_ready() {
return None;
}
Some(FourPortEdgeIndices {
hot_in: self.hot_in_idx?,
hot_out: self.hot_out_idx?,
cold_in: self.cold_in_idx?,
cold_out: self.cold_out_idx?,
})
}
/// Thermal-model equation count (excludes the two pressure-closure rows).
pub(crate) fn model_n_equations(&self) -> usize {
self.model.n_equations()
}
/// Calibration state indices (e.g. free `z_dp` actuator).
pub(crate) fn calib_indices_ref(&self) -> &entropyk_core::CalibIndices {
&self.calib_indices
}
/// Hot-side fluid id used for live property queries.
pub(crate) fn hot_fluid_id_str(&self) -> &str {
&self.hot_fluid_id_str
}
/// Cold-side fluid id used for live property queries.
pub(crate) fn cold_fluid_id_str(&self) -> &str {
&self.cold_fluid_id_str
}
/// Inlet density [kg/m³] from the attached fluid backend at `(P, h)`.
pub(crate) fn side_density(
&self,
side: &str,
fluid_id: &str,
p_pa: f64,
h_jkg: f64,
) -> Result<f64, ComponentError> {
self.query_live_property(side, fluid_id, Property::Density, p_pa, h_jkg)
.and_then(|rho| {
if rho.is_finite() && rho > 1e-10 {
Ok(rho)
} else {
Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!("{} {}-side density is invalid: {}", self.name, side, rho),
}))
}
})
}
fn live_state_required_error(&self) -> ComponentError {
ComponentError::InvalidState(format!(
"{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",
self.name
))
}
pub(crate) fn live_fluid_states(
&self,
state: &StateSlice,
) -> Result<(FluidState, FluidState, FluidState, FluidState), ComponentError> {
if !self.edges_ready() {
return Err(self.live_state_required_error());
}
let (m_h, p_h_in, h_h_in) = self.hot_in_idx.unwrap();
let (m_h_out, p_h_out, h_h_out) = self.hot_out_idx.unwrap();
let (m_c, p_c_in, h_c_in) = self.cold_in_idx.unwrap();
let (m_c_out, p_c_out, h_c_out) = self.cold_out_idx.unwrap();
let max_idx = [
m_h, p_h_in, h_h_in, m_h_out, p_h_out, h_h_out, m_c, p_c_in, h_c_in, m_c_out, p_c_out,
h_c_out,
]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
let hot_cp_in = self.hot_cp(state[p_h_in], state[h_h_in])?;
let hot_cp_out = self.hot_cp(state[p_h_out], state[h_h_out])?;
let cold_cp_in = self.cold_cp(state[p_c_in], state[h_c_in])?;
let cold_cp_out = self.cold_cp(state[p_c_out], state[h_c_out])?;
let hot_t_in = self.hot_temperature(state[p_h_in], state[h_h_in])?;
let hot_t_out = self.hot_temperature(state[p_h_out], state[h_h_out])?;
let cold_t_in = self.cold_temperature(state[p_c_in], state[h_c_in])?;
let cold_t_out = self.cold_temperature(state[p_c_out], state[h_c_out])?;
let m_hot = state[m_h].max(0.0);
let m_cold = state[m_c].max(0.0);
Ok((
Self::create_fluid_state(hot_t_in, state[p_h_in], state[h_h_in], m_hot, hot_cp_in),
Self::create_fluid_state(hot_t_out, state[p_h_out], state[h_h_out], m_hot, hot_cp_out),
Self::create_fluid_state(cold_t_in, state[p_c_in], state[h_c_in], m_cold, cold_cp_in),
Self::create_fluid_state(
cold_t_out,
state[p_c_out],
state[h_c_out],
m_cold,
cold_cp_out,
),
))
}
/// `true` when the hot-side fluid follows the moist-air convention.
fn hot_is_air(&self) -> bool {
let f = self.hot_fluid_id_str.trim();
f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
}
/// `true` when the cold-side fluid follows the moist-air convention.
fn cold_is_air(&self) -> bool {
let f = self.cold_fluid_id_str.trim();
f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
}
/// Hot-side cp [J/(kg·K)] at (P, h). Moist air uses the psychrometric cp;
/// other fluids query the backend.
fn hot_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.hot_is_air() {
return Ok(1006.0 + 1860.0 * self.hot_humidity_ratio);
}
self.query_live_property("hot", &self.hot_fluid_id_str, Property::Cp, p_pa, h_jkg)
.and_then(|cp| {
if cp.is_finite() && cp > 0.0 {
Ok(cp)
} else {
// A non-finite Cp means the trial (P, h) state left the
// fluid model's valid envelope (e.g. two-phase region): a
// recoverable domain violation (KINSOL `> 0`).
Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!("{} hot-side Cp is invalid: {}", self.name, cp),
}))
}
})
}
/// Cold-side cp [J/(kg·K)] at (P, h).
fn cold_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.cold_is_air() {
return Ok(1006.0 + 1860.0 * self.cold_humidity_ratio);
}
self.query_live_property("cold", &self.cold_fluid_id_str, Property::Cp, p_pa, h_jkg)
.and_then(|cp| {
if cp.is_finite() && cp > 0.0 {
Ok(cp)
} else {
// A non-finite Cp means the trial (P, h) state left the
// fluid model's valid envelope (e.g. two-phase region): a
// recoverable domain violation (KINSOL `> 0`).
Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!("{} cold-side Cp is invalid: {}", self.name, cp),
}))
}
})
}
/// Hot-side temperature [K] at (P, h). Moist air uses the linear psychrometric
/// inversion; other fluids query the backend T(P,h).
fn hot_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.hot_is_air() {
let w = self.hot_humidity_ratio;
let cp = 1006.0 + 1860.0 * w;
return Ok((h_jkg - 2_501_000.0 * w) / cp + 273.15);
}
self.query_live_property(
"hot",
&self.hot_fluid_id_str,
Property::Temperature,
p_pa,
h_jkg,
)
.and_then(|t| {
if t.is_finite() && t > 0.0 {
Ok(t)
} else {
// Non-finite/negative T at a trial state = recoverable domain
// violation (same class as the Cp check), not a fatal defect.
Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!("{} hot-side temperature is invalid: {}", self.name, t),
}))
}
})
}
/// Cold-side temperature [K] at (P, h).
fn cold_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.cold_is_air() {
let w = self.cold_humidity_ratio;
let cp = 1006.0 + 1860.0 * w;
return Ok((h_jkg - 2_501_000.0 * w) / cp + 273.15);
}
self.query_live_property(
"cold",
&self.cold_fluid_id_str,
Property::Temperature,
p_pa,
h_jkg,
)
.and_then(|t| {
if t.is_finite() && t > 0.0 {
Ok(t)
} else {
// Non-finite/negative T at a trial state = recoverable domain
// violation (same class as the Cp check), not a fatal defect.
Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!("{} cold-side temperature is invalid: {}", self.name, t),
}))
}
})
}
fn query_live_property(
&self,
side: &str,
fluid_id: &str,
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!(
"{} {} side has invalid pressure: {} Pa",
self.name, side, p_pa
)));
}
if !h_jkg.is_finite() {
return Err(ComponentError::InvalidState(format!(
"{} {} side has invalid enthalpy: {} J/kg",
self.name, side, h_jkg
)));
}
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::InvalidState(format!(
"{} {} side fluid '{}' requires a FluidBackend; no simulation fallback is allowed",
self.name, side, fluid_id
))
})?;
backend
.property(
FluidsFluidId::new(fluid_id),
property,
entropyk_fluids::FluidState::PressureEnthalpy(
Pressure::from_pascals(p_pa),
entropyk_core::Enthalpy::from_joules_per_kg(h_jkg),
),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"{} failed to evaluate {:?} for {} side fluid '{}': {}",
self.name, property, side, fluid_id, e
))
})
}
/// Creates a fluid state from temperature, pressure, enthalpy, mass flow, and Cp.
fn create_fluid_state(
temperature: f64,
pressure: f64,
enthalpy: f64,
mass_flow: f64,
cp: f64,
) -> FluidState {
FluidState::new(temperature, pressure, enthalpy, mass_flow, cp)
}
/// Documentation pending
pub fn compute_residuals_with_ua_scale(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
custom_ua_scale: f64,
) -> Result<(), ComponentError> {
self.do_compute_residuals(_state, residuals, Some(custom_ua_scale))
}
/// Documentation pending
pub fn do_compute_residuals(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
custom_ua_scale: Option<f64>,
) -> Result<(), ComponentError> {
if residuals.len() < self.n_equations() {
return Err(ComponentError::InvalidResidualDimensions {
expected: self.n_equations(),
actual: residuals.len(),
});
}
match self.operational_state {
OperationalState::Off | OperationalState::Bypass => {
// Q = 0 in both modes (OFF: no flow; BYPASS: adiabatic pass-through).
// Thermal residuals are trivially satisfied; pressure closures stay
// active so the P rows never go singular.
let n_model = self.model.n_equations();
for r in residuals.iter_mut().take(n_model) {
*r = 0.0;
}
self.append_pressure_closures(_state, residuals, n_model)?;
return Ok(());
}
OperationalState::On => {
// Normal operation - continue with heat transfer model
}
}
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) = self.live_fluid_states(_state)?;
let dynamic_f_ua =
custom_ua_scale.or_else(|| self.calib_indices.z_ua.map(|idx| _state[idx]));
self.model.compute_residuals(
&hot_inlet,
&hot_outlet,
&cold_inlet,
&cold_outlet,
residuals,
dynamic_f_ua,
);
self.append_pressure_closures(_state, residuals, self.model.n_equations())?;
Ok(())
}
/// Writes the per-stream isobaric pressure closures in 4-port mode:
/// `P_hot_out P_hot_in = 0` and `P_cold_out P_cold_in = 0`.
///
/// These rows make the Modelica `MassFlowSource_T` (Free P) + `Boundary_pT`
/// sink pattern square: the sink anchors pressure and the exchanger
/// propagates it to the source edge (same convention as `Condenser` /
/// `Evaporator` secondary sides). No-op outside 4-port mode.
fn append_pressure_closures(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
row_start: usize,
) -> Result<(), ComponentError> {
if !self.edges_ready() {
return Ok(());
}
let (_, p_h_in, _) = self.hot_in_idx.unwrap();
let (_, p_h_out, _) = self.hot_out_idx.unwrap();
let (_, p_c_in, _) = self.cold_in_idx.unwrap();
let (_, p_c_out, _) = self.cold_out_idx.unwrap();
let max_idx = [p_h_in, p_h_out, p_c_in, p_c_out]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
if residuals.len() < row_start + 2 {
return Err(ComponentError::InvalidResidualDimensions {
expected: row_start + 2,
actual: residuals.len(),
});
}
residuals[row_start] = state[p_h_out] - state[p_h_in];
residuals[row_start + 1] = state[p_c_out] - state[p_c_in];
Ok(())
}
}
impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
fn compute_residuals(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.do_compute_residuals(_state, residuals, None)
}
fn jacobian_entries(
&self,
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// 4-port mode: numerical Jacobian via finite differences. Perturb each
// relevant state variable, recompute residuals, take the difference.
if self.edges_ready() {
let (m_h, p_h_in, h_h_in) = self.hot_in_idx.unwrap();
let (_, p_h_out, h_h_out) = self.hot_out_idx.unwrap();
let (m_c, p_c_in, h_c_in) = self.cold_in_idx.unwrap();
let (_, p_c_out, h_c_out) = self.cold_out_idx.unwrap();
let cols = [
m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, p_c_out, h_c_out,
];
let unique_cols: Vec<usize> = {
let mut s: Vec<usize> =
cols.iter().copied().filter(|c| *c < _state.len()).collect();
s.sort_unstable();
s.dedup();
s
};
let compute_res = |s: &[f64]| -> [f64; 2] {
let mut r = vec![0.0_f64; self.n_equations()];
let _ = self.do_compute_residuals(s, &mut r, None);
[r[0], r[1]]
};
for &col in &unique_cols {
let h = (_state[col].abs() * 1e-6).max(1e-3);
let mut sp = _state.to_vec();
sp[col] += h;
let rp = compute_res(&sp);
let mut sm = _state.to_vec();
sm[col] -= h;
let rm = compute_res(&sm);
for row in 0..2 {
let fd = (rp[row] - rm[row]) / (2.0 * h);
if fd.abs() > 1e-15 {
_jacobian.add_entry(row, col, fd);
}
}
}
// Analytic entries for the isobaric pressure closures (rows after
// the thermal model rows): r = P_out P_in per stream.
let row0 = self.model.n_equations();
_jacobian.add_entry(row0, p_h_out, 1.0);
_jacobian.add_entry(row0, p_h_in, -1.0);
_jacobian.add_entry(row0 + 1, p_c_out, 1.0);
_jacobian.add_entry(row0 + 1, p_c_in, -1.0);
return Ok(());
}
Ok(())
}
fn n_equations(&self) -> usize {
// 4-port mode adds the two per-stream isobaric pressure closures so the
// Modelica Free-P source + Fixed-P sink boundary pattern stays square.
if self.edges_ready() {
self.model.n_equations() + 2
} else {
self.model.n_equations()
}
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.calib_indices = indices;
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
if let Some(Some(triple)) = port_edges.first() {
self.hot_in_idx = Some(*triple);
}
if let Some(Some(triple)) = port_edges.get(1) {
self.hot_out_idx = Some(*triple);
}
if let Some(Some(triple)) = port_edges.get(2) {
self.cold_in_idx = Some(*triple);
}
if let Some(Some(triple)) = port_edges.get(3) {
self.cold_out_idx = Some(*triple);
}
}
fn port_names(&self) -> Vec<String> {
vec![
"hot_inlet".to_string(),
"hot_outlet".to_string(),
"cold_inlet".to_string(),
"cold_outlet".to_string(),
]
}
fn flow_paths(&self) -> Vec<(usize, usize)> {
vec![(0, 1), (2, 3)]
}
fn port_mass_flows(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
if !self.edges_ready() {
return Err(self.live_state_required_error());
}
let (m_h_in, _, _) = self.hot_in_idx.unwrap();
let (m_h_out, _, _) = self.hot_out_idx.unwrap();
let (m_c_in, _, _) = self.cold_in_idx.unwrap();
let (m_c_out, _, _) = self.cold_out_idx.unwrap();
let max_idx = [m_h_in, m_h_out, m_c_in, m_c_out]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
Ok(vec![
entropyk_core::MassFlow::from_kg_per_s(state[m_h_in]),
entropyk_core::MassFlow::from_kg_per_s(-state[m_h_out]),
entropyk_core::MassFlow::from_kg_per_s(state[m_c_in]),
entropyk_core::MassFlow::from_kg_per_s(-state[m_c_out]),
])
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
if !self.edges_ready() {
return Err(self.live_state_required_error());
}
let (_, _, h_h_in) = self.hot_in_idx.unwrap();
let (_, _, h_h_out) = self.hot_out_idx.unwrap();
let (_, _, h_c_in) = self.cold_in_idx.unwrap();
let (_, _, h_c_out) = self.cold_out_idx.unwrap();
let max_idx = [h_h_in, h_h_out, h_c_in, h_c_out]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
Ok(vec![
entropyk_core::Enthalpy::from_joules_per_kg(state[h_h_in]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_h_out]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_c_in]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_c_out]),
])
}
fn energy_transfers(
&self,
_state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
match self.operational_state {
OperationalState::Off | OperationalState::Bypass | OperationalState::On => {
// Internal heat exchange between tracked streams; adiabatic to macro-environment
Some((
entropyk_core::Power::from_watts(0.0),
entropyk_core::Power::from_watts(0.0),
))
}
}
}
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
match kind {
crate::MeasuredOutput::Capacity | crate::MeasuredOutput::HeatTransferRate => {
if !self.edges_ready() {
return None;
}
let (m_h, _, h_h_in) = self.hot_in_idx?;
let (_, _, h_h_out) = self.hot_out_idx?;
let (m_c, _, h_c_in) = self.cold_in_idx?;
let (_, _, h_c_out) = self.cold_out_idx?;
let max_idx = [m_h, h_h_in, h_h_out, m_c, h_c_in, h_c_out]
.into_iter()
.max()?;
if max_idx >= state.len() {
return None;
}
let q_hot_w = state[m_h].abs() * (state[h_h_in] - state[h_h_out]).abs();
let q_cold_w = state[m_c].abs() * (state[h_c_out] - state[h_c_in]).abs();
if q_hot_w.is_finite() && q_cold_w.is_finite() {
Some(0.5 * (q_hot_w + q_cold_w))
} else if q_hot_w.is_finite() {
Some(q_hot_w)
} else if q_cold_w.is_finite() {
Some(q_cold_w)
} else {
None
}
}
_ => None,
}
}
fn set_fluid_backend_from_builder(
&mut self,
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
) {
if self.fluid_backend.is_none() {
self.fluid_backend = Some(backend);
}
}
fn signature(&self) -> String {
format!("{}(circuit={})", self.name, self.circuit_id.0)
}
fn to_params(&self) -> crate::ComponentParams {
crate::ComponentParams::new(&self.name)
.with_param("circuitId", self.circuit_id.0)
.with_param(
"calib",
serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null),
)
}
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
let mut c = self.calib().clone();
if c.set_factor(factor, value) {
self.set_calib(c);
true
} else {
false
}
}
}
impl<Model: HeatTransferModel + 'static> StateManageable for HeatExchanger<Model> {
fn state(&self) -> OperationalState {
self.operational_state
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
if self.operational_state.can_transition_to(state) {
let from = self.operational_state;
self.operational_state = state;
self.on_state_change(from, state);
Ok(())
} else {
Err(ComponentError::InvalidStateTransition {
from: self.operational_state,
to: state,
reason: "Transition not allowed".to_string(),
})
}
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.operational_state.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
&self.circuit_id
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.circuit_id = circuit_id;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::heat_exchanger::{FlowConfiguration, LmtdModel};
use crate::state_machine::StateManageable;
fn live_air_state(t_k: f64) -> f64 {
1006.0 * (t_k - 273.15)
}
#[test]
fn test_heat_exchanger_creation() {
let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
let hx = HeatExchanger::new(model, "TestHX");
assert_eq!(hx.name(), "TestHX");
assert_eq!(hx.ua(), 5000.0);
assert_eq!(hx.operational_state(), OperationalState::On);
}
#[test]
fn test_n_equations() {
let model = LmtdModel::counter_flow(1000.0);
let hx = HeatExchanger::new(model, "Test");
assert_eq!(hx.n_equations(), 2);
}
#[test]
fn test_compute_residuals() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
fn test_live_four_port_residuals_compute_from_state() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test")
.with_hot_fluid("Air")
.with_cold_fluid("Air");
hx.set_port_context(&[
Some((0, 1, 2)),
Some((0, 3, 4)),
Some((5, 6, 7)),
Some((5, 8, 9)),
]);
let state = vec![
0.5,
101_325.0,
live_air_state(350.0),
101_325.0,
live_air_state(330.0),
0.8,
101_325.0,
live_air_state(290.0),
101_325.0,
live_air_state(300.0),
];
let mut residuals = vec![0.0; hx.n_equations()];
hx.compute_residuals(&state, &mut residuals).unwrap();
assert!(residuals.iter().all(|r| r.is_finite()));
assert!(residuals.iter().any(|r| r.abs() > 1e-9));
assert_eq!(
hx.port_enthalpies(&state)
.unwrap()
.iter()
.map(|h| h.to_joules_per_kg())
.collect::<Vec<_>>(),
vec![
live_air_state(350.0),
live_air_state(330.0),
live_air_state(290.0),
live_air_state(300.0)
]
);
}
#[test]
fn test_four_port_metadata_is_name_based() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert!(hx.get_ports().is_empty());
assert_eq!(
hx.port_names(),
vec!["hot_inlet", "hot_outlet", "cold_inlet", "cold_outlet"]
);
assert_eq!(hx.flow_paths(), vec![(0, 1), (2, 3)]);
}
#[test]
fn test_residual_dimension_error() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 1];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_err());
}
#[test]
fn test_builder() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchangerBuilder::new(model)
.name("Condenser")
.circuit_id(CircuitId::from_number(5))
.build();
assert_eq!(hx.name(), "Condenser");
assert_eq!(hx.circuit_id().as_number(), 5);
}
#[test]
fn test_state_manageable_state() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert_eq!(hx.state(), OperationalState::On);
}
#[test]
fn test_state_manageable_set_state() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
let result = hx.set_state(OperationalState::Off);
assert!(result.is_ok());
assert_eq!(hx.state(), OperationalState::Off);
}
#[test]
fn test_state_manageable_can_transition_to() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert!(hx.can_transition_to(OperationalState::Off));
assert!(hx.can_transition_to(OperationalState::Bypass));
}
#[test]
fn test_state_manageable_circuit_id() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert_eq!(*hx.circuit_id(), CircuitId::ZERO);
}
#[test]
fn test_state_manageable_set_circuit_id() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
hx.set_circuit_id(CircuitId::from_number(2));
assert_eq!(hx.circuit_id().as_number(), 2);
}
#[test]
fn test_off_mode_residuals() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
hx.set_operational_state(OperationalState::Off);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
// In OFF mode, all residuals should be zero
assert_eq!(residuals[0], 0.0);
assert_eq!(residuals[1], 0.0);
assert_eq!(residuals[2], 0.0);
}
#[test]
fn test_bypass_mode_residuals() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
hx.set_operational_state(OperationalState::Bypass);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
// In BYPASS mode, all residuals should be zero (no heat transfer)
assert_eq!(residuals[0], 0.0);
assert_eq!(residuals[1], 0.0);
assert_eq!(residuals[2], 0.0);
}
#[test]
fn test_circuit_id_via_builder() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchangerBuilder::new(model)
.circuit_id(CircuitId::from_number(1))
.build();
assert_eq!(hx.circuit_id().as_number(), 1);
}
#[test]
fn test_with_circuit_id() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test").with_circuit_id(CircuitId::from_number(3));
assert_eq!(hx.circuit_id().as_number(), 3);
}
// ===== Story 5.1: FluidBackend Integration Tests =====
#[test]
fn test_no_fluid_backend_by_default() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert!(!hx.has_fluid_backend());
}
#[test]
fn test_with_fluid_backend_sets_flag() {
use entropyk_fluids::TestBackend;
use std::sync::Arc;
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test").with_fluid_backend(Arc::new(TestBackend::new()));
assert!(hx.has_fluid_backend());
}
#[test]
fn test_hx_side_conditions_construction() {
use entropyk_core::{MassFlow, Pressure, Temperature};
let conds = HxSideConditions::new(
Temperature::from_celsius(60.0),
Pressure::from_bar(25.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
)
.expect("Valid conditions should not fail");
assert!((conds.temperature_k() - 333.15).abs() < 0.01);
assert!((conds.pressure_pa() - 25.0e5).abs() < 1.0);
assert!((conds.mass_flow_kg_s() - 0.05).abs() < 1e-10);
assert_eq!(conds.fluid_id().0, "R410A");
}
#[test]
fn test_boundary_conditions_without_outlet_state_error() {
use entropyk_core::{MassFlow, Pressure, Temperature};
use entropyk_fluids::TestBackend;
use std::sync::Arc;
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Condenser")
.with_fluid_backend(Arc::new(TestBackend::new()))
.with_hot_conditions(
HxSideConditions::new(
Temperature::from_celsius(60.0),
Pressure::from_bar(20.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
)
.expect("Valid hot conditions"),
)
.with_cold_conditions(
HxSideConditions::new(
Temperature::from_celsius(30.0),
Pressure::from_pascals(102_000.0),
MassFlow::from_kg_per_s(0.2),
"Water",
)
.expect("Valid cold conditions"),
);
let state = vec![0.0f64; 10];
let mut residuals = vec![0.0f64; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(
matches!(result, Err(ComponentError::InvalidState(_))),
"inlet-only boundary conditions must not fabricate outlet states"
);
}
#[test]
fn test_unwired_hx_never_returns_dummy_finite_residuals() {
use entropyk_core::{MassFlow, Pressure, Temperature};
use entropyk_fluids::TestBackend;
use std::sync::Arc;
let model1 = LmtdModel::counter_flow(5000.0);
let hx_no_backend = HeatExchanger::new(model1, "HX_nobackend");
let state = vec![0.0f64; 10];
let mut residuals_no_backend = vec![0.0f64; 3];
assert!(matches!(
hx_no_backend.compute_residuals(&state, &mut residuals_no_backend),
Err(ComponentError::InvalidState(_))
));
let model2 = LmtdModel::counter_flow(5000.0);
let hx_with_backend = HeatExchanger::new(model2, "HX_with_backend")
.with_fluid_backend(Arc::new(TestBackend::new()))
.with_hot_conditions(
HxSideConditions::new(
Temperature::from_celsius(60.0),
Pressure::from_bar(20.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
)
.expect("Valid hot conditions"),
)
.with_cold_conditions(
HxSideConditions::new(
Temperature::from_celsius(30.0),
Pressure::from_pascals(102_000.0),
MassFlow::from_kg_per_s(0.2),
"Water",
)
.expect("Valid cold conditions"),
);
let mut residuals_with_backend = vec![0.0f64; 3];
assert!(matches!(
hx_with_backend.compute_residuals(&state, &mut residuals_with_backend),
Err(ComponentError::InvalidState(_))
));
}
#[test]
fn test_set_fluid_backend_mutable() {
use entropyk_fluids::TestBackend;
use std::sync::Arc;
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
assert!(!hx.has_fluid_backend());
hx.set_fluid_backend(Arc::new(TestBackend::new()));
assert!(hx.has_fluid_backend());
}
}