939 lines
31 KiB
Rust
939 lines
31 KiB
Rust
//! 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(¶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);
|
||
}
|
||
}
|