Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -0,0 +1,302 @@
//! Air-Cooled Condenser Component
//!
//! A condenseur à condensation par air où l'ingénieur fournit directement :
//! - `oat_k` : Outdoor Air Temperature [K] (OAT = température de l'air extérieur)
//! - `approach_k` : Approach temperature [K], différence de température entre la
//! condensation et l'air extérieur. Valeur typique : 1015 K.
//! Défaut : 12 K (représentatif d'un chiller air-cooled industriel).
//!
//! La température de condensation est calculée automatiquement :
//! ```text
//! T_cond = OAT + approach_k
//! ```
//!
//! ## Équations (2)
//!
//! - r0 = P_out P_sat(T_cond) [drive outlet pressure to condensing saturation]
//! - r1 = H_out H_sat_liq(T_cond) [drive outlet enthalpy to saturated-liquid]
//!
//! ## Jacobian (analytique)
//!
//! - ∂r0/∂P_out = 1
//! - ∂r1/∂H_out = 1
//!
//! ## Exemple JSON
//!
//! ```json
//! {
//! "type": "AirCooledCondenser",
//! "name": "cond",
//! "oat_k": 308.15,
//! "approach_k": 12.0
//! }
//! ```
//!
//! Soit : OAT = 35°C → T_cond = 47°C → P_cond_sat(R290) ≈ 16.8 bar
use super::condenser::Condenser;
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::Calib;
use entropyk_fluids::FluidBackend;
use std::sync::Arc;
/// Air-cooled condenser : T_cond = OAT + approach.
///
/// L'ingénieur saisit directement la température extérieure (OAT) et
/// l'approche de condensation (approach_k). Aucune conversion manuelle
/// en `t_sat_k` n'est nécessaire.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::AirCooledCondenser;
/// use entropyk_components::Component;
///
/// // OAT = 35°C, approach = 12 K → T_cond = 47°C = 320.15 K
/// let cond = AirCooledCondenser::new(308.15, 12.0);
/// assert_eq!(cond.n_equations(), 3); // 2 thermo + 1 mass-flow (CM1.3)
/// assert!((cond.t_cond_k() - 320.15).abs() < 1e-9);
/// ```
#[derive(Debug)]
pub struct AirCooledCondenser {
inner: Condenser,
oat_k: f64,
approach_k: f64,
}
impl AirCooledCondenser {
/// Crée un condenseur à air.
///
/// # Arguments
///
/// * `oat_k` — Outdoor Air Temperature \[K\]
/// * `approach_k` — Approach temperature = T_cond OAT \[K\]. Valeur typique : 1015 K.
pub fn new(oat_k: f64, approach_k: f64) -> Self {
let t_cond_k = oat_k + approach_k;
Self {
inner: Condenser::with_saturation_temp(0.0, t_cond_k),
oat_k,
approach_k,
}
}
/// Crée un condenseur à air avec UA connu.
///
/// Le UA n'est pas utilisé dans les équations du solver (approach-based),
/// mais il est disponible pour les calculs de performance post-traitement.
pub fn with_ua(oat_k: f64, approach_k: f64, ua: f64) -> Self {
let t_cond_k = oat_k + approach_k;
Self {
inner: Condenser::with_saturation_temp(ua, t_cond_k),
oat_k,
approach_k,
}
}
/// Attache un identifiant de fluide réfrigérant.
pub fn with_refrigerant(mut self, id: &str) -> Self {
self.inner = self.inner.with_refrigerant(id);
self
}
/// Attache un backend de propriétés thermodynamiques.
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
self.inner = self.inner.with_fluid_backend(backend);
self
}
/// Retourne la température de condensation calculée [K].
pub fn t_cond_k(&self) -> f64 {
self.oat_k + self.approach_k
}
/// Retourne la température extérieure (OAT) [K].
pub fn oat_k(&self) -> f64 {
self.oat_k
}
/// Retourne l'approche de condensation [K].
pub fn approach_k(&self) -> f64 {
self.approach_k
}
/// Retourne le UA [W/K].
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Retourne les facteurs de calibration.
pub fn calib(&self) -> &Calib {
self.inner.calib()
}
/// Met à jour les facteurs de calibration.
pub fn set_calib(&mut self, calib: Calib) {
self.inner.set_calib(calib);
}
/// Met à jour OAT en runtime (ex. simulation paramétrique).
pub fn set_oat_k(&mut self, oat_k: f64) {
self.oat_k = oat_k;
self.inner.set_saturation_temp(self.oat_k + self.approach_k);
}
/// Met à jour l'approche en runtime.
pub fn set_approach_k(&mut self, approach_k: f64) {
self.approach_k = approach_k;
self.inner.set_saturation_temp(self.oat_k + self.approach_k);
}
}
impl Component for AirCooledCondenser {
fn set_system_context(
&mut self,
state_offset: usize,
external_edge_state_indices: &[(usize, usize, usize)],
) {
self.inner
.set_system_context(state_offset, external_edge_state_indices);
}
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 n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
self.inner.set_fluid_backend_from_builder(backend);
}
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<entropyk_core::MassFlow>, ComponentError> {
self.inner.port_mass_flows(state)
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
self.inner.port_enthalpies(state)
}
fn energy_transfers(
&self,
state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
self.inner.energy_transfers(state)
}
fn signature(&self) -> String {
format!(
"AirCooledCondenser(oat={:.1}K, approach={:.1}K, t_cond={:.1}K)",
self.oat_k,
self.approach_k,
self.t_cond_k()
)
}
fn to_params(&self) -> crate::ComponentParams {
self.inner.to_params()
}
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
self.inner.update_calib_factor(factor, value)
}
}
impl StateManageable for AirCooledCondenser {
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_new_t_cond_computed() {
// OAT = 35°C = 308.15 K, approach = 12 K → T_cond = 47°C = 320.15 K
let cond = AirCooledCondenser::new(308.15, 12.0);
assert!((cond.t_cond_k() - 320.15).abs() < 1e-9);
assert_eq!(cond.oat_k(), 308.15);
assert_eq!(cond.approach_k(), 12.0);
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Condenser)
assert_eq!(cond.n_equations(), 3);
}
#[test]
fn test_with_ua() {
let cond = AirCooledCondenser::with_ua(308.15, 12.0, 6000.0);
assert_eq!(cond.ua(), 6000.0);
assert!((cond.t_cond_k() - 320.15).abs() < 1e-9);
}
#[test]
fn test_set_oat_updates_t_cond() {
let mut cond = AirCooledCondenser::new(308.15, 12.0);
// Change OAT to 40°C = 313.15 K → T_cond = 52°C = 325.15 K
cond.set_oat_k(313.15);
assert!((cond.t_cond_k() - 325.15).abs() < 1e-9);
}
#[test]
fn test_set_approach_updates_t_cond() {
let mut cond = AirCooledCondenser::new(308.15, 12.0);
cond.set_approach_k(10.0);
assert!((cond.t_cond_k() - 318.15).abs() < 1e-9);
}
#[test]
fn test_no_backend_does_not_fabricate_residuals() {
let cond = AirCooledCondenser::new(308.15, 12.0);
let state = vec![0.0_f64; 10];
let mut residuals = vec![99.0_f64; 3];
let result = cond.compute_residuals(&state, &mut residuals);
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
}

View File

