//! 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,
};
use entropyk_core::smoothing::{
    cubic_blend, smooth_clamp, smooth_clamp_derivative, smoothstep, smoothstep_derivative,
};

/// Standard gravitational acceleration [m/s²].
const G_ACCEL: f64 = 9.80665;

/// C¹ transition width for quality on Newton-visible Friedel / homogeneous helpers.
///
/// Story 0.4 / FR19: keep the band physically small on `[0, 1]` (Story 0.2 guidance).
const QUALITY_WIDTH: f64 = 1e-2;

/// 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)
}

/// Reynolds number of the laminar→Blasius crossover (`16/Re = 0.079·Re^-0.25`).
const RE_CROSSOVER: f64 = 1187.0;

/// Relative half-width of the C¹ friction-factor blend around [`RE_CROSSOVER`].
///
/// Story 0.2 guidance: keep the band physically small; the two branches cross
/// at the crossover by construction, so a ±5 % window changes the value by
/// < 0.2 % while making `df/dRe` continuous (Newton-safe).
const RE_BLEND_REL_HALF_WIDTH: f64 = 0.05;

/// 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. The two branches are joined with a C¹ [`cubic_blend`] over
/// `1187 ± 5 %` so `df/dRe` stays continuous for Newton Jacobians (Story 0.2 /
/// 0.4 regularization standard); outside the window the values are exactly the
/// raw branches. Guards against non-positive Reynolds numbers.
#[inline]
pub fn fanning_friction_factor(reynolds: f64) -> f64 {
    if reynolds <= 0.0 {
        return 0.0;
    }
    let e0 = RE_CROSSOVER * (1.0 - RE_BLEND_REL_HALF_WIDTH);
    let e1 = RE_CROSSOVER * (1.0 + RE_BLEND_REL_HALF_WIDTH);
    cubic_blend(
        16.0 / reynolds,
        0.079 * reynolds.powf(-0.25),
        reynolds,
        e0,
        e1,
    )
}

/// Analytic derivative `df/dRe` of [`fanning_friction_factor`].
///
/// Chain-rules the [`cubic_blend`] join (branch derivatives + smoothstep
/// weight); returns 0 for non-positive Reynolds numbers, matching the guard in
/// [`fanning_friction_factor`].
#[inline]
pub fn fanning_friction_factor_dre(reynolds: f64) -> f64 {
    if reynolds <= 0.0 {
        return 0.0;
    }
    let e0 = RE_CROSSOVER * (1.0 - RE_BLEND_REL_HALF_WIDTH);
    let e1 = RE_CROSSOVER * (1.0 + RE_BLEND_REL_HALF_WIDTH);
    let lam = 16.0 / reynolds;
    let d_lam = -16.0 / (reynolds * reynolds);
    let turb = 0.079 * reynolds.powf(-0.25);
    let d_turb = -0.25 * 0.079 * reynolds.powf(-1.25);
    // d/dRe [lam + (turb − lam)·s(Re)] = lam' + (turb' − lam')·s + (turb − lam)·s'
    d_lam
        + (d_turb - d_lam) * smoothstep(e0, e1, reynolds)
        + (turb - lam) * smoothstep_derivative(e0, e1, reynolds)
}

/// Homogeneous two-phase density 1/(x/ρ_g + (1-x)/ρ_l) [kg/m³].
///
/// Quality is mapped through [`smooth_clamp`] with [`QUALITY_WIDTH`] so ∂ρ/∂x
/// stays C¹ near the dome edges (Story 0.4). Interior `x ∈ [w, 1−w]` matches
/// the hard-clamp formula exactly.
#[inline]
pub fn homogeneous_density(quality: f64, rho_liquid: f64, rho_vapor: f64) -> f64 {
    let x = smooth_clamp(quality, 0.0, 1.0, QUALITY_WIDTH);
    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.
///
/// **Story 0.4 scope:** not called from residual/Jacobian assembly (export +
/// unit tests only). Hard edges left as-is; do not expand regularization here
/// without a live Newton call path.
#[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.
///
/// Quality uses [`smooth_clamp`] ([`QUALITY_WIDTH`]) so the multiplier is C¹
/// across the dome edges for Newton (Story 0.4). Tube MSH / acceleration paths
/// keep their separate out-of-dome single-phase limits — this helper must not
/// re-introduce hard clamps on those call sites.
pub fn friedel_multiplier(input: &FriedelInput) -> f64 {
    let x = smooth_clamp(input.quality, 0.0, 1.0, QUALITY_WIDTH);
    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))
}

/// Quality of the Hermite blend upper edge (vapor side) for [`msh_gradient`].
const X_BLEND: f64 = 0.90;

/// Half-width of the C¹ subcooled blend (liquid side) for [`msh_gradient`].
///
/// Below `−X_SUB_W` the gradient is exactly the liquid-only value `A`; above
/// `0` it is exactly the raw MSH correlation. Inside the band the two are
/// joined with a [`smoothstep`] weight so the value *and* the quality slope
/// stay continuous (Story 0.2 regularization standard) — the raw correlation
/// has a non-zero slope at `x = 0` while the subcooled limit is flat, which is
/// a C⁰ kink on the condenser operating path without the blend.
const X_SUB_W: f64 = 1e-2;

