//! IsentropicCompressor Component
//!
//! A simplified compressor model for vapor-compression cycle simulation.
//! Uses fixed design-point parameters (t_evap_k, t_cond_k) to compute the
//! discharge state, avoiding transient instability during Picard iterations.
//!
//! ## Model
//!
//! Given fixed operating point parameters (t_evap_k, t_cond_k, superheat_k):
//!
//! 1. P_evap = P_sat(T_evap)                      [CoolProp]
//! 2. T_suc = T_evap + superheat_k                [fixed suction temperature]
//! 3. H_suc = H(P_evap, T_suc)                    [superheated vapor enthalpy]
//! 4. T_dis_isen = T_suc × (P_cond/P_evap)^((γ-1)/γ)  [polytropic approx, γ≈1.14]
//! 5. H_dis_isen = H(P_cond, T_dis_isen)          [isentropic discharge enthalpy]
//! 6. H_dis = H_suc + (H_dis_isen - H_suc) / η_is [actual discharge]
//!
//! Residuals (2 equations, constrain the compressor outlet edge):
//!
//! - r0 = P_dis_state - P_cond_sat(T_cond)
//! - r1 = H_dis_state - H_dis

use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
    Component, ComponentError, ConnectedPort, JacobianBuilder, MeasuredOutput, ResidualVector,
    StateSlice,
};
use entropyk_core::{CalibIndices, Enthalpy, Entropy, Power, Pressure};
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property};
use std::sync::Arc;

/// Volumetric-efficiency model for the compressor displacement mass-flow closure.
///
/// The swept mass flow is `ṁ = ρ_suc · V_s · N · η_vol`, where `η_vol` captures
/// re-expansion of the clearance volume as the pressure ratio grows.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum VolumetricEfficiency {
    /// Constant volumetric efficiency `η_vol` (0..1].
    Constant(f64),
    /// Clearance-volume model `η_vol = 1 + C − C·(P_dis/P_suc)^(1/n)`.
    ///
    /// * `clearance` — clearance-volume ratio `C` (typically 0.02..0.10).
    /// * `polytropic_n` — re-expansion polytropic exponent `n` (≈1.0..1.2).
    Clearance {
        /// Clearance-volume ratio `C`.
        clearance: f64,
        /// Re-expansion polytropic exponent `n`.
        polytropic_n: f64,
    },
}

impl VolumetricEfficiency {
    /// Evaluates the volumetric efficiency at the given pressure ratio `Pr = P_dis/P_suc`.
    ///
    /// The result is clamped to `[0, 1]` to keep the swept mass flow physical even
    /// during early Newton iterations when the pressure ratio is not yet realistic.
    pub fn eval(&self, pressure_ratio: f64) -> f64 {
        let eta = match *self {
            VolumetricEfficiency::Constant(eta) => eta,
            VolumetricEfficiency::Clearance {
                clearance,
                polytropic_n,
            } => {
                let pr = pressure_ratio.max(1.0);
                let n = polytropic_n.max(1e-3);
                1.0 + clearance - clearance * pr.powf(1.0 / n)
            }
        };
        eta.clamp(0.0, 1.0)
    }
}

/// Variable-speed-drive (VSD) efficiency map for an inverter-driven compressor.
///
/// Real inverter compressors do not run at a single fixed efficiency: both the
/// volumetric and the isentropic efficiency peak near a rated speed and fall off
/// at very low speed (leakage-dominated) and very high speed
/// (friction/throttling-dominated). This map applies a multiplicative speed
/// correction `f(r)` to the base efficiencies, with `r = N / N_ref` the ratio to
/// the reference (rating) speed:
///
/// `f(r) = c0 + c1·r + c2·r²`  (clamped to a physical band).
///
/// The default coefficients `[1, 0, 0]` reproduce a speed-independent
/// (constant-efficiency) compressor exactly, so the map is fully opt-in.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VsdSpeedMap {
    /// Reference (rating) speed `N_ref` [rev/s] the correction is normalized to.
    pub reference_speed_hz: f64,
    /// Quadratic coefficients `[c0, c1, c2]` for the volumetric-efficiency
    /// speed correction `η_vol,eff = η_vol · (c0 + c1·r + c2·r²)`.
    pub volumetric_coeffs: [f64; 3],
    /// Quadratic coefficients `[c0, c1, c2]` for the isentropic-efficiency
    /// speed correction `η_is,eff = η_is · (c0 + c1·r + c2·r²)`.
    pub isentropic_coeffs: [f64; 3],
}

impl VsdSpeedMap {
    /// Lower/upper clamp band for the multiplicative correction factor.
    const MIN_FACTOR: f64 = 0.1;
    const MAX_FACTOR: f64 = 1.2;

    /// Creates an identity map (no speed correction) at the given reference speed.
    pub fn identity(reference_speed_hz: f64) -> Self {
        Self {
            reference_speed_hz: reference_speed_hz.max(1e-6),
            volumetric_coeffs: [1.0, 0.0, 0.0],
            isentropic_coeffs: [1.0, 0.0, 0.0],
        }
    }

    /// Creates a VSD map from explicit volumetric and isentropic coefficients.
    pub fn new(
        reference_speed_hz: f64,
        volumetric_coeffs: [f64; 3],
        isentropic_coeffs: [f64; 3],
    ) -> Self {
        Self {
            reference_speed_hz: reference_speed_hz.max(1e-6),
            volumetric_coeffs,
            isentropic_coeffs,
        }
    }

    #[inline]
    fn eval(coeffs: &[f64; 3], r: f64) -> f64 {
        (coeffs[0] + coeffs[1] * r + coeffs[2] * r * r).clamp(Self::MIN_FACTOR, Self::MAX_FACTOR)
    }

    /// Volumetric-efficiency speed-correction factor at speed `N` [rev/s].
    #[inline]
    pub fn volumetric_correction(&self, speed_hz: f64) -> f64 {
        Self::eval(&self.volumetric_coeffs, speed_hz / self.reference_speed_hz)
    }

    /// Isentropic-efficiency speed-correction factor at speed `N` [rev/s].
    #[inline]
    pub fn isentropic_correction(&self, speed_hz: f64) -> f64 {
        Self::eval(&self.isentropic_coeffs, speed_hz / self.reference_speed_hz)
    }
}

/// Simplified isentropic compressor for vapor-compression cycle simulation.
///
/// Uses fixed design-point parameters (t_evap_k, t_cond_k, superheat_k) rather than
/// reading suction conditions from the solver state vector. This avoids transient
/// instability from unphysical intermediate states during Picard iterations.
///
/// # Example (JSON CLI)
///
/// ```json
/// {
///   "type": "IsentropicCompressor",
///   "name": "comp",
///   "isentropic_efficiency": 0.75,
///   "t_cond_k": 323.15,
///   "t_evap_k": 275.15,
///   "superheat_k": 5.0
/// }
/// ```
pub struct IsentropicCompressor {
    /// Isentropic efficiency (0..1)
    isentropic_efficiency: f64,
    /// Condensing saturation temperature [K]
    t_cond_k: f64,
    /// Evaporating saturation temperature [K]
    t_evap_k: f64,
    /// Suction superheat above evaporating temperature [K]
    superheat_k: f64,
    /// Refrigerant identifier
    refrigerant_id: String,
    /// Fluid backend for CoolProp property queries
    fluid_backend: Option<Arc<dyn FluidBackend>>,
    /// State-vector index: discharge pressure (outgoing edge — constrained by this component)
    discharge_p_idx: Option<usize>,
    /// State-vector index: discharge enthalpy (outgoing edge — constrained by this component)
    discharge_h_idx: Option<usize>,
    /// State-vector index: discharge mass flow (outgoing edge, CM1.3)
    discharge_m_idx: Option<usize>,
    /// State-vector index: suction pressure (incoming edge — read for actual compression)
    suction_p_idx: Option<usize>,
    /// State-vector index: suction enthalpy (incoming edge — read for actual compression)
    suction_h_idx: Option<usize>,
    /// State-vector index: suction mass flow (incoming edge, CM1.3)
    suction_m_idx: Option<usize>,
    /// True when suction and discharge share the same ṁ state index (same
    /// series branch). The mass-conservation residual is dropped (CM1.4).
    same_branch_m: bool,
    /// When `true`, the condensing/evaporating pressures are NOT imposed by this
    /// compressor. Instead of driving `P_dis → P_sat(t_cond_k)`, residual r0
    /// closes the shared mass flow via the volumetric displacement model, letting
    /// the discharge pressure emerge from the condenser ↔ secondary balance.
    emergent_pressure: bool,
    /// Swept (displacement) volume per revolution [m³/rev] — required in emergent mode
    /// unless [`mass_flow_external`] is set (ṁ metered by a fixed EXV orifice).
    displacement_m3: Option<f64>,
    /// When true with `emergent_pressure`, the compressor does **not** close ṁ
    /// (an EXV fixed orifice meters the branch). Only the energy residual remains.
    mass_flow_external: bool,
    /// Rotational speed [rev/s] — required in emergent mode.
    speed_hz: Option<f64>,
    /// Volumetric-efficiency model used by the displacement mass-flow closure.
    volumetric_efficiency: VolumetricEfficiency,
    /// Optional variable-speed-drive efficiency map. When set (with a known
    /// `speed_hz`), it applies a multiplicative speed correction to both the
    /// volumetric and the isentropic efficiency. `None` = speed-independent.
    vsd_map: Option<VsdSpeedMap>,
    circuit_id: CircuitId,
    operational_state: OperationalState,
    /// Inverse-control calibration state indices. When `f_m` is `Some(i)`, the
    /// volumetric mass-flow closure is scaled by the control variable at
    /// `state[i]`, turning the compressor into a capacity/mass-flow actuator.
    /// When `f_w` is `Some(i)`, discharge enthalpy uses the live energy-
    /// retention factor at `state[i]` (`1` = adiabatic, `0` = all lost).
    calib_indices: CalibIndices,
    /// Nominal energy-retention factor when not a free unknown. Default 1.
    f_w: f64,
    /// When `true`, a screw-compressor slide valve modulates the effective swept
    /// volume to hold a target suction saturated temperature (SST). The slide
    /// position `σ ∈ [σ_min, 1]` is a free actuator that scales the displacement
    /// mass flow (`ṁ = σ · f_m · ṁ_calc`); one extra equation
    /// `r = T_sat(P_suc) − SST_target` is closed by `σ`. Requires emergent mode.
    slide_valve: bool,
    /// Target suction saturated temperature [K] held by the slide-valve actuator.
    sst_target_k: Option<f64>,
    /// When `true`, a liquid-injection port desuperheats the discharge gas. The
    /// injection ratio `φ_inj ∈ [0, φ_max]` is a *physical actuator* read from
    /// `CalibIndices.actuator`; it lowers the effective discharge enthalpy
    /// (`h_dis,eff = h_dis − φ_inj·(h_dis − h_liq_sat(P_dis))`). Unlike the slide
    /// valve, it emits NO internal setpoint equation — the closing equation is
    /// supplied by a user-declared `controls[]` loop (e.g. hold DGT ≤ max_DGT),
    /// so the controlled variable is selectable in configuration, not hard-coded.
    liquid_injection: bool,
}

