Wire BPHX channel pressure drop on both sides with selectable correlations.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Replace isobaric 4-port closures with SimplifiedChannel (default) and Martin1996 DP models so z_dp and UI dp_correlation actually affect the Newton solve. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
//! ```
|
||||
|
||||
use super::bphx_correlation::{BphxCorrelation, CorrelationEvaluation};
|
||||
use super::bphx_dp::BphxDpCorrelation;
|
||||
use super::bphx_exchanger::BphxExchanger;
|
||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||
use super::correlation_registry::CorrelationSelectionError;
|
||||
@@ -150,6 +151,17 @@ impl BphxCondenser {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the channel frictional pressure-drop correlation (independent of HTC).
|
||||
pub fn with_dp_correlation(mut self, correlation: BphxDpCorrelation) -> Self {
|
||||
self.inner = self.inner.with_dp_correlation(correlation);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the configured pressure-drop correlation.
|
||||
pub fn dp_correlation(&self) -> BphxDpCorrelation {
|
||||
self.inner.dp_correlation()
|
||||
}
|
||||
|
||||
/// Sets the target subcooling in Kelvin.
|
||||
///
|
||||
/// # Panics
|
||||
|
||||
@@ -344,7 +344,11 @@ impl BphxCorrelation {
|
||||
CorrelationId::Cooper1984
|
||||
| CorrelationId::Mostinski1963
|
||||
| CorrelationId::Friedel1979
|
||||
| CorrelationId::MullerSteinhagenHeck1986 => None,
|
||||
| CorrelationId::MullerSteinhagenHeck1986
|
||||
| CorrelationId::SimplifiedChannel
|
||||
| CorrelationId::Martin1996
|
||||
| CorrelationId::AmalfiThome2016
|
||||
| CorrelationId::TaoInfanteFerreiraSurvey => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
422
crates/components/src/heat_exchanger/bphx_dp.rs
Normal file
422
crates/components/src/heat_exchanger/bphx_dp.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
//! BPHX channel pressure-drop correlations (friction), separate from HTC.
|
||||
//!
|
||||
//! Pressure-drop form (Fanning-like `f`, matching existing SimplifiedChannel):
|
||||
//! `ΔP = z_dp · 2 · f · L · G² / (ρ · d_h)`.
|
||||
//!
|
||||
//! Martin (1996/1999) returns a Fanning friction factor from the reciprocal-
|
||||
//! square-root chevron model (same intermediate `f` as `fluids` /
|
||||
//! `ht.friction_plate_Martin_1999` before the ×4 Darcy conversion).
|
||||
|
||||
use super::bphx_geometry::BphxGeometry;
|
||||
use crate::ComponentError;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Reference dynamic viscosity [Pa·s] used for Re in channel DP models.
|
||||
/// Matches the historical SimplifiedChannel implementation (Newton-friendly constant).
|
||||
pub const BPHX_DP_MU_REF_PA_S: f64 = 0.0002;
|
||||
|
||||
/// Selectable BPHX frictional pressure-drop correlation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum BphxDpCorrelation {
|
||||
/// Existing Blasius/laminar C¹ blend (default).
|
||||
#[default]
|
||||
SimplifiedChannel,
|
||||
/// Holger Martin chevron-plate single-phase friction (1996 / 1999 form).
|
||||
Martin1996,
|
||||
}
|
||||
|
||||
impl BphxDpCorrelation {
|
||||
/// Stable serialization name.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SimplifiedChannel => "SimplifiedChannel",
|
||||
Self::Martin1996 => "Martin1996",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse CLI/UI identifiers. Rejects unknown strings (no silent fallback).
|
||||
pub fn parse(name: &str) -> Result<Self, String> {
|
||||
let key = name.trim().to_ascii_lowercase().replace(['-', '_', ' '], "");
|
||||
match key.as_str() {
|
||||
"simplifiedchannel" | "simplified" | "channel" | "default" => {
|
||||
Ok(Self::SimplifiedChannel)
|
||||
}
|
||||
"martin1996" | "martin1999" | "martin" => Ok(Self::Martin1996),
|
||||
_ => Err(format!(
|
||||
"unsupported dp_correlation '{name}'. Use 'SimplifiedChannel' or 'Martin1996'."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluated channel pressure drop and ∂ΔP/∂G at fixed ρ and z_dp.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BphxDpEval {
|
||||
/// Pressure drop [Pa] (always ≥ 0 for the channel friction model).
|
||||
pub delta_p_pa: f64,
|
||||
/// ∂ΔP/∂G [Pa / (kg/(m²·s))].
|
||||
pub d_delta_p_d_g: f64,
|
||||
}
|
||||
|
||||
/// Fanning friction factor and ∂f/∂Re for the selected correlation.
|
||||
fn fanning_friction(
|
||||
corr: BphxDpCorrelation,
|
||||
re: f64,
|
||||
chevron_angle_deg: f64,
|
||||
) -> Result<(f64, f64), ComponentError> {
|
||||
match corr {
|
||||
BphxDpCorrelation::SimplifiedChannel => Ok(simplified_channel_fanning(re)),
|
||||
BphxDpCorrelation::Martin1996 => {
|
||||
validate_martin_chevron(chevron_angle_deg)?;
|
||||
Ok(martin1996_fanning(re, chevron_angle_deg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_martin_chevron(angle_deg: f64) -> Result<(), ComponentError> {
|
||||
if !angle_deg.is_finite() || !(0.0..90.0).contains(&angle_deg) {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"Martin1996 requires chevron_angle in (0, 90) degrees (got {angle_deg})"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SimplifiedChannel: laminar 64/Re, Blasius 0.079 Re^−0.25, C¹ blend on [2300, 4000].
|
||||
///
|
||||
/// Note: laminar uses the Darcy 64/Re coefficient inside the Fanning ΔP form —
|
||||
/// historical Entropyk convention; kept identical for continuity.
|
||||
fn simplified_channel_fanning(re: f64) -> (f64, f64) {
|
||||
const RE_LAMINAR: f64 = 2300.0;
|
||||
const RE_TURBULENT: f64 = 4000.0;
|
||||
let re_safe = re.max(1e-9);
|
||||
let f_lam = 64.0 / re_safe;
|
||||
let df_lam = -64.0 / (re_safe * re_safe);
|
||||
|
||||
if re <= RE_LAMINAR {
|
||||
return (f_lam, df_lam);
|
||||
}
|
||||
|
||||
let f_turb = 0.079 * re_safe.powf(-0.25);
|
||||
let df_turb = 0.079 * (-0.25) * re_safe.powf(-1.25);
|
||||
|
||||
if re >= RE_TURBULENT {
|
||||
return (f_turb, df_turb);
|
||||
}
|
||||
|
||||
let f = entropyk_core::smoothing::cubic_blend(
|
||||
f_lam,
|
||||
f_turb,
|
||||
re,
|
||||
RE_LAMINAR,
|
||||
RE_TURBULENT,
|
||||
);
|
||||
// d/dRe of blend(f_lam(Re), f_turb(Re), Re): product rule on cubic Hermite.
|
||||
let df_blend_dx = entropyk_core::smoothing::cubic_blend_derivative(
|
||||
f_lam,
|
||||
f_turb,
|
||||
re,
|
||||
RE_LAMINAR,
|
||||
RE_TURBULENT,
|
||||
);
|
||||
// cubic_blend treats endpoints as constants w.r.t x; add chain-rule terms for
|
||||
// f_lam(Re) and f_turb(Re) via the complementary weights.
|
||||
let t = ((re - RE_LAMINAR) / (RE_TURBULENT - RE_LAMINAR)).clamp(0.0, 1.0);
|
||||
let w_b = t * t * (3.0 - 2.0 * t); // weight on b (= f_turb)
|
||||
let w_a = 1.0 - w_b;
|
||||
let df = df_blend_dx + w_a * df_lam + w_b * df_turb;
|
||||
(f, df)
|
||||
}
|
||||
|
||||
/// Martin 1996/1999 Fanning friction factor vs Re and chevron angle φ [deg].
|
||||
///
|
||||
/// ```text
|
||||
/// 1/√f = cosφ / √(0.045 tanφ + 0.09 sinφ + f0/cosφ) + (1−cosφ) / √(3.8 f1)
|
||||
/// ```
|
||||
/// with piecewise `f0`, `f1` (Shah/Martin 1999 appendix form).
|
||||
/// Laminar/turbulent `f0`/`f1` branches are C¹-blended on Re ∈ [1800, 2200]
|
||||
/// (same Newton rationale as SimplifiedChannel’s [2300, 4000] blend).
|
||||
fn martin1996_fanning(re: f64, chevron_angle_deg: f64) -> (f64, f64) {
|
||||
const RE_LO: f64 = 1800.0;
|
||||
const RE_HI: f64 = 2200.0;
|
||||
let re_safe = re.max(1e-9);
|
||||
|
||||
let lam = |re_v: f64| -> (f64, f64, f64, f64) {
|
||||
let f0 = 16.0 / re_v;
|
||||
let df0 = -16.0 / (re_v * re_v);
|
||||
let f1 = 149.0 / re_v + 0.9625;
|
||||
let df1 = -149.0 / (re_v * re_v);
|
||||
(f0, df0, f1, df1)
|
||||
};
|
||||
let turb = |re_v: f64| -> (f64, f64, f64, f64) {
|
||||
let u = 1.56 * re_v.ln() - 3.0;
|
||||
let u_safe = if u.abs() < 1e-12 {
|
||||
if u >= 0.0 {
|
||||
1e-12
|
||||
} else {
|
||||
-1e-12
|
||||
}
|
||||
} else {
|
||||
u
|
||||
};
|
||||
let f0 = u_safe.powi(-2);
|
||||
let df0 = -2.0 * u_safe.powi(-3) * (1.56 / re_v);
|
||||
let f1 = 9.75 * re_v.powf(-0.289);
|
||||
let df1 = 9.75 * (-0.289) * re_v.powf(-1.289);
|
||||
(f0, df0, f1, df1)
|
||||
};
|
||||
|
||||
let (f0, df0, f1, df1) = if re_safe <= RE_LO {
|
||||
lam(re_safe)
|
||||
} else if re_safe >= RE_HI {
|
||||
turb(re_safe)
|
||||
} else {
|
||||
let (f0_l, df0_l, f1_l, df1_l) = lam(re_safe);
|
||||
let (f0_t, df0_t, f1_t, df1_t) = turb(re_safe);
|
||||
let f0 = entropyk_core::smoothing::cubic_blend(f0_l, f0_t, re_safe, RE_LO, RE_HI);
|
||||
let f1 = entropyk_core::smoothing::cubic_blend(f1_l, f1_t, re_safe, RE_LO, RE_HI);
|
||||
let t = ((re_safe - RE_LO) / (RE_HI - RE_LO)).clamp(0.0, 1.0);
|
||||
let w_b = t * t * (3.0 - 2.0 * t);
|
||||
let w_a = 1.0 - w_b;
|
||||
let df0_blend = entropyk_core::smoothing::cubic_blend_derivative(
|
||||
f0_l, f0_t, re_safe, RE_LO, RE_HI,
|
||||
);
|
||||
let df1_blend = entropyk_core::smoothing::cubic_blend_derivative(
|
||||
f1_l, f1_t, re_safe, RE_LO, RE_HI,
|
||||
);
|
||||
let df0 = df0_blend + w_a * df0_l + w_b * df0_t;
|
||||
let df1 = df1_blend + w_a * df1_l + w_b * df1_t;
|
||||
(f0, df0, f1, df1)
|
||||
};
|
||||
|
||||
let phi = chevron_angle_deg * PI / 180.0;
|
||||
let cos_p = phi.cos();
|
||||
let sin_p = phi.sin();
|
||||
let tan_p = phi.tan();
|
||||
// Guard grazing angles so /cosφ stays finite inside (0, 90).
|
||||
let cos_safe = cos_p.signum() * cos_p.abs().max(1e-12);
|
||||
|
||||
let a = 0.045 * tan_p + 0.09 * sin_p + f0 / cos_safe;
|
||||
let b = 3.8 * f1.max(1e-30);
|
||||
let a_safe = a.max(1e-30);
|
||||
|
||||
let term0 = cos_p / a_safe.sqrt();
|
||||
let term1 = (1.0 - cos_p) / b.sqrt();
|
||||
let rhs = term0 + term1;
|
||||
let rhs_safe = rhs.max(1e-30);
|
||||
let f = rhs_safe.powi(-2);
|
||||
|
||||
let da_dre = df0 / cos_safe;
|
||||
let db_dre = 3.8 * df1;
|
||||
let dterm0 = cos_p * (-0.5) * a_safe.powf(-1.5) * da_dre;
|
||||
let dterm1 = (1.0 - cos_p) * (-0.5) * b.powf(-1.5) * db_dre;
|
||||
let drhs = dterm0 + dterm1;
|
||||
let df = -2.0 * rhs_safe.powi(-3) * drhs;
|
||||
(f, df)
|
||||
}
|
||||
|
||||
/// Channel frictional ΔP and ∂ΔP/∂G for the given correlation.
|
||||
///
|
||||
/// `mass_flux` may be signed; Re uses `|G|`, ΔP uses `G²` (existing convention).
|
||||
pub fn evaluate_channel_pressure_drop(
|
||||
corr: BphxDpCorrelation,
|
||||
geometry: &BphxGeometry,
|
||||
mass_flux: f64,
|
||||
rho: f64,
|
||||
z_dp: f64,
|
||||
) -> Result<BphxDpEval, ComponentError> {
|
||||
if !mass_flux.is_finite() {
|
||||
return Ok(BphxDpEval {
|
||||
delta_p_pa: 0.0,
|
||||
d_delta_p_d_g: 0.0,
|
||||
});
|
||||
}
|
||||
if !rho.is_finite() || rho < 1e-10 || geometry.dh < 1e-10 {
|
||||
return Ok(BphxDpEval {
|
||||
delta_p_pa: 0.0,
|
||||
d_delta_p_d_g: 0.0,
|
||||
});
|
||||
}
|
||||
if !geometry.plate_length.is_finite() || geometry.plate_length <= 0.0 {
|
||||
return Ok(BphxDpEval {
|
||||
delta_p_pa: 0.0,
|
||||
d_delta_p_d_g: 0.0,
|
||||
});
|
||||
}
|
||||
if !z_dp.is_finite() || z_dp < 0.0 {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"BPHX z_dp must be finite and >= 0 (got {z_dp})"
|
||||
)));
|
||||
}
|
||||
|
||||
let g_abs = mass_flux.abs();
|
||||
let re = g_abs * geometry.dh / BPHX_DP_MU_REF_PA_S;
|
||||
let (f, df_dre) = fanning_friction(corr, re, geometry.chevron_angle)?;
|
||||
|
||||
// ΔP = z_dp · 2 f L G² / (ρ dh)
|
||||
let k = z_dp * 2.0 * geometry.plate_length / (rho * geometry.dh);
|
||||
let g2 = mass_flux * mass_flux;
|
||||
let delta_p_pa = k * f * g2;
|
||||
|
||||
// dRe/dG = sign(G) · dh/μ (0 at stagnation)
|
||||
let d_re_d_g = if g_abs < 1e-30 {
|
||||
0.0
|
||||
} else {
|
||||
mass_flux.signum() * geometry.dh / BPHX_DP_MU_REF_PA_S
|
||||
};
|
||||
// d(G²)/dG = 2 G
|
||||
let d_delta_p_d_g = k * (df_dre * d_re_d_g * g2 + f * 2.0 * mass_flux);
|
||||
|
||||
Ok(BphxDpEval {
|
||||
delta_p_pa,
|
||||
d_delta_p_d_g,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::heat_exchanger::BphxGeometry;
|
||||
|
||||
fn geo() -> BphxGeometry {
|
||||
BphxGeometry::from_dh_area(0.003, 0.5, 20)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_aliases_rejects_unknown() {
|
||||
assert_eq!(
|
||||
BphxDpCorrelation::parse("SimplifiedChannel").unwrap(),
|
||||
BphxDpCorrelation::SimplifiedChannel
|
||||
);
|
||||
assert_eq!(
|
||||
BphxDpCorrelation::parse("martin-1996").unwrap(),
|
||||
BphxDpCorrelation::Martin1996
|
||||
);
|
||||
assert!(BphxDpCorrelation::parse("Amalfi2016").is_err());
|
||||
assert!(BphxDpCorrelation::parse("friedel").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplified_matches_historical_scale() {
|
||||
let g = 30.0;
|
||||
let rho = 1100.0;
|
||||
let eval = evaluate_channel_pressure_drop(
|
||||
BphxDpCorrelation::SimplifiedChannel,
|
||||
&geo(),
|
||||
g,
|
||||
rho,
|
||||
1.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(eval.delta_p_pa > 0.0);
|
||||
assert!(eval.delta_p_pa.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn martin_differs_from_simplified_same_state() {
|
||||
let g = 80.0;
|
||||
let rho = 1000.0;
|
||||
let simp = evaluate_channel_pressure_drop(
|
||||
BphxDpCorrelation::SimplifiedChannel,
|
||||
&geo(),
|
||||
g,
|
||||
rho,
|
||||
1.0,
|
||||
)
|
||||
.unwrap();
|
||||
let martin = evaluate_channel_pressure_drop(
|
||||
BphxDpCorrelation::Martin1996,
|
||||
&geo(),
|
||||
g,
|
||||
rho,
|
||||
1.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(simp.delta_p_pa - martin.delta_p_pa).abs() > 1.0,
|
||||
"Martin and Simplified should differ: {} vs {}",
|
||||
simp.delta_p_pa,
|
||||
martin.delta_p_pa
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn z_dp_scales_linearly() {
|
||||
let g = 40.0;
|
||||
let rho = 1100.0;
|
||||
let a = evaluate_channel_pressure_drop(
|
||||
BphxDpCorrelation::SimplifiedChannel,
|
||||
&geo(),
|
||||
g,
|
||||
rho,
|
||||
1.0,
|
||||
)
|
||||
.unwrap();
|
||||
let b = evaluate_channel_pressure_drop(
|
||||
BphxDpCorrelation::SimplifiedChannel,
|
||||
&geo(),
|
||||
g,
|
||||
rho,
|
||||
0.5,
|
||||
)
|
||||
.unwrap();
|
||||
assert!((b.delta_p_pa - 0.5 * a.delta_p_pa).abs() < 1e-9 * a.delta_p_pa.max(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_flow_smooth() {
|
||||
let eval = evaluate_channel_pressure_drop(
|
||||
BphxDpCorrelation::SimplifiedChannel,
|
||||
&geo(),
|
||||
0.0,
|
||||
1100.0,
|
||||
1.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(eval.delta_p_pa, 0.0);
|
||||
assert!(eval.d_delta_p_d_g.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn martin_rejects_invalid_chevron() {
|
||||
let mut g = geo();
|
||||
g.chevron_angle = f64::NAN;
|
||||
let err = evaluate_channel_pressure_drop(
|
||||
BphxDpCorrelation::Martin1996,
|
||||
&g,
|
||||
30.0,
|
||||
1000.0,
|
||||
1.0,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("chevron"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_d_dg_matches_central_difference() {
|
||||
let geo = geo();
|
||||
let g0 = 55.0;
|
||||
let rho = 1050.0;
|
||||
for corr in [
|
||||
BphxDpCorrelation::SimplifiedChannel,
|
||||
BphxDpCorrelation::Martin1996,
|
||||
] {
|
||||
let eval = evaluate_channel_pressure_drop(corr, &geo, g0, rho, 1.0).unwrap();
|
||||
let h = 1e-4;
|
||||
let plus =
|
||||
evaluate_channel_pressure_drop(corr, &geo, g0 + h, rho, 1.0).unwrap();
|
||||
let minus =
|
||||
evaluate_channel_pressure_drop(corr, &geo, g0 - h, rho, 1.0).unwrap();
|
||||
let fd = (plus.delta_p_pa - minus.delta_p_pa) / (2.0 * h);
|
||||
let rel = (eval.d_delta_p_d_g - fd).abs() / fd.abs().max(1.0);
|
||||
assert!(
|
||||
rel < 1e-4,
|
||||
"{:?}: analytic {} vs fd {} (rel {})",
|
||||
corr,
|
||||
eval.d_delta_p_d_g,
|
||||
fd,
|
||||
rel
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
//! ```
|
||||
|
||||
use super::bphx_correlation::{BphxCorrelation, CorrelationEvaluation};
|
||||
use super::bphx_dp::BphxDpCorrelation;
|
||||
use super::bphx_exchanger::BphxExchanger;
|
||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||
use super::correlation_registry::CorrelationSelectionError;
|
||||
@@ -154,6 +155,17 @@ impl BphxEvaporator {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the channel frictional pressure-drop correlation (independent of HTC).
|
||||
pub fn with_dp_correlation(mut self, correlation: BphxDpCorrelation) -> Self {
|
||||
self.inner = self.inner.with_dp_correlation(correlation);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the configured pressure-drop correlation.
|
||||
pub fn dp_correlation(&self) -> BphxDpCorrelation {
|
||||
self.inner.dp_correlation()
|
||||
}
|
||||
|
||||
/// Returns the component name.
|
||||
pub fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
|
||||
@@ -33,6 +33,7 @@ use super::bphx_correlation::{
|
||||
BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
|
||||
CorrelationSelector, ValidityStatus,
|
||||
};
|
||||
use super::bphx_dp::{evaluate_channel_pressure_drop, BphxDpCorrelation, BphxDpEval};
|
||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||
use super::correlation_registry::{
|
||||
CorrelationSelectionError, ExchangerGeometryType, FlowRegime, SelectionOutcome,
|
||||
@@ -55,6 +56,7 @@ pub struct BphxExchanger {
|
||||
inner: HeatExchanger<EpsNtuModel>,
|
||||
geometry: BphxGeometry,
|
||||
correlation_selector: CorrelationSelector,
|
||||
dp_correlation: BphxDpCorrelation,
|
||||
refrigerant_id: String,
|
||||
secondary_fluid_id: String,
|
||||
fluid_backend: Option<Arc<dyn entropyk_fluids::FluidBackend>>,
|
||||
@@ -70,6 +72,7 @@ impl std::fmt::Debug for BphxExchanger {
|
||||
.field("ua", &self.ua())
|
||||
.field("geometry", &self.geometry)
|
||||
.field("correlation", &self.correlation_selector.correlation)
|
||||
.field("dp_correlation", &self.dp_correlation)
|
||||
.field("refrigerant_id", &self.refrigerant_id)
|
||||
.field("secondary_fluid_id", &self.secondary_fluid_id)
|
||||
.field("has_fluid_backend", &self.fluid_backend.is_some())
|
||||
@@ -108,6 +111,7 @@ impl BphxExchanger {
|
||||
inner: HeatExchanger::new(model, "BphxExchanger"),
|
||||
geometry,
|
||||
correlation_selector: CorrelationSelector::default(),
|
||||
dp_correlation: BphxDpCorrelation::default(),
|
||||
refrigerant_id: String::new(),
|
||||
secondary_fluid_id: String::new(),
|
||||
fluid_backend: None,
|
||||
@@ -125,6 +129,7 @@ impl BphxExchanger {
|
||||
inner: HeatExchanger::new(model, "BphxExchanger"),
|
||||
geometry,
|
||||
correlation_selector: CorrelationSelector::default(),
|
||||
dp_correlation: BphxDpCorrelation::default(),
|
||||
refrigerant_id: String::new(),
|
||||
secondary_fluid_id: String::new(),
|
||||
fluid_backend: None,
|
||||
@@ -151,6 +156,17 @@ impl BphxExchanger {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the channel frictional pressure-drop correlation (independent of HTC).
|
||||
pub fn with_dp_correlation(mut self, correlation: BphxDpCorrelation) -> Self {
|
||||
self.dp_correlation = correlation;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the configured pressure-drop correlation.
|
||||
pub fn dp_correlation(&self) -> BphxDpCorrelation {
|
||||
self.dp_correlation
|
||||
}
|
||||
|
||||
/// Sets the refrigerant fluid identifier.
|
||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.set_refrigerant_id(fluid);
|
||||
@@ -338,12 +354,10 @@ impl BphxExchanger {
|
||||
self.last_validity_warning.set(false);
|
||||
}
|
||||
|
||||
/// Computes the pressure drop using a simplified correlation.
|
||||
/// Computes the channel frictional pressure drop for the configured correlation.
|
||||
///
|
||||
/// ΔP = f_dp × (2 × f × L × G²) / (ρ × d_h)
|
||||
///
|
||||
/// where f is the friction factor, L is the plate length, G is mass flux.
|
||||
/// The result is scaled by `calib().z_dp`.
|
||||
/// ΔP = z_dp × (2 × f × L × G²) / (ρ × d_h) with Fanning-like `f`
|
||||
/// (SimplifiedChannel default; Martin1996 when selected).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -352,46 +366,158 @@ impl BphxExchanger {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Pressure drop in Pa, scaled by f_dp calibration factor.
|
||||
/// Pressure drop in Pa, scaled by `calib().z_dp`. Returns 0 on invalid inputs
|
||||
/// or (for Martin) invalid chevron — callers that need errors should use
|
||||
/// [`evaluate_channel_pressure_drop`].
|
||||
pub fn compute_pressure_drop(&self, mass_flux: f64, rho: f64) -> f64 {
|
||||
if rho < 1e-10 || self.geometry.dh < 1e-10 {
|
||||
return 0.0;
|
||||
evaluate_channel_pressure_drop(
|
||||
self.dp_correlation,
|
||||
&self.geometry,
|
||||
mass_flux,
|
||||
rho,
|
||||
self.calib().z_dp,
|
||||
)
|
||||
.map(|e| e.delta_p_pa)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Live `z_dp`: free actuator from state when indexed, else calib value.
|
||||
fn live_z_dp(&self, state: &StateSlice) -> f64 {
|
||||
self.inner
|
||||
.calib_indices_ref()
|
||||
.z_dp
|
||||
.and_then(|idx| state.get(idx).copied())
|
||||
.unwrap_or_else(|| self.calib().z_dp)
|
||||
}
|
||||
|
||||
fn side_channel_dp(
|
||||
&self,
|
||||
mass_flow: f64,
|
||||
rho: f64,
|
||||
z_dp: f64,
|
||||
) -> Result<BphxDpEval, ComponentError> {
|
||||
let g = self.geometry.mass_flux(mass_flow);
|
||||
evaluate_channel_pressure_drop(self.dp_correlation, &self.geometry, g, rho, z_dp)
|
||||
}
|
||||
|
||||
/// Overwrites the two 4-port pressure rows with `P_out − P_in + ΔP = 0`.
|
||||
fn overwrite_pressure_closures_with_dp(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let Some(edges) = self.inner.four_port_edges() else {
|
||||
return Ok(());
|
||||
};
|
||||
let row0 = self.inner.model_n_equations();
|
||||
if residuals.len() < row0 + 2 {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: row0 + 2,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let re = mass_flux.abs() * self.geometry.dh / 0.0002;
|
||||
// C¹ laminar→turbulent blend over [2300, 4000] (same rationale as the
|
||||
// pipe friction factor and the Gnielinski correlation): a hard switch at
|
||||
// Re = 2300 put a kink in the pressure-drop residual that the analytic
|
||||
// Newton Jacobian could not see, hurting convergence near the transition.
|
||||
// Reynolds uses |mass_flux| so transient reverse flow during iteration
|
||||
// yields a physical friction factor instead of the flat `64` the old
|
||||
// signed/`max(1.0)` form produced. The lower clamp only guards the exact
|
||||
// div-by-zero at stagnation; at 1e-9 it is far below any reachable Re, so
|
||||
// it introduces no visible kink (the previous `max(1.0)` kinked at Re = 1).
|
||||
const RE_LAMINAR: f64 = 2300.0;
|
||||
const RE_TURBULENT: f64 = 4000.0;
|
||||
let f_laminar = 64.0 / re.max(1e-9);
|
||||
let f = if re <= RE_LAMINAR {
|
||||
f_laminar
|
||||
} else {
|
||||
let f_turbulent = 0.079 * re.powf(-0.25);
|
||||
if re >= RE_TURBULENT {
|
||||
f_turbulent
|
||||
} else {
|
||||
entropyk_core::smoothing::cubic_blend(
|
||||
f_laminar,
|
||||
f_turbulent,
|
||||
re,
|
||||
RE_LAMINAR,
|
||||
RE_TURBULENT,
|
||||
)
|
||||
}
|
||||
let z_dp = self.live_z_dp(state);
|
||||
let (m_h, p_h_in, h_h_in) = edges.hot_in;
|
||||
let (_, p_h_out, _) = edges.hot_out;
|
||||
let (m_c, p_c_in, h_c_in) = edges.cold_in;
|
||||
let (_, p_c_out, _) = edges.cold_out;
|
||||
|
||||
let max_idx = [m_h, p_h_in, h_h_in, p_h_out, m_c, p_c_in, h_c_in, p_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 rho_hot = self.inner.side_density(
|
||||
"hot",
|
||||
self.inner.hot_fluid_id_str(),
|
||||
state[p_h_in],
|
||||
state[h_h_in],
|
||||
)?;
|
||||
let rho_cold = self.inner.side_density(
|
||||
"cold",
|
||||
self.inner.cold_fluid_id_str(),
|
||||
state[p_c_in],
|
||||
state[h_c_in],
|
||||
)?;
|
||||
|
||||
let dp_hot = self.side_channel_dp(state[m_h], rho_hot, z_dp)?;
|
||||
let dp_cold = self.side_channel_dp(state[m_c], rho_cold, z_dp)?;
|
||||
|
||||
residuals[row0] = state[p_h_out] - state[p_h_in] + dp_hot.delta_p_pa;
|
||||
residuals[row0 + 1] = state[p_c_out] - state[p_c_in] + dp_cold.delta_p_pa;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds analytic ∂r/∂ṁ and ∂r/∂z_dp for BPHX pressure rows (P entries already set).
|
||||
fn append_pressure_dp_jacobian(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
let Some(edges) = self.inner.four_port_edges() else {
|
||||
return Ok(());
|
||||
};
|
||||
let row0 = self.inner.model_n_equations();
|
||||
let z_dp = self.live_z_dp(state);
|
||||
let (m_h, p_h_in, h_h_in) = edges.hot_in;
|
||||
let (_, p_h_out, _) = edges.hot_out;
|
||||
let (m_c, p_c_in, h_c_in) = edges.cold_in;
|
||||
let (_, p_c_out, _) = edges.cold_out;
|
||||
|
||||
let dp_base =
|
||||
2.0 * f * self.geometry.plate_length * mass_flux.powi(2) / (rho * self.geometry.dh);
|
||||
let max_idx = [m_h, p_h_in, h_h_in, p_h_out, m_c, p_c_in, h_c_in, p_c_out]
|
||||
.into_iter()
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if max_idx >= state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: max_idx + 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
dp_base * self.calib().z_dp
|
||||
let rho_hot = self.inner.side_density(
|
||||
"hot",
|
||||
self.inner.hot_fluid_id_str(),
|
||||
state[p_h_in],
|
||||
state[h_h_in],
|
||||
)?;
|
||||
let rho_cold = self.inner.side_density(
|
||||
"cold",
|
||||
self.inner.cold_fluid_id_str(),
|
||||
state[p_c_in],
|
||||
state[h_c_in],
|
||||
)?;
|
||||
|
||||
let dp_hot = self.side_channel_dp(state[m_h], rho_hot, z_dp)?;
|
||||
let dp_cold = self.side_channel_dp(state[m_c], rho_cold, z_dp)?;
|
||||
|
||||
let a_flow = self.geometry.channel_flow_area() * self.geometry.n_channels() as f64;
|
||||
if a_flow <= 1e-30 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BPHX channel flow area too small for pressure-drop Jacobian".into(),
|
||||
));
|
||||
}
|
||||
let d_g_d_m = 1.0 / a_flow;
|
||||
|
||||
// r = P_out − P_in + ΔP(G(ṁ), z_dp) ⇒ ∂r/∂ṁ = ∂ΔP/∂G · ∂G/∂ṁ
|
||||
jacobian.add_entry(row0, m_h, dp_hot.d_delta_p_d_g * d_g_d_m);
|
||||
jacobian.add_entry(row0 + 1, m_c, dp_cold.d_delta_p_d_g * d_g_d_m);
|
||||
|
||||
if let Some(z_idx) = self.inner.calib_indices_ref().z_dp {
|
||||
// ΔP = z_dp · ΔP_corr ⇒ ∂r/∂z_dp = ΔP_corr = ΔP / z_dp
|
||||
// Keep the column even near z_dp→0 so a free actuator stays visible to Newton.
|
||||
let z_safe = z_dp.max(1e-30);
|
||||
jacobian.add_entry(row0, z_idx, dp_hot.delta_p_pa / z_safe);
|
||||
jacobian.add_entry(row0 + 1, z_idx, dp_cold.delta_p_pa / z_safe);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates UA based on computed HTC.
|
||||
@@ -413,7 +539,9 @@ impl Component for BphxExchanger {
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.compute_residuals(state, residuals)
|
||||
self.inner.compute_residuals(state, residuals)?;
|
||||
// Replace isobaric P_out − P_in with channel ΔP on both sides.
|
||||
self.overwrite_pressure_closures_with_dp(state, residuals)
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
@@ -421,7 +549,9 @@ impl Component for BphxExchanger {
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.jacobian_entries(state, jacobian)
|
||||
self.inner.jacobian_entries(state, jacobian)?;
|
||||
// Inner already wrote ∂r/∂P_out=+1, ∂r/∂P_in=−1; add ṁ and z_dp terms.
|
||||
self.append_pressure_dp_jacobian(state, jacobian)
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
@@ -844,4 +974,131 @@ mod tests {
|
||||
"ΔP slope kink near Re = 1 ({s_below:.3e} vs {s_above:.3e})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_both_sides_pressure_residuals_include_dp() {
|
||||
use entropyk_fluids::TestBackend;
|
||||
use std::sync::Arc;
|
||||
|
||||
// State layout per port triple (ṁ, P, h):
|
||||
// hot_in 0..3, hot_out 3..6, cold_in 6..9, cold_out 9..12
|
||||
let mut hx = BphxExchanger::new(test_geometry())
|
||||
.with_fluid_backend(Arc::new(TestBackend::new()));
|
||||
hx.set_hot_fluid("Water");
|
||||
hx.set_cold_fluid("Water");
|
||||
hx.set_port_context(&[
|
||||
Some((0, 1, 2)),
|
||||
Some((3, 4, 5)),
|
||||
Some((6, 7, 8)),
|
||||
Some((9, 10, 11)),
|
||||
]);
|
||||
|
||||
let mut state = vec![0.0; 12];
|
||||
// Liquid water-ish P-h (TestBackend): ~2 bar, ~80 kJ/kg
|
||||
state[0] = 0.25; // m_hot
|
||||
state[1] = 200_000.0;
|
||||
state[2] = 80_000.0;
|
||||
state[3] = 0.25;
|
||||
state[4] = 200_000.0; // isobaric guess — residual should show +ΔP
|
||||
state[5] = 70_000.0;
|
||||
state[6] = 0.20; // m_cold
|
||||
state[7] = 200_000.0;
|
||||
state[8] = 50_000.0;
|
||||
state[9] = 0.20;
|
||||
state[10] = 200_000.0;
|
||||
state[11] = 60_000.0;
|
||||
|
||||
let n = hx.n_equations();
|
||||
assert!(n >= 4, "4-port BPHX should include two pressure rows");
|
||||
let mut residuals = vec![0.0; n];
|
||||
hx.compute_residuals(&state, &mut residuals).unwrap();
|
||||
|
||||
let row_p = hx.inner.model_n_equations();
|
||||
assert!(
|
||||
residuals[row_p].abs() > 1.0,
|
||||
"hot pressure residual should include ΔP, got {}",
|
||||
residuals[row_p]
|
||||
);
|
||||
assert!(
|
||||
residuals[row_p + 1].abs() > 1.0,
|
||||
"cold pressure residual should include ΔP, got {}",
|
||||
residuals[row_p + 1]
|
||||
);
|
||||
|
||||
// z_dp scale: half calib → half residual contribution at same P_out=P_in
|
||||
let mut hx_half = BphxExchanger::new(test_geometry())
|
||||
.with_fluid_backend(Arc::new(TestBackend::new()));
|
||||
hx_half.set_hot_fluid("Water");
|
||||
hx_half.set_cold_fluid("Water");
|
||||
hx_half.set_port_context(&[
|
||||
Some((0, 1, 2)),
|
||||
Some((3, 4, 5)),
|
||||
Some((6, 7, 8)),
|
||||
Some((9, 10, 11)),
|
||||
]);
|
||||
let mut calib = Calib::default();
|
||||
calib.z_dp = 0.5;
|
||||
hx_half.set_calib(calib);
|
||||
let mut r_half = vec![0.0; n];
|
||||
hx_half.compute_residuals(&state, &mut r_half).unwrap();
|
||||
assert!(
|
||||
(r_half[row_p] - 0.5 * residuals[row_p]).abs() < 1e-6 * residuals[row_p].abs().max(1.0),
|
||||
"z_dp should scale hot ΔP residual"
|
||||
);
|
||||
assert!(
|
||||
(r_half[row_p + 1] - 0.5 * residuals[row_p + 1]).abs()
|
||||
< 1e-6 * residuals[row_p + 1].abs().max(1.0),
|
||||
"z_dp should scale cold ΔP residual"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_martin_dp_in_residuals_differs() {
|
||||
use entropyk_fluids::TestBackend;
|
||||
use std::sync::Arc;
|
||||
|
||||
let ports = [
|
||||
Some((0, 1, 2)),
|
||||
Some((3, 4, 5)),
|
||||
Some((6, 7, 8)),
|
||||
Some((9, 10, 11)),
|
||||
];
|
||||
let mut state = vec![0.0; 12];
|
||||
state[0] = 0.30;
|
||||
state[1] = 200_000.0;
|
||||
state[2] = 80_000.0;
|
||||
state[3] = 0.30;
|
||||
state[4] = 200_000.0;
|
||||
state[5] = 70_000.0;
|
||||
state[6] = 0.30;
|
||||
state[7] = 200_000.0;
|
||||
state[8] = 50_000.0;
|
||||
state[9] = 0.30;
|
||||
state[10] = 200_000.0;
|
||||
state[11] = 60_000.0;
|
||||
|
||||
let mut simp = BphxExchanger::new(test_geometry())
|
||||
.with_fluid_backend(Arc::new(TestBackend::new()));
|
||||
simp.set_hot_fluid("Water");
|
||||
simp.set_cold_fluid("Water");
|
||||
simp.set_port_context(&ports);
|
||||
|
||||
let mut martin = BphxExchanger::new(test_geometry())
|
||||
.with_dp_correlation(BphxDpCorrelation::Martin1996)
|
||||
.with_fluid_backend(Arc::new(TestBackend::new()));
|
||||
martin.set_hot_fluid("Water");
|
||||
martin.set_cold_fluid("Water");
|
||||
martin.set_port_context(&ports);
|
||||
|
||||
let n = simp.n_equations();
|
||||
let mut r_s = vec![0.0; n];
|
||||
let mut r_m = vec![0.0; n];
|
||||
simp.compute_residuals(&state, &mut r_s).unwrap();
|
||||
martin.compute_residuals(&state, &mut r_m).unwrap();
|
||||
let row = simp.inner.model_n_equations();
|
||||
assert!(
|
||||
(r_s[row] - r_m[row]).abs() > 1.0,
|
||||
"Martin residual should differ from Simplified"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,14 @@ pub enum CorrelationId {
|
||||
Friedel1979,
|
||||
/// Müller-Steinhagen-Heck (1986) two-phase pressure-drop correlation.
|
||||
MullerSteinhagenHeck1986,
|
||||
/// BPHX SimplifiedChannel single-phase channel friction (default ΔP).
|
||||
SimplifiedChannel,
|
||||
/// Martin (1996/1999) chevron-plate single-phase friction.
|
||||
Martin1996,
|
||||
/// Amalfi–Thome (2016) boiling plate ΔP — metadata only (not implemented).
|
||||
AmalfiThome2016,
|
||||
/// Tao & Infante Ferreira condensation-in-BPHE DP survey — metadata only.
|
||||
TaoInfanteFerreiraSurvey,
|
||||
}
|
||||
|
||||
impl CorrelationId {
|
||||
@@ -58,6 +66,10 @@ impl CorrelationId {
|
||||
Self::Mostinski1963 => "mostinski-1963",
|
||||
Self::Friedel1979 => "friedel-1979",
|
||||
Self::MullerSteinhagenHeck1986 => "muller-steinhagen-heck-1986",
|
||||
Self::SimplifiedChannel => "simplified-channel",
|
||||
Self::Martin1996 => "martin-1996",
|
||||
Self::AmalfiThome2016 => "amalfi-thome-2016",
|
||||
Self::TaoInfanteFerreiraSurvey => "tao-infante-ferreira-survey",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,6 +349,28 @@ const POOL_BOILING_GEOMETRIES: &[ExchangerGeometryType] = &[
|
||||
ExchangerGeometryType::FinnedTube,
|
||||
];
|
||||
const DP_TUBE_GEOMETRIES: &[ExchangerGeometryType] = &[ExchangerGeometryType::SmoothTube];
|
||||
const BPHX_DP_BOUNDS: &[BoundedQuantity] = &[
|
||||
bound(
|
||||
BoundedQuantityKind::Reynolds,
|
||||
1.0,
|
||||
100_000.0,
|
||||
SINGLE_PHASE_REGIMES,
|
||||
),
|
||||
bound(
|
||||
BoundedQuantityKind::MassFlux,
|
||||
1.0,
|
||||
2000.0,
|
||||
SINGLE_PHASE_REGIMES,
|
||||
),
|
||||
];
|
||||
const AMALFI_BOUNDS: &[BoundedQuantity] = &[
|
||||
bound(BoundedQuantityKind::MassFlux, 5.0, 600.0, EVAPORATION),
|
||||
bound(BoundedQuantityKind::Quality, 0.0, 1.0, EVAPORATION),
|
||||
];
|
||||
const TAO_BOUNDS: &[BoundedQuantity] = &[
|
||||
bound(BoundedQuantityKind::MassFlux, 5.0, 600.0, CONDENSATION),
|
||||
bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
|
||||
];
|
||||
|
||||
const fn bound(
|
||||
kind: BoundedQuantityKind,
|
||||
@@ -481,6 +515,42 @@ pub fn correlation_metadata(id: CorrelationId) -> CorrelationMetadata {
|
||||
RefrigerantApplicability::EvidenceIncomplete,
|
||||
"Muller-Steinhagen, H., Heck, K. (1986). Chem. Eng. Process. 20, 297-308",
|
||||
),
|
||||
CorrelationId::SimplifiedChannel => (
|
||||
"BPHX SimplifiedChannel (default)",
|
||||
CorrelationPurpose::PressureDrop,
|
||||
PLATES,
|
||||
SINGLE_PHASE_REGIMES,
|
||||
BPHX_DP_BOUNDS,
|
||||
RefrigerantApplicability::FluidAgnostic,
|
||||
"Entropyk channel Blasius/laminar C¹ blend; ΔP = z_dp·2 f L G²/(ρ dh)",
|
||||
),
|
||||
CorrelationId::Martin1996 => (
|
||||
"Martin (1996/1999) chevron plate friction",
|
||||
CorrelationPurpose::PressureDrop,
|
||||
PLATES,
|
||||
SINGLE_PHASE_REGIMES,
|
||||
BPHX_DP_BOUNDS,
|
||||
RefrigerantApplicability::FluidAgnostic,
|
||||
"Martin, H. (1996). Chem. Eng. Process. 35:301–310; Martin 1999 / Shah appendix form",
|
||||
),
|
||||
CorrelationId::AmalfiThome2016 => (
|
||||
"Amalfi–Thome (2016) boiling plate ΔP [not implemented]",
|
||||
CorrelationPurpose::PressureDrop,
|
||||
PLATES,
|
||||
EVAPORATION,
|
||||
AMALFI_BOUNDS,
|
||||
RefrigerantApplicability::EvidenceIncomplete,
|
||||
"Amalfi, Vakili-Farahani, Thome (2016). Int. J. Refrigeration — Parts 1–2; deferred",
|
||||
),
|
||||
CorrelationId::TaoInfanteFerreiraSurvey => (
|
||||
"Tao & Infante Ferreira condensation BPHE DP survey [not implemented]",
|
||||
CorrelationPurpose::PressureDrop,
|
||||
PLATES,
|
||||
CONDENSATION,
|
||||
TAO_BOUNDS,
|
||||
RefrigerantApplicability::EvidenceIncomplete,
|
||||
"Tao & Infante Ferreira — condensation-in-BPHE pressure-drop survey; deferred",
|
||||
),
|
||||
};
|
||||
CorrelationMetadata {
|
||||
id,
|
||||
@@ -516,6 +586,10 @@ pub fn registered_correlations() -> Vec<CorrelationMetadata> {
|
||||
CorrelationId::Mostinski1963,
|
||||
CorrelationId::Friedel1979,
|
||||
CorrelationId::MullerSteinhagenHeck1986,
|
||||
CorrelationId::SimplifiedChannel,
|
||||
CorrelationId::Martin1996,
|
||||
CorrelationId::AmalfiThome2016,
|
||||
CorrelationId::TaoInfanteFerreiraSurvey,
|
||||
]
|
||||
.into_iter()
|
||||
.map(correlation_metadata)
|
||||
@@ -1369,16 +1443,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn registry_does_not_claim_unverified_fluid_agnostic_coverage() {
|
||||
assert!(registered_correlations().iter().all(|metadata| matches!(
|
||||
metadata.domain.refrigerants,
|
||||
RefrigerantApplicability::EvidenceIncomplete
|
||||
)));
|
||||
// Geometry-based single-phase BPHX channel DP may declare FluidAgnostic;
|
||||
// HTC / two-phase entries must stay EvidenceIncomplete until verified.
|
||||
assert!(registered_correlations().iter().all(|metadata| {
|
||||
matches!(
|
||||
metadata.domain.refrigerants,
|
||||
RefrigerantApplicability::EvidenceIncomplete
|
||||
) || matches!(
|
||||
metadata.id,
|
||||
CorrelationId::SimplifiedChannel | CorrelationId::Martin1996
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn central_registry_contains_heat_transfer_and_pressure_drop_entries() {
|
||||
let registered = registered_correlations();
|
||||
assert_eq!(registered.len(), 14);
|
||||
assert_eq!(registered.len(), 18);
|
||||
assert!(registered
|
||||
.iter()
|
||||
.any(|entry| entry.id == CorrelationId::Friedel1979
|
||||
@@ -1387,6 +1468,18 @@ mod tests {
|
||||
entry.id == CorrelationId::MullerSteinhagenHeck1986
|
||||
&& entry.domain.purpose == CorrelationPurpose::PressureDrop
|
||||
}));
|
||||
assert!(registered.iter().any(|entry| {
|
||||
entry.id == CorrelationId::SimplifiedChannel
|
||||
&& entry.domain.purpose == CorrelationPurpose::PressureDrop
|
||||
}));
|
||||
assert!(registered.iter().any(|entry| {
|
||||
entry.id == CorrelationId::Martin1996
|
||||
&& entry.domain.purpose == CorrelationPurpose::PressureDrop
|
||||
}));
|
||||
assert!(registered.iter().any(|entry| {
|
||||
entry.id == CorrelationId::AmalfiThome2016
|
||||
&& entry.domain.purpose == CorrelationPurpose::PressureDrop
|
||||
}));
|
||||
assert!(registered
|
||||
.iter()
|
||||
.any(|entry| entry.id == CorrelationId::Cooper1984));
|
||||
|
||||
@@ -163,6 +163,15 @@ impl HxSideConditions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Live four-port edge indices: each triple is `(ṁ_idx, P_idx, h_idx)`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct FourPortEdgeIndices {
|
||||
pub hot_in: (usize, usize, usize),
|
||||
pub hot_out: (usize, usize, usize),
|
||||
pub cold_in: (usize, usize, usize),
|
||||
pub cold_out: (usize, usize, usize),
|
||||
}
|
||||
|
||||
/// Generic heat exchanger component with 4 ports.
|
||||
///
|
||||
/// Uses the Strategy Pattern for heat transfer calculations via the
|
||||
@@ -516,7 +525,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
||||
}
|
||||
|
||||
/// `true` when all 4 edges are wired (Modelica-style 4-port mode).
|
||||
fn edges_ready(&self) -> bool {
|
||||
pub(crate) fn edges_ready(&self) -> bool {
|
||||
self.hot_in_idx.is_some()
|
||||
&& self.hot_out_idx.is_some()
|
||||
&& self.cold_in_idx.is_some()
|
||||
@@ -525,6 +534,60 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
||||
&& !self.cold_fluid_id_str.is_empty()
|
||||
}
|
||||
|
||||
/// Live four-port edge index triples `(ṁ, P, h)` when wired.
|
||||
pub(crate) fn four_port_edges(&self) -> Option<FourPortEdgeIndices> {
|
||||
if !self.edges_ready() {
|
||||
return None;
|
||||
}
|
||||
Some(FourPortEdgeIndices {
|
||||
hot_in: self.hot_in_idx?,
|
||||
hot_out: self.hot_out_idx?,
|
||||
cold_in: self.cold_in_idx?,
|
||||
cold_out: self.cold_out_idx?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Thermal-model equation count (excludes the two pressure-closure rows).
|
||||
pub(crate) fn model_n_equations(&self) -> usize {
|
||||
self.model.n_equations()
|
||||
}
|
||||
|
||||
/// Calibration state indices (e.g. free `z_dp` actuator).
|
||||
pub(crate) fn calib_indices_ref(&self) -> &entropyk_core::CalibIndices {
|
||||
&self.calib_indices
|
||||
}
|
||||
|
||||
/// Hot-side fluid id used for live property queries.
|
||||
pub(crate) fn hot_fluid_id_str(&self) -> &str {
|
||||
&self.hot_fluid_id_str
|
||||
}
|
||||
|
||||
/// Cold-side fluid id used for live property queries.
|
||||
pub(crate) fn cold_fluid_id_str(&self) -> &str {
|
||||
&self.cold_fluid_id_str
|
||||
}
|
||||
|
||||
/// Inlet density [kg/m³] from the attached fluid backend at `(P, h)`.
|
||||
pub(crate) fn side_density(
|
||||
&self,
|
||||
side: &str,
|
||||
fluid_id: &str,
|
||||
p_pa: f64,
|
||||
h_jkg: f64,
|
||||
) -> Result<f64, ComponentError> {
|
||||
self.query_live_property(side, fluid_id, Property::Density, p_pa, h_jkg)
|
||||
.and_then(|rho| {
|
||||
if rho.is_finite() && rho > 1e-10 {
|
||||
Ok(rho)
|
||||
} else {
|
||||
Err(ComponentError::DomainViolation(DomainViolation {
|
||||
component: Some(self.name.clone()),
|
||||
detail: format!("{} {}-side density is invalid: {}", self.name, side, rho),
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
pub mod air_cooled_condenser;
|
||||
pub mod bphx_condenser;
|
||||
pub mod bphx_correlation;
|
||||
pub mod bphx_dp;
|
||||
pub mod bphx_evaporator;
|
||||
pub mod bphx_exchanger;
|
||||
pub mod bphx_geometry;
|
||||
@@ -83,6 +84,7 @@ pub use bphx_correlation::{
|
||||
BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
|
||||
CorrelationSelector, ValidityStatus,
|
||||
};
|
||||
pub use bphx_dp::{evaluate_channel_pressure_drop, BphxDpCorrelation, BphxDpEval};
|
||||
pub use bphx_evaporator::BphxEvaporator;
|
||||
pub use bphx_exchanger::BphxExchanger;
|
||||
pub use bphx_geometry::{BphxGeometry, BphxGeometryBuilder, BphxGeometryError, BphxType};
|
||||
|
||||
@@ -132,11 +132,11 @@ pub use free_cooling_exchanger::{
|
||||
};
|
||||
pub use heat_exchanger::model::FluidState;
|
||||
pub use heat_exchanger::{
|
||||
AirCooledCondenser, CoilGeometry, Condenser, CondenserCoil, CondenserRating, Economizer,
|
||||
EpsNtuModel, Evaporator, EvaporatorCoil, EvaporatorRating, ExchangerType, FanCoilUnit,
|
||||
FinCoilCondenser, FinType, FloodedCondenser, FloodedEvaporator, FloodedPoolBoilingConfig,
|
||||
FlowConfiguration, GasCooler, HeatExchanger, HeatExchangerBuilder, HeatTransferModel,
|
||||
HxSideConditions, LmtdModel, MchxCondenserCoil, ShellAndTubeHx, UaMode,
|
||||
AirCooledCondenser, BphxDpCorrelation, CoilGeometry, Condenser, CondenserCoil, CondenserRating,
|
||||
Economizer, EpsNtuModel, Evaporator, EvaporatorCoil, EvaporatorRating, ExchangerType,
|
||||
FanCoilUnit, FinCoilCondenser, FinType, FloodedCondenser, FloodedEvaporator,
|
||||
FloodedPoolBoilingConfig, FlowConfiguration, GasCooler, HeatExchanger, HeatExchangerBuilder,
|
||||
HeatTransferModel, HxSideConditions, LmtdModel, MchxCondenserCoil, ShellAndTubeHx, UaMode,
|
||||
};
|
||||
pub use heat_source::HeatSource;
|
||||
pub use isenthalpic_expansion_valve::IsenthalpicExpansionValve;
|
||||
|
||||
Reference in New Issue
Block a user