/// Raw MSH correlation value and analytic partials (no regime blends).
///
/// Valid for `x < 1` (the `(1−x)^(1/3)` factor needs a non-negative base).
/// Returns `(value, ∂/∂x, ∂/∂A, ∂/∂B, ∂²/∂x∂A, ∂²/∂x∂B)` — the cross-partials
/// are needed to chain the Hermite end-slope through the transport props.
#[inline]
fn msh_raw_full_partials(a: f64, b: f64, x: f64) -> (f64, f64, f64, f64, f64, f64) {
    let one_m = (1.0 - x).max(0.0);
    let p = one_m.powf(1.0 / 3.0);
    let d_p = if one_m > 0.0 {
        -(1.0 / 3.0) * one_m.powf(-2.0 / 3.0)
    } else {
        0.0
    };
    // L(x) = A + 2(B−A)x,  MSH = L·(1−x)^{1/3} + B·x³
    let linear = a + 2.0 * (b - a) * x;
    let d_linear_dx = 2.0 * (b - a);
    let d_linear_da = 1.0 - 2.0 * x;
    let d_linear_db = 2.0 * x;
    let value = linear * p + b * x.powi(3);
    let d_dx = d_linear_dx * p + linear * d_p + 3.0 * b * x * x;
    let d_da = d_linear_da * p;
    let d_db = d_linear_db * p + x.powi(3);
    // ∂/∂A(∂MSH/∂x) = −2p + (1−2x)·d_p ;  ∂/∂B(∂MSH/∂x) = 2p + 2x·d_p + 3x²
    let d_dx_da = -2.0 * p + d_linear_da * d_p;
    let d_dx_db = 2.0 * p + d_linear_db * d_p + 3.0 * x * x;
    (value, d_dx, d_da, d_db, d_dx_da, d_dx_db)
}

/// MSH frictional gradient and analytic partials w.r.t. `(x, A, B)`.
///
/// C¹ on the whole real line: liquid-only `A` for `x ≤ −X_SUB_W`, smoothstep
/// blend into the raw correlation on `(−X_SUB_W, 0)`, raw MSH on
/// `[0, X_BLEND]`, cubic Hermite onto the vapor-only `B` (zero slope) on
/// `(X_BLEND, 1)`, and exactly `B` for `x ≥ 1`.
#[inline]
fn msh_value_partials(a: f64, b: f64, x: f64) -> (f64, f64, f64, f64) {
    if x >= 1.0 {
        return (b, 0.0, 0.0, 1.0);
    }
    if x > X_BLEND {
        // Cubic Hermite from (X_BLEND, g0, g0') → (1, B, 0).
        let (g0, g0p, g0a, g0b, g0pa, g0pb) = msh_raw_full_partials(a, b, X_BLEND);
        let dx = 1.0 - X_BLEND;
        let t = (x - X_BLEND) / dx;
        let t2 = t * t;
        let t3 = t2 * t;
        let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
        let h10 = t3 - 2.0 * t2 + t;
        let h01 = -2.0 * t3 + 3.0 * t2;
        // h11 term omitted: end slope m1 = 0.
        let value = h00 * g0 + h10 * dx * g0p + h01 * b;
        // Basis derivatives w.r.t. t (÷ dx for ∂/∂x).
        let dh00 = 6.0 * t2 - 6.0 * t;
        let dh10 = 3.0 * t2 - 4.0 * t + 1.0;
        let dh01 = -6.0 * t2 + 6.0 * t;
        let d_dx = (dh00 * g0 + dh10 * dx * g0p + dh01 * b) / dx;
        let d_da = h00 * g0a + h10 * dx * g0pa;
        let d_db = h00 * g0b + h10 * dx * g0pb + h01;
        (value, d_dx, d_da, d_db)
    } else if x >= 0.0 {
        let (value, d_dx, d_da, d_db, _, _) = msh_raw_full_partials(a, b, x);
        (value, d_dx, d_da, d_db)
    } else if x <= -X_SUB_W {
        (a, 0.0, 1.0, 0.0)
    } else {
        // Subcooled C¹ blend: a + (raw(x) − a)·s(x), s = smoothstep(−w, 0, x).
        let (raw, d_raw_dx, d_raw_da, d_raw_db, _, _) = msh_raw_full_partials(a, b, x);
        let s = smoothstep(-X_SUB_W, 0.0, x);
        let ds = smoothstep_derivative(-X_SUB_W, 0.0, x);
        let value = a + (raw - a) * s;
        let d_dx = d_raw_dx * s + (raw - a) * ds;
        let d_da = 1.0 + (d_raw_da - 1.0) * s;
        let d_db = d_raw_db * s;
        (value, d_dx, d_da, d_db)
    }
}