impl std::fmt::Debug for IsentropicCompressor {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("IsentropicCompressor")
            .field("isentropic_efficiency", &self.isentropic_efficiency)
            .field("t_cond_k", &self.t_cond_k)
            .field("t_evap_k", &self.t_evap_k)
            .field("superheat_k", &self.superheat_k)
            .field("refrigerant_id", &self.refrigerant_id)
            .field("backend_set", &self.fluid_backend.is_some())
            .field("discharge_p_idx", &self.discharge_p_idx)
            .field("discharge_h_idx", &self.discharge_h_idx)
            .field("suction_p_idx", &self.suction_p_idx)
            .field("suction_h_idx", &self.suction_h_idx)
            .finish()
    }
}

impl IsentropicCompressor {
    /// Creates a new isentropic compressor.
    ///
    /// # Arguments
    ///
    /// * `isentropic_efficiency` - η_is in (0,1], typically 0.70-0.85
    /// * `t_cond_k` - Condensing saturation temperature [K] (sets discharge pressure target)
    /// * `t_evap_k` - Evaporating saturation temperature [K] (sets suction conditions)
    /// * `superheat_k` - Suction superheat above evaporating temperature [K]
    pub fn new(isentropic_efficiency: f64, t_cond_k: f64, t_evap_k: f64, superheat_k: f64) -> Self {
        Self {
            isentropic_efficiency,
            t_cond_k,
            t_evap_k,
            superheat_k,
            refrigerant_id: String::new(),
            fluid_backend: None,
            discharge_p_idx: None,
            discharge_h_idx: None,
            discharge_m_idx: None,
            suction_p_idx: None,
            suction_h_idx: None,
            suction_m_idx: None,
            same_branch_m: false,
            emergent_pressure: false,
            displacement_m3: None,
            mass_flow_external: false,
            speed_hz: None,
            volumetric_efficiency: VolumetricEfficiency::Constant(1.0),
            vsd_map: None,
            circuit_id: CircuitId::default(),
            operational_state: OperationalState::default(),
            calib_indices: CalibIndices::default(),
            f_w: 1.0,
            slide_valve: false,
            sst_target_k: None,
            liquid_injection: false,
        }
    }

    /// Sets the nominal energy-retention factor `f_w` (`1` = adiabatic).
    pub fn with_f_w(mut self, f_w: f64) -> Self {
        self.f_w = f_w.clamp(0.0, 1.0);
        self
    }

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

