//! 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: Model, name: String, circuit_id: CircuitId, } impl HeatExchangerBuilder { /// 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) -> 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 { 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, ) -> Result { 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: 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>, /// Boundary conditions for the hot side inlet. hot_conditions: Option, /// Boundary conditions for the cold side inlet. cold_conditions: Option, // ── 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 std::fmt::Debug for HeatExchanger { 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 HeatExchanger { /// Creates a new heat exchanger with the given model. pub fn new(mut model: Model, name: impl Into) -> 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) -> 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) { 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 { 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 { 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 { 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 { 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) -> 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) -> 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) { self.hot_fluid_id_str = fluid.into(); } /// Sets the cold-side fluid identifier. pub fn set_cold_fluid(&mut self, fluid: impl Into) { 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 { 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 { 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 { 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 { 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 { 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, ) -> 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 Component for HeatExchanger { 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 = { let mut s: Vec = 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 { 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, 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, 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 { 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, ) { 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 StateManageable for HeatExchanger { 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![ 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()); } }