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:
302
crates/components/src/heat_exchanger/air_cooled_condenser.rs
Normal file
302
crates/components/src/heat_exchanger/air_cooled_condenser.rs
Normal 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 : 10–15 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 : 10–15 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(_))));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms);
|
||||
let evaluation = match self.correlation_selector.select_and_compute(
|
||||
¶ms,
|
||||
ExchangerGeometryType::BrazedPlate,
|
||||
self.refrigerant_id(),
|
||||
) {
|
||||
Ok(evaluation) => evaluation,
|
||||
Err(error) => {
|
||||
self.clear_last_htc_evaluation();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let result = &evaluation.result;
|
||||
|
||||
self.last_htc.set(result.h);
|
||||
self.last_htc_result.set(Some(result.clone()));
|
||||
self.last_selection
|
||||
.replace(Some(evaluation.selection.clone()));
|
||||
self.last_validity_warning
|
||||
.set(result.validity != ValidityStatus::Valid);
|
||||
|
||||
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
@@ -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]
|
||||
|
||||
1394
crates/components/src/heat_exchanger/correlation_registry.rs
Normal file
1394
crates/components/src/heat_exchanger/correlation_registry.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
165
crates/components/src/heat_exchanger/fan_coil_unit.rs
Normal file
165
crates/components/src/heat_exchanger/fan_coil_unit.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! Fan-coil unit (FCU): water–air ε-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.05–0.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 water–air.
|
||||
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);
|
||||
}
|
||||
}
|
||||
909
crates/components/src/heat_exchanger/fin_coil_condenser.rs
Normal file
909
crates/components/src/heat_exchanger/fin_coil_condenser.rs
Normal 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):533–544
|
||||
//! - 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 : 10–18 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 Wang–Lin–Lee (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.0–3.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 Wang–Lin–Lee 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.85–1.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 10–25 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
|
||||
}
|
||||
}
|
||||
@@ -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
182
crates/components/src/heat_exchanger/flow_regularization.rs
Normal file
182
crates/components/src/heat_exchanger/flow_regularization.rs
Normal 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.01–1 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
142
crates/components/src/heat_exchanger/gas_cooler.rs
Normal file
142
crates/components/src/heat_exchanger/gas_cooler.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! CO₂ / supercritical gas cooler HTC (Pettersen / Cheng–Thome 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] (≈ 304–320 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(−((T−T_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(¶ms).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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
232
crates/components/src/heat_exchanger/pool_boiling.rs
Normal file
232
crates/components/src/heat_exchanger/pool_boiling.rs
Normal 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 Thome–Robinson 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.6–0.9).
|
||||
#[inline]
|
||||
pub fn palen_bundle_factor(f_b: f64) -> f64 {
|
||||
f_b.clamp(0.5, 1.2)
|
||||
}
|
||||
|
||||
/// Thome–Robinson (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());
|
||||
}
|
||||
}
|
||||
190
crates/components/src/heat_exchanger/shell_and_tube.rs
Normal file
190
crates/components/src/heat_exchanger/shell_and_tube.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! Shell-and-tube heat exchanger rating via Bell–Delaware 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 Bell–Delaware 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.7–0.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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bell–Delaware 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 Bell–Delaware 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 Bell–Delaware + 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);
|
||||
}
|
||||
}
|
||||
718
crates/components/src/heat_exchanger/two_phase_dp.rs
Normal file
718
crates/components/src/heat_exchanger/two_phase_dp.rs
Normal 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(B−A)x` at low x, then smooth to vapor-only at x→1 via
|
||||
/// `(dP/dz) = (A + 2(B−A)x)·(1−x)^(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 + (1−x)/ρ_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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user