    /// Attaches a fluid backend for property lookups.
    pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
        self.fluid_backend = Some(backend);
        self
    }

    /// Enables the emergent-pressure mode with a volumetric displacement
    /// mass-flow closure.
    ///
    /// In this mode the compressor no longer pins the discharge pressure to
    /// `P_sat(t_cond_k)`; instead residual r0 closes the shared mass flow via
    /// `ṁ = ρ_suc · V_s · N · η_vol(P_dis/P_suc)`, so the condensing pressure
    /// emerges from the downstream condenser ↔ secondary balance.
    ///
    /// # Arguments
    ///
    /// * `displacement_m3` — swept volume per revolution [m³/rev]
    /// * `speed_hz` — rotational speed [rev/s]
    /// * `volumetric_efficiency` — volumetric-efficiency model
    pub fn with_displacement(
        mut self,
        displacement_m3: f64,
        speed_hz: f64,
        volumetric_efficiency: VolumetricEfficiency,
    ) -> Self {
        self.emergent_pressure = true;
        self.mass_flow_external = false;
        self.displacement_m3 = Some(displacement_m3);
        self.speed_hz = Some(speed_hz);
        self.volumetric_efficiency = volumetric_efficiency;
        self
    }

    /// Emergent pressures, but ṁ is metered elsewhere (fixed EXV orifice).
    ///
    /// Residuals: isentropic energy at the floating discharge pressure (+ mass
    /// continuity when suction/discharge branches differ). No volumetric ṁ closure.
    pub fn with_emergent_metered_flow(mut self) -> Self {
        self.emergent_pressure = true;
        self.mass_flow_external = true;
        self.displacement_m3 = None;
        self.speed_hz = None;
        self
    }

    /// Attaches a variable-speed-drive efficiency map (see [`VsdSpeedMap`]).
    ///
    /// When set together with a known rotational speed, the volumetric and
    /// isentropic efficiencies are corrected by the map's speed factor, so the
    /// compressor performance reacts to inverter speed the way a real VSD unit
    /// does. Has no effect when the speed is unknown.
    pub fn with_vsd_map(mut self, map: VsdSpeedMap) -> Self {
        self.vsd_map = Some(map);
        self
    }

    /// Sets the variable-speed-drive efficiency map (see [`with_vsd_map`]).
    ///
    /// [`with_vsd_map`]: Self::with_vsd_map
    pub fn set_vsd_map(&mut self, map: VsdSpeedMap) {
        self.vsd_map = Some(map);
    }

    /// Volumetric-efficiency speed-correction factor from the VSD map (1.0 when
    /// no map or no speed is configured).
    #[inline]
    fn vsd_volumetric_correction(&self) -> f64 {
        match (self.vsd_map, self.speed_hz) {
            (Some(map), Some(n)) => map.volumetric_correction(n),
            _ => 1.0,
        }
    }

    /// Effective isentropic efficiency including the VSD speed correction.
    #[inline]
    pub fn effective_isentropic_efficiency(&self) -> f64 {
        let factor = match (self.vsd_map, self.speed_hz) {
            (Some(map), Some(n)) => map.isentropic_correction(n),
            _ => 1.0,
        };
        (self.isentropic_efficiency * factor).clamp(1e-3, 1.0)
    }

    /// Returns `true` when the emergent-pressure displacement closure is active
    /// and every prerequisite (backend, refrigerant, wired suction indices, and
    /// displacement parameters) is available.
    fn displacement_ready(&self) -> bool {
        self.emergent_pressure
            && self.fluid_backend.is_some()
            && !self.refrigerant_id.is_empty()
            && self.displacement_m3.is_some()
            && self.speed_hz.is_some()
            && self.suction_p_idx.is_some()
            && self.suction_h_idx.is_some()
            && self.discharge_p_idx.is_some()
            && self.discharge_h_idx.is_some()
            && (self.same_branch_m
                || (self.suction_m_idx.is_some() && self.discharge_m_idx.is_some()))
    }

    /// Enables screw-compressor slide-valve capacity control holding a target
    /// suction saturated temperature `sst_target_k` [K].
    ///
    /// The slide position `σ` becomes a free actuator that scales the effective
    /// swept volume (`ṁ = σ · f_m · ṁ_calc`), and one equation
    /// `T_sat(P_suc) = SST_target` is closed by `σ`. Genuine inverse capacity
    /// control — the slide unloads until suction saturation reaches the setpoint.
    /// Only active in emergent-pressure mode (enable via [`with_displacement`]).
    ///
    /// [`with_displacement`]: Self::with_displacement
    pub fn with_slide_valve(mut self, sst_target_k: f64) -> Self {
        self.slide_valve = true;
        self.sst_target_k = Some(sst_target_k);
        self
    }

    /// Returns the slide-valve target suction saturated temperature [K], if set.
    pub fn sst_target_k(&self) -> Option<f64> {
        self.sst_target_k
    }

    /// Static config test: the slide-valve actuator is requested with all
    /// build-time prerequisites present. Keeps `n_equations` consistent before
    /// the actuator index is wired.
    fn slide_active(&self) -> bool {
        self.slide_valve && self.emergent_pressure && self.sst_target_k.is_some()
    }

    /// `true` when the slide-valve actuator is fully wired (config + resolved
    /// actuator state index) so its residual/Jacobian can act.
    fn slide_ready(&self) -> bool {
        self.slide_active() && self.calib_indices.actuator.is_some()
    }

    /// Effective slide-valve factor `σ` (1.0 when no active/ready slide valve).
    /// Read from the free-actuator state slot and clamped to a small positive
    /// floor to keep the displacement closure well-posed.
    fn slide_factor(&self, state: &StateSlice) -> f64 {
        match (self.slide_ready(), self.calib_indices.actuator) {
            (true, Some(i)) if i < state.len() => {
                let v = state[i];
                if v.is_finite() && v > 0.0 {
                    v
                } else {
                    1.0
                }
            }
            _ => 1.0,
        }
    }

    /// Residual row index of the slide-valve equation (appended after the
    /// thermodynamic + optional mass-conservation rows).
    fn slide_row(&self) -> usize {
        if self.same_branch_m {
            2
        } else {
            3
        }
    }

    /// Suction saturated (evaporating) temperature `T_sat(P_suc)` [K].
    fn evap_temperature(&self, p_suc_pa: f64) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed("Compressor: no fluid backend".to_string())
        })?;
        backend
            .property(
                FluidId::new(&self.refrigerant_id),
                Property::Temperature,
                FluidState::from_px(
                    Pressure::from_pascals(p_suc_pa),
                    entropyk_fluids::Quality::new(0.5),
                ),
            )
            .map_err(|e| ComponentError::CalculationFailed(e.to_string()))
    }

    /// Enables a liquid-injection port that desuperheats the discharge gas.
    ///
    /// The injection ratio `φ_inj` becomes a *physical actuator* read from the
    /// generic `CalibIndices.actuator` slot. It lowers the effective discharge
    /// enthalpy toward saturated-liquid enthalpy at the discharge pressure:
    /// `h_dis,eff = h_dis − φ_inj·(h_dis − h_liq_sat(P_dis))`. No internal
    /// setpoint equation is emitted; a user `controls[]` loop closes the system
    /// (e.g. modulate `φ_inj` to hold the discharge gas temperature at a limit).
    /// Only active in emergent-pressure mode.
    pub fn with_liquid_injection(mut self) -> Self {
        self.liquid_injection = true;
        self
    }

    /// `true` when the liquid-injection port is configured (build-time flag).
    fn injection_active(&self) -> bool {
        self.liquid_injection && self.emergent_pressure
    }

    /// `true` when the injection actuator is fully wired (config + resolved
    /// actuator state index) so its desuperheat term can act.
    fn injection_ready(&self) -> bool {
        self.injection_active() && self.calib_indices.actuator.is_some()
    }

    /// Effective injection ratio `φ_inj ≥ 0` (0.0 when no active/ready port).
    /// Read from the generic actuator state slot, clamped to a non-negative floor.
    fn injection_factor(&self, state: &StateSlice) -> f64 {
        match (self.injection_ready(), self.calib_indices.actuator) {
            (true, Some(i)) if i < state.len() => {
                let v = state[i];
                if v.is_finite() && v > 0.0 {
                    v
                } else {
                    0.0
                }
            }
            _ => 0.0,
        }
    }

    /// Saturated-liquid enthalpy `h_f(P_dis)` [J/kg] at the discharge pressure —
    /// the enthalpy of the injected liquid stream.
    fn liquid_enthalpy(&self, p_dis_pa: f64) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed("Compressor: no fluid backend".to_string())
        })?;
        backend
            .property(
                FluidId::new(&self.refrigerant_id),
                Property::Enthalpy,
                FluidState::from_px(
                    Pressure::from_pascals(p_dis_pa),
                    entropyk_fluids::Quality::new(0.0),
                ),
            )
            .map_err(|e| ComponentError::CalculationFailed(e.to_string()))
    }

    /// Discharge gas temperature (DGT) `T(P_dis, H_dis)` [K] from the solved
    /// discharge edge — the measurable output a liquid-injection control loop
    /// regulates.
    fn discharge_gas_temperature(&self, state: &StateSlice) -> Option<f64> {
        let backend = self.fluid_backend.as_ref()?;
        let dis_p = self.discharge_p_idx?;
        let dis_h = self.discharge_h_idx?;
        if dis_p >= state.len() || dis_h >= state.len() {
            return None;
        }
        let p_dis = state[dis_p];
        let h_dis = state[dis_h];
        if !(p_dis > 1_000.0 && h_dis > 50_000.0) {
            return None;
        }
        backend
            .property(
                FluidId::new(&self.refrigerant_id),
                Property::Temperature,
                FluidState::PressureEnthalpy(
                    Pressure::from_pascals(p_dis),
                    Enthalpy::from_joules_per_kg(h_dis),
                ),
            )
            .ok()
    }

    /// Swept mass flow `ṁ = ρ_suc · V_s · N · η_vol(P_dis/P_suc)` [kg/s].
    fn swept_mass_flow(
        &self,
        backend: &dyn FluidBackend,
        fluid: FluidId,
        p_suc_pa: f64,
        h_suc_jkg: f64,
        p_dis_pa: f64,
    ) -> Result<f64, ComponentError> {
        let rho_suc = backend
            .property(
                fluid,
                Property::Density,
                FluidState::PressureEnthalpy(
                    Pressure::from_pascals(p_suc_pa),
                    Enthalpy::from_joules_per_kg(h_suc_jkg),
                ),
            )
            .map_err(|e| ComponentError::CalculationFailed(format!("rho_suc: {}", e)))?;
        let v_s = self.displacement_m3.ok_or_else(|| {
            ComponentError::InvalidState(
                "IsentropicCompressor swept-flow closure requires displacement_m3".to_string(),
            )
        })?;
        let n = self.speed_hz.ok_or_else(|| {
            ComponentError::InvalidState(
                "IsentropicCompressor swept-flow closure requires speed_hz".to_string(),
            )
        })?;
        let pr = if p_suc_pa > 1.0 {
            p_dis_pa / p_suc_pa
        } else {
            1.0
        };
        let eta_vol = self.volumetric_efficiency.eval(pr) * self.vsd_volumetric_correction();
        Ok(rho_suc * v_s * n * eta_vol)
    }

    /// Returns the inverse-control mass-flow multiplier `f_m` read from the
    /// solver state when this compressor is used as an actuator, or `1.0` when
    /// no control variable is linked. Non-finite or non-positive values fall
    /// back to `1.0` to keep the closure well-posed during early iterations.
    fn control_f_w(&self, state: &StateSlice) -> f64 {
        match self.calib_indices.f_w {
            Some(idx) => state
                .get(idx)
                .copied()
                .unwrap_or(self.f_w)
                .clamp(0.0, 1.0),
            None => self.f_w.clamp(0.0, 1.0),
        }
    }

    fn control_f_m(&self, state: &StateSlice) -> f64 {
        match self.calib_indices.z_flow {
            Some(i) if i < state.len() => {
                let v = state[i];
                if v.is_finite() && v > 0.0 {
                    v
                } else {
                    1.0
                }
            }
            _ => 1.0,
        }
    }

    /// Returns the isentropic efficiency [-].
    pub fn isentropic_efficiency(&self) -> f64 {
        self.isentropic_efficiency
    }

    /// Returns the design-point condensing temperature [K].
    pub fn t_cond_k(&self) -> f64 {
        self.t_cond_k
    }

    /// Returns the design-point evaporating temperature [K].
    pub fn t_evap_k(&self) -> f64 {
        self.t_evap_k
    }

    /// Returns the design-point suction superheat [K].
    pub fn superheat_k(&self) -> f64 {
        self.superheat_k
    }

    /// True isentropic compression using actual suction state (P_suc, H_suc) from the solver.
    ///
    /// Uses CoolProp's (P, H) → entropy and (P, S) → enthalpy to compute the real isentropic
    /// path, then applies isentropic efficiency.
    fn compute_h_dis_from_state(
        &self,
        backend: &dyn FluidBackend,
        fluid: FluidId,
        p_suc_pa: f64,
        h_suc_jkg: f64,
        p_dis_pa: f64,
        state: &StateSlice,
    ) -> Result<f64, ComponentError> {
        let s_suc = backend
            .property(
                fluid.clone(),
                Property::Entropy,
                FluidState::PressureEnthalpy(
                    Pressure::from_pascals(p_suc_pa),
                    Enthalpy::from_joules_per_kg(h_suc_jkg),
                ),
            )
            .map_err(|e| ComponentError::CalculationFailed(format!("S_suc: {}", e)))?;
        let h_dis_isen = backend
            .property(
                fluid,
                Property::Enthalpy,
                FluidState::PressureEntropy(Pressure::from_pascals(p_dis_pa), Entropy(s_suc)),
            )
            .map_err(|e| ComponentError::CalculationFailed(format!("H_dis_isen: {}", e)))?;
        // Retention: h_dis = h_suc + f_w·Δh_is/η_is
        // f_w = 1 → adiabatic; f_w = 0 → all work lost (h_dis → h_suc).
        let dh = (h_dis_isen - h_suc_jkg) / self.effective_isentropic_efficiency();
        let f_w = self.control_f_w(state);
        Ok(h_suc_jkg + f_w * dh)
    }
}

