//! Condenser Component
//!
//! A heat exchanger configured for refrigerant condensation.
//! The refrigerant (hot side) condenses from superheated vapor to
//! subcooled liquid, releasing heat to the cold side.

use super::exchanger::HeatExchanger;
use super::flow_regularization::{smooth_mass_magnitude, DEFAULT_M_EPS_KG_S};
use super::lmtd::{FlowConfiguration, LmtdModel};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
    Component, ComponentError, ConnectedPort, DomainViolation, JacobianBuilder, ResidualVector,
    StateSlice,
};
use entropyk_core::smoothing::{smooth_clamp, smooth_clamp_derivative};
use entropyk_core::Calib;
use entropyk_core::Pressure;
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
use std::sync::Arc;

/// Fan actuator φ ∈ [0, 1.5] — C¹ transition width (Story 0.4 / FR19).
const FAN_PHI_WIDTH: f64 = 1e-2;
/// Flood level λ ∈ [0, 0.98] — C¹ transition width (`width ≤ (hi−lo)/2`).
const FLOOD_LAMBDA_WIDTH: f64 = 1e-2;
const FAN_PHI_LO: f64 = 0.0;
const FAN_PHI_HI: f64 = 1.5;
const FLOOD_LAMBDA_LO: f64 = 0.0;
const FLOOD_LAMBDA_HI: f64 = 0.98;

/// Condenser heat exchanger.
///
/// Uses the LMTD method for heat transfer calculation.
/// The refrigerant condenses on the hot side, releasing heat
/// to the cold side (typically water or air).
///
/// # Configuration
///
/// - Hot side: Refrigerant condensing (phase change)
/// - Cold side: Heat sink (water, air, etc.)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Condenser;
/// use entropyk_components::Component;
///
/// let condenser = Condenser::new(10_000.0);  // UA = 10 kW/K
/// assert_eq!(condenser.n_equations(), 3);
/// ```
pub struct Condenser {
    inner: HeatExchanger<LmtdModel>,
    saturation_temp: f64,
    refrigerant_id: String,
    fluid_backend: Option<Arc<dyn FluidBackend>>,
    inlet_p_idx: Option<usize>,
    /// Refrigerant inlet enthalpy state index (needed for the coupled energy balance).
    inlet_h_idx: Option<usize>,
    outlet_p_idx: Option<usize>,
    outlet_h_idx: Option<usize>,
    /// Mass-flow state index of the inlet edge (ṁ unknown, CM1.3).
    inlet_m_idx: Option<usize>,
    /// Mass-flow state index of the outlet edge (ṁ unknown, CM1.3).
    outlet_m_idx: Option<usize>,
    /// True when inlet and outlet share the same ṁ state index (CM1.4).
    same_branch_m: bool,
    /// Secondary (air/water) inlet temperature [K] for the coupled ε-NTU model.
    secondary_inlet_temp_k: Option<f64>,
    /// Secondary heat-capacity rate ṁ·cp [W/K] for the coupled ε-NTU model.
    secondary_capacity_rate: Option<f64>,
    /// When `true`, the condensing pressure is emergent: an extra outlet-closure
    /// residual pins the refrigerant outlet to saturated liquid minus `subcooling_k`,
    /// so P_cond is determined by the ε-NTU ↔ secondary balance instead of being
    /// imposed by the compressor.
    emergent_pressure: bool,
    /// Target sub-cooling below the condensing temperature [K] (emergent mode).
    subcooling_k: f64,
    /// Lumped quadratic coefficient `k` [Pa·s²/kg²] (`ΔP = k·ṁ·|ṁ|`) when no
    /// tube geometry is set. Prefer [`Self::with_tube_pressure_drop`] (MSH /
    /// Friedel). `None` is isobaric unless tube DP is configured.
    pressure_drop_coeff: Option<f64>,
    /// Tube-side two-phase correlation (MSH / Friedel). Requires geometry.
    tube_dp_correlation: Option<crate::heat_exchanger::two_phase_dp::TwoPhaseDpCorrelation>,
    /// Tube channel geometry for frictional + acceleration ΔP.
    tube_dp_geometry: Option<crate::heat_exchanger::two_phase_dp::TubeChannelGeometry>,
    /// When `true`, an air-cooled condenser fan modulates the secondary air
    /// capacity rate to hold a target condensing temperature (head-pressure
    /// control). The fan speed ratio is a free actuator `φ ∈ [φ_min, φ_max]` and
    /// the effective capacity rate becomes `C_sec = φ · C_nominal` (fan-affinity
    /// law: air flow ∝ speed). Requires `emergent_pressure` and a secondary
    /// stream (whose capacity rate is taken as the full-speed nominal).
    fan_head_pressure: bool,
    /// When `true`, a refrigerant-side condenser-flooding actuator holds a target
    /// condensing temperature (head-pressure control, e.g. Sporlan Head Master /
    /// ORI+ORD in low ambient). The flooded liquid level `λ ∈ [λ_min, λ_max]`
    /// backs liquid up over the tubes, deactivating part of the condensing area:
    /// the effective conductance becomes `UA_eff = (1 − λ)·UA`. Requires
    /// `emergent_pressure` and a secondary stream. Mutually exclusive with the fan
    /// actuator (both share the single generic actuator slot / head-pressure eq).
    flooded_head_pressure: bool,
    /// Target condensing (saturation) temperature [K] held by the fan actuator.
    head_pressure_target_k: Option<f64>,
    /// Free-actuator state index of the fan speed ratio `φ`, wired from the
    /// solver via `CalibIndices.actuator` in `finalize()`.
    fan_actuator_idx: Option<usize>,
    /// Secondary (water/air) **inlet** edge state indices (Modelica-style
    /// 4-port mode). Wired by `set_port_context` from local port 2.
    sec_in_idx: Option<(usize, usize, usize)>,
    /// Secondary (water/air) **outlet** edge state indices (local port 3).
    sec_out_idx: Option<(usize, usize, usize)>,
    /// Secondary fluid identifier for property queries ("Water",
    /// "INCOMP::MEG-30", "Air"…). Air uses the moist-air linear h↔T convention.
    secondary_fluid_id: String,
    /// Humidity ratio W [kg_v/kg_da] for the moist-air convention
    /// `h = 1006·T_c + W·(2 501 000 + 1860·T_c)` (matches `AirSource`).
    secondary_humidity_ratio: f64,
    /// Secondary (water/air) lumped quadratic ΔP coefficient `k` [Pa·s²/kg²].
    /// `None` / 0 → isobaric secondary (`P_out = P_in`). Distinct from the
    /// refrigerant-side `pressure_drop_coeff` / tube MSH model.
    secondary_pressure_drop_coeff: Option<f64>,
    skip_pressure_eq: bool,
}

/// Pre-computed quantities shared between the secondary energy-balance
/// Jacobian rows (4-port mode). All evaluated at the current state.
#[derive(Debug, Clone, Copy)]
struct SecondaryJacCtx {
    /// `g = ε·C_sec` [W/K].
    g: f64,
    /// `g'(C_sec) = d(ε·C)/dC` [-].
    g_prime: f64,
    /// Secondary cp [J/(kg·K)].
    cp_sec: f64,
    /// `dT_sec,in/dh_sec,in = 1/cp` [K·kg/J].
    dt_sec_dh: f64,
    /// Driving temperature difference `T_cond − T_sec,in` [K].
    delta_t: f64,
    /// `dT_cond/dP_ref,in` [K/Pa] (central finite difference).
    dtcond_dp: f64,
    /// Refrigerant inlet-pressure state index.
    ref_p_in_idx: usize,
    /// `exp(−UA_eff/C_sec)` for the flooding-actuator entry.
    e_exp: f64,
}

/// Result of qualifying a condenser at a fixed refrigerant regime against a
/// secondary (air/water) stream. All fields are solved from the ε-NTU balance.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CondenserRating {
    /// Heat duty `Q = ε·C_sec·(T_cond − T_sec,in)` [W] rejected to the secondary fluid.
    pub q_w: f64,
    /// Effectiveness `ε = 1 − exp(−UA / C_sec)` [-].
    pub effectiveness: f64,
    /// Condensing (saturation) temperature `T_sat(P)` [K].
    pub t_cond_k: f64,
    /// Approach `T_cond − T_sec,in` [K].
    pub approach_k: f64,
    /// Secondary outlet temperature `T_sec,in + Q/C_sec` [K].
    pub secondary_outlet_k: f64,
}

impl std::fmt::Debug for Condenser {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Condenser")
            .field("saturation_temp", &self.saturation_temp)
            .field("refrigerant_id", &self.refrigerant_id)
            .field("fluid_backend_set", &self.fluid_backend.is_some())
            .field("outlet_p_idx", &self.outlet_p_idx)
            .field("outlet_h_idx", &self.outlet_h_idx)
            .finish()
    }
}

impl Condenser {
    /// Creates a new condenser with the given UA value.
    ///
    /// # Arguments
    ///
    /// * `ua` - Overall heat transfer coefficient × Area (W/K)
    ///
    /// # Example
    ///
    /// ```
    /// use entropyk_components::heat_exchanger::Condenser;
    ///
    /// let condenser = Condenser::new(15_000.0);
    /// ```
    pub fn new(ua: f64) -> Self {
        let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
        Self {
            inner: HeatExchanger::new(model, "Condenser"),
            saturation_temp: 323.15,
            refrigerant_id: String::new(),
            fluid_backend: None,
            inlet_p_idx: None,
            inlet_h_idx: None,
            outlet_p_idx: None,
            outlet_h_idx: None,
            inlet_m_idx: None,
            outlet_m_idx: None,
            same_branch_m: false,
            secondary_inlet_temp_k: None,
            secondary_capacity_rate: None,
            emergent_pressure: false,
            subcooling_k: 0.0,
            pressure_drop_coeff: None,
            tube_dp_correlation: None,
            tube_dp_geometry: None,
            fan_head_pressure: false,
            flooded_head_pressure: false,
            head_pressure_target_k: None,
            fan_actuator_idx: None,
            sec_in_idx: None,
            sec_out_idx: None,
            secondary_fluid_id: String::new(),
            secondary_humidity_ratio: 0.0,
            secondary_pressure_drop_coeff: None,
            skip_pressure_eq: false,
        }
    }

    /// Sets the secondary-side quadratic pressure-drop coefficient
    /// `ΔP_sec = k·ṁ·|ṁ|` [Pa]. Pass `0` / clear for isobaric water/air path.
    pub fn with_secondary_pressure_drop_coeff(mut self, k_pa_s2_per_kg2: f64) -> Self {
        self.secondary_pressure_drop_coeff = if k_pa_s2_per_kg2 > 0.0 {
            Some(k_pa_s2_per_kg2)
        } else {
            None
        };
        self
    }

    /// See [`with_secondary_pressure_drop_coeff`](Self::with_secondary_pressure_drop_coeff).
    pub fn set_secondary_pressure_drop_coeff(&mut self, k_pa_s2_per_kg2: f64) {
        self.secondary_pressure_drop_coeff = if k_pa_s2_per_kg2 > 0.0 {
            Some(k_pa_s2_per_kg2)
        } else {
            None
        };
    }