@@ -23,9 +23,10 @@
//! assert_eq!(cond.n_equations(), 3);
//! ```
use super::bphx_correlation::{BphxCorrelation, CorrelationResult};
use super::bphx_correlation::{BphxCorrelation, CorrelationEvaluation};
use super::bphx_exchanger::BphxExchanger;
use super::bphx_geometry::{BphxGeometry, BphxType};
use super::correlation_registry::CorrelationSelectionError;
use super::exchanger::HxSideConditions;
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
@@ -105,17 +106,22 @@ impl BphxCondenser {
/// Sets the refrigerant fluid identifier.
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
self.refrigerant_id = fluid.into();
self.inner.set_refrigerant_id(self.refrigerant_id.clone());
self.inner.set_hot_fluid(self.refrigerant_id.clone());
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.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 FluidBackend>) -> Self {
self.inner
.set_fluid_backend_from_builder(Arc::clone(&backend));
self.fluid_backend = Some(backend);
self
}
@@ -298,7 +304,7 @@ impl BphxCondenser {
pr_l: f64,
t_sat: f64,
t_wall: f64,
) -> CorrelationResult {
) -> Result<CorrelationEvaluation, CorrelationSelectionError> {
self.inner.compute_htc(
mass_flux, quality, rho_l, rho_v, mu_l, mu_v, k_l, pr_l, t_sat, t_wall,
)
@@ -393,6 +399,23 @@ impl Component for BphxCondenser {
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> {
vec![
"inlet".to_string(),
"outlet".to_string(),
"secondary_inlet".to_string(),
"secondary_outlet".to_string(),
]
}
fn flow_paths(&self) -> Vec<(usize, usize)> {
vec![(0, 1), (2, 3)]
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
@@ -409,11 +432,14 @@ impl Component for BphxCondenser {
self.inner.energy_transfers(state)
}
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
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(backend.clone());
self.inner.set_fluid_backend_from_builder(backend);
self.fluid_backend = Some(Arc::clone(&backend));
}
self.inner.set_fluid_backend_from_builder(backend);
}
fn signature(&self) -> String {
@@ -580,6 +606,7 @@ mod tests {
let geo = test_geometry();
let cond = BphxCondenser::new(geo).with_refrigerant("R410A");
assert_eq!(cond.refrigerant_id, "R410A");
assert_eq!(cond.inner.refrigerant_id(), Some("R410A"));
}
#[test]
@@ -606,7 +633,24 @@ mod tests {
let mut residuals = vec![0.0; 3];
let result = cond.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
fn test_bphx_condenser_exposes_four_port_metadata() {
let geo = test_geometry();
let cond = BphxCondenser::new(geo);
assert_eq!(
cond.port_names(),
vec![
"inlet".to_string(),
"outlet".to_string(),
"secondary_inlet".to_string(),
"secondary_outlet".to_string(),
]
);
assert_eq!(cond.flow_paths(), vec![(0, 1), (2, 3)]);
}
#[test]
@@ -636,7 +680,7 @@ mod tests {
let geo = test_geometry();
let cond = BphxCondenser::new(geo);
let calib = cond.calib();
assert_eq!(calib.f_ua, 1.0);
assert_eq!(calib.z_ua, 1.0);
}
#[test]
@@ -644,9 +688,9 @@ mod tests {
let geo = test_geometry();
let mut cond = BphxCondenser::new(geo);
let mut calib = Calib::default();
calib.f_ua = 0.9;
calib.z_ua = 0.9;
cond.set_calib(calib);
assert_eq!(cond.calib().f_ua, 0.9);
assert_eq!(cond.calib().z_ua, 0.9);
}
#[test]
@@ -698,9 +742,11 @@ mod tests {
let geo = test_geometry();
let cond = BphxCondenser::new(geo);
let result = cond.compute_htc(
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
);
let result = cond
.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);
@@ -717,7 +763,7 @@ mod tests {
let mut cond_with_calib = BphxCondenser::new(geo);
let mut calib = Calib::default();
calib.f_dp = 0.5;
calib.z_dp = 0.5;
cond_with_calib.set_calib(calib);
let dp_calib = cond_with_calib.compute_pressure_drop(30.0, 1100.0);
@@ -777,7 +823,7 @@ mod tests {
let state = vec![0.0, 0.0, 300_000.0, 400_000.0, 0.0, 0.0];
let mut residuals = vec![0.0; 3];
let result = cond.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
@@ -845,13 +891,13 @@ mod tests {
}
#[test]
fn test_bphx_condenser_default_correlation_is_longo() {
fn test_bphx_condenser_default_selection_is_automatic() {
let geo = test_geometry();
let cond = BphxCondenser::new(geo);
let sig = cond.signature();
assert!(
sig.contains("Longo (2004)"),
"Default correlation should be Longo2004 for condensation"
sig.contains("Automatic"),
"Default correlation policy should be automatic for condensation"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,32 @@
//! BphxEvaporator - Brazed Plate Heat Exchanger Evaporator Component
//!
//! A plate evaporator component supporting both DX (Direct Expansion) and
//! Flooded operation modes with geometry-based heat transfer correlations.
//! A plate evaporator component. Brazed plate exchangers in this project are
//! modeled as Direct Expansion (DX) only: the refrigerant outlet is always
//! superheated vapor (x >= 1), controlled by a target superheat closure.
//!
//! ## Operation Modes
//!
//! - **DX Mode**: Outlet is superheated vapor (x >= 1), controlled by superheat
//! - **Flooded Mode**: Outlet is two-phase (x ~ 0.5-0.8), works with Drum for recirculation
//! A "flooded" operating style (liquid overfeed with vapor/liquid separation)
//! is a system-topology property achieved by connecting a `Drum` and
//! recirculation loop around a DX-terminated exchanger — it is not a distinct
//! mode of the plate exchanger itself, so it is intentionally not modeled here.
//! Use `FloodedEvaporator` for a shell-and-tube flooded evaporator.
//!
//! ## Example
//!
//! ```ignore
//! use entropyk_components::heat_exchanger::{BphxEvaporator, BphxGeometry, BphxEvaporatorMode};
//! use entropyk_components::heat_exchanger::{BphxEvaporator, BphxGeometry};
//!
//! // DX evaporator
//! let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
//! let evap = BphxEvaporator::new(geo)
//! .with_mode(BphxEvaporatorMode::Dx { target_superheat: 5.0 })
//! .with_target_superheat(5.0)
//! .with_refrigerant("R410A");
//!
//! assert_eq!(evap.n_equations(), 3);
//! ```
use super::bphx_correlation::{BphxCorrelation, CorrelationResult};
use super::bphx_correlation::{BphxCorrelation, CorrelationEvaluation};
use super::bphx_exchanger::BphxExchanger;
use super::bphx_geometry::{BphxGeometry, BphxType};
use super::correlation_registry::CorrelationSelectionError;
use super::exchanger::HxSideConditions;
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
@@ -35,69 +37,18 @@ use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
use std::cell::Cell;
use std::sync::Arc;
/// Operation mode for BphxEvaporator
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BphxEvaporatorMode {
/// Direct Expansion - outlet is superheated vapor (x >= 1)
Dx {
/// Target superheat in Kelvin (default: 5.0 K)
target_superheat: f64,
},
/// Flooded - outlet is two-phase (x ~ 0.5-0.8)
Flooded {
/// Target outlet quality (default: 0.7)
target_quality: f64,
},
}
impl Default for BphxEvaporatorMode {
fn default() -> Self {
Self::Dx {
target_superheat: 5.0,
}
}
}
impl BphxEvaporatorMode {
/// Returns the target superheat (DX mode) or None (Flooded mode)
pub fn target_superheat(&self) -> Option<f64> {
match self {
Self::Dx { target_superheat } => Some(*target_superheat),
Self::Flooded { .. } => None,
}
}
/// Returns the target quality (Flooded mode) or None (DX mode)
pub fn target_quality(&self) -> Option<f64> {
match self {
Self::Dx { .. } => None,
Self::Flooded { target_quality } => Some(*target_quality),
}
}
/// Returns true if DX mode
pub fn is_dx(&self) -> bool {
matches!(self, Self::Dx { .. })
}
/// Returns true if Flooded mode
pub fn is_flooded(&self) -> bool {
matches!(self, Self::Flooded { .. })
}
}
/// BphxEvaporator - Brazed Plate Heat Exchanger configured as evaporator
///
/// Supports DX and Flooded operation modes with geometry-based correlations.
/// Wraps a `BphxExchanger` for base residual computation.
/// Direct Expansion (DX) only: the refrigerant outlet is always superheated
/// vapor (x >= 1), controlled by a target superheat closure. Wraps a
/// `BphxExchanger` for base residual computation.
pub struct BphxEvaporator {
inner: BphxExchanger,
mode: BphxEvaporatorMode,
target_superheat_k: f64,
refrigerant_id: String,
secondary_fluid_id: String,
fluid_backend: Option<Arc<dyn FluidBackend>>,
last_superheat: Cell<Option<f64>>,
last_outlet_quality: Cell<Option<f64>>,
outlet_pressure_idx: Option<usize>,
outlet_enthalpy_idx: Option<usize>,
}
@@ -107,7 +58,7 @@ impl std::fmt::Debug for BphxEvaporator {
f.debug_struct("BphxEvaporator")
.field("ua", &self.inner.ua())
.field("geometry", &self.inner.geometry())
.field("mode", &self.mode)
.field("target_superheat_k", &self.target_superheat_k)
.field("refrigerant_id", &self.refrigerant_id)
.field("secondary_fluid_id", &self.secondary_fluid_id)
.field("has_fluid_backend", &self.fluid_backend.is_some())
@@ -136,12 +87,11 @@ impl BphxEvaporator {
let geometry = geometry.with_exchanger_type(BphxType::Evaporator);
Self {
inner: BphxExchanger::new(geometry),
mode: BphxEvaporatorMode::default(),
target_superheat_k: 5.0,
refrigerant_id: String::new(),
secondary_fluid_id: String::new(),
fluid_backend: None,
last_superheat: Cell::new(None),
last_outlet_quality: Cell::new(None),
outlet_pressure_idx: None,
outlet_enthalpy_idx: None,
}
@@ -152,26 +102,31 @@ impl BphxEvaporator {
Self::new(geometry)
}
/// Sets the operation mode.
pub fn with_mode(mut self, mode: BphxEvaporatorMode) -> Self {
self.mode = mode;
/// Sets the target superheat (K) used as the DX outlet closure.
pub fn with_target_superheat(mut self, target_superheat_k: f64) -> Self {
self.target_superheat_k = target_superheat_k;
self
}
/// Sets the refrigerant fluid identifier.
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
self.refrigerant_id = fluid.into();
self.inner.set_refrigerant_id(self.refrigerant_id.clone());
self.inner.set_cold_fluid(self.refrigerant_id.clone());
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.inner.set_hot_fluid(self.secondary_fluid_id.clone());
self
}
/// Attaches a fluid backend for property queries.
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
self.inner
.set_fluid_backend_from_builder(Arc::clone(&backend));
self.fluid_backend = Some(backend);
self
}
@@ -192,9 +147,9 @@ impl BphxEvaporator {
self.inner.geometry()
}
/// Returns the operation mode.
pub fn mode(&self) -> BphxEvaporatorMode {
self.mode
/// Returns the target superheat (K) used as the DX outlet closure.
pub fn target_superheat_k(&self) -> f64 {
self.target_superheat_k
}
/// Returns the effective UA value (W/K).
@@ -215,23 +170,12 @@ impl BphxEvaporator {
/// Returns the last computed superheat (K).
///
/// Returns `None` if:
/// - Mode is not DX
/// - `compute_residuals` has not been called
/// - No FluidBackend configured
pub fn superheat(&self) -> Option<f64> {
self.last_superheat.get()
}
/// Returns the last computed outlet quality.
///
/// Returns `None` if:
/// - Mode is not Flooded
/// - `compute_residuals` has not been called
/// - No FluidBackend configured
pub fn outlet_quality(&self) -> Option<f64> {
self.last_outlet_quality.get()
}
/// Sets the outlet state indices for quality/superheat control.
///
/// These indices point to the pressure and enthalpy in the global state vector
@@ -253,8 +197,9 @@ impl BphxEvaporator {
/// Computes outlet quality from enthalpy and saturation properties.
///
/// Returns `None` if no FluidBackend is configured or saturation properties
/// cannot be computed.
/// Used only to validate that the DX outlet is genuinely superheated
/// (quality >= 1.0). Returns `None` if no FluidBackend is configured or
/// saturation properties cannot be computed.
fn compute_quality(&self, h_out: f64, p_pa: f64) -> Option<f64> {
if self.refrigerant_id.is_empty() {
return None;
@@ -331,7 +276,7 @@ impl BphxEvaporator {
pr_l: f64,
t_sat: f64,
t_wall: f64,
) -> CorrelationResult {
) -> Result<CorrelationEvaluation, CorrelationSelectionError> {
self.inner.compute_htc(
mass_flux, quality, rho_l, rho_v, mu_l, mu_v, k_l, pr_l, t_sat, t_wall,
)
@@ -347,12 +292,11 @@ impl BphxEvaporator {
self.inner.update_ua_from_htc(h);
}
/// Validates that outlet is in the correct region for the mode.
/// Validates that the outlet is genuinely superheated (DX operation).
///
/// # Errors
///
/// - DX mode: Returns error if outlet quality < 1.0 (not superheated)
/// - Flooded mode: Returns error if outlet quality >= 1.0 (superheated)
/// Returns an error if outlet quality < 1.0 (not superheated).
pub fn validate_outlet(&self, h_out: f64, p_pa: f64) -> Result<f64, ComponentError> {
if self.refrigerant_id.is_empty() {
return Err(ComponentError::InvalidState(
@@ -372,27 +316,13 @@ impl BphxEvaporator {
))
})?;
match self.mode {
BphxEvaporatorMode::Dx { .. } => {
if quality < 1.0 {
Err(ComponentError::InvalidState(format!(
"BphxEvaporator DX mode: outlet quality {:.2} < 1.0 (not superheated)",
quality
)))
} else {
Ok(quality)
}
}
BphxEvaporatorMode::Flooded { .. } => {
if quality >= 1.0 {
Err(ComponentError::InvalidState(format!(
"BphxEvaporator Flooded mode: outlet quality {:.2} >= 1.0 (superheated). Use DX mode instead.",
quality
)))
} else {
Ok(quality)
}
}
if quality < 1.0 {
Err(ComponentError::InvalidState(format!(
"BphxEvaporator DX mode: outlet quality {:.2} < 1.0 (not superheated)",
quality
)))
} else {
Ok(quality)
}
}
}
@@ -414,17 +344,8 @@ impl Component for BphxEvaporator {
let p_pa = state[p_idx];
let h_out = state[h_idx];
match self.mode {
BphxEvaporatorMode::Dx { .. } => {
if let Some(sh) = self.compute_superheat(h_out, p_pa) {
self.last_superheat.set(Some(sh));
}
}
BphxEvaporatorMode::Flooded { .. } => {
if let Some(q) = self.compute_quality(h_out, p_pa) {
self.last_outlet_quality.set(Some(q.clamp(0.0, 1.0)));
}
}
if let Some(sh) = self.compute_superheat(h_out, p_pa) {
self.last_superheat.set(Some(sh));
}
}
}
@@ -444,6 +365,29 @@ impl Component for BphxEvaporator {
self.inner.get_ports()
}
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
let remapped = [
port_edges.get(2).copied().flatten(),
port_edges.get(3).copied().flatten(),
port_edges.first().copied().flatten(),
port_edges.get(1).copied().flatten(),
];
self.inner.set_port_context(&remapped);
}
fn port_names(&self) -> Vec<String> {
vec![
"inlet".to_string(),
"outlet".to_string(),
"secondary_inlet".to_string(),
"secondary_outlet".to_string(),
]
}
fn flow_paths(&self) -> Vec<(usize, usize)> {
vec![(0, 1), (2, 3)]
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
@@ -460,22 +404,18 @@ impl Component for BphxEvaporator {
self.inner.energy_transfers(state)
}
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
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(backend.clone());
self.inner.set_fluid_backend_from_builder(backend);
self.fluid_backend = Some(Arc::clone(&backend));
}
self.inner.set_fluid_backend_from_builder(backend);
}
fn signature(&self) -> String {
let mode_str = match self.mode {
BphxEvaporatorMode::Dx { target_superheat } => {
format!("DX,tsh={:.1}K", target_superheat)
}
BphxEvaporatorMode::Flooded { target_quality } => {
format!("Flooded,q={:.2}", target_quality)
}
};
let mode_str = format!("DX,tsh={:.1}K", self.target_superheat_k);
format!(
"BphxEvaporator({} plates, dh={:.2}mm, A={:.3}m², {}, {}, {})",
self.inner.geometry().n_plates,
@@ -534,32 +474,15 @@ mod tests {
fn test_bphx_evaporator_mode_default() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo);
assert!(evap.mode().is_dx());
assert_eq!(evap.mode().target_superheat(), Some(5.0));
assert_eq!(evap.target_superheat_k(), 5.0);
}
#[test]
fn test_bphx_evaporator_with_dx_mode() {
fn test_bphx_evaporator_with_target_superheat() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo).with_mode(BphxEvaporatorMode::Dx {
target_superheat: 10.0,
});
let evap = BphxEvaporator::new(geo).with_target_superheat(10.0);
assert!(evap.mode().is_dx());
assert_eq!(evap.mode().target_superheat(), Some(10.0));
assert_eq!(evap.mode().target_quality(), None);
}
#[test]
fn test_bphx_evaporator_with_flooded_mode() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo).with_mode(BphxEvaporatorMode::Flooded {
target_quality: 0.7,
});
assert!(evap.mode().is_flooded());
assert_eq!(evap.mode().target_quality(), Some(0.7));
assert_eq!(evap.mode().target_superheat(), None);
assert_eq!(evap.target_superheat_k(), 10.0);
}
#[test]
@@ -567,6 +490,7 @@ mod tests {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo).with_refrigerant("R410A");
assert_eq!(evap.refrigerant_id, "R410A");
assert_eq!(evap.inner.refrigerant_id(), Some("R410A"));
}
#[test]
@@ -593,7 +517,24 @@ mod tests {
let mut residuals = vec![0.0; 3];
let result = evap.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
fn test_bphx_evaporator_exposes_four_port_metadata() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo);
assert_eq!(
evap.port_names(),
vec![
"inlet".to_string(),
"outlet".to_string(),
"secondary_inlet".to_string(),
"secondary_outlet".to_string(),
]
);
assert_eq!(evap.flow_paths(), vec![(0, 1), (2, 3)]);
}
#[test]
@@ -623,7 +564,7 @@ mod tests {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo);
let calib = evap.calib();
assert_eq!(calib.f_ua, 1.0);
assert_eq!(calib.z_ua, 1.0);
}
#[test]
@@ -631,9 +572,9 @@ mod tests {
let geo = test_geometry();
let mut evap = BphxEvaporator::new(geo);
let mut calib = Calib::default();
calib.f_ua = 0.9;
calib.z_ua = 0.9;
evap.set_calib(calib);
assert_eq!(evap.calib().f_ua, 0.9);
assert_eq!(evap.calib().z_ua, 0.9);
}
#[test]
@@ -647,9 +588,7 @@ mod tests {
fn test_bphx_evaporator_signature_dx() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo)
.with_mode(BphxEvaporatorMode::Dx {
target_superheat: 5.0,
})
.with_target_superheat(5.0)
.with_refrigerant("R410A");
let sig = evap.signature();
@@ -659,22 +598,6 @@ mod tests {
assert!(sig.contains("tsh=5.0K"));
}
#[test]
fn test_bphx_evaporator_signature_flooded() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo)
.with_mode(BphxEvaporatorMode::Flooded {
target_quality: 0.7,
})
.with_refrigerant("R134a");
let sig = evap.signature();
assert!(sig.contains("BphxEvaporator"));
assert!(sig.contains("Flooded"));
assert!(sig.contains("R134a"));
assert!(sig.contains("q=0.70"));
}
#[test]
fn test_bphx_evaporator_energy_transfers() {
let geo = test_geometry();
@@ -688,29 +611,20 @@ mod tests {
#[test]
fn test_bphx_evaporator_superheat_initial() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo).with_mode(BphxEvaporatorMode::Dx {
target_superheat: 5.0,
});
let evap = BphxEvaporator::new(geo).with_target_superheat(5.0);
assert_eq!(evap.superheat(), None);
}
#[test]
fn test_bphx_evaporator_outlet_quality_initial() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo).with_mode(BphxEvaporatorMode::Flooded {
target_quality: 0.7,
});
assert_eq!(evap.outlet_quality(), None);
}
#[test]
fn test_bphx_evaporator_compute_htc() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo);
let result = evap.compute_htc(
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
);
let result = evap
.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);
@@ -727,7 +641,7 @@ mod tests {
let mut evap_with_calib = BphxEvaporator::new(geo);
let mut calib = Calib::default();
calib.f_dp = 0.5;
calib.z_dp = 0.5;
evap_with_calib.set_calib(calib);
let dp_calib = evap_with_calib.compute_pressure_drop(30.0, 1100.0);
@@ -787,27 +701,12 @@ mod tests {
let state = vec![0.0, 0.0, 300_000.0, 400_000.0, 0.0, 0.0];
let mut residuals = vec![0.0; 3];
let result = evap.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
#[test]
fn test_bphx_evaporator_mode_is_dx() {
let dx_mode = BphxEvaporatorMode::Dx {
target_superheat: 5.0,
};
assert!(dx_mode.is_dx());
assert!(!dx_mode.is_flooded());
let flooded_mode = BphxEvaporatorMode::Flooded {
target_quality: 0.7,
};
assert!(flooded_mode.is_flooded());
assert!(!flooded_mode.is_dx());
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
fn test_bphx_evaporator_superheat_with_mock_backend() {
use entropyk_core::{Enthalpy, Temperature};
use entropyk_core::Enthalpy;
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
struct MockBackend;
@@ -871,9 +770,7 @@ mod tests {
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(MockBackend);
let geo = test_geometry();
let evap = BphxEvaporator::new(geo)
.with_mode(BphxEvaporatorMode::Dx {
target_superheat: 5.0,
})
.with_target_superheat(5.0)
.with_refrigerant("R134a")
.with_fluid_backend(backend);
@@ -945,9 +842,7 @@ mod tests {
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(MockBackend);
let geo = test_geometry();
let evap = BphxEvaporator::new(geo)
.with_mode(BphxEvaporatorMode::Flooded {
target_quality: 0.7,
})
.with_target_superheat(5.0)
.with_refrigerant("R134a")
.with_fluid_backend(backend);
@@ -958,28 +853,13 @@ mod tests {
}
#[test]
fn test_bphx_evaporator_drum_interface_compatibility() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo)
.with_mode(BphxEvaporatorMode::Flooded {
target_quality: 0.7,
})
.with_refrigerant("R410A");
assert_eq!(evap.refrigerant_id, "R410A");
assert!(evap.mode().is_flooded());
assert_eq!(evap.mode().target_quality(), Some(0.7));
assert_eq!(evap.n_equations(), 2);
}
#[test]
fn test_bphx_evaporator_default_correlation_is_longo() {
fn test_bphx_evaporator_default_selection_is_automatic() {
let geo = test_geometry();
let evap = BphxEvaporator::new(geo);
let sig = evap.signature();
assert!(
sig.contains("Longo (2004)"),
"Default correlation should be Longo2004 for evaporation"
sig.contains("Automatic"),
"Default correlation policy should be automatic for evaporation"
);
}
}

View File

@@ -30,10 +30,13 @@
//! ```
use super::bphx_correlation::{
BphxCorrelation, CorrelationParams, CorrelationResult, CorrelationSelector, FlowRegime,
ValidityStatus,
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};
@@ -41,7 +44,7 @@ use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power};
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::sync::Arc;
/// BphxExchanger - Brazed Plate Heat Exchanger component
@@ -57,6 +60,7 @@ pub struct BphxExchanger {
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>,
}
@@ -109,6 +113,7 @@ impl BphxExchanger {
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),
}
}
@@ -125,6 +130,7 @@ impl BphxExchanger {
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),
}
}
@@ -147,22 +153,46 @@ impl BphxExchanger {
/// Sets the refrigerant fluid identifier.
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
self.refrigerant_id = fluid.into();
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()
@@ -173,9 +203,13 @@ impl BphxExchanger {
&self.geometry
}
/// Returns the name of the configured heat transfer correlation.
/// Returns the configured formula name, or `Automatic` before/while auto-selecting.
pub fn correlation_name(&self) -> &'static str {
self.correlation_selector.correlation.name()
if self.correlation_selector.automatic {
"Automatic"
} else {
self.correlation_selector.correlation.name()
}
}
/// Returns the effective UA value (W/K).
@@ -203,6 +237,11 @@ impl BphxExchanger {
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()
@@ -245,7 +284,7 @@ impl BphxExchanger {
pr_l: f64,
t_sat: f64,
t_wall: f64,
) -> CorrelationResult {
) -> Result<CorrelationEvaluation, CorrelationSelectionError> {
let regime = match self.geometry.exchanger_type {
BphxType::Evaporator => FlowRegime::Evaporation,
BphxType::Condenser => FlowRegime::Condensation,
@@ -266,18 +305,37 @@ impl BphxExchanger {
t_wall,
regime,
chevron_angle: self.geometry.chevron_angle,
p_reduced: 0.2,
};
let result = self.correlation_selector.compute_htc(&params);
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);
if result.validity == ValidityStatus::Extrapolation {
self.last_validity_warning.set(true);
}
Ok(evaluation)
}
result
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.
@@ -285,7 +343,7 @@ impl BphxExchanger {
/// Δ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`.
/// The result is scaled by `calib().z_dp`.
///
/// # Arguments
///
@@ -300,24 +358,47 @@ impl BphxExchanger {
return 0.0;
}
let re = mass_flux * self.geometry.dh / 0.0002;
let f = if re < 2300.0 {
64.0 / re.max(1.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 {
0.079 * re.powf(-0.25)
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().f_dp
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().f_ua;
let ua = h * self.geometry.area * self.calib().z_ua;
self.inner.set_ua_scale(ua / self.inner.ua_nominal());
}
}
@@ -347,6 +428,18 @@ impl Component for BphxExchanger {
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);
}
@@ -363,10 +456,14 @@ impl Component for BphxExchanger {
self.inner.energy_transfers(state)
}
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
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(backend);
self.fluid_backend = Some(Arc::clone(&backend));
}
self.inner.set_fluid_backend_from_builder(backend);
}
fn signature(&self) -> String {
@@ -375,7 +472,7 @@ impl Component for BphxExchanger {
self.geometry.n_plates,
self.geometry.dh * 1000.0,
self.geometry.area,
self.correlation_selector.correlation.name()
self.correlation_name()
)
}
@@ -456,7 +553,24 @@ mod tests {
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
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]
@@ -486,13 +600,24 @@ mod tests {
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,
);
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(), 8);
assert_eq!(
hx.last_selection_report().unwrap().selected,
result.selection.selected
);
}
#[test]
@@ -502,9 +627,11 @@ mod tests {
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,
);
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);
}
@@ -517,7 +644,7 @@ mod tests {
let sig = hx.signature();
assert!(sig.contains("BphxExchanger"));
assert!(sig.contains("20 plates"));
assert!(sig.contains("Longo (2004)"));
assert!(sig.contains("Automatic"));
}
#[test]
@@ -535,7 +662,7 @@ mod tests {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
let calib = hx.calib();
assert_eq!(calib.f_ua, 1.0);
assert_eq!(calib.z_ua, 1.0);
}
#[test]
@@ -543,9 +670,9 @@ mod tests {
let geo = test_geometry();
let mut hx = BphxExchanger::new(geo);
let mut calib = Calib::default();
calib.f_ua = 0.9;
calib.z_ua = 0.9;
hx.set_calib(calib);
assert_eq!(hx.calib().f_ua, 0.9);
assert_eq!(hx.calib().z_ua, 0.9);
}
#[test]
@@ -570,18 +697,59 @@ mod tests {
#[test]
fn test_bphx_exchanger_validity_warning() {
let geo = test_geometry();
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();
@@ -592,10 +760,88 @@ mod tests {
let mut hx_with_calib = BphxExchanger::new(geo);
let mut calib = Calib::default();
calib.f_dp = 0.5;
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})"
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@ use crate::{
///
/// let coil = CondenserCoil::new(10_000.0); // UA = 10 kW/K
/// assert_eq!(coil.ua(), 10_000.0);
/// assert_eq!(coil.n_equations(), 2);
/// assert_eq!(coil.n_equations(), 3); // 2 thermo + 1 mass-flow (CM1.3)
/// ```
#[derive(Debug)]
pub struct CondenserCoil {
@@ -197,7 +197,8 @@ mod tests {
#[test]
fn test_condenser_coil_n_equations() {
let coil = CondenserCoil::new(10_000.0);
assert_eq!(coil.n_equations(), 2);
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Condenser)
assert_eq!(coil.n_equations(), 3);
}
#[test]
@@ -212,11 +213,7 @@ mod tests {
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"
);
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]

File diff suppressed because it is too large Load Diff

View File

@@ -247,7 +247,7 @@ mod tests {
let mut residuals = vec![0.0; 3];
let result = economizer.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]

View File

@@ -234,11 +234,8 @@ impl HeatTransferModel for EpsNtuModel {
)
.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);
let q_hot = hot_inlet.mass_flow * (hot_inlet.enthalpy - hot_outlet.enthalpy);
let q_cold = cold_inlet.mass_flow * (cold_outlet.enthalpy - cold_inlet.enthalpy);
residuals[0] = q_hot - q;
residuals[1] = q_cold - q;

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@ use crate::{
///
/// let coil = EvaporatorCoil::new(8_000.0); // UA = 8 kW/K
/// assert_eq!(coil.ua(), 8_000.0);
/// assert_eq!(coil.n_equations(), 2);
/// assert_eq!(coil.n_equations(), 3); // 2 thermo + 1 mass-flow (CM1.3)
/// ```
#[derive(Debug)]
pub struct EvaporatorCoil {
@@ -207,7 +207,8 @@ mod tests {
#[test]
fn test_evaporator_coil_n_equations() {
let coil = EvaporatorCoil::new(5_000.0);
assert_eq!(coil.n_equations(), 2);
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Evaporator)
assert_eq!(coil.n_equations(), 3);
}
#[test]
@@ -223,11 +224,7 @@ mod tests {
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"
);
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]

View File

@@ -5,9 +5,9 @@
//!
//! ## Fluid Backend Integration (Story 5.1)
//!
//! When a `FluidBackend` is provided via `with_fluid_backend()`, `compute_residuals`
//! queries the backend for real Cp and enthalpy values at the boundary conditions
//! instead of using hardcoded placeholder values.
//! `compute_residuals` requires live four-port edge state. Inlet-only boundary
//! conditions may be used for property inspection, but they are not enough to
//! synthesize outlet states.
use super::model::{FluidState, HeatTransferModel};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
@@ -48,7 +48,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchangerBuilder<Model> {
self
}
/// Builds the heat exchanger with placeholder connected ports.
/// Builds the heat exchanger. Topology is injected later by name/context.
pub fn build(self) -> HeatExchanger<Model> {
HeatExchanger::new(self.model, self.name).with_circuit_id(self.circuit_id)
}
@@ -167,8 +167,8 @@ impl HxSideConditions {
/// Uses the Strategy Pattern for heat transfer calculations via the
/// `HeatTransferModel` trait. When a `FluidBackend` is attached via
/// [`with_fluid_backend`](Self::with_fluid_backend), the `compute_residuals`
/// method queries real thermodynamic properties (Cp, h) from the backend
/// instead of using hardcoded placeholder values.
/// method queries real thermodynamic properties (Cp, h) from the live edge
/// state instead of using hardcoded placeholder values.
pub struct HeatExchanger<Model: HeatTransferModel> {
model: Model,
name: String,
@@ -184,6 +184,23 @@ pub struct HeatExchanger<Model: HeatTransferModel> {
hot_conditions: Option<HxSideConditions>,
/// Boundary conditions for the cold side inlet.
cold_conditions: Option<HxSideConditions>,
// ── 4-port (Modelica-style) edge-driven mode ───────────────────────────
/// Hot inlet edge state indices (m, p, h). Wired by `set_port_context` port 0.
hot_in_idx: Option<(usize, usize, usize)>,
/// Hot outlet edge state indices. Wired by `set_port_context` port 1.
hot_out_idx: Option<(usize, usize, usize)>,
/// Cold inlet edge state indices. Wired by `set_port_context` port 2.
cold_in_idx: Option<(usize, usize, usize)>,
/// Cold outlet edge state indices. Wired by `set_port_context` port 3.
cold_out_idx: Option<(usize, usize, usize)>,
/// Hot-side fluid identifier ("Water", "Air", "INCOMP::MEG-30"…).
hot_fluid_id_str: String,
/// Cold-side fluid identifier.
cold_fluid_id_str: String,
/// Humidity ratio for moist-air hot side (0 = dry).
hot_humidity_ratio: f64,
/// Humidity ratio for moist-air cold side.
cold_humidity_ratio: f64,
_phantom: PhantomData<()>,
}
@@ -204,7 +221,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
/// Creates a new heat exchanger with the given model.
pub fn new(mut model: Model, name: impl Into<String>) -> Self {
let calib = Calib::default();
model.set_ua_scale(calib.f_ua);
model.set_ua_scale(calib.z_ua);
Self {
model,
name: name.into(),
@@ -215,6 +232,14 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
fluid_backend: None,
hot_conditions: None,
cold_conditions: None,
hot_in_idx: None,
hot_out_idx: None,
cold_in_idx: None,
cold_out_idx: None,
hot_fluid_id_str: String::new(),
cold_fluid_id_str: String::new(),
hot_humidity_ratio: 0.0,
cold_humidity_ratio: 0.0,
_phantom: PhantomData,
}
}
@@ -349,6 +374,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
}
/// Queries Cp (J/(kg·K)) from the backend for a given side.
#[allow(dead_code)]
fn query_cp(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
if let Some(backend) = &self.fluid_backend {
let state = entropyk_fluids::FluidState::from_pt(
@@ -448,10 +474,261 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
/// Sets calibration factors.
pub fn set_calib(&mut self, calib: Calib) {
self.model.set_ua_scale(calib.f_ua);
self.model.set_ua_scale(calib.z_ua);
self.calib = calib;
}
// ── 4-port (Modelica-style) configuration ───────────────────────────────
/// Declares the hot-side fluid for edge-driven 4-port mode ("Water", "Air",
/// "INCOMP::MEG-30"…). When hot-side edges are wired (ports 0 and 1), the
/// HX reads T and cp from the live edge state via the backend.
pub fn with_hot_fluid(mut self, fluid: impl Into<String>) -> Self {
self.hot_fluid_id_str = fluid.into();
self
}
/// Declares the cold-side fluid for edge-driven 4-port mode.
pub fn with_cold_fluid(mut self, fluid: impl Into<String>) -> Self {
self.cold_fluid_id_str = fluid.into();
self
}
/// Sets the hot-side fluid identifier (see [`with_hot_fluid`]).
pub fn set_hot_fluid(&mut self, fluid: impl Into<String>) {
self.hot_fluid_id_str = fluid.into();
}
/// Sets the cold-side fluid identifier.
pub fn set_cold_fluid(&mut self, fluid: impl Into<String>) {
self.cold_fluid_id_str = fluid.into();
}
/// Sets the humidity ratio for the hot side (moist air).
pub fn set_hot_humidity_ratio(&mut self, w: f64) {
self.hot_humidity_ratio = w.max(0.0);
}
/// Sets the humidity ratio for the cold side (moist air).
pub fn set_cold_humidity_ratio(&mut self, w: f64) {
self.cold_humidity_ratio = w.max(0.0);
}
/// `true` when all 4 edges are wired (Modelica-style 4-port mode).
fn edges_ready(&self) -> bool {
self.hot_in_idx.is_some()
&& self.hot_out_idx.is_some()
&& self.cold_in_idx.is_some()
&& self.cold_out_idx.is_some()
&& !self.hot_fluid_id_str.is_empty()
&& !self.cold_fluid_id_str.is_empty()
}
fn live_state_required_error(&self) -> ComponentError {
ComponentError::InvalidState(format!(
"{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",
self.name
))
}
pub(crate) fn live_fluid_states(
&self,
state: &StateSlice,
) -> Result<(FluidState, FluidState, FluidState, FluidState), ComponentError> {
if !self.edges_ready() {
return Err(self.live_state_required_error());
}
let (m_h, p_h_in, h_h_in) = self.hot_in_idx.unwrap();
let (m_h_out, p_h_out, h_h_out) = self.hot_out_idx.unwrap();
let (m_c, p_c_in, h_c_in) = self.cold_in_idx.unwrap();
let (m_c_out, p_c_out, h_c_out) = self.cold_out_idx.unwrap();
let max_idx = [
m_h, p_h_in, h_h_in, m_h_out, p_h_out, h_h_out, m_c, p_c_in, h_c_in, m_c_out, p_c_out,
h_c_out,
]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
let hot_cp_in = self.hot_cp(state[p_h_in], state[h_h_in])?;
let hot_cp_out = self.hot_cp(state[p_h_out], state[h_h_out])?;
let cold_cp_in = self.cold_cp(state[p_c_in], state[h_c_in])?;
let cold_cp_out = self.cold_cp(state[p_c_out], state[h_c_out])?;
let hot_t_in = self.hot_temperature(state[p_h_in], state[h_h_in])?;
let hot_t_out = self.hot_temperature(state[p_h_out], state[h_h_out])?;
let cold_t_in = self.cold_temperature(state[p_c_in], state[h_c_in])?;
let cold_t_out = self.cold_temperature(state[p_c_out], state[h_c_out])?;
let m_hot = state[m_h].max(0.0);
let m_cold = state[m_c].max(0.0);
Ok((
Self::create_fluid_state(hot_t_in, state[p_h_in], state[h_h_in], m_hot, hot_cp_in),
Self::create_fluid_state(hot_t_out, state[p_h_out], state[h_h_out], m_hot, hot_cp_out),
Self::create_fluid_state(cold_t_in, state[p_c_in], state[h_c_in], m_cold, cold_cp_in),
Self::create_fluid_state(
cold_t_out,
state[p_c_out],
state[h_c_out],
m_cold,
cold_cp_out,
),
))
}
/// `true` when the hot-side fluid follows the moist-air convention.
fn hot_is_air(&self) -> bool {
let f = self.hot_fluid_id_str.trim();
f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
}
/// `true` when the cold-side fluid follows the moist-air convention.
fn cold_is_air(&self) -> bool {
let f = self.cold_fluid_id_str.trim();
f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
}
/// Hot-side cp [J/(kg·K)] at (P, h). Moist air uses the psychrometric cp;
/// other fluids query the backend.
fn hot_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.hot_is_air() {
return Ok(1006.0 + 1860.0 * self.hot_humidity_ratio);
}
self.query_live_property("hot", &self.hot_fluid_id_str, Property::Cp, p_pa, h_jkg)
.and_then(|cp| {
if cp.is_finite() && cp > 0.0 {
Ok(cp)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} hot-side Cp is invalid: {}",
self.name, cp
)))
}
})
}
/// Cold-side cp [J/(kg·K)] at (P, h).
fn cold_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.cold_is_air() {
return Ok(1006.0 + 1860.0 * self.cold_humidity_ratio);
}
self.query_live_property("cold", &self.cold_fluid_id_str, Property::Cp, p_pa, h_jkg)
.and_then(|cp| {
if cp.is_finite() && cp > 0.0 {
Ok(cp)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} cold-side Cp is invalid: {}",
self.name, cp
)))
}
})
}
/// Hot-side temperature [K] at (P, h). Moist air uses the linear psychrometric
/// inversion; other fluids query the backend T(P,h).
fn hot_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.hot_is_air() {
let w = self.hot_humidity_ratio;
let cp = 1006.0 + 1860.0 * w;
return Ok((h_jkg - 2_501_000.0 * w) / cp + 273.15);
}
self.query_live_property(
"hot",
&self.hot_fluid_id_str,
Property::Temperature,
p_pa,
h_jkg,
)
.and_then(|t| {
if t.is_finite() && t > 0.0 {
Ok(t)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} hot-side temperature is invalid: {}",
self.name, t
)))
}
})
}
/// Cold-side temperature [K] at (P, h).
fn cold_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.cold_is_air() {
let w = self.cold_humidity_ratio;
let cp = 1006.0 + 1860.0 * w;
return Ok((h_jkg - 2_501_000.0 * w) / cp + 273.15);
}
self.query_live_property(
"cold",
&self.cold_fluid_id_str,
Property::Temperature,
p_pa,
h_jkg,
)
.and_then(|t| {
if t.is_finite() && t > 0.0 {
Ok(t)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} cold-side temperature is invalid: {}",
self.name, t
)))
}
})
}
fn query_live_property(
&self,
side: &str,
fluid_id: &str,
property: Property,
p_pa: f64,
h_jkg: f64,
) -> Result<f64, ComponentError> {
if !p_pa.is_finite() || p_pa <= 0.0 {
return Err(ComponentError::InvalidState(format!(
"{} {} side has invalid pressure: {} Pa",
self.name, side, p_pa
)));
}
if !h_jkg.is_finite() {
return Err(ComponentError::InvalidState(format!(
"{} {} side has invalid enthalpy: {} J/kg",
self.name, side, h_jkg
)));
}
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::InvalidState(format!(
"{} {} side fluid '{}' requires a FluidBackend; no simulation fallback is allowed",
self.name, side, fluid_id
))
})?;
backend
.property(
FluidsFluidId::new(fluid_id),
property,
entropyk_fluids::FluidState::PressureEnthalpy(
Pressure::from_pascals(p_pa),
entropyk_core::Enthalpy::from_joules_per_kg(h_jkg),
),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"{} failed to evaluate {:?} for {} side fluid '{}': {}",
self.name, property, side, fluid_id, e
))
})
}
/// Creates a fluid state from temperature, pressure, enthalpy, mass flow, and Cp.
fn create_fluid_state(
temperature: f64,
@@ -509,63 +786,10 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
}
}
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) =
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
&self.hot_conditions,
&self.cold_conditions,
&self.fluid_backend,
) {
// Hot side from backend
let hot_cp = self.query_cp(hot_cond)?;
let hot_h_in = self.query_enthalpy(hot_cond)?;
let hot_inlet = Self::create_fluid_state(
hot_cond.temperature_k(),
hot_cond.pressure_pa(),
hot_h_in,
hot_cond.mass_flow_kg_s(),
hot_cp,
);
let hot_dh = hot_cp * 5.0; // J/kg per degree
let hot_outlet = Self::create_fluid_state(
hot_cond.temperature_k() - 5.0,
hot_cond.pressure_pa() * 0.998,
hot_h_in - hot_dh,
hot_cond.mass_flow_kg_s(),
hot_cp,
);
// Cold side from backend
let cold_cp = self.query_cp(cold_cond)?;
let cold_h_in = self.query_enthalpy(cold_cond)?;
let cold_inlet = Self::create_fluid_state(
cold_cond.temperature_k(),
cold_cond.pressure_pa(),
cold_h_in,
cold_cond.mass_flow_kg_s(),
cold_cp,
);
let cold_dh = cold_cp * 5.0;
let cold_outlet = Self::create_fluid_state(
cold_cond.temperature_k() + 5.0,
cold_cond.pressure_pa() * 0.998,
cold_h_in + cold_dh,
cold_cond.mass_flow_kg_s(),
cold_cp,
);
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
} else {
let hot_inlet = Self::create_fluid_state(350.0, 500_000.0, 400_000.0, 0.1, 1000.0);
let hot_outlet = Self::create_fluid_state(330.0, 490_000.0, 380_000.0, 0.1, 1000.0);
let cold_inlet = Self::create_fluid_state(290.0, 101_325.0, 80_000.0, 0.2, 4180.0);
let cold_outlet =
Self::create_fluid_state(300.0, 101_325.0, 120_000.0, 0.2, 4180.0);
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
};
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) = self.live_fluid_states(_state)?;
let dynamic_f_ua =
custom_ua_scale.or_else(|| self.calib_indices.f_ua.map(|idx| _state[idx]));
custom_ua_scale.or_else(|| self.calib_indices.z_ua.map(|idx| _state[idx]));
self.model.compute_residuals(
&hot_inlet,
@@ -594,68 +818,49 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// ∂r/∂f_ua = -∂Q/∂f_ua (Story 5.5)
if let Some(f_ua_idx) = self.calib_indices.f_ua {
// Need to compute Q_nominal (with UA_scale = 1.0)
// This requires repeating the residual calculation logic with dynamic_ua_scale = None
// For now, we'll use a finite difference approximation or a simplified nominal calculation.
// 4-port mode: numerical Jacobian via finite differences. Perturb each
// relevant state variable, recompute residuals, take the difference.
if self.edges_ready() {
let (m_h, p_h_in, h_h_in) = self.hot_in_idx.unwrap();
let (_, p_h_out, h_h_out) = self.hot_out_idx.unwrap();
let (m_c, p_c_in, h_c_in) = self.cold_in_idx.unwrap();
let (_, p_c_out, h_c_out) = self.cold_out_idx.unwrap();
// Re-use logic from compute_residuals but only for Q
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
&self.hot_conditions,
&self.cold_conditions,
&self.fluid_backend,
) {
let hot_cp = self.query_cp(hot_cond)?;
let hot_h_in = self.query_enthalpy(hot_cond)?;
let hot_inlet = Self::create_fluid_state(
hot_cond.temperature_k(),
hot_cond.pressure_pa(),
hot_h_in,
hot_cond.mass_flow_kg_s(),
hot_cp,
);
let cols = [
m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, p_c_out, h_c_out,
];
let unique_cols: Vec<usize> = {
let mut s: Vec<usize> =
cols.iter().copied().filter(|c| *c < _state.len()).collect();
s.sort_unstable();
s.dedup();
s
};
let hot_dh = hot_cp * 5.0;
let hot_outlet = Self::create_fluid_state(
hot_cond.temperature_k() - 5.0,
hot_cond.pressure_pa() * 0.998,
hot_h_in - hot_dh,
hot_cond.mass_flow_kg_s(),
hot_cp,
);
let compute_res = |s: &[f64]| -> [f64; 2] {
let mut r = vec![0.0_f64; 2];
let _ = self.do_compute_residuals(s, &mut r, None);
[r[0], r[1]]
};
let cold_cp = self.query_cp(cold_cond)?;
let cold_h_in = self.query_enthalpy(cold_cond)?;
let cold_inlet = Self::create_fluid_state(
cold_cond.temperature_k(),
cold_cond.pressure_pa(),
cold_h_in,
cold_cond.mass_flow_kg_s(),
cold_cp,
);
let cold_dh = cold_cp * 5.0;
let cold_outlet = Self::create_fluid_state(
cold_cond.temperature_k() + 5.0,
cold_cond.pressure_pa() * 0.998,
cold_h_in + cold_dh,
cold_cond.mass_flow_kg_s(),
cold_cp,
);
let q_nominal = self
.model
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None)
.to_watts();
// r0 = Q_hot - Q -> ∂r0/∂f_ua = -Q_nominal
// r1 = Q_cold - Q -> ∂r1/∂f_ua = -Q_nominal
// r2 = Q_hot - Q_cold -> ∂r2/∂f_ua = 0
_jacobian.add_entry(0, f_ua_idx, -q_nominal);
_jacobian.add_entry(1, f_ua_idx, -q_nominal);
_jacobian.add_entry(2, f_ua_idx, 0.0);
for &col in &unique_cols {
let h = (_state[col].abs() * 1e-6).max(1e-3);
let mut sp = _state.to_vec();
sp[col] += h;
let rp = compute_res(&sp);
let mut sm = _state.to_vec();
sm[col] -= h;
let rm = compute_res(&sm);
for row in 0..2 {
let fd = (rp[row] - rm[row]) / (2.0 * h);
if fd.abs() > 1e-15 {
_jacobian.add_entry(row, col, fd);
}
}
}
return Ok(());
}
Ok(())
}
@@ -668,63 +873,93 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
}
fn get_ports(&self) -> &[ConnectedPort] {
// TODO: Return actual ports when port storage is implemented.
// Port storage pending integration with Port<Connected> system from Story 1.3.
&[]
}
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
if let Some(Some(triple)) = port_edges.first() {
self.hot_in_idx = Some(*triple);
}
if let Some(Some(triple)) = port_edges.get(1) {
self.hot_out_idx = Some(*triple);
}
if let Some(Some(triple)) = port_edges.get(2) {
self.cold_in_idx = Some(*triple);
}
if let Some(Some(triple)) = port_edges.get(3) {
self.cold_out_idx = Some(*triple);
}
}
fn port_names(&self) -> Vec<String> {
vec![
"hot_inlet".to_string(),
"hot_outlet".to_string(),
"cold_inlet".to_string(),
"cold_outlet".to_string(),
]
}
fn flow_paths(&self) -> Vec<(usize, usize)> {
vec![(0, 1), (2, 3)]
}
fn port_mass_flows(
&self,
_state: &StateSlice,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
// HeatExchanger has two sides: hot and cold, each with inlet and outlet.
// Mass balance: hot_in = hot_out, cold_in = cold_out (no mixing between sides)
//
// For now, we use the configured conditions if available.
// When port storage is implemented, this will use actual port state.
let mut flows = Vec::with_capacity(4);
if let Some(hot_cond) = &self.hot_conditions {
let m_hot = hot_cond.mass_flow_kg_s();
// Hot inlet (positive = entering), Hot outlet (negative = leaving)
flows.push(entropyk_core::MassFlow::from_kg_per_s(m_hot));
flows.push(entropyk_core::MassFlow::from_kg_per_s(-m_hot));
if !self.edges_ready() {
return Err(self.live_state_required_error());
}
if let Some(cold_cond) = &self.cold_conditions {
let m_cold = cold_cond.mass_flow_kg_s();
// Cold inlet (positive = entering), Cold outlet (negative = leaving)
flows.push(entropyk_core::MassFlow::from_kg_per_s(m_cold));
flows.push(entropyk_core::MassFlow::from_kg_per_s(-m_cold));
let (m_h_in, _, _) = self.hot_in_idx.unwrap();
let (m_h_out, _, _) = self.hot_out_idx.unwrap();
let (m_c_in, _, _) = self.cold_in_idx.unwrap();
let (m_c_out, _, _) = self.cold_out_idx.unwrap();
let max_idx = [m_h_in, m_h_out, m_c_in, m_c_out]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
Ok(flows)
Ok(vec![
entropyk_core::MassFlow::from_kg_per_s(state[m_h_in]),
entropyk_core::MassFlow::from_kg_per_s(-state[m_h_out]),
entropyk_core::MassFlow::from_kg_per_s(state[m_c_in]),
entropyk_core::MassFlow::from_kg_per_s(-state[m_c_out]),
])
}
fn port_enthalpies(
&self,
_state: &StateSlice,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
let mut enthalpies = Vec::with_capacity(4);
// This matches the order in port_mass_flows
if let Some(hot_cond) = &self.hot_conditions {
let h_in = self.query_enthalpy(hot_cond).unwrap_or(400_000.0);
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in));
// HACK: As mentioned in compute_residuals, proper port mappings are pending.
// We use a dummy 5 K delta for the outlet until full Port system integration.
let cp = self.query_cp(hot_cond).unwrap_or(1000.0);
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in - cp * 5.0));
if !self.edges_ready() {
return Err(self.live_state_required_error());
}
if let Some(cold_cond) = &self.cold_conditions {
let h_in = self.query_enthalpy(cold_cond).unwrap_or(80_000.0);
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in));
let cp = self.query_cp(cold_cond).unwrap_or(4180.0);
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in + cp * 5.0));
let (_, _, h_h_in) = self.hot_in_idx.unwrap();
let (_, _, h_h_out) = self.hot_out_idx.unwrap();
let (_, _, h_c_in) = self.cold_in_idx.unwrap();
let (_, _, h_c_out) = self.cold_out_idx.unwrap();
let max_idx = [h_h_in, h_h_out, h_c_in, h_c_out]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
Ok(enthalpies)
Ok(vec![
entropyk_core::Enthalpy::from_joules_per_kg(state[h_h_in]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_h_out]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_c_in]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_c_out]),
])
}
fn energy_transfers(
@@ -742,7 +977,43 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
}
}
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
match kind {
crate::MeasuredOutput::Capacity | crate::MeasuredOutput::HeatTransferRate => {
if !self.edges_ready() {
return None;
}
let (m_h, _, h_h_in) = self.hot_in_idx?;
let (_, _, h_h_out) = self.hot_out_idx?;
let (m_c, _, h_c_in) = self.cold_in_idx?;
let (_, _, h_c_out) = self.cold_out_idx?;
let max_idx = [m_h, h_h_in, h_h_out, m_c, h_c_in, h_c_out]
.into_iter()
.max()?;
if max_idx >= state.len() {
return None;
}
let q_hot_w = state[m_h].abs() * (state[h_h_in] - state[h_h_out]).abs();
let q_cold_w = state[m_c].abs() * (state[h_c_out] - state[h_c_in]).abs();
if q_hot_w.is_finite() && q_cold_w.is_finite() {
Some(0.5 * (q_hot_w + q_cold_w))
} else if q_hot_w.is_finite() {
Some(q_hot_w)
} else if q_cold_w.is_finite() {
Some(q_cold_w)
} else {
None
}
}
_ => None,
}
}
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(backend);
}
@@ -755,7 +1026,10 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
fn to_params(&self) -> crate::ComponentParams {
crate::ComponentParams::new(&self.name)
.with_param("circuitId", self.circuit_id.0)
.with_param("calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null))
.with_param(
"calib",
serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null),
)
}
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
@@ -808,6 +1082,10 @@ mod tests {
use crate::heat_exchanger::{FlowConfiguration, LmtdModel};
use crate::state_machine::StateManageable;
fn live_air_state(t_k: f64) -> f64 {
1006.0 * (t_k - 273.15)
}
#[test]
fn test_heat_exchanger_creation() {
let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
@@ -835,7 +1113,65 @@ mod tests {
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
fn test_live_four_port_residuals_compute_from_state() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test")
.with_hot_fluid("Air")
.with_cold_fluid("Air");
hx.set_port_context(&[
Some((0, 1, 2)),
Some((0, 3, 4)),
Some((5, 6, 7)),
Some((5, 8, 9)),
]);
let state = vec![
0.5,
101_325.0,
live_air_state(350.0),
101_325.0,
live_air_state(330.0),
0.8,
101_325.0,
live_air_state(290.0),
101_325.0,
live_air_state(300.0),
];
let mut residuals = vec![0.0; hx.n_equations()];
hx.compute_residuals(&state, &mut residuals).unwrap();
assert!(residuals.iter().all(|r| r.is_finite()));
assert!(residuals.iter().any(|r| r.abs() > 1e-9));
assert_eq!(
hx.port_enthalpies(&state)
.unwrap()
.iter()
.map(|h| h.to_joules_per_kg())
.collect::<Vec<_>>(),
vec![
live_air_state(350.0),
live_air_state(330.0),
live_air_state(290.0),
live_air_state(300.0)
]
);
}
#[test]
fn test_four_port_metadata_is_name_based() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert!(hx.get_ports().is_empty());
assert_eq!(
hx.port_names(),
vec!["hot_inlet", "hot_outlet", "cold_inlet", "cold_outlet"]
);
assert_eq!(hx.flow_paths(), vec![(0, 1), (2, 3)]);
}
#[test]
@@ -999,8 +1335,7 @@ mod tests {
}
#[test]
fn test_compute_residuals_with_backend_succeeds() {
/// Using TestBackend: Water on cold side, R410A on hot side.
fn test_boundary_conditions_without_outlet_state_error() {
use entropyk_core::{MassFlow, Pressure, Temperature};
use entropyk_fluids::TestBackend;
use std::sync::Arc;
@@ -1031,30 +1366,27 @@ mod tests {
let mut residuals = vec![0.0f64; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(
result.is_ok(),
"compute_residuals with FluidBackend should succeed"
matches!(result, Err(ComponentError::InvalidState(_))),
"inlet-only boundary conditions must not fabricate outlet states"
);
}
#[test]
fn test_residuals_with_backend_vs_without_differ() {
/// Residuals computed with a real backend should differ from placeholder residuals
/// because real Cp and enthalpy values are used.
fn test_unwired_hx_never_returns_dummy_finite_residuals() {
use entropyk_core::{MassFlow, Pressure, Temperature};
use entropyk_fluids::TestBackend;
use std::sync::Arc;
// Without backend (placeholder values)
let model1 = LmtdModel::counter_flow(5000.0);
let hx_no_backend = HeatExchanger::new(model1, "HX_nobackend");
let state = vec![0.0f64; 10];
let mut residuals_no_backend = vec![0.0f64; 3];
hx_no_backend
.compute_residuals(&state, &mut residuals_no_backend)
.unwrap();
assert!(matches!(
hx_no_backend.compute_residuals(&state, &mut residuals_no_backend),
Err(ComponentError::InvalidState(_))
));
// With backend (real Water + R410A properties)
let model2 = LmtdModel::counter_flow(5000.0);
let hx_with_backend = HeatExchanger::new(model2, "HX_with_backend")
.with_fluid_backend(Arc::new(TestBackend::new()))
@@ -1078,22 +1410,10 @@ mod tests {
);
let mut residuals_with_backend = vec![0.0f64; 3];
hx_with_backend
.compute_residuals(&state, &mut residuals_with_backend)
.unwrap();
// The energy balance residual (index 2) should differ because real Cp differs
// from the 1000.0/4180.0 hardcoded fallback values.
// (TestBackend returns Cp=1500 for refrigerants and 4184 for water,
// but temperatures and flows differ, so the residual WILL differ)
let residuals_are_different = residuals_no_backend
.iter()
.zip(residuals_with_backend.iter())
.any(|(a, b)| (a - b).abs() > 1e-6);
assert!(
residuals_are_different,
"Residuals with FluidBackend should differ from placeholder residuals"
);
assert!(matches!(
hx_with_backend.compute_residuals(&state, &mut residuals_with_backend),
Err(ComponentError::InvalidState(_))
));
}
#[test]

View File

@@ -0,0 +1,165 @@
//! Fan-coil unit (FCU): waterair ε-NTU with bypass factor (BPF) wet-coil model.
//!
//! Sensible / latent split via apparatus dew point (ADP) and BPF:
//! `T_leave = BPF · T_enter + (1 BPF) · T_ADP`.
use super::eps_ntu::{EpsNtuModel, ExchangerType};
/// Psychrometric / coil inputs for FCU rating.
#[derive(Debug, Clone, Copy)]
pub struct FanCoilRatingInput {
/// Entering air dry-bulb [K].
pub t_air_in_k: f64,
/// Entering air wet-bulb or humidity ratio proxy via dewpoint [K].
pub t_dew_in_k: f64,
/// Entering water temperature [K].
pub t_water_in_k: f64,
/// Air capacity rate ṁ·cp [W/K].
pub c_air: f64,
/// Water capacity rate ṁ·cp [W/K].
pub c_water: f64,
/// Bypass factor (0.050.40 typical).
pub bypass_factor: f64,
/// Apparatus dew-point temperature [K] (coil surface).
pub t_adp_k: f64,
}
/// Result of an FCU rating.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FanCoilRating {
/// Total cooling capacity [W] (sensible + latent proxy).
pub q_total_w: f64,
/// Sensible capacity [W].
pub q_sensible_w: f64,
/// Latent capacity [W] (q_total q_sensible, ≥ 0).
pub q_latent_w: f64,
/// Leaving air dry-bulb [K].
pub t_air_out_k: f64,
/// Leaving water temperature [K].
pub t_water_out_k: f64,
/// Sensible heat ratio SHR = q_sensible / q_total.
pub shr: f64,
/// Effectiveness of the dry ε-NTU backbone [-].
pub effectiveness: f64,
}
/// Fan-coil unit with fixed UA and BPF wet-coil model.
#[derive(Debug, Clone)]
pub struct FanCoilUnit {
ua: f64,
/// Default bypass factor when not overridden in the rating call.
default_bpf: f64,
}
impl FanCoilUnit {
/// Creates an FCU with overall UA [W/K].
pub fn new(ua: f64) -> Self {
Self {
ua: ua.max(0.0),
default_bpf: 0.15,
}
}
/// Sets default bypass factor.
pub fn with_bypass_factor(mut self, bpf: f64) -> Self {
self.default_bpf = bpf.clamp(0.0, 0.5);
self
}
/// Rates the coil. Uses `input.bypass_factor` if > 0, else default BPF.
pub fn rate(&self, input: &FanCoilRatingInput) -> FanCoilRating {
let bpf = if input.bypass_factor > 0.0 {
input.bypass_factor.clamp(0.0, 0.5)
} else {
self.default_bpf
};
let c_min = input.c_air.min(input.c_water).max(1.0);
let c_max = input.c_air.max(input.c_water).max(c_min);
let cr = c_min / c_max;
let model = EpsNtuModel::new(self.ua, ExchangerType::CrossFlowUnmixed);
let ntu = self.ua / c_min;
let eps = model.effectiveness(ntu, cr);
// Dry ε-NTU sensible backbone on waterair.
let q_max = c_min * (input.t_air_in_k - input.t_water_in_k).max(0.0);
let q_dry = eps * q_max;
// Wet-coil leaving air via BPF / ADP.
let t_air_out = bpf * input.t_air_in_k + (1.0 - bpf) * input.t_adp_k;
let q_sensible = input.c_air * (input.t_air_in_k - t_air_out).max(0.0);
// Latent proxy: dewpoint depression when coil below dewpoint.
let wet = input.t_adp_k < input.t_dew_in_k;
let q_latent = if wet {
// Contact factor × latent drive (simplified, ~2450 kJ/kg · Δω proxy via ΔT).
let cf = 1.0 - bpf;
cf * input.c_air * 0.5 * (input.t_dew_in_k - input.t_adp_k).max(0.0)
} else {
0.0
};
let (q_total, q_sensible_out, q_latent_out) = if wet {
(q_sensible + q_latent, q_sensible, q_latent)
} else {
let q = q_dry.max(q_sensible);
(q, q, 0.0)
};
let t_water_out = input.t_water_in_k + q_total / input.c_water.max(1.0);
let shr = if wet && q_total > 1.0 {
(q_sensible_out / q_total).clamp(0.0, 1.0)
} else {
1.0
};
FanCoilRating {
q_total_w: q_total,
q_sensible_w: q_sensible_out,
q_latent_w: q_latent_out,
t_air_out_k: t_air_out,
t_water_out_k: t_water_out,
shr,
effectiveness: eps,
}
}
/// UA [W/K].
pub fn ua(&self) -> f64 {
self.ua
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cool_input() -> FanCoilRatingInput {
FanCoilRatingInput {
t_air_in_k: 300.15, // 27°C
t_dew_in_k: 289.15, // 16°C dew
t_water_in_k: 280.15, // 7°C
c_air: 1200.0,
c_water: 2500.0,
bypass_factor: 0.15,
t_adp_k: 283.15, // 10°C
}
}
#[test]
fn wet_coil_has_latent() {
let fcu = FanCoilUnit::new(8000.0);
let r = fcu.rate(&cool_input());
assert!(r.q_latent_w > 0.0);
assert!(r.shr < 1.0);
assert!(r.t_air_out_k < cool_input().t_air_in_k);
}
#[test]
fn dry_coil_zero_latent() {
let fcu = FanCoilUnit::new(8000.0);
let mut inp = cool_input();
inp.t_adp_k = 295.0; // above dewpoint
let r = fcu.rate(&inp);
assert_eq!(r.q_latent_w, 0.0);
assert!((r.shr - 1.0).abs() < 1e-9);
}
}

View File

@@ -0,0 +1,909 @@
//! Fin-and-Tube Condenser Coil — RTPF (Round Tube Plate Fin)
//!
//! Modèle physique complet d'un condenseur à air à ailettes et tubes ronds,
//! tel qu'utilisé dans les chillers air-cooled industriels (Carrier, Trane, York, Daikin).
//!
//! ## Paramètres géométriques ingénieur
//!
//! ```text
//! ┌──────────────────────────────────────────────────┐
//! │ COIL FACE (vue de face) │
//! │ │
//! │ ←── face_width_m ──→ │
//! │ ┌─────────────────────┐ ↑ │
//! │ │ ○ ○ ○ ○ ○ ○ │ │ face_height_m │
//! │ │ ○ ○ ○ ○ ○ ○ │ │ │
//! │ │ ○ ○ ○ ○ ○ ○ │ ↓ │
//! │ └─────────────────────┘ │
//! │ ↑ Pt (tube pitch transversal) │
//! └──────────────────────────────────────────────────┘
//!
//! Profondeur (coupe latérale) :
//! ← n_rows × Pl →
//! ○ ○ ○ (n_rows = 3, arrangement staggered)
//! ○ ○
//! ○ ○ ○
//! ```
//!
//! ## Physique — UA depuis la géométrie
//!
//! ### Côté air (dominant)
//!
//! Corrélation de Chang & Wang (1997) pour ailettes à persiennes (louvered) :
//! ```text
//! j = C × Re_Lp^n (facteur de Colburn pour transfert de chaleur)
//! h_air = j × G_air × Cp_air / Pr^(2/3)
//! ```
//! Pour autres types d'ailettes : facteurs multiplicatifs empiriques.
//!
//! Efficacité des ailettes (profil rectangulaire) :
//! ```text
//! m = sqrt(2 × h_air / (k_fin × t_fin))
//! L = (Pt/2 - d_tube/2) × correction_stagger
//! η_fin = tanh(m × L) / (m × L)
//! η_surface = 1 - (1 - η_fin) × A_fin / A_total
//! UA_air = η_surface × h_air × A_total
//! ```
//!
//! ### Côté réfrigérant (condensation, résistance faible ~5-10%)
//!
//! ```text
//! UA_total = 1 / (1/UA_air + 1/UA_ref) ≈ UA_air × 0.92 (résistance réfrigérant ≈ 8%)
//! ```
//!
//! ### Température de condensation — Méthode ε-NTU (isotherme côté réfrigérant)
//!
//! Pour condensation isotherme (T_cond = const), la méthode ε-NTU donne :
//! ```text
//! ṁ_air = ρ_air × A_face × v_face
//! C_air = ṁ_air × Cp_air
//! NTU = UA_total / C_air
//! ε = 1 - exp(-NTU) (efficacité pour fluide isotherme)
//! ΔT_air = Q_design / C_air (montée en température côté air)
//! T_cond = OAT + ΔT_air / ε (EXACT pour condensation isotherme)
//! ```
//!
//! ## Références
//!
//! - Chang & Wang (1997) : Int. J. Heat Mass Transfer, 40(3):533544
//! - ASHRAE Handbook of Fundamentals (2021), Chap. 4 — Two-Phase Flow
//! - Webb & Kim (2005) : Principles of Enhanced Heat Transfer
use super::condenser::Condenser;
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::Calib;
use entropyk_fluids::FluidBackend;
use std::sync::Arc;
// ─────────────────────────────────────────────────────────────────────────────
// Constantes physiques air (conditions standard)
// ─────────────────────────────────────────────────────────────────────────────
/// Chaleur spécifique de l'air sec [J/(kg·K)]
const CP_AIR: f64 = 1006.0;
/// Nombre de Prandtl de l'air (~35°C)
const PR_AIR: f64 = 0.707;
/// Viscosité dynamique de l'air (~35°C) [Pa·s]
const MU_AIR: f64 = 1.87e-5;
/// Conductivité thermique de l'air (~35°C) [W/(m·K)]
#[allow(dead_code)] // Reference air property, kept for completeness/future correlations.
const K_AIR: f64 = 0.0270;
/// Conductivité des ailettes aluminium [W/(m·K)]
const K_FIN_ALU: f64 = 205.0;
/// Densité air standard (35°C, 101.325 kPa) [kg/m³]
#[allow(dead_code)] // Reference air property, kept for completeness/future correlations.
const RHO_AIR_STD: f64 = 1.145;
/// Fraction de résistance côté réfrigérant (condensation) — correction UA_total
const REF_RESISTANCE_FACTOR: f64 = 0.92;
// ─────────────────────────────────────────────────────────────────────────────
// Types publics
// ─────────────────────────────────────────────────────────────────────────────
/// Type d'ailette — détermine la corrélation de transfert de chaleur côté air.
///
/// | Type | Corrélation | Performance relative | Application typique |
/// |------------|-----------------|----------------------|-----------------------------|
/// | Louvered | Chang & Wang | 100% (référence) | Chillers air-cooled modernes|
/// | Wavy | Corrélation VDI | 78% | Anciennes unités, plus robuste|
/// | Slit | Interpolation | 93% | Compromis performance/coût |
/// | Plain | Gnielinski-fin | 58% | Ambiances corrosives/poussiéreuses|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FinType {
/// Ailettes à persiennes (louvered) — technologie dominante pour chillers modernes.
/// Corrélation Chang & Wang (1997) : j = 0.49 × (Lp/Fp)^0.27 × Re_Lp^(-0.49)
Louvered,
/// Ailettes ondulées (wavy/corrugated).
Wavy,
/// Ailettes à fentes (slit fins).
Slit,
/// Ailettes plates (plain fins) — application ambiances poussiéreuses.
Plain,
}
impl FinType {
/// Facteur multiplicatif sur le coefficient j de Colburn par rapport aux ailettes louvered.
/// Valeurs calibrées sur données fabricants (Carrier, Trane, York).
pub fn j_factor_relative(&self) -> f64 {
match self {
FinType::Louvered => 1.00,
FinType::Slit => 0.93,
FinType::Wavy => 0.78,
FinType::Plain => 0.58,
}
}
/// Nom textuel pour la sérialisation JSON.
pub fn as_str(&self) -> &'static str {
match self {
FinType::Louvered => "louvered",
FinType::Wavy => "wavy",
FinType::Slit => "slit",
FinType::Plain => "plain",
}
}
/// Désérialisation depuis chaîne JSON.
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"louvered" | "louver" | "lv" => FinType::Louvered,
"wavy" | "wave" | "corrugated" => FinType::Wavy,
"slit" => FinType::Slit,
"plain" | "flat" => FinType::Plain,
_ => FinType::Louvered, // Default sécurisé
}
}
}
/// Géométrie d'un condenseur RTPF.
///
/// Tous les paramètres par défaut correspondent à un condenseur de chiller
/// air-cooled industriel typique (Carrier 30XA / Trane RTAC).
#[derive(Debug, Clone)]
pub struct CoilGeometry {
/// Largeur de face de bobine [m] (dimension horizontale du coil)
pub face_width_m: f64,
/// Hauteur de face de bobine [m] (dimension verticale)
pub face_height_m: f64,
/// Nombre de rangs de tubes (profondeur du coil)
pub n_rows: u32,
/// Type d'ailettes
pub fin_type: FinType,
/// Pas des ailettes [ailettes/pouce], typique : 1018 FPI
pub fin_pitch_fpi: f64,
/// Épaisseur des ailettes aluminium [m] (défaut 0.1 mm)
pub fin_thickness_m: f64,
/// Diamètre extérieur des tubes [m] (défaut 9.52 mm = 3/8")
pub tube_od_m: f64,
/// Pas transversal des tubes (Pt) [m] (défaut 25.4 mm = 1")
pub tube_pitch_transverse_m: f64,
/// Pas longitudinal des tubes (Pl) [m] (défaut 22.0 mm, arrangement staggered)
pub tube_pitch_longitudinal_m: f64,
/// Louver pitch [m] — pour ailettes louvered (défaut 1.4 mm)
pub louver_pitch_m: f64,
/// Longueur des persiennes [m] — pour ailettes louvered (défaut 8.0 mm)
pub louver_length_m: f64,
}
impl Default for CoilGeometry {
fn default() -> Self {
Self {
face_width_m: 1.0,
face_height_m: 1.0,
n_rows: 3,
fin_type: FinType::Louvered,
fin_pitch_fpi: 14.0,
fin_thickness_m: 0.0001, // 0.1 mm
tube_od_m: 0.009525, // 9.525 mm = 3/8"
tube_pitch_transverse_m: 0.0254, // 25.4 mm = 1"
tube_pitch_longitudinal_m: 0.022, // 22.0 mm staggered
louver_pitch_m: 0.0014, // 1.4 mm
louver_length_m: 0.008, // 8.0 mm
}
}
}
impl CoilGeometry {
/// Construit une géométrie depuis la surface de face et le nombre de rangs.
///
/// # Arguments
///
/// * `face_width_m` — Largeur de face [m]
/// * `face_height_m` — Hauteur de face [m]
/// * `n_rows` — Nombre de rangs de tubes
pub fn new(face_width_m: f64, face_height_m: f64, n_rows: u32) -> Self {
Self {
face_width_m,
face_height_m,
n_rows,
..Default::default()
}
}
/// Surface de face du coil [m²].
pub fn face_area_m2(&self) -> f64 {
self.face_width_m * self.face_height_m
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Composant principal
// ─────────────────────────────────────────────────────────────────────────────
/// Condenseur à air RTPF (Round Tube Plate Fin) — modèle physique complet.
///
/// L'ingénieur définit :
/// 1. La géométrie du coil (rangs, type d'ailettes, dimensions)
/// 2. Les conditions d'air (OAT, vitesse de face)
/// 3. La capacité nominale (pour le calcul de T_cond via ε-NTU)
///
/// Le composant calcule automatiquement :
/// - UA total à partir de la géométrie (corrélation Chang & Wang)
/// - Température de condensation T_cond via la méthode ε-NTU
///
/// # Exemple JSON
///
/// ```json
/// {
/// "type": "FinCoilCondenser",
/// "name": "cond_bat",
/// "oat_k": 308.15,
/// "face_width_m": 2.0,
/// "face_height_m": 1.5,
/// "n_rows": 3,
/// "fin_type": "louvered",
/// "fin_pitch_fpi": 14,
/// "air_face_velocity_m_s": 2.5,
/// "design_capacity_kw": 125.0
/// }
/// ```
///
/// # Exemple Rust
///
/// ```rust
/// use entropyk_components::heat_exchanger::{FinCoilCondenser, CoilGeometry, FinType};
///
/// let geometry = CoilGeometry {
/// face_width_m: 2.0,
/// face_height_m: 1.5,
/// n_rows: 3,
/// fin_type: FinType::Louvered,
/// fin_pitch_fpi: 14.0,
/// ..Default::default()
/// };
///
/// let cond = FinCoilCondenser::new(
/// 308.15, // OAT = 35°C [K]
/// geometry,
/// 2.5, // vitesse air [m/s]
/// 125_000.0, // Q_design [W]
/// );
///
/// println!("UA = {:.0} W/K", cond.ua_total());
/// println!("T_cond = {:.1} °C", cond.t_cond_k() - 273.15);
/// println!("Approach = {:.1} K", cond.approach_k());
/// ```
pub struct FinCoilCondenser {
inner: Condenser,
// ── Inputs ingénieur ──────────────────────────────────────────────────
oat_k: f64,
geometry: CoilGeometry,
air_face_velocity_m_s: f64,
air_density_kg_m3: f64,
design_capacity_w: f64,
/// When true, apply WangLinLee (2000) wet-surface j-factor correction.
wet_surface: bool,
// ── Résultats calculés ────────────────────────────────────────────────
ua_air_side: f64, // UA côté air [W/K]
ua_total: f64, // UA total (air + réfrigérant) [W/K]
t_cond_k: f64, // Température de condensation calculée [K]
approach_k: f64, // Approche = T_cond OAT [K]
ntu: f64, // Nombre d'Unités de Transfert []
effectiveness: f64, // Efficacité ε = 1 exp(NTU)
}
impl std::fmt::Debug for FinCoilCondenser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FinCoilCondenser")
.field("oat_k", &self.oat_k)
.field("t_cond_k", &self.t_cond_k)
.field("approach_k", &self.approach_k)
.field("ua_total", &self.ua_total)
.field("ntu", &self.ntu)
.field("n_rows", &self.geometry.n_rows)
.field("fin_type", &self.geometry.fin_type)
.finish()
}
}
impl FinCoilCondenser {
// ─────────────────────────────────────────────────────────────────────
// Constructeurs
// ─────────────────────────────────────────────────────────────────────
/// Construit un condenseur RTPF complet.
///
/// # Arguments
///
/// * `oat_k` — Outdoor Air Temperature \[K\]
/// * `geometry` — Géométrie du coil (rangs, ailettes, tubes)
/// * `air_face_velocity` — Vitesse d'air en face de coil \[m/s\] (typique : 2.03.0)
/// * `design_capacity_w` — Capacité de rejet thermique au point nominal \[W\]
pub fn new(
oat_k: f64,
geometry: CoilGeometry,
air_face_velocity_m_s: f64,
design_capacity_w: f64,
) -> Self {
// Densité air à OAT (loi des gaz parfaits, P = 101325 Pa, R = 287 J/kg·K)
let rho_air = 101_325.0 / (287.0 * oat_k);
let mut s = Self {
inner: Condenser::new(0.0), // UA sera mis à jour
oat_k,
geometry,
air_face_velocity_m_s,
air_density_kg_m3: rho_air,
design_capacity_w,
wet_surface: false,
ua_air_side: 0.0,
ua_total: 0.0,
t_cond_k: oat_k + 15.0, // valeur initiale
approach_k: 15.0,
ntu: 0.0,
effectiveness: 0.0,
};
s.recompute();
s
}
/// Enables WangLinLee wet-surface air-side correlation (cooling coils).
pub fn with_wet_surface(mut self, wet: bool) -> Self {
self.wet_surface = wet;
self.recompute();
self
}
/// Returns whether wet-surface air-side correlations are active.
pub fn wet_surface(&self) -> bool {
self.wet_surface
}
/// Attache un identifiant de fluide réfrigérant.
pub fn with_refrigerant(mut self, id: &str) -> Self {
self.inner = self.inner.with_refrigerant(id);
self
}
/// Attache un backend de propriétés thermodynamiques.
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
self.inner = self.inner.with_fluid_backend(backend);
self
}
/// Surcharge la densité d'air (par ex. altitude élevée).
pub fn with_air_density(mut self, rho: f64) -> Self {
self.air_density_kg_m3 = rho;
self.recompute();
self
}
// ─────────────────────────────────────────────────────────────────────
// Setters runtime (simulation paramétrique)
// ─────────────────────────────────────────────────────────────────────
/// Met à jour OAT et recalcule T_cond.
pub fn set_oat_k(&mut self, oat_k: f64) {
self.oat_k = oat_k;
self.air_density_kg_m3 = 101_325.0 / (287.0 * oat_k);
self.recompute();
}
/// Met à jour la vitesse d'air et recalcule UA + T_cond.
pub fn set_air_face_velocity(&mut self, v: f64) {
self.air_face_velocity_m_s = v;
self.recompute();
}
/// Met à jour la capacité nominale et recalcule T_cond.
pub fn set_design_capacity_w(&mut self, q_w: f64) {
self.design_capacity_w = q_w;
self.recompute();
}
// ─────────────────────────────────────────────────────────────────────
// Getters
// ─────────────────────────────────────────────────────────────────────
/// Température de condensation calculée [K].
pub fn t_cond_k(&self) -> f64 {
self.t_cond_k
}
/// OAT [K].
pub fn oat_k(&self) -> f64 {
self.oat_k
}
/// Approche = T_cond OAT [K].
pub fn approach_k(&self) -> f64 {
self.approach_k
}
/// UA côté air [W/K].
pub fn ua_air_side(&self) -> f64 {
self.ua_air_side
}
/// UA total (air + réfrigérant) [W/K].
pub fn ua_total(&self) -> f64 {
self.ua_total
}
/// Nombre d'unités de transfert NTU.
pub fn ntu(&self) -> f64 {
self.ntu
}
/// Efficacité ε du condenseur.
pub fn effectiveness(&self) -> f64 {
self.effectiveness
}
/// Débit massique d'air [kg/s].
pub fn air_mass_flow_kg_s(&self) -> f64 {
self.air_density_kg_m3 * self.geometry.face_area_m2() * self.air_face_velocity_m_s
}
/// Puissance côté air C_air = ṁ_air × Cp_air [W/K].
pub fn c_air(&self) -> f64 {
self.air_mass_flow_kg_s() * CP_AIR
}
/// Température de sortie de l'air [K].
pub fn t_air_out_k(&self) -> f64 {
self.oat_k + self.design_capacity_w / self.c_air().max(1.0)
}
/// Géométrie du coil.
pub fn geometry(&self) -> &CoilGeometry {
&self.geometry
}
/// UA [W/K] (alias vers ua_total pour compatibilité avec Condenser).
pub fn ua(&self) -> f64 {
self.ua_total
}
/// Facteurs de calibration.
pub fn calib(&self) -> &Calib {
self.inner.calib()
}
// ─────────────────────────────────────────────────────────────────────
// Calcul interne : UA depuis géométrie + T_cond via ε-NTU
// ─────────────────────────────────────────────────────────────────────
/// Recalcule UA_air, UA_total et T_cond depuis les paramètres courants.
///
/// Appelé automatiquement à la construction et lors de tout changement
/// des paramètres (OAT, vitesse d'air, capacité nominale).
fn recompute(&mut self) {
self.ua_air_side = self.compute_ua_air();
// Correction résistance réfrigérant (condensation ~ 8% de la résistance totale)
self.ua_total = self.ua_air_side * REF_RESISTANCE_FACTOR;
// ε-NTU pour condensation isotherme
let c_air = self.c_air().max(1.0);
self.ntu = self.ua_total / c_air;
self.effectiveness = 1.0 - (-self.ntu).exp();
// T_cond analytique (isothermal refrigerant, Chang & Wang 1997, App. B)
// ΔT_air = Q / C_air
// ε = ΔT_air / (T_cond - OAT) → T_cond = OAT + ΔT_air / ε
let delta_t_air = self.design_capacity_w / c_air;
self.t_cond_k = if self.effectiveness > 1e-6 {
self.oat_k + delta_t_air / self.effectiveness
} else {
self.oat_k + 20.0 // fallback si ε ≈ 0
};
self.approach_k = self.t_cond_k - self.oat_k;
// Mettre à jour le condenseur interne avec la nouvelle T_cond
self.inner.set_saturation_temp(self.t_cond_k);
self.inner.set_ua(self.ua_total);
}
/// Calcule le UA côté air via la corrélation Chang & Wang (1997) + efficacité ailettes.
///
/// ## Algorithme
///
/// 1. Géométrie → paramètres de maille (cellule unitaire)
/// 2. Vitesse d'air interstitielle → Reynolds Re_Lp
/// 3. Facteur j de Colburn → coefficient h_air
/// 4. Efficacité ailettes η_fin (profil rectangulaire)
/// 5. Efficacité de surface η_surface
/// 6. UA_air = η_surface × h_air × A_total
fn compute_ua_air(&self) -> f64 {
let geom = &self.geometry;
// ── Paramètres dérivés de la géométrie ───────────────────────────
let fin_pitch_m = 0.0254 / geom.fin_pitch_fpi; // distance centre-à-centre entre ailettes [m]
let n_rows = geom.n_rows as f64;
// Nombre de tubes :
// - Dans la direction hauteur (transversale) : n_tubes_per_row = H / Pt
// - Profondeur (n_rows rangées) : n_tubes_total = n_per_row × n_rows
// - Chaque tube traverse TOUTE la largeur W (longueur de tube = face_width_m)
let n_tubes_per_row = (geom.face_height_m / geom.tube_pitch_transverse_m)
.floor()
.max(1.0);
let n_tubes = n_tubes_per_row * n_rows; // tubes au total dans le coil
// ── Surfaces des ailettes ─────────────────────────────────────────
// Chaque ailette : plaque H × coil_depth avec n_tubes trous
// coil_depth = n_rows × Pl (Pl = pas longitudinal, profondeur du coil)
// A_fin_1_face = H × coil_depth n_tubes × π×d²/4
// A_fin_2_faces = 2 × A_fin_1_face
let coil_depth_m = n_rows * geom.tube_pitch_longitudinal_m;
let tube_hole_area = n_tubes * std::f64::consts::PI * geom.tube_od_m.powi(2) / 4.0;
let a_fin_per_fin = 2.0 * (geom.face_height_m * coil_depth_m - tube_hole_area).max(0.0);
// Nombre d'ailettes sur la largeur du coil
let n_fins = geom.face_width_m / fin_pitch_m;
let a_fin_total = n_fins * a_fin_per_fin;
// ── Surface nue des tubes (portions entre ailettes) ───────────────
let tube_perimeter = std::f64::consts::PI * geom.tube_od_m;
let tube_length_between_fins = fin_pitch_m - geom.fin_thickness_m;
let a_tube_bare = n_tubes * tube_perimeter * tube_length_between_fins.max(0.0);
let a_total = a_fin_total + a_tube_bare;
// ── Vitesse d'air massique G [kg/(m²·s)] ─────────────────────────
// Section libre minimale : σ = (Pt - d_tube) / Pt (approximation)
let sigma = (geom.tube_pitch_transverse_m - geom.tube_od_m) / geom.tube_pitch_transverse_m;
let g_air = self.air_density_kg_m3 * self.air_face_velocity_m_s / sigma.max(0.1);
// ── Nombre de Reynolds basé sur louver pitch (Re_Lp) ─────────────
let re_lp = g_air * geom.louver_pitch_m / MU_AIR;
// ── Facteur j de Colburn (Chang & Wang 1997, Eq.4) ───────────────
// j_louvered = 0.49 × (Lp/Fp)^0.27 × Re_Lp^(-0.49) × (Lh/Lp)^(-0.14) × (N)^(-0.29)
// Version simplifiée (terme dominant) :
let lp_over_fp = geom.louver_pitch_m / fin_pitch_m;
let j_louvered = 0.49
* lp_over_fp.powf(0.27)
* re_lp.powf(-0.49)
* (geom.louver_length_m / geom.louver_pitch_m).powf(-0.14)
* n_rows.powf(-0.29);
// Correction pour le type d'ailettes
let mut j = j_louvered * geom.fin_type.j_factor_relative();
// Wang, Lin & Lee (2000) wet-surface plain-fin correction (j_wet / j_dry ≈ 0.851.05).
// Simplified multiplicative factor used when condensate films the fins.
if self.wet_surface {
let re_dc = g_air * geom.tube_od_m / MU_AIR;
let j_wet_factor = 0.914 * re_dc.powf(-0.05);
j *= j_wet_factor.clamp(0.7, 1.1);
}
// ── Coefficient convectif côté air h_air [W/(m²·K)] ─────────────
let h_air = j * g_air * CP_AIR / PR_AIR.powf(2.0 / 3.0);
// ── Efficacité des ailettes η_fin (profil rectangulaire) ─────────
// Hauteur effective de l'ailette
let l_fin = (geom.tube_pitch_transverse_m / 2.0 - geom.tube_od_m / 2.0)
* (1.0 + 0.35 * (geom.tube_pitch_transverse_m / geom.tube_od_m).ln());
let m = ((2.0 * h_air) / (K_FIN_ALU * geom.fin_thickness_m)).sqrt();
let ml = (m * l_fin).max(1e-9);
let eta_fin = if ml < 1e-6 { 1.0 } else { ml.tanh() / ml };
// ── Efficacité de surface ─────────────────────────────────────────
let eta_surface = if a_total > 0.0 {
1.0 - (1.0 - eta_fin) * a_fin_total / a_total
} else {
1.0
};
// ── UA côté air ──────────────────────────────────────────────────
let ua_air = eta_surface * h_air * a_total;
// Garde-fous : valeur physiquement raisonnable
ua_air.max(100.0).min(1_000_000.0)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Implémentation du trait Component — délégation vers inner Condenser
// ─────────────────────────────────────────────────────────────────────────────
impl Component for FinCoilCondenser {
fn set_system_context(
&mut self,
state_offset: usize,
external_edge_state_indices: &[(usize, usize, usize)],
) {
self.inner
.set_system_context(state_offset, external_edge_state_indices);
}
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 n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
self.inner.set_fluid_backend_from_builder(backend);
}
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<entropyk_core::MassFlow>, ComponentError> {
self.inner.port_mass_flows(state)
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
self.inner.port_enthalpies(state)
}
fn energy_transfers(
&self,
state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
self.inner.energy_transfers(state)
}
fn signature(&self) -> String {
format!(
"FinCoilCondenser(oat={:.1}K, T_cond={:.1}K, approach={:.1}K, UA={:.0}W/K, NTU={:.2}, {}×{}rows)",
self.oat_k,
self.t_cond_k,
self.approach_k,
self.ua_total,
self.ntu,
self.geometry.fin_type.as_str(),
self.geometry.n_rows,
)
}
fn to_params(&self) -> crate::ComponentParams {
self.inner.to_params()
}
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
self.inner.update_calib_factor(factor, value)
}
}
impl StateManageable for FinCoilCondenser {
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);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn make_typical_condenser() -> FinCoilCondenser {
// Chiller ~100 kW, condenseur 3 rangs, 14 FPI louvered, OAT=35°C
let geom = CoilGeometry {
face_width_m: 2.0,
face_height_m: 1.5,
n_rows: 3,
fin_type: FinType::Louvered,
fin_pitch_fpi: 14.0,
..Default::default()
};
FinCoilCondenser::new(308.15, geom, 2.5, 125_000.0)
}
#[test]
fn test_n_equations() {
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Condenser)
assert_eq!(make_typical_condenser().n_equations(), 3);
}
#[test]
fn test_ua_positive() {
let c = make_typical_condenser();
assert!(
c.ua_total() > 1000.0,
"UA should be > 1 kW/K, got {}",
c.ua_total()
);
assert!(
c.ua_total() < 200_000.0,
"UA unrealistically high: {}",
c.ua_total()
);
}
#[test]
fn test_approach_realistic() {
let c = make_typical_condenser();
// Chiller air-cooled : approach typique 1025 K
assert!(
c.approach_k() > 5.0 && c.approach_k() < 35.0,
"Approach unrealistic: {:.1} K (UA={:.0} W/K)",
c.approach_k(),
c.ua_total()
);
}
#[test]
fn test_t_cond_above_oat() {
let c = make_typical_condenser();
assert!(
c.t_cond_k() > c.oat_k(),
"T_cond ({:.1} K) must be > OAT ({:.1} K)",
c.t_cond_k(),
c.oat_k()
);
}
#[test]
fn test_more_rows_lower_approach() {
let geom3 = CoilGeometry {
n_rows: 3,
face_width_m: 2.0,
face_height_m: 1.5,
..Default::default()
};
let geom4 = CoilGeometry {
n_rows: 4,
face_width_m: 2.0,
face_height_m: 1.5,
..Default::default()
};
let c3 = FinCoilCondenser::new(308.15, geom3, 2.5, 125_000.0);
let c4 = FinCoilCondenser::new(308.15, geom4, 2.5, 125_000.0);
assert!(
c4.approach_k() < c3.approach_k(),
"More rows should reduce approach: 3rows={:.1}K, 4rows={:.1}K",
c3.approach_k(),
c4.approach_k()
);
}
#[test]
fn test_higher_velocity_lower_approach() {
let geom = CoilGeometry {
n_rows: 3,
face_width_m: 2.0,
face_height_m: 1.5,
..Default::default()
};
let c_slow = FinCoilCondenser::new(308.15, geom.clone(), 2.0, 125_000.0);
let c_fast = FinCoilCondenser::new(308.15, geom, 3.5, 125_000.0);
assert!(
c_fast.approach_k() < c_slow.approach_k(),
"Higher velocity should reduce approach: 2m/s={:.1}K, 3.5m/s={:.1}K",
c_slow.approach_k(),
c_fast.approach_k()
);
}
#[test]
fn test_louvered_vs_plain_fins() {
let geom_louv = CoilGeometry {
n_rows: 3,
face_width_m: 2.0,
face_height_m: 1.5,
fin_type: FinType::Louvered,
..Default::default()
};
let geom_plain = CoilGeometry {
n_rows: 3,
face_width_m: 2.0,
face_height_m: 1.5,
fin_type: FinType::Plain,
..Default::default()
};
let c_louv = FinCoilCondenser::new(308.15, geom_louv, 2.5, 125_000.0);
let c_plain = FinCoilCondenser::new(308.15, geom_plain, 2.5, 125_000.0);
assert!(
c_louv.ua_total() > c_plain.ua_total(),
"Louvered fins should give higher UA than plain"
);
}
#[test]
fn test_set_oat_updates_t_cond() {
let mut c = make_typical_condenser();
let t_cond_35 = c.t_cond_k();
c.set_oat_k(313.15); // OAT = 40°C
let t_cond_40 = c.t_cond_k();
assert!(
t_cond_40 > t_cond_35,
"T_cond should increase with OAT: 35°C→{:.1}K, 40°C→{:.1}K",
t_cond_35,
t_cond_40
);
}
#[test]
fn test_epsilon_ntu_consistency() {
let c = make_typical_condenser();
// Vérification : ε × (T_cond - OAT) = ΔT_air = Q/C_air
let delta_t_air_check = c.effectiveness() * c.approach_k();
let delta_t_air_ref = c.design_capacity_w / c.c_air();
assert!(
(delta_t_air_check - delta_t_air_ref).abs() < 0.01,
"ε-NTU consistency error: {:.3} vs {:.3}",
delta_t_air_check,
delta_t_air_ref
);
}
#[test]
fn test_fin_type_from_str() {
assert_eq!(FinType::from_str("louvered"), FinType::Louvered);
assert_eq!(FinType::from_str("wavy"), FinType::Wavy);
assert_eq!(FinType::from_str("plain"), FinType::Plain);
assert_eq!(FinType::from_str("SLIT"), FinType::Slit);
assert_eq!(FinType::from_str("unknown"), FinType::Louvered); // default
}
}
// Rendre `design_capacity_w` accessible pour les tests
impl FinCoilCondenser {
#[doc(hidden)]
pub fn design_capacity_w(&self) -> f64 {
self.design_capacity_w
}
}

