Update project structure and configurations
This commit is contained in:
@@ -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(¶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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user