    fn secondary_delta_p(&self, m_sec: f64) -> f64 {
        match self.secondary_pressure_drop_coeff {
            Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_sec),
            _ => 0.0,
        }
    }

    fn secondary_delta_p_dm(&self, m_sec: f64) -> f64 {
        match self.secondary_pressure_drop_coeff {
            Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_sec),
            _ => 0.0,
        }
    }

    /// Creates a condenser with a specific saturation temperature.
    pub fn with_saturation_temp(ua: f64, saturation_temp: f64) -> Self {
        let mut cond = Self::new(ua);
        cond.saturation_temp = saturation_temp;
        cond
    }

    /// Attaches a refrigerant identifier used for saturation-property queries.
    pub fn with_refrigerant(mut self, refrigerant: &str) -> Self {
        self.refrigerant_id = refrigerant.to_string();
        self
    }

    /// Attaches a fluid backend used for saturation-property queries.
    pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
        self.inner
            .set_fluid_backend_from_builder(Arc::clone(&backend));
        self.fluid_backend = Some(backend);
        self
    }

    /// Returns the name of this condenser.
    pub fn name(&self) -> &str {
        self.inner.name()
    }

    /// Returns the UA value (effective: f_ua × UA_nominal).
    pub fn ua(&self) -> f64 {
        self.inner.ua()
    }

    /// Returns calibration factors (f_ua for condenser).
    pub fn calib(&self) -> &Calib {
        self.inner.calib()
    }

    /// Sets calibration factors.
    pub fn set_calib(&mut self, calib: Calib) {
        self.inner.set_calib(calib);
    }

    /// Defines the secondary (air/water) boundary stream that drives the coupled
    /// ε-NTU duty: inlet temperature [K] and heat-capacity rate ṁ·cp [W/K].
    pub fn with_secondary_stream(mut self, inlet_temp_k: f64, capacity_rate_w_per_k: f64) -> Self {
        self.secondary_inlet_temp_k = Some(inlet_temp_k);
        self.secondary_capacity_rate = Some(capacity_rate_w_per_k.max(0.0));
        self
    }

    /// Sets the secondary boundary stream (see [`with_secondary_stream`]).
    ///
    /// [`with_secondary_stream`]: Self::with_secondary_stream
    pub fn set_secondary_stream(&mut self, inlet_temp_k: f64, capacity_rate_w_per_k: f64) {
        self.secondary_inlet_temp_k = Some(inlet_temp_k);
        self.secondary_capacity_rate = Some(capacity_rate_w_per_k.max(0.0));
    }

    /// Declares the secondary fluid for the Modelica-style 4-port mode
    /// ("Water", "INCOMP::MEG-30", "Air"…). When the secondary inlet/outlet
    /// edges are wired (ports 2 and 3), the coupled duty reads `T_sec,in` and
    /// `C_sec = ṁ_sec·cp` directly from the live edge state instead of fixed
    /// parameters.
    pub fn with_secondary_fluid(mut self, fluid: impl Into<String>) -> Self {
        self.secondary_fluid_id = fluid.into();
        self
    }

    /// Skips the pressure-equality equation (r0: P_out = P_in) in coupled mode.
    /// Use when both inlet and outlet pressures are imposed by boundary
    /// components (RefrigerantSource/Sink), making r0 redundant. Without this,
    /// the DoF is overdetermined by 1 in standalone HX configurations.
    pub fn with_skip_pressure_eq(mut self) -> Self {
        self.skip_pressure_eq = true;
        self
    }

    /// Sets the secondary fluid identifier (see [`with_secondary_fluid`]).
    ///
    /// [`with_secondary_fluid`]: Self::with_secondary_fluid
    pub fn set_secondary_fluid(&mut self, fluid: impl Into<String>) {
        self.secondary_fluid_id = fluid.into();
    }

    /// Sets the humidity ratio W [kg_v/kg_da] used by the moist-air h↔T
    /// convention on the secondary side (must match the upstream `AirSource`).
    pub fn set_secondary_humidity_ratio(&mut self, w: f64) {
        self.secondary_humidity_ratio = w.max(0.0);
    }

    /// `true` when the secondary fluid follows the moist-air linear convention
    /// (`h = 1006·T_c + W·(2 501 000 + 1860·T_c)`, as produced by `AirSource`).
    fn secondary_is_air(&self) -> bool {
        let f = self.secondary_fluid_id.trim();
        f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
    }

    /// `true` when both secondary edges are wired: the exchanger runs in true
    /// Modelica-style 4-port mode (edge-driven secondary stream).
    fn secondary_edges_ready(&self) -> bool {
        self.sec_in_idx.is_some()
            && self.sec_out_idx.is_some()
            && !self.secondary_fluid_id.is_empty()
    }

    /// `true` when the secondary inlet and outlet edges share the same branch
    /// ṁ unknown (closed pumped loop) — the secondary mass-conservation row is
    /// then trivially zero and must be dropped.
    fn sec_same_branch(&self) -> bool {
        matches!(
            (self.sec_in_idx, self.sec_out_idx),
            (Some((m_in, _, _)), Some((m_out, _, _))) if m_in == m_out
        )
    }

    /// Number of secondary-side residuals in 4-port mode.
    ///
    /// Always includes isobaric pressure closure `P_out − P_in = 0` so a
    /// Modelica `MassFlowSource_T` (Free P) + `Boundary_pT` sink pair is
    /// square: the sink anchors pressure and the HX propagates it to the
    /// source edge (ideal short secondary path; frictional ΔP is a later
    /// extension). Plus mass (dropped on a shared branch) and energy.
    fn n_secondary(&self) -> usize {
        if !self.secondary_edges_ready() {
            0
        } else if self.sec_same_branch() {
            2 // P + energy
        } else {
            3 // P + mass + energy
        }
    }

    /// Residual row index of the first secondary equation (after the thermo
    /// rows, the optional refrigerant mass row and the optional head-pressure
    /// row).
    fn sec_row_start(&self) -> usize {
        self.n_thermo()
            + usize::from(!self.same_branch_m)
            + usize::from(self.head_pressure_active())
    }

    /// Secondary specific heat cp [J/(kg·K)] at the given edge state.
    fn sec_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        if self.secondary_is_air() {
            return Ok(1006.0 + 1860.0 * self.secondary_humidity_ratio);
        }
        let cp = self.query_secondary_property(Property::Cp, p_pa, h_jkg)?;
        if cp.is_finite() && cp > 0.0 {
            Ok(cp)
        } else {
            // A non-finite Cp means the trial (P, h) state left the fluid
            // model's valid envelope (e.g. two-phase region): a recoverable
            // domain violation (KINSOL `> 0`), not a fatal defect.
            Err(ComponentError::DomainViolation(DomainViolation {
                component: None,
                detail: format!("Condenser secondary Cp is invalid: {}", cp),
            }))
        }
    }

    /// Secondary temperature [K] at the given edge state. Moist air uses the
    /// exact linear inversion of the psychrometric enthalpy; other fluids
    /// query the backend `T(P, h)`.
    fn sec_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        if self.secondary_is_air() {
            let w = self.secondary_humidity_ratio;
            let t_c = (h_jkg - 2_501_000.0 * w) / (1006.0 + 1860.0 * w);
            return Ok(t_c + 273.15);
        }
        let t = self.query_secondary_property(Property::Temperature, p_pa, h_jkg)?;
        if t.is_finite() && t > 0.0 {
            Ok(t)
        } else {
            // Non-finite/negative T at a trial state = recoverable domain
            // violation (same class as the Cp check above), not a fatal defect.
            Err(ComponentError::DomainViolation(DomainViolation {
                component: None,
                detail: format!("Condenser secondary temperature is invalid: {}", t),
            }))
        }
    }

    fn query_secondary_property(
        &self,
        property: Property,
        p_pa: f64,
        h_jkg: f64,
    ) -> Result<f64, ComponentError> {
        if !p_pa.is_finite() || p_pa <= 0.0 {
            return Err(ComponentError::InvalidState(format!(
                "Condenser secondary side has invalid pressure: {} Pa",
                p_pa
            )));
        }
        if !h_jkg.is_finite() {
            return Err(ComponentError::InvalidState(format!(
                "Condenser secondary side has invalid enthalpy: {} J/kg",
                h_jkg
            )));
        }
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::InvalidState(
                "Condenser secondary side requires a FluidBackend; no simulation fallback is allowed"
                    .to_string(),
            )
        })?;
        backend
            .property(
                FluidId::new(&self.secondary_fluid_id),
                property,
                FluidState::PressureEnthalpy(
                    Pressure::from_pascals(p_pa),
                    entropyk_core::Enthalpy::from_joules_per_kg(h_jkg),
                ),
            )
            .map_err(|e| {
                ComponentError::from_fluid_error_context(
                    &format!(
                        "Condenser failed to evaluate secondary property for fluid '{}'",
                        self.secondary_fluid_id
                    ),
                    e,
                )
            })
    }

    /// Derivative `dT_sec/dh_sec` [K·kg/J] — exact `1/cp` for both the
    /// moist-air convention and (near-)incompressible liquids.
    fn sec_dt_dh(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
        Ok(1.0 / self.sec_cp(p_pa, h_jkg)?)
    }

    /// Secondary stream `(T_sec,in [K], C_sec [W/K])`.
    ///
    /// Prefers live 4-port edges; falls back to rating scalars when no edges.
    /// Fan actuator (if active) scales rating-mode `C_sec = φ · C_nominal`.
    fn live_secondary_stream(&self, state: &StateSlice) -> Result<(f64, f64), ComponentError> {
        if self.secondary_edges_ready() {
            let (m_s, p_s, h_s) = self.sec_in_idx.unwrap();
            if m_s < state.len() && p_s < state.len() && h_s < state.len() {
                let cp = self.sec_cp(state[p_s], state[h_s])?;
                let t = self.sec_temperature(state[p_s], state[h_s])?;
                let m_mag = smooth_mass_magnitude(state[m_s], DEFAULT_M_EPS_KG_S);
                return Ok((t, m_mag * cp));
            }
        }
        if self.rating_secondary_ready() {
            let t = self.secondary_inlet_temp_k.unwrap();
            let c_nominal = self.secondary_capacity_rate.unwrap();
            // Fan head-pressure: C_sec = φ_eff · C_nominal (C¹ smooth_clamp, Story 0.4).
            let c_sec = if self.fan_active() {
                if let Some(idx) = self.fan_actuator_idx {
                    if idx < state.len() {
                        smooth_clamp(state[idx], FAN_PHI_LO, FAN_PHI_HI, FAN_PHI_WIDTH) * c_nominal
                    } else {
                        c_nominal
                    }
                } else {
                    c_nominal
                }
            } else {
                c_nominal
            };
            return Ok((t, c_sec));
        }
        Err(ComponentError::InvalidState(
            "Condenser requires live secondary edges (system mode) or rating scalars \
             (secondary_inlet_temp_* + capacity rate / mass·cp)"
                .to_string(),
        ))
    }

    /// Appends the secondary-side residuals (4-port mode).
    ///
    /// * secondary momentum: `P_sec,out − P_sec,in + ΔP_sec(ṁ) = 0`
    ///   (`ΔP_sec = 0` isobaric, or quadratic `k·ṁ·|ṁ|`)
    /// * mass conservation: `ṁ_sec,out − ṁ_sec,in = 0` (dropped on a shared branch)
    /// * energy balance:
    ///   - physical (`q = Some(Q)`): `ṁ_sec·(h_out − h_in) − Q = 0`
    ///     (the condenser rejects `Q` into the secondary stream)
    ///   - seeding (`q = None`, transient non-physical refrigerant pressure):
    ///     `h_out − h_in = 0` keeps the row well-posed and non-singular.
    fn secondary_residuals(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
        q: Option<f64>,
    ) {
        if !self.secondary_edges_ready() {
            return;
        }
        let (m_in, p_in, h_in) = self.sec_in_idx.unwrap();
        let (m_out, p_out, h_out) = self.sec_out_idx.unwrap();
        let mut row = self.sec_row_start();
        let m_sec = state[m_in];
        residuals[row] = state[p_out] - state[p_in] + self.secondary_delta_p(m_sec);
        row += 1;
        if !self.sec_same_branch() {
            residuals[row] = state[m_out] - state[m_in];
            row += 1;
        }
        residuals[row] = match q {
            // Use raw ṁ (not max(0)) so reverse-flow Newton trials stay antisymmetric.
            // C_sec already uses smooth |ṁ| so Q → 0 as ṁ_sec → 0.
            Some(q) => state[m_in] * (state[h_out] - state[h_in]) - q,
            None => state[h_out] - state[h_in],
        };
    }

    /// Appends the secondary-side Jacobian entries matching
    /// [`secondary_residuals`](Self::secondary_residuals).
    ///
    /// In physical mode (`ctx = Some(…)`) the energy row carries the exact
    /// cross-derivatives to the refrigerant inlet pressure (through
    /// `T_cond(P)`), the secondary mass flow (through `C_sec = ṁ·cp`) and the
    /// secondary inlet enthalpy (through `T_sec,in(h)`).
    #[allow(clippy::too_many_arguments)]
    fn secondary_jacobian(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
        ctx: Option<SecondaryJacCtx>,
    ) {
        if !self.secondary_edges_ready() {
            return;
        }
        let (m_in, p_in, h_in) = self.sec_in_idx.unwrap();
        let (m_out, p_out, h_out) = self.sec_out_idx.unwrap();
        let mut row = self.sec_row_start();
        let m_sec_p = state[m_in];
        jacobian.add_entry(row, p_out, 1.0);
        jacobian.add_entry(row, p_in, -1.0);
        let ddp_dm = self.secondary_delta_p_dm(m_sec_p);
        if ddp_dm != 0.0 {
            jacobian.add_entry(row, m_in, ddp_dm);
        }
        row += 1;
        if !self.sec_same_branch() {
            jacobian.add_entry(row, m_out, 1.0);
            jacobian.add_entry(row, m_in, -1.0);
            row += 1;
        }
        match ctx {
            Some(c) => {
                let m_sec = state[m_in];
                // r = ṁ_sec·(h_out − h_in) − Q(P_ref_in, ṁ_sec, h_sec_in)
                jacobian.add_entry(row, h_out, m_sec);
                // ∂r/∂h_in = −ṁ_sec − ∂Q/∂h_in, with ∂Q/∂h_in = −g·dT/dh.
                jacobian.add_entry(row, h_in, -m_sec + c.g * c.dt_sec_dh);
                // ∂r/∂ṁ_sec = Δh − ∂Q/∂ṁ_sec = Δh − g'(C_sec)·cp·ΔT.
                jacobian.add_entry(
                    row,
                    m_in,
                    (state[h_out] - state[h_in]) - c.g_prime * c.cp_sec * c.delta_t,
                );
                // ∂r/∂P_ref_in = −∂Q/∂P = −g·dT_cond/dP.
                jacobian.add_entry(row, c.ref_p_in_idx, -c.g * c.dtcond_dp);
                // Flooding actuator: ∂r/∂λ = −∂Q/∂λ_eff · dλ_eff/dλ
                // with λ_eff = smooth_clamp(λ, 0, 0.98, w) (Story 0.4).
                if let (true, Some(act_idx)) = (self.flood_ready(), self.fan_actuator_idx) {
                    let dlam = smooth_clamp_derivative(
                        state[act_idx],
                        FLOOD_LAMBDA_LO,
                        FLOOD_LAMBDA_HI,
                        FLOOD_LAMBDA_WIDTH,
                    );
                    let ua_nom = self.inner.ua_nominal() * self.live_z_ua(state);
                    jacobian.add_entry(row, act_idx, ua_nom * c.delta_t * c.e_exp * dlam);
                }
                // Live z_ua on secondary energy: r = ṁ·Δh − Q ⇒ ∂r/∂z = −∂Q/∂z
                if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
                    let flood_scale = if self.flood_ready() {
                        1.0 - self.flooded_level(state)
                    } else {
                        1.0
                    };
                    let d_q_dz =
                        self.inner.ua_nominal() * flood_scale * c.e_exp * c.delta_t;
                    jacobian.add_entry(row, z_ua_idx, -d_q_dz);
                }
            }
            None => {
                // Seeding row: r = h_out − h_in.
                jacobian.add_entry(row, h_out, 1.0);
                jacobian.add_entry(row, h_in, -1.0);
            }
        }
    }

    /// Enables a lumped quadratic refrigerant pressure drop `ΔP = k·ṁ·|ṁ|`.
    ///
    /// Prefer [`Self::with_tube_pressure_drop`] (MSH/Friedel) when tube geometry
    /// is known. Clears any tube-correlation mode.
    pub fn with_pressure_drop_coeff(mut self, k_pa_s2_per_kg2: f64) -> Self {
        self.pressure_drop_coeff = Some(k_pa_s2_per_kg2.max(0.0));
        self.tube_dp_correlation = None;
        self.tube_dp_geometry = None;
        self
    }

    /// Idealised isobaric refrigerant path (`P_out = P_in`, `ΔP = 0`).
    pub fn with_isobaric(mut self) -> Self {
        self.pressure_drop_coeff = None;
        self.tube_dp_correlation = None;
        self.tube_dp_geometry = None;
        self
    }

    /// Tube-side two-phase ΔP: friction (MSH or Friedel at mean quality) +
    /// acceleration, per NIST EVAP-COND / literature tube models.
    pub fn with_tube_pressure_drop(
        mut self,
        correlation: crate::heat_exchanger::two_phase_dp::TwoPhaseDpCorrelation,
        geometry: crate::heat_exchanger::two_phase_dp::TubeChannelGeometry,
    ) -> Self {
        self.tube_dp_correlation = Some(correlation);
        self.tube_dp_geometry = Some(geometry);
        self.pressure_drop_coeff = None;
        self
    }

    /// Sets the lumped pressure-drop coefficient (see [`with_pressure_drop_coeff`]).
    pub fn set_pressure_drop_coeff(&mut self, k_pa_s2_per_kg2: f64) {
        self.pressure_drop_coeff = Some(k_pa_s2_per_kg2.max(0.0));
        self.tube_dp_correlation = None;
        self.tube_dp_geometry = None;
    }

    /// Configures tube two-phase ΔP (see [`with_tube_pressure_drop`]).
    pub fn set_tube_pressure_drop(
        &mut self,
        correlation: crate::heat_exchanger::two_phase_dp::TwoPhaseDpCorrelation,
        geometry: crate::heat_exchanger::two_phase_dp::TubeChannelGeometry,
    ) {
        self.tube_dp_correlation = Some(correlation);
        self.tube_dp_geometry = Some(geometry);
        self.pressure_drop_coeff = None;
    }

    /// Enables air-cooled condenser fan head-pressure control.
    ///
    /// The fan speed ratio `φ` becomes a free actuator that scales the secondary
    /// air capacity rate (`C_sec = φ · C_nominal`, fan-affinity law) so the
    /// condensing temperature is held at `target_cond_temp_k`. This adds one
    /// equation `r = T_cond(P_in) − T_target` closed by the fan-speed unknown —
    /// genuine inverse head-pressure control, not a fixed design point.
    ///
    /// Requires a secondary stream (its capacity rate is the full-speed nominal)
    /// and forces emergent-pressure mode so `P_in` is free to be pinned.
    pub fn with_fan_head_pressure(mut self, target_cond_temp_k: f64) -> Self {
        self.fan_head_pressure = true;
        self.head_pressure_target_k = Some(target_cond_temp_k);
        self.emergent_pressure = true;
        self
    }

    /// Returns the fan head-pressure target condensing temperature [K], if set.
    pub fn head_pressure_target_k(&self) -> Option<f64> {
        self.head_pressure_target_k
    }

    /// Enables refrigerant-side condenser-flooding head-pressure control.
    ///
    /// The flooded liquid level `λ` becomes a free actuator that deactivates part
    /// of the condensing area (`UA_eff = (1 − λ)·UA`) so the condensing temperature
    /// is held at `target_cond_temp_k` even in low ambient. This adds one equation
    /// `r = T_cond(P_in) − T_target` closed by the level unknown — genuine inverse
    /// head-pressure control (Sporlan Head Master / ORI+ORD style), not a fixed
    /// design point. Mutually exclusive with the fan actuator (both share the
    /// single generic actuator slot and the head-pressure equation).
    ///
    /// Requires a secondary stream and forces emergent-pressure mode so `P_in`
    /// is free to be pinned by the balance.
    pub fn with_flooded_head_pressure(mut self, target_cond_temp_k: f64) -> Self {
        assert!(
            !self.fan_head_pressure,
            "Condenser: fan and flooded head-pressure control are mutually exclusive"
        );
        self.flooded_head_pressure = true;
        self.head_pressure_target_k = Some(target_cond_temp_k);
        self.emergent_pressure = true;
        self
    }

    /// Static configuration test: fan head-pressure control is requested and all
    /// build-time prerequisites (target + nominal secondary capacity rate +
    /// emergent mode) are present. Used for a consistent `n_equations` before the
    /// actuator index is wired.
    fn fan_active(&self) -> bool {
        self.fan_head_pressure
            && self.emergent_pressure
            && self.head_pressure_target_k.is_some()
            && self.secondary_capacity_rate.is_some()
    }

    /// `true` when the fan actuator is fully wired (config + resolved actuator
    /// state index), so the head-pressure residual/Jacobian can act.
    fn fan_ready(&self) -> bool {
        self.fan_active() && self.fan_actuator_idx.is_some()
    }

    /// Static configuration test: condenser-flooding head-pressure control is
    /// requested with all build-time prerequisites present.
    fn flood_active(&self) -> bool {
        self.flooded_head_pressure
            && self.emergent_pressure
            && self.head_pressure_target_k.is_some()
            && self.secondary_capacity_rate.is_some()
    }

    /// `true` when the flooding actuator is fully wired (config + resolved
    /// actuator state index).
    fn flood_ready(&self) -> bool {
        self.flood_active() && self.fan_actuator_idx.is_some()
    }

    /// Either head-pressure actuator (fan or flooding) is configured. One extra
    /// equation `T_cond = T_target` is emitted in this case.
    fn head_pressure_active(&self) -> bool {
        self.fan_active() || self.flood_active()
    }

    /// Either head-pressure actuator is fully wired and may act.
    fn head_pressure_ready(&self) -> bool {
        self.fan_ready() || self.flood_ready()
    }

    /// Flooded liquid level `λ_eff ∈ [0, 0.98]` read from the generic actuator
    /// slot (0.0 when no active/ready flooding actuator). Mapped through
    /// [`smooth_clamp`] so ∂/∂λ stays C¹ at the bounds (Story 0.4); upper bound
    /// keeps a sliver of condensing area (finite duty).
    fn flooded_level(&self, state: &StateSlice) -> f64 {
        match (self.flood_ready(), self.fan_actuator_idx) {
            (true, Some(idx)) if idx < state.len() => smooth_clamp(
                state[idx],
                FLOOD_LAMBDA_LO,
                FLOOD_LAMBDA_HI,
                FLOOD_LAMBDA_WIDTH,
            ),
            _ => 0.0,
        }
    }

    /// Live calibration factor `z_ua` from Newton state when free, else calib param.
    fn live_z_ua(&self, state: &StateSlice) -> f64 {
        self.inner
            .calib_indices_ref()
            .z_ua
            .and_then(|idx| state.get(idx).copied())
            .unwrap_or_else(|| self.calib().z_ua)
            .max(0.0)
    }

    /// Effective conductance `UA_eff` [W/K] used by the coupled duty.
    ///
    /// `UA_eff = UA_nominal · z_ua · (1 − λ)` when flooding is active, else
    /// `UA_nominal · z_ua`. Reads live `state[z_ua]` when an embedding frees it
    /// (otherwise ∂Q/∂z_ua = 0 → singular Jacobian).
    fn effective_ua(&self, state: &StateSlice) -> f64 {
        let ua = self.inner.ua_nominal() * self.live_z_ua(state);
        if self.flood_ready() {
            ua * (1.0 - self.flooded_level(state))
        } else {
            ua
        }
    }

    /// Derivative `d(ε·C_sec)/dC_sec` for the phase-changing effectiveness
    /// `ε = 1 − exp(−UA/C_sec)`. `g(C) = C·(1 − exp(−UA/C))`,
    /// `g'(C) = (1 − e) − (UA/C)·e` with `e = exp(−UA/C)`. Used for the exact
    /// `∂Q/∂φ` fan-actuator Jacobian entry.
    fn d_eps_csec_d_csec(&self, c_sec: f64) -> f64 {
        let ua = self.ua();
        if c_sec <= 1e-10 || ua <= 0.0 {
            return 0.0;
        }
        let e = (-ua / c_sec).exp();
        (1.0 - e) - (ua / c_sec) * e
    }

    /// Resolves the mass-flow state index only when it maps to a genuine
    /// mass-flow slot (never falls back to a pressure column).
    #[inline]
    fn resolved_mass_idx(&self) -> Option<usize> {
        self.inlet_m_idx.or(self.outlet_m_idx)
    }

    /// Signed refrigerant ΔP [Pa]: tube MSH/Friedel (+ acceleration) if
    /// configured, else lumped quadratic, else 0.
    ///
    /// The tube arm is **total and C¹** (see [`crate::heat_exchanger::tube_dp`]
    /// and the Story 0.2 phantom-gradient regularization standard): it never
    /// silently switches to the lumped model when a Newton iterate leaves the
    /// saturation domain — the query pressure is C¹-clamped into the detected
    /// domain and the latent heat is smooth-floored, so a hard backend failure
    /// is a recoverable [`ComponentError::DomainViolation`] (Story 1.3), not a
    /// model switch. The only remaining lumped fallback is solve-invariant
    /// (no backend / empty refrigerant id / no detectable saturation domain),
    /// so the active ΔP model cannot change mid-solve.
    fn refrigerant_pressure_drop(
        &self,
        m_ref: f64,
        p_pa: f64,
        h_in: f64,
        h_out: f64,
    ) -> Result<f64, ComponentError> {
        if self.resolved_mass_idx().is_none() {
            return Ok(0.0);
        }
        if let (Some(corr), Some(geom)) = (self.tube_dp_correlation, self.tube_dp_geometry) {
            if let Some(backend) = self.fluid_backend.as_ref() {
                if let Some(state) = crate::heat_exchanger::tube_dp::sat_dp_value_state(
                    backend,
                    &self.refrigerant_id,
                    p_pa,
                )? {
                    let x_in = state.quality(h_in);
                    let x_out = state.quality(h_out);
                    return Ok(crate::heat_exchanger::two_phase_dp::tube_two_phase_delta_p(
                        corr,
                        &geom,
                        m_ref,
                        x_in,
                        x_out,
                        &state.props,
                    ));
                }
            }
        }
        Ok(match self.pressure_drop_coeff {
            Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref),
            _ => 0.0,
        })
    }

    /// ΔP value + **exact** partials (∂/∂ṁ, ∂/∂P_in, ∂/∂h_in, ∂/∂h_out) for the
    /// momentum-row Jacobian (NFR9 exact-analytic policy).
    ///
    /// The tube ΔP composition is differentiated analytically (ṁ → mass flux →
    /// friction + acceleration; h → unclamped qualities; P → saturated
    /// properties) via [`crate::heat_exchanger::tube_dp`]; only the individual
    /// saturation-property pressure derivatives use narrow domain-safe FDs
    /// (the thin CoolProp FFI exposes no saturation derivatives). The lumped
    /// quadratic arm contributes its analytic `2·k·|ṁ|` only.
    fn refrigerant_pressure_drop_jacobian(
        &self,
        m_ref: f64,
        p_pa: f64,
        h_in: f64,
        h_out: f64,
    ) -> Result<crate::heat_exchanger::tube_dp::TubeDpEvaluation, ComponentError> {
        if self.resolved_mass_idx().is_none() {
            return Ok(crate::heat_exchanger::tube_dp::TubeDpEvaluation::default());
        }
        if let (Some(corr), Some(geom)) = (self.tube_dp_correlation, self.tube_dp_geometry) {
            if let Some(backend) = self.fluid_backend.as_ref() {
                if let Some(state) = crate::heat_exchanger::tube_dp::sat_dp_full_state(
                    backend,
                    &self.refrigerant_id,
                    p_pa,
                )? {
                    return Ok(crate::heat_exchanger::tube_dp::tube_dp_evaluation(
                        corr, &geom, m_ref, h_in, h_out, &state,
                    ));
                }
            }
        }
        Ok(match self.pressure_drop_coeff {
            Some(k) if k > 0.0 => crate::heat_exchanger::tube_dp::TubeDpEvaluation {
                value: crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref),
                d_dm: crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_ref),
                ..crate::heat_exchanger::tube_dp::TubeDpEvaluation::default()
            },
            _ => crate::heat_exchanger::tube_dp::TubeDpEvaluation::default(),
        })
    }

    /// Enables the emergent-pressure mode with the given sub-cooling target [K].
    ///
    /// In this mode an extra outlet-closure residual pins the refrigerant outlet
    /// enthalpy to `h(P, T_cond(P) − subcooling_k)`. Combined with the ε-NTU energy
    /// balance this makes the condensing pressure emerge from the secondary
    /// conditions rather than being imposed by the compressor. Requires a secondary
    /// stream ([`with_secondary_stream`]).
    ///
    /// [`with_secondary_stream`]: Self::with_secondary_stream
    pub fn with_emergent_pressure(mut self, subcooling_k: f64) -> Self {
        self.emergent_pressure = true;
        self.subcooling_k = subcooling_k.max(0.0);
        self
    }

    /// Number of thermodynamic residuals emitted (2 normally, 3 in emergent mode
    /// where the outlet-closure equation pins the condensing pressure).
    fn n_thermo(&self) -> usize {
        let base = if self.skip_pressure_eq {
            1
        } else {
            self.inner.n_equations()
        };
        base + if self.emergent_pressure { 1 } else { 0 }
    }

    /// Residual row index of the head-pressure equation (fan or flooding),
    /// appended after the thermodynamic + optional mass-conservation rows.
    fn head_pressure_row(&self) -> usize {
        let thermo = self.n_thermo();
        if self.same_branch_m {
            thermo
        } else {
            thermo + 1
        }
    }

    /// Target outlet enthalpy `h(P, T_cond(P) − subcooling_k)` [J/kg] (emergent mode).
    fn emergent_outlet_enthalpy(&self, p_pa: f64) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed("Condenser: no fluid backend".to_string())
        })?;
        let fluid = FluidId::new(&self.refrigerant_id);
        if self.subcooling_k <= 1e-9 {
            return self.h_sat_liq_at_p(backend.as_ref(), fluid, p_pa);
        }
        let t_cond = self.cond_temperature(p_pa)?;
        backend
            .property(
                fluid,
                Property::Enthalpy,
                FluidState::PressureTemperature(
                    Pressure::from_pascals(p_pa),
                    entropyk_core::Temperature::from_kelvin(t_cond - self.subcooling_k),
                ),
            )
            .map_err(ComponentError::from_fluid_error)
    }

    /// Condensing (saturation) temperature of the refrigerant at pressure `p_pa` [K].
    fn cond_temperature(&self, p_pa: f64) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed("Condenser: no fluid backend".to_string())
        })?;
        // Clamp into the detected saturation domain so the condensing
        // temperature — and every residual derived from it — stays defined
        // when a Newton iterate leaves the domain (see sat_domain).
        let p_pa = crate::heat_exchanger::sat_domain::clamp_to_saturation_domain(
            backend,
            &self.refrigerant_id,
            p_pa,
        )
        .unwrap_or(p_pa);
        backend
            .property(
                FluidId::new(&self.refrigerant_id),
                Property::Temperature,
                FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.5)),
            )
            .map_err(ComponentError::from_fluid_error)
    }

    /// Measured liquid-line subcooling `T_cond(P_out) − T(P_out, h_out)` [K]
    /// from the solved state, for inverse control / `controls[]` loops.
    fn measured_subcooling(&self, state: &StateSlice) -> Option<f64> {
        let backend = self.fluid_backend.as_ref()?;
        if self.refrigerant_id.is_empty() {
            return None;
        }
        let p_idx = self.outlet_p_idx?;
        let h_idx = self.outlet_h_idx?;
        if p_idx >= state.len() || h_idx >= state.len() {
            return None;
        }
        let p_out = state[p_idx];
        let h_out = state[h_idx];
        if !p_out.is_finite() || !h_out.is_finite() || p_out <= 0.0 {
            return None;
        }
        let fluid = FluidId::new(&self.refrigerant_id);
        let t_out = backend
            .property(
                fluid,
                Property::Temperature,
                FluidState::PressureEnthalpy(
                    Pressure::from_pascals(p_out),
                    entropyk_core::Enthalpy::from_joules_per_kg(h_out),
                ),
            )
            .ok()?;
        let t_sat = self.cond_temperature(p_out).ok()?;
        let sc = t_sat - t_out;
        sc.is_finite().then_some(sc)
    }

    /// Measured condensing (saturation) temperature SDT `T_sat(P_out)` [K]
    /// from the solved state, for head-pressure control loops.
    fn measured_saturation_temperature(&self, state: &StateSlice) -> Option<f64> {
        if self.fluid_backend.is_none() || self.refrigerant_id.is_empty() {
            return None;
        }
        let p_idx = self.outlet_p_idx.or(self.inlet_p_idx)?;
        if p_idx >= state.len() {
            return None;
        }
        let p = state[p_idx];
        if !p.is_finite() || p <= 0.0 {
            return None;
        }
        let t_sat = self.cond_temperature(p).ok()?;
        t_sat.is_finite().then_some(t_sat)
    }

    /// Effectiveness for a phase-changing refrigerant (`C_min = C_sec`, `C_r → 0`):
    /// `ε = 1 − exp(−UA / C_sec)` at the nominal conductance.
    fn effectiveness(&self, c_sec: f64) -> f64 {
        self.effectiveness_ua(c_sec, self.ua())
    }

    /// Effectiveness at an explicit conductance `ua` [W/K]: `ε = 1 − exp(−ua/C_sec)`.
    /// Used by the flooding actuator, which lowers the effective conductance
    /// (`UA_eff = (1 − λ)·UA`) to raise the condensing temperature.
    fn effectiveness_ua(&self, c_sec: f64, ua: f64) -> f64 {
        if c_sec <= 1e-10 || ua <= 0.0 {
            return 0.0;
        }
        1.0 - (-ua / c_sec).exp()
    }

    /// Rating scalars present: `T_sec,in` and strictly positive `C_sec`.
    fn rating_secondary_ready(&self) -> bool {
        matches!(
            (self.secondary_inlet_temp_k, self.secondary_capacity_rate),
            (Some(t), Some(c)) if t.is_finite() && c.is_finite() && c > 0.0
        )
    }

    /// Returns `true` when all prerequisites for the coupled ε-NTU model are met:
    /// refrigerant indices + either live secondary edges (system) or rating scalars.
    fn coupled_ready(&self) -> bool {
        self.fluid_backend.is_some()
            && !self.refrigerant_id.is_empty()
            && self.inlet_p_idx.is_some()
            && self.inlet_h_idx.is_some()
            && self.outlet_p_idx.is_some()
            && self.outlet_h_idx.is_some()
            && (self.secondary_edges_ready() || self.rating_secondary_ready())
    }

    /// Coupled condenser duty `Q = ε·C_sec·(T_cond(P_in) − T_sec,in)` in watts.
    ///
    /// Positive `Q` means heat flows from the refrigerant into the secondary fluid.
    #[cfg_attr(not(test), allow(dead_code))]
    fn coupled_duty(&self, p_in_pa: f64) -> Result<f64, ComponentError> {
        self.coupled_duty_with_csec(p_in_pa, self.secondary_capacity_rate.unwrap_or(0.0))
    }

    /// Coupled duty at an explicit secondary capacity rate `c_sec` [W/K].
    ///
    /// Used by the fan head-pressure actuator, which scales `C_sec` with the fan
    /// speed ratio (`C_sec = φ · C_nominal`).
    fn coupled_duty_with_csec(&self, p_in_pa: f64, c_sec: f64) -> Result<f64, ComponentError> {
        self.coupled_duty_full(p_in_pa, c_sec, self.ua())
    }

    /// Coupled duty at an explicit capacity rate `c_sec` and conductance `ua`
    /// [W/K]: `Q = ε(c_sec, ua)·c_sec·(T_cond(P_in) − T_sec,in)`. The flooding
    /// actuator feeds `ua = (1 − λ)·UA_nominal`.
    fn coupled_duty_full(&self, p_in_pa: f64, c_sec: f64, ua: f64) -> Result<f64, ComponentError> {
        let t_sec_in = self.secondary_inlet_temp_k.unwrap_or(0.0);
        let t_cond = self.cond_temperature(p_in_pa)?;
        let eps = self.effectiveness_ua(c_sec, ua);
        Ok(eps * c_sec * (t_cond - t_sec_in))
    }

    /// Rates the condenser at a fixed refrigerant regime (constant condensing
    /// pressure `p_in_pa`) against the configured secondary (air/water) stream.
    ///
    /// Qualification entry point: keep the refrigerant regime constant, vary the
    /// secondary inlet temperature and/or flow, and read the genuine ε-NTU response
    /// (`C_min = C_sec` for a phase-changing refrigerant). Nothing is imposed:
    ///
    /// - `q_w`            : `Q = ε·C_sec·(T_cond − T_sec,in)` [W] rejected to the secondary
    /// - `effectiveness`  : `ε = 1 − exp(−UA / C_sec)` [-]
    /// - `t_cond_k`       : condensing temperature `T_sat(P)` [K]
    /// - `approach_k`     : `T_cond − T_sec,in` [K]
    /// - `secondary_outlet_k` : `T_sec,in + Q/C_sec` [K]
    pub fn rate(&self, p_in_pa: f64) -> Result<CondenserRating, ComponentError> {
        let c_sec = self.secondary_capacity_rate.ok_or_else(|| {
            ComponentError::InvalidState(
                "Condenser::rate requires a secondary stream (set_secondary_stream)".to_string(),
            )
        })?;
        let t_sec_in = self.secondary_inlet_temp_k.ok_or_else(|| {
            ComponentError::InvalidState(
                "Condenser::rate requires a secondary inlet temperature".to_string(),
            )
        })?;
        let t_cond = self.cond_temperature(p_in_pa)?;
        let eps = self.effectiveness(c_sec);
        let q = eps * c_sec * (t_cond - t_sec_in);
        let secondary_outlet_k = if c_sec > 1e-10 {
            t_sec_in + q / c_sec
        } else {
            t_sec_in
        };
        Ok(CondenserRating {
            q_w: q,
            effectiveness: eps,
            t_cond_k: t_cond,
            approach_k: t_cond - t_sec_in,
            secondary_outlet_k,
        })
    }

    /// Returns the saturation temperature.
    pub fn saturation_temp(&self) -> f64 {
        self.saturation_temp
    }

    /// Sets the saturation temperature.
    pub fn set_saturation_temp(&mut self, temp: f64) {
        self.saturation_temp = temp;
    }

    /// Overrides the effective UA value [W/K] at runtime.
    ///
    /// Sets the UA scale factor so that `UA_nominal × scale = ua_value`.
    /// Used by `MchxCondenserCoil` to apply fan-speed and air-density corrections.
    pub fn set_ua(&mut self, ua_value: f64) {
        let ua_nominal = self.inner.ua_nominal();
        let scale = if ua_nominal > 0.0 {
            ua_value / ua_nominal
        } else {
            1.0
        };
        self.inner.set_ua_scale(scale.max(0.0));
    }

    /// Validates that the outlet quality is <= 1 (fully condensed or subcooled).
    ///
    /// # Arguments
    ///
    /// * `outlet_enthalpy` - Outlet specific enthalpy (J/kg)
    /// * `h_liquid` - Saturated liquid enthalpy at condensing pressure
    /// * `h_vapor` - Saturated vapor enthalpy at condensing pressure
    ///
    /// # Returns
    ///
    /// Returns Ok(true) if fully condensed, Err otherwise
    pub fn validate_outlet_quality(
        &self,
        outlet_enthalpy: f64,
        h_liquid: f64,
        h_vapor: f64,
    ) -> Result<bool, ComponentError> {
        if h_vapor <= h_liquid {
            return Err(ComponentError::NumericalError(
                "Invalid saturation enthalpies".to_string(),
            ));
        }

        let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid);

        if quality <= 0.0 + 1e-6 {
            Ok(true)
        } else {
            Err(ComponentError::InvalidState(format!(
                "Condenser outlet quality {} > 0 (not fully condensed)",
                quality
            )))
        }
    }

    /// Returns sat-liquid enthalpy at a given pressure [J/kg] (used for residuals).
    fn h_sat_liq_at_p(
        &self,
        backend: &dyn FluidBackend,
        fluid: FluidId,
        p_pa: f64,
    ) -> Result<f64, ComponentError> {
        backend
            .property(
                fluid,
                Property::Enthalpy,
                FluidState::PressureQuality(Pressure::from_pascals(p_pa), Quality(0.0)),
            )
            .map_err(|e| ComponentError::CalculationFailed(e.to_string()))
    }

    /// Computes the full thermodynamic state at the hot (refrigerant) inlet.
    pub fn hot_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
        self.inner.hot_inlet_state()
    }

    /// Computes the full thermodynamic state at the cold inlet.
    pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
        self.inner.cold_inlet_state()
    }

    /// Returns the hot side fluid identifier, if set.
    pub fn hot_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
        self.inner.hot_fluid_id()
    }

    /// Sets the cold side boundary conditions.
    pub fn set_cold_conditions(&mut self, conditions: super::exchanger::HxSideConditions) {
        self.inner.set_cold_conditions(conditions);
    }

    /// Returns the cold side fluid identifier, if set.
    pub fn cold_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
        self.inner.cold_fluid_id()
    }
}

