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