Files
Entropyk/crates/components/src/heat_exchanger/eps_ntu.rs

345 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}