feat(components): add BphxExchanger with geometry-based heat transfer correlations (Story 11.5)

- Add BphxGeometry with builder pattern for plate heat exchanger specs
- Implement 6 heat transfer correlations: Longo2004, Shah1979/2021, Kandlikar1990, GungorWinterton1986, Gnielinski1976
- Add CorrelationSelector with validity range checking
- Add compute_pressure_drop() using Calib.f_dp
- Implement HeatTransferCorrelation trait for BphxCorrelation
- 45 BPHX tests + 17 correlation tests passing
This commit is contained in:
Sepehr
2026-02-24 21:18:22 +01:00
parent 613afb5351
commit bd4113f49e
5 changed files with 1865 additions and 14 deletions

View File

@@ -0,0 +1,584 @@
//! BphxExchanger - Brazed Plate Heat Exchanger Component
//!
//! A heat exchanger component that uses geometry-based heat transfer correlations
//! for brazed plate heat exchangers. Supports evaporation, condensation, and
//! generic heat transfer applications.
//!
//! ## Key Features
//!
//! - Geometry-based heat transfer coefficient calculation
//! - Multiple correlation support (Longo2004, Shah, etc.)
//! - Calib factor support (f_ua, f_dp)
//! - Single-phase and two-phase flow handling
//!
//! ## Example
//!
//! ```ignore
//! use entropyk_components::heat_exchanger::{BphxExchanger, BphxGeometry, BphxCorrelation};
//!
//! let geo = BphxGeometry::new(30)
//! .with_plate_dimensions(0.5, 0.1)
//! .with_chevron_angle(60.0)
//! .build()
//! .unwrap();
//!
//! let hx = BphxExchanger::new(geo)
//! .with_correlation(BphxCorrelation::Longo2004)
//! .with_refrigerant("R410A");
//!
//! assert_eq!(hx.n_equations(), 3);
//! ```
use super::bphx_correlation::{
BphxCorrelation, CorrelationParams, CorrelationResult, CorrelationSelector, FlowRegime,
ValidityStatus,
};
use super::bphx_geometry::{BphxGeometry, BphxType};
use super::eps_ntu::{EpsNtuModel, ExchangerType};
use super::exchanger::{HeatExchanger, HxSideConditions};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power};
use std::cell::Cell;
use std::sync::Arc;
/// BphxExchanger - Brazed Plate Heat Exchanger component
///
/// Uses geometry-based correlations to compute heat transfer coefficients.
/// Wraps a generic `HeatExchanger<EpsNtuModel>` for residual computation.
pub struct BphxExchanger {
inner: HeatExchanger<EpsNtuModel>,
geometry: BphxGeometry,
correlation_selector: CorrelationSelector,
refrigerant_id: String,
secondary_fluid_id: String,
fluid_backend: Option<Arc<dyn entropyk_fluids::FluidBackend>>,
last_htc: Cell<f64>,
last_htc_result: Cell<Option<CorrelationResult>>,
last_validity_warning: Cell<bool>,
}
impl std::fmt::Debug for BphxExchanger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BphxExchanger")
.field("ua", &self.ua())
.field("geometry", &self.geometry)
.field("correlation", &self.correlation_selector.correlation)
.field("refrigerant_id", &self.refrigerant_id)
.field("secondary_fluid_id", &self.secondary_fluid_id)
.field("has_fluid_backend", &self.fluid_backend.is_some())
.finish()
}
}
impl BphxExchanger {
/// Minimum valid UA value (W/K)
const MIN_UA: f64 = 0.0;
/// Creates a new BphxExchanger with the specified geometry.
///
/// The UA value is estimated from geometry and typical heat transfer coefficients.
///
/// # Arguments
///
/// * `geometry` - BPHX geometry specification
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::{BphxExchanger, BphxGeometry};
///
/// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
/// let hx = BphxExchanger::new(geo);
/// assert_eq!(hx.n_equations(), 3);
/// ```
pub fn new(geometry: BphxGeometry) -> Self {
let ua_estimate = Self::estimate_ua(&geometry);
let model = EpsNtuModel::new(ua_estimate, ExchangerType::CounterFlow);
Self {
inner: HeatExchanger::new(model, "BphxExchanger"),
geometry,
correlation_selector: CorrelationSelector::default(),
refrigerant_id: String::new(),
secondary_fluid_id: String::new(),
fluid_backend: None,
last_htc: Cell::new(0.0),
last_htc_result: Cell::new(None),
last_validity_warning: Cell::new(false),
}
}
/// Creates a BphxExchanger with a specified nominal UA value.
pub fn with_ua(geometry: BphxGeometry, ua: f64) -> Self {
let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
Self {
inner: HeatExchanger::new(model, "BphxExchanger"),
geometry,
correlation_selector: CorrelationSelector::default(),
refrigerant_id: String::new(),
secondary_fluid_id: String::new(),
fluid_backend: None,
last_htc: Cell::new(0.0),
last_htc_result: Cell::new(None),
last_validity_warning: Cell::new(false),
}
}
/// Estimates UA from geometry and typical HTC values.
fn estimate_ua(geometry: &BphxGeometry) -> f64 {
let h_typical = match geometry.exchanger_type {
BphxType::Evaporator => 5000.0,
BphxType::Condenser => 4000.0,
BphxType::Generic => 3000.0,
};
h_typical * geometry.area
}
/// Sets the heat transfer correlation.
pub fn with_correlation(mut self, correlation: BphxCorrelation) -> Self {
self.correlation_selector = CorrelationSelector::new().with_correlation(correlation);
self
}
/// Sets the refrigerant fluid identifier.
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
self.refrigerant_id = fluid.into();
self
}
/// Sets the secondary fluid identifier (water, brine, etc.).
pub fn with_secondary_fluid(mut self, fluid: impl Into<String>) -> Self {
self.secondary_fluid_id = fluid.into();
self
}
/// Attaches a fluid backend for property queries.
pub fn with_fluid_backend(mut self, backend: Arc<dyn entropyk_fluids::FluidBackend>) -> Self {
self.fluid_backend = Some(backend);
self
}
/// Returns the component name.
pub fn name(&self) -> &str {
self.inner.name()
}
/// Returns the geometry specification.
pub fn geometry(&self) -> &BphxGeometry {
&self.geometry
}
/// Returns the effective UA value (W/K).
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns calibration factors.
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 last computed heat transfer coefficient (W/(m²·K)).
pub fn last_htc(&self) -> f64 {
self.last_htc.get()
}
/// Returns the last correlation result (if available).
pub fn last_htc_result(&self) -> Option<CorrelationResult> {
self.last_htc_result.take()
}
/// Returns whether a validity warning was issued.
pub fn had_validity_warning(&self) -> bool {
self.last_validity_warning.get()
}
/// Sets the hot side (refrigerant for evaporator, secondary for condenser) conditions.
pub fn set_hot_conditions(&mut self, conditions: HxSideConditions) {
self.inner.set_hot_conditions(conditions);
}
/// Sets the cold side conditions.
pub fn set_cold_conditions(&mut self, conditions: HxSideConditions) {
self.inner.set_cold_conditions(conditions);
}
/// Computes the heat transfer coefficient using the configured correlation.
///
/// # Arguments
///
/// * `mass_flux` - Mass flux (kg/(m²·s))
/// * `quality` - Vapor quality (0-1)
/// * `rho_l` - Liquid density (kg/m³)
/// * `rho_v` - Vapor density (kg/m³)
/// * `mu_l` - Liquid dynamic viscosity (Pa·s)
/// * `mu_v` - Vapor dynamic viscosity (Pa·s)
/// * `k_l` - Liquid thermal conductivity (W/(m·K))
/// * `pr_l` - Liquid Prandtl number
/// * `t_sat` - Saturation temperature (K)
/// * `t_wall` - Wall temperature (K)
#[allow(clippy::too_many_arguments)]
pub fn compute_htc(
&self,
mass_flux: f64,
quality: f64,
rho_l: f64,
rho_v: f64,
mu_l: f64,
mu_v: f64,
k_l: f64,
pr_l: f64,
t_sat: f64,
t_wall: f64,
) -> CorrelationResult {
let regime = match self.geometry.exchanger_type {
BphxType::Evaporator => FlowRegime::Evaporation,
BphxType::Condenser => FlowRegime::Condensation,
BphxType::Generic => FlowRegime::SinglePhaseLiquid,
};
let params = CorrelationParams {
mass_flux,
quality,
dh: self.geometry.dh,
rho_l,
rho_v,
mu_l,
mu_v,
k_l,
pr_l,
t_sat,
t_wall,
regime,
chevron_angle: self.geometry.chevron_angle,
};
let result = self.correlation_selector.compute_htc(&params);
self.last_htc.set(result.h);
self.last_htc_result.set(Some(result.clone()));
if result.validity == ValidityStatus::Extrapolation {
self.last_validity_warning.set(true);
}
result
}
/// Computes the pressure drop using a simplified correlation.
///
/// ΔP = f_dp × (2 × f × L × G²) / (ρ × d_h)
///
/// where f is the friction factor, L is the plate length, G is mass flux.
/// The result is scaled by `calib().f_dp`.
///
/// # Arguments
///
/// * `mass_flux` - Mass flux (kg/(m²·s))
/// * `rho` - Fluid density (kg/m³)
///
/// # Returns
///
/// Pressure drop in Pa, scaled by f_dp calibration factor.
pub fn compute_pressure_drop(&self, mass_flux: f64, rho: f64) -> f64 {
if rho < 1e-10 || self.geometry.dh < 1e-10 {
return 0.0;
}
let re = mass_flux * self.geometry.dh / 0.0002;
let f = if re < 2300.0 {
64.0 / re.max(1.0)
} else {
0.079 * re.powf(-0.25)
};
let dp_base =
2.0 * f * self.geometry.plate_length * mass_flux.powi(2) / (rho * self.geometry.dh);
dp_base * self.calib().f_dp
}
/// Updates UA based on computed HTC.
///
/// UA_eff = h × A × f_ua
pub fn update_ua_from_htc(&mut self, h: f64) {
let ua = h * self.geometry.area * self.calib().f_ua;
self.inner.set_ua_scale(ua / self.inner.ua_nominal());
}
}
impl Component for BphxExchanger {
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
}
fn jacobian_entries(
&self,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
fn port_mass_flows(&self, state: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
self.inner.port_mass_flows(state)
}
fn port_enthalpies(&self, state: &StateSlice) -> Result<Vec<Enthalpy>, ComponentError> {
self.inner.port_enthalpies(state)
}
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
self.inner.energy_transfers(state)
}
fn signature(&self) -> String {
format!(
"BphxExchanger({} plates, dh={:.2}mm, A={:.3}m², {})",
self.geometry.n_plates,
self.geometry.dh * 1000.0,
self.geometry.area,
self.correlation_selector.correlation.name()
)
}
}
impl StateManageable for BphxExchanger {
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::*;
fn test_geometry() -> BphxGeometry {
BphxGeometry::from_dh_area(0.003, 0.5, 20)
}
#[test]
fn test_bphx_exchanger_creation() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
assert_eq!(hx.n_equations(), 3);
assert!(hx.ua() > 0.0);
}
#[test]
fn test_bphx_exchanger_with_ua() {
let geo = test_geometry();
let hx = BphxExchanger::with_ua(geo, 5000.0);
assert!((hx.ua() - 5000.0).abs() < 1e-6);
}
#[test]
fn test_bphx_exchanger_with_correlation() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo).with_correlation(BphxCorrelation::Shah1979);
assert_eq!(
hx.correlation_selector.correlation,
BphxCorrelation::Shah1979
);
}
#[test]
fn test_bphx_exchanger_with_refrigerant() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo).with_refrigerant("R410A");
assert_eq!(hx.refrigerant_id, "R410A");
}
#[test]
fn test_bphx_exchanger_compute_residuals() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
#[test]
fn test_bphx_exchanger_state_manageable() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
assert_eq!(hx.state(), OperationalState::On);
assert!(hx.can_transition_to(OperationalState::Off));
}
#[test]
fn test_bphx_exchanger_set_state() {
let geo = test_geometry();
let mut hx = BphxExchanger::new(geo);
let result = hx.set_state(OperationalState::Off);
assert!(result.is_ok());
assert_eq!(hx.state(), OperationalState::Off);
let result = hx.set_state(OperationalState::Bypass);
assert!(result.is_ok());
assert_eq!(hx.state(), OperationalState::Bypass);
}
#[test]
fn test_bphx_exchanger_compute_htc() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
let result = hx.compute_htc(
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
);
assert!(result.h > 0.0);
assert!(result.re > 0.0);
assert!(result.nu > 0.0);
}
#[test]
fn test_bphx_exchanger_last_htc() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
assert_eq!(hx.last_htc(), 0.0);
let result = hx.compute_htc(
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
);
assert!((hx.last_htc() - result.h).abs() < 1e-10);
}
#[test]
fn test_bphx_exchanger_signature() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
let sig = hx.signature();
assert!(sig.contains("BphxExchanger"));
assert!(sig.contains("20 plates"));
assert!(sig.contains("Longo (2004)"));
}
#[test]
fn test_bphx_exchanger_energy_transfers() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
let state = vec![0.0; 10];
let (heat, work) = hx.energy_transfers(&state).unwrap();
assert_eq!(heat.to_watts(), 0.0);
assert_eq!(work.to_watts(), 0.0);
}
#[test]
fn test_bphx_exchanger_calib_default() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
let calib = hx.calib();
assert_eq!(calib.f_ua, 1.0);
}
#[test]
fn test_bphx_exchanger_set_calib() {
let geo = test_geometry();
let mut hx = BphxExchanger::new(geo);
let mut calib = Calib::default();
calib.f_ua = 0.9;
hx.set_calib(calib);
assert_eq!(hx.calib().f_ua, 0.9);
}
#[test]
fn test_bphx_exchanger_geometry() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo.clone());
assert_eq!(hx.geometry().n_plates, geo.n_plates);
}
#[test]
fn test_bphx_exchanger_update_ua_from_htc() {
let geo = test_geometry();
let mut hx = BphxExchanger::new(geo);
let h = 5000.0;
let ua_before = hx.ua();
hx.update_ua_from_htc(h);
let ua_after = hx.ua();
assert!((ua_after - ua_before).abs() > 1.0);
}
#[test]
fn test_bphx_exchanger_validity_warning() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo).with_correlation(BphxCorrelation::Longo2004);
assert!(!hx.had_validity_warning());
hx.compute_htc(
100.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
);
assert!(hx.had_validity_warning());
}
#[test]
fn test_bphx_exchanger_pressure_drop() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo.clone());
let dp = hx.compute_pressure_drop(30.0, 1100.0);
assert!(dp >= 0.0);
let mut hx_with_calib = BphxExchanger::new(geo);
let mut calib = Calib::default();
calib.f_dp = 0.5;
hx_with_calib.set_calib(calib);
let dp_calib = hx_with_calib.compute_pressure_drop(30.0, 1100.0);
assert!((dp_calib - dp * 0.5).abs() < 1e-6);
}
}