Update project structure and configurations

This commit is contained in:
2026-05-23 10:19:55 +02:00
parent ab5dc7e568
commit 62efea0646
1832 changed files with 83568 additions and 51829 deletions

View File

@@ -2,18 +2,21 @@
//!
//! 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 entropyk_core::{Power, Temperature};
use entropyk_fluids::FluidBackend;
use crate::{
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
ResidualVector, SystemState,
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 {
@@ -22,7 +25,10 @@ pub enum FreeCoolingMode {
/// Full bypass (no heat exchange)
Bypass,
/// Mixed mode (partial bypass)
Mixed { bypass_fraction: f64 },
Mixed {
/// Fraction of flow that bypasses the heat exchanger
bypass_fraction: f64,
},
}
/// Configuration for the free cooling heat exchanger
@@ -38,6 +44,20 @@ pub struct FreeCoolingConfig {
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
@@ -62,10 +82,7 @@ pub struct FreeCoolingExchanger {
/// Current mode
mode: FreeCoolingMode,
/// Ports (4 ports: cold water in/out, hot water in/out)
port_cold_inlet: ConnectedPort,
port_cold_outlet: ConnectedPort,
port_hot_inlet: ConnectedPort,
port_hot_outlet: ConnectedPort,
ports: [ConnectedPort; 4],
/// Outdoor temperature (for auto mode)
outdoor_temp: Option<Temperature>,
/// Calculated after convergence
@@ -74,6 +91,12 @@ pub struct FreeCoolingExchanger {
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 {
@@ -86,11 +109,19 @@ impl std::fmt::Debug for FreeCoolingExchanger {
.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(
@@ -102,7 +133,6 @@ impl FreeCoolingExchanger {
port_hot_inlet: ConnectedPort,
port_hot_outlet: ConnectedPort,
) -> Result<Self, ComponentError> {
// Validate parameters
if config.effectiveness < 0.0 || config.effectiveness > 1.0 {
return Err(ComponentError::InvalidState(
"Effectiveness must be between 0.0 and 1.0".to_string(),
@@ -119,15 +149,15 @@ impl FreeCoolingExchanger {
id: id.to_string(),
circuit_id,
config,
mode: FreeCoolingMode::Bypass, // Starts in bypass
port_cold_inlet,
port_cold_outlet,
port_hot_inlet,
port_hot_outlet,
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(),
})
}
@@ -136,21 +166,49 @@ impl FreeCoolingExchanger {
self.fluid_backend = Some(backend);
}
/// Calculates maximum possible heat transfer
fn calculate_max_heat_transfer(&self, state: &SystemState) -> Result<Power, ComponentError> {
// Get inlet temperatures
let t_cold_in = self.get_cold_inlet_temp(state)?;
let t_hot_in = self.get_hot_inlet_temp(state)?;
// Heat capacity rates
let c_cold = self.get_cold_capacity_rate(state)?;
let c_hot = self.get_hot_capacity_rate(state)?;
/// 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 };
// Maximum heat transfer
let q_max = c_min * (t_hot_in - t_cold_in);
if c_min <= 0.0 || ua <= 0.0 {
return 0.0;
}
Ok(Power::from_watts(q_max.max(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
@@ -160,96 +218,53 @@ impl FreeCoolingExchanger {
match self.config.control_mode {
FreeCoolingControlMode::AutoTemperature => {
let t_cold_in = self.get_current_cold_inlet_temp()?;
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);
// Switching logic with hysteresis
if self.mode == FreeCoolingMode::Bypass {
// Check if we can switch to free cooling
if t_outdoor.0 < (t_cold_in - self.config.min_outdoor_temp) {
self.mode = FreeCoolingMode::Active;
match self.mode {
FreeCoolingMode::Bypass => {
if t_outdoor.0 < (t_cold_in - self.config.min_outdoor_temp) {
self.mode = FreeCoolingMode::Active;
}
}
} else {
// Check if we should go back to bypass
if t_outdoor.0
> (t_cold_in - self.config.min_outdoor_temp + self.config.hysteresis)
{
self.mode = FreeCoolingMode::Bypass;
_ => {
if t_outdoor.0
> (t_cold_in - self.config.min_outdoor_temp + self.config.hysteresis)
{
self.mode = FreeCoolingMode::Bypass;
}
}
}
}
FreeCoolingControlMode::Optimized => {
// TODO: Implement energy optimization
self.mode = FreeCoolingMode::Active;
}
FreeCoolingControlMode::Manual => {
// Do nothing, fixed mode
}
FreeCoolingControlMode::Manual => {}
}
}
Ok(())
}
/// Helper methods for temperature and flow calculations
fn get_cold_inlet_temp(&self, _state: &SystemState) -> Result<f64, ComponentError> {
// Placeholder - would extract from state vector
Ok(285.15) // 12°C
/// Sets the f_ua calibration factor
pub fn set_f_ua(&mut self, f_ua: f64) {
self.f_ua = f_ua;
}
fn get_hot_inlet_temp(&self, _state: &SystemState) -> Result<f64, ComponentError> {
// Placeholder - would extract from state vector
Ok(298.15) // 25°C
/// Returns the f_ua calibration factor
pub fn f_ua(&self) -> f64 {
self.f_ua
}
fn get_cold_capacity_rate(&self, _state: &SystemState) -> Result<f64, ComponentError> {
// Placeholder - would calculate from mass flow and specific heat
Ok(4186.0 * 0.1) // Water at 0.1 kg/s
/// Sets the f_dp calibration factor
pub fn set_f_dp(&mut self, f_dp: f64) {
self.f_dp = f_dp;
}
fn get_hot_capacity_rate(&self, _state: &SystemState) -> Result<f64, ComponentError> {
// Placeholder - would calculate from mass flow and specific heat
Ok(4186.0 * 0.1) // Water at 0.1 kg/s
/// Returns the f_dp calibration factor
pub fn f_dp(&self) -> f64 {
self.f_dp
}
fn get_current_cold_inlet_temp(&self) -> Result<f64, ComponentError> {
Ok(285.15) // Placeholder
}
}
impl Component for FreeCoolingExchanger {
fn n_equations(&self) -> usize {
// 4 equations for energy balances at each port
// + 1 equation for heat transfer
// + 1 equation for flow continuity
6
}
fn compute_residuals(
&self,
_state: &[f64],
_residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// TODO: Implement actual residual calculations
// For now, return zero residuals
Ok(())
}
fn jacobian_entries(
&self,
_state: &[f64],
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// TODO: Implement partial derivatives
Ok(())
}
fn get_ports(&self) -> &[ConnectedPort] {
// Return the 4 ports
&[] // Placeholder
}
}
/// Specific methods for FreeCoolingExchanger
impl FreeCoolingExchanger {
/// Returns the current operational state
pub fn operational_state(&self) -> OperationalState {
match self.mode {
@@ -282,10 +297,7 @@ impl FreeCoolingExchanger {
/// Returns estimated energy savings (in %)
pub fn energy_savings_percent(&self) -> f64 {
match self.mode {
FreeCoolingMode::Active => {
// Estimation based on effectiveness
self.current_effectiveness * 100.0
}
FreeCoolingMode::Active => self.current_effectiveness * 100.0,
FreeCoolingMode::Bypass => 0.0,
FreeCoolingMode::Mixed { bypass_fraction } => {
self.current_effectiveness * bypass_fraction * 100.0
@@ -300,7 +312,6 @@ impl FreeCoolingExchanger {
/// Updates configuration
pub fn update_config(&mut self, config: FreeCoolingConfig) -> Result<(), ComponentError> {
// Validation
if config.effectiveness < 0.0 || config.effectiveness > 1.0 {
return Err(ComponentError::InvalidState(
"Effectiveness must be between 0.0 and 1.0".to_string(),
@@ -318,13 +329,9 @@ impl FreeCoolingExchanger {
/// Calculates effective COP (very high in free cooling)
pub fn effective_cop(&self) -> f64 {
match self.mode {
FreeCoolingMode::Active => {
// Typical COP > 20 for free cooling (only pumps)
20.0 + self.current_effectiveness * 10.0
}
FreeCoolingMode::Bypass => 1.0, // No gain
FreeCoolingMode::Active => 20.0 + self.current_effectiveness * 10.0,
FreeCoolingMode::Bypass => 1.0,
FreeCoolingMode::Mixed { bypass_fraction } => {
// Weighted COP
let cop_fc = 20.0 + self.current_effectiveness * 10.0;
bypass_fraction * cop_fc + (1.0 - bypass_fraction) * 1.0
}
@@ -340,6 +347,224 @@ impl FreeCoolingExchanger {
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 {
@@ -347,9 +572,16 @@ impl Default for FreeCoolingConfig {
Self {
effectiveness: 0.85,
bypass_fraction: 0.2,
min_outdoor_temp: 285.15, // 12°C
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,
}
}
}
@@ -358,9 +590,8 @@ impl Default for FreeCoolingConfig {
mod tests {
use super::*;
use crate::port::{FluidId, Port};
use entropyk_core::{Enthalpy, Pressure};
use entropyk_core::Pressure;
/// Creates a pair of connected ports for tests (same fluid, P, h).
fn make_connected_ports() -> (ConnectedPort, ConnectedPort) {
let fluid = FluidId::new("Water");
let p = Pressure::from_pascals(3e5);
@@ -370,6 +601,45 @@ mod tests {
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();
@@ -416,17 +686,16 @@ mod tests {
#[test]
fn test_energy_savings_calculation() {
let config = FreeCoolingConfig {
effectiveness: 0.85,
..Default::default()
};
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),
config,
FreeCoolingConfig {
effectiveness: 0.85,
..Default::default()
},
cold_in,
cold_out,
hot_in,
@@ -434,18 +703,18 @@ mod tests {
)
.unwrap();
// Bypass mode -> 0% savings
assert_eq!(exchanger.energy_savings_percent(), 0.0);
// Active mode -> effectiveness * 100%
exchanger.mode = FreeCoolingMode::Active;
assert_eq!(exchanger.energy_savings_percent(), 85.0);
// Mixed mode
exchanger.mode = FreeCoolingMode::Mixed {
bypass_fraction: 0.3,
};
assert_eq!(exchanger.energy_savings_percent(), 85.0 * 0.3);
let expected = 85.0 * 0.3;
assert!(
(exchanger.energy_savings_percent() - expected).abs() < 1e-10
);
}
#[test]
@@ -464,12 +733,206 @@ mod tests {
)
.unwrap();
// COP in free cooling
exchanger.mode = FreeCoolingMode::Active;
assert!(exchanger.effective_cop() > 20.0);
// COP in bypass
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);
}
}