impl Component for Condenser {
    fn set_system_context(
        &mut self,
        _state_offset: usize,
        external_edge_state_indices: &[(usize, usize, usize)],
    ) {
        // external_edge_state_indices layout (from system.rs finalize):
        //   [0..n_incoming]: incoming edges (hot refrigerant inlet from compressor)
        //   [n_incoming..]: outgoing edges (hot refrigerant outlet to EXV)
        // For a typical single-circuit condenser: 1 incoming + 1 outgoing.
        // Triple: (m_idx, p_idx, h_idx)
        if !external_edge_state_indices.is_empty() {
            self.inlet_m_idx = Some(external_edge_state_indices[0].0);
            self.inlet_p_idx = Some(external_edge_state_indices[0].1);
            self.inlet_h_idx = Some(external_edge_state_indices[0].2);
        }
        if external_edge_state_indices.len() >= 2 {
            self.outlet_m_idx = Some(external_edge_state_indices[1].0);
            self.outlet_p_idx = Some(external_edge_state_indices[1].1);
            self.outlet_h_idx = Some(external_edge_state_indices[1].2);
        }
        // CM1.4: detect same-branch topology.
        self.same_branch_m = matches!(
            (self.inlet_m_idx, self.outlet_m_idx),
            (Some(m_in), Some(m_out)) if m_in == m_out
        );
        self.inner
            .set_system_context(_state_offset, external_edge_state_indices);
    }

