Some checks failed
CI / check (push) Has been cancelled
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>
848 lines
27 KiB
Rust
848 lines
27 KiB
Rust
//! 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(
|
||
¶ms,
|
||
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})"
|
||
);
|
||
}
|
||
}
|