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>
1432 lines
49 KiB
Rust
1432 lines
49 KiB
Rust
//! 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());
|
||
}
|
||
}
|