View File

@@ -314,7 +314,10 @@ impl Component for FloodedCondenser {
self.inner.energy_transfers(state)
}
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
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(backend);
}
@@ -334,7 +337,10 @@ impl Component for FloodedCondenser {
.with_param("fluid", self.refrigerant_id.as_str())
.with_param("ua", self.ua())
.with_param("targetSubcoolingK", self.target_subcooling_k)
.with_param("calib", serde_json::to_value(&self.calib()).unwrap_or(serde_json::Value::Null))
.with_param(
"calib",
serde_json::to_value(&self.calib()).unwrap_or(serde_json::Value::Null),
)
}
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
@@ -414,7 +420,7 @@ mod tests {
let mut residuals = vec![0.0; 3];
let result = cond.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
@@ -514,7 +520,7 @@ mod tests {
let state = vec![0.0, 0.0, 1_000_000.0, 200_000.0, 0.0, 0.0];
let mut residuals = vec![0.0; 4];
let result = cond.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
@@ -552,21 +558,21 @@ mod tests {
fn test_flooded_condenser_calib_default() {
let cond = FloodedCondenser::new(15_000.0);
let calib = cond.calib();
assert_eq!(calib.f_ua, 1.0);
assert_eq!(calib.z_ua, 1.0);
}
#[test]
fn test_flooded_condenser_set_calib() {
let mut cond = FloodedCondenser::new(15_000.0);
let mut calib = Calib::default();
calib.f_ua = 0.9;
calib.z_ua = 0.9;
cond.set_calib(calib);
assert_eq!(cond.calib().f_ua, 0.9);
assert_eq!(cond.calib().z_ua, 0.9);
}
#[test]
fn test_subcooling_calculation_with_mock_backend() {
use entropyk_core::{Enthalpy, Temperature};
use entropyk_core::Enthalpy;
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
struct MockBackend;
@@ -601,7 +607,7 @@ mod tests {
fn full_state(
&self,
fluid: FluidId,
_fluid: FluidId,
_p: Pressure,
_h: Enthalpy,
) -> FluidResult<ThermoState> {
@@ -632,7 +638,7 @@ mod tests {
#[test]
fn test_validate_outlet_subcooled_with_mock_backend() {
use entropyk_core::{Enthalpy, Temperature};
use entropyk_core::Enthalpy;
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
struct MockBackend;
@@ -667,7 +673,7 @@ mod tests {
fn full_state(
&self,
fluid: FluidId,
_fluid: FluidId,
_p: Pressure,
_h: Enthalpy,
) -> FluidResult<ThermoState> {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
//! C¹/C∞ flow regularization for zero-flow-safe heat-exchanger residuals.
//!
//! Zero mass flow is a **valid** operating state (circuit staging, isolation,
//! Newton trial steps). Hard branches `if |m| < ε { Q = 0 }` create Jacobian
//! discontinuities and can leave energy residuals under-ranked.
//!
//! This module builds on [`entropyk_core::smoothing::smooth_abs`] and provides:
//!
//! - [`flow_activity`] — smooth activity α(m) ∈ [0, 1), α(0)=0, α→1 as |m| grows
//! - [`effective_duty`] — Q scaled by product of stream activities
//! - [`blend_transport_residual`] — blend active energy residual with no-flow hold
//!
//! # Residual units
//!
//! Active energy residuals are in **watts** (`ṁ·Δh Q`). The no-flow hold term
//! uses `m_scale * Δh` so residual units remain watts when `m_scale` is a
//! characteristic mass flow [kg/s].
//!
//! # Derivatives
//!
//! All helpers expose analytic first derivatives for exact Jacobians.
use entropyk_core::smoothing::{smooth_abs, smooth_abs_derivative};
/// Default mass-flow transition scale [kg/s] (~0.1 g/s).
///
/// Below this order of magnitude, activity drops toward zero and duty is
/// smoothly extinguished. Chosen small relative to HVAC branch flows (0.011 kg/s)
/// so normal operation is unaffected.
pub const DEFAULT_M_EPS_KG_S: f64 = 1e-4;
/// Default characteristic mass flow [kg/s] for residual scaling of no-flow holds.
pub const DEFAULT_M_SCALE_KG_S: f64 = 0.05;
/// Smooth flow activity α(m) = m² / (m² + ε²) ∈ [0, 1).
///
/// - α(0) = 0
/// - α → 1 as |m| ≫ ε
/// - C∞ everywhere
///
/// Equivalent to `(|m|/smooth_abs(m,ε))²` for ε>0.
#[inline]
pub fn flow_activity(m: f64, m_eps: f64) -> f64 {
let e = m_eps.abs().max(1e-30);
let m2 = m * m;
m2 / (m2 + e * e)
}
/// Analytic dα/dm for [`flow_activity`].
#[inline]
pub fn flow_activity_derivative(m: f64, m_eps: f64) -> f64 {
let e = m_eps.abs().max(1e-30);
let e2 = e * e;
let d = m * m + e2;
2.0 * m * e2 / (d * d)
}
/// Smooth |m| for capacity-rate constructions C = |ṁ|·cp (never zero denominator).
#[inline]
pub fn smooth_mass_magnitude(m: f64, m_eps: f64) -> f64 {
smooth_abs(m, m_eps.abs().max(1e-30))
}
/// d(|m|_smooth)/dm.
#[inline]
pub fn smooth_mass_magnitude_derivative(m: f64, m_eps: f64) -> f64 {
smooth_abs_derivative(m, m_eps.abs().max(1e-30))
}
/// Effective heat duty after stream activity gating: Q_eff = α_a · α_b · Q.
#[inline]
pub fn effective_duty(q: f64, alpha_a: f64, alpha_b: f64) -> f64 {
alpha_a * alpha_b * q
}
/// Blend an active transport residual with a no-flow hold on Δh.
///
/// ```text
/// r = α · r_active + (1 α) · m_scale · (h_out h_in)
/// ```
///
/// When α→0 (zero flow), the residual forces `h_out ≈ h_in` (no transport)
/// without a hard branch. Residual units stay [W] if `m_scale` is [kg/s].
#[inline]
pub fn blend_transport_residual(
alpha: f64,
r_active: f64,
m_scale: f64,
h_out: f64,
h_in: f64,
) -> f64 {
let a = alpha.clamp(0.0, 1.0);
a * r_active + (1.0 - a) * m_scale * (h_out - h_in)
}
/// Partials of [`blend_transport_residual`] for analytic Jacobians.
///
/// Returns `(∂r/∂α, ∂r/∂r_active, ∂r/∂h_out, ∂r/∂h_in)` treating `r_active` as
/// independent of `h_*` (caller adds chain rule on `r_active`).
#[inline]
pub fn blend_transport_partials(
alpha: f64,
r_active: f64,
m_scale: f64,
h_out: f64,
h_in: f64,
) -> (f64, f64, f64, f64) {
let a = alpha.clamp(0.0, 1.0);
let dr_d_alpha = r_active - m_scale * (h_out - h_in);
let dr_d_r_active = a;
let dr_d_h_out = (1.0 - a) * m_scale;
let dr_d_h_in = -(1.0 - a) * m_scale;
(dr_d_alpha, dr_d_r_active, dr_d_h_out, dr_d_h_in)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activity_zero_at_origin_and_near_one_at_large_flow() {
let eps = DEFAULT_M_EPS_KG_S;
assert!((flow_activity(0.0, eps)).abs() < 1e-15);
assert!(flow_activity(1.0, eps) > 0.999);
assert!(flow_activity(-1.0, eps) > 0.999);
assert!(flow_activity(eps, eps) > 0.4 && flow_activity(eps, eps) < 0.6);
}
#[test]
fn activity_derivative_matches_central_fd() {
let eps = 1e-3;
for m in [-1e-2, -1e-4, 0.0, 1e-4, 1e-2, 0.5] {
let analytic = flow_activity_derivative(m, eps);
let h = 1e-8_f64.max(m.abs() * 1e-6);
let fd = (flow_activity(m + h, eps) - flow_activity(m - h, eps)) / (2.0 * h);
assert!(
(analytic - fd).abs() < 1e-5,
"α' mismatch at m={m}: analytic={analytic} fd={fd}"
);
}
}
#[test]
fn effective_duty_vanishes_when_either_stream_inactive() {
let q = 10_000.0;
assert_eq!(effective_duty(q, 0.0, 1.0), 0.0);
assert_eq!(effective_duty(q, 1.0, 0.0), 0.0);
assert!((effective_duty(q, 1.0, 1.0) - q).abs() < 1e-12);
}
#[test]
fn blend_hold_at_zero_activity_forces_dh_zero() {
let r = blend_transport_residual(0.0, 999.0, 0.05, 400e3, 250e3);
// (1-0)*0.05*(150e3) = 7500
assert!((r - 0.05 * 150e3).abs() < 1e-6);
}
#[test]
fn blend_at_full_activity_is_active_residual() {
let r = blend_transport_residual(1.0, 123.0, 0.05, 400e3, 250e3);
assert!((r - 123.0).abs() < 1e-12);
}
#[test]
fn smooth_mass_magnitude_positive_at_zero() {
let eps = DEFAULT_M_EPS_KG_S;
assert!(smooth_mass_magnitude(0.0, eps) > 0.0);
assert!((smooth_mass_magnitude(0.0, eps) - eps).abs() < 1e-15);
}
#[test]
fn no_nan_across_zero_crossing() {
let eps = DEFAULT_M_EPS_KG_S;
for i in -20..=20 {
let m = i as f64 * 1e-5;
let a = flow_activity(m, eps);
let q = effective_duty(5e3, a, a);
let r = blend_transport_residual(a, m * 1e5 - q, DEFAULT_M_SCALE_KG_S, 3e5, 2e5);
assert!(a.is_finite() && q.is_finite() && r.is_finite(), "m={m}");
}
}
}

View File

@@ -0,0 +1,142 @@
//! CO₂ / supercritical gas cooler HTC (Pettersen / ChengThome simplified).
//!
//! Activated when `P > P_crit`. Uses a Gnielinski-like single-phase form with a
//! specific-heat peak correction near the pseudo-critical temperature.
use super::bphx_correlation::{BphxCorrelation, CorrelationParams, FlowRegime};
/// Inputs for gas-cooler HTC evaluation.
#[derive(Debug, Clone, Copy)]
pub struct GasCoolerInput {
/// Pressure [Pa].
pub pressure_pa: f64,
/// Critical pressure [Pa].
pub p_crit_pa: f64,
/// Bulk temperature [K].
pub t_bulk_k: f64,
/// Pseudo-critical temperature at this pressure [K] (≈ 304320 K for CO₂).
pub t_pc_k: f64,
/// Mass flux [kg/(m²·s)].
pub mass_flux: f64,
/// Hydraulic diameter [m].
pub dh: f64,
/// Density [kg/m³].
pub rho: f64,
/// Dynamic viscosity [Pa·s].
pub mu: f64,
/// Thermal conductivity [W/(m·K)].
pub k: f64,
/// Prandtl number [-].
pub pr: f64,
}
/// Returns true when the stream is supercritical (gas-cooler regime).
pub fn is_supercritical(pressure_pa: f64, p_crit_pa: f64) -> bool {
pressure_pa > p_crit_pa && p_crit_pa > 0.0
}
/// Pettersen-style HTC with pseudo-critical enhancement [W/(m²·K)].
///
/// Base: Gnielinski single-phase. Near `T_pc`, multiply by
/// `1 + 0.8 · exp(((TT_pc)/15)²)` to capture the c_p peak.
pub fn pettersen_htc(input: &GasCoolerInput) -> f64 {
let params = CorrelationParams {
mass_flux: input.mass_flux,
quality: 0.0,
dh: input.dh,
rho_l: input.rho,
rho_v: input.rho,
mu_l: input.mu,
mu_v: input.mu,
k_l: input.k,
pr_l: input.pr,
t_sat: input.t_bulk_k,
t_wall: input.t_bulk_k + 5.0,
regime: FlowRegime::SinglePhaseVapor,
chevron_angle: 60.0,
p_reduced: (input.pressure_pa / input.p_crit_pa.max(1.0)).min(0.99),
};
let base = BphxCorrelation::Gnielinski1976.compute_htc(&params).h;
let d_t = (input.t_bulk_k - input.t_pc_k) / 15.0;
let enhance = 1.0 + 0.8 * (-d_t * d_t).exp();
(base * enhance).max(50.0)
}
/// Gas cooler component wrapper: stores UA from Pettersen HTC × area.
#[derive(Debug, Clone)]
pub struct GasCooler {
/// Heat-transfer area [m²].
area_m2: f64,
/// Last HTC [W/(m²·K)].
last_htc: f64,
/// Last UA [W/K].
last_ua: f64,
}
impl GasCooler {
/// Creates a gas cooler with given heat-transfer area.
pub fn new(area_m2: f64) -> Self {
Self {
area_m2: area_m2.max(1e-6),
last_htc: 0.0,
last_ua: 0.0,
}
}
/// Updates HTC/UA from operating point.
pub fn update(&mut self, input: &GasCoolerInput) -> f64 {
self.last_htc = pettersen_htc(input);
self.last_ua = self.last_htc * self.area_m2;
self.last_ua
}
/// Last UA [W/K].
pub fn ua(&self) -> f64 {
self.last_ua
}
/// Last HTC [W/(m²·K)].
pub fn htc(&self) -> f64 {
self.last_htc
}
}
#[cfg(test)]
mod tests {
use super::*;
fn co2_input(t_k: f64) -> GasCoolerInput {
GasCoolerInput {
pressure_pa: 90e5,
p_crit_pa: 73.77e5,
t_bulk_k: t_k,
t_pc_k: 310.0,
mass_flux: 400.0,
dh: 0.001,
rho: 400.0,
mu: 3e-5,
k: 0.05,
pr: 2.0,
}
}
#[test]
fn supercritical_detected() {
assert!(is_supercritical(90e5, 73.77e5));
assert!(!is_supercritical(50e5, 73.77e5));
}
#[test]
fn htc_peaks_near_tpc() {
let h_peak = pettersen_htc(&co2_input(310.0));
let h_far = pettersen_htc(&co2_input(340.0));
assert!(h_peak > h_far);
}
#[test]
fn update_sets_ua() {
let mut gc = GasCooler::new(2.0);
let ua = gc.update(&co2_input(315.0));
assert!(ua > 0.0 && (ua - gc.htc() * 2.0).abs() < 1e-6);
}
}

View File

@@ -203,11 +203,8 @@ impl HeatTransferModel for LmtdModel {
)
.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);
let q_hot = hot_inlet.mass_flow * (hot_inlet.enthalpy - hot_outlet.enthalpy);
let q_cold = cold_inlet.mass_flow * (cold_outlet.enthalpy - cold_inlet.enthalpy);
residuals[0] = q_hot - q;
residuals[1] = q_cold - q;

View File

@@ -310,6 +310,18 @@ impl Component for MchxCondenserCoil {
self.inner.get_ports()
}
/// CM1.4: Forward system context to the inner `Condenser` so that
/// `same_branch_m` is detected and the redundant mass-conservation residual
/// is dropped when inlet and outlet share the same ṁ branch index.
fn set_system_context(
&mut self,
state_offset: usize,
external_edge_state_indices: &[(usize, usize, usize)],
) {
self.inner
.set_system_context(state_offset, external_edge_state_indices);
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
@@ -451,17 +463,17 @@ mod tests {
#[test]
fn test_n_equations_is_two() {
let coil = MchxCondenserCoil::new(15_000.0, 0.5, 0);
assert_eq!(coil.n_equations(), 2);
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Condenser)
assert_eq!(coil.n_equations(), 3);
}
#[test]
fn test_compute_residuals_ok() {
let coil = MchxCondenserCoil::new(15_000.0, 0.5, 0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 2];
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()));
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]

View File

@@ -31,8 +31,7 @@
//!
//! ## BPHX Evaporator (Story 11.6)
//!
//! - [`BphxEvaporator`]: Plate evaporator supporting DX and Flooded modes
//! - [`BphxEvaporatorMode`]: Operation mode (DX with superheat or Flooded with quality)
//! - [`BphxEvaporator`]: Plate evaporator (DX only, superheat-controlled outlet)
//!
//! ## BPHX Condenser (Story 11.7)
//!
@@ -48,6 +47,7 @@
//! // Heat exchanger would be created with connected ports
//! ```
pub mod air_cooled_condenser;
pub mod bphx_condenser;
pub mod bphx_correlation;
pub mod bphx_evaporator;
@@ -55,35 +55,81 @@ pub mod bphx_exchanger;
pub mod bphx_geometry;
pub mod condenser;
pub mod condenser_coil;
pub mod correlation_registry;
pub mod economizer;
pub mod eps_ntu;
pub mod evaporator;
pub mod evaporator_coil;
pub mod exchanger;
pub mod fin_coil_condenser;
pub mod fan_coil_unit;
pub mod flooded_condenser;
pub mod flooded_evaporator;
pub mod flow_regularization;
pub mod gas_cooler;
pub mod lmtd;
pub mod mchx_condenser_coil;
pub mod model;
pub mod moving_boundary_hx;
pub mod pool_boiling;
pub mod shell_and_tube;
pub mod two_phase_dp;
pub use air_cooled_condenser::AirCooledCondenser;
pub use bphx_condenser::BphxCondenser;
pub use bphx_correlation::{
BphxCorrelation, CorrelationParams, CorrelationResult, CorrelationSelector, FlowRegime,
ValidityStatus,
BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
CorrelationSelector, ValidityStatus,
};
pub use bphx_evaporator::{BphxEvaporator, BphxEvaporatorMode};
pub use bphx_evaporator::BphxEvaporator;
pub use bphx_exchanger::BphxExchanger;
pub use bphx_geometry::{BphxGeometry, BphxGeometryBuilder, BphxGeometryError, BphxType};
pub use condenser::Condenser;
pub use condenser::{Condenser, CondenserRating};
pub use condenser_coil::CondenserCoil;
pub use correlation_registry::{
assess_candidate, correlation_metadata, registered_correlations, select_correlation,
ApplicabilityDomain, BoundaryProximity, BoundedQuantity, BoundedQuantityKind,
CandidateAssessment, CandidateRejection, CorrelationId, CorrelationMetadata,
CorrelationPurpose, CorrelationSelectionError, DomainInputError, DomainStatus,
EvidenceMetadata, ExchangerGeometryType, FlowRegime, LimitViolation, OperatingPoint,
RefrigerantApplicability, RefrigerantEvidenceStatus, SelectionContext, SelectionOutcome,
ValidationEvidence,
};
pub use economizer::Economizer;
pub use eps_ntu::{EpsNtuModel, ExchangerType};
pub use evaporator::Evaporator;
pub use evaporator_coil::EvaporatorCoil;
pub use exchanger::{HeatExchanger, HeatExchangerBuilder, HxSideConditions};
pub use fin_coil_condenser::{CoilGeometry, FinCoilCondenser, FinType};
pub use flooded_condenser::FloodedCondenser;
pub use flooded_evaporator::FloodedEvaporator;
pub use fan_coil_unit::{FanCoilRating, FanCoilRatingInput, FanCoilUnit};
pub use flooded_evaporator::{
EvaporatorRating, FloodedEvaporator, FloodedPoolBoilingConfig, UaMode,
};
pub use gas_cooler::{is_supercritical, pettersen_htc, GasCooler, GasCoolerInput};
pub use shell_and_tube::{
bell_delaware_factors, default_tube_params, shell_and_tube_ua, shell_side_htc, tube_side_htc,
BellDelawareFactors, BellDelawareGeometry, ShellAndTubeHx,
};
pub use flow_regularization::{
blend_transport_partials, blend_transport_residual, effective_duty, flow_activity,
flow_activity_derivative, smooth_mass_magnitude, smooth_mass_magnitude_derivative,
DEFAULT_M_EPS_KG_S, DEFAULT_M_SCALE_KG_S,
};
pub use lmtd::{FlowConfiguration, LmtdModel};
pub use mchx_condenser_coil::MchxCondenserCoil;
pub use model::HeatTransferModel;
pub use pool_boiling::{
assess_cooper_domain, cooper_1984, cooper_metadata, flooded_shell_htc, mostinski_1963,
palen_bundle_factor, thome_robinson_oil_factor, ua_from_two_side_htc, PoolBoilingInput,
};
pub use two_phase_dp::{
acceleration_drop, assess_friedel_domain, assess_msh_domain, calibrate_quadratic_k,
default_refrigerant_pressure_drop_coeff, friedel_gradient, friedel_metadata,
friedel_multiplier, friedel_pressure_drop, msh_gradient, msh_metadata, msh_pressure_drop,
parse_dp_model_name, quadratic_drop, quadratic_drop_dm, tube_two_phase_delta_p,
zivi_void_fraction, FriedelInput, SatTransportProps, TubeChannelGeometry,
DEFAULT_N_PARALLEL_TUBES, DEFAULT_REFRIGERANT_DP_NOMINAL_PA,
DEFAULT_REFRIGERANT_M_NOMINAL_KG_S, DEFAULT_TUBE_DIAMETER_M, DEFAULT_TUBE_LENGTH_M,
TwoPhaseDpCorrelation,
};

View File

@@ -169,7 +169,7 @@ pub trait HeatTransferModel: Send + Sync {
1.0
}
/// Sets the UA calibration scale (e.g. from Calib.f_ua).
/// Sets the UA calibration scale (e.g. from Calib.z_ua).
fn set_ua_scale(&mut self, _s: f64) {}
/// Returns the effective UA used in heat transfer. If dynamic_ua_scale is provided, it is used instead of ua_scale.

View File

@@ -153,12 +153,14 @@ impl MovingBoundaryHX {
/// Sets the refrigerant fluid identifier.
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
self.refrigerant_id = fluid.into();
self.inner.set_hot_fluid(self.refrigerant_id.clone());
self
}
/// Sets the secondary fluid identifier.
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
}
@@ -180,29 +182,13 @@ impl Component for MovingBoundaryHX {
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
let (p, m_refrig, t_sec_in, t_sec_out) = if let (Some(hot), Some(cold)) =
(self.inner.hot_conditions(), self.inner.cold_conditions())
{
(
hot.pressure_pa(),
hot.mass_flow_kg_s(),
cold.temperature_k(),
cold.temperature_k() + 5.0,
)
} else {
(500_000.0, 0.1, 300.0, 320.0)
};
// Extract enthalpies exactly as HeatExchanger does:
let enthalpies = self.port_enthalpies(state)?;
let h_in = enthalpies
.get(0)
.map(|h| h.to_joules_per_kg())
.unwrap_or(400_000.0);
let h_out = enthalpies
.get(1)
.map(|h| h.to_joules_per_kg())
.unwrap_or(200_000.0);
let (ref_in, ref_out, sec_in, sec_out) = self.inner.live_fluid_states(state)?;
let p = ref_in.pressure;
let m_refrig = ref_in.mass_flow;
let t_sec_in = sec_in.temperature;
let t_sec_out = sec_out.temperature;
let h_in = ref_in.enthalpy;
let h_out = ref_out.enthalpy;
let mut cache = self.cache.borrow_mut();
let use_cache = cache.is_valid_for(p, m_refrig);
@@ -242,6 +228,18 @@ impl Component for MovingBoundaryHX {
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);
}
@@ -258,7 +256,10 @@ impl Component for MovingBoundaryHX {
self.inner.energy_transfers(state)
}
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
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(backend.clone());
self.inner.set_fluid_backend_from_builder(backend);
@@ -673,7 +674,7 @@ mod tests {
#[test]
fn test_compute_residuals_uses_cache() {
use crate::{Component, ResidualVector};
use crate::Component;
use entropyk_core::Pressure;
use entropyk_fluids::{
CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState,
@@ -727,11 +728,30 @@ mod tests {
calls: std::sync::atomic::AtomicUsize::new(0),
});
let hx = MovingBoundaryHX::new()
let mut hx = MovingBoundaryHX::new()
.with_refrigerant("R410A")
.with_secondary_fluid("Air")
.with_fluid_backend(backend.clone());
hx.set_port_context(&[
Some((0, 1, 2)),
Some((0, 3, 4)),
Some((5, 6, 7)),
Some((5, 8, 9)),
]);
let state = vec![500_000.0, 400_000.0];
let h_air = |t_k: f64| 1006.0 * (t_k - 273.15);
let state = vec![
0.1,
500_000.0,
400_000.0,
490_000.0,
200_000.0,
0.2,
101_325.0,
h_air(300.0),
101_325.0,
h_air(310.0),
];
let mut residuals = vec![0.0; 3];
// First call should calculate property (backend calls)
@@ -739,15 +759,19 @@ mod tests {
let calls_first = backend.calls.load(std::sync::atomic::Ordering::SeqCst);
assert!(calls_first > 0);
// Second call with same state should use cache -> 0 new backend calls
// Second call with same state still evaluates live edge properties, but
// should reuse cached zone identification.
let _ = hx.compute_residuals(&state, &mut residuals);
let calls_second = backend.calls.load(std::sync::atomic::Ordering::SeqCst);
assert_eq!(calls_first, calls_second); // Calls remained the same because cache was used
assert!(
calls_second - calls_first < calls_first,
"cache should avoid the zone-identification property calls"
);
}
#[test]
fn test_performance_speedup() {
use crate::{Component, ResidualVector};
use crate::Component;
use entropyk_core::Pressure;
use entropyk_fluids::{
CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState,

View File

@@ -0,0 +1,232 @@
//! Nucleate pool-boiling correlations for flooded evaporators.
//!
//! Implements Cooper (1984) and Mostinski (1963) for shell-side boiling on
//! tubes, plus Palen bundle and ThomeRobinson oil attenuation factors.
use super::correlation_registry::{
assess_candidate, correlation_metadata, CandidateAssessment, CorrelationId,
CorrelationMetadata, CorrelationPurpose, DomainInputError, ExchangerGeometryType, FlowRegime,
OperatingPoint, SelectionContext,
};
/// Inputs for pool-boiling heat-transfer evaluation (SI).
#[derive(Debug, Clone, Copy)]
pub struct PoolBoilingInput {
/// Reduced pressure `p_sat / p_crit` [-].
pub p_reduced: f64,
/// Heat flux [W/m²].
pub heat_flux: f64,
/// Molar mass [g/mol] (Cooper uses g/mol in its published form).
pub molar_mass_g_mol: f64,
/// Surface roughness Ra [μm]. Default 1.0 μm if unknown.
pub roughness_um: f64,
/// Critical pressure [Pa] (Mostinski).
pub p_crit_pa: f64,
}
impl Default for PoolBoilingInput {
fn default() -> Self {
Self {
p_reduced: 0.15,
heat_flux: 15_000.0,
molar_mass_g_mol: 102.0,
roughness_um: 1.0,
p_crit_pa: 4.059e6,
}
}
}
impl PoolBoilingInput {
fn validate(&self) -> Result<(), DomainInputError> {
for (field, value) in [
("p_reduced", self.p_reduced),
("heat_flux", self.heat_flux),
("molar_mass_g_mol", self.molar_mass_g_mol),
("roughness_um", self.roughness_um),
("p_crit_pa", self.p_crit_pa),
] {
if !value.is_finite() || value <= 0.0 {
return Err(DomainInputError::InvalidPositive { field, value });
}
}
if self.p_reduced >= 1.0 {
return Err(DomainInputError::InvalidPositive {
field: "p_reduced",
value: self.p_reduced,
});
}
Ok(())
}
}
/// Cooper (1984) nucleate pool-boiling HTC [W/(m²·K)].
///
/// `h = 55 · p_r^0.12 · (log10 p_r)^(0.55) · M^(0.5) · q^0.67`
/// with roughness correction `0.4343·ln(Ra)` on the `p_r^0.12` exponent term
/// as commonly implemented for industrial flooded evaporators.
pub fn cooper_1984(input: &PoolBoilingInput) -> Result<f64, DomainInputError> {
input.validate()?;
let pr = input.p_reduced.clamp(1e-6, 0.99);
let ra = input.roughness_um.max(0.01);
let log_term = (-pr.log10()).max(1e-6);
let h = 55.0
* pr.powf(0.12 - 0.4343 * ra.ln())
* log_term.powf(-0.55)
* input.molar_mass_g_mol.powf(-0.5)
* input.heat_flux.powf(0.67);
Ok(h.max(0.0))
}
/// Mostinski (1963) nucleate pool-boiling HTC [W/(m²·K)].
///
/// `h = 0.00417 · q^0.7 · p_crit^0.69 · F(p_r)` with
/// `F = 1.8 p_r^0.17 + 4 p_r^1.2 + 10 p_r^10` and `p_crit` in kN/m² (kPa).
pub fn mostinski_1963(input: &PoolBoilingInput) -> Result<f64, DomainInputError> {
input.validate()?;
let pr = input.p_reduced.clamp(1e-6, 0.99);
let p_crit_kpa = input.p_crit_pa / 1.0e3;
let f_pr = 1.8 * pr.powf(0.17) + 4.0 * pr.powf(1.2) + 10.0 * pr.powi(10);
let h = 0.00417 * input.heat_flux.powf(0.7) * p_crit_kpa.powf(0.69) * f_pr;
Ok(h.max(0.0))
}
/// Palen bundle correction factor (typical flooded evaporator range 0.60.9).
#[inline]
pub fn palen_bundle_factor(f_b: f64) -> f64 {
f_b.clamp(0.5, 1.2)
}
/// ThomeRobinson (ASHRAE RP-1089) oil attenuation on pool-boiling HTC.
///
/// Linear blend from 1.0 at zero oil to ~0.25 at 10 % oil mass fraction
/// (published Turbo-BII bundle data). Clamped for numerical safety.
pub fn thome_robinson_oil_factor(oil_mass_fraction: f64) -> f64 {
let w = oil_mass_fraction.clamp(0.0, 0.20);
(1.0 - 7.5 * w).clamp(0.25, 1.0)
}
/// Combined flooded-shell HTC: `h_nb · F_b · F_oil` [W/(m²·K)].
pub fn flooded_shell_htc(
input: &PoolBoilingInput,
correlation: CorrelationId,
bundle_factor: f64,
oil_mass_fraction: f64,
) -> Result<f64, DomainInputError> {
let h_nb = match correlation {
CorrelationId::Cooper1984 => cooper_1984(input)?,
CorrelationId::Mostinski1963 => mostinski_1963(input)?,
other => {
return Err(DomainInputError::InvalidPositive {
field: "pool_boiling_correlation",
value: other as u8 as f64,
});
}
};
Ok(h_nb * palen_bundle_factor(bundle_factor) * thome_robinson_oil_factor(oil_mass_fraction))
}
/// UA from refrigerant-side HTC and secondary-side HTC [W/K].
///
/// `1/UA = 1/(h_ref·A_ref) + 1/(h_sec·A_sec)` (wall resistance neglected).
pub fn ua_from_two_side_htc(
h_ref: f64,
area_ref_m2: f64,
h_sec: f64,
area_sec_m2: f64,
) -> f64 {
let r_ref = 1.0 / (h_ref.max(1.0) * area_ref_m2.max(1e-9));
let r_sec = 1.0 / (h_sec.max(1.0) * area_sec_m2.max(1e-9));
1.0 / (r_ref + r_sec)
}
/// Returns Cooper registry metadata.
pub fn cooper_metadata() -> CorrelationMetadata {
correlation_metadata(CorrelationId::Cooper1984)
}
/// Assesses Cooper applicability without evaluating the formula.
pub fn assess_cooper_domain(
geometry: ExchangerGeometryType,
refrigerant: Option<&str>,
) -> Result<CandidateAssessment, DomainInputError> {
let context = SelectionContext {
purpose: CorrelationPurpose::HeatTransfer,
geometry,
regime: FlowRegime::Evaporation,
refrigerant: refrigerant.map(str::to_owned),
operating_point: OperatingPoint {
quality: Some(0.5),
..OperatingPoint::default()
},
};
assess_candidate(&cooper_metadata(), &context)
}
#[cfg(test)]
mod tests {
use super::*;
fn r134a_like() -> PoolBoilingInput {
PoolBoilingInput {
p_reduced: 0.12,
heat_flux: 20_000.0,
molar_mass_g_mol: 102.03,
roughness_um: 1.0,
p_crit_pa: 4.059e6,
}
}
#[test]
fn cooper_htc_plausible_magnitude() {
let h = cooper_1984(&r134a_like()).unwrap();
assert!(h.is_finite() && h > 500.0 && h < 50_000.0, "h={h}");
}
#[test]
fn cooper_increases_with_heat_flux() {
let mut low = r134a_like();
low.heat_flux = 10_000.0;
let mut high = r134a_like();
high.heat_flux = 30_000.0;
assert!(cooper_1984(&high).unwrap() > cooper_1984(&low).unwrap());
}
#[test]
fn mostinski_positive() {
let h = mostinski_1963(&r134a_like()).unwrap();
assert!(h.is_finite() && h > 100.0);
}
#[test]
fn oil_factor_degrades_htc() {
assert!((thome_robinson_oil_factor(0.0) - 1.0).abs() < 1e-12);
assert!(thome_robinson_oil_factor(0.10) < 0.35);
}
#[test]
fn flooded_shell_applies_bundle_and_oil() {
let base = cooper_1984(&r134a_like()).unwrap();
let combined = flooded_shell_htc(
&r134a_like(),
CorrelationId::Cooper1984,
0.8,
0.05,
)
.unwrap();
assert!(combined < base * 0.8);
assert!(combined > 0.0);
}
#[test]
fn ua_two_side_finite() {
let ua = ua_from_two_side_htc(3000.0, 10.0, 5000.0, 8.0);
assert!(ua.is_finite() && ua > 0.0);
}
#[test]
fn cooper_rejects_invalid_p_reduced() {
let mut inp = r134a_like();
inp.p_reduced = 1.5;
assert!(cooper_1984(&inp).is_err());
}
}

View File

@@ -0,0 +1,190 @@
//! Shell-and-tube heat exchanger rating via BellDelaware shell-side factors.
//!
//! Computes shell-side HTC `h_s = h_ideal · J_C · J_L · J_B · J_R · J_S` and a
//! combined UA with tube-side Gnielinski / Cooper / Shah as selected.
use super::bphx_correlation::{BphxCorrelation, CorrelationParams, FlowRegime};
use super::correlation_registry::CorrelationId;
use super::pool_boiling::{cooper_1984, PoolBoilingInput};
/// Geometric inputs for BellDelaware correction factors.
#[derive(Debug, Clone, Copy)]
pub struct BellDelawareGeometry {
/// Fraction of tubes in cross-flow (F_C ≈ 1 2 F_W).
pub f_c: f64,
/// Leakage area ratio r_s = S_sb / (S_sb + S_tb).
pub r_s: f64,
/// Leakage/main stream area ratio r_lm = (S_sb + S_tb) / S_m.
pub r_lm: f64,
/// Bypass correction J_B (typical 0.70.9).
pub j_b: f64,
/// Laminar gradient correction J_R (1.0 if Re_s ≥ 100).
pub j_r: f64,
/// Unequal baffle spacing correction J_S (1.0 if uniform).
pub j_s: f64,
/// Ideal cross-flow HTC [W/(m²·K)] before corrections.
pub h_ideal: f64,
/// Shell-side area [m²].
pub area_shell_m2: f64,
/// Tube-side area [m²].
pub area_tube_m2: f64,
}
impl Default for BellDelawareGeometry {
fn default() -> Self {
Self {
f_c: 0.9,
r_s: 0.4,
r_lm: 0.3,
j_b: 0.8,
j_r: 1.0,
j_s: 1.0,
h_ideal: 2500.0,
area_shell_m2: 30.0,
area_tube_m2: 25.0,
}
}
}
/// BellDelaware correction factors.
#[derive(Debug, Clone, Copy)]
pub struct BellDelawareFactors {
/// Configuration factor J_C.
pub j_c: f64,
/// Leakage factor J_L.
pub j_l: f64,
/// Bypass factor J_B.
pub j_b: f64,
/// Laminar factor J_R.
pub j_r: f64,
/// Spacing factor J_S.
pub j_s: f64,
}
/// Computes J-factors from geometry (Taborek / Delaware handbook forms).
pub fn bell_delaware_factors(geom: &BellDelawareGeometry) -> BellDelawareFactors {
let j_c = 0.55 + 0.72 * geom.f_c.clamp(0.0, 1.0);
let j_l = 0.44 * (1.0 - geom.r_s)
+ (1.0 - 0.44 * (1.0 - geom.r_s)) * (-2.2 * geom.r_lm).exp();
BellDelawareFactors {
j_c: j_c.clamp(0.65, 1.15),
j_l: j_l.clamp(0.2, 1.0),
j_b: geom.j_b.clamp(0.7, 0.9),
j_r: geom.j_r.clamp(0.7, 1.0),
j_s: geom.j_s.clamp(0.8, 1.0),
}
}
/// Shell-side HTC after BellDelaware corrections [W/(m²·K)].
pub fn shell_side_htc(geom: &BellDelawareGeometry) -> f64 {
let f = bell_delaware_factors(geom);
geom.h_ideal * f.j_c * f.j_l * f.j_b * f.j_r * f.j_s
}
/// Tube-side HTC using a registered correlation [W/(m²·K)].
pub fn tube_side_htc(
correlation: CorrelationId,
params: &CorrelationParams,
pool: Option<&PoolBoilingInput>,
) -> f64 {
match correlation {
CorrelationId::Cooper1984 => pool
.and_then(|p| cooper_1984(p).ok())
.unwrap_or(3000.0),
CorrelationId::Gnielinski1976 => BphxCorrelation::Gnielinski1976.compute_htc(params).h,
CorrelationId::Shah2009 => BphxCorrelation::Shah2009.compute_htc(params).h,
CorrelationId::Shah1979 => BphxCorrelation::Shah1979.compute_htc(params).h,
CorrelationId::Cavallini2006 => BphxCorrelation::Cavallini2006.compute_htc(params).h,
CorrelationId::Kandlikar1990 => BphxCorrelation::Kandlikar1990.compute_htc(params).h,
_ => BphxCorrelation::Gnielinski1976.compute_htc(params).h,
}
}
/// Combined UA [W/K] from shell and tube sides (wall resistance neglected).
pub fn shell_and_tube_ua(
geom: &BellDelawareGeometry,
h_tube: f64,
) -> f64 {
let h_shell = shell_side_htc(geom);
let r = 1.0 / (h_shell.max(1.0) * geom.area_shell_m2.max(1e-9))
+ 1.0 / (h_tube.max(1.0) * geom.area_tube_m2.max(1e-9));
1.0 / r
}
/// Rating helper wrapping BellDelaware + tube correlation.
#[derive(Debug, Clone)]
pub struct ShellAndTubeHx {
geom: BellDelawareGeometry,
tube_correlation: CorrelationId,
last_ua: f64,
}
impl ShellAndTubeHx {
/// Creates a shell-and-tube rater with default geometry.
pub fn new(geom: BellDelawareGeometry) -> Self {
Self {
geom,
tube_correlation: CorrelationId::Gnielinski1976,
last_ua: 0.0,
}
}
/// Selects tube-side correlation.
pub fn with_tube_correlation(mut self, id: CorrelationId) -> Self {
self.tube_correlation = id;
self
}
/// Rates UA from operating tube-side params.
pub fn rate_ua(&mut self, params: &CorrelationParams) -> f64 {
let h_tube = tube_side_htc(self.tube_correlation, params, None);
self.last_ua = shell_and_tube_ua(&self.geom, h_tube);
self.last_ua
}
/// Last computed UA [W/K].
pub fn ua(&self) -> f64 {
self.last_ua
}
/// Geometry accessor.
pub fn geometry(&self) -> &BellDelawareGeometry {
&self.geom
}
}
/// Default condensation CorrelationParams for tube-side rating.
pub fn default_tube_params(regime: FlowRegime) -> CorrelationParams {
CorrelationParams {
regime,
mass_flux: 200.0,
..CorrelationParams::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn factors_in_expected_bands() {
let f = bell_delaware_factors(&BellDelawareGeometry::default());
assert!(f.j_c >= 0.65 && f.j_c <= 1.15);
assert!(f.j_l >= 0.2 && f.j_l <= 1.0);
}
#[test]
fn shell_htc_less_than_ideal() {
let g = BellDelawareGeometry::default();
let h = shell_side_htc(&g);
assert!(h < g.h_ideal);
assert!(h > 500.0);
}
#[test]
fn rate_ua_positive() {
let mut hx = ShellAndTubeHx::new(BellDelawareGeometry::default());
let ua = hx.rate_ua(&default_tube_params(FlowRegime::SinglePhaseLiquid));
assert!(ua.is_finite() && ua > 1000.0);
}
}

View File

@@ -0,0 +1,718 @@
//! Two-phase frictional pressure-drop correlations.
//!
//! Provides Friedel (1979) and Müller-Steinhagen-Heck (1986) two-phase friction
//! models — the latter is the pragmatic default in NIST EVAP-COND — together
//! with the Zivi void fraction and a lumped quadratic model for system-level
//! solvers that do not carry detailed tube geometry.
//!
//! All functions are analytic and side-effect free so they can be embedded in a
//! Newton residual/Jacobian without breaking the zero-panic policy.
use super::correlation_registry::{
assess_candidate, correlation_metadata, CandidateAssessment, CorrelationId,
CorrelationMetadata, CorrelationPurpose, DomainInputError, ExchangerGeometryType, FlowRegime,
OperatingPoint, SelectionContext,
};
/// Standard gravitational acceleration [m/s²].
const G_ACCEL: f64 = 9.80665;
/// Inputs describing the local two-phase state and channel for a Friedel
/// pressure-gradient evaluation. All quantities are SI.
#[derive(Debug, Clone, Copy)]
pub struct FriedelInput {
/// Mass flux G = ṁ / A_cross [kg/(m²·s)].
pub mass_flux: f64,
/// Hydraulic diameter [m].
pub diameter: f64,
/// Vapor quality x ∈ [0, 1] [-].
pub quality: f64,
/// Saturated-liquid density [kg/m³].
pub rho_liquid: f64,
/// Saturated-vapor density [kg/m³].
pub rho_vapor: f64,
/// Liquid dynamic viscosity [Pa·s].
pub mu_liquid: f64,
/// Vapor dynamic viscosity [Pa·s].
pub mu_vapor: f64,
/// Surface tension [N/m].
pub sigma: f64,
}
/// Returns the registered Friedel applicability metadata.
pub fn friedel_metadata() -> CorrelationMetadata {
correlation_metadata(CorrelationId::Friedel1979)
}
/// Assesses Friedel applicability without evaluating the analytic formula.
pub fn assess_friedel_domain(
input: &FriedelInput,
geometry: ExchangerGeometryType,
regime: FlowRegime,
refrigerant: Option<&str>,
) -> Result<CandidateAssessment, DomainInputError> {
for (field, value) in [
("mass_flux", input.mass_flux),
("hydraulic_diameter", input.diameter),
("liquid_density", input.rho_liquid),
("vapor_density", input.rho_vapor),
("liquid_viscosity", input.mu_liquid),
("vapor_viscosity", input.mu_vapor),
("surface_tension", input.sigma),
] {
if !value.is_finite() || value <= 0.0 {
return Err(DomainInputError::InvalidPositive { field, value });
}
}
if !input.quality.is_finite() || !(0.0..=1.0).contains(&input.quality) {
return Err(DomainInputError::InvalidQuality {
value: input.quality,
});
}
let context = SelectionContext {
purpose: CorrelationPurpose::PressureDrop,
geometry,
regime,
refrigerant: refrigerant.map(str::to_owned),
operating_point: OperatingPoint {
reynolds: Some(input.mass_flux * input.diameter / input.mu_liquid),
mass_flux: Some(input.mass_flux),
quality: Some(input.quality),
..OperatingPoint::default()
},
};
assess_candidate(&friedel_metadata(), &context)
}
/// Fanning friction factor for single-phase flow (Blasius/laminar blend).
///
/// Uses `16/Re` in the laminar regime (Re < 1187, the Blasius crossover) and
/// the Blasius smooth-tube correlation `0.079·Re^-0.25` in the turbulent
/// regime. Guards against non-positive Reynolds numbers.
#[inline]
pub fn fanning_friction_factor(reynolds: f64) -> f64 {
if reynolds <= 0.0 {
return 0.0;
}
if reynolds < 1187.0 {
16.0 / reynolds
} else {
0.079 * reynolds.powf(-0.25)
}
}
/// Homogeneous two-phase density 1/(x/ρ_g + (1-x)/ρ_l) [kg/m³].
#[inline]
pub fn homogeneous_density(quality: f64, rho_liquid: f64, rho_vapor: f64) -> f64 {
let x = quality.clamp(0.0, 1.0);
let inv = x / rho_vapor.max(1e-9) + (1.0 - x) / rho_liquid.max(1e-9);
if inv <= 0.0 {
rho_liquid
} else {
1.0 / inv
}
}
/// Zivi (1964) void fraction based on minimum-entropy-production slip.
///
/// `α = 1 / (1 + ((1-x)/x)·(ρ_g/ρ_l)^(2/3))`, clamped to [0, 1]. Returns 0 for
/// x ≤ 0 and 1 for x ≥ 1.
#[inline]
pub fn zivi_void_fraction(quality: f64, rho_liquid: f64, rho_vapor: f64) -> f64 {
let x = quality;
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
let ratio = (rho_vapor.max(1e-9) / rho_liquid.max(1e-9)).powf(2.0 / 3.0);
let denom = 1.0 + ((1.0 - x) / x) * ratio;
(1.0 / denom).clamp(0.0, 1.0)
}
/// Liquid-only frictional pressure gradient `(dP/dz)_LO` [Pa/m].
///
/// The gradient the whole mixture mass flux would produce if flowing as
/// saturated liquid: `2·f_LO·G²/(D·ρ_l)` (Fanning convention).
#[inline]
fn liquid_only_gradient(g: f64, d: f64, rho_l: f64, mu_l: f64) -> f64 {
let re_lo = g * d / mu_l.max(1e-12);
let f_lo = fanning_friction_factor(re_lo);
2.0 * f_lo * g * g / (d.max(1e-9) * rho_l.max(1e-9))
}
/// Friedel (1979) two-phase friction multiplier `φ_LO²` [-].
///
/// Multiplies the liquid-only gradient to obtain the two-phase frictional
/// gradient. Returns 1.0 at x = 0 (single-phase liquid) and is always ≥ 0.
pub fn friedel_multiplier(input: &FriedelInput) -> f64 {
let x = input.quality.clamp(0.0, 1.0);
if x <= 0.0 {
return 1.0;
}
let g = input.mass_flux.abs();
let d = input.diameter.max(1e-9);
let rho_l = input.rho_liquid.max(1e-9);
let rho_g = input.rho_vapor.max(1e-9);
let mu_l = input.mu_liquid.max(1e-12);
let mu_g = input.mu_vapor.max(1e-12);
let re_lo = g * d / mu_l;
let re_go = g * d / mu_g;
let f_lo = fanning_friction_factor(re_lo);
let f_go = fanning_friction_factor(re_go);
// E = (1-x)² + x²·(ρ_l·f_go)/(ρ_g·f_lo)
let e = (1.0 - x).powi(2) + x * x * (rho_l * f_go) / (rho_g * f_lo.max(1e-12));
// F = x^0.78·(1-x)^0.224
let f = x.powf(0.78) * (1.0 - x).powf(0.224);
// H = (ρ_l/ρ_g)^0.91·(μ_g/μ_l)^0.19·(1-μ_g/μ_l)^0.7
let mu_ratio = mu_g / mu_l;
let h = (rho_l / rho_g).powf(0.91) * mu_ratio.powf(0.19) * (1.0 - mu_ratio).max(0.0).powf(0.7);
// Homogeneous Froude and Weber numbers.
let rho_h = homogeneous_density(x, rho_l, rho_g);
let fr = g * g / (G_ACCEL * d * rho_h * rho_h);
let we = g * g * d / (rho_h * input.sigma.max(1e-9));
e + 3.24 * f * h / (fr.powf(0.045) * we.powf(0.035))
}
/// Two-phase frictional pressure gradient `(dP/dz)` [Pa/m] via Friedel.
///
/// `φ_LO² · (dP/dz)_LO`. Always ≥ 0 (a magnitude); the caller applies the sign
/// according to flow direction (pressure decreases downstream).
pub fn friedel_gradient(input: &FriedelInput) -> f64 {
let g = input.mass_flux.abs();
let dpdz_lo = liquid_only_gradient(g, input.diameter, input.rho_liquid, input.mu_liquid);
friedel_multiplier(input) * dpdz_lo
}
/// Total Friedel frictional pressure drop `ΔP` [Pa] over a channel `length`.
///
/// Evaluated at a representative (e.g. mean) quality. Returns a positive
/// magnitude.
pub fn friedel_pressure_drop(input: &FriedelInput, length: f64) -> f64 {
friedel_gradient(input) * length.max(0.0)
}
/// Selectable two-phase frictional ΔP correlation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TwoPhaseDpCorrelation {
/// Friedel (1979) — general-purpose, surface-tension dependent.
#[default]
Friedel1979,
/// Müller-Steinhagen-Heck (1986) — NIST EVAP-COND default, no pattern map.
MullerSteinhagenHeck1986,
}
impl TwoPhaseDpCorrelation {
/// Stable registry identifier.
pub const fn id(self) -> CorrelationId {
match self {
Self::Friedel1979 => CorrelationId::Friedel1979,
Self::MullerSteinhagenHeck1986 => CorrelationId::MullerSteinhagenHeck1986,
}
}
/// Frictional pressure gradient [Pa/m] (positive magnitude).
pub fn gradient(self, input: &FriedelInput) -> f64 {
match self {
Self::Friedel1979 => friedel_gradient(input),
Self::MullerSteinhagenHeck1986 => msh_gradient(input),
}
}
/// Frictional pressure drop [Pa] over `length` (positive magnitude).
pub fn pressure_drop(self, input: &FriedelInput, length: f64) -> f64 {
self.gradient(input) * length.max(0.0)
}
}
/// Liquid-only and vapor-only frictional gradients for MSH [Pa/m].
#[inline]
fn vapor_only_gradient(g: f64, d: f64, rho_g: f64, mu_g: f64) -> f64 {
let re_go = g * d / mu_g.max(1e-12);
let f_go = fanning_friction_factor(re_go);
2.0 * f_go * g * g / (d.max(1e-9) * rho_g.max(1e-9))
}
/// Müller-Steinhagen-Heck (1986) two-phase frictional gradient [Pa/m].
///
/// Linear blend between liquid-only and vapor-only gradients with a cubic
/// quality correction:
/// `(dP/dz) = A + 2(BA)x` at low x, then smooth to vapor-only at x→1 via
/// `(dP/dz) = (A + 2(BA)x)·(1x)^(1/3) + B·x³` where A=(dP/dz)_LO, B=(dP/dz)_GO.
pub fn msh_gradient(input: &FriedelInput) -> f64 {
let x = input.quality.clamp(0.0, 1.0);
let g = input.mass_flux.abs();
let a = liquid_only_gradient(g, input.diameter, input.rho_liquid, input.mu_liquid);
let b = vapor_only_gradient(g, input.diameter, input.rho_vapor, input.mu_vapor);
if x <= 0.0 {
return a;
}
if x >= 1.0 {
return b;
}
let linear = a + 2.0 * (b - a) * x;
linear * (1.0 - x).powf(1.0 / 3.0) + b * x.powi(3)
}
/// MSH frictional pressure drop [Pa] over `length`.
pub fn msh_pressure_drop(input: &FriedelInput, length: f64) -> f64 {
msh_gradient(input) * length.max(0.0)
}
/// Returns MSH registry metadata.
pub fn msh_metadata() -> CorrelationMetadata {
correlation_metadata(CorrelationId::MullerSteinhagenHeck1986)
}
/// Assesses MSH applicability without evaluating the formula.
pub fn assess_msh_domain(
input: &FriedelInput,
geometry: ExchangerGeometryType,
regime: FlowRegime,
refrigerant: Option<&str>,
) -> Result<CandidateAssessment, DomainInputError> {
for (field, value) in [
("mass_flux", input.mass_flux),
("hydraulic_diameter", input.diameter),
("liquid_density", input.rho_liquid),
("vapor_density", input.rho_vapor),
("liquid_viscosity", input.mu_liquid),
("vapor_viscosity", input.mu_vapor),
] {
if !value.is_finite() || value <= 0.0 {
return Err(DomainInputError::InvalidPositive { field, value });
}
}
if !input.quality.is_finite() || !(0.0..=1.0).contains(&input.quality) {
return Err(DomainInputError::InvalidQuality {
value: input.quality,
});
}
let context = SelectionContext {
purpose: CorrelationPurpose::PressureDrop,
geometry,
regime,
refrigerant: refrigerant.map(str::to_owned),
operating_point: OperatingPoint {
reynolds: Some(input.mass_flux * input.diameter / input.mu_liquid),
mass_flux: Some(input.mass_flux),
quality: Some(input.quality),
..OperatingPoint::default()
},
};
assess_candidate(&msh_metadata(), &context)
}
/// Lumped quadratic pressure drop `ΔP = k · ṁ·|ṁ|` [Pa].
///
/// The standard system-level representation when detailed geometry is
/// unavailable: `k` [Pa·s²/kg²] is a single coefficient calibrated from one
/// rated (ṁ, ΔP) point. Signed by `ṁ` so it is antisymmetric and its
/// derivative `∂ΔP/∂ṁ = 2·k·|ṁ|` is continuous through ṁ = 0.
#[inline]
pub fn quadratic_drop(k: f64, mass_flow: f64) -> f64 {
k * mass_flow * mass_flow.abs()
}
/// Analytic derivative of [`quadratic_drop`] w.r.t. mass flow: `2·k·|ṁ|`.
#[inline]
pub fn quadratic_drop_dm(k: f64, mass_flow: f64) -> f64 {
2.0 * k * mass_flow.abs()
}
/// Calibrates the lumped coefficient `k` from a rated point (ṁ, ΔP).
///
/// `k = ΔP_rated / ṁ_rated²`. Returns 0 for a non-positive rated flow.
#[inline]
pub fn calibrate_quadratic_k(rated_dp: f64, rated_mass_flow: f64) -> f64 {
if rated_mass_flow.abs() < 1e-12 {
0.0
} else {
rated_dp / (rated_mass_flow * rated_mass_flow)
}
}
/// Reference design pressure drop [Pa] for *quadratic* rating calibration
/// (Modelica Buildings `dp_nominal` style). Not applied silently: use
/// [`calibrate_quadratic_k`] or tube correlations ([`tube_two_phase_delta_p`]).
pub const DEFAULT_REFRIGERANT_DP_NOMINAL_PA: f64 = 15_000.0;
/// Nominal refrigerant mass flow [kg/s] paired with
/// [`DEFAULT_REFRIGERANT_DP_NOMINAL_PA`] for quadratic-`k` calibration.
pub const DEFAULT_REFRIGERANT_M_NOMINAL_KG_S: f64 = 0.05;
/// Default tube length [m] when `dp_model=msh|friedel` omits geometry.
pub const DEFAULT_TUBE_LENGTH_M: f64 = 6.0;
/// Default hydraulic diameter [m] (~3/8″ DX tube).
pub const DEFAULT_TUBE_DIAMETER_M: f64 = 0.0095;
/// Default number of parallel refrigerant channels (keeps G in a DX-like band
/// for small chillers ~0.05 kg/s).
pub const DEFAULT_N_PARALLEL_TUBES: f64 = 2.0;
/// Lumped `k` [Pa·s²/kg²] from the reference design point
/// (`15 kPa` @ `0.05 kg/s` → `6×10⁶`). Prefer tube MSH/Friedel when geometry
/// is known.
#[inline]
pub fn default_refrigerant_pressure_drop_coeff() -> f64 {
calibrate_quadratic_k(
DEFAULT_REFRIGERANT_DP_NOMINAL_PA,
DEFAULT_REFRIGERANT_M_NOMINAL_KG_S,
)
}
/// Minimal tube-bundle geometry for DX frictional ΔP.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TubeChannelGeometry {
/// Refrigerant path length [m].
pub length_m: f64,
/// Hydraulic diameter [m].
pub diameter_m: f64,
/// Number of parallel tubes / channels [-] (≥ 1).
pub n_parallel: f64,
}
impl TubeChannelGeometry {
/// DX defaults: 6 m × 9.5 mm × 2 parallel.
pub fn dx_default() -> Self {
Self {
length_m: DEFAULT_TUBE_LENGTH_M,
diameter_m: DEFAULT_TUBE_DIAMETER_M,
n_parallel: DEFAULT_N_PARALLEL_TUBES,
}
}
/// Total cross-sectional flow area [m²].
#[inline]
pub fn flow_area_m2(self) -> f64 {
let d = self.diameter_m.max(1e-9);
self.n_parallel.max(1.0) * std::f64::consts::PI * d * d / 4.0
}
}
/// Saturated-phase transport properties at the local pressure.
#[derive(Debug, Clone, Copy)]
pub struct SatTransportProps {
/// Saturated-liquid density [kg/m³].
pub rho_liquid: f64,
/// Saturated-vapor density [kg/m³].
pub rho_vapor: f64,
/// Saturated-liquid dynamic viscosity [Pa·s].
pub mu_liquid: f64,
/// Saturated-vapor dynamic viscosity [Pa·s].
pub mu_vapor: f64,
/// Surface tension [N/m] (Friedel); unused by MSH but kept for a shared input.
pub sigma: f64,
}
/// Acceleration pressure change [Pa]: `G² (v(x_out) v(x_in))` with homogeneous
/// specific volume `v = x/ρ_v + (1x)/ρ_l`. Positive when quality rises (evaporator).
#[inline]
pub fn acceleration_drop(
mass_flux: f64,
x_in: f64,
x_out: f64,
rho_liquid: f64,
rho_vapor: f64,
) -> f64 {
let g = mass_flux;
let v = |x: f64| {
let xc = x.clamp(0.0, 1.0);
xc / rho_vapor.max(1e-9) + (1.0 - xc) / rho_liquid.max(1e-9)
};
g * g * (v(x_out) - v(x_in))
}
/// Tube DX pressure drop [Pa] in the flow direction:
/// `ΔP = ΔP_friction(x̄) + ΔP_acceleration`.
///
/// Friction uses MSH (NIST EVAP-COND default) or Friedel at the mean quality
/// `x̄ = ½(clamp(x_in)+clamp(x_out))` over [`TubeChannelGeometry::length_m`].
/// Signed so `P_out = P_in ΔP` for `ṁ ≥ 0`.
pub fn tube_two_phase_delta_p(
correlation: TwoPhaseDpCorrelation,
geom: &TubeChannelGeometry,
mass_flow: f64,
x_in: f64,
x_out: f64,
props: &SatTransportProps,
) -> f64 {
let area = geom.flow_area_m2().max(1e-12);
let g = mass_flow.abs() / area;
let x_mean = 0.5 * (x_in.clamp(0.0, 1.0) + x_out.clamp(0.0, 1.0));
let input = FriedelInput {
mass_flux: g,
diameter: geom.diameter_m.max(1e-9),
quality: x_mean,
rho_liquid: props.rho_liquid,
rho_vapor: props.rho_vapor,
mu_liquid: props.mu_liquid,
mu_vapor: props.mu_vapor,
sigma: props.sigma.max(1e-9),
};
let dp_fric = correlation.pressure_drop(&input, geom.length_m.max(0.0));
let dp_acc = acceleration_drop(g, x_in, x_out, props.rho_liquid, props.rho_vapor);
let drop = dp_fric + dp_acc;
if mass_flow >= 0.0 {
drop
} else {
-drop
}
}
/// Parse `dp_model` string: `none`/`isobaric`, `quadratic`, `msh`, `friedel`.
pub fn parse_dp_model_name(name: &str) -> Option<&'static str> {
match name.trim().to_ascii_lowercase().as_str() {
"none" | "isobaric" | "zero" => Some("isobaric"),
"quadratic" | "rated" | "lumped" | "dp_nominal" => Some("quadratic"),
"msh" | "muller" | "müller" | "muller-steinhagen-heck" | "muller_steinhagen_heck" => {
Some("msh")
}
"friedel" => Some("friedel"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn r134a_like() -> FriedelInput {
// Representative R134a evaporating near 5 °C.
FriedelInput {
mass_flux: 200.0,
diameter: 0.008,
quality: 0.5,
rho_liquid: 1260.0,
rho_vapor: 17.0,
mu_liquid: 250e-6,
mu_vapor: 11e-6,
sigma: 0.011,
}
}
#[test]
fn multiplier_is_one_at_zero_quality() {
let mut inp = r134a_like();
inp.quality = 0.0;
assert!((friedel_multiplier(&inp) - 1.0).abs() < 1e-12);
}
#[test]
fn multiplier_exceeds_one_in_two_phase() {
// Two-phase acceleration of the vapor makes φ_LO² > 1.
let inp = r134a_like();
assert!(friedel_multiplier(&inp) > 1.0);
}
#[test]
fn gradient_increases_with_mass_flux() {
let low = FriedelInput {
mass_flux: 100.0,
..r134a_like()
};
let high = FriedelInput {
mass_flux: 400.0,
..r134a_like()
};
assert!(friedel_gradient(&high) > friedel_gradient(&low));
}
#[test]
fn gradient_is_finite_and_positive_across_quality() {
for q in [0.05, 0.2, 0.4, 0.6, 0.8, 0.95] {
let inp = FriedelInput {
quality: q,
..r134a_like()
};
let g = friedel_gradient(&inp);
assert!(g.is_finite() && g > 0.0, "q={q} gradient={g}");
}
}
#[test]
fn friedel_pressure_drop_reference_magnitude() {
// Sanity band: a 2 m, 8 mm R134a tube at G=200, x=0.5 gives a two-phase
// drop of order a few kPa (physically plausible for this duty).
let dp = friedel_pressure_drop(&r134a_like(), 2.0);
assert!(
dp > 500.0 && dp < 50_000.0,
"dp={dp} Pa out of expected band"
);
}
#[test]
fn zivi_void_fraction_bounds_and_monotonic() {
assert_eq!(zivi_void_fraction(0.0, 1260.0, 17.0), 0.0);
assert_eq!(zivi_void_fraction(1.0, 1260.0, 17.0), 1.0);
let a_low = zivi_void_fraction(0.1, 1260.0, 17.0);
let a_high = zivi_void_fraction(0.9, 1260.0, 17.0);
assert!(a_low > 0.0 && a_low < 1.0);
assert!(a_high > a_low);
// Even at low quality the void fraction is high (vapor occupies most area).
assert!(a_low > 0.5, "Zivi void fraction unexpectedly low: {a_low}");
}
#[test]
fn quadratic_drop_and_derivative() {
let k_default = default_refrigerant_pressure_drop_coeff();
assert!((k_default - 6.0e6).abs() < 1.0, "15 kPa @ 0.05 kg/s → k=6e6");
let props = SatTransportProps {
rho_liquid: 1260.0,
rho_vapor: 17.0,
mu_liquid: 250e-6,
mu_vapor: 11e-6,
sigma: 0.011,
};
let geom = TubeChannelGeometry::dx_default();
let dp_evap = tube_two_phase_delta_p(
TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
&geom,
0.05,
0.2,
0.95,
&props,
);
assert!(dp_evap > 1000.0, "DX evaporating ΔP should be kPa-scale, got {dp_evap}");
let dp_cond = tube_two_phase_delta_p(
TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
&geom,
0.05,
0.95,
0.05,
&props,
);
// Condensation: acceleration recovers some pressure → ΔP_cond < ΔP_evap typically.
assert!(dp_cond > 0.0 && dp_cond < dp_evap);
let k = calibrate_quadratic_k(20_000.0, 0.1); // 20 kPa at 0.1 kg/s
assert!((quadratic_drop(k, 0.1) - 20_000.0).abs() < 1e-6);
// Antisymmetric.
assert!((quadratic_drop(k, -0.1) + 20_000.0).abs() < 1e-6);
// Derivative matches central finite difference.
let m = 0.07;
let d_fd = (quadratic_drop(k, m + 1e-6) - quadratic_drop(k, m - 1e-6)) / 2e-6;
assert!((quadratic_drop_dm(k, m) - d_fd).abs() < 1e-3);
}
#[test]
fn calibrate_handles_zero_flow() {
assert_eq!(calibrate_quadratic_k(1000.0, 0.0), 0.0);
}
#[test]
fn friedel_domain_assessment_is_separate_from_formula() {
let assessment = assess_friedel_domain(
&r134a_like(),
ExchangerGeometryType::SmoothTube,
FlowRegime::Evaporation,
Some("R134a"),
)
.unwrap();
assert!(assessment.accepted);
assert_eq!(assessment.id, CorrelationId::Friedel1979);
assert_eq!(
assessment.domain_status,
Some(super::super::DomainStatus::InDomain)
);
}
#[test]
fn friedel_rejects_wrong_geometry_structurally() {
let assessment = assess_friedel_domain(
&r134a_like(),
ExchangerGeometryType::BrazedPlate,
FlowRegime::Evaporation,
Some("R134a"),
)
.unwrap();
assert!(!assessment.accepted);
assert!(matches!(
assessment.rejections.as_slice(),
[super::super::CandidateRejection::WrongGeometry { .. }]
));
assert!(assessment.domain_status.is_none());
}
#[test]
fn friedel_assessment_rejects_invalid_physical_input() {
let mut input = r134a_like();
input.sigma = 0.0;
let error = assess_friedel_domain(
&input,
ExchangerGeometryType::SmoothTube,
FlowRegime::Evaporation,
Some("R134a"),
)
.unwrap_err();
assert!(matches!(
error,
DomainInputError::InvalidPositive {
field: "surface_tension",
..
}
));
}
#[test]
fn msh_matches_liquid_only_at_x0() {
let mut inp = r134a_like();
inp.quality = 0.0;
let a = liquid_only_gradient(
inp.mass_flux,
inp.diameter,
inp.rho_liquid,
inp.mu_liquid,
);
assert!((msh_gradient(&inp) - a).abs() < 1e-9);
}
#[test]
fn msh_matches_vapor_only_at_x1() {
let mut inp = r134a_like();
inp.quality = 1.0;
let b = vapor_only_gradient(inp.mass_flux, inp.diameter, inp.rho_vapor, inp.mu_vapor);
assert!((msh_gradient(&inp) - b).abs() < 1e-9);
}
#[test]
fn msh_positive_across_quality() {
for q in [0.05, 0.3, 0.5, 0.7, 0.95] {
let inp = FriedelInput {
quality: q,
..r134a_like()
};
let g = msh_gradient(&inp);
assert!(g.is_finite() && g > 0.0, "q={q} g={g}");
}
}
#[test]
fn two_phase_dp_enum_dispatches() {
let inp = r134a_like();
let friedel = TwoPhaseDpCorrelation::Friedel1979.gradient(&inp);
let msh = TwoPhaseDpCorrelation::MullerSteinhagenHeck1986.gradient(&inp);
assert!(friedel.is_finite() && msh.is_finite());
assert!(friedel > 0.0 && msh > 0.0);
}
#[test]
fn msh_domain_accepted_for_smooth_tube() {
let assessment = assess_msh_domain(
&r134a_like(),
ExchangerGeometryType::SmoothTube,
FlowRegime::Condensation,
Some("R134a"),
)
.unwrap();
assert!(assessment.accepted);
assert_eq!(assessment.id, CorrelationId::MullerSteinhagenHeck1986);
}
}