//! 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` for residual computation. pub struct BphxExchanger { inner: HeatExchanger, geometry: BphxGeometry, correlation_selector: CorrelationSelector, refrigerant_id: String, secondary_fluid_id: String, fluid_backend: Option>, last_htc: Cell, last_htc_result: Cell>, last_selection: RefCell>, last_validity_warning: Cell, } 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) -> 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) { 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) -> 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) -> 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) { 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) { 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 { self.last_htc_result.take() } /// Returns the latest ranked selection report. pub fn last_selection_report(&self) -> Option { 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 { 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 { 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, ComponentError> { self.inner.port_mass_flows(state) } fn port_enthalpies(&self, state: &StateSlice) -> Result, 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, ) { 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})" ); } }