//! 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); } }