/// Analytic partials of a two-phase frictional gradient `(dP/dz)` [Pa/m].
#[derive(Debug, Clone, Copy, Default)]
pub struct TwoPhaseGradientPartials {
    /// Gradient value [Pa/m].
    pub value: f64,
    /// ∂/∂G (mass flux).
    pub d_dg: f64,
    /// ∂/∂x (quality).
    pub d_dx: f64,
    /// ∂/∂ρ_liquid.
    pub d_drho_liquid: f64,
    /// ∂/∂ρ_vapor.
    pub d_drho_vapor: f64,
    /// ∂/∂μ_liquid.
    pub d_dmu_liquid: f64,
    /// ∂/∂μ_vapor.
    pub d_dmu_vapor: f64,
    /// ∂/∂σ (surface tension; zero for MSH).
    pub d_dsigma: f64,
}

/// Single-phase (liquid-only / vapor-only) gradient value and partials w.r.t.
/// `(g, ρ, μ)`: `2·f(Re)·g²/(d·ρ)` with `Re = g·d/μ`.
#[inline]
fn single_phase_gradient_partials(g: f64, d: f64, rho: f64, mu: f64) -> (f64, f64, f64, f64) {
    let re = g * d / mu;
    let f = fanning_friction_factor(re);
    let df = fanning_friction_factor_dre(re);
    let value = 2.0 * f * g * g / (d * rho);
    let d_dg = (2.0 * g / (d * rho)) * (2.0 * f + re * df);
    let d_drho = -value / rho;
    let d_dmu = -(2.0 * g * g / (d * rho)) * df * re / mu;
    (value, d_dg, d_drho, d_dmu)
}

/// Müller-Steinhagen-Heck gradient value + analytic partials w.r.t.
/// `(G, x, ρ_l, ρ_v, μ_l, μ_v)` (NFR9 exact-Jacobian path; Story 0.2/0.4
/// regularization standard).
pub fn msh_gradient_partials(input: &FriedelInput) -> TwoPhaseGradientPartials {
    let g = input.mass_flux.abs();
    let d = input.diameter.max(1e-9);
    let rho_l = input.rho_liquid.max(1e-9);
    let rho_v = input.rho_vapor.max(1e-9);
    let mu_l = input.mu_liquid.max(1e-12);
    let mu_v = input.mu_vapor.max(1e-12);
    let (a, da_dg, da_drho, da_dmu) = single_phase_gradient_partials(g, d, rho_l, mu_l);
    let (b, db_dg, db_drho, db_dmu) = single_phase_gradient_partials(g, d, rho_v, mu_v);
    let (value, d_dx, d_da, d_db) = msh_value_partials(a, b, input.quality);
    TwoPhaseGradientPartials {
        value,
        d_dg: d_da * da_dg + d_db * db_dg,
        d_dx,
        d_drho_liquid: d_da * da_drho,
        d_drho_vapor: d_db * db_drho,
        d_dmu_liquid: d_da * da_dmu,
        d_dmu_vapor: d_db * db_dmu,
        d_dsigma: 0.0,
    }
}

/// Friedel gradient value + partials w.r.t. `(G, x, ρ_l, ρ_v, μ_l, μ_v, σ)`.
///
/// **NFR9 deviation (recorded):** unlike the MSH path, the partials here are
/// narrow central finite differences of the *pure* [`friedel_gradient`]
/// correlation (relative step `1e-7`, no fluid-backend calls, no model
/// switches) — the full analytic expansion of the Friedel `E + 3.24·F·H /
/// (Fr^0.045·We^0.035)` composition was judged out of scope for this fix; the
/// FD of a smooth pure function is domain-safe and agrees with the true
/// derivative to FD precision (~1e-8 relative), which is ample for Newton.
pub fn friedel_gradient_partials(input: &FriedelInput) -> TwoPhaseGradientPartials {
    let value = friedel_gradient(input);
    let fd = |perturb: &mut dyn FnMut(&mut FriedelInput, f64), center: f64, floor: f64| -> f64 {
        let h = (center.abs() * 1e-7).max(floor);
        let (mut up, mut dn) = (*input, *input);
        perturb(&mut up, h);
        perturb(&mut dn, -h);
        (friedel_gradient(&up) - friedel_gradient(&dn)) / (2.0 * h)
    };
    TwoPhaseGradientPartials {
        value,
        d_dg: fd(&mut |i, h| i.mass_flux += h, input.mass_flux, 1e-6),
        d_dx: fd(&mut |i, h| i.quality += h, input.quality, 1e-9),
        d_drho_liquid: fd(&mut |i, h| i.rho_liquid += h, input.rho_liquid, 1e-9),
        d_drho_vapor: fd(&mut |i, h| i.rho_vapor += h, input.rho_vapor, 1e-9),
        d_dmu_liquid: fd(&mut |i, h| i.mu_liquid += h, input.mu_liquid, 1e-13),
        d_dmu_vapor: fd(&mut |i, h| i.mu_vapor += h, input.mu_vapor, 1e-13),
        d_dsigma: fd(&mut |i, h| i.sigma += h, input.sigma, 1e-11),
    }
}

