//! FreeCoolingExchanger component for water-side economizer simulation //! //! This component models a water-to-water heat exchanger used for free cooling, //! allowing the use of outdoor air as a cooling source without operating the compressor. //! Uses ε-NTU method for counter-flow heat exchanger calculation. use entropyk_core::{CalibIndices, Enthalpy, Power, Temperature}; use entropyk_fluids::FluidBackend; use serde::{Deserialize, Serialize}; use std::sync::Arc; use crate::{ CircuitId, Component, ComponentError, ComponentParams, ConnectedPort, JacobianBuilder, OperationalState, ResidualVector, }; /// Default specific heat for water (J/kg/K) const CP_WATER: f64 = 4186.0; /// Operating mode of the FreeCoolingExchanger #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum FreeCoolingMode { /// Free cooling active (direct heat exchange) Active, /// Full bypass (no heat exchange) Bypass, /// Mixed mode (partial bypass) Mixed { /// Fraction of flow that bypasses the heat exchanger bypass_fraction: f64, }, } /// Configuration for the free cooling heat exchanger #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FreeCoolingConfig { /// Effectiveness of the heat exchanger (0.0 - 1.0) pub effectiveness: f64, /// Bypass fraction (0.0 - 1.0) pub bypass_fraction: f64, /// Minimum outdoor temperature for free cooling (K) pub min_outdoor_temp: f64, /// Hysteresis to prevent rapid cycling (K) pub hysteresis: f64, /// Control mode pub control_mode: FreeCoolingControlMode, /// UA value (W/K) — overall heat transfer coefficient × area pub ua: f64, /// Cold-side mass flow rate (kg/s) pub cold_mass_flow: f64, /// Hot-side mass flow rate (kg/s) pub hot_mass_flow: f64, /// Cold-side specific heat capacity (J/kg/K) pub cold_cp: f64, /// Hot-side specific heat capacity (J/kg/K) pub hot_cp: f64, /// Nominal pressure drop on cold side (Pa) pub cold_dp_nominal: f64, /// Nominal pressure drop on hot side (Pa) pub hot_dp_nominal: f64, } /// Control mode for free cooling #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum FreeCoolingControlMode { /// Manual control (fixed mode) Manual, /// Automatic control based on outdoor temperature AutoTemperature, /// Optimized control (minimizes energy consumption) Optimized, } /// FreeCoolingExchanger component pub struct FreeCoolingExchanger { /// Unique identifier id: String, /// Circuit ID circuit_id: CircuitId, /// Configuration config: FreeCoolingConfig, /// Current mode mode: FreeCoolingMode, /// Ports (4 ports: cold water in/out, hot water in/out) ports: [ConnectedPort; 4], /// Outdoor temperature (for auto mode) outdoor_temp: Option, /// Calculated after convergence heat_transfer_rate: Option, /// Current effectiveness (can vary with flow rates) current_effectiveness: f64, /// Fluid backend for property calculations fluid_backend: Option>, /// Calibration factor for UA scaling (default 1.0) f_ua: f64, /// Calibration factor for pressure drop scaling (default 1.0) f_dp: f64, /// Calibration indices for inverse calibration calib_indices: CalibIndices, } impl std::fmt::Debug for FreeCoolingExchanger { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("FreeCoolingExchanger") .field("id", &self.id) .field("circuit_id", &self.circuit_id) .field("config", &self.config) .field("mode", &self.mode) .field("outdoor_temp", &self.outdoor_temp) .field("heat_transfer_rate", &self.heat_transfer_rate) .field("current_effectiveness", &self.current_effectiveness) .field("f_ua", &self.f_ua) .field("f_dp", &self.f_dp) .field("fluid_backend", &"") .finish() } } /// Port index constants const COLD_INLET: usize = 0; const COLD_OUTLET: usize = 1; const HOT_INLET: usize = 2; const HOT_OUTLET: usize = 3; impl FreeCoolingExchanger { /// Creates a new free cooling heat exchanger pub fn new( id: &str, circuit_id: CircuitId, config: FreeCoolingConfig, port_cold_inlet: ConnectedPort, port_cold_outlet: ConnectedPort, port_hot_inlet: ConnectedPort, port_hot_outlet: ConnectedPort, ) -> Result { if config.effectiveness < 0.0 || config.effectiveness > 1.0 { return Err(ComponentError::InvalidState( "Effectiveness must be between 0.0 and 1.0".to_string(), )); } if config.bypass_fraction < 0.0 || config.bypass_fraction > 1.0 { return Err(ComponentError::InvalidState( "Bypass fraction must be between 0.0 and 1.0".to_string(), )); } let current_effectiveness = config.effectiveness; Ok(Self { id: id.to_string(), circuit_id, config, mode: FreeCoolingMode::Bypass, ports: [port_cold_inlet, port_cold_outlet, port_hot_inlet, port_hot_outlet], outdoor_temp: None, heat_transfer_rate: None, current_effectiveness, fluid_backend: None, f_ua: 1.0, f_dp: 1.0, calib_indices: CalibIndices::default(), }) } /// Sets the fluid backend for property calculations pub fn set_fluid_backend(&mut self, backend: Arc) { self.fluid_backend = Some(backend); } /// Computes ε-NTU effectiveness for a counter-flow heat exchanger. /// /// ε = (1 - exp(-NTU × (1 - C_r))) / (1 - C_r × exp(-NTU × (1 - C_r))) /// For C_r ≈ 1: ε = NTU / (1 + NTU) fn compute_effectiveness(&self, ua: f64, c_cold: f64, c_hot: f64) -> f64 { let c_min = c_cold.min(c_hot); let c_max = c_cold.max(c_hot); let c_r = if c_max > 0.0 { c_min / c_max } else { 0.0 }; if c_min <= 0.0 || ua <= 0.0 { return 0.0; } let ntu = ua / c_min; if (c_r - 1.0).abs() < 1e-6 { // Balanced counter-flow: ε = NTU / (1 + NTU) ntu / (1.0 + ntu) } else { let denom = 1.0 - c_r * (-ntu * (1.0 - c_r)).exp(); if denom.abs() < 1e-12 { return 0.0; } (1.0 - (-ntu * (1.0 - c_r)).exp()) / denom } } /// Reads port enthalpy as raw f64 (J/kg) from the ConnectedPort. fn port_enthalpy_raw(&self, idx: usize) -> f64 { self.ports[idx].enthalpy().to_joules_per_kg() } /// Reads port pressure as raw f64 (Pa) from the ConnectedPort. fn port_pressure_raw(&self, idx: usize) -> f64 { self.ports[idx].pressure().to_pascals() } /// Estimates temperature from enthalpy using Cp (incompressible fluid). fn temperature_from_enthalpy(&self, h: f64, cp: f64) -> f64 { // T = h / Cp (simplified for incompressible fluids where h_ref = 0 at T_ref = 0) // More accurately: T = T_ref + (h - h_ref) / Cp // Using h/Cp as approximation consistent with incompressible assumption h / cp } /// Updates the mode based on conditions pub fn update_mode(&mut self, outdoor_temp: Option) -> Result<(), ComponentError> { if let Some(t_outdoor) = outdoor_temp { self.outdoor_temp = Some(t_outdoor); match self.config.control_mode { FreeCoolingControlMode::AutoTemperature => { let h_cold_in = self.port_enthalpy_raw(COLD_INLET); let t_cold_in = self.temperature_from_enthalpy(h_cold_in, self.config.cold_cp); match self.mode { FreeCoolingMode::Bypass => { if t_outdoor.0 < (t_cold_in - self.config.min_outdoor_temp) { self.mode = FreeCoolingMode::Active; } } _ => { if t_outdoor.0 > (t_cold_in - self.config.min_outdoor_temp + self.config.hysteresis) { self.mode = FreeCoolingMode::Bypass; } } } } FreeCoolingControlMode::Optimized => { self.mode = FreeCoolingMode::Active; } FreeCoolingControlMode::Manual => {} } } Ok(()) } /// Sets the f_ua calibration factor pub fn set_f_ua(&mut self, f_ua: f64) { self.f_ua = f_ua; } /// Returns the f_ua calibration factor pub fn f_ua(&self) -> f64 { self.f_ua } /// Sets the f_dp calibration factor pub fn set_f_dp(&mut self, f_dp: f64) { self.f_dp = f_dp; } /// Returns the f_dp calibration factor pub fn f_dp(&self) -> f64 { self.f_dp } /// Returns the current operational state pub fn operational_state(&self) -> OperationalState { match self.mode { FreeCoolingMode::Bypass => OperationalState::Bypass, _ => OperationalState::On, } } /// Sets the operational state pub fn set_operational_state(&mut self, state: OperationalState) -> Result<(), ComponentError> { match state { OperationalState::On => self.mode = FreeCoolingMode::Active, OperationalState::Off | OperationalState::Bypass => { self.mode = FreeCoolingMode::Bypass; } } Ok(()) } /// Returns the current heat transfer rate pub fn heat_transfer_rate(&self) -> Option { self.heat_transfer_rate } /// Returns the current mode pub fn current_mode(&self) -> FreeCoolingMode { self.mode } /// Returns estimated energy savings (in %) pub fn energy_savings_percent(&self) -> f64 { match self.mode { FreeCoolingMode::Active => self.current_effectiveness * 100.0, FreeCoolingMode::Bypass => 0.0, FreeCoolingMode::Mixed { bypass_fraction } => { self.current_effectiveness * bypass_fraction * 100.0 } } } /// Returns outdoor temperature pub fn outdoor_temperature(&self) -> Option { self.outdoor_temp } /// Updates configuration pub fn update_config(&mut self, config: FreeCoolingConfig) -> Result<(), ComponentError> { if config.effectiveness < 0.0 || config.effectiveness > 1.0 { return Err(ComponentError::InvalidState( "Effectiveness must be between 0.0 and 1.0".to_string(), )); } self.config = config; Ok(()) } /// Returns true if free cooling is active pub fn is_free_cooling_active(&self) -> bool { self.mode != FreeCoolingMode::Bypass } /// Calculates effective COP (very high in free cooling) pub fn effective_cop(&self) -> f64 { match self.mode { FreeCoolingMode::Active => 20.0 + self.current_effectiveness * 10.0, FreeCoolingMode::Bypass => 1.0, FreeCoolingMode::Mixed { bypass_fraction } => { let cop_fc = 20.0 + self.current_effectiveness * 10.0; bypass_fraction * cop_fc + (1.0 - bypass_fraction) * 1.0 } } } /// Returns the unique identifier pub fn id(&self) -> &str { &self.id } /// Returns the circuit ID pub fn circuit_id(&self) -> CircuitId { self.circuit_id } /// Returns a reference to the config pub fn config(&self) -> &FreeCoolingConfig { &self.config } } // --------------------------------------------------------------------------- // Component trait implementation // --------------------------------------------------------------------------- /// Equation layout (4 equations total): /// r[0]: cold-side energy balance: ṁ_cold × (h_cold_out − h_cold_in) − Q = 0 /// r[1]: hot-side energy balance: ṁ_hot × (h_hot_out − h_hot_in) + Q = 0 /// r[2]: energy conservation: ṁ_cold × Δh_cold + ṁ_hot × Δh_hot = 0 /// r[3]: pressure continuity: P_cold_in − P_cold_out − f_dp × ΔP_nominal = 0 /// /// In Bypass mode: r[0..3] = pressure/enthalpy continuity (adiabatic) const N_EQUATIONS: usize = 4; impl Component for FreeCoolingExchanger { fn n_equations(&self) -> usize { N_EQUATIONS } fn compute_residuals( &self, _state: &[f64], residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { if residuals.len() < N_EQUATIONS { return Err(ComponentError::InvalidResidualDimensions { expected: N_EQUATIONS, actual: residuals.len(), }); } // Read port values let h_cold_in = self.port_enthalpy_raw(COLD_INLET); let h_cold_out = self.port_enthalpy_raw(COLD_OUTLET); let h_hot_in = self.port_enthalpy_raw(HOT_INLET); let h_hot_out = self.port_enthalpy_raw(HOT_OUTLET); let p_cold_in = self.port_pressure_raw(COLD_INLET); let p_cold_out = self.port_pressure_raw(COLD_OUTLET); let p_hot_in = self.port_pressure_raw(HOT_INLET); let p_hot_out = self.port_pressure_raw(HOT_OUTLET); match self.mode { FreeCoolingMode::Bypass => { // Adiabatic: P and h continuity on both sides residuals[0] = p_cold_in - p_cold_out; residuals[1] = h_cold_in - h_cold_out; residuals[2] = p_hot_in - p_hot_out; residuals[3] = h_hot_in - h_hot_out; } FreeCoolingMode::Active | FreeCoolingMode::Mixed { .. } => { let m_cold = self.config.cold_mass_flow; let m_hot = self.config.hot_mass_flow; let cp_cold = self.config.cold_cp; let cp_hot = self.config.hot_cp; // Capacity rates (W/K) let c_cold = m_cold * cp_cold; let c_hot = m_hot * cp_hot; let c_min = c_cold.min(c_hot); // UA with calibration scaling let ua_eff = self.f_ua * self.config.ua; // ε-NTU effectiveness let eps = self.compute_effectiveness(ua_eff, c_cold, c_hot); // Scale by (1 - bypass_fraction) for mixed mode let eps_eff = match self.mode { FreeCoolingMode::Mixed { bypass_fraction } => eps * (1.0 - bypass_fraction), _ => eps, }; // Inlet temperatures from enthalpy (incompressible: T = h / Cp) let t_cold_in = self.temperature_from_enthalpy(h_cold_in, cp_cold); let t_hot_in = self.temperature_from_enthalpy(h_hot_in, cp_hot); // Heat transfer: Q = ε × C_min × (T_hot_in − T_cold_in) let q = eps_eff * c_min * (t_hot_in - t_cold_in); // Store for reporting // (heat_transfer_rate is updated after convergence externally) // Residuals let dh_cold = h_cold_out - h_cold_in; let dh_hot = h_hot_out - h_hot_in; residuals[0] = m_cold * dh_cold - q; residuals[1] = m_hot * dh_hot + q; residuals[2] = m_cold * dh_cold + m_hot * dh_hot; residuals[3] = (p_cold_in - p_cold_out) - self.f_dp * self.config.cold_dp_nominal; } } Ok(()) } fn jacobian_entries( &self, _state: &[f64], jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { // Jacobian entries for calibration variable sensitivities if let Some(f_ua_idx) = self.calib_indices.f_ua { // ∂r[0]/∂f_ua: cold-side energy balance sensitivity // r[0] = m_cold * (h_cold_out - h_cold_in) - Q(f_ua) // ∂r[0]/∂f_ua = -∂Q/∂f_ua = -ε × C_min × (T_hot_in - T_cold_in) × UA_nominal let m_cold = self.config.cold_mass_flow; let m_hot = self.config.hot_mass_flow; let c_cold = m_cold * self.config.cold_cp; let c_hot = m_hot * self.config.hot_cp; let c_min = c_cold.min(c_hot); let h_cold_in = self.port_enthalpy_raw(COLD_INLET); let h_hot_in = self.port_enthalpy_raw(HOT_INLET); let t_cold_in = self.temperature_from_enthalpy(h_cold_in, self.config.cold_cp); let t_hot_in = self.temperature_from_enthalpy(h_hot_in, self.config.hot_cp); let dt = t_hot_in - t_cold_in; // Approximate ∂Q/∂f_ua ≈ C_min × dt × (∂ε/∂f_ua) × UA_nominal // For small variations: ∂Q/∂f_ua ≈ Q / f_ua when linearized let ua_eff = self.f_ua * self.config.ua; let eps = self.compute_effectiveness(ua_eff, c_cold, c_hot); let q_per_f_ua = eps * c_min * dt; // Q / f_ua at current operating point jacobian.add_entry(0, f_ua_idx, -q_per_f_ua); jacobian.add_entry(1, f_ua_idx, q_per_f_ua); // r[2] = r[0] + r[1], so ∂r[2]/∂f_ua = ∂r[0]/∂f_ua + ∂r[1]/∂f_ua = 0 jacobian.add_entry(2, f_ua_idx, 0.0); } if let Some(f_dp_idx) = self.calib_indices.f_dp { // r[3] = (P_cold_in - P_cold_out) - f_dp × ΔP_nominal // ∂r[3]/∂f_dp = -ΔP_nominal jacobian.add_entry(3, f_dp_idx, -self.config.cold_dp_nominal); } Ok(()) } fn get_ports(&self) -> &[ConnectedPort] { &self.ports } fn set_fluid_backend_from_builder( &mut self, backend: Arc, ) { if self.fluid_backend.is_none() { self.fluid_backend = Some(backend); } } fn set_calib_indices(&mut self, indices: CalibIndices) { self.calib_indices = indices; } fn energy_transfers(&self, _state: &[f64]) -> Option<(Power, Power)> { // Internal heat exchange between two water streams — adiabatic to external environment Some((Power::from_watts(0.0), Power::from_watts(0.0))) } fn port_enthalpies( &self, _state: &[f64], ) -> Result, ComponentError> { Ok(vec![ Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(COLD_INLET)), Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(COLD_OUTLET)), Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(HOT_INLET)), Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(HOT_OUTLET)), ]) } fn signature(&self) -> String { format!( "FreeCoolingExchanger(id={},eff={},ua={},mode={:?},f_ua={},f_dp={})", self.id, self.config.effectiveness, self.config.ua, self.mode, self.f_ua, self.f_dp ) } fn to_params(&self) -> ComponentParams { ComponentParams::new("FreeCoolingExchanger") .with_param("id", self.id.as_str()) .with_param("circuitId", self.circuit_id.0) .with_param("effectiveness", self.config.effectiveness) .with_param("ua", self.config.ua) .with_param("coldMassFlow", self.config.cold_mass_flow) .with_param("hotMassFlow", self.config.hot_mass_flow) .with_param("coldCp", self.config.cold_cp) .with_param("hotCp", self.config.hot_cp) .with_param("bypassFraction", self.config.bypass_fraction) .with_param("f_ua", self.f_ua) .with_param("f_dp", self.f_dp) .with_param("mode", format!("{:?}", self.mode)) } fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool { match factor { "f_ua" => { self.f_ua = value; true } "f_dp" => { self.f_dp = value; true } _ => false, } } } impl Default for FreeCoolingConfig { fn default() -> Self { Self { effectiveness: 0.85, bypass_fraction: 0.2, min_outdoor_temp: 285.15, hysteresis: 2.0, control_mode: FreeCoolingControlMode::AutoTemperature, ua: 10_000.0, // 10 kW/K typical for plate HX cold_mass_flow: 0.5, hot_mass_flow: 0.5, cold_cp: CP_WATER, hot_cp: CP_WATER, cold_dp_nominal: 0.0, hot_dp_nominal: 0.0, } } } #[cfg(test)] mod tests { use super::*; use crate::port::{FluidId, Port}; use entropyk_core::Pressure; fn make_connected_ports() -> (ConnectedPort, ConnectedPort) { let fluid = FluidId::new("Water"); let p = Pressure::from_pascals(3e5); let h = Enthalpy::from_joules_per_kg(63_000.0); let a = Port::new(fluid, p, h); let b = Port::new(FluidId::new("Water"), p, h); a.connect(b).unwrap() } fn make_connected_ports_with( p: Pressure, h_cold: f64, h_hot: f64, ) -> (ConnectedPort, ConnectedPort, ConnectedPort, ConnectedPort) { let h_c = Enthalpy::from_joules_per_kg(h_cold); let h_h = Enthalpy::from_joules_per_kg(h_hot); let ci = Port::new(FluidId::new("Water"), p, h_c); let co = Port::new(FluidId::new("Water"), p, h_c); let (ci, co) = ci.connect(co).unwrap(); let hi = Port::new(FluidId::new("Water"), p, h_h); let ho = Port::new(FluidId::new("Water"), p, h_h); let (hi, ho) = hi.connect(ho).unwrap(); (ci, co, hi, ho) } fn make_exchanger_active() -> FreeCoolingExchanger { let (ci, co, hi, ho) = make_connected_ports_with( Pressure::from_pascals(3e5), 50_000.0, // ~12°C cold (h/Cp) 105_000.0, // ~25°C hot (h/Cp) ); let mut fc = FreeCoolingExchanger::new( "fc_test", CircuitId(0), FreeCoolingConfig::default(), ci, co, hi, ho, ) .unwrap(); fc.mode = FreeCoolingMode::Active; fc } #[test] fn test_free_cooling_exchanger_creation() { let config = FreeCoolingConfig::default(); let (cold_in, cold_out) = make_connected_ports(); let (hot_in, hot_out) = make_connected_ports(); let exchanger = FreeCoolingExchanger::new( "fc_1", CircuitId(0), config, cold_in, cold_out, hot_in, hot_out, ); assert!(exchanger.is_ok()); let exchanger = exchanger.unwrap(); assert_eq!(exchanger.current_mode(), FreeCoolingMode::Bypass); assert!(!exchanger.is_free_cooling_active()); } #[test] fn test_invalid_effectiveness() { let config = FreeCoolingConfig { effectiveness: 1.5, ..Default::default() }; let (cold_in, cold_out) = make_connected_ports(); let (hot_in, hot_out) = make_connected_ports(); let exchanger = FreeCoolingExchanger::new( "fc_1", CircuitId(0), config, cold_in, cold_out, hot_in, hot_out, ); assert!(exchanger.is_err()); } #[test] fn test_energy_savings_calculation() { let (cold_in, cold_out) = make_connected_ports(); let (hot_in, hot_out) = make_connected_ports(); let mut exchanger = FreeCoolingExchanger::new( "fc_1", CircuitId(0), FreeCoolingConfig { effectiveness: 0.85, ..Default::default() }, cold_in, cold_out, hot_in, hot_out, ) .unwrap(); assert_eq!(exchanger.energy_savings_percent(), 0.0); exchanger.mode = FreeCoolingMode::Active; assert_eq!(exchanger.energy_savings_percent(), 85.0); exchanger.mode = FreeCoolingMode::Mixed { bypass_fraction: 0.3, }; let expected = 85.0 * 0.3; assert!( (exchanger.energy_savings_percent() - expected).abs() < 1e-10 ); } #[test] fn test_effective_cop() { let (cold_in, cold_out) = make_connected_ports(); let (hot_in, hot_out) = make_connected_ports(); let mut exchanger = FreeCoolingExchanger::new( "fc_1", CircuitId(0), FreeCoolingConfig::default(), cold_in, cold_out, hot_in, hot_out, ) .unwrap(); exchanger.mode = FreeCoolingMode::Active; assert!(exchanger.effective_cop() > 20.0); exchanger.mode = FreeCoolingMode::Bypass; assert_eq!(exchanger.effective_cop(), 1.0); } #[test] fn test_residuals_active_mode() { let fc = make_exchanger_active(); let mut residuals = vec![0.0; N_EQUATIONS]; fc.compute_residuals(&[], &mut residuals).unwrap(); // In active mode with different temperatures, Q > 0, residuals should be non-zero // (residuals won't be zero because port enthalpies don't match the Q computed) let has_nonzero = residuals.iter().any(|r| r.abs() > 1e-10); assert!(has_nonzero, "Active mode residuals should be non-zero"); } #[test] fn test_residuals_bypass_mode() { let (ci, co, hi, ho) = make_connected_ports_with( Pressure::from_pascals(3e5), 50_000.0, 105_000.0, ); let fc = FreeCoolingExchanger::new( "fc_test", CircuitId(0), FreeCoolingConfig::default(), ci, co, hi, ho, ) .unwrap(); // Starts in Bypass mode let mut residuals = vec![0.0; N_EQUATIONS]; fc.compute_residuals(&[], &mut residuals).unwrap(); // With identical connected port pairs, P and h are equal → residuals near zero for r in &residuals { assert!( r.abs() < 1e-6, "Bypass mode with equal ports should have near-zero residuals" ); } } #[test] fn test_jacobian_entries_active_mode() { let fc = make_exchanger_active(); // Without calib indices, jacobian should have no entries let mut jb = JacobianBuilder::new(); fc.jacobian_entries(&[], &mut jb).unwrap(); assert_eq!(jb.entries().len(), 0); // With f_ua calib index let mut fc = fc; fc.calib_indices.f_ua = Some(100); let mut jb = JacobianBuilder::new(); fc.jacobian_entries(&[], &mut jb).unwrap(); assert!(!jb.entries().is_empty(), "Should have f_ua entries"); // Check that r[0] entry is negative (Q increases with f_ua, so residual decreases) let (row0, _, val0) = jb.entries().iter().find(|(r, _, _)| *r == 0).unwrap(); assert_eq!(*row0, 0); assert!( *val0 <= 0.0, "∂r[0]/∂f_ua should be <= 0 (Q increases with f_ua)" ); } #[test] fn test_jacobian_with_f_dp() { let mut fc = make_exchanger_active(); fc.calib_indices.f_dp = Some(200); fc.config.cold_dp_nominal = 5000.0; let mut jb = JacobianBuilder::new(); fc.jacobian_entries(&[], &mut jb).unwrap(); let f_dp_entries: Vec<_> = jb.entries().iter().filter(|(r, _, _)| *r == 3).collect(); assert!(!f_dp_entries.is_empty()); assert_eq!(f_dp_entries[0].2, -5000.0); } #[test] fn test_energy_transfers() { let fc = make_exchanger_active(); let result = fc.energy_transfers(&[]); assert!(result.is_some()); let (heat, work) = result.unwrap(); assert_eq!(heat.to_watts(), 0.0); assert_eq!(work.to_watts(), 0.0); } #[test] fn test_port_enthalpies() { let fc = make_exchanger_active(); let enthalpies = fc.port_enthalpies(&[]).unwrap(); assert_eq!(enthalpies.len(), 4); } #[test] fn test_calibration_scaling() { let fc1 = make_exchanger_active(); let mut fc2 = make_exchanger_active(); fc2.f_ua = 1.5; // 50% higher UA let mut r1 = vec![0.0; N_EQUATIONS]; let mut r2 = vec![0.0; N_EQUATIONS]; fc1.compute_residuals(&[], &mut r1).unwrap(); fc2.compute_residuals(&[], &mut r2).unwrap(); // With higher UA, ε changes → Q changes → residuals change assert!( (r1[0] - r2[0]).abs() > 1e-6, "f_ua scaling should change residuals" ); } #[test] fn test_signature_and_to_params() { let fc = make_exchanger_active(); let sig = fc.signature(); assert!(sig.contains("FreeCoolingExchanger")); assert!(sig.contains("fc_test")); assert!(sig.contains(&format!("{}", fc.config.effectiveness))); let params = fc.to_params(); let json = serde_json::to_string(¶ms).unwrap(); assert!(json.contains("FreeCoolingExchanger")); assert!(json.contains("fc_test")); } #[test] fn test_set_calib_indices() { let mut fc = make_exchanger_active(); let indices = CalibIndices { f_ua: Some(10), f_dp: Some(20), ..Default::default() }; fc.set_calib_indices(indices); assert_eq!(fc.calib_indices.f_ua, Some(10)); assert_eq!(fc.calib_indices.f_dp, Some(20)); } #[test] fn test_effectiveness_counter_flow() { let fc = make_exchanger_active(); // Balanced flow (Cr ≈ 1): ε = NTU / (1 + NTU) let c = 0.5 * CP_WATER; // 2093 W/K let ua = 10_000.0; let eps = fc.compute_effectiveness(ua, c, c); let expected_ntu = ua / c; let expected_eps = expected_ntu / (1.0 + expected_ntu); assert!((eps - expected_eps).abs() < 1e-10); // UA = 0 → ε = 0 assert_eq!(fc.compute_effectiveness(0.0, c, c), 0.0); // C_min = 0 → ε = 0 assert_eq!(fc.compute_effectiveness(ua, 0.0, c), 0.0); } #[test] fn test_n_equations() { let fc = make_exchanger_active(); assert_eq!(fc.n_equations(), 4); } #[test] fn test_get_ports() { let fc = make_exchanger_active(); let ports = fc.get_ports(); assert_eq!(ports.len(), 4); } #[test] fn test_residual_dimensions_validation() { let fc = make_exchanger_active(); let mut residuals = vec![0.0; 2]; // Too small let result = fc.compute_residuals(&[], &mut residuals); assert!(result.is_err()); } #[test] fn test_operational_state_mapping() { let mut fc = make_exchanger_active(); assert_eq!(fc.operational_state(), OperationalState::On); fc.set_operational_state(OperationalState::Bypass).unwrap(); assert_eq!(fc.operational_state(), OperationalState::Bypass); assert_eq!(fc.current_mode(), FreeCoolingMode::Bypass); fc.set_operational_state(OperationalState::On).unwrap(); assert_eq!(fc.current_mode(), FreeCoolingMode::Active); } }