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>
719 lines
24 KiB
Rust
719 lines
24 KiB
Rust
//! 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);
|
||
}
|
||
}
|