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,249 @@
//! Condenser Component
//!
//! A heat exchanger configured for refrigerant condensation.
//! The refrigerant (hot side) condenses from superheated vapor to
//! subcooled liquid, releasing heat to the cold side.
use super::exchanger::HeatExchanger;
use super::lmtd::{FlowConfiguration, LmtdModel};
use entropyk_core::Calib;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
/// Condenser heat exchanger.
///
/// Uses the LMTD method for heat transfer calculation.
/// The refrigerant condenses on the hot side, releasing heat
/// to the cold side (typically water or air).
///
/// # Configuration
///
/// - Hot side: Refrigerant condensing (phase change)
/// - Cold side: Heat sink (water, air, etc.)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Condenser;
/// use entropyk_components::Component;
///
/// let condenser = Condenser::new(10_000.0); // UA = 10 kW/K
/// assert_eq!(condenser.n_equations(), 3);
/// ```
#[derive(Debug)]
pub struct Condenser {
/// Inner heat exchanger with LMTD model
inner: HeatExchanger<LmtdModel>,
/// Saturation temperature for condensation (K)
saturation_temp: f64,
}
impl Condenser {
/// Creates a new condenser with the given UA value.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Condenser;
///
/// let condenser = Condenser::new(15_000.0);
/// ```
pub fn new(ua: f64) -> Self {
let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Condenser"),
saturation_temp: 323.15,
}
}
/// Creates a condenser with a specific saturation temperature.
pub fn with_saturation_temp(ua: f64, saturation_temp: f64) -> Self {
let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Condenser"),
saturation_temp,
}
}
/// Returns the name of this condenser.
pub fn name(&self) -> &str {
self.inner.name()
}
/// Returns the UA value (effective: f_ua × UA_nominal).
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns calibration factors (f_ua for condenser).
pub fn calib(&self) -> &Calib {
self.inner.calib()
}
/// Sets calibration factors.
pub fn set_calib(&mut self, calib: Calib) {
self.inner.set_calib(calib);
}
/// Returns the saturation temperature.
pub fn saturation_temp(&self) -> f64 {
self.saturation_temp
}
/// Sets the saturation temperature.
pub fn set_saturation_temp(&mut self, temp: f64) {
self.saturation_temp = temp;
}
/// Validates that the outlet quality is <= 1 (fully condensed or subcooled).
///
/// # Arguments
///
/// * `outlet_enthalpy` - Outlet specific enthalpy (J/kg)
/// * `h_liquid` - Saturated liquid enthalpy at condensing pressure
/// * `h_vapor` - Saturated vapor enthalpy at condensing pressure
///
/// # Returns
///
/// Returns Ok(true) if fully condensed, Err otherwise
pub fn validate_outlet_quality(
&self,
outlet_enthalpy: f64,
h_liquid: f64,
h_vapor: f64,
) -> Result<bool, ComponentError> {
if h_vapor <= h_liquid {
return Err(ComponentError::NumericalError(
"Invalid saturation enthalpies".to_string(),
));
}
let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid);
if quality <= 1.0 + 1e-6 {
Ok(true)
} else {
Err(ComponentError::InvalidState(format!(
"Condenser outlet quality {} > 1 (superheated)",
quality
)))
}
}
/// Computes the full thermodynamic state at the hot inlet.
pub fn hot_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
self.inner.hot_inlet_state()
}
/// Computes the full thermodynamic state at the cold inlet.
pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
self.inner.cold_inlet_state()
}
}
impl Component for Condenser {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
}
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
}
impl StateManageable for Condenser {
fn state(&self) -> OperationalState {
self.inner.state()
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
self.inner.set_state(state)
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.inner.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
self.inner.circuit_id()
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.inner.set_circuit_id(circuit_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_condenser_creation() {
let condenser = Condenser::new(10_000.0);
assert_eq!(condenser.ua(), 10_000.0);
assert_eq!(condenser.n_equations(), 3);
}
#[test]
fn test_condenser_with_saturation_temp() {
let condenser = Condenser::with_saturation_temp(10_000.0, 323.15);
assert_eq!(condenser.saturation_temp(), 323.15);
}
#[test]
fn test_validate_outlet_quality_fully_condensed() {
let condenser = Condenser::new(10_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 180_000.0;
let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
assert!(result.is_ok());
}
#[test]
fn test_validate_outlet_quality_superheated() {
let condenser = Condenser::new(10_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 450_000.0;
let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
assert!(result.is_err());
}
#[test]
fn test_compute_residuals() {
let condenser = Condenser::new(10_000.0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = condenser.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
}

View File

@@ -0,0 +1,195 @@
//! Condenser Coil Component
//!
//! An air-side (finned) heat exchanger for refrigerant condensation.
//! The refrigerant (hot side) condenses, releasing heat to air (cold side).
//! Used in split systems and air-source heat pumps.
//!
//! ## Port Convention
//!
//! - **Hot side (refrigerant)**: Condensing
//! - **Cold side (air)**: Heat sink — connect to Fan outlet/inlet
//!
//! ## Integration with Fan
//!
//! Connect Fan outlet → CondenserCoil air inlet, CondenserCoil air outlet → Fan inlet.
//! Use `FluidId::new("Air")` for air ports.
use super::condenser::Condenser;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
/// Condenser coil (air-side finned heat exchanger).
///
/// Explicit component for air-source condensers. Uses LMTD method.
/// Refrigerant condenses on hot side, air on cold side.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::CondenserCoil;
/// use entropyk_components::Component;
///
/// let coil = CondenserCoil::new(10_000.0); // UA = 10 kW/K
/// assert_eq!(coil.ua(), 10_000.0);
/// assert_eq!(coil.n_equations(), 3);
/// ```
#[derive(Debug)]
pub struct CondenserCoil {
inner: Condenser,
}
impl CondenserCoil {
/// Creates a new condenser coil with the given UA value.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
pub fn new(ua: f64) -> Self {
Self {
inner: Condenser::new(ua),
}
}
/// Creates a condenser coil with a specific saturation temperature.
pub fn with_saturation_temp(ua: f64, saturation_temp: f64) -> Self {
Self {
inner: Condenser::with_saturation_temp(ua, saturation_temp),
}
}
/// Returns the name of this component.
pub fn name(&self) -> &str {
"CondenserCoil"
}
/// Returns the UA value.
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns the saturation temperature.
pub fn saturation_temp(&self) -> f64 {
self.inner.saturation_temp()
}
/// Sets the saturation temperature.
pub fn set_saturation_temp(&mut self, temp: f64) {
self.inner.set_saturation_temp(temp);
}
}
impl Component for CondenserCoil {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
}
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
}
impl StateManageable for CondenserCoil {
fn state(&self) -> OperationalState {
self.inner.state()
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
self.inner.set_state(state)
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.inner.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
self.inner.circuit_id()
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.inner.set_circuit_id(circuit_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_condenser_coil_creation() {
let coil = CondenserCoil::new(10_000.0);
assert_eq!(coil.ua(), 10_000.0);
assert_eq!(coil.name(), "CondenserCoil");
}
#[test]
fn test_condenser_coil_n_equations() {
let coil = CondenserCoil::new(10_000.0);
assert_eq!(coil.n_equations(), 3);
}
#[test]
fn test_condenser_coil_with_saturation_temp() {
let coil = CondenserCoil::with_saturation_temp(10_000.0, 323.15);
assert_eq!(coil.saturation_temp(), 323.15);
}
#[test]
fn test_condenser_coil_compute_residuals() {
let coil = CondenserCoil::new(10_000.0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = coil.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(residuals.iter().all(|r| r.is_finite()), "residuals must be finite");
}
#[test]
fn test_condenser_coil_jacobian_entries() {
let coil = CondenserCoil::new(10_000.0);
let state = vec![0.0; 10];
let mut jacobian = crate::JacobianBuilder::new();
let result = coil.jacobian_entries(&state, &mut jacobian);
assert!(result.is_ok());
// HeatExchanger base returns empty jacobian until framework implements it
assert!(
jacobian.is_empty(),
"delegation works; empty jacobian expected until HeatExchanger implements entries"
);
}
#[test]
fn test_condenser_coil_set_saturation_temp() {
let mut coil = CondenserCoil::new(10_000.0);
coil.set_saturation_temp(320.0);
assert!((coil.saturation_temp() - 320.0).abs() < 1e-10);
}
#[test]
fn test_condenser_coil_state_manageable() {
use crate::state_machine::{OperationalState, StateManageable};
let mut coil = CondenserCoil::new(10_000.0);
assert_eq!(coil.state(), OperationalState::On);
assert!(coil.can_transition_to(OperationalState::Off));
assert!(coil.set_state(OperationalState::Off).is_ok());
assert_eq!(coil.state(), OperationalState::Off);
}
}

View File

@@ -0,0 +1,251 @@
//! Economizer Component
//!
//! An internal heat exchanger with bypass support for refrigeration systems.
/// Can be switched between ON (active heat exchange), OFF (no flow), and
/// BYPASS (adiabatic pipe) modes.
use super::exchanger::HeatExchanger;
use super::lmtd::{FlowConfiguration, LmtdModel};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState, ResidualVector,
SystemState,
};
/// Economizer (internal heat exchanger) with state machine support.
///
/// The economizer can operate in three modes:
/// - **ON**: Normal heat exchange between suction and liquid lines
/// - **OFF**: No mass flow contribution (component disabled)
/// - **BYPASS**: Adiabatic pipe (P_in = P_out, h_in = h_out)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Economizer;
/// use entropyk_components::OperationalState;
///
/// let mut economizer = Economizer::new(2_000.0);
/// assert_eq!(economizer.state(), OperationalState::On);
///
/// economizer.set_state(OperationalState::Bypass);
/// assert_eq!(economizer.state(), OperationalState::Bypass);
/// ```
#[derive(Debug)]
pub struct Economizer {
/// Inner heat exchanger with LMTD model
inner: HeatExchanger<LmtdModel>,
/// Operational state
state: OperationalState,
}
impl Economizer {
/// Creates a new economizer with the given UA value.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Economizer;
///
/// let economizer = Economizer::new(2_000.0);
/// ```
pub fn new(ua: f64) -> Self {
let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Economizer"),
state: OperationalState::On,
}
}
/// Creates an economizer in a specific state.
pub fn with_state(ua: f64, state: OperationalState) -> Self {
let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Economizer"),
state,
}
}
/// Returns the name of this economizer.
pub fn name(&self) -> &str {
self.inner.name()
}
/// Returns the UA value.
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns the current operational state.
pub fn state(&self) -> OperationalState {
self.state
}
/// Sets the operational state.
pub fn set_state(&mut self, state: OperationalState) {
self.state = state;
}
/// Returns true if the economizer is active (ON or BYPASS).
pub fn is_active(&self) -> bool {
self.state.is_active()
}
/// Returns true if in bypass mode (adiabatic pipe behavior).
pub fn is_bypass(&self) -> bool {
self.state.is_bypass()
}
/// Returns the mass flow multiplier based on state.
pub fn mass_flow_multiplier(&self) -> f64 {
self.state.mass_flow_multiplier()
}
/// Computes bypass residuals (P_in = P_out, h_in = h_out).
fn compute_bypass_residuals(&self, residuals: &mut ResidualVector) {
residuals[0] = 0.0;
residuals[1] = 0.0;
residuals[2] = 0.0;
}
/// Computes off residuals (zero flow).
fn compute_off_residuals(&self, residuals: &mut ResidualVector) {
residuals[0] = 0.0;
residuals[1] = 0.0;
residuals[2] = 0.0;
}
}
impl Component for Economizer {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() < self.n_equations() {
return Err(ComponentError::InvalidResidualDimensions {
expected: self.n_equations(),
actual: residuals.len(),
});
}
match self.state {
OperationalState::On => self.inner.compute_residuals(state, residuals),
OperationalState::Off => {
self.compute_off_residuals(residuals);
Ok(())
}
OperationalState::Bypass => {
self.compute_bypass_residuals(residuals);
Ok(())
}
}
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
match self.state {
OperationalState::On => self.inner.jacobian_entries(state, jacobian),
OperationalState::Off | OperationalState::Bypass => Ok(()),
}
}
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_economizer_creation() {
let economizer = Economizer::new(2_000.0);
assert_eq!(economizer.ua(), 2_000.0);
assert_eq!(economizer.state(), OperationalState::On);
}
#[test]
fn test_economizer_with_state() {
let economizer = Economizer::with_state(2_000.0, OperationalState::Bypass);
assert_eq!(economizer.state(), OperationalState::Bypass);
}
#[test]
fn test_state_transitions() {
let mut economizer = Economizer::new(2_000.0);
assert!(economizer.is_active());
assert!(!economizer.is_bypass());
economizer.set_state(OperationalState::Bypass);
assert!(economizer.is_active());
assert!(economizer.is_bypass());
economizer.set_state(OperationalState::Off);
assert!(!economizer.is_active());
assert!(!economizer.is_bypass());
}
#[test]
fn test_mass_flow_multiplier() {
let mut economizer = Economizer::new(2_000.0);
assert_eq!(economizer.mass_flow_multiplier(), 1.0);
economizer.set_state(OperationalState::Bypass);
assert_eq!(economizer.mass_flow_multiplier(), 1.0);
economizer.set_state(OperationalState::Off);
assert_eq!(economizer.mass_flow_multiplier(), 0.0);
}
#[test]
fn test_compute_residuals_on() {
let economizer = Economizer::new(2_000.0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = economizer.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
#[test]
fn test_compute_residuals_bypass() {
let economizer = Economizer::with_state(2_000.0, OperationalState::Bypass);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = economizer.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
#[test]
fn test_compute_residuals_off() {
let economizer = Economizer::with_state(2_000.0, OperationalState::Off);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = economizer.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
#[test]
fn test_n_equations() {
let economizer = Economizer::new(2_000.0);
assert_eq!(economizer.n_equations(), 3);
}
}

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

View File

@@ -0,0 +1,292 @@
//! Evaporator Component
//!
//! A heat exchanger configured for refrigerant evaporation.
//! The refrigerant (cold side) evaporates from two-phase mixture to
/// superheated vapor, absorbing heat from the hot side.
use super::eps_ntu::{EpsNtuModel, ExchangerType};
use super::exchanger::HeatExchanger;
use entropyk_core::Calib;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
/// Evaporator heat exchanger.
///
/// Uses the ε-NTU method for heat transfer calculation.
/// The refrigerant evaporates on the cold side, absorbing heat
/// from the hot side (typically water or air).
///
/// # Configuration
///
/// - Hot side: Heat source (water, air, etc.)
/// - Cold side: Refrigerant evaporating (phase change)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Evaporator;
/// use entropyk_components::Component;
///
/// let evaporator = Evaporator::new(8_000.0); // UA = 8 kW/K
/// assert_eq!(evaporator.n_equations(), 3);
/// ```
#[derive(Debug)]
pub struct Evaporator {
/// Inner heat exchanger with ε-NTU model
inner: HeatExchanger<EpsNtuModel>,
/// Saturation temperature for evaporation (K)
saturation_temp: f64,
/// Target superheat (K)
superheat_target: f64,
}
impl Evaporator {
/// Creates a new evaporator with the given UA value.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Evaporator;
///
/// let evaporator = Evaporator::new(8_000.0);
/// ```
pub fn new(ua: f64) -> Self {
let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Evaporator"),
saturation_temp: 278.15,
superheat_target: 5.0,
}
}
/// Creates an evaporator with specific saturation and superheat.
pub fn with_superheat(ua: f64, saturation_temp: f64, superheat_target: f64) -> Self {
let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Evaporator"),
saturation_temp,
superheat_target,
}
}
/// Returns the name of this evaporator.
pub fn name(&self) -> &str {
self.inner.name()
}
/// Returns the UA value (effective: f_ua × UA_nominal).
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns calibration factors (f_ua for evaporator).
pub fn calib(&self) -> &Calib {
self.inner.calib()
}
/// Sets calibration factors.
pub fn set_calib(&mut self, calib: Calib) {
self.inner.set_calib(calib);
}
/// Returns the saturation temperature.
pub fn saturation_temp(&self) -> f64 {
self.saturation_temp
}
/// Returns the superheat target.
pub fn superheat_target(&self) -> f64 {
self.superheat_target
}
/// Sets the saturation temperature.
pub fn set_saturation_temp(&mut self, temp: f64) {
self.saturation_temp = temp;
}
/// Sets the superheat target.
pub fn set_superheat_target(&mut self, superheat: f64) {
self.superheat_target = superheat;
}
/// Validates that the outlet quality is >= 0 (fully evaporated or superheated).
///
/// # Arguments
///
/// * `outlet_enthalpy` - Outlet specific enthalpy (J/kg)
/// * `h_liquid` - Saturated liquid enthalpy at evaporating pressure
/// * `h_vapor` - Saturated vapor enthalpy at evaporating pressure
///
/// # Returns
///
/// Returns Ok(superheat) if valid, Err otherwise
pub fn validate_outlet_quality(
&self,
outlet_enthalpy: f64,
h_liquid: f64,
h_vapor: f64,
cp_vapor: f64,
) -> Result<f64, ComponentError> {
if h_vapor <= h_liquid {
return Err(ComponentError::NumericalError(
"Invalid saturation enthalpies".to_string(),
));
}
let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid);
if quality >= 0.0 - 1e-6 {
if outlet_enthalpy >= h_vapor {
let superheat = (outlet_enthalpy - h_vapor) / cp_vapor;
Ok(superheat)
} else {
Ok(0.0)
}
} else {
Err(ComponentError::InvalidState(format!(
"Evaporator outlet quality {} < 0 (subcooled)",
quality
)))
}
}
/// Calculates the superheat residual for inverse control.
///
/// Returns (actual_superheat - target_superheat)
pub fn superheat_residual(&self, actual_superheat: f64) -> f64 {
actual_superheat - self.superheat_target
}
/// Computes the full thermodynamic state at the hot inlet.
pub fn hot_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
self.inner.hot_inlet_state()
}
/// Computes the full thermodynamic state at the cold inlet.
pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
self.inner.cold_inlet_state()
}
}
impl Component for Evaporator {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
}
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
}
impl StateManageable for Evaporator {
fn state(&self) -> OperationalState {
self.inner.state()
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
self.inner.set_state(state)
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.inner.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
self.inner.circuit_id()
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.inner.set_circuit_id(circuit_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_evaporator_creation() {
let evaporator = Evaporator::new(8_000.0);
assert_eq!(evaporator.ua(), 8_000.0);
assert_eq!(evaporator.n_equations(), 3);
}
#[test]
fn test_evaporator_with_superheat() {
let evaporator = Evaporator::with_superheat(8_000.0, 278.15, 10.0);
assert_eq!(evaporator.saturation_temp(), 278.15);
assert_eq!(evaporator.superheat_target(), 10.0);
}
#[test]
fn test_validate_outlet_quality_superheated() {
let evaporator = Evaporator::new(8_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 420_000.0;
let cp_vapor = 1000.0;
let result = evaporator.validate_outlet_quality(outlet_h, h_liquid, h_vapor, cp_vapor);
assert!(result.is_ok());
let superheat = result.unwrap();
assert!((superheat - 20.0).abs() < 1e-10);
}
#[test]
fn test_validate_outlet_quality_subcooled() {
let evaporator = Evaporator::new(8_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 150_000.0;
let cp_vapor = 1000.0;
let result = evaporator.validate_outlet_quality(outlet_h, h_liquid, h_vapor, cp_vapor);
assert!(result.is_err());
}
#[test]
fn test_superheat_residual() {
let evaporator = Evaporator::with_superheat(8_000.0, 278.15, 5.0);
let residual = evaporator.superheat_residual(7.0);
assert!((residual - 2.0).abs() < 1e-10);
let residual = evaporator.superheat_residual(3.0);
assert!((residual - (-2.0)).abs() < 1e-10);
}
#[test]
fn test_compute_residuals() {
let evaporator = Evaporator::new(8_000.0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = evaporator.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
}

View File

@@ -0,0 +1,208 @@
//! Evaporator Coil Component
//!
//! An air-side (finned) heat exchanger for refrigerant evaporation.
//! The refrigerant (cold side) evaporates, absorbing heat from air (hot side).
//! Used in split systems and air-source heat pumps.
//!
//! ## Port Convention
//!
//! - **Hot side (air)**: Heat source — connect to Fan outlet/inlet
//! - **Cold side (refrigerant)**: Evaporating
//!
//! ## Integration with Fan
//!
//! Connect Fan outlet → EvaporatorCoil air inlet, EvaporatorCoil air outlet → Fan inlet.
//! Use `FluidId::new("Air")` for air ports.
use super::evaporator::Evaporator;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
/// Evaporator coil (air-side finned heat exchanger).
///
/// Explicit component for air-source evaporators. Uses ε-NTU method.
/// Refrigerant evaporates on cold side, air on hot side.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::EvaporatorCoil;
/// use entropyk_components::Component;
///
/// let coil = EvaporatorCoil::new(8_000.0); // UA = 8 kW/K
/// assert_eq!(coil.ua(), 8_000.0);
/// assert_eq!(coil.n_equations(), 3);
/// ```
#[derive(Debug)]
pub struct EvaporatorCoil {
inner: Evaporator,
}
impl EvaporatorCoil {
/// Creates a new evaporator coil with the given UA value.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
pub fn new(ua: f64) -> Self {
Self {
inner: Evaporator::new(ua),
}
}
/// Creates an evaporator coil with specific saturation and superheat.
pub fn with_superheat(ua: f64, saturation_temp: f64, superheat_target: f64) -> Self {
Self {
inner: Evaporator::with_superheat(ua, saturation_temp, superheat_target),
}
}
/// Returns the name of this component.
pub fn name(&self) -> &str {
"EvaporatorCoil"
}
/// Returns the UA value.
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns the saturation temperature.
pub fn saturation_temp(&self) -> f64 {
self.inner.saturation_temp()
}
/// Returns the superheat target.
pub fn superheat_target(&self) -> f64 {
self.inner.superheat_target()
}
/// Sets the saturation temperature.
pub fn set_saturation_temp(&mut self, temp: f64) {
self.inner.set_saturation_temp(temp);
}
/// Sets the superheat target.
pub fn set_superheat_target(&mut self, superheat: f64) {
self.inner.set_superheat_target(superheat);
}
}
impl Component for EvaporatorCoil {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
}
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
}
impl StateManageable for EvaporatorCoil {
fn state(&self) -> OperationalState {
self.inner.state()
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
self.inner.set_state(state)
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.inner.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
self.inner.circuit_id()
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.inner.set_circuit_id(circuit_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_evaporator_coil_creation() {
let coil = EvaporatorCoil::new(8_000.0);
assert_eq!(coil.ua(), 8_000.0);
assert_eq!(coil.name(), "EvaporatorCoil");
}
#[test]
fn test_evaporator_coil_n_equations() {
let coil = EvaporatorCoil::new(5_000.0);
assert_eq!(coil.n_equations(), 3);
}
#[test]
fn test_evaporator_coil_with_superheat() {
let coil = EvaporatorCoil::with_superheat(8_000.0, 278.15, 5.0);
assert_eq!(coil.saturation_temp(), 278.15);
assert_eq!(coil.superheat_target(), 5.0);
}
#[test]
fn test_evaporator_coil_compute_residuals() {
let coil = EvaporatorCoil::new(8_000.0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = coil.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(residuals.iter().all(|r| r.is_finite()), "residuals must be finite");
}
#[test]
fn test_evaporator_coil_jacobian_entries() {
let coil = EvaporatorCoil::new(8_000.0);
let state = vec![0.0; 10];
let mut jacobian = crate::JacobianBuilder::new();
let result = coil.jacobian_entries(&state, &mut jacobian);
assert!(result.is_ok());
// HeatExchanger base returns empty jacobian until framework implements it
assert!(
jacobian.is_empty(),
"delegation works; empty jacobian expected until HeatExchanger implements entries"
);
}
#[test]
fn test_evaporator_coil_setters() {
let mut coil = EvaporatorCoil::new(8_000.0);
coil.set_saturation_temp(275.0);
coil.set_superheat_target(7.0);
assert!((coil.saturation_temp() - 275.0).abs() < 1e-10);
assert!((coil.superheat_target() - 7.0).abs() < 1e-10);
}
#[test]
fn test_evaporator_coil_state_manageable() {
use crate::state_machine::{OperationalState, StateManageable};
let mut coil = EvaporatorCoil::new(8_000.0);
assert_eq!(coil.state(), OperationalState::On);
assert!(coil.can_transition_to(OperationalState::Off));
assert!(coil.set_state(OperationalState::Off).is_ok());
assert_eq!(coil.state(), OperationalState::Off);
}
}

View File

@@ -262,10 +262,34 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
self.fluid_backend.is_some()
}
/// Computes the full thermodynamic state at the hot inlet.
pub fn hot_inlet_state(&self) -> Result<ThermoState, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| ComponentError::CalculationFailed("No FluidBackend configured".to_string()))?;
let conditions = self.hot_conditions.as_ref().ok_or_else(|| ComponentError::CalculationFailed("Hot conditions not set".to_string()))?;
let h = self.query_enthalpy(conditions)?;
backend.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
).map_err(|e| ComponentError::CalculationFailed(format!("Failed to compute hot inlet state: {}", e)))
}
/// Computes the full thermodynamic state at the cold inlet.
pub fn cold_inlet_state(&self) -> Result<ThermoState, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| ComponentError::CalculationFailed("No FluidBackend configured".to_string()))?;
let conditions = self.cold_conditions.as_ref().ok_or_else(|| ComponentError::CalculationFailed("Cold conditions not set".to_string()))?;
let h = self.query_enthalpy(conditions)?;
backend.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
).map_err(|e| ComponentError::CalculationFailed(format!("Failed to compute cold inlet state: {}", e)))
}
/// Queries Cp (J/(kg·K)) from the backend for a given side.
fn query_cp(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
if let Some(backend) = &self.fluid_backend {
let state = ThermoState::from_pt(
let state = entropyk_fluids::FluidState::from_pt(
Pressure::from_pascals(conditions.pressure_pa()),
Temperature::from_kelvin(conditions.temperature_k()),
);
@@ -279,7 +303,7 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
/// Queries specific enthalpy (J/kg) from the backend for a given side at (P, T).
fn query_enthalpy(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
if let Some(backend) = &self.fluid_backend {
let state = ThermoState::from_pt(
let state = entropyk_fluids::FluidState::from_pt(
Pressure::from_pascals(conditions.pressure_pa()),
Temperature::from_kelvin(conditions.temperature_k()),
);

View File

@@ -0,0 +1,398 @@
//! Log Mean Temperature Difference (LMTD) Model
//!
//! Implements the LMTD method for heat exchanger calculations.
//!
//! ## Theory
//!
//! The heat transfer rate is calculated as:
//!
//! $$\dot{Q} = U \cdot A \cdot \Delta T_{lm} \cdot F$$
//!
//! Where:
//! - $\dot{Q}$: Heat transfer rate (W)
//! - $U$: Overall heat transfer coefficient (W/m²·K)
//! - $A$: Heat transfer area (m²)
//! - $\Delta T_{lm}$: Log mean temperature difference (K)
//! - $F$: Correction factor for flow configuration
//!
//! For counter-flow:
//! $$\Delta T_{lm} = \frac{\Delta T_1 - \Delta T_2}{\ln(\Delta T_1 / \Delta T_2)}$$
//!
//! Where:
//! - $\Delta T_1 = T_{hot,in} - T_{cold,out}$
//! - $\Delta T_2 = T_{hot,out} - T_{cold,in}$
use super::model::{FluidState, HeatTransferModel};
use crate::ResidualVector;
use entropyk_core::Power;
/// Flow configuration for the heat exchanger.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum FlowConfiguration {
/// Counter-flow (most efficient)
#[default]
CounterFlow,
/// Parallel-flow (co-current)
ParallelFlow,
/// Cross-flow with correction factor
CrossFlow {
/// Correction factor F (typically 0.8-1.0)
correction_factor: f64,
},
/// Shell-and-tube with 1 shell pass, 2 tube passes
ShellAndTube1_2,
}
impl FlowConfiguration {
/// Returns the correction factor F for this configuration.
pub fn correction_factor(&self) -> f64 {
match self {
FlowConfiguration::CounterFlow => 1.0,
FlowConfiguration::ParallelFlow => 1.0,
FlowConfiguration::CrossFlow { correction_factor } => *correction_factor,
FlowConfiguration::ShellAndTube1_2 => 0.9,
}
}
}
/// LMTD (Log Mean Temperature Difference) heat transfer model.
///
/// Uses the classical LMTD method for heat exchanger sizing and rating.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::{LmtdModel, FlowConfiguration, HeatTransferModel};
/// use entropyk_components::heat_exchanger::model::FluidState;
///
/// let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
/// assert_eq!(model.ua(), 5000.0);
/// ```
#[derive(Debug, Clone)]
pub struct LmtdModel {
/// Overall heat transfer coefficient × Area (W/K), nominal
ua: f64,
/// UA calibration scale: UA_eff = ua_scale × ua (default 1.0)
ua_scale: f64,
/// Flow configuration
flow_config: FlowConfiguration,
}
impl LmtdModel {
/// Creates a new LMTD model.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K). Must be positive.
/// * `flow_config` - Flow configuration (counter-flow, parallel-flow, etc.)
///
/// # Panics
///
/// Panics if `ua` is negative or NaN.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::{LmtdModel, FlowConfiguration};
///
/// let model = LmtdModel::new(10000.0, FlowConfiguration::CounterFlow);
/// ```
pub fn new(ua: f64, flow_config: FlowConfiguration) -> Self {
assert!(
ua.is_finite() && ua >= 0.0,
"UA must be non-negative and finite, got {}",
ua
);
Self {
ua,
ua_scale: 1.0,
flow_config,
}
}
/// Creates a counter-flow LMTD model.
pub fn counter_flow(ua: f64) -> Self {
Self::new(ua, FlowConfiguration::CounterFlow)
}
/// Creates a parallel-flow LMTD model.
pub fn parallel_flow(ua: f64) -> Self {
Self::new(ua, FlowConfiguration::ParallelFlow)
}
/// Creates a cross-flow LMTD model with correction factor.
pub fn cross_flow(ua: f64, correction_factor: f64) -> Self {
Self::new(ua, FlowConfiguration::CrossFlow { correction_factor })
}
/// Calculates the Log Mean Temperature Difference.
///
/// For counter-flow:
/// - ΔT₁ = T_hot,in - T_cold,out
/// - ΔT₂ = T_hot,out - T_cold,in
///
/// For parallel-flow:
/// - ΔT₁ = T_hot,in - T_cold,in
/// - ΔT₂ = T_hot,out - T_cold,out
///
/// Special handling when ΔT₁ ≈ ΔT₂: uses arithmetic mean.
pub fn lmtd(&self, t_hot_in: f64, t_hot_out: f64, t_cold_in: f64, t_cold_out: f64) -> f64 {
let (dt1, dt2) = match self.flow_config {
FlowConfiguration::CounterFlow
| FlowConfiguration::CrossFlow { .. }
| FlowConfiguration::ShellAndTube1_2 => (t_hot_in - t_cold_out, t_hot_out - t_cold_in),
FlowConfiguration::ParallelFlow => (t_hot_in - t_cold_in, t_hot_out - t_cold_out),
};
// Zero-flow / zero LMTD regularization: avoid division by zero (Story 3.5)
if dt1.abs() < 1e-10 && dt2.abs() < 1e-10 {
return 0.0;
}
if (dt1 - dt2).abs() / dt1.max(dt2).max(1e-10) < 1e-6 {
return (dt1 + dt2) / 2.0;
}
if dt1 <= 0.0 || dt2 <= 0.0 {
return (dt1 + dt2) / 2.0;
}
(dt1 - dt2) / (dt1 / dt2).ln()
}
}
impl HeatTransferModel for LmtdModel {
fn compute_heat_transfer(
&self,
hot_inlet: &FluidState,
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
) -> Power {
let lmtd = self.lmtd(
hot_inlet.temperature,
hot_outlet.temperature,
cold_inlet.temperature,
cold_outlet.temperature,
);
let f = self.flow_config.correction_factor();
let ua_eff = self.effective_ua();
let q = ua_eff * lmtd * f;
Power::from_watts(q)
}
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::*;
use crate::heat_exchanger::{EpsNtuModel, HeatTransferModel};
use approx::assert_relative_eq;
#[test]
fn test_lmtd_model_creation() {
let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
assert_eq!(model.ua(), 5000.0);
}
#[test]
fn test_f_ua_scales_heat_transfer() {
let mut model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
assert_relative_eq!(model.effective_ua(), 5000.0, epsilon = 1e-10);
model.set_ua_scale(1.1);
assert_relative_eq!(model.effective_ua(), 5500.0, epsilon = 1e-10);
}
#[test]
fn test_lmtd_counter_flow() {
let model = LmtdModel::counter_flow(5000.0);
let t_hot_in = 80.0;
let t_hot_out = 60.0;
let t_cold_in = 20.0;
let t_cold_out = 50.0;
let lmtd = model.lmtd(t_hot_in, t_hot_out, t_cold_in, t_cold_out);
assert!(lmtd > 0.0);
assert!(lmtd < (t_hot_in - t_cold_in));
}
#[test]
fn test_lmtd_equal_deltas() {
let model = LmtdModel::counter_flow(5000.0);
let t_hot_in = 80.0;
let t_hot_out = 60.0;
let t_cold_in = 40.0;
let t_cold_out = 60.0;
let lmtd = model.lmtd(t_hot_in, t_hot_out, t_cold_in, t_cold_out);
assert!((lmtd - 20.0).abs() < 0.1);
}
#[test]
fn test_lmtd_parallel_flow() {
let model = LmtdModel::parallel_flow(5000.0);
let t_hot_in = 80.0;
let t_hot_out = 60.0;
let t_cold_in = 20.0;
let t_cold_out = 40.0;
let lmtd = model.lmtd(t_hot_in, t_hot_out, t_cold_in, t_cold_out);
assert!(lmtd > 0.0);
}
#[test]
fn test_compute_heat_transfer() {
let model = LmtdModel::counter_flow(5000.0);
let hot_inlet = FluidState::from_temperature(80.0 + 273.15);
let hot_outlet = FluidState::from_temperature(60.0 + 273.15);
let cold_inlet = FluidState::from_temperature(20.0 + 273.15);
let cold_outlet = FluidState::from_temperature(50.0 + 273.15);
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet);
assert!(q.to_watts() > 0.0);
}
#[test]
fn test_flow_configuration_correction_factor() {
assert_eq!(FlowConfiguration::CounterFlow.correction_factor(), 1.0);
assert_eq!(FlowConfiguration::ParallelFlow.correction_factor(), 1.0);
assert_eq!(FlowConfiguration::ShellAndTube1_2.correction_factor(), 0.9);
let cross = FlowConfiguration::CrossFlow {
correction_factor: 0.85,
};
assert_eq!(cross.correction_factor(), 0.85);
}
#[test]
fn test_n_equations() {
let model = LmtdModel::counter_flow(1000.0);
assert_eq!(model.n_equations(), 3);
}
#[test]
fn test_lmtd_negative_deltas() {
let model = LmtdModel::counter_flow(5000.0);
let t_hot_in = 40.0;
let t_hot_out = 50.0;
let t_cold_in = 60.0;
let t_cold_out = 70.0;
let lmtd = model.lmtd(t_hot_in, t_hot_out, t_cold_in, t_cold_out);
assert!(lmtd < 0.0);
}
#[test]
#[should_panic(expected = "UA must be non-negative")]
fn test_negative_ua_panics() {
let _model = LmtdModel::new(-1000.0, FlowConfiguration::CounterFlow);
}
#[test]
fn test_zero_ua_allowed() {
let model = LmtdModel::new(0.0, FlowConfiguration::CounterFlow);
assert_eq!(model.ua(), 0.0);
}
#[test]
fn test_lmtd_vs_eps_ntu_comparison() {
// AC #8: Compare LMTD vs ε-NTU results for same conditions
// Verify both methods produce reasonable heat transfer values
let ua = 5_000.0;
let lmtd_model = LmtdModel::counter_flow(ua);
let eps_ntu_model = EpsNtuModel::counter_flow(ua);
// Typical HVAC operating conditions
// Hot water cooling from 80°C to 60°C (353K to 333K)
// Cold water heating from 20°C to 40°C (293K to 313K)
let hot_inlet = FluidState::new(353.0, 200_000.0, 335_000.0, 0.5, 4180.0);
let hot_outlet = FluidState::new(333.0, 195_000.0, 250_000.0, 0.5, 4180.0);
let cold_inlet = FluidState::new(293.0, 101_325.0, 85_000.0, 0.3, 4180.0);
let cold_outlet = FluidState::new(313.0, 101_325.0, 170_000.0, 0.3, 4180.0);
let q_lmtd = lmtd_model
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet)
.to_watts();
let q_eps_ntu = eps_ntu_model
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet)
.to_watts();
// Both methods should give positive heat transfer
assert!(q_lmtd > 0.0, "LMTD should give positive Q, got {}", q_lmtd);
assert!(
q_eps_ntu > 0.0,
"ε-NTU should give positive Q, got {}",
q_eps_ntu
);
// Verify reasonable magnitude for a 5 kW/K heat exchanger
// LMTD should be around 30-40K, so Q should be 150-200 kW range for these temps
assert!(
q_lmtd > 100_000.0 && q_lmtd < 300_000.0,
"LMTD Q unexpected: {}",
q_lmtd
);
// ε-NTU uses inlet temps only, so result differs from LMTD
assert!(
q_eps_ntu < 300_000.0,
"ε-NTU Q should be reasonable, got {}",
q_eps_ntu
);
}
}

View File

@@ -0,0 +1,51 @@
//! Heat Exchanger Framework
//!
//! This module provides a pluggable heat exchanger framework supporting multiple
//! calculation models (LMTD, ε-NTU) for thermodynamic simulations.
//!
//! ## Architecture
//!
//! The framework uses the Strategy Pattern for heat transfer calculations:
//!
//! - [`HeatTransferModel`]: Trait for pluggable calculation strategies
//! - [`LmtdModel`]: Log Mean Temperature Difference method
//! - [`EpsNtuModel`]: Effectiveness-NTU method
//! - [`HeatExchanger`]: Generic heat exchanger component
//!
//! ## Components
//!
//! - [`Condenser`]: Refrigerant condensing (phase change) on hot side
//! - [`Evaporator`]: Refrigerant evaporating (phase change) on cold side
//! - [`EvaporatorCoil`]: Air-side evaporator (finned coil)
//! - [`CondenserCoil`]: Air-side condenser (finned coil)
//! - [`Economizer`]: Internal heat exchanger with bypass support
//!
//! ## Example
//!
//! ```rust
//! use entropyk_components::heat_exchanger::{HeatExchanger, LmtdModel, FlowConfiguration};
//!
//! // Create a heat exchanger with LMTD model
//! let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
//! // Heat exchanger would be created with connected ports
//! ```
pub mod condenser;
pub mod condenser_coil;
pub mod economizer;
pub mod evaporator_coil;
pub mod eps_ntu;
pub mod evaporator;
pub mod exchanger;
pub mod lmtd;
pub mod model;
pub use condenser::Condenser;
pub use condenser_coil::CondenserCoil;
pub use economizer::Economizer;
pub use evaporator_coil::EvaporatorCoil;
pub use eps_ntu::{EpsNtuModel, ExchangerType};
pub use evaporator::Evaporator;
pub use exchanger::{HeatExchanger, HeatExchangerBuilder, HxSideConditions};
pub use lmtd::{FlowConfiguration, LmtdModel};
pub use model::HeatTransferModel;

View File

@@ -0,0 +1,204 @@
//! Heat Transfer Model Trait
//!
//! Defines the Strategy Pattern interface for heat transfer calculations.
//! This trait is object-safe for dynamic dispatch.
use crate::ResidualVector;
use entropyk_core::{Enthalpy, MassFlow, Power, Pressure, Temperature};
/// Fluid state for heat transfer calculations.
///
/// Represents the thermodynamic state at a port (inlet or outlet).
#[derive(Debug, Clone, Copy)]
pub struct FluidState {
/// Temperature in Kelvin
pub temperature: f64,
/// Pressure in Pascals
pub pressure: f64,
/// Specific enthalpy in J/kg
pub enthalpy: f64,
/// Mass flow rate in kg/s
pub mass_flow: f64,
/// Specific heat capacity at constant pressure in J/(kg·K)
pub cp: f64,
}
impl Default for FluidState {
fn default() -> Self {
Self {
temperature: 300.0,
pressure: 101_325.0,
enthalpy: 0.0,
mass_flow: 0.1,
cp: 1000.0,
}
}
}
impl FluidState {
/// Creates a new fluid state.
pub fn new(temperature: f64, pressure: f64, enthalpy: f64, mass_flow: f64, cp: f64) -> Self {
Self {
temperature,
pressure,
enthalpy,
mass_flow,
cp,
}
}
/// Creates a fluid state with default properties for a given temperature.
pub fn from_temperature(temperature: f64) -> Self {
Self {
temperature,
..Default::default()
}
}
/// Returns the heat capacity rate C = ṁ × Cp in W/K.
pub fn heat_capacity_rate(&self) -> f64 {
self.mass_flow * self.cp
}
/// Creates a FluidState from strongly-typed physical quantities.
pub fn from_types(
temperature: Temperature,
pressure: Pressure,
enthalpy: Enthalpy,
mass_flow: MassFlow,
cp: f64,
) -> Self {
Self {
temperature: temperature.to_kelvin(),
pressure: pressure.to_pascals(),
enthalpy: enthalpy.to_joules_per_kg(),
mass_flow: mass_flow.to_kg_per_s(),
cp,
}
}
/// Returns temperature as a strongly-typed Temperature.
pub fn temperature(&self) -> Temperature {
Temperature::from_kelvin(self.temperature)
}
/// Returns pressure as a strongly-typed Pressure.
pub fn pressure(&self) -> Pressure {
Pressure::from_pascals(self.pressure)
}
/// Returns enthalpy as a strongly-typed Enthalpy.
pub fn enthalpy(&self) -> Enthalpy {
Enthalpy::from_joules_per_kg(self.enthalpy)
}
/// Returns mass flow as a strongly-typed MassFlow.
pub fn mass_flow(&self) -> MassFlow {
MassFlow::from_kg_per_s(self.mass_flow)
}
}
/// Trait for heat transfer calculation models.
///
/// This trait uses the Strategy Pattern to allow different heat transfer
/// calculation methods (LMTD, ε-NTU, etc.) to be used interchangeably.
///
/// # Object Safety
///
/// This trait is object-safe and can be used with dynamic dispatch:
///
/// ```
/// # use entropyk_components::heat_exchanger::model::{HeatTransferModel, FluidState};
/// # use entropyk_components::ResidualVector;
/// # use entropyk_core::Power;
/// struct SimpleModel { ua: f64 }
/// impl HeatTransferModel for SimpleModel {
/// fn compute_heat_transfer(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState) -> Power {
/// Power::from_watts(0.0)
/// }
/// fn compute_residuals(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState, _: &mut ResidualVector) {}
/// fn n_equations(&self) -> usize { 3 }
/// fn ua(&self) -> f64 { self.ua }
/// }
/// let model: Box<dyn HeatTransferModel> = Box::new(SimpleModel { ua: 1000.0 });
/// ```
pub trait HeatTransferModel: Send + Sync {
/// Computes the heat transfer rate Q̇ (Watts).
///
/// # Arguments
///
/// * `hot_inlet` - Hot side inlet state
/// * `hot_outlet` - Hot side outlet state
/// * `cold_inlet` - Cold side inlet state
/// * `cold_outlet` - Cold side outlet state
///
/// # Returns
///
/// The heat transfer rate in Watts (positive = heat flows from hot to cold)
fn compute_heat_transfer(
&self,
hot_inlet: &FluidState,
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
) -> Power;
/// Computes residuals for the solver.
///
/// The residuals represent the error in the heat transfer equations
/// that the solver will attempt to drive to zero.
fn compute_residuals(
&self,
hot_inlet: &FluidState,
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
residuals: &mut ResidualVector,
);
/// Returns the number of equations this model contributes.
fn n_equations(&self) -> usize;
/// Returns the nominal UA value (overall heat transfer coefficient × area) in W/K.
fn ua(&self) -> f64;
/// Returns the UA calibration scale (default 1.0). UA_eff = ua_scale × ua_nominal.
fn ua_scale(&self) -> f64 {
1.0
}
/// Sets the UA calibration scale (e.g. from Calib.f_ua).
fn set_ua_scale(&mut self, _s: f64) {}
/// Returns the effective UA used in heat transfer: ua_scale × ua_nominal.
fn effective_ua(&self) -> f64 {
self.ua() * self.ua_scale()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fluid_state_default() {
let state = FluidState::default();
assert_eq!(state.temperature, 300.0);
assert_eq!(state.pressure, 101_325.0);
assert_eq!(state.cp, 1000.0);
}
#[test]
fn test_fluid_state_heat_capacity_rate() {
let state = FluidState::new(300.0, 101_325.0, 0.0, 0.5, 2000.0);
let c = state.heat_capacity_rate();
assert!((c - 1000.0).abs() < 1e-10);
}
#[test]
fn test_fluid_state_from_temperature() {
let state = FluidState::from_temperature(350.0);
assert_eq!(state.temperature, 350.0);
assert_eq!(state.pressure, 101_325.0);
}
}