    fn compute_residuals(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        let n_thermo = self.n_thermo(); // 2, or 3 in emergent mode

        // Coupled ε-NTU path: when a secondary (water/air) stream is configured, the
        // condenser duty Q = ε·C_sec·(T_cond(P_in) − T_sec,in) is rejected to that
        // stream and the refrigerant outlet enthalpy follows the energy balance
        // ṁ·(h_in − h_out) = Q. The condenser load — and the degree of condensation /
        // subcooling — then react to the secondary inlet temperature and flow.
        if self.coupled_ready() {
            if residuals.len() < self.n_equations() {
                return Err(ComponentError::InvalidResidualDimensions {
                    expected: self.n_equations(),
                    actual: residuals.len(),
                });
            }
            let inlet_p_idx = self.inlet_p_idx.unwrap();
            let p_in = state[inlet_p_idx];
            if p_in > 10_000.0 {
                let inlet_h_idx = self.inlet_h_idx.unwrap();
                let outlet_p_idx = self.outlet_p_idx.unwrap();
                let outlet_h_idx = self.outlet_h_idx.unwrap();
                let m_idx = self.inlet_m_idx.or(self.outlet_m_idx).ok_or_else(|| {
                    ComponentError::InvalidState(
                        "mass-flow state index not resolved (cannot fall back to a pressure index)"
                            .into(),
                    )
                })?;

                let m_ref = state[m_idx];
                let h_in = state[inlet_h_idx];
                let h_out = state[outlet_h_idx];
                // Live secondary stream: edge-driven in 4-port mode (Modelica).
                let (t_sec_in, c_sec) = self.live_secondary_stream(state)?;
                let ua_eff = self.effective_ua(state);
                let t_cond = self.cond_temperature(p_in)?;
                let eps = self.effectiveness_ua(c_sec, ua_eff);
                let q = eps * c_sec * (t_cond - t_sec_in);

                // r0: refrigerant pressure drop (tube MSH/Friedel + accel, or
                // lumped quadratic): P_out = P_in − ΔP.
                let dp_drop = self.refrigerant_pressure_drop(m_ref, p_in, h_in, h_out)?;
                let mut row = 0;
                if !self.skip_pressure_eq {
                    residuals[row] = state[outlet_p_idx] - (p_in - dp_drop);
                    row += 1;
                }
                residuals[row] = m_ref * (h_in - h_out) - q;
                row += 1;

                if self.emergent_pressure {
                    residuals[row] = state[outlet_h_idx] - self.emergent_outlet_enthalpy(p_in)?;
                }

                if !self.same_branch_m {
                    residuals[n_thermo] = match (self.inlet_m_idx, self.outlet_m_idx) {
                        (Some(m_in), Some(m_out)) => state[m_out] - state[m_in],
                        _ => 0.0,
                    };
                }

                // Head-pressure equation: T_cond(P_in) = T_target, closed by the
                // active free actuator (fan speed φ scales C_sec, or flooding level
                // λ scales UA_eff above). Same closure for both mechanisms.
                if self.head_pressure_active() {
                    let row = self.head_pressure_row();
                    residuals[row] = if self.head_pressure_ready() {
                        self.cond_temperature(p_in)? - self.head_pressure_target_k.unwrap()
                    } else {
                        0.0
                    };
                }

                // 4-port mode: secondary mass conservation + energy balance
                // (the secondary stream absorbs Q).
                self.secondary_residuals(state, residuals, Some(q));
                return Ok(());
            } else if self.emergent_pressure {
                // Transient seeding (P_in not yet physical): keep all three emergent
                // residuals defined so the residual/DoF count stays consistent. Seed
                // the pressure and outlet from the nominal saturation temperature.
                let backend = self.fluid_backend.as_ref().unwrap();
                let fluid = FluidId::new(&self.refrigerant_id);
                let outlet_p_idx = self.outlet_p_idx.unwrap();
                let outlet_h_idx = self.outlet_h_idx.unwrap();
                let p_cond_sat = backend
                    .saturation_pressure_t(fluid.clone(), self.saturation_temp)
                    .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                let h_sat_liq = backend
                    .saturation_enthalpy_t(fluid, self.saturation_temp, 0.0)
                    .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                residuals[0] = state[outlet_p_idx] - p_cond_sat;
                residuals[1] = state[inlet_p_idx] - p_cond_sat;
                residuals[2] = state[outlet_h_idx] - h_sat_liq;
                if !self.same_branch_m {
                    residuals[n_thermo] = match (self.inlet_m_idx, self.outlet_m_idx) {
                        (Some(m_in), Some(m_out)) => state[m_out] - state[m_in],
                        _ => 0.0,
                    };
                }
                // Transient head-pressure row: keep the actuator well-posed until
                // P_in becomes physical — drive the fan speed toward full (φ → 1)
                // or the flooding level toward empty (λ → 0), i.e. maximum area.
                if self.head_pressure_active() {
                    let row = self.head_pressure_row();
                    let seed = if self.fan_active() { 1.0 } else { 0.0 };
                    residuals[row] = match (self.head_pressure_ready(), self.fan_actuator_idx) {
                        (true, Some(idx)) => state[idx] - seed,
                        _ => 0.0,
                    };
                }
                // 4-port seeding: keep the secondary rows well-posed
                // (mass conservation + h_out = h_in).
                self.secondary_residuals(state, residuals, None);
                return Ok(());
            }
        }

        if let (Some(backend), Some(outlet_p_idx), Some(outlet_h_idx)) =
            (&self.fluid_backend, self.outlet_p_idx, self.outlet_h_idx)
        {
            if !self.refrigerant_id.is_empty() {
                if residuals.len() < self.n_equations() {
                    return Err(ComponentError::InvalidResidualDimensions {
                        expected: self.n_equations(),
                        actual: residuals.len(),
                    });
                }

                if let Some(inlet_p_idx) = self.inlet_p_idx {
                    let p_in = state[inlet_p_idx];
                    // Only use coupled model when inlet pressure is physically plausible.
                    // During solver initialization (P≈0), fall back to fixed T_cond model.
                    if p_in > 10_000.0 {
                        let h_sat_liq = self.h_sat_liq_at_p(
                            backend.as_ref(),
                            FluidId::new(&self.refrigerant_id),
                            p_in,
                        )?;
                        residuals[0] = state[outlet_p_idx] - p_in;
                        residuals[1] = state[outlet_h_idx] - h_sat_liq;
                    } else {
                        let fluid = FluidId::new(&self.refrigerant_id);
                        let p_cond_sat = backend
                            .saturation_pressure_t(fluid.clone(), self.saturation_temp)
                            .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                        let h_sat_liq = backend
                            .saturation_enthalpy_t(fluid, self.saturation_temp, 0.0)
                            .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                        residuals[0] = state[outlet_p_idx] - p_cond_sat;
                        residuals[1] = state[outlet_h_idx] - h_sat_liq;
                    }
                } else {
                    // Fallback: fixed saturation-temperature model (no inlet coupling).
                    let fluid = FluidId::new(&self.refrigerant_id);
                    let p_cond_sat = backend
                        .saturation_pressure_t(fluid.clone(), self.saturation_temp)
                        .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                    let h_sat_liq = backend
                        .saturation_enthalpy_t(fluid, self.saturation_temp, 0.0)
                        .map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
                    residuals[0] = state[outlet_p_idx] - p_cond_sat;
                    residuals[1] = state[outlet_h_idx] - h_sat_liq;
                }
                // r[n_thermo] = ṁ_outlet − ṁ_inlet = 0  (mass conservation, CM1.3)
                // CM1.4: skip when same_branch_m — trivially zero.
                if !self.same_branch_m {
                    residuals[n_thermo] = match (self.inlet_m_idx, self.outlet_m_idx) {
                        (Some(m_in), Some(m_out)) => state[m_out] - state[m_in],
                        _ => 0.0,
                    };
                }
                // 4-port: secondary rows stay defined even on this transient path.
                self.secondary_residuals(state, residuals, None);
                return Ok(());
            }
        }
        Err(ComponentError::InvalidState(
            "Condenser requires refrigerant inlet/outlet indices with backend/refrigerant, \
             and either live secondary ports (system) or rating scalars \
             (secondary_inlet_temp_* + capacity rate); refusing generic HX fallback"
                .to_string(),
        ))
    }