impl Component for IsentropicCompressor {
    fn set_system_context(
        &mut self,
        _state_offset: usize,
        external_edge_state_indices: &[(usize, usize, usize)],
    ) {
        // incoming[0] = evap:outlet → comp:inlet (suction) — read for actual compression
        // outgoing[0] = comp:outlet → cond:inlet (discharge) — constrained by residuals
        // Triple: (m_idx, p_idx, h_idx)
        if !external_edge_state_indices.is_empty() {
            self.suction_m_idx = Some(external_edge_state_indices[0].0);
            self.suction_p_idx = Some(external_edge_state_indices[0].1);
            self.suction_h_idx = Some(external_edge_state_indices[0].2);
        }
        if external_edge_state_indices.len() >= 2 {
            self.discharge_m_idx = Some(external_edge_state_indices[1].0);
            self.discharge_p_idx = Some(external_edge_state_indices[1].1);
            self.discharge_h_idx = Some(external_edge_state_indices[1].2);
        }
        // CM1.4: detect same-branch topology.
        self.same_branch_m = matches!(
            (self.suction_m_idx, self.discharge_m_idx),
            (Some(suc), Some(dis)) if suc == dis
        );
    }

    fn compute_residuals(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        // Emergent + external ṁ (fixed EXV orifice): energy only at floating P_dis.
        if self.emergent_pressure && self.mass_flow_external {
            if residuals.len() < self.n_equations() {
                return Err(ComponentError::InvalidResidualDimensions {
                    expected: self.n_equations(),
                    actual: residuals.len(),
                });
            }
            let backend = self.fluid_backend.as_ref().ok_or_else(|| {
                ComponentError::InvalidState(
                    "IsentropicCompressor metered-flow mode requires a fluid backend".into(),
                )
            })?;
            let fluid = FluidId::new(&self.refrigerant_id);
            let (Some(suc_p), Some(suc_h), Some(dis_p), Some(dis_h)) = (
                self.suction_p_idx,
                self.suction_h_idx,
                self.discharge_p_idx,
                self.discharge_h_idx,
            ) else {
                return Err(ComponentError::InvalidState(
                    "IsentropicCompressor metered-flow mode requires live suction/discharge indices"
                        .into(),
                ));
            };
            let p_suc = state[suc_p];
            let h_suc = state[suc_h];
            let p_dis = state[dis_p];
            if p_suc <= 1_000.0 || h_suc <= 50_000.0 || p_dis <= 1_000.0 {
                return Err(ComponentError::InvalidState(
                    "IsentropicCompressor metered-flow mode requires physical live states".into(),
                ));
            }
            let h_dis =
                self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis, state)?;
            residuals[0] = state[dis_h] - h_dis;
            if !self.same_branch_m {
                residuals[1] = match (self.suction_m_idx, self.discharge_m_idx) {
                    (Some(m_suc), Some(m_dis)) => state[m_dis] - state[m_suc],
                    _ => {
                        return Err(ComponentError::InvalidState(
                            "IsentropicCompressor mass conservation requires live mass-flow indices"
                                .into(),
                        ));
                    }
                };
            }
            return Ok(());
        }