/// 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)·(1−x)^(1/3) + B·x³` where A=(dP/dz)_LO, B=(dP/dz)_GO.
///
/// # Domain extension outside the saturation dome
///
/// Thermodynamic quality is allowed to leave `[0, 1]` — `x > 1` denotes
/// superheated vapor and `x < 0` denotes subcooled liquid. The correlation
/// then degenerates smoothly to the corresponding single-phase gradient:
/// * `x ≤ −X_SUB_W` → liquid-only gradient `A` (subcooled liquid region),
///   joined C¹ through a [`smoothstep`] blend on `(−X_SUB_W, 0)` — the raw
///   correlation has a non-zero slope at `x = 0` while the subcooled limit is
///   flat, which would otherwise be a C⁰ kink on the condenser operating path.
/// * `x ≥ 1` → vapor-only gradient `B` (superheated vapor region).
///
/// # Regularization near x = 1
///
/// The published formula `(1-x)^(1/3)` has a derivative in `(1-x)^(-2/3)`
/// that diverges as `x → 1`, making the Newton Jacobian ∂ΔP/∂h pathological
/// when an evaporator outlet approaches saturated-vapor conditions. We replace
/// the raw correlation on `[X_BLEND, 1]` (`X_BLEND = 0.90`) by a cubic Hermite
/// interpolant that matches value **and** slope of the raw MSH at `X_BLEND`
/// and lands on the vapor-only gradient `B` with zero slope at `x = 1`. The
/// frictional gradient is therefore C¹ on the whole real line and ∂ΔP/∂x stays
/// bounded (Story 0.4; subcooled-side blend added under the Story 0.2 standard).
pub fn msh_gradient(input: &FriedelInput) -> f64 {
    let x = input.quality;
    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);
    msh_value_partials(a, b, x).0
}

/// 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_c/ρ_v + (1−x_c)/ρ_l`. Positive when quality rises (evaporator).
///
/// Quality is allowed to leave `[0, 1]` to match the MSH domain extension:
/// superheated vapor (`x > 1`) uses the saturated-vapor specific volume, and
/// subcooled liquid (`x < 0`) uses the saturated-liquid specific volume. The
/// clamped quality `x_c` goes through [`smooth_clamp`] with [`QUALITY_WIDTH`]
/// (Story 0.2/0.4 regularization standard), so the term is C¹ across the
/// saturation boundary — including ∂ΔP_acc/∂x, which the former hard piecewise
/// `v(x)` jumped between `0` and `1/ρ_v − 1/ρ_l` — and the momentum-row
/// Jacobian ∂ΔP_acc/∂h is well-defined for Newton.
#[inline]
pub fn acceleration_drop(
    mass_flux: f64,
    x_in: f64,
    x_out: f64,
    rho_liquid: f64,
    rho_vapor: f64,
) -> f64 {
    acceleration_drop_partials(mass_flux, x_in, x_out, rho_liquid, rho_vapor).0
}

/// Value and analytic partials of [`acceleration_drop`]:
/// `(value, ∂/∂G, ∂/∂x_in, ∂/∂x_out, ∂/∂ρ_liquid, ∂/∂ρ_vapor)`.
#[inline]
pub fn acceleration_drop_partials(
    mass_flux: f64,
    x_in: f64,
    x_out: f64,
    rho_liquid: f64,
    rho_vapor: f64,
) -> (f64, f64, f64, f64, f64, f64) {
    let rl = rho_liquid.max(1e-9);
    let rv = rho_vapor.max(1e-9);
    // Homogeneous specific volume of the smooth-clamped quality.
    let v = |x: f64| {
        let xc = smooth_clamp(x, 0.0, 1.0, QUALITY_WIDTH);
        (xc, xc / rv + (1.0 - xc) / rl)
    };
    let dv = |x: f64| smooth_clamp_derivative(x, 0.0, 1.0, QUALITY_WIDTH) * (1.0 / rv - 1.0 / rl);
    let g = mass_flux;
    let (xc_in, v_in) = v(x_in);
    let (xc_out, v_out) = v(x_out);
    let value = g * g * (v_out - v_in);
    let d_dg = 2.0 * g * (v_out - v_in);
    let d_dx_in = -g * g * dv(x_in);
    let d_dx_out = g * g * dv(x_out);
    // ∂v/∂ρ_v = −x_c/ρ_v² ;  ∂v/∂ρ_l = −(1−x_c)/ρ_l²
    let d_drho_l = g * g * ((1.0 - xc_in) - (1.0 - xc_out)) / (rl * rl);
    let d_drho_v = g * g * (xc_in - xc_out) / (rv * rv);
    (value, d_dg, d_dx_in, d_dx_out, d_drho_l, d_drho_v)
}