    fn jacobian_entries(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        let n_thermo = self.n_thermo(); // 2, or 3 in emergent mode

        // Coupled ε-NTU path: analytical Jacobian of the energy balance.
        if self.coupled_ready() {
            let inlet_p_idx = self.inlet_p_idx.unwrap();
            let p_in = state[inlet_p_idx];
            if p_in > 10_000.0 {
                let inlet_h_idx = self.inlet_h_idx.unwrap();
                let outlet_p_idx = self.outlet_p_idx.unwrap();
                let outlet_h_idx = self.outlet_h_idx.unwrap();
                let m_idx = self.inlet_m_idx.or(self.outlet_m_idx).ok_or_else(|| {
                    ComponentError::InvalidState(
                        "mass-flow state index not resolved (cannot fall back to a pressure index)"
                            .into(),
                    )
                })?;

                let m_ref = state[m_idx];
                let h_in = state[inlet_h_idx];
                let h_out = state[outlet_h_idx];

                let mut row = 0;
                if !self.skip_pressure_eq {
                    jacobian.add_entry(row, outlet_p_idx, 1.0);
                    jacobian.add_entry(row, inlet_p_idx, -1.0);
                    // Momentum-row tube-ΔP partials: exact analytic composition
                    // (∂/∂ṁ, ∂/∂P_in through the saturated properties, ∂/∂h_in
                    // and ∂/∂h_out through the unclamped qualities) — NFR9.
                    let eval = self.refrigerant_pressure_drop_jacobian(m_ref, p_in, h_in, h_out)?;
                    if let Some(m_real) = self.resolved_mass_idx() {
                        if eval.d_dm != 0.0 {
                            // r0 = P_out − P_in + ΔP  ⇒  ∂r0/∂ṁ = ∂ΔP/∂ṁ
                            jacobian.add_entry(row, m_real, eval.d_dm);
                        }
                    }
                    if eval.d_dp != 0.0 {
                        jacobian.add_entry(row, inlet_p_idx, eval.d_dp);
                    }
                    if eval.d_dh_in != 0.0 {
                        jacobian.add_entry(row, inlet_h_idx, eval.d_dh_in);
                    }
                    if eval.d_dh_out != 0.0 {
                        jacobian.add_entry(row, outlet_h_idx, eval.d_dh_out);
                    }
                    row += 1;
                }

                jacobian.add_entry(row, inlet_h_idx, m_ref);
                jacobian.add_entry(row, outlet_h_idx, -m_ref);
                jacobian.add_entry(row, m_idx, h_in - h_out);

                // ∂r1/∂P_in = −∂Q/∂P_in = −ε·C_sec·dT_cond/dP_in (T_sec,in constant),
                // dT_cond/dP via central finite difference. ε uses the effective
                // conductance so a flooded level (UA_eff) is reflected exactly.
                let (t_sec_in, c_sec) = self.live_secondary_stream(state)?;
                let ua_eff = self.effective_ua(state);
                let eps = self.effectiveness_ua(c_sec, ua_eff);
                let g = eps * c_sec;
                let t_cond = self.cond_temperature(p_in)?;
                let dp = p_in * 1e-4 + 100.0;
                let t_plus = self.cond_temperature(p_in + dp)?;
                let t_minus = self.cond_temperature((p_in - dp).max(1.0))?;
                let dt_dp = (t_plus - t_minus) / (2.0 * dp);
                jacobian.add_entry(1, inlet_p_idx, -g * dt_dp);

                // 4-port cross-derivatives of r1 to the secondary edge state:
                // ∂r1/∂h_sec,in = −∂Q/∂h_sec,in = +g·dT_sec/dh (exact 1/cp),
                // ∂r1/∂ṁ_sec    = −∂Q/∂ṁ_sec    = −g'(C_sec)·cp·(T_cond − T_sec,in).
                let sec_ctx = if self.secondary_edges_ready() {
                    let (m_s, p_s, h_s) = self.sec_in_idx.unwrap();
                    let cp_sec = self.sec_cp(state[p_s], state[h_s])?;
                    let dt_dh = self.sec_dt_dh(state[p_s], state[h_s])?;
                    let g_prime = {
                        let ua = ua_eff;
                        if c_sec <= 1e-10 || ua <= 0.0 {
                            0.0
                        } else {
                            let e = (-ua / c_sec).exp();
                            (1.0 - e) - (ua / c_sec) * e
                        }
                    };
                    jacobian.add_entry(row, h_s, g * dt_dh);
                    jacobian.add_entry(row, m_s, -g_prime * cp_sec * (t_cond - t_sec_in));
                    Some(SecondaryJacCtx {
                        g,
                        g_prime,
                        cp_sec,
                        dt_sec_dh: dt_dh,
                        delta_t: t_cond - t_sec_in,
                        dtcond_dp: dt_dp,
                        ref_p_in_idx: inlet_p_idx,
                        e_exp: if c_sec > 1e-10 {
                            (-ua_eff / c_sec).exp()
                        } else {
                            0.0
                        },
                    })
                } else {
                    None
                };

                // ∂r1/∂φ = −∂Q/∂φ_eff · dφ_eff/dφ with
                // φ_eff = smooth_clamp(φ, 0, 1.5, w), C_sec = φ_eff·C_nominal,
                // g(C) = ε(C)·C (Story 0.4 chain rule).
                if self.fan_ready() && !self.secondary_edges_ready() {
                    if let (Some(fan_idx), Some(t_sec_param)) =
                        (self.fan_actuator_idx, self.secondary_inlet_temp_k)
                    {
                        let c_nominal = self.secondary_capacity_rate.unwrap_or(0.0);
                        let g_prime = self.d_eps_csec_d_csec(c_sec);
                        let dphi = smooth_clamp_derivative(
                            state[fan_idx],
                            FAN_PHI_LO,
                            FAN_PHI_HI,
                            FAN_PHI_WIDTH,
                        );
                        jacobian.add_entry(
                            row,
                            fan_idx,
                            -g_prime * c_nominal * (t_cond - t_sec_param) * dphi,
                        );
                    }
                }

                // ∂r1/∂λ = −∂Q/∂λ_eff · dλ_eff/dλ. With UA_eff = (1 − λ_eff)·UA_nom,
                // λ_eff = smooth_clamp(λ, 0, 0.98, w),
                // ∂Q/∂λ_eff = −UA_nom·(T_cond − T_sec,in)·e ⇒
                // ∂r1/∂λ = +UA_nom·(T_cond − T_sec,in)·e · dλ_eff/dλ.
                if self.flood_ready() {
                    if let Some(lvl_idx) = self.fan_actuator_idx {
                        let ua_nom = self.inner.ua_nominal() * self.live_z_ua(state);
                        let e = if c_sec > 1e-10 {
                            (-ua_eff / c_sec).exp()
                        } else {
                            0.0
                        };
                        let dlam = smooth_clamp_derivative(
                            state[lvl_idx],
                            FLOOD_LAMBDA_LO,
                            FLOOD_LAMBDA_HI,
                            FLOOD_LAMBDA_WIDTH,
                        );
                        jacobian.add_entry(row, lvl_idx, ua_nom * (t_cond - t_sec_in) * e * dlam);
                    }
                }

                // Live z_ua: UA = UA_nom·z_ua·(1−λ), ε = 1−e^(−UA/C), Q = ε·C·ΔT
                // ⇒ ∂Q/∂z_ua = UA_nom·(1−λ)·e·ΔT  ⇒  ∂r_energy/∂z_ua = −∂Q/∂z_ua
                if let Some(z_ua_idx) = self.inner.calib_indices_ref().z_ua {
                    let e = if c_sec > 1e-10 {
                        (-ua_eff / c_sec).exp()
                    } else {
                        0.0
                    };
                    let flood_scale = if self.flood_ready() {
                        1.0 - self.flooded_level(state)
                    } else {
                        1.0
                    };
                    let d_q_dz =
                        self.inner.ua_nominal() * flood_scale * e * (t_cond - t_sec_in);
                    jacobian.add_entry(row, z_ua_idx, -d_q_dz);
                }

                // r2 (emergent) = H_out − h_target(P_in): ∂/∂H_out = 1,
                // ∂/∂P_in = −dh_target/dP via central finite difference.
                if self.emergent_pressure {
                    let emergent_row = if self.skip_pressure_eq { 1 } else { 2 };
                    jacobian.add_entry(emergent_row, outlet_h_idx, 1.0);
                    let hp = self.emergent_outlet_enthalpy(p_in + dp);
                    let hm = self.emergent_outlet_enthalpy((p_in - dp).max(1.0));
                    if let (Ok(hp), Ok(hm)) = (hp, hm) {
                        jacobian.add_entry(emergent_row, inlet_p_idx, -(hp - hm) / (2.0 * dp));
                    }
                }

                if !self.same_branch_m {
                    if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) {
                        jacobian.add_entry(n_thermo, m_out, 1.0);
                        jacobian.add_entry(n_thermo, m_in, -1.0);
                    }
                }

                // Head-pressure row: r = T_cond(P_in) − T_target.
                // ∂/∂P_in = dT_cond/dP_in (same FD slope as above), fan or flooding.
                if self.head_pressure_ready() {
                    jacobian.add_entry(self.head_pressure_row(), inlet_p_idx, dt_dp);
                }

                // 4-port: secondary mass + energy rows (exact cross terms).
                self.secondary_jacobian(state, jacobian, sec_ctx);
                return Ok(());
            } else if self.emergent_pressure {
                // Transient seeding Jacobian (diagonal): r0=P_out−const,
                // r1=P_in−const, r2=H_out−const.
                let outlet_p_idx = self.outlet_p_idx.unwrap();
                let outlet_h_idx = self.outlet_h_idx.unwrap();
                jacobian.add_entry(0, outlet_p_idx, 1.0);
                jacobian.add_entry(1, inlet_p_idx, 1.0);
                jacobian.add_entry(2, outlet_h_idx, 1.0);
                // Transient head-pressure row: r = actuator − seed, ∂/∂actuator = 1
                // (keeps it non-singular). Fan seeds φ→1, flooding seeds λ→0.
                if self.head_pressure_ready() {
                    if let Some(act_idx) = self.fan_actuator_idx {
                        jacobian.add_entry(self.head_pressure_row(), act_idx, 1.0);
                    }
                }
                if !self.same_branch_m {
                    if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) {
                        jacobian.add_entry(n_thermo, m_out, 1.0);
                        jacobian.add_entry(n_thermo, m_in, -1.0);
                    }
                }
                // 4-port seeding: diagonal secondary rows.
                self.secondary_jacobian(state, jacobian, None);
                return Ok(());
            }
        }

        if let (Some(backend), Some(outlet_p_idx), Some(outlet_h_idx)) =
            (&self.fluid_backend, self.outlet_p_idx, self.outlet_h_idx)
        {
            if !self.refrigerant_id.is_empty() {
                if let Some(inlet_p_idx) = self.inlet_p_idx {
                    let p_in = state[inlet_p_idx];
                    if p_in > 10_000.0 {
                        // r0 = P_out - P_in  →  ∂r0/∂P_out = +1, ∂r0/∂P_in = -1
                        jacobian.add_entry(0, outlet_p_idx, 1.0);
                        jacobian.add_entry(0, inlet_p_idx, -1.0);
                        // r1 = H_out - H_sat_liq(P_in)  →  ∂r1/∂H_out = +1, ∂r1/∂P_in = -dH_liq/dP
                        jacobian.add_entry(1, outlet_h_idx, 1.0);
                        let dp = p_in * 1e-4 + 100.0;
                        let fluid = FluidId::new(&self.refrigerant_id);
                        let h_plus =
                            self.h_sat_liq_at_p(backend.as_ref(), fluid.clone(), p_in + dp);
                        let h_minus = self.h_sat_liq_at_p(backend.as_ref(), fluid, p_in - dp);
                        if let (Ok(hp), Ok(hm)) = (h_plus, h_minus) {
                            jacobian.add_entry(1, inlet_p_idx, -(hp - hm) / (2.0 * dp));
                        }
                    } else {
                        // Fallback: fixed T_cond model — diagonal only
                        jacobian.add_entry(0, outlet_p_idx, 1.0);
                        jacobian.add_entry(1, outlet_h_idx, 1.0);
                    }
                } else {
                    // Fallback: fixed T_cond model — diagonal only
                    jacobian.add_entry(0, outlet_p_idx, 1.0);
                    jacobian.add_entry(1, outlet_h_idx, 1.0);
                }
                // r[n_thermo] = ṁ_outlet − ṁ_inlet  →  exact analytic entries (CM1.3)
                // CM1.4: omit when same_branch_m (dropped from n_equations).
                if !self.same_branch_m {
                    if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) {
                        jacobian.add_entry(n_thermo, m_out, 1.0);
                        jacobian.add_entry(n_thermo, m_in, -1.0);
                    }
                }
                self.secondary_jacobian(state, jacobian, None);
                return Ok(());
            }
        }
        Ok(())
    }

    fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
        self.fluid_backend = Some(Arc::clone(&backend));
        self.inner.set_fluid_backend_from_builder(backend);
    }

    fn n_equations(&self) -> usize {
        // Thermodynamic residuals: 2 normally, 3 in emergent mode (outlet closure).
        let thermo = self.n_thermo();
        // CM1.4: drop the conservation equation when in the same series branch.
        let core = if self.same_branch_m {
            thermo
        } else {
            thermo + 1 // +1 for mass conservation (CM1.3)
        };
        // +1 for the head-pressure equation (T_cond = T_target) closed by the
        // active free actuator (fan speed or flooding level). Static config test
        // keeps DoF consistent before the actuator index is wired in finalize().
        // + secondary rows in Modelica-style 4-port mode.
        core + if self.head_pressure_active() { 1 } else { 0 } + self.n_secondary()
    }

    fn equation_roles(&self) -> Vec<crate::EquationRole> {
        let mut roles = Vec::new();
        if !self.skip_pressure_eq {
            roles.push(crate::EquationRole::MomentumOrPressureDrop {
                stream: "refrigerant",
            });
        }
        roles.push(crate::EquationRole::EnergyBalance {
            stream: "refrigerant",
        });
        if self.emergent_pressure {
            roles.push(crate::EquationRole::OutletClosure { kind: "subcooling" });
        }
        if !self.same_branch_m {
            roles.push(crate::EquationRole::MassConservation {
                stream: "refrigerant",
            });
        }
        if self.head_pressure_active() {
            roles.push(crate::EquationRole::ActuatorClosure {
                name: "head_pressure",
            });
        }
        if self.secondary_edges_ready() {
            roles.push(crate::EquationRole::MomentumOrPressureDrop {
                stream: "secondary",
            });
            if !self.sec_same_branch() {
                roles.push(crate::EquationRole::MassConservation {
                    stream: "secondary",
                });
            }
            roles.push(crate::EquationRole::EnergyBalance {
                stream: "secondary",
            });
        }
        roles
    }

    fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
        // Deterministic 4-port wiring:
        //   port 0 = refrigerant inlet, port 1 = refrigerant outlet,
        //   port 2 = secondary inlet,   port 3 = secondary outlet.
        if let Some(Some((m, p, h))) = port_edges.first() {
            self.inlet_m_idx = Some(*m);
            self.inlet_p_idx = Some(*p);
            self.inlet_h_idx = Some(*h);
        }
        if let Some(Some((m, p, h))) = port_edges.get(1) {
            self.outlet_m_idx = Some(*m);
            self.outlet_p_idx = Some(*p);
            self.outlet_h_idx = Some(*h);
        }
        if let Some(Some(triple)) = port_edges.get(2) {
            self.sec_in_idx = Some(*triple);
        }
        if let Some(Some(triple)) = port_edges.get(3) {
            self.sec_out_idx = Some(*triple);
        }
        self.same_branch_m = matches!(
            (self.inlet_m_idx, self.outlet_m_idx),
            (Some(m_in), Some(m_out)) if m_in == m_out
        );
    }

    fn port_names(&self) -> Vec<String> {
        vec![
            "inlet".to_string(),
            "outlet".to_string(),
            "secondary_inlet".to_string(),
            "secondary_outlet".to_string(),
        ]
    }

    fn flow_paths(&self) -> Vec<(usize, usize)> {
        // Modelica-style internal series paths: refrigerant 0→1, secondary 2→3.
        // Each path shares one ṁ unknown across its inlet/outlet edges.
        vec![(0, 1), (2, 3)]
    }

    fn get_ports(&self) -> &[ConnectedPort] {
        self.inner.get_ports()
    }

    fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
        self.fan_actuator_idx = indices.actuator;
        self.inner.set_calib_indices(indices);
    }

    fn port_mass_flows(
        &self,
        state: &StateSlice,
    ) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
        self.inner.port_mass_flows(state)
    }

    fn port_enthalpies(
        &self,
        state: &StateSlice,
    ) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
        self.inner.port_enthalpies(state)
    }

    fn energy_transfers(
        &self,
        state: &StateSlice,
    ) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
        // When coupled to an *external* secondary stream, the refrigerant rejects
        // its duty Q = ṁ·(h_in − h_out) to the environment. Report it as heat
        // leaving the component (negative), so the cycle-level performance can
        // recover the true heating/rejected capacity. Without a secondary stream
        // the exchanger is internal to a tracked two-circuit coupling and must
        // stay adiabatic to avoid double counting — delegate to the inner model.
        if self.coupled_ready() {
            if let (Some(m_idx), Some(in_h), Some(out_h)) = (
                self.resolved_mass_idx(),
                self.inlet_h_idx,
                self.outlet_h_idx,
            ) {
                if m_idx < state.len() && in_h < state.len() && out_h < state.len() {
                    let q_rej = state[m_idx] * (state[in_h] - state[out_h]);
                    if q_rej.is_finite() {
                        return Some((
                            entropyk_core::Power::from_watts(-q_rej),
                            entropyk_core::Power::from_watts(0.0),
                        ));
                    }
                }
            }
        }
        self.inner.energy_transfers(state)
    }

    fn signature(&self) -> String {
        self.inner.signature()
    }

    fn to_params(&self) -> crate::ComponentParams {
        self.inner.to_params()
    }

    fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
        self.inner.update_calib_factor(factor, value)
    }

    fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
        use crate::MeasuredOutput;
        match kind {
            // Real liquid-line subcooling (replaces the solver's mock formula).
            MeasuredOutput::Subcooling => self.measured_subcooling(state),
            // Condensing saturation temperature (SDT) for head-pressure loops.
            MeasuredOutput::SaturationTemperature => self.measured_saturation_temperature(state),
            // Preserve the default Capacity/HeatTransferRate derivation.
            MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => self
                .energy_transfers(state)
                .map(|(heat, _work)| heat.to_watts().abs()),
            _ => None,
        }
    }
}