        // Emergent-pressure path: close the shared mass flow via the volumetric
        // displacement model and let the discharge pressure float (set by the
        // downstream condenser outlet closure). r0 no longer pins P_dis.
        if self.displacement_ready() {
            if residuals.len() < self.n_equations() {
                return Err(ComponentError::InvalidResidualDimensions {
                    expected: self.n_equations(),
                    actual: residuals.len(),
                });
            }
            let backend = self.fluid_backend.as_ref().unwrap();
            let fluid = FluidId::new(&self.refrigerant_id);
            let suc_p = self.suction_p_idx.unwrap();
            let suc_h = self.suction_h_idx.unwrap();
            let dis_p = self.discharge_p_idx.unwrap();
            let dis_h = self.discharge_h_idx.unwrap();
            let m_idx = self.suction_m_idx.or(self.discharge_m_idx).ok_or_else(|| {
                ComponentError::InvalidState(
                    "IsentropicCompressor displacement closure requires a live mass-flow index"
                        .to_string(),
                )
            })?;

            let p_suc = state[suc_p];
            let h_suc = state[suc_h];
            let p_dis = state[dis_p];

            if p_suc > 1_000.0 && h_suc > 50_000.0 && p_dis > 1_000.0 {
                let m_calc =
                    self.swept_mass_flow(backend.as_ref(), fluid.clone(), p_suc, h_suc, p_dis)?;
                let h_dis =
                    self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis, state)?;
                // Inverse-control actuator: scale the swept mass flow by the
                // linked control variable f_m (1.0 when no control is attached)
                // and the slide-valve position σ (1.0 when no slide valve).
                let f_m = self.control_f_m(state);
                let sigma = self.slide_factor(state);
                // r0: mass-flow closure ṁ_state − σ·f_m·ṁ_calc = 0 (pins the branch ṁ).
                residuals[0] = state[m_idx] - sigma * f_m * m_calc;
                // r1: actual isentropic compression to the (floating) discharge
                // pressure, optionally desuperheated by liquid injection:
                //   h_dis,eff = h_dis − φ_inj·(h_dis − h_liq_sat(P_dis)).
                let h_dis_eff = if self.injection_ready() {
                    let phi = self.injection_factor(state);
                    let h_liq = self.liquid_enthalpy(p_dis)?;
                    h_dis - phi * (h_dis - h_liq)
                } else {
                    h_dis
                };
                residuals[1] = state[dis_h] - h_dis_eff;
                if !self.same_branch_m {
                    residuals[2] = match (self.suction_m_idx, self.discharge_m_idx) {
                        (Some(m_suc), Some(m_dis)) => state[m_dis] - state[m_suc],
                        _ => {
                            return Err(ComponentError::InvalidState(
                                "IsentropicCompressor mass conservation requires live suction and discharge mass-flow indices".to_string(),
                            ));
                        }
                    };
                }
                // Slide-valve equation: T_sat(P_suc) = SST_target, closed by σ.
                if self.slide_active() {
                    residuals[self.slide_row()] = if self.slide_ready() {
                        self.evap_temperature(p_suc)?
                            - self.sst_target_k.ok_or_else(|| {
                                ComponentError::InvalidState(
                                    "IsentropicCompressor slide valve requires an SST target"
                                        .to_string(),
                                )
                            })?
                    } else {
                        return Err(ComponentError::InvalidState(
                            "IsentropicCompressor slide valve requires a live actuator index"
                                .to_string(),
                        ));
                    };
                }
                return Ok(());
            }
            // Soft domain penalty — never abort the whole system residual
            // evaluation. A hard InvalidState here kills Picard/homotopy recovery
            // after a singular-J Newton step wanders into P≈0 / h≈0.
            residuals[0] = 1.0e6;
            residuals[1] = 1.0e6;
            if !self.same_branch_m && residuals.len() > 2 {
                residuals[2] = 1.0e6;
            }
            return Ok(());
        }

        if let (Some(backend), Some(dis_p), Some(dis_h)) = (
            &self.fluid_backend,
            self.discharge_p_idx,
            self.discharge_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(),
                    });
                }

                // r0: drive discharge pressure to condensing saturation pressure (unchanged)
                let p_cond_sat = backend
                    .saturation_pressure_t(FluidId::new(&self.refrigerant_id), self.t_cond_k)
                    .map_err(|e| ComponentError::CalculationFailed(format!("P_cond: {}", e)))?;

                // r1: true isentropic compression from actual suction state when available
                let h_dis =
                    if let (Some(suc_p), Some(suc_h)) = (self.suction_p_idx, self.suction_h_idx) {
                        let p_suc = state[suc_p];
                        let h_suc = state[suc_h];
                        if p_suc > 1_000.0 && h_suc > 50_000.0 {
                            self.compute_h_dis_from_state(
                                backend.as_ref(),
                                FluidId::new(&self.refrigerant_id),
                                p_suc,
                                h_suc,
                                p_cond_sat,
                            state)?
                        } else {
                            return Err(ComponentError::InvalidState(
                            "IsentropicCompressor requires physical live suction pressure/enthalpy"
                                .to_string(),
                        ));
                        }
                    } else {
                        return Err(ComponentError::InvalidState(
                            "IsentropicCompressor requires live suction pressure/enthalpy indices"
                                .to_string(),
                        ));
                    };

                residuals[0] = state[dis_p] - p_cond_sat;
                residuals[1] = state[dis_h] - h_dis;
                // r2 = ṁ_discharge − ṁ_suction = 0  (mass conservation, CM1.3)
                // CM1.4: skip when same_branch_m — trivially zero.
                if !self.same_branch_m {
                    residuals[2] = match (self.suction_m_idx, self.discharge_m_idx) {
                        (Some(m_suc), Some(m_dis)) => state[m_dis] - state[m_suc],
                        _ => {
                            return Err(ComponentError::InvalidState(
                                "IsentropicCompressor mass conservation requires live suction and discharge mass-flow indices".to_string(),
                            ));
                        }
                    };
                }
                if self.slide_active() {
                    residuals[self.slide_row()] =
                        match (self.slide_ready(), self.calib_indices.actuator) {
                            (true, Some(i)) if i < state.len() => state[i] - 1.0,
                            _ => {
                                return Err(ComponentError::InvalidState(
                                "IsentropicCompressor slide valve requires a live actuator index"
                                    .to_string(),
                            ));
                            }
                        };
                }
                return Ok(());
            }
        }
        Err(ComponentError::InvalidState(
            "IsentropicCompressor requires a refrigerant backend and live discharge pressure/enthalpy indices".to_string(),
        ))
    }

    fn jacobian_entries(
        &self,
        state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        // Metered ṁ: r0 = h_dis − h_isen(P_suc,h_suc,P_dis)
        if self.emergent_pressure && self.mass_flow_external {
            if let (Some(suc_p), Some(suc_h), Some(dis_p), Some(dis_h)) = (
                self.suction_p_idx,
                self.suction_h_idx,
                self.discharge_p_idx,
                self.discharge_h_idx,
            ) {
                jacobian.add_entry(0, dis_h, 1.0);
                // Numerical ∂h_isen/∂(P_suc,h_suc,P_dis)
                if let Some(backend) = self.fluid_backend.as_ref() {
                    let fluid = FluidId::new(&self.refrigerant_id);
                    let p_suc = state[suc_p];
                    let h_suc = state[suc_h];
                    let p_dis = state[dis_p];
                    let dpp = p_suc * 1e-4 + 100.0;
                    let dph = h_suc * 1e-4 + 10.0;
                    let dpd = p_dis * 1e-4 + 100.0;
                    let h = |ps: f64, hs: f64, pd: f64| {
                        self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd, state)
                    };
                    if let (Ok(a), Ok(b)) =
                        (h(p_suc + dpp, h_suc, p_dis), h(p_suc - dpp, h_suc, p_dis))
                    {
                        jacobian.add_entry(0, suc_p, -(a - b) / (2.0 * dpp));
                    }
                    if let (Ok(a), Ok(b)) =
                        (h(p_suc, h_suc + dph, p_dis), h(p_suc, h_suc - dph, p_dis))
                    {
                        jacobian.add_entry(0, suc_h, -(a - b) / (2.0 * dph));
                    }
                    if let (Ok(a), Ok(b)) =
                        (h(p_suc, h_suc, p_dis + dpd), h(p_suc, h_suc, p_dis - dpd))
                    {
                        jacobian.add_entry(0, dis_p, -(a - b) / (2.0 * dpd));
                    }
                }
                if !self.same_branch_m {
                    if let (Some(m_suc), Some(m_dis)) = (self.suction_m_idx, self.discharge_m_idx) {
                        jacobian.add_entry(1, m_dis, 1.0);
                        jacobian.add_entry(1, m_suc, -1.0);
                    }
                }
            }
            return Ok(());
        }

        // Emergent-pressure path: Jacobian of the displacement mass-flow closure
        // (r0) and the floating-pressure isentropic compression (r1).
        if self.displacement_ready() {
            let backend = self.fluid_backend.as_ref().unwrap();
            let fluid = FluidId::new(&self.refrigerant_id);
            let suc_p = self.suction_p_idx.unwrap();
            let suc_h = self.suction_h_idx.unwrap();
            let dis_p = self.discharge_p_idx.unwrap();
            let dis_h = self.discharge_h_idx.unwrap();
            let m_idx = self.suction_m_idx.or(self.discharge_m_idx).ok_or_else(|| {
                ComponentError::InvalidState(
                    "IsentropicCompressor displacement Jacobian requires a live mass-flow index"
                        .to_string(),
                )
            })?;

            let p_suc = state[suc_p];
            let h_suc = state[suc_h];
            let p_dis = state[dis_p];

            if p_suc > 1_000.0 && h_suc > 50_000.0 && p_dis > 1_000.0 {
                // r0 = ṁ_state − σ·f_m·ṁ_calc(P_suc, H_suc, P_dis)
                let f_m = self.control_f_m(state);
                let sigma = self.slide_factor(state);
                let fm_sigma = f_m * sigma;
                jacobian.add_entry(0, m_idx, 1.0);
                let dpp = p_suc * 1e-4 + 100.0;
                let dph = h_suc * 1e-4 + 10.0;
                let dpd = p_dis * 1e-4 + 100.0;
                let mf = |ps: f64, hs: f64, pd: f64| {
                    self.swept_mass_flow(backend.as_ref(), fluid.clone(), ps, hs, pd)
                };
                if let (Ok(a), Ok(b)) =
                    (mf(p_suc + dpp, h_suc, p_dis), mf(p_suc - dpp, h_suc, p_dis))
                {
                    jacobian.add_entry(0, suc_p, -fm_sigma * (a - b) / (2.0 * dpp));
                }
                if let (Ok(a), Ok(b)) =
                    (mf(p_suc, h_suc + dph, p_dis), mf(p_suc, h_suc - dph, p_dis))
                {
                    jacobian.add_entry(0, suc_h, -fm_sigma * (a - b) / (2.0 * dph));
                }
                if let (Ok(a), Ok(b)) =
                    (mf(p_suc, h_suc, p_dis + dpd), mf(p_suc, h_suc, p_dis - dpd))
                {
                    jacobian.add_entry(0, dis_p, -fm_sigma * (a - b) / (2.0 * dpd));
                }
                // ∂r0/∂f_m = −σ·ṁ_calc and ∂r0/∂σ = −f_m·ṁ_calc — couple the
                // control/slide unknowns into the closure so the Newton system
                // stays non-singular.
                if let Ok(m_calc) =
                    self.swept_mass_flow(backend.as_ref(), fluid.clone(), p_suc, h_suc, p_dis)
                {
                    if let Some(z_flow_idx) = self.calib_indices.z_flow {
                        jacobian.add_entry(0, z_flow_idx, -sigma * m_calc);
                    }
                    if self.slide_ready() {
                        if let Some(sigma_idx) = self.calib_indices.actuator {
                            jacobian.add_entry(0, sigma_idx, -f_m * m_calc);
                        }
                    }
                }

                // r1 = H_dis_state − h_dis,eff(P_suc, H_suc, P_dis; φ_inj).
                // When liquid injection is active, h_dis,eff subtracts the
                // desuperheating term; the closure captures its P_dis dependence.
                jacobian.add_entry(1, dis_h, 1.0);
                let phi_inj = self.injection_factor(state);
                let inj_ready = self.injection_ready();
                let hd = |ps: f64, hs: f64, pd: f64| -> Result<f64, ComponentError> {
                    let h =
                        self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd, state)?;
                    if inj_ready {
                        let h_liq = self.liquid_enthalpy(pd)?;
                        Ok(h - phi_inj * (h - h_liq))
                    } else {
                        Ok(h)
                    }
                };
                if let (Ok(a), Ok(b)) =
                    (hd(p_suc + dpp, h_suc, p_dis), hd(p_suc - dpp, h_suc, p_dis))
                {
                    jacobian.add_entry(1, suc_p, -(a - b) / (2.0 * dpp));
                }
                if let (Ok(a), Ok(b)) =
                    (hd(p_suc, h_suc + dph, p_dis), hd(p_suc, h_suc - dph, p_dis))
                {
                    jacobian.add_entry(1, suc_h, -(a - b) / (2.0 * dph));
                }
                if let (Ok(a), Ok(b)) =
                    (hd(p_suc, h_suc, p_dis + dpd), hd(p_suc, h_suc, p_dis - dpd))
                {
                    jacobian.add_entry(1, dis_p, -(a - b) / (2.0 * dpd));
                }
                // ∂r1/∂f_w = −dh  (h_dis = h_suc + f_w·dh ⇒ r = h − h_dis)
                if let Some(f_w_idx) = self.calib_indices.f_w {
                    if let Ok(h_full) = self.compute_h_dis_from_state(
                        backend.as_ref(),
                        fluid.clone(),
                        p_suc,
                        h_suc,
                        p_dis,
                        state,
                    ) {
                        // Reconstruct dh from h_full at current f_w: h = h_suc + fw·dh
                        let fw = self.control_f_w(state).max(1e-9);
                        let dh = (h_full - h_suc) / fw;
                        jacobian.add_entry(1, f_w_idx, -dh);
                    }
                }
                // ∂r1/∂φ_inj = +(h_dis − h_liq): the injection actuator couples
                // into the energy balance so a controls[] loop has a plant to act
                // on (higher injection ⇒ lower discharge enthalpy ⇒ lower DGT).
                if inj_ready {
                    if let Some(inj_idx) = self.calib_indices.actuator {
                        let h_dis = self.compute_h_dis_from_state(
                            backend.as_ref(),
                            fluid.clone(),
                            p_suc,
                            h_suc,
                            p_dis,
                        state);
                        let h_liq = self.liquid_enthalpy(p_dis);
                        if let (Ok(h_dis), Ok(h_liq)) = (h_dis, h_liq) {
                            jacobian.add_entry(1, inj_idx, h_dis - h_liq);
                        }
                    }
                }

                if !self.same_branch_m {
                    if let (Some(m_suc), Some(m_dis)) = (self.suction_m_idx, self.discharge_m_idx) {
                        jacobian.add_entry(2, m_dis, 1.0);
                        jacobian.add_entry(2, m_suc, -1.0);
                    }
                }
                // Slide-valve row: r_slide = T_sat(P_suc) − SST_target.
                // ∂/∂P_suc = dT_sat/dP_suc via central finite difference.
                if self.slide_ready() {
                    let tp = self.evap_temperature(p_suc + dpp);
                    let tm = self.evap_temperature((p_suc - dpp).max(1.0));
                    if let (Ok(tp), Ok(tm)) = (tp, tm) {
                        jacobian.add_entry(self.slide_row(), suc_p, (tp - tm) / (2.0 * dpp));
                    }
                }
                return Ok(());
            }
        }

        if let (Some(backend), Some(dis_p), Some(dis_h)) = (
            &self.fluid_backend,
            self.discharge_p_idx,
            self.discharge_h_idx,
        ) {
            if !self.refrigerant_id.is_empty() {
                // r0: P_dis - P_cond_sat(T_cond_fixed) — constant target → diagonal only
                jacobian.add_entry(0, dis_p, 1.0);
                // r1: H_dis - h_dis(P_suc, H_suc)
                jacobian.add_entry(1, dis_h, 1.0);

                if let (Some(suc_p), Some(suc_h)) = (self.suction_p_idx, self.suction_h_idx) {
                    let p_suc = state[suc_p];
                    let h_suc = state[suc_h];
                    if p_suc > 1_000.0 && h_suc > 50_000.0 {
                        // Numerical ∂h_dis/∂P_suc
                        let dp = p_suc * 1e-4 + 100.0;
                        let p_cond_sat = backend
                            .saturation_pressure_t(
                                FluidId::new(&self.refrigerant_id),
                                self.t_cond_k,
                            )
                            .map_err(|e| {
                                ComponentError::CalculationFailed(format!("P_cond: {}", e))
                            })?;
                        let fluid = FluidId::new(&self.refrigerant_id);
                        let hp = self.compute_h_dis_from_state(
                            backend.as_ref(),
                            fluid.clone(),
                            p_suc + dp,
                            h_suc,
                            p_cond_sat,
                        state);
                        let hm = self.compute_h_dis_from_state(
                            backend.as_ref(),
                            fluid.clone(),
                            p_suc - dp,
                            h_suc,
                            p_cond_sat,
                        state);
                        if let (Ok(hp), Ok(hm)) = (hp, hm) {
                            jacobian.add_entry(1, suc_p, -(hp - hm) / (2.0 * dp));
                        }
                        // Numerical ∂h_dis/∂H_suc
                        let dh = h_suc * 1e-4 + 10.0;
                        let hp = self.compute_h_dis_from_state(
                            backend.as_ref(),
                            fluid.clone(),
                            p_suc,
                            h_suc + dh,
                            p_cond_sat,
                        state);
                        let hm = self.compute_h_dis_from_state(
                            backend.as_ref(),
                            fluid,
                            p_suc,
                            h_suc - dh,
                            p_cond_sat,
                        state);
                        if let (Ok(hp), Ok(hm)) = (hp, hm) {
                            jacobian.add_entry(1, suc_h, -(hp - hm) / (2.0 * dh));
                        }
                    }
                }
                // r2 = ṁ_discharge − ṁ_suction  →  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_suc), Some(m_dis)) = (self.suction_m_idx, self.discharge_m_idx) {
                        jacobian.add_entry(2, m_dis, 1.0);
                        jacobian.add_entry(2, m_suc, -1.0);
                    }
                }
                // Transient slide-valve row: r_slide = σ − 1, ∂/∂σ = 1.
                if self.slide_ready() {
                    if let Some(sigma_idx) = self.calib_indices.actuator {
                        jacobian.add_entry(self.slide_row(), sigma_idx, 1.0);
                    }
                }
                return Ok(());
            }
        }
        Ok(())
    }

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

    fn set_calib_indices(&mut self, indices: CalibIndices) {
        self.calib_indices = indices;
    }

    fn n_equations(&self) -> usize {
        // Metered ṁ (EXV orifice): energy only (+ mass continuity if distinct branches).
        if self.emergent_pressure && self.mass_flow_external {
            return if self.same_branch_m { 1 } else { 2 };
        }
        // CM1.4: drop conservation equation when same-branch.
        let core = if self.same_branch_m { 2 } else { 3 };
        // +1 for the slide-valve equation (T_sat(P_suc) = SST_target) closed by
        // the slide-position free actuator. Static config keeps DoF consistent.
        core + if self.slide_active() { 1 } else { 0 }
    }

    fn equation_roles(&self) -> Vec<crate::EquationRole> {
        // Same-branch: energy + volumetric ṁ closure. Distinct-branch adds mass eq.
        let mut roles = vec![crate::EquationRole::EnergyBalance {
            stream: "refrigerant",
        }];
        if !self.same_branch_m {
            roles.push(crate::EquationRole::MassConservation {
                stream: "refrigerant",
            });
        }
        roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "m" });
        if self.slide_active() {
            roles.push(crate::EquationRole::ActuatorClosure {
                name: "slide_valve",
            });
        }
        roles
    }

    fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
        // A stopped or bypassed compressor exchanges no energy.
        if matches!(
            self.operational_state,
            OperationalState::Off | OperationalState::Bypass
        ) {
            return Some((Power::from_watts(0.0), Power::from_watts(0.0)));
        }

        // Shaft work is evaluated from the *solved* cycle state: the volumetric
        // closure has already fixed the branch mass flow ṁ and the isentropic
        // compression has fixed the discharge enthalpy, so at convergence
        // W = ṁ·(h_dis − h_suc) is the genuine electrical/shaft power draw.
        let suc_h = self.suction_h_idx?;
        let dis_h = self.discharge_h_idx?;
        let m_idx = self.suction_m_idx.or(self.discharge_m_idx)?;
        if suc_h >= state.len() || dis_h >= state.len() || m_idx >= state.len() {
            return None;
        }
        let h_suc = state[suc_h];
        let h_dis = state[dis_h];
        let m_dot = state[m_idx];
        if !(h_suc.is_finite() && h_dis.is_finite() && m_dot.is_finite()) {
            return None;
        }

        // Electrical/shaft power. With liquid injection the discharge EDGE enthalpy
        // has been desuperheated (h_dis,eff), but the motor still performs the full
        // compression work. Recover the un-desuperheated compression enthalpy so
        // the reported power and COP stay physical (the injection cooling happens
        // downstream of the shaft and must not deflate the electrical input).
        let h_dis_work = if self.injection_ready() {
            match (
                self.suction_p_idx,
                self.discharge_p_idx,
                self.fluid_backend.as_ref(),
            ) {
                (Some(sp), Some(dp), Some(backend)) if sp < state.len() && dp < state.len() => {
                    let fluid = FluidId::new(&self.refrigerant_id);
                    self.compute_h_dis_from_state(
                        backend.as_ref(),
                        fluid,
                        state[sp],
                        h_suc,
                        state[dp],
                    state)
                    .unwrap_or(h_dis)
                }
                _ => h_dis,
            }
        } else {
            h_dis
        };

        // The compressor is modelled as adiabatic (Q = 0); all the enthalpy rise
        // is shaft work done ON the fluid. Work done ON the component is reported
        // negative, matching the ΣQ − ΣW + Σṁh = 0 balance convention.
        let work_w = m_dot * (h_dis_work - h_suc);
        Some((Power::from_watts(0.0), Power::from_watts(-work_w)))
    }

    fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
        // Expose the discharge gas temperature (DGT) so a controls[] loop can
        // regulate liquid injection against a maximum-DGT limit (Carrier CL:
        // LI_ON when comp_out.T > DGT_max). Only meaningful in emergent mode.
        match kind {
            MeasuredOutput::Temperature => self.discharge_gas_temperature(state),
            _ => None,
        }
    }

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

    fn signature(&self) -> String {
        format!(
            "IsentropicCompressor(fluid={}, eta_is={:.2}, t_evap={:.1}K, t_cond={:.1}K)",
            self.refrigerant_id, self.isentropic_efficiency, self.t_evap_k, self.t_cond_k
        )
    }

    fn to_params(&self) -> crate::ComponentParams {
        crate::ComponentParams::new("IsentropicCompressor")
            .with_param("isentropic_efficiency", self.isentropic_efficiency)
            .with_param("t_cond_k", self.t_cond_k)
            .with_param("t_evap_k", self.t_evap_k)
            .with_param("superheat_k", self.superheat_k)
            .with_param("fluid", self.refrigerant_id.as_str())
    }
}