/// 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̄ = ½(x_in + x_out)` over [`TubeChannelGeometry::length_m`]. Signed so
/// `P_out = P_in − ΔP` for `ṁ ≥ 0`.
///
/// Qualities are **not** clamped to `[0, 1]`: values above 1 (superheated vapor)
/// or below 0 (subcooled liquid) are passed through so the correlation can
/// blend smoothly into the corresponding single-phase limit (see
/// [`msh_gradient`] and [`acceleration_drop`]). This keeps the momentum-row
/// Jacobian ∂ΔP/∂h well-defined across the saturation boundary.
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 + x_out);
    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
    }
}

/// Analytic partials of [`tube_two_phase_delta_p`] w.r.t. the mass flow, the
/// inlet/outlet qualities and the saturated transport properties.
///
/// Everything is differentiated in closed form for MSH (and Friedel falls back
/// to narrow FD of the pure correlation — see [`friedel_gradient_partials`]);
/// the composition (sign, `G = |ṁ|/A`, mean quality) is exact. The
/// ∂ΔP/∂ṁ entry is continuous through ṁ = 0 (the composition is quadratic in
/// `G` there).
#[derive(Debug, Clone, Copy, Default)]
pub struct TubeDpPartials {
    /// ΔP value [Pa].
    pub value: f64,
    /// ∂ΔP/∂ṁ [Pa·s/kg].
    pub d_dm: f64,
    /// ∂ΔP/∂x_in [-].
    pub d_dx_in: f64,
    /// ∂ΔP/∂x_out [-].
    pub d_dx_out: f64,
    /// ∂ΔP/∂ρ_liquid.
    pub d_drho_liquid: f64,
    /// ∂ΔP/∂ρ_vapor.
    pub d_drho_vapor: f64,
    /// ∂ΔP/∂μ_liquid.
    pub d_dmu_liquid: f64,
    /// ∂ΔP/∂μ_vapor.
    pub d_dmu_vapor: f64,
    /// ∂ΔP/∂σ (Friedel only).
    pub d_dsigma: f64,
}

/// Computes [`tube_two_phase_delta_p`] together with its analytic partials
/// (NFR9 exact-Jacobian path for the HX momentum rows).
pub fn tube_two_phase_delta_p_partials(
    correlation: TwoPhaseDpCorrelation,
    geom: &TubeChannelGeometry,
    mass_flow: f64,
    x_in: f64,
    x_out: f64,
    props: &SatTransportProps,
) -> TubeDpPartials {
    let area = geom.flow_area_m2().max(1e-12);
    let g = mass_flow.abs() / area;
    let x_mean = 0.5 * (x_in + x_out);
    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 grad = match correlation {
        TwoPhaseDpCorrelation::MullerSteinhagenHeck1986 => msh_gradient_partials(&input),
        TwoPhaseDpCorrelation::Friedel1979 => friedel_gradient_partials(&input),
    };
    let length = geom.length_m.max(0.0);
    let (acc, da_dg, da_dxin, da_dxout, da_drhol, da_drhov) =
        acceleration_drop_partials(g, x_in, x_out, props.rho_liquid, props.rho_vapor);
    let sign = if mass_flow >= 0.0 { 1.0 } else { -1.0 };
    // ΔP(ṁ) = sign(ṁ)·F(|ṁ|): dΔP/dṁ = F'(|ṁ|)/A on both branches (C¹ at 0
    // since F is quadratic in G there — F'(0) = 0).
    let d_dm = (grad.d_dg * length + da_dg) / area;
    TubeDpPartials {
        value: sign * (grad.value * length + acc),
        d_dm,
        d_dx_in: sign * (grad.d_dx * length * 0.5 + da_dxin),
        d_dx_out: sign * (grad.d_dx * length * 0.5 + da_dxout),
        d_drho_liquid: sign * (grad.d_drho_liquid * length + da_drhol),
        d_drho_vapor: sign * (grad.d_drho_vapor * length + da_drhov),
        d_dmu_liquid: sign * (grad.d_dmu_liquid * length),
        d_dmu_vapor: sign * (grad.d_dmu_vapor * length),
        d_dsigma: sign * (grad.d_dsigma * length),
    }
}

