Files
Entropyk/crates/components/src/heat_exchanger/bphx_exchanger.rs
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:35:31 +02:00

848 lines
27 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.
//! 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, CorrelationEvaluation, CorrelationParams, CorrelationResult,
CorrelationSelector, ValidityStatus,
};
use super::bphx_geometry::{BphxGeometry, BphxType};
use super::correlation_registry::{
CorrelationSelectionError, ExchangerGeometryType, FlowRegime, SelectionOutcome,
};
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, RefCell};
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_selection: RefCell<Option<SelectionOutcome>>,
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)
#[allow(dead_code)]
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};
/// use entropyk_components::Component;
///
/// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
/// let hx = BphxExchanger::new(geo);
/// assert_eq!(hx.n_equations(), 2);
/// ```
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_selection: RefCell::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_selection: RefCell::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.set_refrigerant_id(fluid);
self.inner.set_hot_fluid(self.refrigerant_id.clone());
self
}
/// Sets the refrigerant identifier used by correlation applicability selection.
pub fn set_refrigerant_id(&mut self, fluid: impl Into<String>) {
self.refrigerant_id = fluid.into();
}
/// Returns the configured refrigerant identifier, when present.
pub fn refrigerant_id(&self) -> Option<&str> {
(!self.refrigerant_id.is_empty()).then_some(self.refrigerant_id.as_str())
}
/// 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.inner.set_cold_fluid(self.secondary_fluid_id.clone());
self
}
/// Attaches a fluid backend for property queries.
pub fn with_fluid_backend(mut self, backend: Arc<dyn entropyk_fluids::FluidBackend>) -> Self {
self.inner
.set_fluid_backend_from_builder(Arc::clone(&backend));
self.fluid_backend = Some(backend);
self
}
/// Declares the hot-side live-port fluid for the delegated four-port exchanger.
pub fn set_hot_fluid(&mut self, fluid: impl Into<String>) {
self.inner.set_hot_fluid(fluid);
}
/// Declares the cold-side live-port fluid for the delegated four-port exchanger.
pub fn set_cold_fluid(&mut self, fluid: impl Into<String>) {
self.inner.set_cold_fluid(fluid);
}
/// 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 configured formula name, or `Automatic` before/while auto-selecting.
pub fn correlation_name(&self) -> &'static str {
if self.correlation_selector.automatic {
"Automatic"
} else {
self.correlation_selector.correlation.name()
}
}
/// 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 the latest ranked selection report.
pub fn last_selection_report(&self) -> Option<SelectionOutcome> {
self.last_selection.borrow().clone()
}
/// 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,
) -> Result<CorrelationEvaluation, CorrelationSelectionError> {
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,
p_reduced: 0.2,
};
let evaluation = match self.correlation_selector.select_and_compute(
&params,
ExchangerGeometryType::BrazedPlate,
self.refrigerant_id(),
) {
Ok(evaluation) => evaluation,
Err(error) => {
self.clear_last_htc_evaluation();
return Err(error);
}
};
let result = &evaluation.result;
self.last_htc.set(result.h);
self.last_htc_result.set(Some(result.clone()));
self.last_selection
.replace(Some(evaluation.selection.clone()));
self.last_validity_warning
.set(result.validity != ValidityStatus::Valid);
Ok(evaluation)
}
fn clear_last_htc_evaluation(&self) {
self.last_htc.set(0.0);
self.last_htc_result.set(None);
self.last_selection.replace(None);
self.last_validity_warning.set(false);
}
/// 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().z_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.abs() * self.geometry.dh / 0.0002;
// C¹ laminar→turbulent blend over [2300, 4000] (same rationale as the
// pipe friction factor and the Gnielinski correlation): a hard switch at
// Re = 2300 put a kink in the pressure-drop residual that the analytic
// Newton Jacobian could not see, hurting convergence near the transition.
// Reynolds uses |mass_flux| so transient reverse flow during iteration
// yields a physical friction factor instead of the flat `64` the old
// signed/`max(1.0)` form produced. The lower clamp only guards the exact
// div-by-zero at stagnation; at 1e-9 it is far below any reachable Re, so
// it introduces no visible kink (the previous `max(1.0)` kinked at Re = 1).
const RE_LAMINAR: f64 = 2300.0;
const RE_TURBULENT: f64 = 4000.0;
let f_laminar = 64.0 / re.max(1e-9);
let f = if re <= RE_LAMINAR {
f_laminar
} else {
let f_turbulent = 0.079 * re.powf(-0.25);
if re >= RE_TURBULENT {
f_turbulent
} else {
entropyk_core::smoothing::cubic_blend(
f_laminar,
f_turbulent,
re,
RE_LAMINAR,
RE_TURBULENT,
)
}
};
let dp_base =
2.0 * f * self.geometry.plate_length * mass_flux.powi(2) / (rho * self.geometry.dh);
dp_base * self.calib().z_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().z_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_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
self.inner.set_port_context(port_edges);
}
fn port_names(&self) -> Vec<String> {
self.inner.port_names()
}
fn flow_paths(&self) -> Vec<(usize, usize)> {
self.inner.flow_paths()
}
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 set_fluid_backend_from_builder(
&mut self,
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
) {
if self.fluid_backend.is_none() {
self.fluid_backend = Some(Arc::clone(&backend));
}
self.inner.set_fluid_backend_from_builder(backend);
}
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_name()
)
}
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
self.inner.update_calib_factor(factor, value)
}
}
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(), 2);
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!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
fn test_bphx_exchanger_exposes_four_port_metadata() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
assert_eq!(
hx.port_names(),
vec![
"hot_inlet".to_string(),
"hot_outlet".to_string(),
"cold_inlet".to_string(),
"cold_outlet".to_string(),
]
);
assert_eq!(hx.flow_paths(), vec![(0, 1), (2, 3)]);
}
#[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,
)
.unwrap();
assert!(result.h > 0.0);
assert!(result.re > 0.0);
assert!(result.nu > 0.0);
assert_eq!(
result.selection.selected,
super::super::correlation_registry::CorrelationId::Longo2004
);
assert_eq!(result.selection.assessments.len(), 10);
assert_eq!(
hx.last_selection_report().unwrap().selected,
result.selection.selected
);
}
#[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,
)
.unwrap();
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("Automatic"));
}
#[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.z_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.z_ua = 0.9;
hx.set_calib(calib);
assert_eq!(hx.calib().z_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().with_exchanger_type(BphxType::Evaporator);
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,
)
.unwrap();
assert!(hx.had_validity_warning());
}
#[test]
fn test_bphx_exchanger_invalid_input_returns_error() {
let hx = BphxExchanger::new(test_geometry());
let error = hx
.compute_htc(
30.0, 1.2, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
)
.unwrap_err();
assert!(matches!(
error,
CorrelationSelectionError::InvalidInput(
super::super::correlation_registry::DomainInputError::InvalidQuality { .. }
)
));
assert_eq!(hx.last_htc(), 0.0);
assert!(hx.last_selection_report().is_none());
}
#[test]
fn failed_htc_evaluation_clears_previous_success_state() {
let hx = BphxExchanger::new(test_geometry()).with_refrigerant("R134a");
hx.compute_htc(
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
)
.unwrap();
assert!(hx.last_htc() > 0.0);
assert!(hx.last_selection_report().is_some());
hx.compute_htc(
30.0, 1.2, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
)
.unwrap_err();
assert_eq!(hx.last_htc(), 0.0);
assert!(hx.last_htc_result().is_none());
assert!(hx.last_selection_report().is_none());
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.z_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);
}
#[test]
fn test_bphx_pressure_drop_transition_is_c1() {
// re = mass_flux · dh / 0.0002 = mass_flux · 15 for dh = 0.003.
// The laminar→turbulent friction blend over [2300, 4000] must keep ΔP
// and its slope continuous at the band edges so the analytic Jacobian
// sees no kink.
let hx = BphxExchanger::new(test_geometry());
let rho = 1100.0;
let re_to_g = |re: f64| re / 15.0;
let d_g = 0.01; // mass-flux step for the finite-difference slope
for &re_edge in &[2300.0_f64, 4000.0_f64] {
let g = re_to_g(re_edge);
let dp_below = hx.compute_pressure_drop(g - d_g, rho);
let dp_at = hx.compute_pressure_drop(g, rho);
let dp_above = hx.compute_pressure_drop(g + d_g, rho);
let rel_jump = (dp_above - dp_below).abs() / dp_at.abs();
assert!(
rel_jump < 0.01,
"ΔP jump {:.4} at Re = {}",
rel_jump,
re_edge
);
let slope_below = (dp_at - dp_below) / d_g;
let slope_above = (dp_above - dp_at) / d_g;
let denom = slope_below.abs().max(1e-9);
assert!(
(slope_above - slope_below).abs() / denom < 0.5,
"ΔP slope discontinuity at Re = {} ({:.4} vs {:.4})",
re_edge,
slope_below,
slope_above
);
}
}
#[test]
fn test_bphx_pressure_drop_reverse_flow_and_low_re() {
// ΔP must be a positive magnitude and symmetric under flow reversal
// (friction now uses |Re|), and must not exhibit the old slope kink at
// Re = 1 (the `re.max(1.0)` floor was lowered to a pure div-by-zero
// guard). It must also stay finite and vanish at exactly zero flow.
let hx = BphxExchanger::new(test_geometry());
let rho = 1100.0;
for &g in &[0.5_f64, 5.0, 50.0] {
let dp_fwd = hx.compute_pressure_drop(g, rho);
let dp_rev = hx.compute_pressure_drop(-g, rho);
assert!(dp_fwd >= 0.0, "ΔP must be non-negative, got {dp_fwd}");
assert!(
(dp_fwd - dp_rev).abs() <= 1e-9 * dp_fwd.max(1.0),
"ΔP not symmetric under flow reversal: {dp_fwd} vs {dp_rev}"
);
}
// Zero flow → finite, ~zero ΔP (no NaN from the div-by-zero guard).
let dp_zero = hx.compute_pressure_drop(0.0, rho);
assert!(
dp_zero.is_finite() && dp_zero.abs() < 1e-6,
"ΔP(0) = {dp_zero}"
);
// No kink near the old Re = 1 transition (dh=0.003 ⇒ re = g·15).
let g1 = 1.0 / 15.0; // Re = 1
let d_g = g1 * 0.1;
let s_below =
(hx.compute_pressure_drop(g1, rho) - hx.compute_pressure_drop(g1 - d_g, rho)) / d_g;
let s_above =
(hx.compute_pressure_drop(g1 + d_g, rho) - hx.compute_pressure_drop(g1, rho)) / d_g;
let denom = s_below.abs().max(1e-12);
assert!(
(s_above - s_below).abs() / denom < 0.5,
"ΔP slope kink near Re = 1 ({s_below:.3e} vs {s_above:.3e})"
);
}
}