feat(components): add ThermoState generators and Eurovent backend demo

This commit is contained in:
Sepehr
2026-02-20 22:01:38 +01:00
parent 375d288950
commit 4a40fddfe3
271 changed files with 28614 additions and 447 deletions

View File

@@ -0,0 +1,344 @@
//! Effectiveness-NTU (ε-NTU) Model
//!
//! Implements the ε-NTU method for heat exchanger calculations.
//!
//! ## Theory
//!
//! The heat transfer rate is calculated as:
//!
//! $$\dot{Q} = \varepsilon \cdot \dot{Q}_{max} = \varepsilon \cdot C_{min} \cdot (T_{hot,in} - T_{cold,in})$$
//!
//! Where:
//! - $\varepsilon$: Effectiveness (0 to 1)
//! - $C_{min} = \min(\dot{m}_{hot} \cdot c_{p,hot}, \dot{m}_{cold} \cdot c_{p,cold})$: Minimum heat capacity rate
//! - $NTU = UA / C_{min}$: Number of Transfer Units
//! - $C_r = C_{min} / C_{max}$: Heat capacity ratio
//!
//! ## Zero-flow regularization (Story 3.5)
//!
//! When $C_{min} < 10^{-10}$ (e.g. zero mass flow on one side), heat transfer is set to zero
//! and divisions by $C_{min}$ or $C_r$ are avoided to prevent NaN/Inf.
//!
//! Note: This module uses `1e-10` kW/K for capacity rate regularization, which is appropriate
//! for the kW/K scale. For mass flow regularization at the kg/s scale, see
//! [`MIN_MASS_FLOW_REGULARIZATION_KG_S`](entropyk_core::MIN_MASS_FLOW_REGULARIZATION_KG_S).
//!
//! For counter-flow:
//! $$\varepsilon = \frac{1 - \exp(-NTU \cdot (1 - C_r))}{1 - C_r \cdot \exp(-NTU \cdot (1 - C_r))}$$
use super::model::{FluidState, HeatTransferModel};
use crate::ResidualVector;
use entropyk_core::Power;
/// Heat exchanger type for ε-NTU calculations.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ExchangerType {
/// Counter-flow (most efficient)
#[default]
CounterFlow,
/// Parallel-flow (co-current)
ParallelFlow,
/// Cross-flow, both fluids unmixed
CrossFlowUnmixed,
/// Cross-flow, one fluid mixed (C_max mixed)
CrossFlowMixedMax,
/// Cross-flow, one fluid mixed (C_min mixed)
CrossFlowMixedMin,
/// Shell-and-tube with specified number of shell passes
ShellAndTube {
/// Number of shell passes
passes: usize,
},
}
/// ε-NTU (Effectiveness-NTU) heat transfer model.
///
/// Uses the effectiveness-NTU method for heat exchanger rating.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::{EpsNtuModel, ExchangerType, HeatTransferModel};
///
/// let model = EpsNtuModel::new(5000.0, ExchangerType::CounterFlow);
/// assert_eq!(model.ua(), 5000.0);
/// ```
#[derive(Debug, Clone)]
pub struct EpsNtuModel {
/// Overall heat transfer coefficient × Area (W/K), nominal
ua: f64,
/// UA calibration scale: UA_eff = ua_scale × ua (default 1.0)
ua_scale: f64,
/// Heat exchanger type
exchanger_type: ExchangerType,
}
impl EpsNtuModel {
/// Creates a new ε-NTU model.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K). Must be non-negative.
/// * `exchanger_type` - Type of heat exchanger
///
/// # Panics
///
/// Panics if `ua` is negative or NaN.
pub fn new(ua: f64, exchanger_type: ExchangerType) -> Self {
assert!(
ua.is_finite() && ua >= 0.0,
"UA must be non-negative and finite, got {}",
ua
);
Self {
ua,
ua_scale: 1.0,
exchanger_type,
}
}
/// Creates a counter-flow ε-NTU model.
pub fn counter_flow(ua: f64) -> Self {
Self::new(ua, ExchangerType::CounterFlow)
}
/// Creates a parallel-flow ε-NTU model.
pub fn parallel_flow(ua: f64) -> Self {
Self::new(ua, ExchangerType::ParallelFlow)
}
/// Creates a cross-flow (unmixed) ε-NTU model.
pub fn cross_flow_unmixed(ua: f64) -> Self {
Self::new(ua, ExchangerType::CrossFlowUnmixed)
}
/// Calculates the effectiveness ε.
///
/// # Arguments
///
/// * `ntu` - Number of Transfer Units (UA / C_min)
/// * `c_r` - Heat capacity ratio (C_min / C_max)
///
/// # Returns
///
/// The effectiveness ε (0 to 1)
pub fn effectiveness(&self, ntu: f64, c_r: f64) -> f64 {
if ntu <= 0.0 {
return 0.0;
}
match self.exchanger_type {
ExchangerType::CounterFlow => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
let exp_term = (-ntu * (1.0 - c_r)).exp();
(1.0 - exp_term) / (1.0 - c_r * exp_term)
}
}
ExchangerType::ParallelFlow => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
(1.0 - (-ntu * (1.0 + c_r)).exp()) / (1.0 + c_r)
}
}
ExchangerType::CrossFlowUnmixed => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
1.0 - (-c_r * (1.0 - (-ntu / c_r).exp())).exp()
}
}
ExchangerType::CrossFlowMixedMax => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
let ntu_c_r = ntu / c_r;
(1.0 - (-ntu_c_r).exp()) / c_r * (1.0 - (-c_r * ntu).exp())
}
}
ExchangerType::CrossFlowMixedMin => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
(1.0 / c_r) * (1.0 - (-c_r * (1.0 - (-ntu).exp())).exp())
}
}
ExchangerType::ShellAndTube { passes: _ } => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
(1.0 - (-ntu * (1.0 + c_r * c_r).sqrt()).exp()) / (1.0 + c_r)
}
}
}
}
/// Calculates the maximum possible heat transfer rate.
///
/// Q̇_max = C_min × (T_hot,in - T_cold,in)
pub fn q_max(&self, c_min: f64, t_hot_in: f64, t_cold_in: f64) -> f64 {
c_min * (t_hot_in - t_cold_in).max(0.0)
}
}
impl HeatTransferModel for EpsNtuModel {
fn compute_heat_transfer(
&self,
hot_inlet: &FluidState,
_hot_outlet: &FluidState,
cold_inlet: &FluidState,
_cold_outlet: &FluidState,
) -> Power {
let c_hot = hot_inlet.heat_capacity_rate();
let c_cold = cold_inlet.heat_capacity_rate();
let (c_min, c_max) = if c_hot < c_cold {
(c_hot, c_cold)
} else {
(c_cold, c_hot)
};
if c_min < 1e-10 {
return Power::from_watts(0.0);
}
let c_r = c_min / c_max;
let ntu = self.effective_ua() / c_min;
let effectiveness = self.effectiveness(ntu, c_r);
let q_max = self.q_max(c_min, hot_inlet.temperature, cold_inlet.temperature);
Power::from_watts(effectiveness * q_max)
}
fn compute_residuals(
&self,
hot_inlet: &FluidState,
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
residuals: &mut ResidualVector,
) {
let q = self
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
.to_watts();
let q_hot =
hot_inlet.mass_flow * hot_inlet.cp * (hot_inlet.temperature - hot_outlet.temperature);
let q_cold = cold_inlet.mass_flow
* cold_inlet.cp
* (cold_outlet.temperature - cold_inlet.temperature);
residuals[0] = q_hot - q;
residuals[1] = q_cold - q;
residuals[2] = q_hot - q_cold;
}
fn n_equations(&self) -> usize {
3
}
fn ua(&self) -> f64 {
self.ua
}
fn ua_scale(&self) -> f64 {
self.ua_scale
}
fn set_ua_scale(&mut self, s: f64) {
self.ua_scale = s;
}
fn effective_ua(&self) -> f64 {
self.ua * self.ua_scale
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_eps_ntu_model_creation() {
let model = EpsNtuModel::new(5000.0, ExchangerType::CounterFlow);
assert_eq!(model.ua(), 5000.0);
}
#[test]
fn test_effectiveness_counter_flow() {
let model = EpsNtuModel::counter_flow(5000.0);
let eps = model.effectiveness(5.0, 0.5);
assert!(eps > 0.0 && eps < 1.0);
let eps_cr_zero = model.effectiveness(5.0, 0.0);
assert!((eps_cr_zero - (1.0 - (-5.0_f64).exp())).abs() < 1e-10);
}
#[test]
fn test_effectiveness_parallel_flow() {
let model = EpsNtuModel::parallel_flow(5000.0);
let eps = model.effectiveness(5.0, 0.5);
assert!(eps > 0.0 && eps < 1.0);
assert!(eps < model.effectiveness(5.0, 0.5) + 0.1);
}
#[test]
fn test_effectiveness_zero_ntu() {
let model = EpsNtuModel::counter_flow(5000.0);
let eps = model.effectiveness(0.0, 0.5);
assert_eq!(eps, 0.0);
}
#[test]
fn test_compute_heat_transfer() {
let model = EpsNtuModel::counter_flow(5000.0);
let hot_inlet = FluidState::new(80.0 + 273.15, 101_325.0, 400_000.0, 0.1, 1000.0);
let hot_outlet = FluidState::new(60.0 + 273.15, 101_325.0, 380_000.0, 0.1, 1000.0);
let cold_inlet = FluidState::new(20.0 + 273.15, 101_325.0, 80_000.0, 0.2, 4180.0);
let cold_outlet = FluidState::new(30.0 + 273.15, 101_325.0, 120_000.0, 0.2, 4180.0);
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet);
assert!(q.to_watts() > 0.0);
}
#[test]
fn test_n_equations() {
let model = EpsNtuModel::counter_flow(1000.0);
assert_eq!(model.n_equations(), 3);
}
#[test]
fn test_q_max() {
let model = EpsNtuModel::counter_flow(5000.0);
let c_min = 1000.0;
let t_hot_in = 350.0;
let t_cold_in = 300.0;
let q_max = model.q_max(c_min, t_hot_in, t_cold_in);
assert_eq!(q_max, 50_000.0);
}
#[test]
#[should_panic(expected = "UA must be non-negative")]
fn test_negative_ua_panics() {
let _model = EpsNtuModel::new(-1000.0, ExchangerType::CounterFlow);
}
#[test]
fn test_effectiveness_cross_flow_unmixed_cr_zero() {
let model = EpsNtuModel::cross_flow_unmixed(5000.0);
let eps = model.effectiveness(5.0, 0.0);
let expected = 1.0 - (-5.0_f64).exp();
assert!((eps - expected).abs() < 1e-10);
}
}