/// 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);
    }

    #[test]
    fn quality_smooth_clamp_matches_hard_formula_in_physical_region() {
        let rho_l = 1000.0;
        let rho_g = 50.0;
        let w = QUALITY_WIDTH;
        for x in [w, 0.25, 0.5, 0.75, 1.0 - w] {
            // Interior of smooth_clamp is identity → same as former hard clamp.
            assert!((smooth_clamp(x, 0.0, 1.0, w) - x).abs() < 1e-15);
            let soft = homogeneous_density(x, rho_l, rho_g);
            let hard = {
                let inv = x / rho_g + (1.0 - x) / rho_l;
                1.0 / inv
            };
            assert!(
                (soft - hard).abs() / hard < 1e-12,
                "homogeneous x={x}: soft={soft} hard={hard}"
            );
        }
        let base = r134a_like();
        for x in [w, 0.2, 0.5, 0.8, 1.0 - w] {
            let m = friedel_multiplier(&FriedelInput { quality: x, ..base });
            assert!(m.is_finite() && m >= 1.0, "friedel physical x={x} m={m}");
        }
    }

    #[test]
    fn quality_transition_band_differs_from_hard_clamp() {
        let rho_l = 1000.0;
        let rho_g = 50.0;
        // In the lower C¹ ramp, smooth_clamp ≠ identity → soft ≠ hard-clamp formula.
        let x_band = 0.5 * QUALITY_WIDTH;
        let soft = homogeneous_density(x_band, rho_l, rho_g);
        let hard = 1.0 / (x_band / rho_g + (1.0 - x_band) / rho_l);
        assert!(
            (soft - hard).abs() / hard > 1e-6,
            "Story 0.4: C¹ quality ramp must differ from hard clamp; soft={soft} hard={hard}"
        );
    }

    #[test]
    fn msh_quality_derivative_bounded_across_blend() {
        const X_BLEND: f64 = 0.90;
        let h = 1e-6;
        let slope_at = |x: f64| -> f64 {
            if x <= h {
                let mut p = r134a_like();
                p.quality = x + h;
                let mut c = r134a_like();
                c.quality = x;
                (msh_gradient(&p) - msh_gradient(&c)) / h
            } else if x >= 1.0 - h {
                let mut c = r134a_like();
                c.quality = x;
                let mut m = r134a_like();
                m.quality = x - h;
                (msh_gradient(&c) - msh_gradient(&m)) / h
            } else {
                let mut up = r134a_like();
                up.quality = x + h;
                let mut dn = r134a_like();
                dn.quality = x - h;
                (msh_gradient(&up) - msh_gradient(&dn)) / (2.0 * h)
            }
        };
        for x in [0.5, 0.8, X_BLEND, 0.95, 0.99, 0.999, 1.0 - 1e-9] {
            let slope = slope_at(x);
            assert!(
                slope.is_finite() && slope.abs() < 1e12,
                "MSH ∂g/∂x must stay finite near x={x}, got {slope}"
            );
        }
        // C¹ join at X_BLEND: left/right FD slopes must agree closely.
        let left = slope_at(X_BLEND - 10.0 * h);
        let right = slope_at(X_BLEND + 10.0 * h);
        let scale = left.abs().max(right.abs()).max(1.0);
        assert!(
            (left - right).abs() / scale < 1e-2,
            "MSH C¹ join at X_BLEND: left={left} right={right}"
        );
    }

    // ── Story: exact tube-ΔP Jacobian (NFR9) — analytic partials vs FD ──────

    /// Central FD helper for scalar functions.
    fn cfd(f: impl Fn(f64) -> f64, x: f64, h: f64) -> f64 {
        (f(x + h) - f(x - h)) / (2.0 * h)
    }

    #[test]
    fn fanning_friction_factor_dre_matches_fd_and_is_c1_at_crossover() {
        // Across laminar, the blend window, and turbulent.
        for re in [100.0, 500.0, 1000.0, 1128.0, 1187.0, 1247.0, 2000.0, 1e5] {
            let analytic = fanning_friction_factor_dre(re);
            let numeric = cfd(fanning_friction_factor, re, re * 1e-6);
            let scale = analytic.abs().max(numeric.abs()).max(1e-12);
            assert!(
                (analytic - numeric).abs() / scale < 1e-4,
                "fanning df/dRe mismatch at Re={re}: {analytic} vs {numeric}"
            );
        }
        // C¹ at the crossover: left/right FD slopes agree (former hard kink).
        for re in [1128.0, 1247.0] {
            let left = cfd(fanning_friction_factor, re - 1e-3, 1e-3);
            let right = cfd(fanning_friction_factor, re + 1e-3, 1e-3);
            let scale = left.abs().max(right.abs());
            assert!(
                (left - right).abs() / scale < 1e-2,
                "fanning slope jump near Re={re}: {left} vs {right}"
            );
        }
        // Values outside the window are exactly the raw branches.
        assert!((fanning_friction_factor(500.0) - 16.0 / 500.0).abs() < 1e-15);
        assert!((fanning_friction_factor(1.0e5) - 0.079 * 1.0e5_f64.powf(-0.25)).abs() < 1e-15);
    }

    #[test]
    fn msh_gradient_partials_match_fd_across_regimes() {
        // Covers subcooled flat/blend, raw MSH, Hermite, superheated — the C¹
        // path across x = 0 and x = 1. (x = 1 exactly is a C¹ join with large
        // curvature: FD-vs-analytic there is a FD straddle artifact, checked
        // separately in msh_gradient_is_c1_across_subcooled_and_superheated_edges.)
        for x in [-0.05, -0.005, 0.0, 0.2, 0.5, 0.9, 0.95, 0.999, 1.3] {
            let base = FriedelInput {
                quality: x,
                ..r134a_like()
            };
            let p = msh_gradient_partials(&base);
            // Value consistency with msh_gradient.
            assert!((p.value - msh_gradient(&base)).abs() < 1e-12 * p.value.abs().max(1.0));
            let fields: [(
                &str,
                f64,
                f64,
                Box<dyn Fn(&FriedelInput, f64) -> FriedelInput>,
            ); 6] = [
                (
                    "g",
                    p.d_dg,
                    base.mass_flux,
                    Box::new(|i, h| {
                        let mut c = *i;
                        c.mass_flux += h;
                        c
                    }),
                ),
                (
                    "x",
                    p.d_dx,
                    base.quality,
                    Box::new(|i, h| {
                        let mut c = *i;
                        c.quality += h;
                        c
                    }),
                ),
                (
                    "rho_l",
                    p.d_drho_liquid,
                    base.rho_liquid,
                    Box::new(|i, h| {
                        let mut c = *i;
                        c.rho_liquid += h;
                        c
                    }),
                ),
                (
                    "rho_v",
                    p.d_drho_vapor,
                    base.rho_vapor,
                    Box::new(|i, h| {
                        let mut c = *i;
                        c.rho_vapor += h;
                        c
                    }),
                ),
                (
                    "mu_l",
                    p.d_dmu_liquid,
                    base.mu_liquid,
                    Box::new(|i, h| {
                        let mut c = *i;
                        c.mu_liquid += h;
                        c
                    }),
                ),
                (
                    "mu_v",
                    p.d_dmu_vapor,
                    base.mu_vapor,
                    Box::new(|i, h| {
                        let mut c = *i;
                        c.mu_vapor += h;
                        c
                    }),
                ),
            ];
            for (name, analytic, center, perturb) in fields {
                let h = (center.abs() * 1e-7).max(1e-10);
                let up = msh_gradient(&perturb(&base, h));
                let dn = msh_gradient(&perturb(&base, -h));
                let numeric = (up - dn) / (2.0 * h);
                let scale = analytic.abs().max(numeric.abs()).max(1e-9);
                assert!(
                    (analytic - numeric).abs() / scale < 1e-3,
                    "msh d/d{name} mismatch at x={x}: analytic={analytic} fd={numeric}"
                );
            }
        }
    }

    #[test]
    fn msh_gradient_is_c1_across_subcooled_and_superheated_edges() {
        // Slope continuity across x = 0 (subcooled blend, moderate curvature):
        // left/right FD slopes agree.
        let h = 1e-7;
        let fd_slope = |x: f64| {
            cfd(
                |xx| {
                    let mut ii = r134a_like();
                    ii.quality = xx;
                    msh_gradient(&ii)
                },
                x,
                h,
            )
        };
        let left = fd_slope(-10.0 * h);
        let right = fd_slope(10.0 * h);
        let scale = left.abs().max(right.abs()).max(1.0);
        assert!(
            (left - right).abs() / scale < 1e-2,
            "MSH slope jump at x=0: left={left} right={right}"
        );
        // Slope continuity across x = 1: the Hermite lands with zero slope by
        // construction and huge curvature, so verify the analytic slope
        // vanishes ~linearly toward the join instead of FD-straddling it.
        let s = |x: f64| {
            msh_gradient_partials(&FriedelInput {
                quality: x,
                ..r134a_like()
            })
            .d_dx
        };
        assert_eq!(s(1.0), 0.0, "analytic ∂g/∂x at the x=1 join must be 0");
        assert_eq!(s(1.0 + 1e-4), 0.0, "superheated side is exactly flat");
        let s2 = s(1.0 - 1e-2).abs();
        let s3 = s(1.0 - 1e-3).abs();
        let s4 = s(1.0 - 1e-4).abs();
        assert!(
            s3 < 0.2 * s2 && s4 < 0.2 * s3,
            "∂g/∂x must vanish ~linearly toward x=1: {s2} {s3} {s4}"
        );
        // Values outside the bands are exactly the single-phase limits.
        let mut i = r134a_like();
        i.quality = -0.05;
        let a = liquid_only_gradient(i.mass_flux, i.diameter, i.rho_liquid, i.mu_liquid);
        assert!((msh_gradient(&i) - a).abs() < 1e-9 * a.abs());
        i.quality = 1.05;
        let b = vapor_only_gradient(i.mass_flux, i.diameter, i.rho_vapor, i.mu_vapor);
        assert!((msh_gradient(&i) - b).abs() < 1e-9 * b.abs());
    }

    #[test]
    fn acceleration_drop_partials_match_fd_and_c1_across_dome() {
        let (g, rl, rv) = (350.0, 1260.0, 17.0);
        for (x_in, x_out) in [(0.2, 0.95), (-0.05, 1.1), (0.005, 0.995), (1.2, 0.3)] {
            let (value, d_dg, d_dxin, d_dxout, d_drl, d_drv) =
                acceleration_drop_partials(g, x_in, x_out, rl, rv);
            assert!((value - acceleration_drop(g, x_in, x_out, rl, rv)).abs() < 1e-12);
            let eps = 1e-7;
            let num_dg = cfd(|gg| acceleration_drop(gg, x_in, x_out, rl, rv), g, g * eps);
            let num_dxin = cfd(|x| acceleration_drop(g, x, x_out, rl, rv), x_in, eps);
            let num_dxout = cfd(|x| acceleration_drop(g, x_in, x, rl, rv), x_out, eps);
            let num_drl = cfd(|r| acceleration_drop(g, x_in, x_out, r, rv), rl, rl * eps);
            let num_drv = cfd(|r| acceleration_drop(g, x_in, x_out, rl, r), rv, rv * eps);
            for (name, a, n) in [
                ("g", d_dg, num_dg),
                ("x_in", d_dxin, num_dxin),
                ("x_out", d_dxout, num_dxout),
                ("rho_l", d_drl, num_drl),
                ("rho_v", d_drv, num_drv),
            ] {
                let scale = a.abs().max(n.abs()).max(1e-9);
                assert!(
                    (a - n).abs() / scale < 1e-3,
                    "acc d/{name} mismatch at ({x_in},{x_out}): {a} vs {n}"
                );
            }
        }
        // C¹ across x = 0 and x = 1 (former hard piecewise kinks).
        for x0 in [0.0, 1.0] {
            let h = 1e-6;
            let left = cfd(|x| acceleration_drop(g, x, 0.5, rl, rv), x0 - h, h);
            let right = cfd(|x| acceleration_drop(g, x, 0.5, rl, rv), x0 + h, h);
            let scale = left.abs().max(right.abs()).max(1.0);
            assert!(
                (left - right).abs() / scale < 1e-2,
                "acc slope jump at x={x0}: {left} vs {right}"
            );
        }
    }

    #[test]
    fn tube_two_phase_delta_p_partials_match_fd_both_correlations() {
        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();
        for corr in [
            TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
            TwoPhaseDpCorrelation::Friedel1979,
        ] {
            for (m, x_in, x_out) in [(0.05, 0.2, 0.95), (0.12, -0.03, 1.1), (-0.08, 0.9, 0.1)] {
                let p = tube_two_phase_delta_p_partials(corr, &geom, m, x_in, x_out, &props);
                // Value consistency with tube_two_phase_delta_p.
                let v = tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &props);
                assert!(
                    (p.value - v).abs() < 1e-9 * v.abs().max(1.0),
                    "value mismatch {corr:?} ({m},{x_in},{x_out})"
                );
                let cases: [(&str, f64, Box<dyn Fn(f64) -> f64>); 8] = [
                    (
                        "m",
                        p.d_dm,
                        Box::new(|mm| tube_two_phase_delta_p(corr, &geom, mm, x_in, x_out, &props)),
                    ),
                    (
                        "x_in",
                        p.d_dx_in,
                        Box::new(|x| tube_two_phase_delta_p(corr, &geom, m, x, x_out, &props)),
                    ),
                    (
                        "x_out",
                        p.d_dx_out,
                        Box::new(|x| tube_two_phase_delta_p(corr, &geom, m, x_in, x, &props)),
                    ),
                    (
                        "rho_l",
                        p.d_drho_liquid,
                        Box::new(|r| {
                            let mut pp = props;
                            pp.rho_liquid = r;
                            tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &pp)
                        }),
                    ),
                    (
                        "rho_v",
                        p.d_drho_vapor,
                        Box::new(|r| {
                            let mut pp = props;
                            pp.rho_vapor = r;
                            tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &pp)
                        }),
                    ),
                    (
                        "mu_l",
                        p.d_dmu_liquid,
                        Box::new(|r| {
                            let mut pp = props;
                            pp.mu_liquid = r;
                            tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &pp)
                        }),
                    ),
                    (
                        "mu_v",
                        p.d_dmu_vapor,
                        Box::new(|r| {
                            let mut pp = props;
                            pp.mu_vapor = r;
                            tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &pp)
                        }),
                    ),
                    (
                        "sigma",
                        p.d_dsigma,
                        Box::new(|r| {
                            let mut pp = props;
                            pp.sigma = r;
                            tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &pp)
                        }),
                    ),
                ];
                let centers = [
                    m,
                    x_in,
                    x_out,
                    props.rho_liquid,
                    props.rho_vapor,
                    props.mu_liquid,
                    props.mu_vapor,
                    props.sigma,
                ];
                for ((name, analytic, f), center) in cases.iter().zip(centers) {
                    let h = (center.abs() * 1e-7).max(1e-10);
                    let numeric = (f(center + h) - f(center - h)) / (2.0 * h);
                    let scale = analytic.abs().max(numeric.abs()).max(1e-9);
                    assert!(
                        (analytic - numeric).abs() / scale < 1e-3,
                        "tube ΔP d/{name} mismatch {corr:?} ({m},{x_in},{x_out}): analytic={analytic} fd={numeric}"
                    );
                }
            }
        }
    }
}
