Files
Entropyk/crates/components/src/heat_exchanger/two_phase_dp.rs
sepehr 3358b74342 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>
2026-07-17 22:46:46 +02:00

719 lines
24 KiB
Rust
Raw Blame History

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