impl StateManageable for IsentropicCompressor {
    fn state(&self) -> OperationalState {
        self.operational_state
    }

    fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
        self.operational_state = state;
        Ok(())
    }

    fn can_transition_to(&self, _target: OperationalState) -> bool {
        true
    }

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

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

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

    #[test]
    fn test_isentropic_compressor_creation() {
        let comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0);
        assert_eq!(comp.isentropic_efficiency(), 0.75);
        assert_eq!(comp.t_cond_k(), 323.15);
        assert_eq!(comp.t_evap_k(), 275.15);
        assert_eq!(comp.superheat_k(), 5.0);
        // CM1.3: 2 thermo + 1 mass-flow conservation = 3
        assert_eq!(comp.n_equations(), 3);
    }

    #[test]
    fn test_builder_pattern() {
        let comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0).with_refrigerant("R410A");
        assert_eq!(comp.refrigerant_id, "R410A");
    }

    #[test]
    fn test_volumetric_efficiency_models() {
        // Constant model is clamped to [0,1].
        assert_eq!(VolumetricEfficiency::Constant(0.9).eval(3.0), 0.9);
        assert_eq!(VolumetricEfficiency::Constant(1.5).eval(3.0), 1.0);
        // Clearance model decreases with pressure ratio.
        let m = VolumetricEfficiency::Clearance {
            clearance: 0.05,
            polytropic_n: 1.1,
        };
        let eta1 = m.eval(2.0);
        let eta2 = m.eval(5.0);
        assert!(
            eta1 > eta2,
            "η_vol must fall as Pr grows: {} !> {}",
            eta1,
            eta2
        );
        assert!(eta2 > 0.0 && eta1 <= 1.0);
    }

    #[test]
    fn test_control_f_m_reads_state_with_safe_fallbacks() {
        use entropyk_core::CalibIndices;
        use entropyk_fluids::TestBackend;
        let backend = Arc::new(TestBackend::new());

        let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95));
        comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);

        // No control attached → neutral multiplier.
        let state = vec![0.2, 350_000.0, 410_000.0, 1_200_000.0, 440_000.0, 0.7];
        assert_eq!(comp.control_f_m(&state), 1.0);

        // Control attached → reads f_m from the mapped state slot.
        comp.set_calib_indices(CalibIndices {
            z_flow: Some(5),
            ..CalibIndices::default()
        });
        assert!((comp.control_f_m(&state) - 0.7).abs() < 1e-12);

        // Non-positive / non-finite / out-of-range → safe fallback to 1.0.
        let bad = vec![0.2, 350_000.0, 410_000.0, 1_200_000.0, 440_000.0, -1.0];
        assert_eq!(comp.control_f_m(&bad), 1.0);
        let short = vec![0.2, 350_000.0];
        assert_eq!(comp.control_f_m(&short), 1.0);
    }

    #[test]
    fn test_control_f_m_emits_jacobian_column() {
        use entropyk_core::CalibIndices;
        use entropyk_fluids::TestBackend;
        let backend = Arc::new(TestBackend::new());

        let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95));
        comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
        comp.set_calib_indices(CalibIndices {
            z_flow: Some(5),
            ..CalibIndices::default()
        });

        let fluid = FluidId::new("R134a");
        let (p_suc, h_suc, p_dis) = (350_000.0, 410_000.0, 1_200_000.0);
        let m_calc = comp
            .swept_mass_flow(backend.as_ref(), fluid, p_suc, h_suc, p_dis)
            .unwrap();

        let state = vec![0.2, p_suc, h_suc, p_dis, 440_000.0, 1.0];
        let mut jac = JacobianBuilder::new();
        comp.jacobian_entries(&state, &mut jac).unwrap();

        // ∂r0/∂f_m must equal −ṁ_calc so the control couples into the Newton system.
        let entry = jac
            .entries()
            .iter()
            .find(|(row, col, _)| *row == 0 && *col == 5)
            .expect("compressor must emit a ∂r0/∂f_m Jacobian column");
        assert!(
            (entry.2 + m_calc).abs() < 1e-6,
            "∂r0/∂f_m={} expected {}",
            entry.2,
            -m_calc
        );
    }

    #[test]
    fn test_emergent_mass_flow_closure_reacts_to_speed() {
        use entropyk_fluids::TestBackend;
        let backend = Arc::new(TestBackend::new());
        let fluid = FluidId::new("R134a");
        let (p_suc, h_suc, p_dis) = (350_000.0, 410_000.0, 1_200_000.0);

        let make = |speed_hz: f64| {
            let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
                .with_refrigerant("R134a")
                .with_fluid_backend(backend.clone())
                .with_displacement(3.0e-5, speed_hz, VolumetricEfficiency::Constant(0.95));
            comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
            comp
        };
        let comp_slow = make(25.0);
        let comp_fast = make(50.0);
        assert!(comp_slow.displacement_ready());

        let m_slow = comp_slow
            .swept_mass_flow(backend.as_ref(), fluid.clone(), p_suc, h_suc, p_dis)
            .unwrap();
        let m_fast = comp_fast
            .swept_mass_flow(backend.as_ref(), fluid, p_suc, h_suc, p_dis)
            .unwrap();

        // Doubling the speed doubles the swept mass flow — the displacement
        // closure genuinely reacts to the compressor, closing the branch ṁ.
        assert!(m_slow > 0.0);
        assert!(
            (m_fast - 2.0 * m_slow).abs() < 1e-9,
            "ṁ must scale linearly with speed ({} vs {})",
            m_fast,
            m_slow
        );
    }

    // ---- Slide-valve actuator (arch-6) ---------------------------------------

    /// A slide-valve compressor emits one extra equation (T_sat(P_suc)=SST_target),
    /// closed by the slide-position free actuator. The count must be static so DoF
    /// stays balanced before the actuator index is wired.
    #[test]
    fn test_slide_valve_adds_one_equation() {
        use entropyk_fluids::TestBackend;
        let backend = Arc::new(TestBackend::new());
        let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95))
            .with_slide_valve(278.15);
        // Same-branch topology: suction (m=0,p=1,h=2), discharge (m=0,p=3,h=4).
        comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
        // 2 thermo (same-branch) + 1 slide = 3.
        assert_eq!(comp.n_equations(), 3);
        assert_eq!(comp.slide_row(), 2);
        // Static: active before the actuator index is wired, not yet ready.
        assert!(comp.slide_active());
        assert!(!comp.slide_ready());
    }

    /// The slide residual `T_sat(P_suc) − SST_target` reacts to the setpoint, and
    /// the mass-flow closure r0 reacts to the slide position σ. Genuine unloading
    /// physics — no fixed design point.
    #[test]
    fn test_slide_valve_residual_reacts_to_target_and_sigma() {
        use entropyk_core::CalibIndices;
        use entropyk_fluids::TestBackend;
        let backend = Arc::new(TestBackend::new());
        let sst_target = 278.15;
        let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend.clone())
            .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95))
            .with_slide_valve(sst_target);
        comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
        comp.set_calib_indices(CalibIndices {
            actuator: Some(5),
            ..CalibIndices::default()
        });
        assert!(comp.slide_ready());

        let (p_suc, h_suc, p_dis) = (350_000.0, 410_000.0, 1_200_000.0);
        // State: [ṁ, P_suc, H_suc, P_dis, H_dis, σ].
        let mut state = vec![0.2, p_suc, h_suc, p_dis, 440_000.0, 1.0];

        let t_evap = comp.evap_temperature(p_suc).unwrap();
        let mut r = vec![0.0; comp.n_equations()];
        comp.compute_residuals(&state, &mut r).unwrap();
        // Slide residual = T_sat(P_suc) − SST_target.
        assert!(
            (r[comp.slide_row()] - (t_evap - sst_target)).abs() < 1e-6,
            "slide residual must be T_sat − SST_target: {}",
            r[comp.slide_row()]
        );

        // r0 = ṁ − σ·f_m·ṁ_calc: reducing σ raises r0 (less mass flow drawn).
        let r0_full = r[0];
        state[5] = 0.5;
        comp.compute_residuals(&state, &mut r).unwrap();
        assert!(
            r[0] > r0_full + 1e-6,
            "unloaded slide (smaller σ) ⇒ larger mass-flow residual: {} !> {}",
            r[0],
            r0_full
        );
    }

    /// Analytic Jacobian of the slide-valve emergent path matches central finite
    /// differences, including the ∂r0/∂σ coupling and the ∂r_slide/∂P_suc entry.
    #[test]
    fn test_slide_valve_jacobian_matches_finite_difference() {
        use entropyk_core::CalibIndices;
        use entropyk_fluids::TestBackend;
        let backend = Arc::new(TestBackend::new());
        let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95))
            .with_slide_valve(278.15);
        comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
        comp.set_calib_indices(CalibIndices {
            actuator: Some(5),
            ..CalibIndices::default()
        });

        let state = vec![0.2, 350_000.0, 410_000.0, 1_200_000.0, 440_000.0, 0.8];
        let n_eq = comp.n_equations();
        let n_var = state.len();

        let mut jac = JacobianBuilder::new();
        comp.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];
            comp.compute_residuals(&sp, &mut rp).unwrap();
            comp.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
                );
            }
        }
    }

    // ---- Liquid-injection actuator (arch-6) ----------------------------------

    /// Liquid injection is a *factor* (like f_m), not a setpoint equation: it must
    /// NOT change the compressor equation count. The closing equation is provided
    /// by the user `controls[]` loop, keeping DoF balanced.
    #[test]
    fn test_liquid_injection_adds_no_equation() {
        use entropyk_fluids::TestBackend;
        let backend = Arc::new(TestBackend::new());
        let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95))
            .with_liquid_injection();
        comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
        // Same-branch topology ⇒ 2 thermo equations, injection adds none.
        assert_eq!(comp.n_equations(), 2);
        assert!(comp.injection_active());
        // Not ready until the actuator slot is wired.
        assert!(!comp.injection_ready());
    }

    /// Raising the injection ratio φ_inj desuperheats the discharge gas, lowering
    /// the effective discharge enthalpy in r1 (genuine energy coupling) and the
    /// measured discharge gas temperature (DGT) exposed to control loops.
    #[test]
    fn test_liquid_injection_desuperheats_discharge() {
        use entropyk_core::CalibIndices;
        use entropyk_fluids::TestBackend;
        let backend = Arc::new(TestBackend::new());
        let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95))
            .with_liquid_injection();
        comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
        comp.set_calib_indices(CalibIndices {
            actuator: Some(5),
            ..CalibIndices::default()
        });
        assert!(comp.injection_ready());

        let (p_suc, h_suc, p_dis) = (350_000.0, 410_000.0, 1_200_000.0);
        // State: [ṁ, P_suc, H_suc, P_dis, H_dis, φ_inj].
        let mut state = vec![0.2, p_suc, h_suc, p_dis, 440_000.0, 0.0];

        let mut r = vec![0.0; comp.n_equations()];
        comp.compute_residuals(&state, &mut r).unwrap();
        let r1_no_inj = r[1];

        // Non-zero injection ⇒ smaller effective h_dis ⇒ larger r1 = H_dis − h_dis,eff.
        state[5] = 0.2;
        comp.compute_residuals(&state, &mut r).unwrap();
        assert!(
            r[1] > r1_no_inj + 1.0,
            "injection must desuperheat (raise r1): {} !> {}",
            r[1],
            r1_no_inj
        );

        // DGT is exposed via measure_output(Temperature) for control loops.
        let dgt = comp.measure_output(MeasuredOutput::Temperature, &state);
        assert!(
            dgt.is_some(),
            "compressor must expose discharge gas temperature"
        );
    }

    /// Analytic Jacobian of the liquid-injection emergent path matches central
    /// finite differences, including the ∂r1/∂φ_inj = (h_dis − h_liq) coupling.
    #[test]
    fn test_liquid_injection_jacobian_matches_finite_difference() {
        use entropyk_core::CalibIndices;
        use entropyk_fluids::TestBackend;
        let backend = Arc::new(TestBackend::new());
        let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95))
            .with_liquid_injection();
        comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
        comp.set_calib_indices(CalibIndices {
            actuator: Some(5),
            ..CalibIndices::default()
        });

        let state = vec![0.2, 350_000.0, 410_000.0, 1_200_000.0, 440_000.0, 0.15];
        let n_eq = comp.n_equations();
        let n_var = state.len();

        let mut jac = JacobianBuilder::new();
        comp.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];
            comp.compute_residuals(&sp, &mut rp).unwrap();
            comp.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
                );
            }
        }
    }

    #[test]
    fn test_energy_transfers_reports_shaft_work_from_state() {
        use entropyk_fluids::TestBackend;
        let backend = Arc::new(TestBackend::new());
        let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
            .with_refrigerant("R134a")
            .with_fluid_backend(backend)
            .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95));
        // Same-branch topology: suction (m=0,p=1,h=2), discharge (m=0,p=3,h=4).
        comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);

        // Solved cycle state: ṁ = 0.20 kg/s, h_suc = 410 kJ/kg, h_dis = 440 kJ/kg.
        let mut state = vec![0.0; 5];
        state[0] = 0.20; // ṁ
        state[2] = 410_000.0; // h_suc
        state[4] = 440_000.0; // h_dis

        let (heat, work) = comp
            .energy_transfers(&state)
            .expect("compressor reports energy");
        // Adiabatic: no heat exchange.
        assert!(heat.to_watts().abs() < 1e-9);
        // Work is done ON the compressor, so it is negative and equals −ṁ·Δh.
        let expected = -0.20 * (440_000.0 - 410_000.0);
        assert!(
            (work.to_watts() - expected).abs() < 1e-6,
            "shaft work {} != {}",
            work.to_watts(),
            expected
        );
        assert!(work.to_watts() < 0.0, "compressor consumes work");
    }

    #[test]
    fn test_energy_transfers_off_compressor_is_zero() {
        use crate::state_machine::StateManageable;
        let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0);
        comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
        comp.set_state(OperationalState::Off).unwrap();
        let state = vec![0.20, 0.0, 410_000.0, 0.0, 440_000.0];
        let (heat, work) = comp
            .energy_transfers(&state)
            .expect("off compressor reports zero");
        assert!(heat.to_watts().abs() < 1e-12);
        assert!(work.to_watts().abs() < 1e-12);
    }

    #[test]
    fn test_vsd_map_identity_is_noop() {
        // Identity map must leave both efficiencies unchanged at any speed.
        let map = VsdSpeedMap::identity(50.0);
        assert!((map.volumetric_correction(25.0) - 1.0).abs() < 1e-12);
        assert!((map.isentropic_correction(80.0) - 1.0).abs() < 1e-12);

        let comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0)
            .with_displacement(3.0e-5, 30.0, VolumetricEfficiency::Constant(0.95))
            .with_vsd_map(VsdSpeedMap::identity(50.0));
        assert!((comp.effective_isentropic_efficiency() - 0.75).abs() < 1e-12);
    }

    #[test]
    fn test_vsd_map_corrects_efficiencies_with_speed() {
        // Concave map peaking at the reference speed: f(r) = -0.5 + 3r - 1.5r²,
        // which gives f(1)=1.0 and falls off below/above the rated speed.
        let coeffs = [-0.5, 3.0, -1.5];
        let map = VsdSpeedMap::new(50.0, coeffs, coeffs);
        let at_ref = map.isentropic_correction(50.0);
        let at_low = map.isentropic_correction(25.0);
        let at_high = map.isentropic_correction(75.0);
        assert!((at_ref - 1.0).abs() < 1e-9, "peak at rated speed: {at_ref}");
        assert!(at_low < at_ref, "low-speed penalty: {at_low} !< {at_ref}");
        assert!(
            at_high < at_ref,
            "high-speed penalty: {at_high} !< {at_ref}"
        );

        // Effective isentropic efficiency drops away from the rated speed.
        let make = |speed: f64| {
            IsentropicCompressor::new(0.80, 323.15, 275.15, 5.0)
                .with_displacement(3.0e-5, speed, VolumetricEfficiency::Constant(0.95))
                .with_vsd_map(map)
        };
        let eff_ref = make(50.0).effective_isentropic_efficiency();
        let eff_low = make(25.0).effective_isentropic_efficiency();
        assert!((eff_ref - 0.80).abs() < 1e-9);
        assert!(eff_low < eff_ref);
    }

    #[test]
    fn test_vsd_correction_is_clamped() {
        // Extreme coefficients must stay within the physical band [0.1, 1.2].
        let map = VsdSpeedMap::new(50.0, [100.0, 0.0, 0.0], [-100.0, 0.0, 0.0]);
        assert!((map.volumetric_correction(50.0) - 1.2).abs() < 1e-12);
        assert!((map.isentropic_correction(50.0) - 0.1).abs() < 1e-12);
    }
}