impl StateManageable for Condenser {
    fn state(&self) -> OperationalState {
        self.inner.state()
    }

    fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
        self.inner.set_state(state)
    }

    fn can_transition_to(&self, target: OperationalState) -> bool {
        self.inner.can_transition_to(target)
    }

    fn circuit_id(&self) -> &CircuitId {
        self.inner.circuit_id()
    }

    fn set_circuit_id(&mut self, circuit_id: CircuitId) {
        self.inner.set_circuit_id(circuit_id);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Wires secondary Air edges for unit tests that need `coupled_ready()`.
    /// Extends the refrigerant state with secondary air state at the given
    /// temperature [K] and capacity rate [W/K]. Returns the full state vector
    /// and calls `set_port_context` on the condenser.
    fn wire_secondary(
        cond: &mut Condenser,
        ref_state: &[f64],
        t_sec_k: f64,
        c_sec: f64,
        ref_in: (usize, usize, usize),
        ref_out: (usize, usize, usize),
    ) -> Vec<f64> {
        let w = 0.010_f64;
        let cp = 1006.0 + 1860.0 * w; // ≈1024.6
        let t_c = t_sec_k - 273.15;
        let h_air = cp * t_c + 2_501_000.0 * w;
        let m_air = c_sec / cp;

        let n = ref_state.len();
        let sec_in = (n, n + 1, n + 2);
        let sec_out = (n + 3, n + 4, n + 5);

        cond.set_secondary_fluid("Air");
        cond.set_secondary_humidity_ratio(w);
        cond.set_port_context(&[Some(ref_in), Some(ref_out), Some(sec_in), Some(sec_out)]);

        let mut state = ref_state.to_vec();
        state.extend_from_slice(&[
            m_air,
            101_325.0,
            h_air,
            m_air,
            101_325.0,
            h_air + 5000.0, // outlet slightly warmer
        ]);
        state
    }

    #[test]
    fn test_condenser_creation() {
        let condenser = Condenser::new(10_000.0);
        assert_eq!(condenser.ua(), 10_000.0);
        // CM1.3: 2 thermo + 1 mass-flow conservation = 3
        assert_eq!(condenser.n_equations(), 3);
    }

    #[test]
    fn test_condenser_with_saturation_temp() {
        let condenser = Condenser::with_saturation_temp(10_000.0, 323.15);
        assert_eq!(condenser.saturation_temp(), 323.15);
    }

    #[test]
    fn test_condenser_emergent_outlet_closure() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let edges = [(0usize, 1usize, 2usize), (0usize, 3usize, 4usize)];
        let p_cond = 1_200_000.0_f64;
        let mut cond = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_emergent_pressure(0.0);
        cond.set_system_context(0, &edges);

        let h_liq = backend
            .property(
                entropyk_fluids::FluidId::new("R134a"),
                entropyk_fluids::Property::Enthalpy,
                entropyk_fluids::FluidState::PressureQuality(
                    entropyk_core::Pressure::from_pascals(p_cond),
                    entropyk_fluids::Quality(0.0),
                ),
            )
            .unwrap();

        let ref_state = vec![0.1, p_cond, 440_000.0, p_cond, h_liq];
        let state = wire_secondary(&mut cond, &ref_state, 300.0, 3000.0, (0, 1, 2), (0, 3, 4));

        let mut r = vec![0.0; cond.n_equations()];
        cond.compute_residuals(&state, &mut r).unwrap();
        let closure_row = if cond.skip_pressure_eq { 1 } else { 2 };
        assert!(
            r[closure_row].abs() < 1.0,
            "outlet closure should vanish at sat-liquid: {}",
            r[closure_row]
        );

        let mut state2 = state.clone();
        state2[4] = h_liq + 20_000.0;
        cond.compute_residuals(&state2, &mut r).unwrap();
        assert!(
            (r[closure_row] - 20_000.0).abs() < 1.0,
            "r2 must track outlet enthalpy: {}",
            r[closure_row]
        );
    }

    // ---- Fan head-pressure actuator (arch-6) ----------------------------------

    /// A fan-head-pressure condenser emits one extra equation (T_cond = T_target),
    /// closed by the fan-speed free actuator. The count must be static (present
    /// before the actuator index is wired) to keep DoF balanced.
    #[test]
    fn test_fan_head_pressure_adds_one_equation() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        // Separate branches: inlet (0,1,2), outlet (3,4,5).
        let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
        let mut cond = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_secondary_stream(305.0, 3000.0)
            .with_emergent_pressure(0.0)
            .with_fan_head_pressure(320.0);
        cond.set_system_context(0, &edges);
        // 3 thermo (emergent) + 1 mass conservation + 1 fan = 5.
        assert_eq!(cond.n_equations(), 5);
        assert_eq!(cond.head_pressure_row(), 4);
        // Static: the count holds even before the actuator index is wired.
        assert!(cond.fan_active());
        assert!(!cond.fan_ready());
    }

    /// The fan residual `T_cond(P_in) − T_target` reacts to the target, and the
    /// duty (energy-balance residual r1) reacts to the fan speed φ that scales the
    /// secondary air capacity rate. Genuine physics — no fixed design point.
    #[test]
    fn test_fan_head_pressure_residual_reacts_to_target_and_speed() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
        let p_cond = 1_200_000.0_f64;

        let mut cond = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_secondary_stream(305.0, 3000.0)
            .with_emergent_pressure(0.0)
            .with_fan_head_pressure(320.0);
        cond.set_system_context(0, &edges);
        cond.set_calib_indices(entropyk_core::CalibIndices {
            actuator: Some(6),
            ..Default::default()
        });
        assert!(cond.fan_ready());

        // State: fan actuator φ at index 6, secondary Air edges start at index 7.
        let ref_state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, 1.0];
        let mut state = wire_secondary(&mut cond, &ref_state, 305.0, 3000.0, (0, 1, 2), (3, 4, 5));

        let t_cond = cond.cond_temperature(p_cond).unwrap();
        let mut r = vec![0.0; cond.n_equations()];
        cond.compute_residuals(&state, &mut r).unwrap();
        // Fan residual = T_cond(P_in) − T_target.
        assert!(
            (r[cond.head_pressure_row()] - (t_cond - 320.0)).abs() < 1e-6,
            "fan residual must be T_cond − T_target: {}",
            r[cond.head_pressure_row()]
        );

        // Halving the secondary air flow halves C_sec ⇒ less duty Q ⇒ r1 = ṁΔh − Q rises.
        let r1_full = r[1];
        state[7] *= 0.5; // secondary inlet mass-flow slot (edge-driven C_sec)
        cond.compute_residuals(&state, &mut r).unwrap();
        assert!(
            r[1] > r1_full + 1.0,
            "slower secondary flow ⇒ smaller duty ⇒ larger energy residual: {} !> {}",
            r[1],
            r1_full
        );
    }

    /// Analytic Jacobian of the coupled fan path matches central finite differences,
    /// including the ∂r1/∂φ fan-coupling entry and the ∂r_fan/∂P_in entry.
    #[test]
    fn test_fan_head_pressure_jacobian_matches_finite_difference() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
        let p_cond = 1_200_000.0_f64;

        let mut cond = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_secondary_stream(305.0, 3000.0)
            .with_emergent_pressure(0.0)
            .with_fan_head_pressure(320.0);
        cond.set_system_context(0, &edges);
        cond.set_calib_indices(entropyk_core::CalibIndices {
            actuator: Some(6),
            ..Default::default()
        });

        let state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, 0.8];
        let n_eq = cond.n_equations();
        let n_var = state.len();

        // Analytic Jacobian into a dense matrix.
        let mut jac = JacobianBuilder::new();
        cond.jacobian_entries(&state, &mut jac).unwrap();
        let mut analytic = vec![vec![0.0; n_var]; n_eq];
        for &(row, col, val) in jac.entries() {
            if row < n_eq && col < n_var {
                analytic[row][col] += val;
            }
        }

        // Central finite differences of the residual.
        for col in 0..n_var {
            let h = (state[col].abs() * 1e-6).max(1e-3);
            let mut sp = state.clone();
            let mut sm = state.clone();
            sp[col] += h;
            sm[col] -= h;
            let mut rp = vec![0.0; n_eq];
            let mut rm = vec![0.0; n_eq];
            cond.compute_residuals(&sp, &mut rp).unwrap();
            cond.compute_residuals(&sm, &mut rm).unwrap();
            for row in 0..n_eq {
                let fd = (rp[row] - rm[row]) / (2.0 * h);
                let an = analytic[row][col];
                let scale = 1.0 + fd.abs().max(an.abs());
                assert!(
                    (fd - an).abs() / scale < 1e-4,
                    "J[{}][{}]: analytic {} vs FD {}",
                    row,
                    col,
                    an,
                    fd
                );
            }
        }
    }

    /// A flooded-head-pressure condenser emits one extra equation (T_cond=T_target),
    /// closed by the flooding-level free actuator. The count must be static (present
    /// before the actuator index is wired) to keep DoF balanced.
    #[test]
    fn test_flooded_head_pressure_adds_one_equation() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
        let mut cond = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_secondary_stream(305.0, 3000.0)
            .with_emergent_pressure(0.0)
            .with_flooded_head_pressure(320.0);
        cond.set_system_context(0, &edges);
        // 3 thermo (emergent) + 1 mass conservation + 1 flooding = 5.
        assert_eq!(cond.n_equations(), 5);
        assert_eq!(cond.head_pressure_row(), 4);
        // Static: active before the actuator index is wired, not yet ready.
        assert!(cond.flood_active());
        assert!(!cond.flood_ready());
    }

    /// The flooding residual `T_cond(P_in) − T_target` reacts to the target, and
    /// the duty (energy-balance residual r1) reacts to the flooded level λ that
    /// scales the effective conductance `UA_eff = (1 − λ)·UA` — genuine head-
    /// pressure control by area deactivation, not a fixed design point.
    #[test]
    fn test_flooded_head_pressure_residual_reacts_to_target_and_level() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
        let p_cond = 1_200_000.0_f64;

        let mut cond = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_secondary_stream(305.0, 3000.0)
            .with_emergent_pressure(0.0)
            .with_flooded_head_pressure(320.0);
        cond.set_system_context(0, &edges);
        cond.set_calib_indices(entropyk_core::CalibIndices {
            actuator: Some(6),
            ..Default::default()
        });
        assert!(cond.flood_ready());

        // State: flooding level λ at index 6, secondary Air edges start at index 7.
        let ref_state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, 0.0];
        let mut state = wire_secondary(&mut cond, &ref_state, 305.0, 3000.0, (0, 1, 2), (3, 4, 5));

        let t_cond = cond.cond_temperature(p_cond).unwrap();
        let mut r = vec![0.0; cond.n_equations()];
        cond.compute_residuals(&state, &mut r).unwrap();
        // Flooding residual = T_cond(P_in) − T_target.
        assert!(
            (r[cond.head_pressure_row()] - (t_cond - 320.0)).abs() < 1e-6,
            "flooding residual must be T_cond − T_target: {}",
            r[cond.head_pressure_row()]
        );

        // Raising λ deactivates area ⇒ less duty Q ⇒ r1 = ṁΔh − Q rises.
        let r1_full = r[1];
        state[6] = 0.5;
        cond.compute_residuals(&state, &mut r).unwrap();
        assert!(
            r[1] > r1_full + 1.0,
            "more flooding (larger λ) ⇒ smaller duty ⇒ larger energy residual: {} !> {}",
            r[1],
            r1_full
        );
    }

    /// Analytic Jacobian of the coupled flooding path matches central finite
    /// differences, including the ∂r1/∂λ flooding-coupling entry.
    #[test]
    fn test_flooded_head_pressure_jacobian_matches_finite_difference() {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
        let p_cond = 1_200_000.0_f64;

        let mut cond = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_secondary_stream(305.0, 3000.0)
            .with_emergent_pressure(0.0)
            .with_flooded_head_pressure(320.0);
        cond.set_system_context(0, &edges);
        cond.set_calib_indices(entropyk_core::CalibIndices {
            actuator: Some(6),
            ..Default::default()
        });

        let state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, 0.35];
        let n_eq = cond.n_equations();
        let n_var = state.len();

        let mut jac = JacobianBuilder::new();
        cond.jacobian_entries(&state, &mut jac).unwrap();
        let mut analytic = vec![vec![0.0; n_var]; n_eq];
        for &(row, col, val) in jac.entries() {
            if row < n_eq && col < n_var {
                analytic[row][col] += val;
            }
        }

        for col in 0..n_var {
            let h = (state[col].abs() * 1e-6).max(1e-3);
            let mut sp = state.clone();
            let mut sm = state.clone();
            sp[col] += h;
            sm[col] -= h;
            let mut rp = vec![0.0; n_eq];
            let mut rm = vec![0.0; n_eq];
            cond.compute_residuals(&sp, &mut rp).unwrap();
            cond.compute_residuals(&sm, &mut rm).unwrap();
            for row in 0..n_eq {
                let fd = (rp[row] - rm[row]) / (2.0 * h);
                let an = analytic[row][col];
                let scale = 1.0 + fd.abs().max(an.abs());
                assert!(
                    (fd - an).abs() / scale < 1e-4,
                    "J[{}][{}]: analytic {} vs FD {}",
                    row,
                    col,
                    an,
                    fd
                );
            }
        }
    }

    /// Condenser qualification: fixed refrigerant regime (constant condensing
    /// pressure), sweep the secondary (air/water) inlet temperature and flow. All
    /// outputs are solved from the real ε-NTU balance and must respond physically.
    #[test]
    fn test_condenser_qualification_sweep() {
        let backend = std::sync::Arc::new(entropyk_fluids::TestBackend::new());
        let p_cond = 1_200_000.0_f64; // constant refrigerant regime (R134a ~ +46 °C)

        let rate_at = |t_sec_k: f64, c_sec: f64| {
            Condenser::new(10_000.0)
                .with_refrigerant("R134a")
                .with_fluid_backend(backend.clone())
                .with_secondary_stream(t_sec_k, c_sec)
                .rate(p_cond)
                .unwrap()
        };

        // Sweep secondary inlet temperature at fixed flow (C_sec = 3000 W/K).
        // Hotter secondary ⇒ smaller ΔT ⇒ LESS heat rejected.
        let mut last_q = f64::INFINITY;
        for t_c in [20.0, 30.0, 40.0] {
            let r = rate_at(t_c + 273.15, 3000.0);
            assert!(r.effectiveness > 0.0 && r.effectiveness < 1.0);
            assert!(
                r.approach_k > 0.0,
                "T_cond must exceed secondary inlet temp"
            );
            assert!(r.q_w > 0.0, "condenser must reject heat");
            assert!(
                r.secondary_outlet_k > t_c + 273.15,
                "secondary must warm up"
            );
            assert!(
                r.q_w < last_q - 1.0,
                "hotter secondary inlet must reduce duty: {} !< {}",
                r.q_w,
                last_q
            );
            last_q = r.q_w;
        }

        // More secondary flow ⇒ effectiveness DOWN but duty UP (C_sec dominates).
        let t_s = 30.0 + 273.15;
        let r_low = rate_at(t_s, 1500.0);
        let r_high = rate_at(t_s, 6000.0);
        assert!(r_high.effectiveness < r_low.effectiveness);
        assert!(r_high.q_w > r_low.q_w + 1.0);
        // Refrigerant regime unchanged ⇒ condensing temperature constant.
        assert!((r_low.t_cond_k - r_high.t_cond_k).abs() < 1e-9);
    }

    /// Coupled residual path: in a full cycle the condenser duty must react to the
    /// secondary (air/water) inlet temperature and flow. Colder secondary ⇒ larger
    /// duty ⇒ the refrigerant energy-balance residual r1 = ṁ(h_in−h_out) − Q changes.
    #[test]
    fn test_condenser_coupled_residual_reacts_to_secondary() {
        use std::sync::Arc;

        // Refrigerant edges: inlet (m,p,h)=(0,1,2), outlet (m,p,h)=(3,4,5).
        let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
        let p_cond = 1_200_000.0_f64; // ~46 °C condensing (R134a), in TestBackend range
        let mut state = vec![0.0; 6];
        state[0] = 0.1; // ṁ_ref [kg/s]
        state[1] = p_cond; // P_in
        state[2] = 440_000.0; // h_in [J/kg] (superheated discharge)
        state[3] = 0.1; // ṁ_out
        state[4] = p_cond; // P_out
        state[5] = 260_000.0; // h_out [J/kg]

        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let make = |t_sec_k: f64, c_sec: f64| -> (Condenser, Vec<f64>) {
            // Isolate secondary coupling from the default DX ΔP model.
            let mut cond = Condenser::new(10_000.0)
                .with_refrigerant("R134a")
                .with_fluid_backend(backend.clone())
                .with_secondary_stream(t_sec_k, c_sec)
                .with_isobaric();
            cond.set_system_context(0, &edges);
            let st = wire_secondary(&mut cond, &state, t_sec_k, c_sec, (0, 1, 2), (3, 4, 5));
            (cond, st)
        };

        // Cold secondary (air at 290 K) vs hot secondary (air at 310 K).
        let (cond_cold, state_cold) = make(290.0, 3000.0);
        let mut r_cold = vec![0.0; cond_cold.n_equations()];
        cond_cold
            .compute_residuals(&state_cold, &mut r_cold)
            .unwrap();
        let q_cold = cond_cold.coupled_duty(p_cond).unwrap();

        let (cond_hot, state_hot) = make(310.0, 3000.0);
        let mut r_hot = vec![0.0; cond_hot.n_equations()];
        cond_hot.compute_residuals(&state_hot, &mut r_hot).unwrap();
        let q_hot = cond_hot.coupled_duty(p_cond).unwrap();

        assert!(
            q_cold > 0.0,
            "condenser must reject heat to colder secondary"
        );
        assert!(
            q_cold > q_hot + 1.0,
            "colder secondary must increase duty: q_cold={q_cold}, q_hot={q_hot}"
        );
        // r1 = ṁ(h_in−h_out) − Q grows as Q drops (hotter secondary).
        assert!(
            r_hot[1] > r_cold[1] + 1.0,
            "energy-balance residual must change with secondary temp: r_hot={}, r_cold={}",
            r_hot[1],
            r_cold[1]
        );
        // Pressure closure unaffected.
        assert!(r_cold[0].abs() < 1e-9 && r_hot[0].abs() < 1e-9);

        // More secondary flow ⇒ more duty (C_sec dominates).
        let (cond_highflow, _) = make(290.0, 9000.0);
        let q_highflow = cond_highflow.coupled_duty(p_cond).unwrap();
        assert!(
            q_highflow > q_cold + 1.0,
            "more secondary flow must increase duty: q_highflow={q_highflow}, q_cold={q_cold}"
        );
    }

    /// Opt-in two-phase pressure drop: with a drop coefficient the coupled r0
    /// residual reflects P_out = P_in − k·ṁ·|ṁ|, and the analytic ∂r0/∂ṁ matches
    /// a central finite difference. Without a coefficient r0 is unchanged.
    #[test]
    fn test_condenser_pressure_drop_residual_and_jacobian() {
        use crate::JacobianBuilder;
        use std::sync::Arc;

        let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
        let p_cond = 1_200_000.0_f64;
        let mut state = vec![0.0; 6];
        state[0] = 0.12; // ṁ_ref
        state[1] = p_cond; // P_in
        state[2] = 440_000.0; // h_in
        state[3] = 0.12; // ṁ_out
        state[4] = p_cond; // P_out
        state[5] = 260_000.0; // h_out

        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let k = 5.0e6; // Pa·s²/kg²  ⇒ ΔP = k·ṁ² = 5e6·0.0144 = 72 kPa

        // Baseline (explicit isobaric): r0 = P_out − P_in = 0.
        let mut cond0 = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_isobaric();
        cond0.set_system_context(0, &edges);
        let state0 = wire_secondary(&mut cond0, &state, 300.0, 3000.0, (0, 1, 2), (3, 4, 5));
        let mut r0v = vec![0.0; cond0.n_equations()];
        cond0.compute_residuals(&state0, &mut r0v).unwrap();
        assert!(r0v[0].abs() < 1e-9, "no-drop r0 must be ~0, got {}", r0v[0]);

        // With drop: r0 = P_out − (P_in − ΔP) = ΔP = k·ṁ².
        let mut cond = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_pressure_drop_coeff(k);
        cond.set_system_context(0, &edges);
        let state = wire_secondary(&mut cond, &state, 300.0, 3000.0, (0, 1, 2), (3, 4, 5));
        let mut r = vec![0.0; cond.n_equations()];
        cond.compute_residuals(&state, &mut r).unwrap();
        let expected_dp = k * state[0] * state[0];
        assert!(
            (r[0] - expected_dp).abs() < 1.0,
            "r0 must equal ΔP={expected_dp}, got {}",
            r[0]
        );

        // Analytic ∂r0/∂ṁ vs central finite difference on the mass-flow slot.
        let mut jb = JacobianBuilder::new();
        cond.jacobian_entries(&state, &mut jb).unwrap();
        let analytic: f64 = jb
            .entries()
            .iter()
            .filter(|(row, col, _)| *row == 0 && *col == 0)
            .map(|(_, _, v)| *v)
            .sum();
        let eps = 1e-6;
        let mut sp = state.clone();
        let mut sm = state.clone();
        sp[0] += eps;
        sm[0] -= eps;
        let n_eq = cond.n_equations();
        let (mut rp, mut rm) = (vec![0.0; n_eq], vec![0.0; n_eq]);
        cond.compute_residuals(&sp, &mut rp).unwrap();
        cond.compute_residuals(&sm, &mut rm).unwrap();
        let fd = (rp[0] - rm[0]) / (2.0 * eps);
        assert!(
            (analytic - fd).abs() < 1e-2 * fd.abs().max(1.0),
            "∂r0/∂ṁ analytic={analytic} vs fd={fd}"
        );
    }

    #[test]
    fn test_validate_outlet_quality_fully_condensed() {
        let condenser = Condenser::new(10_000.0);

        let h_liquid = 200_000.0;
        let h_vapor = 400_000.0;
        let outlet_h = 200_000.0;

        let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
        assert!(result.is_ok());
    }

    #[test]
    fn test_validate_outlet_quality_subcooled() {
        let condenser = Condenser::new(10_000.0);

        let h_liquid = 200_000.0;
        let h_vapor = 400_000.0;
        let outlet_h = 180_000.0;

        let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
        assert!(result.is_ok());
    }

    #[test]
    fn test_validate_outlet_quality_two_phase() {
        let condenser = Condenser::new(10_000.0);

        let h_liquid = 200_000.0;
        let h_vapor = 400_000.0;
        let outlet_h = 300_000.0;

        let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
        assert!(result.is_err());
    }

    #[test]
    fn test_validate_outlet_quality_superheated() {
        let condenser = Condenser::new(10_000.0);

        let h_liquid = 200_000.0;
        let h_vapor = 400_000.0;
        let outlet_h = 450_000.0;

        let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
        assert!(result.is_err());
    }

    #[test]
    fn test_compute_residuals() {
        let condenser = Condenser::new(10_000.0);

        let state = vec![0.0; 10];
        let mut residuals = vec![0.0; 3];

        let result = condenser.compute_residuals(&state, &mut residuals);
        assert!(matches!(result, Err(ComponentError::InvalidState(_))));
    }

    // ---- Modelica-style 4-port mode ----------------------------------------

    /// Builds a 4-port condenser wired via `set_port_context`:
    /// refrigerant inlet (0,1,2) → outlet (3,4,5),
    /// secondary (moist air) inlet (6,7,8) → outlet (9,10,11).
    fn make_4port_condenser() -> Condenser {
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let mut cond = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_secondary_fluid("Air");
        cond.set_secondary_humidity_ratio(0.010);
        cond.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
        cond.set_port_context(&[
            Some((0, 1, 2)),
            Some((3, 4, 5)),
            Some((6, 7, 8)),
            Some((9, 10, 11)),
        ]);
        cond
    }

    /// Physically-plausible 12-slot state for the 4-port condenser.
    fn make_4port_state() -> Vec<f64> {
        let w = 0.010;
        let cp_air = 1006.0 + 1860.0 * w;
        let h_air = |t_c: f64| cp_air * t_c + 2_501_000.0 * w;
        vec![
            0.12,        // 0: ṁ_ref,in
            1_200_000.0, // 1: P_ref,in
            440_000.0,   // 2: h_ref,in (superheated discharge)
            0.12,        // 3: ṁ_ref,out
            1_200_000.0, // 4: P_ref,out
            260_000.0,   // 5: h_ref,out (subcooled liquid)
            2.5,         // 6: ṁ_air,in
            101_325.0,   // 7: P_air,in
            h_air(30.0), // 8: h_air,in (30 °C)
            2.5,         // 9: ṁ_air,out
            101_325.0,   // 10: P_air,out
            h_air(38.0), // 11: h_air,out (38 °C)
        ]
    }

    /// 4-port mode adds 3 secondary rows (P + mass + energy) and exposes named ports.
    #[test]
    fn test_condenser_4port_equations_and_ports() {
        let cond = make_4port_condenser();
        // 2 thermo + 1 refrigerant mass + 3 secondary = 6.
        assert_eq!(cond.n_equations(), 6);
        assert_eq!(
            cond.port_names(),
            vec!["inlet", "outlet", "secondary_inlet", "secondary_outlet"]
        );
    }

    /// Secondary energy balance: ṁ_air·(h_out − h_in) = Q, with Q the coupled
    /// ε-NTU duty driven by the LIVE air-edge state (temperature + flow).
    #[test]
    fn test_condenser_4port_secondary_energy_balance() {
        let cond = make_4port_condenser();
        let state = make_4port_state();
        let mut r = vec![0.0; cond.n_equations()];
        cond.compute_residuals(&state, &mut r).unwrap();

        // Row 2: refrigerant mass conservation (indices 3 vs 0 → 0).
        assert!(r[2].abs() < 1e-12, "ref mass row: {}", r[2]);
        // Row 3: secondary isobaric pressure.
        assert!(r[3].abs() < 1e-12, "sec P row: {}", r[3]);
        // Row 4: secondary mass conservation.
        assert!(r[4].abs() < 1e-12, "sec mass row: {}", r[4]);

        // Row 5 must equal ṁ_air·Δh_air − Q where Q = ε·C_sec·(T_cond − T_air,in).
        let w = 0.010;
        let cp_air = 1006.0 + 1860.0 * w;
        let c_sec = state[6] * cp_air;
        let t_air_in = (state[8] - 2_501_000.0 * w) / cp_air + 273.15;
        let t_cond = cond.cond_temperature(state[1]).unwrap();
        let eps = cond.effectiveness(c_sec);
        let q = eps * c_sec * (t_cond - t_air_in);
        assert!(q > 0.0, "condenser must reject heat: q={q}");
        let expected = state[6] * (state[11] - state[8]) - q;
        assert!(
            (r[5] - expected).abs() < 1e-6 * expected.abs().max(1.0),
            "sec energy row: {} vs expected {}",
            r[5],
            expected
        );

        // Refrigerant energy balance row must carry the SAME duty.
        let expected_r1 = state[0] * (state[2] - state[5]) - q;
        assert!(
            (r[1] - expected_r1).abs() < 1e-6 * expected_r1.abs().max(1.0),
            "ref energy row: {} vs expected {}",
            r[1],
            expected_r1
        );
    }

    /// Full Jacobian of the 4-port condenser vs central finite differences on
    /// every (row, column) pair — validates all analytic cross-derivatives
    /// (secondary ṁ/h into both energy rows, refrigerant P into the secondary row).
    #[test]
    fn test_condenser_4port_jacobian_vs_finite_differences() {
        use crate::JacobianBuilder;
        let cond = make_4port_condenser();
        let state = make_4port_state();
        let n_eq = cond.n_equations();
        let n_state = state.len();

        let mut jb = JacobianBuilder::new();
        cond.jacobian_entries(&state, &mut jb).unwrap();
        let mut analytic = vec![vec![0.0_f64; n_state]; n_eq];
        for &(row, col, v) in jb.entries() {
            if row < n_eq && col < n_state {
                analytic[row][col] += v;
            }
        }

        for col in 0..n_state {
            // Scale-aware step: pressures need a large step, enthalpies medium.
            let eps = (state[col].abs() * 1e-6).max(1e-7);
            let (mut sp, mut sm) = (state.clone(), state.clone());
            sp[col] += eps;
            sm[col] -= eps;
            let (mut rp, mut rm) = (vec![0.0; n_eq], vec![0.0; n_eq]);
            cond.compute_residuals(&sp, &mut rp).unwrap();
            cond.compute_residuals(&sm, &mut rm).unwrap();
            for row in 0..n_eq {
                let fd = (rp[row] - rm[row]) / (2.0 * eps);
                let a = analytic[row][col];
                let tol = 1e-3 * fd.abs().max(a.abs()).max(1e-6);
                assert!(
                    (a - fd).abs() <= tol.max(1e-6),
                    "J[{row}][{col}]: analytic={a} vs fd={fd}"
                );
            }
        }
    }

    /// Builds a 4-port condenser with tube two-phase (MSH) pressure drop.
    fn make_4port_condenser_tube_dp() -> Condenser {
        use crate::heat_exchanger::two_phase_dp::{TubeChannelGeometry, TwoPhaseDpCorrelation};
        use std::sync::Arc;
        let backend = Arc::new(entropyk_fluids::TestBackend::new());
        let mut cond = Condenser::new(10_000.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_secondary_fluid("Air")
            .with_tube_pressure_drop(
                TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
                TubeChannelGeometry::dx_default(),
            );
        cond.set_secondary_humidity_ratio(0.010);
        cond.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
        cond.set_port_context(&[
            Some((0, 1, 2)),
            Some((3, 4, 5)),
            Some((6, 7, 8)),
            Some((9, 10, 11)),
        ]);
        cond
    }

    /// Momentum-row Jacobian must include the tube-ΔP partials w.r.t. inlet
    /// pressure (saturated transport properties) and both enthalpies (vapor
    /// qualities) — not only ∂ΔP/∂ṁ. Regression test: with tube MSH at high
    /// mass flow, a momentum row limited to ∂/∂ṁ makes the Newton direction
    /// wrong enough that line search fails and the solve diverges.
    #[test]
    fn test_condenser_4port_tube_dp_momentum_jacobian_vs_finite_differences() {
        use crate::JacobianBuilder;
        let cond = make_4port_condenser_tube_dp();
        let state = make_4port_state();
        let n_eq = cond.n_equations();
        let n_state = state.len();

        let mut jb = JacobianBuilder::new();
        cond.jacobian_entries(&state, &mut jb).unwrap();
        let mut analytic = vec![vec![0.0_f64; n_state]; n_eq];
        for &(row, col, v) in jb.entries() {
            if row < n_eq && col < n_state {
                analytic[row][col] += v;
            }
        }

        // Row 0: r = P_out − P_in + ΔP(ṁ, P_in, h_in, h_out).
        for col in 0..n_state {
            let eps = (state[col].abs() * 1e-6).max(1e-7);
            let (mut sp, mut sm) = (state.clone(), state.clone());
            sp[col] += eps;
            sm[col] -= eps;
            let (mut rp, mut rm) = (vec![0.0; n_eq], vec![0.0; n_eq]);
            cond.compute_residuals(&sp, &mut rp).unwrap();
            cond.compute_residuals(&sm, &mut rm).unwrap();
            let fd = (rp[0] - rm[0]) / (2.0 * eps);
            let a = analytic[0][col];
            let tol = (1e-3 * fd.abs().max(a.abs())).max(1e-9);
            assert!(
                (a - fd).abs() <= tol,
                "momentum J[0][{col}]: analytic={a} vs fd={fd}"
            );
        }
    }
}
