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

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

1432 lines
49 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, 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),
})
}
}
/// 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).
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()
}
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 {
Err(ComponentError::CalculationFailed(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 {
Err(ComponentError::CalculationFailed(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 {
Err(ComponentError::CalculationFailed(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 {
Err(ComponentError::CalculationFailed(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 => {
// In OFF mode: Q = 0, mass flow = 0 on both sides
// All residuals should be zero (no heat transfer, no flow)
residuals[0] = 0.0; // Hot side: no energy transfer
residuals[1] = 0.0; // Cold side: no energy transfer
residuals[2] = 0.0; // Energy conservation (Q_hot = Q_cold = 0)
return Ok(());
}
OperationalState::Bypass => {
// In BYPASS mode: Q = 0, mass flow continues
// Temperature continuity (T_out = T_in for both sides)
residuals[0] = 0.0; // Hot side: no energy transfer (adiabatic)
residuals[1] = 0.0; // Cold side: no energy transfer (adiabatic)
residuals[2] = 0.0; // Energy conservation (Q_hot = Q_cold = 0)
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,
);
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; 2];
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);
}
}
}
return Ok(());
}
Ok(())
}
fn n_equations(&self) -> usize {
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());
}
}