Files
Entropyk/crates/components/src/free_cooling_exchanger.rs

939 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Temperature>,
/// Calculated after convergence
heat_transfer_rate: Option<Power>,
/// Current effectiveness (can vary with flow rates)
current_effectiveness: f64,
/// Fluid backend for property calculations
fluid_backend: Option<Arc<dyn FluidBackend>>,
/// 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", &"<FluidBackend>")
.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<Self, 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(),
));
}
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<dyn FluidBackend>) {
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<Temperature>) -> 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<Power> {
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<Temperature> {
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<dyn FluidBackend>,
) {
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<Vec<Enthalpy>, 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(&params).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);
}
}