//! Single simulation execution module.
//!
//! Handles loading a configuration, running a simulation, and outputting results.

use std::path::Path;
use std::sync::Arc;

use serde::{Deserialize, Serialize};
use tracing::info;

use crate::config::ScenarioConfig;
use crate::error::{CliError, CliResult};

#[derive(Debug, Clone, Copy)]
struct InitializationGuesses {
    t_evap_k: f64,
    t_cond_k: f64,
    superheat_k: f64,
}

fn resolve_initialization_guesses(
    config: &ScenarioConfig,
    legacy_t_evap_k: Option<f64>,
    legacy_t_cond_k: Option<f64>,
    legacy_superheat_k: Option<f64>,
) -> InitializationGuesses {
    let explicit = config.initialization.as_ref();
    InitializationGuesses {
        t_evap_k: explicit
            .and_then(|init| init.t_evap_guess_k)
            .or(legacy_t_evap_k)
            .unwrap_or(275.15),
        t_cond_k: explicit
            .and_then(|init| init.t_cond_guess_k)
            .or(legacy_t_cond_k)
            .unwrap_or(318.15),
        superheat_k: explicit
            .and_then(|init| init.superheat_guess_k)
            .or(legacy_superheat_k)
            .unwrap_or(5.0),
    }
}

/// Result of a single simulation run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationResult {
    /// Input configuration name or path.
    pub input: String,
    /// Simulation status.
    pub status: SimulationStatus,
    /// Convergence information.
    pub convergence: Option<ConvergenceInfo>,
    /// Solver iterations.
    pub iterations: Option<usize>,
    /// Final state vector (P, h per edge).
    pub state: Option<Vec<StateEntry>>,
    /// Performance metrics.
    pub performance: Option<PerformanceMetrics>,
    /// Error message if failed.
    pub error: Option<String>,
    /// Optional post-mortem solver diagnostics for failed iterative solves.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub failure_diagnostics: Option<FailureDiagnostics>,
    /// Optional initialization diagnostics explaining state-vector start values.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub initialization_diagnostics: Option<entropyk_solver::InitializationDiagnostics>,
    /// Degrees-of-freedom ledger (equations vs unknowns) after topology finalize.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dof: Option<DofSummary>,
    /// Execution time in milliseconds.
    pub elapsed_ms: u64,
    /// Raw Newton state vector at the end of the solve (length =
    /// `system.full_state_vector_len()`). Populated for both converged and
    /// failed runs when a state is available. Useful for warm-starting another
    /// config (e.g. transfer a `dp_model=quadratic` solution into an `msh`
    /// solve via the `ENTROPYK_INIT_STATE_JSON` environment variable).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub raw_state_vector: Option<Vec<f64>>,
    /// Named solver-computed unknowns (free actuators + calibration factors
    /// like `z_ua`, `opening`, `z_dp`) read out of `raw_state_vector` and
    /// labeled with their stable id, owning component, and `[min, max]`
    /// bounds. Empty for systems with no bounded control variables; skipped
    /// on serialization when empty so older consumers keep working.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub solved_variables: Vec<entropyk::SolvedVariable>,
}

/// Compact DoF summary for CLI / web API consumers.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DofSummary {
    /// Total residual equations.
    pub n_equations: usize,
    /// Total Newton unknowns.
    pub n_unknowns: usize,
    /// `balanced`, `over-constrained`, or `under-constrained`.
    pub balance: String,
    /// Multi-line human summary (component roles, diagnostics).
    pub summary: String,
}

/// Compact failure diagnostics surfaced in CLI JSON.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureDiagnostics {
    /// Final residual norm reported by the solver diagnostics.
    pub final_residual_norm: f64,
    /// Last recorded per-iteration residual norm, when available.
    pub last_residual_norm: Option<f64>,
    /// Equation index with the dominant residual at the last recorded iteration.
    pub dominant_residual_index: Option<usize>,
    /// Dominant residual magnitude at the last recorded iteration.
    pub dominant_residual_value: Option<f64>,
}

/// Performance metrics from simulation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceMetrics {
    /// Cooling capacity in kW.
    pub q_cooling_kw: Option<f64>,
    /// Heating capacity in kW.
    pub q_heating_kw: Option<f64>,
    /// Compressor power in kW.
    pub compressor_power_kw: Option<f64>,
    /// Coefficient of performance.
    pub cop: Option<f64>,
}

/// Simulation status.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SimulationStatus {
    Converged,
    Timeout,
    NonConverged,
    Error,
}

/// One Newton / Picard iteration snapshot for UI / CLI consumers.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IterationInfo {
    /// 0-based iteration index.
    pub iteration: usize,
    /// ℓ₂ residual norm.
    pub residual_norm: f64,
    /// ‖Δx‖ step norm.
    pub delta_norm: f64,
    /// Line-search step length when used.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub alpha: Option<f64>,
    /// Dominant residual equation index.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_residual_index: Option<usize>,
    /// Dominant residual magnitude (ℓ∞).
    #[serde(default)]
    pub max_residual: f64,
}

/// Convergence information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvergenceInfo {
    /// Final residual norm.
    pub final_residual: f64,
    /// Convergence tolerance achieved.
    pub tolerance: f64,
    /// Iteration count (mirrors top-level `iterations` when present).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub iterations: Option<usize>,
    /// Strategy that produced the final state (`newton`, `picard`, …).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub strategy: Option<String>,
    /// Per-iteration residual history (populated when the solver ran verbose).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub iteration_history: Vec<IterationInfo>,
}

/// State entry for one edge (Modelica-style line variables for the UI).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StateEntry {
    /// Edge index.
    pub edge: usize,
    /// Pressure in bar.
    pub pressure_bar: f64,
    /// Enthalpy in kJ/kg.
    pub enthalpy_kj_kg: f64,
    /// Mass flow on this branch [kg/s].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mass_flow_kg_s: Option<f64>,
    /// Upstream component name.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    /// Downstream component name.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target: Option<String>,
    /// Upstream port name (`outlet`, `secondary_outlet`, …).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_port: Option<String>,
    /// Downstream port name (`inlet`, `secondary_inlet`, …).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_port: Option<String>,
    /// Temperature from (P,h) flash [°C], when the fluid backend can evaluate it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub temperature_c: Option<f64>,
    /// Saturation temperature at edge pressure [°C], when available.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub saturation_temperature_c: Option<f64>,
}

/// Run a single simulation from a configuration file.
/// Run a simulation directly from a JSON string.
///
/// Parses the `ScenarioConfig` from `json`, executes the simulation with the
/// full CLI physics engine, and returns the `SimulationResult`.
/// This is the entry point used by the web API server.
pub fn simulate_from_json(json: &str) -> CliResult<SimulationResult> {
    let config = ScenarioConfig::from_json(json)?;
    let result = execute_simulation(&config, "web-api", 0);
    Ok(result)
}

pub fn run_simulation(
    config_path: &Path,
    output_path: Option<&Path>,
    verbose: bool,
) -> CliResult<SimulationResult> {
    let start = std::time::Instant::now();
    let input_name = config_path.display().to_string();

    if verbose {
        info!("Loading configuration from: {}", config_path.display());
    }

    let config = ScenarioConfig::from_file(config_path)?;

    if verbose {
        info!("Scenario: {:?}", config.name);
        info!("Primary fluid: {}", config.fluid);
        info!("Circuits: {}", config.circuits.len());
        info!("Thermal couplings: {}", config.thermal_couplings.len());
        info!("Solver: {}", config.solver.strategy);
    }

    let result = execute_simulation(&config, &input_name, start.elapsed().as_millis() as u64);

    if let Some(ref path) = output_path {
        let json = serde_json::to_string_pretty(&result)
            .map_err(|e| CliError::Simulation(format!("Failed to serialize result: {}", e)))?;
        std::fs::write(path, json)?;
        if verbose {
            info!("Results written to: {}", path.display());
        }
    }

    Ok(result)
}

/// Physical-parameter continuation on a fixed-opening EXV (TLK-Thermo /
/// Modelica 2019 homotopy on a physical parameter).
///
/// When a direct solve fails, the opening is first reduced to `target × 0.5`
/// (an easier operating point: the valve dominates the resistance chain and
/// the momentum block is well-conditioned), then walked back up to `target`
/// with adaptive steps, warm-starting every step from the previous converged
/// solution. This keeps each intermediate solve near a feasible point of the
/// same branch instead of jumping from the cold start across the stiff
/// region created by two-phase pressure drops at high opening.
fn solve_with_opening_continuation<S>(
    system: &mut entropyk_solver::System,
    exv_name: &str,
    target: f64,
    initial_state: Vec<f64>,
    solve_once: &S,
) -> Result<entropyk_solver::solver::ConvergedState, entropyk_solver::solver::SolverError>
where
    S: Fn(
        &mut entropyk_solver::System,
        Vec<f64>,
    )
        -> Result<entropyk_solver::solver::ConvergedState, entropyk_solver::solver::SolverError>,
{
    const MIN_STEP: f64 = 0.001;

    let node = system.get_component_node(exv_name).ok_or_else(|| {
        entropyk_solver::solver::SolverError::InvalidSystem {
            message: format!("EXV '{exv_name}' not found for opening continuation"),
        }
    })?;

    tracing::info!(
        exv = exv_name,
        target,
        "Direct solve failed; starting EXV opening continuation"
    );

    // Baseline: solve at the reduced opening.
    let mut current = target * 0.5;
    if let Some(comp) = system.component_mut(node) {
        comp.set_opening_fraction(current);
    }
    let mut result = solve_once(system, initial_state)?;
    let mut warm = result.state.clone();

    // Adaptive walk back to the target opening.
    let mut step = ((target - current) / 4.0).max(MIN_STEP);
    while current < target - 1e-9 {
        let next = (current + step).min(target);
        if let Some(comp) = system.component_mut(node) {
            comp.set_opening_fraction(next);
        }
        match solve_once(system, warm.clone()) {
            Ok(converged) => {
                tracing::info!(
                    opening = next,
                    final_residual = converged.final_residual,
                    "Opening continuation step converged"
                );
                current = next;
                warm = converged.state.clone();
                result = converged;
                step = (step * 1.2).min(0.1);
            }
            Err(err) => {
                step *= 0.5;
                // Restore the last good opening before retrying smaller.
                if let Some(comp) = system.component_mut(node) {
                    comp.set_opening_fraction(current);
                }
                if step < MIN_STEP {
                    tracing::warn!(
                        opening = current,
                        target,
                        "Opening continuation could not reach the target"
                    );
                    return Err(err);
                }
            }
        }
    }
    Ok(result)
}

/// Execute the simulation with the given configuration.
fn execute_simulation(
    config: &ScenarioConfig,
    input_name: &str,
    elapsed_ms: u64,
) -> SimulationResult {
    use entropyk::{
        ConvergenceStatus, Enthalpy, FallbackConfig, FallbackSolver, FluidId, InitializerConfig,
        NewtonConfig, PicardConfig, SmartInitializer, Solver, SolverStrategy, System, Temperature,
        ThermalConductance,
    };
    use entropyk_fluids::TestBackend;
    use entropyk_solver::{solver::VerboseConfig, CircuitId, ThermalCoupling};
    use std::collections::HashMap;

    let fluid_id = FluidId::new(&config.fluid);

    // Circuit staging validation: at least one circuit must be energized.
    if !config.circuits.is_empty() && !config.circuits.iter().any(|c| c.enabled) {
        return SimulationResult {
            input: input_name.to_string(),
            status: SimulationStatus::Error,
            convergence: None,
            iterations: None,
            state: None,
            performance: None,
            error: Some(
                "All circuits are disabled (enabled=false). At least one circuit \
                 must be energized to run a simulation."
                    .to_string(),
            ),
            failure_diagnostics: None,
            initialization_diagnostics: None,
            dof: None,
            elapsed_ms,
            raw_state_vector: None,
            solved_variables: Vec::new(),
        };
    }

    let backend: Arc<dyn entropyk_fluids::FluidBackend> = match config.fluid_backend.as_deref() {
        Some("CoolProp") => {
            let inner = entropyk_fluids::CoolPropBackend::new();
            // Opt-in property cache (Story 0.7): finite-only values and
            // overflow-safe keys; bit-identical results, off by default.
            if std::env::var("ENTROPYK_CACHE")
                .map(|v| v == "1")
                .unwrap_or(false)
            {
                Arc::new(entropyk_fluids::CachedBackend::new(inner))
            } else {
                Arc::new(inner)
            }
        }
        Some("Test") | None => Arc::new(TestBackend::new()),
        Some(other) => {
            return SimulationResult {
                input: input_name.to_string(),
                status: SimulationStatus::Error,
                convergence: None,
                iterations: None,
                state: None,
                performance: None,
                error: Some(format!(
                    "Unknown fluid backend: '{}'. Supported: 'CoolProp', 'Test'",
                    other
                )),
                failure_diagnostics: None,
                initialization_diagnostics: None,
                dof: None,
                elapsed_ms,
                raw_state_vector: None,
                solved_variables: Vec::new(),
            };
        }
    };

    // Fail fast: Z-factors belong in embeddings[], never controls[].
    for control in &config.controls {
        let factor = control.actuator.factor.trim();
        if entropyk_core::normalize_factor_name(factor).is_some() {
            return SimulationResult {
                input: input_name.to_string(),
                status: SimulationStatus::Error,
                convergence: None,
                iterations: None,
                state: None,
                performance: None,
                error: Some(format!(
                    "control '{}': factor '{}' is a Z-factor — use embeddings[] \
                     (Modelica parameter/equation), not controls[]/SaturatedController. \
                     controls[] is for physical regulation (opening, injection, …).",
                    control.id, factor
                )),
                failure_diagnostics: None,
                initialization_diagnostics: None,
                dof: None,
                elapsed_ms,
                raw_state_vector: None,
                solved_variables: Vec::new(),
            };
        }
    }

    let mut system = System::new();

    // Track component name -> (node index, component type) mapping per circuit
    // The component type is needed for port-name-to-index resolution (Task 3.3)
    let mut component_indices: HashMap<String, (petgraph::graph::NodeIndex, String)> =
        HashMap::new();

    // Collect variables and constraints to add *after* components are added
    struct PendingControl {
        component_node: petgraph::graph::NodeIndex,
        control_type: String,
        min: f64,
        max: f64,
        initial: f64,
    }
    let mut pending_controls = Vec::new();

    // Pre-scan: compute T_cond for each circuit containing a FinCoilCondenser or AirCooledCondenser.
    // This allows auto-injecting T_cond into IsentropicCompressor without the user having to specify it.
    let mut circuit_auto_t_cond: HashMap<usize, f64> = HashMap::new();
    for circuit_config in &config.circuits {
        if !circuit_config.enabled {
            continue;
        }
        for comp in &circuit_config.components {
            let params = &comp.params;
            match comp.component_type.as_str() {
                "FinCoilCondenser" => {
                    use entropyk::{CoilGeometry, FinCoilCondenser, FinType};
                    let oat_k = params
                        .get("oat_k")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(308.15);
                    let face_width_m = params
                        .get("face_width_m")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(1.5);
                    let face_height_m = params
                        .get("face_height_m")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(1.0);
                    let n_rows = params.get("n_rows").and_then(|v| v.as_u64()).unwrap_or(3) as u32;
                    let fin_type = FinType::from_str(
                        params
                            .get("fin_type")
                            .and_then(|v| v.as_str())
                            .unwrap_or("louvered"),
                    );
                    let fin_pitch_fpi = params
                        .get("fin_pitch_fpi")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(14.0);
                    let tube_od_mm = params
                        .get("tube_od_mm")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(9.525);
                    let tube_pitch_mm = params
                        .get("tube_pitch_mm")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(25.4);
                    let louver_pitch_mm = params
                        .get("louver_pitch_mm")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(1.4);
                    let fin_thickness_mm = params
                        .get("fin_thickness_mm")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(0.1);
                    let air_face_velocity_m_s = params
                        .get("air_face_velocity_m_s")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(2.5);
                    let design_capacity_kw = params
                        .get("design_capacity_kw")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(100.0);
                    let geometry = CoilGeometry {
                        face_width_m,
                        face_height_m,
                        n_rows,
                        fin_type,
                        fin_pitch_fpi,
                        tube_od_m: tube_od_mm / 1000.0,
                        tube_pitch_transverse_m: tube_pitch_mm / 1000.0,
                        tube_pitch_longitudinal_m: tube_pitch_mm * 0.866 / 1000.0,
                        louver_pitch_m: louver_pitch_mm / 1000.0,
                        fin_thickness_m: fin_thickness_mm / 1000.0,
                        ..Default::default()
                    };
                    let probe = FinCoilCondenser::new(
                        oat_k,
                        geometry,
                        air_face_velocity_m_s,
                        design_capacity_kw * 1000.0,
                    );
                    circuit_auto_t_cond.insert(circuit_config.id, probe.t_cond_k());
                }
                "AirCooledCondenser" => {
                    let oat_k = params
                        .get("oat_k")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(308.15);
                    let approach_k = params
                        .get("approach_k")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(12.0);
                    circuit_auto_t_cond.insert(circuit_config.id, oat_k + approach_k);
                }
                _ => {}
            }
        }
    }

    // Pre-scan: collect legacy initialization guesses from compressor / EXV for
    // compatibility. Explicit top-level `initialization` wins later.
    let mut legacy_t_evap_k: Option<f64> = None;
    let mut legacy_t_cond_k: Option<f64> = None;
    let mut legacy_superheat_k: Option<f64> = None;
    for circuit_config in &config.circuits {
        let auto_t_cond = circuit_auto_t_cond.get(&circuit_config.id).copied();
        if !circuit_config.enabled {
            continue;
        }
        for comp in &circuit_config.components {
            let params = &comp.params;
            match comp.component_type.as_str() {
                "IsentropicCompressor" => {
                    if legacy_t_evap_k.is_none() {
                        legacy_t_evap_k = params.get("t_evap_k").and_then(|v| v.as_f64());
                    }
                    if legacy_t_cond_k.is_none() {
                        legacy_t_cond_k = params
                            .get("t_cond_k")
                            .and_then(|v| v.as_f64())
                            .or(auto_t_cond);
                    }
                    if legacy_superheat_k.is_none() {
                        legacy_superheat_k = params.get("superheat_k").and_then(|v| v.as_f64());
                    }
                }
                "IsenthalpicExpansionValve" | "EXV" => {
                    if legacy_t_evap_k.is_none() {
                        legacy_t_evap_k = params.get("t_evap_k").and_then(|v| v.as_f64());
                    }
                }
                _ => {}
            }
        }
    }
    let initialization_guesses = resolve_initialization_guesses(
        config,
        legacy_t_evap_k,
        legacy_t_cond_k.or_else(|| circuit_auto_t_cond.values().next().copied()),
        legacy_superheat_k,
    );

    // Physical-parameter continuation support: first fixed-opening EXV
    // (name, target opening), captured during component expansion below so a
    // failed solve can be retried by walking the opening up from a reduced value.
    let mut fixed_exv_opening: Option<(String, f64)> = None;

    for circuit_config in &config.circuits {
        let circuit_id = CircuitId(circuit_config.id as u16);
        let auto_t_cond_k = circuit_auto_t_cond.get(&circuit_config.id).copied();

        // Circuit staging: a disabled circuit is OFF (zero mass flow / duty). Skip
        // it entirely so no component, edge or state is added to the solver.
        if !circuit_config.enabled {
            info!(
                "Circuit {} staged OFF (disabled) — skipped",
                circuit_config.id
            );
            continue;
        }

        // Pre-process components to expand banks
        let mut expanded_components = Vec::new();
        for component_config in &circuit_config.components {
            if let Some(bank_config) = &component_config.condenser_bank {
                // Expand MCHX condenser bank into multiple coils
                for c in 0..bank_config.circuits {
                    for i in 0..bank_config.coils_per_circuit {
                        let mut expanded = component_config.clone();
                        // Clear the bank config to avoid infinite recursion logically
                        expanded.condenser_bank = None;

                        // Set the specific coil index
                        let coil_index = c * bank_config.coils_per_circuit + i;
                        expanded.params.insert(
                            "coil_index".to_string(),
                            serde_json::Value::Number(coil_index.into()),
                        );

                        // Modify the name (e.g., mchx_0a, mchx_0b for circuit 0, coils a, b)
                        let letter = (b'a' + (i as u8)) as char;
                        expanded.name = format!("{}_{}{}", component_config.name, c, letter);

                        expanded_components.push(expanded);
                    }
                }
            } else {
                expanded_components.push(component_config.clone());
            }
        }

        // Free z_ua on BPHX only: drop absolute `ua` override (geometry = UA_nominal).
        // Legacy Condenser/Evaporator keep `ua` — it is the constructor nominal.
        let mut free_z_ua: Vec<(String, String)> = config
            .embeddings
            .iter()
            .filter(|e| {
                entropyk_core::normalize_factor_name(e.unknown.factor.trim())
                    == Some(entropyk_core::Z_UA)
            })
            .map(|e| (e.unknown.component.clone(), e.id.clone()))
            .collect();
        for eq in &config.equations {
            if let Some(u) = eq.unknown.as_ref() {
                if entropyk_core::normalize_factor_name(u.factor.trim()) == Some(entropyk_core::Z_UA)
                {
                    free_z_ua.push((u.component.clone(), eq.id.clone()));
                }
            } else if let Ok(entropyk_solver::inverse::Expr::Ref { component, field }) =
                entropyk_solver::inverse::parse_expr(&eq.lhs)
            {
                if entropyk_core::normalize_factor_name(&field) == Some(entropyk_core::Z_UA) {
                    free_z_ua.push((component, eq.id.clone()));
                }
            }
        }
        for (comp_name, src_id) in &free_z_ua {
            if let Some(comp) = expanded_components
                .iter_mut()
                .find(|c| c.name == *comp_name)
            {
                let is_bphx = matches!(
                    comp.component_type.as_str(),
                    "BphxCondenser" | "BphxEvaporator" | "BphxExchanger"
                );
                if is_bphx && comp.params.remove("ua").is_some() {
                    tracing::info!(
                        component = %comp.name,
                        source = %src_id,
                        "Dropped BPHX 'ua' override — free z_ua owns UA scaling"
                    );
                }
            }
        }

        // Fixed EXV orifice opening sets ṁ via the valve — skip compressor
        // displacement ṁ closure so DoF stays square (ṁ follows the valve).
        let meter_mass_flow_via_exv = expanded_components.iter().any(|c| {
            exv_uses_fixed_orifice(&c.params)
                && matches!(
                    c.component_type.as_str(),
                    "IsenthalpicExpansionValve" | "EXV"
                )
        });

        // Capture the first fixed-opening EXV for opening continuation.
        if fixed_exv_opening.is_none() {
            fixed_exv_opening = expanded_components
                .iter()
                .find(|c| {
                    exv_uses_fixed_orifice(&c.params)
                        && matches!(
                            c.component_type.as_str(),
                            "IsenthalpicExpansionValve" | "EXV"
                        )
                })
                .and_then(|c| {
                    c.params
                        .get("opening")
                        .and_then(|v| v.as_f64())
                        .map(|o| (c.name.clone(), o))
                });
        }

        for component_config in &expanded_components {
            match create_component(
                component_config,
                &fluid_id,
                Arc::clone(&backend),
                auto_t_cond_k,
                meter_mass_flow_via_exv,
            ) {
                Ok(component) => match system.add_component_to_circuit(component, circuit_id) {
                    Ok(node_id) => {
                        component_indices.insert(
                            component_config.name.clone(),
                            (node_id, component_config.component_type.clone()),
                        );

                        // Check if this component needs explicit fan control
                        if let Some(fan_control) = component_config
                            .params
                            .get("fan_control")
                            .and_then(|v| v.as_str())
                        {
                            if fan_control == "bounded" {
                                let min = component_config
                                    .params
                                    .get("fan_speed_min")
                                    .and_then(|v| v.as_f64())
                                    .unwrap_or(0.1);
                                let max = component_config
                                    .params
                                    .get("fan_speed_max")
                                    .and_then(|v| v.as_f64())
                                    .unwrap_or(1.0);
                                let initial = component_config
                                    .fan_speed
                                    .or_else(|| {
                                        component_config
                                            .params
                                            .get("fan_speed")
                                            .and_then(|v| v.as_f64())
                                    })
                                    .unwrap_or(1.0);

                                pending_controls.push(PendingControl {
                                    component_node: node_id,
                                    control_type: "fan_speed".to_string(),
                                    min,
                                    max,
                                    initial,
                                });
                            }
                        }

                        // Register component name for constraint validation
                        system.register_component_name(&component_config.name, node_id);
                    }
                    Err(e) => {
                        return SimulationResult {
                            input: input_name.to_string(),
                            status: SimulationStatus::Error,
                            convergence: None,
                            iterations: None,
                            state: None,
                            performance: None,
                            error: Some(format!(
                                "Failed to add component '{}': {:?}",
                                component_config.name, e
                            )),
                            failure_diagnostics: None,
                            initialization_diagnostics: None,
                            dof: None,
                            elapsed_ms,
                            raw_state_vector: None,
                            solved_variables: Vec::new(),
                        };
                    }
                },
                Err(e) => {
                    return SimulationResult {
                        input: input_name.to_string(),
                        status: SimulationStatus::Error,
                        convergence: None,
                        iterations: None,
                        state: None,
                        performance: None,
                        error: Some(format!(
                            "Failed to create component '{}': {}",
                            component_config.name, e
                        )),
                        failure_diagnostics: None,
                        initialization_diagnostics: None,
                        dof: None,
                        elapsed_ms,
                        raw_state_vector: None,
                        solved_variables: Vec::new(),
                    };
                }
            }
        }
    }

    // Add edges between components (Task 3.3: port-aware edge routing)
    // Port specifications (e.g., "screw_0:economizer") are resolved to port indices.
    // For components with ports, add_edge_with_ports() is used to allow multi-port routing.
    // Unknown or omitted multi-port names are hard errors; never guess physical topology.
    for circuit_config in &config.circuits {
        if !circuit_config.enabled {
            continue;
        }
        for edge in &circuit_config.edges {
            let from_parts: Vec<&str> = edge.from.split(':').collect();
            let to_parts: Vec<&str> = edge.to.split(':').collect();

            let from_name = from_parts.first().copied().unwrap_or("");
            let to_name = to_parts.first().copied().unwrap_or("");
            let from_port_name = from_parts.get(1).copied();
            let to_port_name = to_parts.get(1).copied();

            let from_entry = component_indices.get(from_name);
            let to_entry = component_indices.get(to_name);

            match (from_entry, to_entry) {
                (Some((from_node, from_type)), Some((to_node, to_type))) => {
                    let from_port_idx = match from_port_name {
                        Some(p) => match resolve_port_index(from_type, p, true) {
                            Ok(idx) => idx,
                            Err(msg) => {
                                return SimulationResult {
                                    input: input_name.to_string(),
                                    status: SimulationStatus::Error,
                                    convergence: None,
                                    iterations: None,
                                    state: None,
                                    performance: None,
                                    error: Some(msg),
                                    failure_diagnostics: None,
                                    initialization_diagnostics: None,
                                    dof: None,
                                    elapsed_ms,
                                    raw_state_vector: None,
                                    solved_variables: Vec::new(),
                                };
                            }
                        },
                        None if requires_explicit_port(from_type) => {
                            return SimulationResult {
                                input: input_name.to_string(),
                                status: SimulationStatus::Error,
                                convergence: None,
                                iterations: None,
                                state: None,
                                performance: None,
                                error: Some(format!(
                                    "Component '{}' of type '{}' requires an explicit source port in edge '{}'",
                                    from_name, from_type, edge.from
                                )),
                                failure_diagnostics: None,
                                initialization_diagnostics: None,
                                dof: None,
                                elapsed_ms,
                raw_state_vector: None, solved_variables: Vec::new(),
            };
                        }
                        None => 1,
                    };
                    let to_port_idx = match to_port_name {
                        Some(p) => match resolve_port_index(to_type, p, false) {
                            Ok(idx) => idx,
                            Err(msg) => {
                                return SimulationResult {
                                    input: input_name.to_string(),
                                    status: SimulationStatus::Error,
                                    convergence: None,
                                    iterations: None,
                                    state: None,
                                    performance: None,
                                    error: Some(msg),
                                    failure_diagnostics: None,
                                    initialization_diagnostics: None,
                                    dof: None,
                                    elapsed_ms,
                                    raw_state_vector: None,
                                    solved_variables: Vec::new(),
                                };
                            }
                        },
                        None if requires_explicit_port(to_type) => {
                            return SimulationResult {
                                input: input_name.to_string(),
                                status: SimulationStatus::Error,
                                convergence: None,
                                iterations: None,
                                state: None,
                                performance: None,
                                error: Some(format!(
                                    "Component '{}' of type '{}' requires an explicit target port in edge '{}'",
                                    to_name, to_type, edge.to
                                )),
                                failure_diagnostics: None,
                                initialization_diagnostics: None,
                                dof: None,
                                elapsed_ms,
                raw_state_vector: None, solved_variables: Vec::new(),
            };
                        }
                        None => 0,
                    };

                    if let Err(e) =
                        system.add_edge_with_ports(*from_node, from_port_idx, *to_node, to_port_idx)
                    {
                        return SimulationResult {
                            input: input_name.to_string(),
                            status: SimulationStatus::Error,
                            convergence: None,
                            iterations: None,
                            state: None,
                            performance: None,
                            error: Some(format!(
                                "Failed to add edge '{} -> {}': {:?}",
                                edge.from, edge.to, e
                            )),
                            failure_diagnostics: None,
                            initialization_diagnostics: None,
                            dof: None,
                            elapsed_ms,
                            raw_state_vector: None,
                            solved_variables: Vec::new(),
                        };
                    }
                }
                _ => {
                    return SimulationResult {
                        input: input_name.to_string(),
                        status: SimulationStatus::Error,
                        convergence: None,
                        iterations: None,
                        state: None,
                        performance: None,
                        error: Some(format!(
                            "Edge references unknown component: '{}' or '{}'",
                            from_name, to_name
                        )),
                        failure_diagnostics: None,
                        initialization_diagnostics: None,
                        dof: None,
                        elapsed_ms,
                        raw_state_vector: None,
                        solved_variables: Vec::new(),
                    };
                }
            }
        }
    }

    {
        // A coupling is *physical* only when it names both interface components
        // (hot duty provider + cold `ThermalLoad` receiver): the measured hot
        // duty is then injected into the cold circuit's energy balance. Without
        // them the coupling is a legacy no-op (Q pinned to 0) and the coupled
        // circuit stays inert.
        let stub_count = config
            .thermal_couplings
            .iter()
            .filter(|c| c.hot_component.is_none() || c.cold_component.is_none())
            .count();
        if stub_count > 0 {
            tracing::warn!(
                couplings = stub_count,
                "`thermal_couplings` without `hot_component`/`cold_component` are inert \
                 (Q pinned to 0, no heat injected). Set both fields (cold side must be a \
                 `ThermalLoad`) for a physical coupling, or use a two-sided \
                 HeatExchanger/BPHX or a secondary stream instead."
            );
        }
    }

    for coupling_config in &config.thermal_couplings {
        // Skip couplings that reference a staged-off (disabled) circuit — the
        // circuit's nodes were never added, so the coupling would be dangling.
        let refs_disabled = config.circuits.iter().any(|c| {
            !c.enabled
                && (c.id == coupling_config.hot_circuit || c.id == coupling_config.cold_circuit)
        });
        if refs_disabled {
            info!(
                "Thermal coupling {} <-> {} skipped (references a disabled circuit)",
                coupling_config.hot_circuit, coupling_config.cold_circuit
            );
            continue;
        }
        let mut coupling = ThermalCoupling::new(
            CircuitId(coupling_config.hot_circuit as u16),
            CircuitId(coupling_config.cold_circuit as u16),
            ThermalConductance::from_watts_per_kelvin(coupling_config.ua),
        )
        .with_efficiency(coupling_config.efficiency)
        .with_duty_scale(coupling_config.duty_scale);
        if let (Some(hot), Some(cold)) = (
            &coupling_config.hot_component,
            &coupling_config.cold_component,
        ) {
            coupling = coupling.with_interface_components(hot, cold);
        }

        if let Err(e) = system.add_thermal_coupling(coupling) {
            return SimulationResult {
                input: input_name.to_string(),
                status: SimulationStatus::Error,
                convergence: None,
                iterations: None,
                state: None,
                performance: None,
                error: Some(format!("Failed to add thermal coupling: {:?}", e)),
                failure_diagnostics: None,
                initialization_diagnostics: None,
                dof: None,
                elapsed_ms,
                raw_state_vector: None,
                solved_variables: Vec::new(),
            };
        }
    }

    // Validate p0b superheat-control wiring before registering loops so a
    // misconfiguration fails fast with a clear message instead of a silent DoF
    // break:
    //   (a) an `opening` control loop must target an EXV that carries `orifice_kv`
    //       (otherwise the actuator drives no equation → structurally singular);
    //   (b) an evaporator flagged `superheat_regulated` must have a matching
    //       superheat→opening control loop (otherwise dropping its outlet closure
    //       leaves the system under-determined).
    {
        let control_error = |msg: String| SimulationResult {
            input: input_name.to_string(),
            status: SimulationStatus::Error,
            convergence: None,
            iterations: None,
            state: None,
            performance: None,
            error: Some(msg),
            failure_diagnostics: None,
            initialization_diagnostics: None,
            dof: None,
            elapsed_ms,
            raw_state_vector: None,
            solved_variables: Vec::new(),
        };
        let find_component = |name: &str| {
            config
                .circuits
                .iter()
                .filter(|c| c.enabled)
                .flat_map(|c| c.components.iter())
                .find(|comp| comp.name == name)
        };
        for control in &config.controls {
            if control.actuator.factor.trim() == "opening" {
                let ok = matches!(
                    find_component(&control.actuator.component),
                    Some(comp)
                        if matches!(
                            comp.component_type.as_str(),
                            "IsenthalpicExpansionValve" | "EXV"
                        ) && comp.params.get("orifice_kv").is_some()
                );
                if !ok {
                    return control_error(format!(
                        "control '{}': actuator factor 'opening' requires an expansion valve \
                         named '{}' with 'orifice_kv' set (the opening drives the orifice residual)",
                        control.id, control.actuator.component
                    ));
                }
            }
        }
        for circuit in config.circuits.iter().filter(|c| c.enabled) {
            for comp in &circuit.components {
                let regulated = matches!(
                    comp.component_type.as_str(),
                    "Evaporator" | "EvaporatorCoil"
                ) && comp
                    .params
                    .get("superheat_regulated")
                    .and_then(|v| v.as_bool())
                    .unwrap_or(false);
                if regulated
                    && !config.controls.iter().any(|ctrl| {
                        ctrl.actuator.factor.trim() == "opening"
                            && ctrl.measure.output.eq_ignore_ascii_case("superheat")
                            && ctrl.measure.component == comp.name
                    })
                {
                    return control_error(format!(
                        "evaporator '{}' sets 'superheat_regulated' but no superheat→opening \
                         control loop measures it (the dropped outlet closure would leave the \
                         system under-determined)",
                        comp.name
                    ));
                }
            }
        }
    }

    // Register model embeddings (Z-factor unknowns + equations) and system
    // regulation controls. Must happen BEFORE finalize() so unknowns wire to
    // CalibIndices.
    //
    // - embeddings[] → plain Constraint + BoundedVariable (Modelica fixed=false)
    // - controls[]   → SaturatedController only (EXV/injection/…); z-factors forbidden
    let control_error = |msg: String| SimulationResult {
        input: input_name.to_string(),
        status: SimulationStatus::Error,
        convergence: None,
        iterations: None,
        state: None,
        performance: None,
        error: Some(msg),
        failure_diagnostics: None,
        initialization_diagnostics: None,
        dof: None,
        elapsed_ms,
        raw_state_vector: None,
        solved_variables: Vec::new(),
    };

    for emb in &config.embeddings {
        match build_model_embedding(emb) {
            Ok((constraint, bounded_var, unknown_id)) => {
                if let Err(e) = system.add_constraint(constraint) {
                    return control_error(format!(
                        "Failed to add equation for embedding '{}': {:?}",
                        emb.id, e
                    ));
                }
                if let Err(e) = system.add_bounded_variable(bounded_var) {
                    return control_error(format!(
                        "Failed to add unknown for embedding '{}': {:?}",
                        emb.id, e
                    ));
                }
                if let Err(e) = system.link_constraint_to_control(
                    &entropyk_solver::inverse::ConstraintId::new(emb.id.clone()),
                    &unknown_id,
                ) {
                    return control_error(format!(
                        "Failed to link embedding '{}': {:?}",
                        emb.id, e
                    ));
                }
            }
            Err(msg) => {
                return control_error(format!("Invalid embedding '{}': {}", emb.id, msg));
            }
        }
    }

    for eq in &config.equations {
        match build_model_equation(eq) {
            Ok((algebraic, bounded_var, unknown_id)) => {
                if let Err(e) = system.add_algebraic_equation(algebraic) {
                    return control_error(format!(
                        "Failed to add algebraic equation '{}': {:?}",
                        eq.id, e
                    ));
                }
                if let Err(e) = system.add_bounded_variable(bounded_var) {
                    return control_error(format!(
                        "Failed to add unknown for equation '{}': {:?}",
                        eq.id, e
                    ));
                }
                if let Err(e) = system.link_constraint_to_control(
                    &entropyk_solver::inverse::ConstraintId::new(eq.id.clone()),
                    &unknown_id,
                ) {
                    return control_error(format!("Failed to link equation '{}': {:?}", eq.id, e));
                }
            }
            Err(msg) => {
                return control_error(format!("Invalid equation '{}': {}", eq.id, msg));
            }
        }
    }

    for control in &config.controls {
        match build_saturated_control(control) {
            Ok((bounded_var, controller)) => {
                if let Err(e) = system.add_bounded_variable(bounded_var) {
                    return control_error(format!(
                        "Failed to add actuator for control '{}': {:?}",
                        control.id, e
                    ));
                }
                system.add_saturated_controller(controller);
            }
            Err(msg) => {
                return control_error(format!("Invalid control '{}': {}", control.id, msg));
            }
        }
    }

    // EXVs whose opening is already driven by a controls[] loop (factor
    // "opening"): the SaturatedController owns that opening as its actuator, so it
    // must NOT also be registered here as a free actuator (that would double-count
    // the degree of freedom).
    let opening_controlled_exvs: std::collections::HashSet<String> = config
        .controls
        .iter()
        .filter(|c| c.actuator.factor.trim() == "opening")
        .map(|c| c.actuator.component.clone())
        .collect();

    // arch-6: register physical free-actuator unknowns (EXV orifice openings).
    // Must happen BEFORE finalize() so the opening is wired to the component's
    // CalibIndices::actuator slot. Each contributes +1 unknown, balanced by the
    // extra orifice equation the EXV emits.
    let mut free_actuator_inits: Vec<f64> = Vec::new();
    {
        use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
        for circuit_config in &config.circuits {
            if !circuit_config.enabled {
                continue;
            }
            for component_config in &circuit_config.components {
                // Detect the free actuator this component declares, if any:
                //   EXV physical orifice  → "{name}__opening"  (orifice_kv set)
                //   Condenser fan head-P  → "{name}__fan_speed" (fan target set)
                let actuator: Option<(String, f64, f64, f64)> = match component_config
                    .component_type
                    .as_str()
                {
                    "IsenthalpicExpansionValve" | "EXV"
                        if exv_orifice_opening_is_free(&component_config.params)
                            && !opening_controlled_exvs.contains(&component_config.name) =>
                    {
                        let init = component_config
                            .params
                            .get("orifice_opening_init")
                            .or_else(|| component_config.params.get("opening"))
                            .and_then(|v| v.as_f64())
                            .unwrap_or(0.5);
                        let min = component_config
                            .params
                            .get("orifice_opening_min")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(0.02);
                        let max = component_config
                            .params
                            .get("orifice_opening_max")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(1.0);
                        Some((
                            format!("{}__opening", component_config.name),
                            init,
                            min,
                            max,
                        ))
                    }
                    "Condenser" | "CondenserCoil"
                        if component_config
                            .params
                            .get("fan_head_pressure_target_c")
                            .or_else(|| component_config.params.get("fan_head_pressure_target_k"))
                            .is_some() =>
                    {
                        let init = component_config
                            .params
                            .get("fan_speed_init")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(1.0);
                        let min = component_config
                            .params
                            .get("fan_speed_min")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(0.2);
                        let max = component_config
                            .params
                            .get("fan_speed_max")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(1.0);
                        Some((
                            format!("{}__fan_speed", component_config.name),
                            init,
                            min,
                            max,
                        ))
                    }
                    "Condenser" | "CondenserCoil"
                        if component_config
                            .params
                            .get("flooded_head_pressure_target_c")
                            .or_else(|| {
                                component_config
                                    .params
                                    .get("flooded_head_pressure_target_k")
                            })
                            .is_some() =>
                    {
                        let init = component_config
                            .params
                            .get("flood_level_init")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(0.0);
                        let min = component_config
                            .params
                            .get("flood_level_min")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(0.0);
                        let max = component_config
                            .params
                            .get("flood_level_max")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(0.95);
                        Some((format!("{}__level", component_config.name), init, min, max))
                    }
                    "IsentropicCompressor" | "Compressor"
                        if component_config
                            .params
                            .get("slide_valve_sst_target_c")
                            .or_else(|| component_config.params.get("slide_valve_sst_target_k"))
                            .is_some() =>
                    {
                        let init = component_config
                            .params
                            .get("slide_position_init")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(1.0);
                        let min = component_config
                            .params
                            .get("slide_position_min")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(0.1);
                        let max = component_config
                            .params
                            .get("slide_position_max")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(1.0);
                        Some((format!("{}__slide", component_config.name), init, min, max))
                    }
                    _ => None,
                };
                let Some((var_name, init, min, max)) = actuator else {
                    continue;
                };
                let var_id = BoundedVariableId::new(var_name);
                match BoundedVariable::with_component(
                    var_id.clone(),
                    &component_config.name,
                    init,
                    min,
                    max,
                ) {
                    Ok(var) => {
                        if let Err(e) = system.add_bounded_variable(var) {
                            return SimulationResult {
                                input: input_name.to_string(),
                                status: SimulationStatus::Error,
                                convergence: None,
                                iterations: None,
                                state: None,
                                performance: None,
                                error: Some(format!(
                                    "Failed to add free actuator for '{}': {:?}",
                                    component_config.name, e
                                )),
                                failure_diagnostics: None,
                                initialization_diagnostics: None,
                                dof: None,
                                elapsed_ms,
                                raw_state_vector: None,
                                solved_variables: Vec::new(),
                            };
                        }
                        system.add_free_actuator(var_id);
                        free_actuator_inits.push(init);
                    }
                    Err(e) => {
                        return SimulationResult {
                            input: input_name.to_string(),
                            status: SimulationStatus::Error,
                            convergence: None,
                            iterations: None,
                            state: None,
                            performance: None,
                            error: Some(format!(
                                "Invalid free actuator for '{}': {:?}",
                                component_config.name, e
                            )),
                            failure_diagnostics: None,
                            initialization_diagnostics: None,
                            dof: None,
                            elapsed_ms,
                            raw_state_vector: None,
                            solved_variables: Vec::new(),
                        };
                    }
                }
            }
        }
    }

    if let Err(e) = system.finalize() {
        return SimulationResult {
            input: input_name.to_string(),
            status: SimulationStatus::Error,
            convergence: None,
            iterations: None,
            state: None,
            performance: None,
            error: Some(format!("System finalization failed: {:?}", e)),
            failure_diagnostics: None,
            initialization_diagnostics: None,
            dof: None,
            elapsed_ms,
            raw_state_vector: None,
            solved_variables: Vec::new(),
        };
    }

    // Hard square-system gate for real-machine simulation: n_equations must equal
    // n_unknowns. Fixing a quantity without freeing another is rejected here.
    let dof_summary = {
        let report = system.dof_report();
        tracing::info!(target: "entropyk_cli::dof", "{}", report.summary());
        let summary = DofSummary {
            n_equations: report.n_equations,
            n_unknowns: report.n_unknowns,
            balance: report.balance.to_string(),
            summary: report.summary(),
        };
        if let Err(e) = system.validate_system_dof() {
            return SimulationResult {
                input: input_name.to_string(),
                status: SimulationStatus::Error,
                convergence: None,
                iterations: None,
                state: None,
                performance: None,
                error: Some(format!(
                    "DoF imbalance (equations vs unknowns). Fix a quantity only if you free another \
                     (actuator, emergent pressure, free boundary) or drop a residual.\n{e}"
                )),
                failure_diagnostics: None,
                initialization_diagnostics: None,
                dof: Some(summary),
                elapsed_ms,
                raw_state_vector: None, solved_variables: Vec::new(),
            };
        }
        summary
    };

    // Add variables and constraints
    for control in pending_controls {
        if control.control_type == "fan_speed" {
            use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};

            // Generate unique IDs
            let var_id =
                BoundedVariableId::new(format!("fan_speed_var_{}", control.component_node.index()));

            // Find the component's generated name to use in BoundedVariable
            let mut comp_name = String::new();
            for (name, (node, _)) in &component_indices {
                if *node == control.component_node {
                    comp_name = name.clone();
                    break;
                }
            }

            let var = BoundedVariable::with_component(
                var_id,
                &comp_name,
                control.initial,
                control.min,
                control.max,
            );

            if let Ok(var) = var {
                if let Err(e) = system.add_bounded_variable(var) {
                    tracing::warn!("Failed to add fan_speed variable: {:?}", e);
                }
            }
        }
    }

    // Build a physically-reasonable initial state using SmartInitializer.
    // Without this, the solver starts from all-zeros (P=0) which causes CoolProp to fail.
    let mut initialization_diagnostics = entropyk_solver::InitializationDiagnostics::default();
    let initial_state = {
        let t_evap = initialization_guesses.t_evap_k;
        let t_cond = initialization_guesses.t_cond_k;
        let init_config = InitializerConfig {
            fluid: fluid_id.clone(),
            dt_approach: 0.0,
        };
        let initializer = SmartInitializer::new(init_config);
        let n_state = system.full_state_vector_len();
        let mut state = vec![0.0_f64; n_state];
        if let Ok((p_evap, p_cond)) = initializer.estimate_pressures(
            Temperature::from_kelvin(t_evap),
            Temperature::from_kelvin(t_cond),
        ) {
            let h_default = Enthalpy::from_joules_per_kg(600_000.0);
            let _ = initializer.populate_state(&system, p_evap, p_cond, h_default, &mut state);

            // Emergent-pressure cycles need a physically-staged seed (distinct high/
            // low-side pressures and enthalpies); the uniform seed above would let
            // Newton converge to a degenerate fixed point. Detect the mode and, when
            // present, override with a topology-walked staged seed.
            let emergent = config.circuits.iter().any(|c| {
                c.components.iter().any(|comp| {
                    comp.params
                        .get("emergent_pressure")
                        .and_then(|v| v.as_bool())
                        .unwrap_or(false)
                })
            });
            if emergent {
                let hx_dp_est = estimate_hx_dp_pa(config, &component_indices);
                if let Some(staged) = build_staged_emergent_seed(
                    &system,
                    &component_indices,
                    backend.as_ref(),
                    fluid_id.clone(),
                    t_evap,
                    t_cond,
                    initialization_guesses.superheat_k,
                    n_state,
                    &hx_dp_est,
                    &mut initialization_diagnostics,
                ) {
                    state = staged;
                }
            }
        }
        seed_from_boundary_conditions(
            config,
            &system,
            backend.as_ref(),
            &mut state,
            &mut initialization_diagnostics,
        );
        state
    };

    // Seed embedding / control unknowns at their start/initial (physical seed
    // leaves them at 0.0 — a poor start for Z-factors whose nominal is ~0.3–1).
    let mut initial_state = initial_state;
    for emb in &config.embeddings {
        let unknown_id = entropyk_solver::inverse::BoundedVariableId::new(saturated_actuator_id(
            &emb.unknown.component,
            &emb.unknown.factor,
        ));
        if let Some(u_idx) = system.control_variable_state_index(&unknown_id) {
            if u_idx < initial_state.len() {
                initial_state[u_idx] = emb.unknown.start;
            }
        }
    }
    for eq in &config.equations {
        if let Ok((_, _, unknown_id)) = build_model_equation(eq) {
            let start = eq
                .unknown
                .as_ref()
                .map(|u| u.start)
                .unwrap_or(eq.start);
            if let Some(u_idx) = system.control_variable_state_index(&unknown_id) {
                if u_idx < initial_state.len() {
                    initial_state[u_idx] = start;
                }
            }
        }
    }
    for control in &config.controls {
        let actuator_id = entropyk_solver::inverse::BoundedVariableId::new(saturated_actuator_id(
            &control.actuator.component,
            &control.actuator.factor,
        ));
        if let Some(u_idx) = system.control_variable_state_index(&actuator_id) {
            if u_idx < initial_state.len() {
                initial_state[u_idx] = control.actuator.initial;
            }
        }
    }

    // Seed physical free-actuator openings (arch-6) at their configured initial
    // value; the staged seed leaves them at 0.0 which is a degenerate orifice start.
    for (i, init) in free_actuator_inits.iter().enumerate() {
        let idx = system.free_actuator_index(i);
        if idx < initial_state.len() {
            initial_state[idx] = *init;
        }
    }

    // Optional warm-start from another run's state. Useful for cross-model
    // verification (e.g. feed a `dp_model=quadratic` converged state into a
    // `dp_model=msh` solve to test whether the msh root is in the basin of
    // attraction). Format: JSON array of f64, length must match
    // `system.full_state_vector_len()`.
    if let Ok(state_json) = std::env::var("ENTROPYK_INIT_STATE_JSON") {
        match serde_json::from_str::<Vec<f64>>(&state_json) {
            Ok(parsed) if parsed.len() == initial_state.len() => {
                tracing::info!(
                    len = parsed.len(),
                    "Warm-starting from ENTROPYK_INIT_STATE_JSON override"
                );
                initial_state = parsed;
            }
            Ok(parsed) => {
                tracing::warn!(
                    expected = initial_state.len(),
                    got = parsed.len(),
                    "ENTROPYK_INIT_STATE_JSON length mismatch — ignoring warm-start override"
                );
            }
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "ENTROPYK_INIT_STATE_JSON is not a valid JSON array of f64 — ignoring"
                );
            }
        }
    }

    let diagnostic_capture = VerboseConfig {
        enabled: true,
        log_residuals: true,
        log_jacobian_condition: false,
        log_solver_switches: true,
        dump_final_state: false,
        output_format: Default::default(),
    };

    let strategy_name = config.solver.strategy.clone();
    let max_iter = config.solver.max_iterations;
    let tol = config.solver.tolerance;
    // Cooperative time budget (0 = unbounded). Propagated to every strategy so
    // pathological cases (e.g. stiff two-phase ΔP grinding through the recovery
    // cascade) fail with an honest `Timeout` instead of running for minutes.
    let solve_time_budget = (config.solver.timeout_ms > 0)
        .then(|| std::time::Duration::from_millis(config.solver.timeout_ms));
    let needs_guarded_newton = !config.controls.is_empty()
        || !config.embeddings.is_empty()
        || !config.equations.is_empty()
        || config.circuits.iter().any(|c| {
            c.enabled
                && c.components.iter().any(|comp| {
                    comp.params.get("orifice_kv").is_some()
                        || component_has_free_boundary_pressure(comp)
                })
        });
    if !matches!(strategy_name.as_str(), "newton" | "picard" | "fallback") {
        return SimulationResult {
            input: input_name.to_string(),
            status: SimulationStatus::Error,
            convergence: None,
            iterations: None,
            state: None,
            performance: None,
            error: Some(format!(
                "Unsupported solver strategy '{}'. Use 'newton', 'picard', or 'fallback' \
                 (Newton→Picard intelligent fallback).",
                strategy_name
            )),
            failure_diagnostics: None,
            initialization_diagnostics: None,
            dof: Some(dof_summary),
            elapsed_ms,
            raw_state_vector: None,
            solved_variables: Vec::new(),
        };
    }

    // Dense linear backend dispatch (Story 1.5): "nalgebra" (default) or
    // "faer"; env ENTROPYK_LINEAR_BACKEND overrides for the test harness.
    if !matches!(config.solver.linear_backend.as_str(), "nalgebra" | "faer") {
        return SimulationResult {
            input: input_name.to_string(),
            status: SimulationStatus::Error,
            convergence: None,
            iterations: None,
            state: None,
            performance: None,
            error: Some(format!(
                "Unsupported linear backend '{}'. Use 'nalgebra' or 'faer'.",
                config.solver.linear_backend
            )),
            failure_diagnostics: None,
            initialization_diagnostics: None,
            dof: Some(dof_summary),
            elapsed_ms,
            raw_state_vector: None,
            solved_variables: Vec::new(),
        };
    }
    entropyk_solver::linear::set_linear_backend_override(Some(&config.solver.linear_backend));

    // Build + run the configured strategy for a single solve. CLI
    // `solver.max_iterations` / `tolerance` are now honoured by every stage
    // (previously the defaults capped Newton at 100 iterations). Reused by the
    // alpha-continuation loop below, always seeded from the supplied state.
    //
    // `fallback` uses the real `FallbackSolver` (Newton → Picard on divergence),
    // not an ad-hoc double call.
    // Budget countdown shared by the direct solve AND every continuation step
    // (the continuation re-enters `solve_once` per step; without a shared
    // countdown each step would get a fresh full budget and the total runtime
    // would be unbounded).
    let solve_start = std::time::Instant::now();
    let remaining_budget = || solve_time_budget.map(|b| b.saturating_sub(solve_start.elapsed()));

    let solve_once = |system: &mut System, init: Vec<f64>| match strategy_name.as_str() {
        "newton" => {
            let mut cfg = NewtonConfig::default();
            cfg.max_iterations = max_iter;
            cfg.tolerance = tol;
            cfg.line_search = needs_guarded_newton;
            cfg.timeout = remaining_budget();
            let mut strategy = SolverStrategy::NewtonRaphson(
                cfg.with_initial_state(init)
                    .with_verbose(diagnostic_capture.clone()),
            );
            strategy.solve(system)
        }
        "picard" => {
            let mut cfg = PicardConfig::default();
            cfg.max_iterations = max_iter;
            cfg.tolerance = tol;
            cfg.timeout = remaining_budget();
            let mut strategy = SolverStrategy::SequentialSubstitution(
                cfg.with_initial_state(init)
                    .with_verbose(diagnostic_capture.clone()),
            );
            strategy.solve(system)
        }
        "fallback" => {
            let mut newton_cfg = NewtonConfig::default();
            newton_cfg.max_iterations = max_iter;
            newton_cfg.tolerance = tol;
            newton_cfg.line_search = needs_guarded_newton;
            newton_cfg = newton_cfg.with_verbose(diagnostic_capture.clone());

            let mut picard_cfg = PicardConfig::default();
            picard_cfg.max_iterations = max_iter;
            picard_cfg.tolerance = tol;
            picard_cfg = picard_cfg.with_verbose(diagnostic_capture.clone());

            let mut fallback = FallbackSolver::new(FallbackConfig {
                fallback_enabled: true,
                verbose_config: diagnostic_capture.clone(),
                ..FallbackConfig::default()
            })
            .with_newton_config(newton_cfg)
            .with_picard_config(picard_cfg)
            .with_homotopy(entropyk_solver::HomotopyConfig::default())
            .with_initial_state(init);
            if let Some(budget) = remaining_budget() {
                fallback = fallback.with_timeout(budget);
            }
            fallback.solve(system)
        }
        _ => unreachable!("solver strategy was validated before solve_once"),
    };

    // Warm-started solve for continuation steps: each step starts near the
    // previous converged point, so a plain Newton was originally enough. On the
    // stiff two-phase ΔP regime at high EXV opening the Newton step stagnates
    // (~0.02 plateau) and the continuation collapses. We therefore run a
    // *trimmed* FallbackSolver chain that still reaches trust-region and
    // homotopy when needed — iteration budgets are cut to keep the per-step
    // cost down (each step is a warm-start, not a cold start).
    let solve_quick_fallback = |system: &mut System, init: Vec<f64>| {
        let mut newton_cfg = NewtonConfig::default();
        newton_cfg.max_iterations = 80;
        newton_cfg.tolerance = tol;
        newton_cfg.line_search = needs_guarded_newton;

        let mut picard_cfg = PicardConfig::default();
        picard_cfg.max_iterations = 80;
        picard_cfg.tolerance = tol;

        let mut fallback = FallbackSolver::new(FallbackConfig {
            fallback_enabled: true,
            ..FallbackConfig::default()
        })
        .with_newton_config(newton_cfg)
        .with_picard_config(picard_cfg)
        .with_homotopy(entropyk_solver::HomotopyConfig::default())
        .with_initial_state(init);
        if let Some(budget) = remaining_budget() {
            fallback = fallback.with_timeout(budget);
        }
        fallback.solve(system)
    };

    let result = if system.has_network_controllers() {
        // Warm-started **adaptive activation continuation** (predictor–corrector
        // homotopy on the override activation λ). This mirrors the Newton-homotopy
        // step control in `entropyk_solver::strategies::homotopy` for coherence:
        //   * λ = 0 solves the feasible primary-only baseline (each controller
        //     acts like a plain single loop — its protections are disabled);
        //   * λ is then walked toward 1 (full override network). The step is
        //     HALVED on any failed solve (retrying from the last good λ) and grown
        //     on success, so the walk automatically takes small steps through the
        //     hard switching region and large steps elsewhere;
        //   * every step is seeded from the previous converged solution.
        // This engages protections gradually and keeps each intermediate solve
        // near-feasible — the robust cure for hard cold-start override switching.
        const MIN_LAMBDA_STEP: f64 = 5e-3;

        let apply_activation = |system: &mut System, lambda: f64| {
            for ctrl in system.saturated_controllers_mut() {
                if ctrl.is_network() {
                    ctrl.set_activation(lambda);
                }
            }
        };

        apply_activation(&mut system, 0.0);
        match solve_once(&mut system, initial_state) {
            Err(err) => Err(err),
            Ok(baseline) => {
                let mut warm = baseline.state.clone();
                let mut result = Ok(baseline);
                let mut lambda = 0.0_f64;
                let mut step = 0.5_f64;
                while lambda < 1.0 {
                    let target = (lambda + step).min(1.0);
                    apply_activation(&mut system, target);
                    match solve_once(&mut system, warm.clone()) {
                        Ok(converged) => {
                            lambda = target;
                            warm = converged.state.clone();
                            result = Ok(converged);
                            step = (step * 1.5).min(0.5); // grow, capped
                        }
                        Err(err) => {
                            step *= 0.5;
                            if step < MIN_LAMBDA_STEP {
                                // Couldn't reach the full network from the last
                                // good λ — surface the failure (the baseline is
                                // primary-only and would silently ignore the
                                // protections).
                                result = Err(err);
                                break;
                            }
                        }
                    }
                }
                result
            }
        }
    } else {
        match solve_once(&mut system, initial_state.clone()) {
            Ok(converged) => Ok(converged),
            Err(first_err) => {
                // Physical-parameter continuation: when a fixed-opening EXV
                // meters the flow and the direct solve fails, walk the opening
                // up from a reduced value (see solve_with_opening_continuation).
                match fixed_exv_opening {
                    Some((ref exv_name, target))
                        if target > 0.5 && std::env::var("ENTROPYK_NO_CONTINUATION").is_err() =>
                    {
                        match solve_with_opening_continuation(
                            &mut system,
                            exv_name,
                            target,
                            initial_state,
                            &solve_quick_fallback,
                        ) {
                            Ok(converged) => Ok(converged),
                            // Surface the original error: it is the actionable
                            // diagnostic, not the continuation failure.
                            Err(_) => Err(first_err),
                        }
                    }
                    _ => Err(first_err),
                }
            }
        }
    };

    match result {
        Ok(converged) => {
            let status = match converged.status {
                ConvergenceStatus::Converged => SimulationStatus::Converged,
                ConvergenceStatus::TimedOutWithBestState => SimulationStatus::Timeout,
                ConvergenceStatus::ControlSaturation => SimulationStatus::NonConverged,
            };

            let state = extract_state(&system, &converged, &config.fluid, backend.as_ref());
            let performance = compute_performance(&system, &converged);
            let history = converged
                .diagnostics
                .as_ref()
                .map(iteration_history_from_diagnostics)
                .unwrap_or_default();
            let strategy = converged
                .diagnostics
                .as_ref()
                .and_then(|d| d.final_solver.map(|s| s.to_string()))
                .unwrap_or_else(|| strategy_name.clone());

            SimulationResult {
                input: input_name.to_string(),
                status,
                convergence: Some(ConvergenceInfo {
                    final_residual: converged.final_residual,
                    tolerance: config.solver.tolerance,
                    iterations: Some(converged.iterations),
                    strategy: Some(strategy),
                    iteration_history: history,
                }),
                iterations: Some(converged.iterations),
                state: Some(state),
                performance,
                error: None,
                failure_diagnostics: None,
                initialization_diagnostics: Some(initialization_diagnostics),
                dof: Some(dof_summary.clone()),
                elapsed_ms,
                raw_state_vector: Some(converged.state.clone()),
                solved_variables: entropyk::extract_solved_variables(&system, &converged.state),
            }
        }
        Err(e) => {
            let failure_diagnostics = failure_diagnostics_from_solver_error(&e);
            let history = e
                .diagnostics()
                .map(iteration_history_from_diagnostics)
                .unwrap_or_default();
            let iterations = e.diagnostics().map(|d| d.iterations);
            let final_residual = e
                .diagnostics()
                .map(|d| d.final_residual)
                .unwrap_or(f64::NAN);
            let e_str = format!("{}", e.base_error());
            let error_msg = if e_str.contains("FluidError")
                || e_str.contains("backend")
                || e_str.contains("CoolProp")
            {
                format!("Thermodynamic/Fluid error: {}", e_str)
            } else {
                format!("Solver error: {}", e_str)
            };

            SimulationResult {
                input: input_name.to_string(),
                status: SimulationStatus::Error,
                convergence: if history.is_empty() && iterations.is_none() {
                    None
                } else {
                    Some(ConvergenceInfo {
                        final_residual,
                        tolerance: config.solver.tolerance,
                        iterations,
                        strategy: Some(strategy_name.clone()),
                        iteration_history: history,
                    })
                },
                iterations,
                state: None,
                performance: None,
                error: Some(error_msg),
                failure_diagnostics,
                initialization_diagnostics: Some(initialization_diagnostics),
                dof: Some(dof_summary),
                elapsed_ms,
                raw_state_vector: None,
                solved_variables: Vec::new(),
            }
        }
    }
}

fn iteration_history_from_diagnostics(
    diagnostics: &entropyk_solver::solver::ConvergenceDiagnostics,
) -> Vec<IterationInfo> {
    diagnostics
        .iteration_history
        .iter()
        .map(|it| IterationInfo {
            iteration: it.iteration,
            residual_norm: it.residual_norm,
            delta_norm: it.delta_norm,
            alpha: it.alpha,
            max_residual_index: it.max_residual_index,
            max_residual: it.max_residual,
        })
        .collect()
}

fn failure_diagnostics_from_solver_error(
    error: &entropyk::SolverError,
) -> Option<FailureDiagnostics> {
    let diagnostics = error.diagnostics()?;
    let last = diagnostics.iteration_history.last();
    Some(FailureDiagnostics {
        final_residual_norm: diagnostics.final_residual,
        last_residual_norm: last.map(|iteration| iteration.residual_norm),
        dominant_residual_index: last.and_then(|iteration| iteration.max_residual_index),
        dominant_residual_value: last.map(|iteration| iteration.max_residual),
    })
}

/// Resolves a port name string to a port index for the given component type.
///
/// This enables named port connections in the edge JSON config, e.g.:
/// ```json
/// { "from": "screw_0:discharge", "to": "mchx_0:inlet" }
/// ```
///
/// Port index conventions:
/// - `ScrewEconomizerCompressor`: suction=0, discharge=1, economizer=2
/// - All other components: inlet=0, outlet=1
///
fn requires_explicit_port(component_type: &str) -> bool {
    matches!(
        component_type,
        "ScrewEconomizerCompressor"
            | "ScrewCompressor"
            | "FlowSplitter"
            | "FlowMerger"
            | "Drum"
            | "HeatExchanger"
            | "Condenser"
            | "Evaporator"
            | "FloodedEvaporator"
            | "FloodedCondenser"
            | "CondenserCoil"
            | "EvaporatorCoil"
            | "BphxEvaporator"
            | "BphxCondenser"
    )
}

/// `is_source` is true when resolving the "from" side of an edge.
fn resolve_port_index(
    component_type: &str,
    port_name: &str,
    _is_source: bool,
) -> Result<usize, String> {
    let port_lower = port_name.to_lowercase();
    let unknown = || {
        Err(format!(
            "Unknown port '{}' for component type '{}'",
            port_name, component_type
        ))
    };

    match component_type {
        "ScrewEconomizerCompressor" | "ScrewCompressor" => match port_lower.as_str() {
            "suction" | "inlet" | "in" => Ok(0),
            "discharge" | "outlet" | "out" => Ok(1),
            "economizer" | "eco" | "economiser" | "flash_in" => Ok(2),
            _ => unknown(),
        },
        // FlowSplitter: 1 inlet (port 0) → N outlets (ports 1..N).
        // Outlet names: "outlet_0", "outlet_1", … or "outlet"/"out" (→ port 1).
        "FlowSplitter" => match port_lower.as_str() {
            "inlet" | "in" => Ok(0),
            other => {
                if let Some(rest) = other
                    .strip_prefix("outlet_")
                    .or_else(|| other.strip_prefix("out_"))
                {
                    rest.parse::<usize>()
                        .map(|i| i + 1)
                        .map_err(|_| format!("Invalid FlowSplitter port '{}'", port_name))
                } else if other == "outlet" || other == "out" {
                    Ok(1)
                } else {
                    unknown()
                }
            }
        },
        // FlowMerger: N inlets (ports 0..N-1) → 1 outlet (port N).
        // Inlet names: "inlet_0", "inlet_1", … ; outlet name: "outlet"/"out".
        // (Port indices are unused while get_ports() is empty — edges route by node.)
        "FlowMerger" => match port_lower.as_str() {
            "outlet" | "out" => Ok(1),
            other => {
                if let Some(rest) = other
                    .strip_prefix("inlet_")
                    .or_else(|| other.strip_prefix("in_"))
                {
                    rest.parse::<usize>()
                        .map_err(|_| format!("Invalid FlowMerger port '{}'", port_name))
                } else if other == "inlet" || other == "in" {
                    Ok(0)
                } else {
                    unknown()
                }
            }
        },
        // Drum: 4-port liquid/vapor separator.
        // Inlets: feed_inlet (0), evaporator_return (1).
        // Outlets: liquid_outlet (2), vapor_outlet (3).
        // (Port indices unused while get_ports() is empty — edges route by node;
        //  the drum identifies its outgoing edges by declaration order.)
        "Drum" => match port_lower.as_str() {
            "feed_inlet" | "feed" | "inlet" | "in" => Ok(0),
            "evaporator_return" | "evap_return" | "return" => Ok(1),
            "liquid_outlet" | "liquid" | "liquid_out" | "liq" => Ok(2),
            "vapor_outlet" | "vapor" | "vapor_out" | "vap" => Ok(3),
            _ => unknown(),
        },
        // HeatExchanger: Modelica-style 4-port generic HX.
        // Hot side: hot_inlet (0) → hot_outlet (1).
        // Cold side: cold_inlet (2) → cold_outlet (3).
        "HeatExchanger" => match port_lower.as_str() {
            "hot_inlet" | "hot_in" | "inlet" | "in" => Ok(0),
            "hot_outlet" | "hot_out" | "outlet" | "out" => Ok(1),
            "cold_inlet" | "cold_in" => Ok(2),
            "cold_outlet" | "cold_out" => Ok(3),
            _ => unknown(),
        },
        // Condenser / Evaporator / Flooded*: Modelica-style 4-port heat exchangers.
        // Refrigerant: inlet (0) → outlet (1).
        // Secondary (water/brine/air): secondary_inlet (2) → secondary_outlet (3).
        "Condenser" | "Evaporator" | "FloodedEvaporator" | "FloodedCondenser" | "CondenserCoil"
        | "EvaporatorCoil" => match port_lower.as_str() {
            "inlet" | "in" | "refrigerant_in" | "refrigerant_inlet" => Ok(0),
            "outlet" | "out" | "refrigerant_out" | "refrigerant_outlet" => Ok(1),
            "secondary_inlet" | "secondary_in" | "water_in" | "water_inlet" | "air_in"
            | "air_inlet" | "brine_in" | "brine_inlet" => Ok(2),
            "secondary_outlet" | "secondary_out" | "water_out" | "water_outlet" | "air_out"
            | "air_outlet" | "brine_out" | "brine_outlet" => Ok(3),
            _ => unknown(),
        },
        // BphxEvaporator and BphxCondenser: Modelica-style 4-port heat exchangers.
        // Refrigerant: inlet (0) → outlet (1). Secondary: secondary_inlet (2) → secondary_outlet (3).
        "BphxEvaporator" | "BphxCondenser" => match port_lower.as_str() {
            "inlet" | "in" | "refrigerant_in" | "refrigerant_inlet" => Ok(0),
            "outlet" | "out" | "refrigerant_out" | "refrigerant_outlet" => Ok(1),
            "secondary_inlet" | "secondary_in" | "water_in" | "water_inlet" | "air_in"
            | "air_inlet" | "brine_in" | "brine_inlet" => Ok(2),
            "secondary_outlet" | "secondary_out" | "water_out" | "water_outlet" | "air_out"
            | "air_outlet" | "brine_out" | "brine_outlet" => Ok(3),
            _ => unknown(),
        },
        _ => {
            // Default 2-port + common 4-port secondary aliases so UI edges never
            // fail only because a HX type was omitted from the match arms above.
            match port_lower.as_str() {
                "inlet" | "in" | "suction" | "cold_in" | "hot_in" | "refrigerant_in"
                | "flash_in" => Ok(0),
                "outlet" | "out" | "discharge" | "cold_out" | "hot_out" | "refrigerant_out"
                | "flash_out" => Ok(1),
                // 4-port secondary aliases (safe fallback for any HX type).
                "secondary_inlet" | "secondary_in" | "water_in" | "water_inlet" | "air_in"
                | "air_inlet" | "brine_in" | "brine_inlet" => Ok(2),
                "secondary_outlet" | "secondary_out" | "water_out" | "water_outlet" | "air_out"
                | "air_outlet" | "brine_out" | "brine_outlet" => Ok(3),
                _ => unknown(),
            }
        }
    }
}

/// Run a simulation from an in-memory config (no file I/O, no JSON re-parse).
///
/// Used by the FMI Co-Simulation binding: the model JSON is parsed once at
/// `fmi2Instantiate`, then each `fmi2DoStep` mutates boundary parameters on the
/// already-parsed `ScenarioConfig` and calls this to re-solve the steady cycle.
///
/// Note: this rebuilds the `System` graph each call. Warm-start (reusing the
/// previous state vector and the built `System`) requires splitting
/// `execute_simulation` into `build_system` + `solve_warm` — tracked as a TODO.
pub fn run_from_config(config: &ScenarioConfig) -> SimulationResult {
    execute_simulation(config, "fmu", 0)
}

fn get_param_f64(
    params: &std::collections::HashMap<String, serde_json::Value>,
    key: &str,
) -> CliResult<f64> {
    params
        .get(key)
        .and_then(|v| v.as_f64())
        .ok_or_else(|| CliError::Config(format!("Missing required parameter: {}", key)))
}

/// Modelica-style Fixed flag: bool JSON, or omit → `default`.
fn param_fix_flag(
    params: &std::collections::HashMap<String, serde_json::Value>,
    key: &str,
    default: bool,
) -> bool {
    match params.get(key) {
        Some(v) => v
            .as_bool()
            .or_else(|| v.as_str().map(|s| s.eq_ignore_ascii_case("true")))
            .unwrap_or(default),
        None => default,
    }
}

/// `true` when a water/brine boundary source runs with Free pressure (Modelica
/// `MassFlowSource_T`): the liquid (P, h) state is then a floating unknown that
/// a full Newton step can push through the saturation dome (Cp → ∞), so the
/// solve needs the Armijo guard. Moist air is exempt — its h↔T convention is
/// linear and pressure-independent, and the guard only slows it down.
fn component_has_free_boundary_pressure(comp: &crate::config::ComponentConfig) -> bool {
    if comp.component_type.as_str() != "BrineSource" {
        return false;
    }
    let default_fix_p = optional_imposed_mass_flow(&comp.params).is_none();
    !param_fix_flag(&comp.params, "fix_pressure", default_fix_p)
}

/// Optional imposed mass flow when `fix_mass_flow` is true (default) and a
/// positive `m_flow_kg_s` is present. Free ṁ keeps the numeric value as a
/// documentation/seed hint only (no Dirichlet residual).
fn optional_imposed_mass_flow(
    params: &std::collections::HashMap<String, serde_json::Value>,
) -> Option<f64> {
    if !param_fix_flag(params, "fix_mass_flow", true) {
        return None;
    }
    params
        .get("m_flow_kg_s")
        .and_then(|v| v.as_f64())
        .filter(|m| m.is_finite() && *m > 0.0)
}

/// Parses an optional coupled secondary (water/brine/air) boundary stream from a
/// component's parameters.
///
/// Accepts the inlet temperature in °C (`secondary_inlet_temp_c`) or K
/// (`secondary_inlet_temp_k`), and a heat-capacity rate either directly
/// (`secondary_capacity_rate_w_per_k`) or as mass flow × cp
/// (`secondary_mass_flow_kg_s` × `secondary_cp_j_per_kgk`). Returns
/// `Some((T_sec,in [K], C_sec [W/K]))` only when both a temperature and a strictly
/// positive capacity rate are supplied.
fn parse_secondary_stream(
    params: &std::collections::HashMap<String, serde_json::Value>,
    default_cp_j_per_kgk: f64,
) -> Option<(f64, f64)> {
    let sec_t_k = params
        .get("secondary_inlet_temp_k")
        .and_then(|v| v.as_f64())
        .or_else(|| {
            params
                .get("secondary_inlet_temp_c")
                .and_then(|v| v.as_f64())
                .map(|c| c + 273.15)
        });
    let sec_c = params
        .get("secondary_capacity_rate_w_per_k")
        .and_then(|v| v.as_f64())
        .or_else(|| {
            params
                .get("secondary_mass_flow_kg_s")
                .and_then(|v| v.as_f64())
                .map(|m| {
                    let cp = params
                        .get("secondary_cp_j_per_kgk")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(default_cp_j_per_kgk);
                    m * cp
                })
        });
    match (sec_t_k, sec_c) {
        (Some(t_k), Some(c)) if c > 0.0 => Some((t_k, c)),
        _ => None,
    }
}

/// Secondary (water/air) quadratic ΔP coefficient [Pa·s²/kg²], or `None` = isobaric.
///
/// Keys (distinct from refrigerant `dp_model` / `rated_pressure_drop_pa`):
/// - `secondary_pressure_drop_coeff` / `secondary_pressure_drop_coeff_pa_s2_kg2`
/// - or `secondary_rated_pressure_drop_pa` + `secondary_rated_m_flow_kg_s`
///   (Modelica Buildings `dp_nominal` / `m_flow_nominal` style)
fn parse_secondary_dp_coeff(
    params: &std::collections::HashMap<String, serde_json::Value>,
) -> Option<f64> {
    use entropyk_components::heat_exchanger::calibrate_quadratic_k;

    if let Some(k) = params
        .get("secondary_pressure_drop_coeff_pa_s2_kg2")
        .or_else(|| params.get("secondary_pressure_drop_coeff"))
        .and_then(|v| v.as_f64())
    {
        return if k > 0.0 { Some(k) } else { None };
    }
    let dp = params
        .get("secondary_rated_pressure_drop_pa")
        .and_then(|v| v.as_f64())
        .or_else(|| {
            params
                .get("secondary_rated_pressure_drop_kpa")
                .and_then(|v| v.as_f64())
                .map(|kpa| kpa * 1000.0)
        })?;
    let m = params
        .get("secondary_rated_m_flow_kg_s")
        .and_then(|v| v.as_f64())
        .filter(|m| *m > 0.0)?;
    let k = calibrate_quadratic_k(dp.max(0.0), m);
    if k > 0.0 {
        Some(k)
    } else {
        None
    }
}

/// Applies refrigerant-side ΔP model on a Condenser / Evaporator.
///
/// `dp_model`:
/// - `isobaric` / `none` — ΔP = 0
/// - `quadratic` — Modelica `dp_nominal` law via `rated_pressure_drop_pa` or `k`
/// - `msh` (default when geometry keys present) — Müller–Steinhagen–Heck + accel
/// - `friedel` — Friedel (1979) + accel
///
/// Tube geometry: `tube_length_m`, `tube_diameter_m`, `n_parallel_tubes`
/// (defaults from [`TubeChannelGeometry::dx_default`] when model is msh/friedel).
///
/// Water/air ΔP is separate: see [`parse_secondary_dp_coeff`].
enum HxDpApply {
    Isobaric,
    Quadratic(f64),
    Tube {
        correlation: entropyk_components::heat_exchanger::TwoPhaseDpCorrelation,
        geometry: entropyk_components::heat_exchanger::TubeChannelGeometry,
    },
}

fn parse_hx_dp_model(params: &std::collections::HashMap<String, serde_json::Value>) -> HxDpApply {
    use entropyk_components::heat_exchanger::{
        calibrate_quadratic_k, parse_dp_model_name, TubeChannelGeometry, TwoPhaseDpCorrelation,
        DEFAULT_REFRIGERANT_M_NOMINAL_KG_S,
    };

    if params
        .get("isobaric")
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        return HxDpApply::Isobaric;
    }

    let model_raw = params
        .get("dp_model")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());
    let model = model_raw
        .as_deref()
        .and_then(parse_dp_model_name)
        .unwrap_or_else(|| {
            // Infer: explicit k/rated → quadratic; else if any tube key → msh; else none.
            if params.get("pressure_drop_coeff_pa_s2_kg2").is_some()
                || params.get("pressure_drop_coeff").is_some()
                || params.get("rated_pressure_drop_pa").is_some()
            {
                "quadratic"
            } else if params.get("tube_length_m").is_some()
                || params.get("tube_diameter_m").is_some()
                || params.get("n_parallel_tubes").is_some()
            {
                "msh"
            } else {
                "isobaric"
            }
        });

    match model {
        "isobaric" => HxDpApply::Isobaric,
        "quadratic" => {
            if let Some(k) = params
                .get("pressure_drop_coeff_pa_s2_kg2")
                .or_else(|| params.get("pressure_drop_coeff"))
                .and_then(|v| v.as_f64())
            {
                return if k > 0.0 {
                    HxDpApply::Quadratic(k)
                } else {
                    HxDpApply::Isobaric
                };
            }
            let rated_dp = params
                .get("rated_pressure_drop_pa")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.0);
            if rated_dp <= 0.0 {
                return HxDpApply::Isobaric;
            }
            let rated_m = params
                .get("rated_mass_flow_kg_s")
                .and_then(|v| v.as_f64())
                .unwrap_or(DEFAULT_REFRIGERANT_M_NOMINAL_KG_S);
            HxDpApply::Quadratic(calibrate_quadratic_k(rated_dp, rated_m).max(0.0))
        }
        "msh" | "friedel" => {
            let corr = if model == "friedel" {
                TwoPhaseDpCorrelation::Friedel1979
            } else {
                TwoPhaseDpCorrelation::MullerSteinhagenHeck1986
            };
            let def = TubeChannelGeometry::dx_default();
            let geometry = TubeChannelGeometry {
                length_m: params
                    .get("tube_length_m")
                    .and_then(|v| v.as_f64())
                    .unwrap_or(def.length_m)
                    .max(0.0),
                diameter_m: params
                    .get("tube_diameter_m")
                    .and_then(|v| v.as_f64())
                    .unwrap_or(def.diameter_m)
                    .max(1e-6),
                n_parallel: params
                    .get("n_parallel_tubes")
                    .and_then(|v| v.as_f64())
                    .unwrap_or(def.n_parallel)
                    .max(1.0),
            };
            HxDpApply::Tube {
                correlation: corr,
                geometry,
            }
        }
        _ => HxDpApply::Isobaric,
    }
}

fn apply_condenser_dp(
    cond: &mut entropyk_components::heat_exchanger::Condenser,
    params: &std::collections::HashMap<String, serde_json::Value>,
) {
    match parse_hx_dp_model(params) {
        HxDpApply::Isobaric => {
            // Explicit clear via zero coeff (isobaric).
            cond.set_pressure_drop_coeff(0.0);
        }
        HxDpApply::Quadratic(k) => cond.set_pressure_drop_coeff(k),
        HxDpApply::Tube {
            correlation,
            geometry,
        } => cond.set_tube_pressure_drop(correlation, geometry),
    }
}

fn apply_evaporator_dp(
    evap: &mut entropyk_components::heat_exchanger::Evaporator,
    params: &std::collections::HashMap<String, serde_json::Value>,
) {
    match parse_hx_dp_model(params) {
        HxDpApply::Isobaric => {
            evap.set_pressure_drop_coeff(0.0);
        }
        HxDpApply::Quadratic(k) => evap.set_pressure_drop_coeff(k),
        HxDpApply::Tube {
            correlation,
            geometry,
        } => evap.set_tube_pressure_drop(correlation, geometry),
    }
}

/// Parses a 3-element `[c0, c1, c2]` coefficient array from a JSON parameter,
/// falling back to `default` when absent or malformed.
fn parse_coeffs3(
    params: &std::collections::HashMap<String, serde_json::Value>,
    key: &str,
    default: [f64; 3],
) -> [f64; 3] {
    match params.get(key).and_then(|v| v.as_array()) {
        Some(arr) if arr.len() == 3 => {
            let mut out = default;
            for (i, slot) in out.iter_mut().enumerate() {
                if let Some(x) = arr[i].as_f64() {
                    *slot = x;
                }
            }
            out
        }
        _ => default,
    }
}

/// Estimates the per-HX refrigerant ΔP [Pa] used to stratify the staged seed:
/// a uniform per-side pressure seed ignores the tube two-phase ΔP and starts
/// the near-singular emergent loop far off (measured σ_min ≈ 1.6e-6 on the
/// 2026-07-19 reference config). Priority: `rated_pressure_drop_pa` param →
/// nominal design ΔP when a tube model is configured (`dp_model=msh|friedel`
/// or tube geometry keys) → 0 (uniform seed, unchanged behavior).
fn estimate_hx_dp_pa(
    config: &ScenarioConfig,
    component_indices: &std::collections::HashMap<String, (petgraph::graph::NodeIndex, String)>,
) -> std::collections::HashMap<usize, f64> {
    let mut map = std::collections::HashMap::new();
    for circuit in config.circuits.iter().filter(|c| c.enabled) {
        for comp in &circuit.components {
            let Some((node, _)) = component_indices.get(&comp.name) else {
                continue;
            };
            let dp = comp
                .params
                .get("rated_pressure_drop_pa")
                .and_then(|v| v.as_f64())
                .filter(|dp| *dp > 0.0)
                .unwrap_or_else(|| {
                    let tube = comp
                        .params
                        .get("dp_model")
                        .and_then(|v| v.as_str())
                        .map(|m| matches!(m, "msh" | "friedel"))
                        .unwrap_or(false)
                        || comp.params.get("tube_length_m").is_some()
                        || comp.params.get("tube_diameter_m").is_some()
                        || comp.params.get("n_parallel_tubes").is_some();
                    if tube {
                        entropyk_components::heat_exchanger::two_phase_dp::DEFAULT_REFRIGERANT_DP_NOMINAL_PA
                    } else {
                        0.0
                    }
                });
            if dp > 0.0 {
                map.insert(node.index(), dp);
            }
        }
    }
    map
}

/// Builds a physically-staged initial state for an **emergent-pressure** cycle.
///
/// In emergent mode no component hard-pins the condensing/evaporating pressures
/// with a single residual, so the crude uniform seed (uniform enthalpy, one
/// pressure per circuit) lets Newton fall into a degenerate basin. This walks the
/// refrigerant loop from the compressor and assigns per-edge physical states:
///
/// * compressor discharge edge → high pressure, superheated vapour,
/// * high-side edges after the condenser → high pressure, subcooled liquid,
/// * post-EXV / two-phase edges → low pressure, flash enthalpy (≈ h_liq),
/// * compressor suction edge → low pressure, superheated vapour.
///
/// Returns `None` (falling back to the generic seed) when the topology does not
/// expose exactly one `IsentropicCompressor` and one `IsenthalpicExpansionValve`,
/// or when the saturation-enthalpy anchors cannot be evaluated.
#[allow(clippy::too_many_arguments)]
fn build_staged_emergent_seed(
    system: &entropyk::System,
    component_indices: &std::collections::HashMap<String, (petgraph::graph::NodeIndex, String)>,
    backend: &dyn entropyk_fluids::FluidBackend,
    fluid: entropyk::FluidId,
    t_evap_k: f64,
    t_cond_k: f64,
    superheat_k: f64,
    n_state: usize,
    hx_dp_est: &std::collections::HashMap<usize, f64>,
    diagnostics: &mut entropyk_solver::InitializationDiagnostics,
) -> Option<Vec<f64>> {
    use petgraph::graph::{EdgeIndex, NodeIndex};

    // Locate every compressor and expansion-valve node. Each compressor roots one
    // independent refrigerant loop; multi-circuit systems (e.g. 61XW System_2C) have
    // several disjoint loops that must ALL be seeded, otherwise the un-seeded circuit's
    // edges stay at 0 and the Jacobian is singular.
    let mut compressor_nodes: Vec<NodeIndex> = Vec::new();
    let mut exv_nodes: std::collections::HashSet<usize> = std::collections::HashSet::new();
    let mut condenser_nodes: std::collections::HashSet<usize> = std::collections::HashSet::new();
    let mut evaporator_nodes: std::collections::HashSet<usize> = std::collections::HashSet::new();
    for (node, typ) in component_indices.values() {
        match typ.as_str() {
            "IsentropicCompressor" => compressor_nodes.push(*node),
            "IsenthalpicExpansionValve" | "EXV" => {
                exv_nodes.insert(node.index());
            }
            t if t.contains("Condenser") => {
                condenser_nodes.insert(node.index());
            }
            t if t.contains("Evaporator") => {
                evaporator_nodes.insert(node.index());
            }
            _ => {}
        }
    }
    if compressor_nodes.is_empty() || exv_nodes.is_empty() {
        return None;
    }

    // Saturation pressures from the accurate CoolProp path. (The generic
    // SmartInitializer estimate can land near the critical point for high
    // condensing temperatures, which seeds the condenser onto a singular
    // saturation derivative — hence we resolve them directly here.)
    let p_cond_pa = backend
        .saturation_pressure_t(fluid.clone(), t_cond_k)
        .ok()?;
    let p_evap_pa = backend
        .saturation_pressure_t(fluid.clone(), t_evap_k)
        .ok()?;

    // Saturation-enthalpy anchors at the design regimes.
    let h_liq = backend
        .saturation_enthalpy_t(fluid.clone(), t_cond_k, 0.0)
        .ok()?;
    let h_vap_cond = backend
        .saturation_enthalpy_t(fluid.clone(), t_cond_k, 1.0)
        .ok()?;
    let h_vap_evap = backend
        .saturation_enthalpy_t(fluid.clone(), t_evap_k, 1.0)
        .ok()?;
    let h_dis = h_vap_cond + 25_000.0; // superheated discharge (compression margin)
    let h_suc = backend
        .property(
            fluid,
            entropyk_fluids::Property::Enthalpy,
            entropyk_fluids::FluidState::from_pt(
                entropyk::Pressure::from_pascals(p_evap_pa),
                entropyk::Temperature::from_kelvin(t_evap_k + superheat_k),
            ),
        )
        .unwrap_or(h_vap_evap + superheat_k.max(1.0) * 1_000.0);
    let h_liq_sub = h_liq - 5_000.0; // subcooled liquid after the condenser

    // Successor map for the directed refrigerant loop: source node → (edge, target).
    // Port-aware (Modelica 4-port HX): only follow edges leaving from the
    // refrigerant outlet (source_port ≤ 1), never a secondary_outlet (port 3);
    // otherwise the walk would wander into the water/air circuit.
    let mut succ: std::collections::HashMap<usize, (EdgeIndex, NodeIndex)> =
        std::collections::HashMap::new();
    let mut edge_count = 0usize;
    for e in system.edge_indices() {
        if let Some((src, tgt)) = system.edge_endpoints(e) {
            let src_port = system
                .graph()
                .edge_weight(e)
                .map(|w| w.source_port)
                .unwrap_or(1);
            if src_port <= 1 {
                succ.insert(src.index(), (e, tgt));
            }
            edge_count += 1;
        }
    }

    let mut state = vec![0.0_f64; n_state];
    let m_seed = entropyk_solver::system::DEFAULT_MASS_FLOW_SEED_KG_S;
    let mut any_seeded = false;

    // Seed each compressor-rooted loop independently.
    for start in compressor_nodes {
        let mut cur = start;
        let mut side_low = false;
        let mut past_condenser = false;
        let mut past_evaporator = false;
        for _ in 0..edge_count {
            let (edge, tgt) = match succ.get(&cur.index()) {
                Some(v) => *v,
                None => break, // dangling node: skip this loop
            };
            // Crossing the expansion valve drops us onto the low-pressure side.
            if exv_nodes.contains(&cur.index()) {
                side_low = true;
            }
            // Track HX crossings so inline nodes (Anchor, HeatSource, probes)
            // inserted anywhere in the loop inherit the correct regime:
            // discharge stays superheated up to the condenser inlet, suction
            // stays superheated from the evaporator outlet to the compressor.
            if condenser_nodes.contains(&cur.index()) {
                past_condenser = true;
            }
            if evaporator_nodes.contains(&cur.index()) {
                past_evaporator = true;
            }
            // Stratified pressure: an edge ENTERING a heat exchanger sits
            // upstream of its ΔP (P_in = P_out + ΔP_est); every other edge
            // keeps the side's saturation anchor. Uniform when ΔP_est = 0.
            let p = match hx_dp_est.get(&tgt.index()) {
                Some(&dp) if !side_low && condenser_nodes.contains(&tgt.index()) => p_cond_pa + dp,
                Some(&dp) if side_low && evaporator_nodes.contains(&tgt.index()) => p_evap_pa + dp,
                _ => {
                    if side_low {
                        p_evap_pa
                    } else {
                        p_cond_pa
                    }
                }
            };
            let (h, regime) = if side_low {
                if past_evaporator || tgt == start {
                    (
                        h_suc,
                        entropyk_solver::InitializationRegime::LowPressureVapor,
                    )
                } else {
                    (
                        h_liq,
                        entropyk_solver::InitializationRegime::LowPressureTwoPhase,
                    )
                }
            } else if cur == start || !past_condenser {
                (
                    h_dis,
                    entropyk_solver::InitializationRegime::HighPressureVapor,
                )
            } else {
                (
                    h_liq_sub,
                    entropyk_solver::InitializationRegime::HighPressureLiquid,
                )
            };
            let (m_idx, p_idx, h_idx) = system.edge_state_indices_full(edge);
            state[m_idx] = m_seed;
            state[p_idx] = p;
            state[h_idx] = h;
            diagnostics.push(
                format!("edge:{}", edge.index()),
                regime,
                entropyk_solver::StartValues {
                    pressure_pa: Some(p),
                    enthalpy_j_kg: Some(h),
                    mass_flow_kg_s: Some(m_seed),
                    temperature_k: None,
                    vapor_quality: matches!(
                        regime,
                        entropyk_solver::InitializationRegime::LowPressureTwoPhase
                    )
                    .then_some(0.0),
                },
            );
            any_seeded = true;
            cur = tgt;
            if cur == start {
                break; // completed this loop
            }
        }
    }

    if !any_seeded {
        return None;
    }

    // Seed the remaining (secondary hydraulic/air) edges. Left at zero they
    // make the 4-port ε-NTU rows singular (C_sec = ṁ·cp = 0 ⇒ all-zero row).
    // The boundary source/sink residuals are linear in (P, h), so Newton pins
    // the exact values in one step from this generic liquid-loop guess.
    for e in system.edge_indices() {
        let (m_idx, p_idx, h_idx) = system.edge_state_indices_full(e);
        if state[p_idx] == 0.0 {
            state[m_idx] = m_seed;
            state[p_idx] = 2.0e5; // ~2 bar hydraulic loop
            state[h_idx] = 6.0e4; // ~15 °C water / ~20 °C moist air
            diagnostics.push(
                format!("edge:{}", e.index()),
                entropyk_solver::InitializationRegime::Secondary,
                entropyk_solver::StartValues {
                    pressure_pa: Some(2.0e5),
                    enthalpy_j_kg: Some(6.0e4),
                    mass_flow_kg_s: Some(m_seed),
                    temperature_k: None,
                    vapor_quality: None,
                },
            );
        }
    }

    Some(state)
}

fn seed_from_boundary_conditions(
    config: &ScenarioConfig,
    system: &entropyk::System,
    backend: &dyn entropyk_fluids::FluidBackend,
    state: &mut [f64],
    diagnostics: &mut entropyk_solver::InitializationDiagnostics,
) {
    let config_edges: Vec<&crate::config::EdgeConfig> = config
        .circuits
        .iter()
        .filter(|c| c.enabled)
        .flat_map(|c| c.edges.iter())
        .collect();
    let system_edges: Vec<_> = system.edge_indices().collect();

    for (edge_cfg, edge_idx) in config_edges.into_iter().zip(system_edges) {
        let Some((fluid, p_pa, h_j_kg, m_kg_s)) = boundary_seed_for_edge(config, edge_cfg, backend)
        else {
            continue;
        };
        if !fluid.is_empty() {
            let (m_idx, p_idx, h_idx) = system.edge_state_indices_full(edge_idx);
            if m_idx < state.len() && p_idx < state.len() && h_idx < state.len() {
                state[m_idx] = m_kg_s;
                state[p_idx] = p_pa;
                state[h_idx] = h_j_kg;
                diagnostics.push(
                    format!("edge:{}", edge_idx.index()),
                    entropyk_solver::InitializationRegime::Boundary,
                    entropyk_solver::StartValues {
                        pressure_pa: Some(p_pa),
                        enthalpy_j_kg: Some(h_j_kg),
                        mass_flow_kg_s: Some(m_kg_s),
                        temperature_k: None,
                        vapor_quality: None,
                    },
                );
            }
        }
    }
}

fn boundary_seed_for_edge(
    config: &ScenarioConfig,
    edge: &crate::config::EdgeConfig,
    backend: &dyn entropyk_fluids::FluidBackend,
) -> Option<(String, f64, f64, f64)> {
    let from_name = edge.from.split(':').next().unwrap_or("");
    let to_name = edge.to.split(':').next().unwrap_or("");

    find_component_config(config, from_name)
        .and_then(|comp| boundary_seed_from_component(comp, backend, true))
        .or_else(|| {
            find_component_config(config, to_name)
                .and_then(|comp| boundary_seed_from_component(comp, backend, false))
        })
}

fn find_component_config<'a>(
    config: &'a ScenarioConfig,
    name: &str,
) -> Option<&'a crate::config::ComponentConfig> {
    config
        .circuits
        .iter()
        .filter(|c| c.enabled)
        .flat_map(|c| c.components.iter())
        .find(|comp| comp.name == name)
}

fn boundary_seed_from_component(
    comp: &crate::config::ComponentConfig,
    backend: &dyn entropyk_fluids::FluidBackend,
    outgoing: bool,
) -> Option<(String, f64, f64, f64)> {
    let params = &comp.params;
    match comp.component_type.as_str() {
        "BrineSource" if outgoing => {
            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or("Water");
            let p_bar = params.get("p_set_bar").and_then(|v| v.as_f64())?;
            let t_c = params.get("t_set_c").and_then(|v| v.as_f64())?;
            let h = backend_enthalpy_pt(backend, fluid, p_bar, t_c)?;
            let m = params
                .get("m_flow_kg_s")
                .and_then(|v| v.as_f64())
                .unwrap_or(entropyk_solver::system::DEFAULT_MASS_FLOW_SEED_KG_S);
            Some((fluid.to_string(), p_bar * 1.0e5, h, m))
        }
        "BrineSink" if !outgoing => {
            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or("Water");
            let p_bar = params.get("p_back_bar").and_then(|v| v.as_f64())?;
            let t_c = params
                .get("t_set_c")
                .and_then(|v| v.as_f64())
                .unwrap_or(20.0);
            let h = backend_enthalpy_pt(backend, fluid, p_bar, t_c)?;
            let m = params
                .get("m_flow_kg_s")
                .and_then(|v| v.as_f64())
                .unwrap_or(entropyk_solver::system::DEFAULT_MASS_FLOW_SEED_KG_S);
            Some((fluid.to_string(), p_bar * 1.0e5, h, m))
        }
        "AirSource" if outgoing => {
            let p_bar = params
                .get("p_set_bar")
                .and_then(|v| v.as_f64())
                .unwrap_or(1.01325);
            let t_c = params
                .get("t_dry_c")
                .and_then(|v| v.as_f64())
                .unwrap_or(20.0);
            let h = moist_air_enthalpy_j_kg(t_c, params.get("rh").and_then(|v| v.as_f64()));
            let m = params
                .get("m_flow_kg_s")
                .and_then(|v| v.as_f64())
                .unwrap_or(entropyk_solver::system::DEFAULT_MASS_FLOW_SEED_KG_S);
            Some(("Air".to_string(), p_bar * 1.0e5, h, m))
        }
        "AirSink" if !outgoing => {
            let p_bar = params
                .get("p_back_bar")
                .and_then(|v| v.as_f64())
                .unwrap_or(1.01325);
            let t_c = params
                .get("t_back_c")
                .and_then(|v| v.as_f64())
                .unwrap_or(20.0);
            let h = moist_air_enthalpy_j_kg(t_c, params.get("rh_back").and_then(|v| v.as_f64()));
            let m = params
                .get("m_flow_kg_s")
                .and_then(|v| v.as_f64())
                .unwrap_or(entropyk_solver::system::DEFAULT_MASS_FLOW_SEED_KG_S);
            Some(("Air".to_string(), p_bar * 1.0e5, h, m))
        }
        _ => None,
    }
}

fn backend_enthalpy_pt(
    backend: &dyn entropyk_fluids::FluidBackend,
    fluid: &str,
    p_bar: f64,
    t_c: f64,
) -> Option<f64> {
    backend
        .property(
            entropyk_fluids::FluidId::new(fluid),
            entropyk_fluids::Property::Enthalpy,
            entropyk_fluids::FluidState::from_pt(
                entropyk_core::Pressure::from_bar(p_bar),
                entropyk_core::Temperature::from_celsius(t_c),
            ),
        )
        .ok()
}

fn moist_air_enthalpy_j_kg(t_dry_c: f64, rh_percent: Option<f64>) -> f64 {
    let rh = rh_percent.unwrap_or(50.0).clamp(0.0, 100.0) / 100.0;
    let w = moist_air_humidity_ratio(t_dry_c, rh, 101.325);
    (1.006 * t_dry_c + w * (2501.0 + 1.86 * t_dry_c)) * 1000.0
}

/// Humidity ratio W [kg_v/kg_da] (Magnus + ideal mixture), `p_total_kpa` absolute.
fn moist_air_humidity_ratio(t_dry_c: f64, rh_fraction: f64, p_total_kpa: f64) -> f64 {
    let rh = rh_fraction.clamp(0.0, 1.0);
    let p_ws_kpa = 0.61078 * ((17.2694 * t_dry_c) / (t_dry_c + 237.3)).exp();
    let p_w_kpa = rh * p_ws_kpa;
    let p_da_kpa = (p_total_kpa - p_w_kpa).max(1e-6);
    0.621_945 * p_w_kpa / p_da_kpa
}

/// Optional W from HX params: `secondary_t_dry_c` + `secondary_rh` [%].
fn moist_air_w_from_params(
    params: &std::collections::HashMap<String, serde_json::Value>,
) -> Option<f64> {
    let t_c = params.get("secondary_t_dry_c").and_then(|v| v.as_f64())?;
    let rh_pct = params.get("secondary_rh").and_then(|v| v.as_f64())?;
    let p_bar = params
        .get("secondary_p_bar")
        .and_then(|v| v.as_f64())
        .unwrap_or(1.01325);
    Some(moist_air_humidity_ratio(t_c, rh_pct / 100.0, p_bar * 100.0))
}

fn get_param_string(
    params: &std::collections::HashMap<String, serde_json::Value>,
    key: &str,
) -> CliResult<String> {
    params
        .get(key)
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| CliError::Config(format!("Missing required parameter: {}", key)))
}

fn parse_side_conditions(
    params: &std::collections::HashMap<String, serde_json::Value>,
    prefix: &str,
) -> CliResult<entropyk::HxSideConditions> {
    use entropyk::{HxSideConditions, MassFlow, Pressure, Temperature};

    let fluid = get_param_string(params, &format!("{}_fluid", prefix))?;
    let t_inlet_c = get_param_f64(params, &format!("{}_t_inlet_c", prefix))?;
    let pressure_bar = params
        .get(&format!("{}_pressure_bar", prefix))
        .and_then(|v| v.as_f64())
        .unwrap_or(1.0);
    let mass_flow = params
        .get(&format!("{}_mass_flow_kg_s", prefix))
        .and_then(|v| v.as_f64())
        .unwrap_or(0.1);

    Ok(HxSideConditions::new(
        Temperature::from_celsius(t_inlet_c),
        Pressure::from_bar(pressure_bar),
        MassFlow::from_kg_per_s(mass_flow),
        &fluid,
    )?)
}

/// Builds BPHX geometry from either direct manufacturer data (`dh_m`,
/// `area_m2`) or a detailed plate pack. Legacy configs without detailed
/// geometry retain the historical 3 mm / 0.5 m² defaults.
fn bphx_geometry_from_params(
    params: &std::collections::HashMap<String, serde_json::Value>,
    exchanger_type: entropyk_components::heat_exchanger::BphxType,
) -> CliResult<entropyk_components::heat_exchanger::BphxGeometry> {
    use entropyk_components::heat_exchanger::BphxGeometry;
    let n_plates_raw = params
        .get("n_plates")
        .and_then(|v| v.as_u64())
        .unwrap_or(20);
    if n_plates_raw > u32::MAX as u64 {
        return Err(CliError::Config(format!(
            "BphxGeometry: n_plates too large (got {}, max {})",
            n_plates_raw,
            u32::MAX
        )));
    }
    let n_plates = n_plates_raw as u32;
    if n_plates == 0 {
        return Err(CliError::Config(
            "BphxGeometry: n_plates must be > 0".into(),
        ));
    }

    let direct_dh = params.get("dh_m").and_then(|v| v.as_f64());
    let direct_area = params.get("area_m2").and_then(|v| v.as_f64());
    match (direct_dh, direct_area) {
        (Some(dh), Some(area)) => {
            if dh <= 0.0 {
                return Err(CliError::Config(format!(
                    "BphxGeometry: dh_m must be > 0 (got {:.6} m)",
                    dh
                )));
            }
            if area <= 0.0 {
                return Err(CliError::Config(format!(
                    "BphxGeometry: area_m2 must be > 0 (got {:.4} m²)",
                    area
                )));
            }
            return Ok(
                BphxGeometry::from_dh_area(dh, area, n_plates).with_exchanger_type(exchanger_type)
            );
        }
        (Some(_), None) | (None, Some(_)) => {
            return Err(CliError::Config(
                "BphxGeometry: dh_m and area_m2 must be provided together".into(),
            ));
        }
        (None, None) => {}
    }

    let detailed_keys = [
        "plate_length_m",
        "plate_width_m",
        "plate_thickness_mm",
        "channel_spacing_mm",
        "chevron_angle_deg",
        "corrugation_pitch_mm",
    ];
    if !detailed_keys.iter().any(|key| params.contains_key(*key)) {
        return Ok(
            BphxGeometry::from_dh_area(0.003, 0.5, n_plates).with_exchanger_type(exchanger_type)
        );
    }

    let positive = |key: &str, default: f64| -> CliResult<f64> {
        let value = params.get(key).and_then(|v| v.as_f64()).unwrap_or(default);
        if value <= 0.0 {
            return Err(CliError::Config(format!(
                "BphxGeometry: {key} must be > 0 (got {value:.6})"
            )));
        }
        Ok(value)
    };
    let plate_length_m = positive("plate_length_m", 0.25)?;
    let plate_width_m = positive("plate_width_m", 0.05)?;
    let plate_thickness_m = positive("plate_thickness_mm", 0.6)? / 1000.0;
    let channel_spacing_m = positive("channel_spacing_mm", 1.5)? / 1000.0;
    let corrugation_pitch_m = positive("corrugation_pitch_mm", 3.0)? / 1000.0;
    let chevron_angle = params
        .get("chevron_angle_deg")
        .and_then(|v| v.as_f64())
        .unwrap_or(60.0);
    if !(BphxGeometry::MIN_CHEVRON_ANGLE..=BphxGeometry::MAX_CHEVRON_ANGLE).contains(&chevron_angle)
    {
        return Err(CliError::Config(format!(
            "BphxGeometry: chevron_angle_deg must be in [{:.0}, {:.0}] (got {:.2})",
            BphxGeometry::MIN_CHEVRON_ANGLE,
            BphxGeometry::MAX_CHEVRON_ANGLE,
            chevron_angle
        )));
    }

    BphxGeometry::new(n_plates)
        .with_plate_dimensions(plate_length_m, plate_width_m)
        .with_plate_thickness(plate_thickness_m)
        .with_channel_spacing(channel_spacing_m)
        .with_chevron_angle(chevron_angle)
        .with_corrugation_pitch(corrugation_pitch_m)
        .with_exchanger_type(exchanger_type)
        .build()
        .map_err(|error| CliError::Config(format!("BphxGeometry: {error}")))
}

fn bphx_correlation_from_params(
    params: &std::collections::HashMap<String, serde_json::Value>,
    component_name: &str,
) -> CliResult<Option<entropyk_components::heat_exchanger::BphxCorrelation>> {
    use entropyk_components::heat_exchanger::BphxCorrelation;

    let Some(correlation) = params.get("correlation").and_then(|value| value.as_str()) else {
        return Ok(None);
    };
    let correlation = match correlation.to_lowercase().as_str() {
        "shah1979" => BphxCorrelation::Shah1979,
        "shah2021" => BphxCorrelation::Shah2021,
        "longo2004" => BphxCorrelation::Longo2004,
        other => {
            return Err(CliError::Config(format!(
                "{component_name}: unsupported correlation '{other}'. Use 'Longo2004', 'Shah1979', or 'Shah2021'."
            )));
        }
    };
    Ok(Some(correlation))
}

/// Parse BPHX frictional `dp_correlation` / `DpCorrelation` (independent of HTC).
///
/// Default when absent: `SimplifiedChannel`. Unknown ids are rejected (no silent isobaric).
fn bphx_dp_correlation_from_params(
    params: &std::collections::HashMap<String, serde_json::Value>,
    component_name: &str,
) -> CliResult<entropyk_components::heat_exchanger::BphxDpCorrelation> {
    use entropyk_components::heat_exchanger::BphxDpCorrelation;

    let Some(value) = params
        .get("dp_correlation")
        .or_else(|| params.get("DpCorrelation"))
        .or_else(|| params.get("dpCorrelation"))
    else {
        return Ok(BphxDpCorrelation::default());
    };
    let Some(raw) = value.as_str() else {
        return Err(CliError::Config(format!(
            "{component_name}: dp_correlation must be a string (got {value})"
        )));
    };
    BphxDpCorrelation::parse(raw)
        .map_err(|msg| CliError::Config(format!("{component_name}: {msg}")))
}

/// Extract calibration Z-factors for BphxEvaporator/BphxCondenser from JSON params.
///
/// Errors if `ua_nominal == 0` and an explicit `ua` override is provided (geometry is
/// likely invalid). Warns if both `ua` and `z_ua` are provided simultaneously.
fn bphx_calib_from_params(
    params: &std::collections::HashMap<String, serde_json::Value>,
    ua_nominal: f64,
) -> CliResult<entropyk_core::Calib> {
    use entropyk_core::Calib;
    let config_ua = params.get("ua").and_then(|v| v.as_f64());
    let explicit_z_ua = params
        .get("z_ua")
        .or_else(|| params.get("Z_UA"))
        .or_else(|| params.get("f_ua"))
        .and_then(|v| v.as_f64());

    // Precedence (DoF-safe): live `z_ua` wins over absolute `ua`.
    // Baking `ua` into a frozen Calib factor while an embedding frees `z_ua`
    // zeros ∂Q/∂z_ua → singular Jacobian. Modelica-style: modifier owns the unknown.
    if config_ua.is_some() && explicit_z_ua.is_some() {
        tracing::warn!(
            "BphxExchanger: both 'ua' and 'z_ua' provided — 'z_ua' takes precedence, 'ua' ignored"
        );
    }

    let z_ua = if let Some(z) = explicit_z_ua {
        z
    } else if let Some(u) = config_ua {
        if u < 0.0 {
            return Err(CliError::Config(format!(
                "BphxExchanger: ua must be >= 0 (got {:.2} W/K)",
                u
            )));
        }
        if ua_nominal > 0.0 {
            u / ua_nominal
        } else {
            return Err(CliError::Config(
                "BphxExchanger: ua_nominal is zero — cannot compute z_ua from explicit 'ua' override. Check geometry parameters.".into(),
            ));
        }
    } else {
        1.0
    };

    if z_ua <= 0.0 {
        return Err(CliError::Config(format!(
            "BphxExchanger: z_ua must be > 0 (got {:.4})",
            z_ua
        )));
    }

    let z_dp = params
        .get("z_dp")
        .or_else(|| params.get("Z_dpc"))
        .or_else(|| params.get("f_dp"))
        .and_then(|v| v.as_f64())
        .unwrap_or(1.0);
    if z_dp <= 0.0 {
        return Err(CliError::Config(format!(
            "BphxExchanger: z_dp must be > 0 (got {:.4})",
            z_dp
        )));
    }

    Ok(Calib {
        z_flow: 1.0,
        z_flow_eco: 1.0,
        z_dp,
        z_ua,
        z_power: 1.0,
        z_etav: 1.0,
        f_w: 1.0,
        calibration_source: None,
    })
}

/// Calibration factors for legacy Condenser / Evaporator (`ua` is nominal, not override).
fn hx_calib_from_params(
    params: &std::collections::HashMap<String, serde_json::Value>,
) -> CliResult<entropyk_core::Calib> {
    use entropyk_core::Calib;
    let z_ua = params
        .get("z_ua")
        .or_else(|| params.get("Z_UA"))
        .or_else(|| params.get("f_ua"))
        .and_then(|v| v.as_f64())
        .unwrap_or(1.0);
    if z_ua <= 0.0 {
        return Err(CliError::Config(format!(
            "z_ua must be > 0 (got {:.4})",
            z_ua
        )));
    }
    let z_dp = params
        .get("z_dp")
        .or_else(|| params.get("Z_dpc"))
        .or_else(|| params.get("f_dp"))
        .and_then(|v| v.as_f64())
        .unwrap_or(1.0);
    if z_dp <= 0.0 {
        return Err(CliError::Config(format!(
            "z_dp must be > 0 (got {:.4})",
            z_dp
        )));
    }
    Ok(Calib {
        z_flow: 1.0,
        z_flow_eco: 1.0,
        z_dp,
        z_ua,
        z_power: 1.0,
        z_etav: 1.0,
        f_w: 1.0,
        calibration_source: None,
    })
}

/// Creates a pair of connected ports for components that need them (screw, MCHX, fan...).
///
/// Ports are initialised at the given pressure and enthalpy. Both ports are connected
/// to each other — the first port is returned as the `ConnectedPort`.
fn make_connected_port(fluid: &str, p_bar: f64, h_kj_kg: f64) -> entropyk::ConnectedPort {
    use entropyk::{ComponentFluidId, Enthalpy, Port, Pressure};
    let a = Port::new(
        ComponentFluidId::new(fluid),
        Pressure::from_bar(p_bar),
        Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
    );
    let b = Port::new(
        ComponentFluidId::new(fluid),
        Pressure::from_bar(p_bar),
        Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
    );
    a.connect(b).expect("port connection ok").0
}

/// Returns true when the fluid name designates an incompressible secondary
/// fluid (water, glycol, brine…), used to pick the right flow-junction
/// constructor. Anything else is treated as a compressible refrigerant.
fn is_incompressible_fluid(fluid: &str) -> bool {
    let f = fluid.to_lowercase();
    f.starts_with("water")
        || f.starts_with("glycol")
        || f.starts_with("brine")
        || f.starts_with("seawater")
        || f.starts_with("ethyleneglycol")
        || f.starts_with("propyleneglycol")
        || f.starts_with("incompressible")
        || f.starts_with("meg")
        || f.starts_with("peg")
        || f.starts_with("incomp::")
}

/// Builds the actuator `BoundedVariableId` string for a control loop. The id
/// MUST end with a canonical Z-factor suffix (`z_flow`, `z_dp`, …) so the solver
/// can wire it to the component's `CalibIndices` during `finalize()`.
fn saturated_actuator_id(component: &str, factor: &str) -> String {
    // The solver wires a saturated-controller actuator whose id ends in
    // `actuator` to the component's generic `CalibIndices.actuator` slot
    // (arch-6 physical actuator, e.g. the expansion-valve opening). Map the
    // user-facing physical factors (`opening`, `injection`) onto that suffix so
    // a controls[] loop can drive the owning component's generic actuator slot;
    // every other factor is normalized to canonical z_*.
    let factor = factor.trim();
    let suffix = if matches!(factor, "opening" | "injection") {
        "actuator".to_string()
    } else {
        entropyk_core::normalize_factor_name(factor)
            .unwrap_or(factor)
            .to_string()
    };
    format!("{}__{}", component, suffix)
}

/// Maps a config output string to a solver `ComponentOutput`.
fn parse_component_output(
    component: &str,
    output: &str,
) -> Result<entropyk_solver::inverse::ComponentOutput, String> {
    use entropyk_solver::inverse::ComponentOutput;
    let id = component.to_string();
    let normalized = output.trim().to_ascii_lowercase().replace(['_', '-'], "");
    let out = match normalized.as_str() {
        "capacity" => ComponentOutput::Capacity { component_id: id },
        "heattransferrate" | "heatrate" | "duty" => {
            ComponentOutput::HeatTransferRate { component_id: id }
        }
        "superheat" => ComponentOutput::Superheat { component_id: id },
        "subcooling" => ComponentOutput::Subcooling { component_id: id },
        "saturationtemperature" | "sst" | "sdt" => {
            ComponentOutput::SaturationTemperature { component_id: id }
        }
        "massflowrate" | "massflow" => ComponentOutput::MassFlowRate { component_id: id },
        "pressure" => ComponentOutput::Pressure { component_id: id },
        "temperature" => ComponentOutput::Temperature { component_id: id },
        other => {
            return Err(format!(
                "unknown measure output '{other}' (expected capacity, heatTransferRate, \
                 superheat, subcooling, saturationTemperature, massFlowRate, pressure, temperature)"
            ))
        }
    };
    Ok(out)
}

/// Modelica Z-factor embedding: one `Constraint` (output − value) + one
/// `BoundedVariable` (the free Z-factor). Bounded var id uses
/// `{component}__{factor}` so `finalize()` wires `CalibIndices`.
fn build_model_embedding(
    emb: &crate::config::EmbeddingConfig,
) -> Result<
    (
        entropyk_solver::inverse::Constraint,
        entropyk_solver::inverse::BoundedVariable,
        entropyk_solver::inverse::BoundedVariableId,
    ),
    String,
> {
    use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, Constraint, ConstraintId};

    let factor = emb.unknown.factor.trim();
    if entropyk_core::normalize_factor_name(factor).is_none() {
        return Err(format!(
            "unknown Z-factor '{factor}' (expected z_ua, z_dp, z_flow, z_power, z_etav, f_w, …)"
        ));
    }
    let output = parse_component_output(&emb.equation.component, &emb.equation.output)?;
    let unknown_id =
        BoundedVariableId::new(saturated_actuator_id(&emb.unknown.component, factor));
    let bounded_var = BoundedVariable::with_component(
        unknown_id.clone(),
        &emb.unknown.component,
        emb.unknown.start,
        emb.unknown.min,
        emb.unknown.max,
    )
    .map_err(|e| format!("invalid unknown bounds: {e:?}"))?;
    let constraint = Constraint::new(
        ConstraintId::new(emb.id.clone()),
        output,
        emb.equation.value,
    );
    Ok((constraint, bounded_var, unknown_id))
}

/// Algebraic model equation (`lhs − rhs = 0`) + free Z-factor unknown.
///
/// Form A: `lhs` is `component.z_*` → unknown inferred, bounds from `start/min/max`.
/// Form B: explicit `unknown` block (Probe target: `lhs=probe.Tsat`, `rhs=315`).
fn build_model_equation(
    eq: &crate::config::EquationConfig,
) -> Result<
    (
        entropyk_solver::inverse::AlgebraicEquation,
        entropyk_solver::inverse::BoundedVariable,
        entropyk_solver::inverse::BoundedVariableId,
    ),
    String,
> {
    use entropyk_solver::inverse::{
        parse_expr, AlgebraicEquation, BoundedVariable, BoundedVariableId, Expr,
    };

    let lhs = parse_expr(&eq.lhs).map_err(|e| format!("lhs: {e}"))?;
    let rhs = parse_expr(&eq.rhs).map_err(|e| format!("rhs: {e}"))?;

    let (comp, factor, start, min, max) = if let Some(u) = eq.unknown.as_ref() {
        let factor = u.factor.trim();
        if entropyk_core::normalize_factor_name(factor).is_none() {
            return Err(format!(
                "unknown Z-factor '{factor}' (expected z_ua, z_dp, z_flow, z_power, z_etav, f_w, …)"
            ));
        }
        (
            u.component.clone(),
            factor.to_string(),
            u.start,
            u.min,
            u.max,
        )
    } else if let Expr::Ref { component, field } = &lhs {
        let canon = entropyk_core::normalize_factor_name(field).ok_or_else(|| {
            format!(
                "lhs '{component}.{field}' is not a Z-factor — set equations[].unknown \
                 (e.g. Probe target form) or use lhs = component.z_ua"
            )
        })?;
        (
            component.clone(),
            canon.to_string(),
            eq.start,
            eq.min,
            eq.max,
        )
    } else {
        return Err(
            "lhs must be component.z_* or provide equations[].unknown for the free Z-factor"
                .into(),
        );
    };

    let unknown_id = BoundedVariableId::new(saturated_actuator_id(&comp, &factor));
    let bounded_var = BoundedVariable::with_component(unknown_id.clone(), &comp, start, min, max)
        .map_err(|e| format!("invalid unknown bounds: {e:?}"))?;
    let algebraic = AlgebraicEquation {
        id: eq.id.clone(),
        lhs,
        rhs,
    };
    Ok((algebraic, bounded_var, unknown_id))
}

fn build_saturated_control(
    control: &crate::config::ControlConfig,
) -> Result<
    (
        entropyk_solver::inverse::BoundedVariable,
        entropyk_solver::inverse::SaturatedController,
    ),
    String,
> {
    use entropyk_solver::inverse::{
        BoundedVariable, BoundedVariableId, ConstraintId, SaturatedController, Saturation,
    };

    if control.control_type != "SaturatedController" {
        return Err(format!(
            "unsupported control type '{}' (only 'SaturatedController')",
            control.control_type
        ));
    }

    let factor = control.actuator.factor.trim();
    if entropyk_core::normalize_factor_name(factor).is_some() {
        return Err(format!(
            "factor '{factor}' is a Z-factor — use embeddings[], not SaturatedController"
        ));
    }
    if factor != "injection" && factor != "opening" {
        return Err(format!(
            "unknown regulation actuator '{factor}' (expected opening, injection)"
        ));
    }

    let output = parse_component_output(&control.measure.component, &control.measure.output)?;
    let actuator_id =
        BoundedVariableId::new(saturated_actuator_id(&control.actuator.component, factor));

    let bounded_var = BoundedVariable::with_component(
        actuator_id.clone(),
        &control.actuator.component,
        control.actuator.initial,
        control.actuator.min,
        control.actuator.max,
    )
    .map_err(|e| format!("invalid actuator bounds: {e:?}"))?;

    let saturation = match control.smooth_eps {
        Some(eps) => Saturation::Smooth { eps },
        None => Saturation::Hard,
    };

    let mut controller = SaturatedController::new(
        ConstraintId::new(control.id.clone()),
        output,
        actuator_id,
        control.target,
        control.actuator.min,
        control.actuator.max,
    )
    .map_err(|e| format!("invalid controller: {e:?}"))?
    .with_gain(control.gain)
    .map_err(|e| format!("invalid gain: {e:?}"))?
    .with_band(control.band)
    .map_err(|e| format!("invalid band: {e:?}"))?
    .with_saturation(saturation);

    // Override / selector network: the primary (measure, target, gain) becomes
    // the first objective; each config objective is folded in via its combinator.
    if let Some(alpha) = control.alpha {
        controller = controller.with_alpha(alpha);
    }

    if !control.objectives.is_empty() {
        use entropyk_solver::inverse::{Combine, Objective};
        let mut objectives = vec![Objective::new(
            parse_component_output(&control.measure.component, &control.measure.output)?,
            control.target,
            control.gain,
            Combine::Min, // combinator of the first objective is unused (it seeds the fold)
        )];
        for oc in &control.objectives {
            let out = parse_component_output(&oc.component, &oc.output)?;
            let combine = match oc.combine.trim().to_ascii_lowercase().as_str() {
                "min" => Combine::Min,
                "max" => Combine::Max,
                other => {
                    return Err(format!(
                        "unknown combine '{other}' (expected 'min' or 'max')"
                    ))
                }
            };
            objectives.push(Objective::new(out, oc.setpoint, oc.gain, combine));
        }
        controller = controller.with_objectives(objectives);
    }

    Ok((bounded_var, controller))
}

/// True when the EXV JSON requests a physical orifice with a **fixed** opening.
/// Requires explicit `orifice_kv` (do not infer from `opening` alone — the UI
/// merges a default opening onto imported examples).
fn exv_uses_fixed_orifice(params: &std::collections::HashMap<String, serde_json::Value>) -> bool {
    let kv = params.get("orifice_kv").and_then(|v| v.as_f64());
    if kv.is_none() {
        return false;
    }
    let opening = params.get("opening").and_then(|v| v.as_f64());
    let fix = params
        .get("fix_opening")
        .and_then(|v| v.as_bool())
        .unwrap_or(true); // Fixed opening is the UI default when orifice is on
    fix && opening.is_some()
}

/// Free-actuator orifice path (opening unknown). Requires explicit `orifice_kv`.
fn exv_orifice_opening_is_free(
    params: &std::collections::HashMap<String, serde_json::Value>,
) -> bool {
    params.get("orifice_kv").and_then(|v| v.as_f64()).is_some() && !exv_uses_fixed_orifice(params)
}

/// Create a component from configuration.
fn create_component(
    component_config: &crate::config::ComponentConfig,
    _primary_fluid: &entropyk::FluidId,
    backend: Arc<dyn entropyk_fluids::FluidBackend>,
    auto_t_cond_k: Option<f64>,
    meter_mass_flow_via_exv: bool,
) -> CliResult<Box<dyn entropyk::Component>> {
    use entropyk::{Condenser, Evaporator, HeatExchanger};
    use entropyk_components::heat_exchanger::{FlowConfiguration, LmtdModel};

    let params = &component_config.params;
    let component_type = component_config.component_type.as_str();

    match component_type {
        // ── NEW: ScrewEconomizerCompressor ─────────────────────────────────────
        "ScrewEconomizerCompressor" | "ScrewCompressor" => {
            use entropyk::{Polynomial2D, ScrewEconomizerCompressor, ScrewPerformanceCurves};

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str());

            let nominal_freq = params
                .get("nominal_frequency_hz")
                .and_then(|v| v.as_f64())
                .unwrap_or(50.0);

            let eta_mech = params
                .get("mechanical_efficiency")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.92);

            // Economizer fraction (default 12%)
            let eco_frac_param = params
                .get("economizer_fraction")
                .and_then(|v| v.as_f64());

            // Task 3.4: Built-in manufacturer curve presets.
            // Presets set default polynomial coefficients; explicit params override them.
            // Available: "bitzer_generic_200kw", "grasso_generic_200kw"
            let preset = params.get("preset").and_then(|v| v.as_str()).unwrap_or("");
            let (
                preset_mf_a00,
                preset_mf_a10,
                preset_mf_a01,
                preset_mf_a11,
                preset_pw_b00,
                preset_pw_b10,
                preset_pw_b01,
                preset_pw_b11,
                preset_eco_frac,
            ) = match preset {
                "bitzer_generic_200kw" => {
                    // Bitzer screw ~200 kW R134a, SST=-5..+10°C, SDT=+35..+55°C
                    // ṁ_suc [kg/s] ≈ 1.35 + 0.004·SST - 0.0025·SDT + 0.000012·SST·SDT
                    // W_shaft [W]  ≈ 58000 + 180·SST - 280·SDT + 0.4·SST·SDT
                    (1.35_f64, 0.004, -0.0025, 0.000_012, 58_000.0, 180.0, -280.0, 0.4, 0.13)
                }
                "grasso_generic_200kw" => {
                    // Grasso screw ~200 kW R134a, SST=-5..+10°C, SDT=+35..+55°C
                    // Similar range, slightly different power curve
                    (1.30_f64, 0.0035, -0.0022, 0.000_010, 60_000.0, 190.0, -310.0, 0.45, 0.11)
                }
                _ => {
                    // Default values (no preset)
                    (1.2_f64, 0.003, -0.002, 1e-5, 55_000.0, 200.0, -300.0, 0.5, 0.12)
                }
            };

            // Mass-flow polynomial coefficients (bilinear SST/SDT)
            // Explicit params override preset defaults
            let mf_a00 = params.get("mf_a00").and_then(|v| v.as_f64()).unwrap_or(preset_mf_a00);
            let mf_a10 = params.get("mf_a10").and_then(|v| v.as_f64()).unwrap_or(preset_mf_a10);
            let mf_a01 = params.get("mf_a01").and_then(|v| v.as_f64()).unwrap_or(preset_mf_a01);
            let mf_a11 = params.get("mf_a11").and_then(|v| v.as_f64()).unwrap_or(preset_mf_a11);

            // Power polynomial coefficients (bilinear)
            let pw_b00 = params.get("pw_b00").and_then(|v| v.as_f64()).unwrap_or(preset_pw_b00);
            let pw_b10 = params.get("pw_b10").and_then(|v| v.as_f64()).unwrap_or(preset_pw_b10);
            let pw_b01 = params.get("pw_b01").and_then(|v| v.as_f64()).unwrap_or(preset_pw_b01);
            let pw_b11 = params.get("pw_b11").and_then(|v| v.as_f64()).unwrap_or(preset_pw_b11);

            // Use preset eco fraction if not specified explicitly
            let eco_frac = eco_frac_param.unwrap_or(preset_eco_frac);

            let curves = ScrewPerformanceCurves::with_fixed_eco_fraction(
                Polynomial2D::bilinear(mf_a00, mf_a10, mf_a01, mf_a11),
                Polynomial2D::bilinear(pw_b00, pw_b10, pw_b01, pw_b11),
                eco_frac,
            );

            // Initial port conditions — use typical chiller values as defaults
            let p_suc = params.get("p_suction_bar").and_then(|v| v.as_f64()).unwrap_or(3.2);
            let h_suc = params.get("h_suction_kj_kg").and_then(|v| v.as_f64()).unwrap_or(400.0);
            let p_dis = params.get("p_discharge_bar").and_then(|v| v.as_f64()).unwrap_or(12.8);
            let h_dis = params.get("h_discharge_kj_kg").and_then(|v| v.as_f64()).unwrap_or(440.0);
            let p_eco = params.get("p_eco_bar").and_then(|v| v.as_f64()).unwrap_or(6.4);
            let h_eco = params.get("h_eco_kj_kg").and_then(|v| v.as_f64()).unwrap_or(260.0);

            let port_suc = make_connected_port(fluid, p_suc, h_suc);
            let port_dis = make_connected_port(fluid, p_dis, h_dis);
            let port_eco = make_connected_port(fluid, p_eco, h_eco);

            let mut comp = ScrewEconomizerCompressor::new(
                curves,
                fluid,
                nominal_freq,
                eta_mech,
                port_suc,
                port_dis,
                port_eco,
            )
            .map_err(CliError::Component)?;

            if let Some(freq_hz) = params.get("frequency_hz").and_then(|v| v.as_f64()) {
                comp.set_frequency_hz(freq_hz)
                    .map_err(CliError::Component)?;
            }

            // Volume index Vi + slide valve position (part-load Vi penalty).
            if let Some(vi) = params.get("volume_index").and_then(|v| v.as_f64()) {
                comp = comp.with_volume_index(vi);
            }
            if let Some(slide) = params
                .get("slide_valve_position")
                .or_else(|| params.get("slide_valve"))
                .and_then(|v| v.as_f64())
            {
                comp = comp.with_slide_valve(slide);
            }

            Ok(Box::new(comp))
        }

        // ── NEW: MchxCondenserCoil ─────────────────────────────────────────────
        "MchxCondenserCoil" | "MchxCoil" => {
            use entropyk::MchxCondenserCoil;

            // Optional explicit field vs fallback to params for backward compatibility
            let ua_kw_k = component_config.ua_nominal_kw_k.or_else(|| {
                params.get("ua_nominal_kw_k").and_then(|v| v.as_f64())
            }).unwrap_or(15.0); // Safe fallback 15 kW/K

            let ua_w_k = ua_kw_k * 1000.0;

            let n_air = component_config.n_air_exponent.or_else(|| {
                params.get("n_air_exponent").and_then(|v| v.as_f64())
            }).unwrap_or(0.5); // ASHRAE louvered-fin default

            let coil_index = params
                .get("coil_index")
                .and_then(|v| v.as_u64())
                .unwrap_or(0) as usize;

            let t_air_c = component_config.air_inlet_temp_c.or_else(|| {
                params.get("air_inlet_temp_c").and_then(|v| v.as_f64())
            }).unwrap_or(35.0);

            let fan_speed = component_config.fan_speed.or_else(|| {
                params.get("fan_speed").and_then(|v| v.as_f64())
            }).unwrap_or(1.0);

            let mut coil = MchxCondenserCoil::new(ua_w_k, n_air, coil_index);
            coil.set_air_temperature_celsius(t_air_c);
            coil.set_fan_speed_ratio(fan_speed);

            Ok(Box::new(coil))
        }

        // ── NEW: FloodedEvaporator ─────────────────────────────────────────────
        "FloodedEvaporator" => {
            use entropyk::FloodedEvaporator;

            // UA is the nominal base (required for construction). Default matches
            // the UI catalogue when the canvas field was left empty during calib.
            let ua = params
                .get("ua")
                .and_then(|v| v.as_f64())
                .unwrap_or(8000.0);
            if ua < 0.0 {
                return Err(CliError::Config(format!(
                    "FloodedEvaporator: ua must be >= 0 (got {ua})"
                )));
            }
            let target_quality = params
                .get("target_quality")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.7);
            let refrigerant = params
                .get("refrigerant")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str());
            let secondary_fluid = params
                .get("secondary_fluid")
                .and_then(|v| v.as_str())
                .unwrap_or("MEG");

            let quality_control = params
                .get("quality_control")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);

            let mut evap = FloodedEvaporator::new(ua)
                .with_target_quality(target_quality)
                .with_quality_control(quality_control)
                .with_refrigerant(refrigerant)
                .with_secondary_fluid(secondary_fluid)
                .with_fluid_backend(Arc::clone(&backend));

            // Rating-mode secondary scalars only (qualification / open-loop `rate()`).
            // System-mode coupling requires live secondary edges on ports
            // secondary_inlet / secondary_outlet (BrineSource → HX → BrineSink).
            // Scalars do NOT create Newton unknowns for the water loop.
            let sec_t_k = params
                .get("secondary_inlet_temp_k")
                .and_then(|v| v.as_f64())
                .or_else(|| {
                    params
                        .get("secondary_inlet_temp_c")
                        .and_then(|v| v.as_f64())
                        .map(|c| c + 273.15)
                });
            let sec_c = params
                .get("secondary_capacity_rate_w_per_k")
                .and_then(|v| v.as_f64())
                .or_else(|| {
                    params
                        .get("secondary_mass_flow_kg_s")
                        .and_then(|v| v.as_f64())
                        .map(|m| {
                            let cp = params
                                .get("secondary_cp_j_per_kgk")
                                .and_then(|v| v.as_f64())
                                .unwrap_or(4186.0);
                            m * cp
                        })
                });
            if let (Some(t_k), Some(c)) = (sec_t_k, sec_c) {
                if c > 0.0 {
                    evap.set_secondary_stream(t_k, c);
                }
            }

            // Secondary water ΔP (quadratic). Distinct from refrigerant tube DP.
            if let Some(k) = parse_secondary_dp_coeff(params) {
                evap.set_secondary_pressure_drop_coeff(k);
            }

            // Optional Cooper pool-boiling UA mode (outer-loop / Picard update).
            let ua_mode = params
                .get("ua_mode")
                .and_then(|v| v.as_str())
                .unwrap_or("fixed");
            if matches!(
                ua_mode.to_ascii_lowercase().as_str(),
                "cooper" | "cooper_pool_boiling" | "cooperpoolboiling"
            ) {
                use entropyk::{FloodedPoolBoilingConfig, UaMode};
                let mut pool = FloodedPoolBoilingConfig::default();
                if let Some(a) = params.get("area_ref_m2").and_then(|v| v.as_f64()) {
                    pool.area_ref_m2 = a;
                }
                if let Some(a) = params.get("area_sec_m2").and_then(|v| v.as_f64()) {
                    pool.area_sec_m2 = a;
                }
                if let Some(h) = params.get("h_sec").and_then(|v| v.as_f64()) {
                    pool.h_sec = h;
                }
                if let Some(b) = params.get("bundle_factor").and_then(|v| v.as_f64()) {
                    pool.bundle_factor = b;
                }
                if let Some(o) = params.get("oil_mass_fraction").and_then(|v| v.as_f64()) {
                    pool.oil_mass_fraction = o;
                }
                evap = evap
                    .with_ua_mode(UaMode::CooperPoolBoiling)
                    .with_pool_boiling(pool);
            }

            if quality_control {
                tracing::warn!(
                    component = %component_config.name,
                    "FloodedEvaporator quality_control=true adds +1 residual (outlet closure). \
                     Pair with a free actuator or free unknown, or the DoF gate will reject \
                     an over-constrained system."
                );
            }

            Ok(Box::new(evap))
        }

        "AirCooledCondenser" => {
            use entropyk::{AirCooledCondenser};

            let oat_k = params
                .get("oat_k")
                .and_then(|v| v.as_f64())
                .unwrap_or(308.15); // 35°C par défaut
            let approach_k = params
                .get("approach_k")
                .and_then(|v| v.as_f64())
                .unwrap_or(12.0);
            let ua = params.get("ua").and_then(|v| v.as_f64()).unwrap_or(0.0);
            let refrigerant = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str())
                .to_string();

            let cond = AirCooledCondenser::with_ua(oat_k, approach_k, ua)
                .with_refrigerant(&refrigerant)
                .with_fluid_backend(Arc::clone(&backend));
            Ok(Box::new(cond))
        }

        "FinCoilCondenser" => {
            use entropyk::{CoilGeometry, FinCoilCondenser, FinType};

            let oat_k = params
                .get("oat_k")
                .and_then(|v| v.as_f64())
                .unwrap_or(308.15);
            let face_width_m = params
                .get("face_width_m")
                .and_then(|v| v.as_f64())
                .unwrap_or(1.5);
            let face_height_m = params
                .get("face_height_m")
                .and_then(|v| v.as_f64())
                .unwrap_or(1.0);
            let n_rows = params
                .get("n_rows")
                .and_then(|v| v.as_u64())
                .unwrap_or(3) as u32;
            let fin_type_str = params
                .get("fin_type")
                .and_then(|v| v.as_str())
                .unwrap_or("louvered");
            let fin_pitch_fpi = params
                .get("fin_pitch_fpi")
                .and_then(|v| v.as_f64())
                .unwrap_or(14.0);
            let tube_od_mm = params
                .get("tube_od_mm")
                .and_then(|v| v.as_f64())
                .unwrap_or(9.525);
            let tube_pitch_mm = params
                .get("tube_pitch_mm")
                .and_then(|v| v.as_f64())
                .unwrap_or(25.4);
            let louver_pitch_mm = params
                .get("louver_pitch_mm")
                .and_then(|v| v.as_f64())
                .unwrap_or(1.4);
            let fin_thickness_mm = params
                .get("fin_thickness_mm")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.1);
            let air_face_velocity_m_s = params
                .get("air_face_velocity_m_s")
                .and_then(|v| v.as_f64())
                .unwrap_or(2.5);
            let design_capacity_kw = params
                .get("design_capacity_kw")
                .and_then(|v| v.as_f64())
                .unwrap_or(100.0);
            let refrigerant = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str())
                .to_string();

            let geometry = CoilGeometry {
                face_width_m,
                face_height_m,
                n_rows,
                fin_type: FinType::from_str(fin_type_str),
                fin_pitch_fpi,
                tube_od_m: tube_od_mm / 1000.0,
                tube_pitch_transverse_m: tube_pitch_mm / 1000.0,
                tube_pitch_longitudinal_m: tube_pitch_mm * 0.866 / 1000.0, // staggered
                louver_pitch_m: louver_pitch_mm / 1000.0,
                fin_thickness_m: fin_thickness_mm / 1000.0,
                ..Default::default()
            };

            let wet_surface = params
                .get("wet_surface")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);

            let cond = FinCoilCondenser::new(
                oat_k,
                geometry,
                air_face_velocity_m_s,
                design_capacity_kw * 1000.0,
            )
            .with_refrigerant(&refrigerant)
            .with_fluid_backend(Arc::clone(&backend))
            .with_wet_surface(wet_surface);

            Ok(Box::new(cond))
        }

        "Condenser" | "CondenserCoil" => {
            let ua = get_param_f64(params, "ua")?;
            let t_sat_k = params.get("t_sat_k").and_then(|v| v.as_f64());
            let refrigerant = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str())
                .to_string();

            let mut cond = if let Some(t_sat) = t_sat_k {
                Condenser::with_saturation_temp(ua, t_sat)
            } else {
                Condenser::new(ua)
            }
            .with_refrigerant(&refrigerant)
            .with_fluid_backend(Arc::clone(&backend));

            // Coupled secondary (water/air) boundary stream. When supplied, the
            // condenser solves a genuinely coupled duty Q = ε·C_sec·(T_cond − T_sec,in)
            // and the outlet state reacts to the secondary conditions. cp defaults to
            // air (1006 J/kg·K) here, but is overridable via secondary_cp_j_per_kgk.
            if let Some((t_k, c)) = parse_secondary_stream(params, 1006.0) {
                cond.set_secondary_stream(t_k, c);
            }

            // Modelica-style 4-port mode: the secondary side is a real graph
            // circuit (edges on ports secondary_inlet=2 / secondary_outlet=3).
            // `secondary_fluid` identifies the secondary medium ("Air", "Water",
            // "INCOMP::MEG-30", …) for the live T(P,h)/cp(P,h) inversion.
            if let Some(fluid) = params.get("secondary_fluid").and_then(|v| v.as_str()) {
                cond.set_secondary_fluid(fluid);
            }
            // Moist-air h↔T on the condenser MUST use the same humidity ratio W as
            // the upstream AirSource. A mismatch (e.g. W=0.010 vs ~0.014 at 35 °C /
            // 40 % RH) mis-reads T_sec by ~10 K and Newton often blows to residual=inf.
            // Prefer explicit `secondary_humidity_ratio`; else derive from
            // `secondary_t_dry_c` + `secondary_rh` (+ optional `secondary_p_bar`).
            if let Some(w) = params
                .get("secondary_humidity_ratio")
                .and_then(|v| v.as_f64())
                .or_else(|| moist_air_w_from_params(params))
            {
                cond.set_secondary_humidity_ratio(w);
            }

            // Refrigerant ΔP: `dp_model` = isobaric | quadratic | msh | friedel.
            apply_condenser_dp(&mut cond, params);
            // Secondary water/air ΔP (quadratic) — NOT the refrigerant tube model.
            if let Some(k) = parse_secondary_dp_coeff(params) {
                cond.set_secondary_pressure_drop_coeff(k);
            }

            // Emergent-pressure mode: the condensing pressure is no longer imposed
            // by the compressor design point. An outlet-closure residual pins the
            // refrigerant outlet to saturated liquid minus `subcooling_k`, so P_cond
            // emerges from the coupled secondary balance. Requires a secondary stream.
            if params
                .get("emergent_pressure")
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                let subcooling_k = params
                    .get("subcooling_k")
                    .and_then(|v| v.as_f64())
                    .unwrap_or(5.0);
                cond = cond.with_emergent_pressure(subcooling_k);
            }

            if params
                .get("skip_pressure_eq")
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                cond = cond.with_skip_pressure_eq();
            }

            // arch-6: air-cooled condenser fan head-pressure actuator. When a
            // target condensing temperature is given (°C or K), the fan speed
            // ratio becomes a free actuator that scales the secondary air capacity
            // rate to hold that condensing temperature (genuine inverse
            // head-pressure control). Forces emergent-pressure mode internally.
            if let Some(target_k) = params
                .get("fan_head_pressure_target_k")
                .and_then(|v| v.as_f64())
                .or_else(|| {
                    params
                        .get("fan_head_pressure_target_c")
                        .and_then(|v| v.as_f64())
                        .map(|c| c + 273.15)
                })
            {
                cond = cond.with_fan_head_pressure(target_k);
            }

            // arch-6: flooded-condenser head-pressure actuator (Sporlan Head Master
            // ORI+ORD style). When a target condensing temperature is given (°C or K),
            // the flooded liquid level λ becomes a free actuator that scales the
            // effective conductance UA_eff = (1−λ)·UA to hold that condensing
            // temperature in low ambient (genuine inverse head-pressure control).
            // Forces emergent-pressure mode internally. Mutually exclusive with the
            // fan actuator (shares the single generic actuator slot).
            if let Some(target_k) = params
                .get("flooded_head_pressure_target_k")
                .and_then(|v| v.as_f64())
                .or_else(|| {
                    params
                        .get("flooded_head_pressure_target_c")
                        .and_then(|v| v.as_f64())
                        .map(|c| c + 273.15)
                })
            {
                cond = cond.with_flooded_head_pressure(target_k);
            }

            // Fixed z_ua (or start hint): scale UA_eff = z_ua · UA. Live Free
            // embeddings override via CalibIndices during solve.
            if let Ok(calib) = hx_calib_from_params(params) {
                cond.set_calib(calib);
            }

            Ok(Box::new(cond))
        }

        "Evaporator" | "EvaporatorCoil" => {
            let ua = get_param_f64(params, "ua")?;
            let t_sat_k = params.get("t_sat_k").and_then(|v| v.as_f64());
            let superheat_k = params.get("superheat_k").and_then(|v| v.as_f64());
            let refrigerant = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str())
                .to_string();

            let mut evap = match (t_sat_k, superheat_k) {
                (Some(t_sat), Some(sh)) => Evaporator::with_superheat(ua, t_sat, sh),
                (Some(t_sat), None) => Evaporator::with_superheat(ua, t_sat, 5.0),
                (None, _) => Evaporator::new(ua),
            }
            .with_refrigerant(&refrigerant)
            .with_fluid_backend(Arc::clone(&backend));

            // Coupled secondary (water/brine) boundary stream. When supplied, the
            // evaporator solves a genuinely coupled duty Q = ε·C_sec·(T_sec,in − T_evap)
            // so the cooling capacity reacts to the secondary conditions. cp defaults to
            // water (4186 J/kg·K), overridable via secondary_cp_j_per_kgk.
            if let Some((t_k, c)) = parse_secondary_stream(params, 4186.0) {
                evap.set_secondary_stream(t_k, c);
            }

            // Modelica-style 4-port mode: the secondary side is a real graph
            // circuit (edges on ports secondary_inlet=2 / secondary_outlet=3).
            if let Some(fluid) = params.get("secondary_fluid").and_then(|v| v.as_str()) {
                evap.set_secondary_fluid(fluid);
            }
            if let Some(w) = params
                .get("secondary_humidity_ratio")
                .and_then(|v| v.as_f64())
            {
                evap.set_secondary_humidity_ratio(w);
            }

            // Refrigerant ΔP: `dp_model` = isobaric | quadratic | msh | friedel.
            apply_evaporator_dp(&mut evap, params);
            // Secondary water/air ΔP (quadratic) — NOT the refrigerant tube model.
            if let Some(k) = parse_secondary_dp_coeff(params) {
                evap.set_secondary_pressure_drop_coeff(k);
            }

            // Emergent-pressure mode: the evaporating pressure is no longer imposed
            // by the expansion-valve design point. An outlet-closure residual pins
            // the refrigerant outlet to the superheat target h(P, T_evap(P)+SH), so
            // P_evap emerges from the coupled secondary balance. Requires a secondary
            // stream.
            if params
                .get("emergent_pressure")
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                evap = evap.with_emergent_pressure();
            }

            if params
                .get("skip_pressure_eq")
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                evap = evap.with_skip_pressure_eq();
            }

            // Regulated-superheat mode (p0b): drop the outlet-closure that imposes
            // the superheat target so superheat emerges from the energy balance and
            // is instead regulated by a controls[] loop acting on the EXV opening.
            // Enabled via the JSON flag; pair it with a controls[] loop whose
            // measure is this evaporator's superheat and actuator is the EXV opening.
            if params
                .get("superheat_regulated")
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                if !params
                    .get("emergent_pressure")
                    .and_then(|v| v.as_bool())
                    .unwrap_or(false)
                {
                    return Err(crate::error::CliError::Config(
                        "evaporator 'superheat_regulated' requires 'emergent_pressure': true \
                         (regulated superheat only drops the emergent outlet-closure residual)"
                            .to_string(),
                    ));
                }
                evap = evap.with_regulated_superheat();
            }

            if let Ok(calib) = hx_calib_from_params(params) {
                evap.set_calib(calib);
            }

            Ok(Box::new(evap))
        }

        "HeatExchanger" => {
            let ua = get_param_f64(params, "ua")?;
            let name = params
                .get("name")
                .and_then(|v| v.as_str())
                .unwrap_or("HeatExchanger");

            let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
            let mut hx = HeatExchanger::new(model, name).with_fluid_backend(backend);

            if params.contains_key("hot_fluid") {
                let hot = parse_side_conditions(params, "hot")?;
                hx = hx.with_hot_conditions(hot);
            }

            if params.contains_key("cold_fluid") {
                let cold = parse_side_conditions(params, "cold")?;
                hx = hx.with_cold_conditions(cold);
            }

            if let Some(fluid) = params.get("hot_fluid_id").and_then(|v| v.as_str()) {
                hx.set_hot_fluid(fluid);
            }
            if let Some(fluid) = params.get("cold_fluid_id").and_then(|v| v.as_str()) {
                hx.set_cold_fluid(fluid);
            }
            if let Some(w) = params.get("hot_humidity_ratio").and_then(|v| v.as_f64()) {
                hx.set_hot_humidity_ratio(w);
            }
            if let Some(w) = params.get("cold_humidity_ratio").and_then(|v| v.as_f64()) {
                hx.set_cold_humidity_ratio(w);
            }

            Ok(Box::new(hx))
        }

        "IsentropicCompressor" => {
            use entropyk::IsentropicCompressor;

            let eta_is = params
                .get("isentropic_efficiency")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.75);
            // t_cond_k: JSON > auto-injected from FinCoilCondenser/AirCooledCondenser > default
            let t_cond_k = params
                .get("t_cond_k")
                .and_then(|v| v.as_f64())
                .or(auto_t_cond_k)
                .unwrap_or(323.15); // 50°C default
            let t_evap_k = params
                .get("t_evap_k")
                .and_then(|v| v.as_f64())
                .unwrap_or(275.15); // 2°C default
            let superheat_k = params
                .get("superheat_k")
                .and_then(|v| v.as_f64())
                .unwrap_or(5.0); // 5K default
            let refrigerant = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str())
                .to_string();

            let mut comp = IsentropicCompressor::new(eta_is, t_cond_k, t_evap_k, superheat_k)
                .with_refrigerant(&refrigerant)
                .with_fluid_backend(Arc::clone(&backend));

            // Energy-retention factor f_w: fraction of shaft work kept in the
            // refrigerant. h_dis = h_suc + f_w·Δh_is/η_is.
            // Default 1 = adiabatic; 0.98 ≈ 2% shell loss; 0 = all lost.
            if let Some(f_w) = params.get("f_w").and_then(|v| v.as_f64()).or_else(|| {
                params
                    .get("fw")
                    .and_then(|v| v.as_f64())
                    .or_else(|| params.get("f_q").and_then(|v| v.as_f64()))
            }) {
                comp = comp.with_f_w(f_w);
            }

            // Emergent-pressure mode: the compressor no longer pins the discharge
            // pressure to P_sat(t_cond_k). Normally ṁ is closed by the volumetric
            // displacement model. When a sibling EXV uses a *fixed* orifice opening,
            // ṁ is metered by the valve instead (energy-only compressor).
            if params
                .get("emergent_pressure")
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                if meter_mass_flow_via_exv {
                    tracing::info!(
                        component = %component_config.name,
                        "EXV fixed orifice meters ṁ — compressor uses emergent energy-only mode"
                    );
                    comp = comp.with_emergent_metered_flow();
                } else {
                use entropyk_components::isentropic_compressor::VolumetricEfficiency;
                let displacement_m3 = params
                    .get("displacement_m3")
                    .and_then(|v| v.as_f64())
                    .unwrap_or(0.0);
                let speed_hz = params
                    .get("speed_hz")
                    .and_then(|v| v.as_f64())
                    .unwrap_or(0.0);
                // Volumetric efficiency: clearance model when `clearance` is present,
                // otherwise a constant (default 1.0 via `volumetric_efficiency`).
                let vol_eff = match params.get("clearance").and_then(|v| v.as_f64()) {
                    Some(clearance) => VolumetricEfficiency::Clearance {
                        clearance,
                        polytropic_n: params
                            .get("polytropic_n")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(1.1),
                    },
                    None => VolumetricEfficiency::Constant(
                        params
                            .get("volumetric_efficiency")
                            .and_then(|v| v.as_f64())
                            .unwrap_or(1.0),
                    ),
                };
                comp = comp.with_displacement(displacement_m3, speed_hz, vol_eff);
                }

                // Optional variable-speed-drive (VSD) efficiency map. Enabled when
                // a `vsd_reference_speed_hz` is supplied; the quadratic speed
                // corrections default to identity ([1,0,0]) when coefficients are
                // omitted, so a bare reference speed is a no-op.
                if let Some(ref_speed) = params.get("vsd_reference_speed_hz").and_then(|v| v.as_f64())
                {
                    use entropyk_components::isentropic_compressor::VsdSpeedMap;
                    let vol_coeffs = parse_coeffs3(params, "vsd_volumetric_coeffs", [1.0, 0.0, 0.0]);
                    let isen_coeffs = parse_coeffs3(params, "vsd_isentropic_coeffs", [1.0, 0.0, 0.0]);
                    comp = comp.with_vsd_map(VsdSpeedMap::new(ref_speed, vol_coeffs, isen_coeffs));
                }

                // Optional screw-compressor slide-valve capacity control. Enabled
                // when a suction-saturated-temperature setpoint is supplied; the
                // slide position becomes a free actuator holding SST = target.
                if let Some(sst_target_k) = params
                    .get("slide_valve_sst_target_k")
                    .and_then(|v| v.as_f64())
                    .or_else(|| {
                        params
                            .get("slide_valve_sst_target_c")
                            .and_then(|v| v.as_f64())
                            .map(|c| c + 273.15)
                    })
                {
                    comp = comp.with_slide_valve(sst_target_k);
                }

                // Optional liquid-injection port (economized/screw compressors).
                // Enabled by `liquid_injection: true`; the injection ratio φ_inj
                // becomes a physical actuator driven by a controls[] loop (e.g. to
                // hold the discharge gas temperature below a limit). No setpoint is
                // hard-coded — the loop reference lives in the controls[] block.
                if params
                    .get("liquid_injection")
                    .and_then(|v| v.as_bool())
                    .unwrap_or(false)
                {
                    comp = comp.with_liquid_injection();
                }
            }

            Ok(Box::new(comp))
        }

        "IsenthalpicExpansionValve" | "EXV" => {
            use entropyk::IsenthalpicExpansionValve;

            let t_evap_k = params
                .get("t_evap_k")
                .and_then(|v| v.as_f64())
                .unwrap_or(275.15); // 2°C default
            let refrigerant = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str())
                .to_string();

            let mut exv = IsenthalpicExpansionValve::new(t_evap_k)
                .with_refrigerant(&refrigerant)
                .with_fluid_backend(Arc::clone(&backend));

            // Emergent-pressure mode: drop the P_evap fix; the valve only enforces
            // isenthalpic throttling and the low-side pressure is set by the
            // downstream evaporator outlet closure.
            if params
                .get("emergent_pressure")
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                exv = exv.with_emergent_pressure();
            }

            // Physical orifice: ṁ = Kv·opening·√(2·ρ_in·ΔP). Requires explicit orifice_kv.
            // - Fixed opening (default): opening is a parameter; compressor uses
            //   energy-only emergent mode (see meter_mass_flow_via_exv).
            // - Free opening (`fix_opening: false`): opening is a free-actuator unknown.
            if let Some(kv) = params.get("orifice_kv").and_then(|v| v.as_f64()) {
                let opening = params.get("opening").and_then(|v| v.as_f64()).unwrap_or(1.0);
                if exv_uses_fixed_orifice(params) {
                    exv = exv.with_orifice_fixed(kv, opening);
                } else {
                    exv = exv.with_orifice(kv);
                }
            }

            Ok(Box::new(exv))
        }

        "ReversingValve" | "FourWayValve" => {
            use entropyk::{ReversingMode, ReversingValve};

            // Mode selector (cooling / heating). Default: cooling.
            let mode = params
                .get("mode")
                .and_then(|v| v.as_str())
                .and_then(ReversingMode::from_str)
                .unwrap_or(ReversingMode::Cooling);

            let mut valve = ReversingValve::new(mode);

            // Pressure drop across the valve body. Accept a fixed baseline in Pa
            // (pressure_drop_pa) or kPa (pressure_drop_kpa), plus an optional
            // quadratic flow coefficient k [Pa·s²/kg²] (pressure_drop_coeff).
            let dp_pa = params
                .get("pressure_drop_pa")
                .and_then(|v| v.as_f64())
                .or_else(|| {
                    params
                        .get("pressure_drop_kpa")
                        .and_then(|v| v.as_f64())
                        .map(|kpa| kpa * 1000.0)
                });
            if let Some(dp) = dp_pa {
                valve = valve.with_pressure_drop_pa(dp);
            }
            if let Some(k) = params.get("pressure_drop_coeff").and_then(|v| v.as_f64()) {
                valve = valve.with_pressure_drop_coeff(k);
            }

            // Internal-leakage suction preheat, in J/kg (leak_enthalpy_j_kg) or
            // kJ/kg (leak_enthalpy_kj_kg).
            let leak = params
                .get("leak_enthalpy_j_kg")
                .and_then(|v| v.as_f64())
                .or_else(|| {
                    params
                        .get("leak_enthalpy_kj_kg")
                        .and_then(|v| v.as_f64())
                        .map(|kj| kj * 1000.0)
                });
            if let Some(dh) = leak {
                valve = valve.with_leak_enthalpy(dh);
            }

            Ok(Box::new(valve))
        }

        // Pipe variants: refrigerant line / water pipe / air duct (same Darcy model).
        "Pipe" | "RefrigerantPipe" | "WaterPipe" | "AirDuct" | "PipeWater" | "PipeAir" => {
            use entropyk::{ComponentFluidId, Pipe, PipeGeometry, Port};
            use entropyk_core::{Enthalpy, Pressure};

            let media = match component_type {
                "WaterPipe" | "PipeWater" => "water",
                "AirDuct" | "PipeAir" => "air",
                "RefrigerantPipe" => "refrigerant",
                _ => params
                    .get("media")
                    .or_else(|| params.get("medium"))
                    .and_then(|v| v.as_str())
                    .unwrap_or("auto"),
            };

            let fluid_default = match media {
                "water" => "Water",
                "air" => "Air",
                "refrigerant" => _primary_fluid.as_str(),
                _ => "", // auto from fluid name
            };

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .unwrap_or_else(|| {
                    if fluid_default.is_empty() {
                        _primary_fluid.as_str()
                    } else {
                        fluid_default
                    }
                })
                .to_string();

            let fluid_lc = fluid.to_ascii_lowercase();
            let is_air = media == "air"
                || fluid_lc == "air"
                || fluid_lc.starts_with("air");
            let is_water = media == "water"
                || matches!(
                    fluid_lc.as_str(),
                    "water" | "meg" | "mpg" | "brine"
                )
                || fluid_lc.contains("glycol");

            // Geometry defaults by medium (Modelica/HVAC sizing intuition).
            let (default_l, default_d, default_rho, default_mu) = if is_air {
                (5.0, 0.20, 1.20, 1.8e-5)
            } else if is_water {
                (5.0, 0.025, 998.0, 1.0e-3)
            } else {
                (3.0, 0.012, 1140.0, 2.0e-4)
            };

            let length_m = params
                .get("length_m")
                .and_then(|v| v.as_f64())
                .unwrap_or(default_l);
            let diameter_m = params
                .get("diameter_m")
                .and_then(|v| v.as_f64())
                .unwrap_or(default_d);
            let roughness_m = params.get("roughness_m").and_then(|v| v.as_f64());

            let geometry = match roughness_m {
                Some(r) => PipeGeometry::new(length_m, diameter_m, r),
                None => PipeGeometry::smooth(length_m, diameter_m),
            }
            .map_err(CliError::Component)?;

            let density = params
                .get("density_kg_m3")
                .and_then(|v| v.as_f64())
                .unwrap_or(default_rho);
            let viscosity = params
                .get("viscosity_pa_s")
                .and_then(|v| v.as_f64())
                .unwrap_or(default_mu);

            let design_dp_pa = params
                .get("pressure_drop_pa")
                .and_then(|v| v.as_f64())
                .or_else(|| {
                    params
                        .get("design_dp_kpa")
                        .and_then(|v| v.as_f64())
                        .map(|k| k * 1000.0)
                })
                .unwrap_or(0.0);

            let fluid_id = ComponentFluidId::new(&fluid);
            let p_bar = if is_air {
                1.01325
            } else if is_water {
                2.0
            } else {
                10.0
            };
            let h_j = if is_air {
                300_000.0
            } else if is_water {
                84_000.0
            } else {
                250_000.0
            };

            let mk = |fid: ComponentFluidId| {
                Port::new(
                    fid,
                    Pressure::from_bar(p_bar),
                    Enthalpy::from_joules_per_kg(h_j),
                )
            };

            let pipe_disconnected = if is_air || is_water {
                Pipe::for_incompressible(
                    geometry,
                    mk(fluid_id.clone()),
                    mk(fluid_id.clone()),
                    density,
                    viscosity,
                )
            } else {
                Pipe::for_refrigerant(
                    geometry,
                    mk(fluid_id.clone()),
                    mk(fluid_id.clone()),
                    density,
                    viscosity,
                )
            }
            .map_err(CliError::Component)?;

            let pipe = pipe_disconnected
                .connect(mk(fluid_id.clone()), mk(fluid_id))
                .map_err(CliError::Component)?
                .with_design_pressure_drop_pa(design_dp_pa);

            Ok(Box::new(pipe))
        }

        "BypassValve" => {
            use entropyk::{BypassValve, BypassValveConfig, ValveCharacteristics};

            let name = params
                .get("name")
                .and_then(|v| v.as_str())
                .unwrap_or("bypass_valve")
                .to_string();

            let cv = params.get("cv").and_then(|v| v.as_f64()).unwrap_or(10.0);
            let min_position = params.get("min_position").and_then(|v| v.as_f64()).unwrap_or(0.0);
            let max_position = params.get("max_position").and_then(|v| v.as_f64()).unwrap_or(1.0);
            let nominal_dp_pa = params
                .get("nominal_pressure_drop_pa")
                .and_then(|v| v.as_f64())
                .or_else(|| params.get("nominal_dp_kpa").and_then(|v| v.as_f64()).map(|k| k * 1000.0))
                .unwrap_or(10_000.0);
            let position = params.get("position").and_then(|v| v.as_f64()).unwrap_or(1.0);

            let characteristics = match params
                .get("characteristics")
                .and_then(|v| v.as_str())
                .unwrap_or("equal_percentage")
                .to_lowercase()
                .as_str()
            {
                "linear" => ValveCharacteristics::Linear,
                "quick_opening" | "quickopening" => ValveCharacteristics::QuickOpening,
                _ => ValveCharacteristics::EqualPercentage,
            };

            let config = BypassValveConfig {
                cv,
                characteristics,
                min_position,
                max_position,
                nominal_pressure_drop_pa: nominal_dp_pa,
            };

            let mut valve = BypassValve::new(&name, config);
            valve
                .set_position(position)
                .map_err(CliError::Component)?;

            Ok(Box::new(valve.with_edge_coupling()))
        }

        "FlowSplitter" => {
            use entropyk::FlowSplitter;

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str())
                .to_string();

            let n_outlets = params
                .get("n_outlets")
                .and_then(|v| v.as_u64())
                .unwrap_or(2)
                .max(2) as usize;

            // Initial guess port conditions (refined by the solver).
            let p_bar = params.get("p_bar").and_then(|v| v.as_f64()).unwrap_or(10.0);
            let h_kj_kg = params.get("h_kj_kg").and_then(|v| v.as_f64()).unwrap_or(400.0);

            let inlet = make_connected_port(&fluid, p_bar, h_kj_kg);
            let outlets: Vec<_> = (0..n_outlets)
                .map(|_| make_connected_port(&fluid, p_bar, h_kj_kg))
                .collect();

            let splitter = if is_incompressible_fluid(&fluid) {
                FlowSplitter::incompressible(fluid, inlet, outlets)
            } else {
                FlowSplitter::compressible(fluid, inlet, outlets)
            }
            .map_err(CliError::Component)?;

            Ok(Box::new(splitter))
        }

        "FlowMerger" => {
            use entropyk::FlowMerger;

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str())
                .to_string();

            let n_inlets = params
                .get("n_inlets")
                .and_then(|v| v.as_u64())
                .unwrap_or(2)
                .max(2) as usize;

            let p_bar = params.get("p_bar").and_then(|v| v.as_f64()).unwrap_or(5.0);
            let h_kj_kg = params.get("h_kj_kg").and_then(|v| v.as_f64()).unwrap_or(580.0);

            let inlets: Vec<_> = (0..n_inlets)
                .map(|_| make_connected_port(&fluid, p_bar, h_kj_kg))
                .collect();
            let outlet = make_connected_port(&fluid, p_bar, h_kj_kg);

            let merger = if is_incompressible_fluid(&fluid) {
                FlowMerger::incompressible(fluid, inlets, outlet)
            } else {
                FlowMerger::compressible(fluid, inlets, outlet)
            }
            .map_err(CliError::Component)?;

            // Optional mass-flow weights for enthalpy mixing.
            let merger = if let Some(weights) = params.get("mass_flow_weights").and_then(|v| v.as_array())
            {
                let w: Vec<f64> = weights.iter().filter_map(|v| v.as_f64()).collect();
                if w.len() == n_inlets {
                    merger.with_mass_flows(w).map_err(CliError::Component)?
                } else {
                    merger
                }
            } else {
                merger
            };

            Ok(Box::new(merger))
        }

        "Fan" => {
            use entropyk::{ComponentFluidId, Enthalpy, Fan, FanCurves, Port, Pressure};

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or("Air")
                .to_string();

            let air_density = params
                .get("air_density_kg_m3")
                .and_then(|v| v.as_f64())
                .unwrap_or(1.2);
            let speed_ratio = params.get("speed_ratio").and_then(|v| v.as_f64()).unwrap_or(1.0);
            let design_flow_m3_s = params
                .get("design_flow_m3_s")
                .and_then(|v| v.as_f64())
                .unwrap_or(1.0);

            // Quadratic fan curve: ΔP(Q) = p0 + p1·Q + p2·Q²  (defaults: 250 Pa shutoff)
            let p0 = params.get("curve_p0").and_then(|v| v.as_f64()).unwrap_or(250.0);
            let p1 = params.get("curve_p1").and_then(|v| v.as_f64()).unwrap_or(0.0);
            let p2 = params.get("curve_p2").and_then(|v| v.as_f64()).unwrap_or(-20.0);

            let curves = FanCurves::quadratic(p0, p1, p2, 0.0, 1.6, -0.64)
                .map_err(CliError::Component)?;

            let p_bar = params.get("p_bar").and_then(|v| v.as_f64()).unwrap_or(1.01325);
            let h_kj_kg = params.get("h_kj_kg").and_then(|v| v.as_f64()).unwrap_or(420.0);
            let fluid_id = ComponentFluidId::new(&fluid);

            let inlet_a = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_bar),
                Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
            );
            let outlet_a = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_bar),
                Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
            );

            let use_drive_chain = params
                .get("drive_chain")
                .or_else(|| params.get("use_drive_chain"))
                .and_then(|v| v.as_bool())
                .unwrap_or(false);

            let mut fan_disconnected = Fan::new(curves, inlet_a, outlet_a, air_density)
                .map_err(CliError::Component)?
                .with_drive_chain(use_drive_chain);
            fan_disconnected
                .set_speed_ratio(speed_ratio)
                .map_err(CliError::Component)?;

            let inlet_b = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_bar),
                Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
            );
            let outlet_b = Port::new(
                fluid_id,
                Pressure::from_bar(p_bar),
                Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
            );

            let fan = fan_disconnected
                .connect(inlet_b, outlet_b)
                .map_err(CliError::Component)?
                .with_edge_coupling(design_flow_m3_s);

            Ok(Box::new(fan))
        }

        "Drum" => {
            use entropyk::Drum;

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str())
                .to_string();

            // Design saturation temperature (the drum operates at the evaporating
            // pressure/temperature, like a flooded recirculation separator).
            let t_sat_k = if let Some(k) = params.get("t_sat_k").and_then(|v| v.as_f64()) {
                k
            } else if let Some(c) = params
                .get("t_sat_c")
                .or_else(|| params.get("t_evap_c"))
                .and_then(|v| v.as_f64())
            {
                c + 273.15
            } else {
                273.15 // 0 °C default
            };

            // Initial guess port conditions (refined by the solver).
            let p_bar = params.get("p_bar").and_then(|v| v.as_f64()).unwrap_or(5.0);
            let h_feed = params.get("h_feed_kj_kg").and_then(|v| v.as_f64()).unwrap_or(250.0);
            let h_return = params.get("h_return_kj_kg").and_then(|v| v.as_f64()).unwrap_or(380.0);
            let h_liq = params.get("h_liquid_kj_kg").and_then(|v| v.as_f64()).unwrap_or(200.0);
            let h_vap = params.get("h_vapor_kj_kg").and_then(|v| v.as_f64()).unwrap_or(420.0);

            let feed_inlet = make_connected_port(&fluid, p_bar, h_feed);
            let evaporator_return = make_connected_port(&fluid, p_bar, h_return);
            let liquid_outlet = make_connected_port(&fluid, p_bar, h_liq);
            let vapor_outlet = make_connected_port(&fluid, p_bar, h_vap);

            let drum = Drum::new(
                fluid,
                feed_inlet,
                evaporator_return,
                liquid_outlet,
                vapor_outlet,
                Arc::clone(&backend),
            )
            .map_err(CliError::Component)?
            .with_edge_coupling(t_sat_k);

            Ok(Box::new(drum))
        }

        "Compressor" => {
            use entropyk::{
                Ahri540Coefficients, Compressor, CompressorModel, ComponentFluidId, Port,
                SstSdtCoefficients,
            };
            use entropyk_core::{Enthalpy, Pressure};

            let speed_rpm = get_param_f64(params, "speed_rpm")?;
            let displacement_m3 = get_param_f64(params, "displacement_m3")?;
            let efficiency = params
                .get("efficiency")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.85);
            let fluid = get_param_string(params, "fluid")?;

            // model_type: "Ahri540" (default) | "SstSdt" (bilinear SST/SDT polynomial)
            let model_type = params
                .get("model_type")
                .or_else(|| params.get("modelType"))
                .and_then(|v| v.as_str())
                .unwrap_or("Ahri540");

            let model = match model_type {
                "SstSdt" | "SstSdtPolynomial" | "sst_sdt" => {
                    // Bilinear ṁ = a00 + a10·SST + a01·SDT + a11·SST·SDT (SST/SDT in K)
                    let a00 = params.get("mf_a00").and_then(|v| v.as_f64()).unwrap_or(0.05);
                    let a10 = params.get("mf_a10").and_then(|v| v.as_f64()).unwrap_or(0.001);
                    let a01 = params.get("mf_a01").and_then(|v| v.as_f64()).unwrap_or(0.0005);
                    let a11 = params.get("mf_a11").and_then(|v| v.as_f64()).unwrap_or(0.00001);
                    let b00 = params.get("pw_b00").and_then(|v| v.as_f64()).unwrap_or(1000.0);
                    let b10 = params.get("pw_b10").and_then(|v| v.as_f64()).unwrap_or(50.0);
                    let b01 = params.get("pw_b01").and_then(|v| v.as_f64()).unwrap_or(30.0);
                    let b11 = params.get("pw_b11").and_then(|v| v.as_f64()).unwrap_or(0.5);
                    CompressorModel::SstSdt(SstSdtCoefficients::bilinear(
                        a00, a10, a01, a11, b00, b10, b01, b11,
                    ))
                }
                "Ahri540" | "AHRI540" | "ahri540" | _ => {
                    let m1 = params.get("m1").and_then(|v| v.as_f64()).unwrap_or(0.85);
                    let m2 = params.get("m2").and_then(|v| v.as_f64()).unwrap_or(2.5);
                    let m3 = params.get("m3").and_then(|v| v.as_f64()).unwrap_or(500.0);
                    let m4 = params.get("m4").and_then(|v| v.as_f64()).unwrap_or(1500.0);
                    let m5 = params.get("m5").and_then(|v| v.as_f64()).unwrap_or(-2.5);
                    let m6 = params.get("m6").and_then(|v| v.as_f64()).unwrap_or(1.8);
                    let m7 = params.get("m7").and_then(|v| v.as_f64()).unwrap_or(600.0);
                    let m8 = params.get("m8").and_then(|v| v.as_f64()).unwrap_or(1600.0);
                    let m9 = params.get("m9").and_then(|v| v.as_f64()).unwrap_or(-3.0);
                    let m10 = params.get("m10").and_then(|v| v.as_f64()).unwrap_or(2.0);
                    CompressorModel::Ahri540(Ahri540Coefficients::new(
                        m1, m2, m3, m4, m5, m6, m7, m8, m9, m10,
                    ))
                }
            };

            // Initial port conditions (same pattern as ScrewCompressor)
            let p_suc = params.get("p_suction_bar").and_then(|v| v.as_f64()).unwrap_or(3.5);
            let h_suc = params.get("h_suction_kj_kg").and_then(|v| v.as_f64()).unwrap_or(400.0);
            let p_dis = params.get("p_discharge_bar").and_then(|v| v.as_f64()).unwrap_or(12.0);
            let h_dis = params.get("h_discharge_kj_kg").and_then(|v| v.as_f64()).unwrap_or(440.0);

            let fluid_id = ComponentFluidId::new(&fluid);

            let suction_a = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_suc),
                Enthalpy::from_joules_per_kg(h_suc * 1000.0),
            );
            let discharge_a = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_dis),
                Enthalpy::from_joules_per_kg(h_dis * 1000.0),
            );

            let comp_disconnected = Compressor::with_model(
                model,
                suction_a,
                discharge_a,
                speed_rpm,
                displacement_m3,
                efficiency,
            )
            .map_err(CliError::Component)?;

            let suction_b = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_suc),
                Enthalpy::from_joules_per_kg(h_suc * 1000.0),
            );
            let discharge_b = Port::new(
                fluid_id,
                Pressure::from_bar(p_dis),
                Enthalpy::from_joules_per_kg(h_dis * 1000.0),
            );

            let comp = comp_disconnected
                .connect(suction_b, discharge_b)
                .map_err(CliError::Component)?;

            Ok(Box::new(comp))
        }

        "ExpansionValve" => {
            use entropyk::{ComponentFluidId, ExpansionValve, Port};
            use entropyk_core::{Enthalpy, Pressure};

            let fluid = get_param_string(params, "fluid")?;
            let opening = params.get("opening").and_then(|v| v.as_f64()).unwrap_or(1.0);

            // Initial port conditions
            let p_in = params.get("p_inlet_bar").and_then(|v| v.as_f64()).unwrap_or(12.0);
            let h_in = params.get("h_inlet_kj_kg").and_then(|v| v.as_f64()).unwrap_or(260.0);
            let p_out = params.get("p_outlet_bar").and_then(|v| v.as_f64()).unwrap_or(3.5);
            let h_out = params.get("h_outlet_kj_kg").and_then(|v| v.as_f64()).unwrap_or(260.0);

            let fluid_id = ComponentFluidId::new(&fluid);

            // Create disconnected ports
            let inlet_a = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_in),
                Enthalpy::from_joules_per_kg(h_in * 1000.0),
            );
            let outlet_a = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_out),
                Enthalpy::from_joules_per_kg(h_out * 1000.0),
            );

            // Optional physical flow model: orifice / EXV Cd·A / TXV Eames.
            use entropyk::ValveFlowModel;
            let flow_model = match params
                .get("flow_model")
                .and_then(|v| v.as_str())
                .unwrap_or("orifice")
                .to_ascii_lowercase()
                .as_str()
            {
                "exv" | "exv_cda" | "cda" => ValveFlowModel::ExvCdA {
                    cd: params.get("cd").and_then(|v| v.as_f64()).unwrap_or(0.7),
                    area_max_m2: params
                        .get("area_max_m2")
                        .or_else(|| params.get("orifice_area_m2"))
                        .and_then(|v| v.as_f64())
                        .unwrap_or(2.0e-6),
                },
                "txv" | "txv_eames" | "eames" => ValveFlowModel::TxvEames {
                    beta_m2: params
                        .get("beta_m2")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(2.0e-6),
                    static_superheat_pa: params
                        .get("static_superheat_pa")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(50_000.0),
                    full_open_delta_pa: params
                        .get("full_open_delta_pa")
                        .and_then(|v| v.as_f64())
                        .unwrap_or(150_000.0),
                },
                _ => ValveFlowModel::IsenthalpicOrifice {
                    beta_m2: params
                        .get("beta_m2")
                        .or_else(|| params.get("orifice_kv"))
                        .and_then(|v| v.as_f64())
                        .unwrap_or(1.0e-6),
                },
            };

            // Build ExpansionValve<Disconnected> with selected flow model
            let valve_disconnected = ExpansionValve::new(inlet_a, outlet_a, Some(opening))
                .map_err(CliError::Component)?
                .with_flow_model(flow_model);

            // Connect ports to transition Disconnected → Connected
            let inlet_b = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_in),
                Enthalpy::from_joules_per_kg(h_in * 1000.0),
            );
            let outlet_b = Port::new(
                fluid_id,
                Pressure::from_bar(p_out),
                Enthalpy::from_joules_per_kg(h_out * 1000.0),
            );

            let mut valve = valve_disconnected
                .connect(inlet_b, outlet_b)
                .map_err(CliError::Component)?;
            if let Some(p_bulb) = params
                .get("bulb_pressure_pa")
                .or_else(|| params.get("p_bulb_pa"))
                .and_then(|v| v.as_f64())
            {
                valve.set_bulb_pressure(p_bulb);
            }

            Ok(Box::new(valve))
        }

        "CapillaryTube" | "Capillary" => {
            use entropyk::{CapillaryGeometry, CapillaryTube, ComponentFluidId, Port};
            use entropyk_core::{Enthalpy, Pressure};

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str());
            let geom = CapillaryGeometry {
                diameter_m: params
                    .get("diameter_m")
                    .and_then(|v| v.as_f64())
                    .unwrap_or(0.001),
                length_m: params.get("length_m").and_then(|v| v.as_f64()).unwrap_or(2.0),
                n_segments: params
                    .get("n_segments")
                    .and_then(|v| v.as_u64())
                    .unwrap_or(20) as usize,
            };
            let p_in = params.get("p_inlet_bar").and_then(|v| v.as_f64()).unwrap_or(12.0);
            let h_in = params
                .get("h_inlet_kj_kg")
                .and_then(|v| v.as_f64())
                .unwrap_or(250.0);
            let p_out = params
                .get("p_outlet_bar")
                .and_then(|v| v.as_f64())
                .unwrap_or(3.5);
            let h_out = params
                .get("h_outlet_kj_kg")
                .and_then(|v| v.as_f64())
                .unwrap_or(250.0);
            let fluid_id = ComponentFluidId::new(fluid);
            let mk = |p: f64, h: f64| {
                Port::new(
                    fluid_id.clone(),
                    Pressure::from_bar(p),
                    Enthalpy::from_joules_per_kg(h * 1000.0),
                )
            };
            let tube = CapillaryTube::new(geom, mk(p_in, h_in), mk(p_out, h_out))
                .map_err(CliError::Component)?
                .connect(mk(p_in, h_in), mk(p_out, h_out))
                .map_err(CliError::Component)?;
            Ok(Box::new(tube))
        }

        "CentrifugalCompressor" | "Centrifugal" => {
            use entropyk::{
                CentrifugalCompressor, CentrifugalMap, ComponentFluidId, Port,
            };
            use entropyk_core::{Enthalpy, Pressure};

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str());
            let diameter_m = params
                .get("diameter_m")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.25);
            let speed_rpm = params
                .get("speed_rpm")
                .and_then(|v| v.as_f64())
                .unwrap_or(9000.0);
            let p_suc = params
                .get("p_suction_bar")
                .and_then(|v| v.as_f64())
                .unwrap_or(3.2);
            let h_suc = params
                .get("h_suction_kj_kg")
                .and_then(|v| v.as_f64())
                .unwrap_or(400.0);
            let p_dis = params
                .get("p_discharge_bar")
                .and_then(|v| v.as_f64())
                .unwrap_or(10.0);
            let h_dis = params
                .get("h_discharge_kj_kg")
                .and_then(|v| v.as_f64())
                .unwrap_or(430.0);
            let fluid_id = ComponentFluidId::new(fluid);
            let mk = |p: f64, h: f64| {
                Port::new(
                    fluid_id.clone(),
                    Pressure::from_bar(p),
                    Enthalpy::from_joules_per_kg(h * 1000.0),
                )
            };
            let mut comp = CentrifugalCompressor::new(
                CentrifugalMap::default_chiller_map(),
                diameter_m,
                speed_rpm,
                mk(p_suc, h_suc),
                mk(p_dis, h_dis),
            )
            .map_err(CliError::Component)?;
            if let (Some(r), Some(g)) = (
                params.get("gas_constant").and_then(|v| v.as_f64()),
                params.get("gamma").and_then(|v| v.as_f64()),
            ) {
                comp = comp.with_gas(r, g);
            }
            let connected = comp
                .connect(mk(p_suc, h_suc), mk(p_dis, h_dis))
                .map_err(CliError::Component)?;
            Ok(Box::new(connected))
        }

        "RefrigerantSource" => {
            use entropyk::RefrigerantSource;
            use entropyk_core::{Pressure, VaporQuality};

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str());
            let p_set = params.get("p_set_bar").and_then(|v| v.as_f64()).unwrap_or(10.0);
            let q = params.get("quality").and_then(|v| v.as_f64()).unwrap_or(1.0);

            let outlet = make_connected_port(fluid, p_set, 250.0);
            let mut comp = RefrigerantSource::new(
                fluid,
                Pressure::from_bar(p_set),
                VaporQuality::from_fraction(q),
                backend,
                outlet,
            )
            .map_err(CliError::Component)?;
            if let Some(m_set) = params.get("m_flow_kg_s").and_then(|v| v.as_f64()) {
                comp = comp.with_imposed_mass_flow(m_set).map_err(CliError::Component)?;
            }
            Ok(Box::new(comp))
        }

        "RefrigerantSink" => {
            use entropyk::RefrigerantSink;
            use entropyk_core::{Pressure, VaporQuality};

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str());
            let p_back = params.get("p_back_bar").and_then(|v| v.as_f64()).unwrap_or(10.0);
            let q_opt = params
                .get("quality")
                .and_then(|v| v.as_f64())
                .map(VaporQuality::from_fraction);

            let inlet = make_connected_port(fluid, p_back, 250.0);
            let comp = RefrigerantSink::new(
                fluid,
                Pressure::from_bar(p_back),
                q_opt,
                backend,
                inlet,
            )
            .map_err(CliError::Component)?;
            Ok(Box::new(comp))
        }

        "BrineSource" => {
            use entropyk::BrineSource;
            use entropyk_core::{Concentration, Pressure, Temperature};

            let fluid = params.get("fluid").and_then(|v| v.as_str()).unwrap_or("Water");
            let p_set = params.get("p_set_bar").and_then(|v| v.as_f64()).unwrap_or(2.0);
            let t_set = params.get("t_set_c").and_then(|v| v.as_f64()).unwrap_or(12.0);
            let conc = params.get("concentration").and_then(|v| v.as_f64()).unwrap_or(0.0);

            let outlet = make_connected_port(fluid, p_set, 100.0);
            let mut comp = BrineSource::new(
                fluid,
                Pressure::from_bar(p_set),
                Temperature::from_celsius(t_set),
                Concentration::from_percent(conc),
                backend,
                outlet,
            )
            .map_err(CliError::Component)?;
            // Modelica MassFlowSource_T: Fixed ṁ + T, Free P (default).
            // Boundary_pT: omit m_flow → Fixed P + T, Free ṁ.
            let m_set = optional_imposed_mass_flow(params);
            if let Some(m) = m_set {
                comp = comp.with_imposed_mass_flow(m).map_err(CliError::Component)?;
            }
            let default_fix_p = m_set.is_none();
            comp = comp
                .with_impose_pressure(param_fix_flag(params, "fix_pressure", default_fix_p))
                .with_impose_temperature(param_fix_flag(params, "fix_temperature", true));
            Ok(Box::new(comp))
        }

        "BrineSink" => {
            use entropyk::BrineSink;
            use entropyk_core::{Concentration, Pressure, Temperature};

            let fluid = params.get("fluid").and_then(|v| v.as_str()).unwrap_or("Water");
            let p_back = params.get("p_back_bar").and_then(|v| v.as_f64()).unwrap_or(2.0);
            // Optional Fixed T_out (ΔT rating). Legacy: presence of t_set_c ⇒ Fixed.
            // Explicit fix_temperature=false keeps the value as a hint only.
            let t_raw = params.get("t_set_c").and_then(|v| v.as_f64());
            let t_opt = match (t_raw, params.get("fix_temperature")) {
                (Some(_), Some(_)) if !param_fix_flag(params, "fix_temperature", true) => None,
                (Some(t), _) => Some(Temperature::from_celsius(t)),
                (None, _) => None,
            };
            let conc = params.get("concentration").and_then(|v| v.as_f64()).unwrap_or(0.0);
            let conc_opt = if t_opt.is_some() {
                Some(Concentration::from_percent(conc))
            } else {
                None
            };

            let inlet = make_connected_port(fluid, p_back, 100.0);
            let mut comp = BrineSink::new(
                fluid,
                Pressure::from_bar(p_back),
                t_opt,
                conc_opt,
                backend,
                inlet,
            )
            .map_err(CliError::Component)?;
            if let Some(m_set) = optional_imposed_mass_flow(params) {
                comp = comp.with_imposed_mass_flow(m_set).map_err(CliError::Component)?;
            }
            Ok(Box::new(comp))
        }

        "AirSource" => {
            use entropyk::AirSource;
            use entropyk_core::{Pressure, RelativeHumidity, Temperature};

            let p_set = params.get("p_set_bar").and_then(|v| v.as_f64()).unwrap_or(1.01325);
            let t_dry = params.get("t_dry_c").and_then(|v| v.as_f64()).unwrap_or(35.0);
            let rh = params.get("rh").and_then(|v| v.as_f64()).unwrap_or(50.0);
            let t_wet = params.get("t_wet_c").and_then(|v| v.as_f64());

            let outlet = make_connected_port("Air", p_set, 50.0);

            let mut comp = if let Some(tw) = t_wet {
                AirSource::from_dry_and_wet_bulb(
                    Temperature::from_celsius(t_dry),
                    Temperature::from_celsius(tw),
                    Pressure::from_bar(p_set),
                    outlet,
                )
            } else {
                AirSource::from_dry_bulb_rh(
                    Temperature::from_celsius(t_dry),
                    RelativeHumidity::from_percent(rh),
                    Pressure::from_bar(p_set),
                    outlet,
                )
            }
            .map_err(CliError::Component)?;
            let m_set = optional_imposed_mass_flow(params);
            if let Some(m) = m_set {
                comp = comp.with_imposed_mass_flow(m).map_err(CliError::Component)?;
            }
            let default_fix_p = m_set.is_none();
            comp = comp
                .with_impose_pressure(param_fix_flag(params, "fix_pressure", default_fix_p))
                .with_impose_temperature(param_fix_flag(params, "fix_temperature", true));
            Ok(Box::new(comp))
        }

        "AirSink" => {
            use entropyk::AirSink;
            use entropyk_core::{Pressure, RelativeHumidity, Temperature};

            let p_back = params.get("p_back_bar").and_then(|v| v.as_f64()).unwrap_or(1.01325);
            let t_raw = params.get("t_back_c").and_then(|v| v.as_f64());
            let t_back = match (t_raw, params.get("fix_temperature")) {
                (Some(_), Some(_)) if !param_fix_flag(params, "fix_temperature", true) => None,
                (Some(t), _) => Some(t),
                (None, _) => None,
            };
            let rh_back = params.get("rh_back").and_then(|v| v.as_f64()).unwrap_or(50.0);

            let inlet = make_connected_port("Air", p_back, 50.0);
            let mut comp = AirSink::new(Pressure::from_bar(p_back), inlet)
                .map_err(CliError::Component)?;

            if let Some(tb) = t_back {
                comp.set_return_temperature(
                    Temperature::from_celsius(tb),
                    RelativeHumidity::from_percent(rh_back),
                )
                .map_err(CliError::Component)?;
            }
            if let Some(m_set) = optional_imposed_mass_flow(params) {
                comp = comp.with_imposed_mass_flow(m_set).map_err(CliError::Component)?;
            }
            Ok(Box::new(comp))
        }

        "Pump" => {
            use entropyk::{ComponentFluidId, Pump, PumpCurves, Port};
            use entropyk_core::{Enthalpy, Pressure};

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str());
            let fluid_density = params
                .get("fluid_density_kg_m3")
                .and_then(|v| v.as_f64())
                .unwrap_or(1000.0);
            let speed_ratio = params
                .get("speed_ratio")
                .and_then(|v| v.as_f64())
                .unwrap_or(1.0);

            let curves = if let (Some(h), Some(e)) = (
                params.get("head_coeffs").and_then(|v| v.as_array()),
                params.get("eff_coeffs").and_then(|v| v.as_array()),
            ) {
                let head_coeffs: Vec<f64> = h.iter().filter_map(|v| v.as_f64()).collect();
                let eff_coeffs: Vec<f64> = e.iter().filter_map(|v| v.as_f64()).collect();
                if !head_coeffs.is_empty() && !eff_coeffs.is_empty() {
                    PumpCurves::from_coefficients(head_coeffs, eff_coeffs).map_err(CliError::Component)?
                } else {
                    PumpCurves::default()
                }
            } else {
                PumpCurves::default()
            };

            let p_in = params
                .get("p_inlet_bar")
                .and_then(|v| v.as_f64())
                .unwrap_or(2.0);
            let h_in = params
                .get("h_inlet_kj_kg")
                .and_then(|v| v.as_f64())
                .unwrap_or(100.0);
            let p_out = params
                .get("p_outlet_bar")
                .and_then(|v| v.as_f64())
                .unwrap_or(2.0);
            let h_out = params
                .get("h_outlet_kj_kg")
                .and_then(|v| v.as_f64())
                .unwrap_or(100.0);

            let fluid_id = ComponentFluidId::new(fluid);

            let inlet_a = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_in),
                Enthalpy::from_joules_per_kg(h_in * 1000.0),
            );
            let outlet_a = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_out),
                Enthalpy::from_joules_per_kg(h_out * 1000.0),
            );

            let pump_disconnected = Pump::new(curves, inlet_a, outlet_a, fluid_density)
                .map_err(CliError::Component)?;

            let inlet_b = Port::new(
                fluid_id.clone(),
                Pressure::from_bar(p_in),
                Enthalpy::from_joules_per_kg(h_in * 1000.0),
            );
            let outlet_b = Port::new(
                fluid_id,
                Pressure::from_bar(p_out),
                Enthalpy::from_joules_per_kg(h_out * 1000.0),
            );

            let mut pump = pump_disconnected
                .connect(inlet_b, outlet_b)
                .map_err(CliError::Component)?;

            if (speed_ratio - 1.0).abs() > 1e-9 {
                pump.set_speed_ratio(speed_ratio)
                    .map_err(CliError::Component)?;
            }

            Ok(Box::new(pump))
        }

        // ── BphxEvaporator (brazed plate HX evaporator) ─────────────────────────
        "BphxEvaporator" => {
            use entropyk_components::heat_exchanger::{BphxEvaporator, BphxType};

            let geo = bphx_geometry_from_params(params, BphxType::Evaporator)?;
            let refrigerant = params
                .get("refrigerant")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str());
            let secondary_fluid = params
                .get("secondary_fluid")
                .and_then(|v| v.as_str())
                .unwrap_or("Water");

            // BphxEvaporator is DX-only: a brazed plate exchanger's refrigerant
            // outlet is always superheated vapor. A "flooded" operating style is
            // a system-topology property (Drum + recirculation loop around a
            // DX-terminated exchanger), not a mode of the exchanger itself.
            if let Some(mode) = params.get("mode").and_then(|v| v.as_str()) {
                let mode = mode.to_lowercase();
                if mode != "dx" {
                    return Err(CliError::Config(format!(
                        "BphxEvaporator: unsupported mode '{mode}'. Only 'dx' is supported — brazed plate exchangers are modeled as Direct Expansion only. Use FloodedEvaporator for a shell-and-tube flooded design."
                    )));
                }
            }
            let target_superheat = params
                .get("target_superheat_k")
                .and_then(|v| v.as_f64())
                .unwrap_or(5.0);
            if target_superheat < 0.0 {
                return Err(CliError::Config(format!(
                    "BphxEvaporator: target_superheat_k must be >= 0 (got {:.2} K)",
                    target_superheat
                )));
            }

            let mut evap = BphxEvaporator::new(geo)
                .with_target_superheat(target_superheat)
                .with_refrigerant(refrigerant)
                .with_secondary_fluid(secondary_fluid)
                .with_fluid_backend(Arc::clone(&backend))
                .with_dp_correlation(bphx_dp_correlation_from_params(
                    params,
                    "BphxEvaporator",
                )?);
            if let Some(correlation) =
                bphx_correlation_from_params(params, "BphxEvaporator")?
            {
                evap = evap.with_correlation(correlation);
            }

            // Emergent-pressure mode: +1 outlet-closure residual pins h_out to
            // h(P, Tsat + target_superheat) so P_evap emerges from the secondary.
            if params
                .get("emergent_pressure")
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                evap = evap.with_emergent_pressure();
            }

            // Convention (Evaporator): hot_fluid = secondary (brine/water), cold_fluid = refrigerant.
            // The refrigerant evaporates (absorbs heat from the secondary).
            // Note: this is opposite to the Condenser convention — see BphxCondenser.
            if params.contains_key("hot_fluid") {
                let hot = parse_side_conditions(params, "hot")?;
                evap.set_secondary_conditions(hot);
            }
            if params.contains_key("cold_fluid") {
                let cold = parse_side_conditions(params, "cold")?;
                evap.set_refrigerant_conditions(cold);
            }

            evap.set_calib(bphx_calib_from_params(params, evap.ua())?);

            Ok(Box::new(evap))
        }

        // ── BphxCondenser (brazed plate HX condenser) ───────────────────────────
        "BphxCondenser" => {
            use entropyk_components::heat_exchanger::{BphxCondenser, BphxType};

            let geo = bphx_geometry_from_params(params, BphxType::Condenser)?;
            let refrigerant = params
                .get("refrigerant")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str());
            let secondary_fluid = params
                .get("secondary_fluid")
                .and_then(|v| v.as_str())
                .unwrap_or("Water");
            let target_subcooling = params
                .get("target_subcooling_k")
                .and_then(|v| v.as_f64())
                .unwrap_or(3.0);

            let mut cond = BphxCondenser::new(geo)
                .with_refrigerant(refrigerant)
                .with_secondary_fluid(secondary_fluid)
                .with_fluid_backend(Arc::clone(&backend))
                .with_target_subcooling(target_subcooling)
                .with_dp_correlation(bphx_dp_correlation_from_params(
                    params,
                    "BphxCondenser",
                )?);
            if let Some(correlation) =
                bphx_correlation_from_params(params, "BphxCondenser")?
            {
                cond = cond.with_correlation(correlation);
            }

            // Emergent-pressure mode: +1 outlet-closure residual pins h_out to
            // h(P, Tsat − subcooling) so P_cond emerges from the secondary.
            if params
                .get("emergent_pressure")
                .and_then(|v| v.as_bool())
                .unwrap_or(false)
            {
                let subcooling_k = params
                    .get("subcooling_k")
                    .and_then(|v| v.as_f64())
                    .or_else(|| params.get("target_subcooling_k").and_then(|v| v.as_f64()))
                    .unwrap_or(target_subcooling);
                cond = cond.with_emergent_pressure(subcooling_k);
            }

            // Convention (Condenser): hot_fluid = refrigerant, cold_fluid = secondary (brine/water).
            // The refrigerant condenses (releases heat to the secondary).
            // Note: this is opposite to the Evaporator convention — see BphxEvaporator.
            if params.contains_key("hot_fluid") {
                let hot = parse_side_conditions(params, "hot")?;
                cond.set_refrigerant_conditions(hot);
            }
            if params.contains_key("cold_fluid") {
                let cold = parse_side_conditions(params, "cold")?;
                cond.set_secondary_conditions(cold);
            }

            cond.set_calib(bphx_calib_from_params(params, cond.ua())?);

            Ok(Box::new(cond))
        }

        "ThermalLoad" => {
            use entropyk::ThermalLoad;
            // Cold-side receiver of a physical `thermal_couplings` entry
            // (BOLT `BoundaryNode.Coolant` pattern): the loop's pressure and
            // inlet temperature MUST be fixed by boundary components
            // (`BrineSource` → ThermalLoad → `BrineSink`, sink temperature
            // left free). The load imposes the design flow and consumes the
            // coupled heat Q in its energy balance (T_out is emergent).
            let mass_flow = params
                .get("mass_flow_kg_s")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.5);
            let load =
                ThermalLoad::new(&component_config.name, mass_flow).map_err(CliError::Component)?;
            Ok(Box::new(load))
        }

        "Probe" => {
            use entropyk::{ComponentFluidId, Enthalpy, Port, Probe, ProbeMeasure, Pressure};
            // Calibration-redesign measurement tap (zero-residual, 2-port splice).
            // The user drops it onto a wire (UI `insertOnEdge`) and picks a
            // measure kind; the freed z-factor on a calibrated component
            // elsewhere is paired with this Probe via a calibration control
            // whose `measure.component` names the Probe.
            let measure_str = params
                .get("measure")
                .and_then(|v| v.as_str())
                .unwrap_or("SH");
            let measure = ProbeMeasure::parse(measure_str).map_err(CliError::Config)?;

            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .unwrap_or_else(|| _primary_fluid.as_str())
                .to_string();

            // Nominal edge state for the port pair (mirrors Pipe's idiom —
            // both pairs carry identical nominal values, the live solver state
            // overrides them via `set_system_context`).
            let p_bar = params.get("p_bar").and_then(|v| v.as_f64()).unwrap_or(5.0);
            let h_j = params
                .get("h_j_kg")
                .and_then(|v| v.as_f64())
                .or_else(|| {
                    params
                        .get("h_kj_kg")
                        .and_then(|v| v.as_f64())
                        .map(|k| k * 1000.0)
                })
                .unwrap_or(250_000.0);

            let mk = || {
                Port::new(
                    ComponentFluidId::new(&fluid),
                    Pressure::from_bar(p_bar),
                    Enthalpy::from_joules_per_kg(h_j),
                )
            };
            let probe = Probe::new(measure, &fluid, mk(), mk())
                .with_fluid_backend(backend)
                .connect(mk(), mk())
                .map_err(CliError::Component)?;
            Ok(Box::new(probe))
        }

        "Anchor" | "RefrigerantNode" => {
            use entropyk::{Anchor, AnchorConstraint};
            // BOLT `BoundaryNode.Refrigerant.Node` equivalent: inline spec node
            // with pass-through continuity (P_out=P_in, h_out=h_in). With no
            // constraint it is a DoF-neutral probe; with ONE of the optional
            // specs below it consumes one system DoF (pair it with a freed
            // quantity: emergent pressure, free actuator, free boundary):
            //   superheat_k   dTsh spec (negative = subcooling), BOLT dTsh_fixed
            //   quality       vapor quality spec, BOLT x_fixed
            //   t_c / t_k     absolute temperature spec, BOLT T_fixed
            //   p_bar         absolute pressure spec
            let fluid = params
                .get("fluid")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| _primary_fluid.as_str());
            let mut anchor = Anchor::new(&component_config.name, fluid);

            let constraint = if let Some(dtsh) =
                params.get("superheat_k").and_then(|v| v.as_f64())
            {
                Some(AnchorConstraint::Superheat(dtsh))
            } else if let Some(x) = params.get("quality").and_then(|v| v.as_f64()) {
                Some(AnchorConstraint::Quality(x))
            } else if let Some(t_k) = params
                .get("t_k")
                .and_then(|v| v.as_f64())
                .or_else(|| params.get("t_c").and_then(|v| v.as_f64()).map(|c| c + 273.15))
            {
                Some(AnchorConstraint::Temperature(t_k))
            } else {
                params
                    .get("p_bar")
                    .and_then(|v| v.as_f64())
                    .map(|p| AnchorConstraint::Pressure(p * 1.0e5))
            };
            if let Some(c) = constraint {
                anchor = anchor.with_constraint(c);
            }
            anchor = anchor.with_fluid_backend(backend);
            Ok(Box::new(anchor))
        }

        "HeatSource" => {
            use entropyk::HeatSource;
            // BOLT `BoundaryNode.Heat.Source` equivalent: injects `q_w` [W]
            // (or `q_kw`) into the stream (negative extracts heat). For the
            // linked mode (BOLT `use_Q_flow_in`, e.g. motor-cooling duty),
            // reference this component as `cold_component` of a
            // `thermal_couplings` entry: the coupled unknown Q adds to q_w.
            let q_w = params
                .get("q_w")
                .and_then(|v| v.as_f64())
                .or_else(|| params.get("q_kw").and_then(|v| v.as_f64()).map(|q| q * 1e3))
                .unwrap_or(0.0);
            let hs = HeatSource::new(&component_config.name, q_w).map_err(CliError::Component)?;
            Ok(Box::new(hs))
        }

        "Placeholder" => {
            let n_eqs = params.get("n_equations").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
            // A Placeholder is INERT: it reserves `n_equations` state slots but imposes
            // zero residuals, so its edges keep whatever value the initializer gave them.
            // It contributes no physics and MUST NOT be used to model a real fluid loop
            // (pump/load/heat exchange). Replace it with concrete components.
            tracing::warn!(
                n_equations = n_eqs,
                "Placeholder component is inert (imposes no equations). Its edges will not be \
                 solved for any physics — replace it with real components for a valid model."
            );
            Ok(Box::new(SimpleComponent::new("", n_eqs)))
        }

        "FreeCoolingExchanger" | "FreeCooling" => {
            use entropyk::{FreeCoolingConfig, FreeCoolingExchanger};
            use entropyk_components::port::{FluidId, Port};
            use entropyk_core::{CircuitId, Enthalpy, Pressure};

            let effectiveness = params.get("effectiveness").and_then(|v| v.as_f64()).unwrap_or(0.85);
            let ua = params.get("ua").and_then(|v| v.as_f64()).unwrap_or(10_000.0);
            let cold_mass_flow = params.get("coldMassFlow").and_then(|v| v.as_f64()).unwrap_or(0.5);
            let hot_mass_flow = params.get("hotMassFlow").and_then(|v| v.as_f64()).unwrap_or(0.5);
            let cold_cp = params.get("coldCp").and_then(|v| v.as_f64()).unwrap_or(4186.0);
            let hot_cp = params.get("hotCp").and_then(|v| v.as_f64()).unwrap_or(4186.0);

            let config = FreeCoolingConfig {
                effectiveness,
                ua,
                cold_mass_flow,
                hot_mass_flow,
                cold_cp,
                hot_cp,
                ..Default::default()
            };

            let circuit_id = CircuitId(0);
            let _fluid = FluidId::new("Water");
            let p = Pressure::from_pascals(3e5);
            let h = Enthalpy::from_joules_per_kg(63_000.0);

            let (ci, co) = Port::new(FluidId::new("Water"), p, h)
                .connect(Port::new(FluidId::new("Water"), p, h))
                .map_err(|e| CliError::Config(format!("Port connect error: {}", e)))?;
            let (hi, ho) = Port::new(FluidId::new("Water"), p, h)
                .connect(Port::new(FluidId::new("Water"), p, h))
                .map_err(|e| CliError::Config(format!("Port connect error: {}", e)))?;

            let fc = FreeCoolingExchanger::new("freecooling", circuit_id, config, ci, co, hi, ho)
                .map_err(|e| CliError::Config(format!("FreeCoolingExchanger error: {}", e)))?;

            Ok(Box::new(fc))
        }

        _ => Err(CliError::Config(format!(
            "Unknown component type: '{}'. Supported: IsentropicCompressor, IsenthalpicExpansionValve, ScrewEconomizerCompressor, CentrifugalCompressor, CapillaryTube, MchxCondenserCoil, FloodedEvaporator, BphxEvaporator, BphxCondenser, FreeCoolingExchanger, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, ReversingValve, Pump, Fan, Pipe, RefrigerantPipe, WaterPipe, AirDuct, Probe, Anchor, Placeholder",
            component_type
        ))),
    }
}

/// Extract state entries from converged state.
///
/// Uses the system's edge→state index map (CM1.4 layout) rather than assuming a
/// flat `(P, h)` stride-2 vector: the real layout is `[ṁ_branch…, P, h, P, h, …]`
/// with shared branch mass-flow slots, so a naive stride-2 read is off by the
/// number of leading ṁ unknowns and mislabels pressures as enthalpies.
fn extract_state(
    system: &entropyk::System,
    converged: &entropyk::ConvergedState,
    fluid: &str,
    backend: &dyn entropyk_fluids::FluidBackend,
) -> Vec<StateEntry> {
    use entropyk_fluids::FluidId;

    let state = &converged.state;
    let refrigerant = FluidId::new(fluid);

    system
        .edge_indices()
        .map(|e| {
            let (m_idx, p_idx, h_idx) = system.edge_state_indices_full(e);
            let p_pa = state[p_idx];
            let h_j = state[h_idx];
            let pressure_bar = p_pa / 1e5;
            let enthalpy_kj_kg = h_j / 1000.0;
            let mass_flow_kg_s = state.get(m_idx).copied().filter(|m| m.is_finite());

            let (source, source_port, target, target_port) = match system.edge_endpoints(e) {
                Some((src, tgt)) => {
                    let edge_w = system.graph().edge_weight(e);
                    let sp = edge_w.map(|w| w.source_port).unwrap_or(1);
                    let tp = edge_w.map(|w| w.target_port).unwrap_or(0);
                    (
                        Some(component_display_name(system, src)),
                        Some(port_display_name(system, src, sp)),
                        Some(component_display_name(system, tgt)),
                        Some(port_display_name(system, tgt, tp)),
                    )
                }
                None => (None, None, None, None),
            };

            // CRITICAL: secondary water/air edges must NOT flash with the cycle
            // refrigerant (that produced absurd T ≈ −58 °C on BrineSource outlets).
            let medium = edge_thermo_medium(
                source.as_deref(),
                source_port.as_deref(),
                target.as_deref(),
                target_port.as_deref(),
            );
            let (temperature_c, saturation_temperature_c) = match medium {
                EdgeThermoMedium::Refrigerant => {
                    edge_temperature_props(backend, &refrigerant, p_pa, h_j, true)
                }
                EdgeThermoMedium::Water => {
                    edge_temperature_props(backend, &FluidId::new("Water"), p_pa, h_j, false)
                }
                EdgeThermoMedium::Air => {
                    edge_temperature_props(backend, &FluidId::new("Air"), p_pa, h_j, false)
                }
            };

            StateEntry {
                edge: e.index(),
                pressure_bar,
                enthalpy_kj_kg,
                mass_flow_kg_s,
                source,
                target,
                source_port,
                target_port,
                temperature_c,
                saturation_temperature_c,
            }
        })
        .collect()
}

#[derive(Clone, Copy)]
enum EdgeThermoMedium {
    Refrigerant,
    Water,
    Air,
}

/// Classify an edge's thermodynamic medium from port / component names.
fn edge_thermo_medium(
    source: Option<&str>,
    source_port: Option<&str>,
    target: Option<&str>,
    target_port: Option<&str>,
) -> EdgeThermoMedium {
    let ports = [source_port.unwrap_or(""), target_port.unwrap_or("")].join(" ");
    let names = [source.unwrap_or(""), target.unwrap_or("")].join(" ");
    let blob = format!("{ports} {names}").to_ascii_lowercase();

    if blob.contains("air")
        || blob.contains("airsource")
        || blob.contains("airsink")
        || blob.contains("duct")
    {
        return EdgeThermoMedium::Air;
    }
    if blob.contains("secondary")
        || blob.contains("water")
        || blob.contains("brine")
        || blob.contains("glycol")
        || blob.contains("brinesource")
        || blob.contains("brinesink")
    {
        return EdgeThermoMedium::Water;
    }
    EdgeThermoMedium::Refrigerant
}

fn edge_temperature_props(
    backend: &dyn entropyk_fluids::FluidBackend,
    fluid: &entropyk_fluids::FluidId,
    p_pa: f64,
    h_j: f64,
    with_tsat: bool,
) -> (Option<f64>, Option<f64>) {
    use entropyk_core::{Enthalpy, Pressure};
    use entropyk_fluids::{FluidState, Property, Quality};

    let temperature_c = backend
        .property(
            fluid.clone(),
            Property::Temperature,
            FluidState::from_ph(
                Pressure::from_pascals(p_pa),
                Enthalpy::from_joules_per_kg(h_j),
            ),
        )
        .ok()
        .filter(|t| t.is_finite() && *t > 150.0 && *t < 800.0)
        .map(|t_k| t_k - 273.15);

    let saturation_temperature_c = if with_tsat {
        backend
            .property(
                fluid.clone(),
                Property::Temperature,
                FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.0)),
            )
            .ok()
            .filter(|t| t.is_finite() && *t > 150.0 && *t < 800.0)
            .map(|t_k| t_k - 273.15)
    } else {
        None // liquid water / air loops: Tsat is not meaningful for the UI
    };

    (temperature_c, saturation_temperature_c)
}

fn component_display_name(system: &entropyk::System, node: petgraph::graph::NodeIndex) -> String {
    for name in system.registered_component_names() {
        if system.get_component_node(name) == Some(node) {
            return name.to_string();
        }
    }
    format!("node_{}", node.index())
}

fn port_display_name(
    system: &entropyk::System,
    node: petgraph::graph::NodeIndex,
    port_idx: usize,
) -> String {
    let names = system.component(node).port_names();
    if let Some(n) = names.get(port_idx) {
        return n.clone();
    }
    match port_idx {
        0 => "inlet".into(),
        1 => "outlet".into(),
        2 => "secondary_inlet".into(),
        3 => "secondary_outlet".into(),
        i => format!("port_{i}"),
    }
}

/// Computes cycle performance metrics (cooling/heating capacity, compressor
/// power, COP) from the converged system state.
///
/// Delegates to [`entropyk::System::cycle_performance`], which sums each
/// component's energy transfers by physical sign. Returns `None` when no
/// component reported energy transfers (e.g. topologies built entirely from
/// components that do not implement `energy_transfers`).
fn compute_performance(
    system: &entropyk::System,
    converged: &entropyk::ConvergedState,
) -> Option<PerformanceMetrics> {
    let perf = system.cycle_performance(&converged.state)?;
    Some(PerformanceMetrics {
        q_cooling_kw: Some(perf.q_cooling_w / 1000.0),
        q_heating_kw: Some(perf.q_heating_w / 1000.0),
        compressor_power_kw: Some(perf.work_input_w / 1000.0),
        cop: perf.cop_cooling(),
    })
}

// =============================================================================
// Python-style components for CLI (no type-state pattern)
// =============================================================================

use std::fmt;

struct SimpleComponent {
    name: String,
    n_eqs: usize,
}

impl SimpleComponent {
    fn new(name: &str, n_eqs: usize) -> Self {
        Self {
            name: name.to_string(),
            n_eqs,
        }
    }
}

impl entropyk::Component for SimpleComponent {
    fn compute_residuals(
        &self,
        _state: &[f64],
        residuals: &mut entropyk::ResidualVector,
    ) -> Result<(), entropyk::ComponentError> {
        // Placeholder: all residuals are zero — this component imposes no physics constraints.
        for r in residuals.iter_mut().take(self.n_eqs) {
            *r = 0.0;
        }
        Ok(())
    }

    fn jacobian_entries(
        &self,
        _state: &[f64],
        _jacobian: &mut entropyk::JacobianBuilder,
    ) -> Result<(), entropyk::ComponentError> {
        // Placeholder imposes no constraints — no Jacobian entries.
        Ok(())
    }

    fn n_equations(&self) -> usize {
        self.n_eqs
    }
    fn get_ports(&self) -> &[entropyk::ConnectedPort] {
        &[]
    }
}

impl fmt::Debug for SimpleComponent {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SimpleComponent")
            .field("name", &self.name)
            .finish()
    }
}

// PyCompressor stub REMOVED — replaced by real Compressor<Connected> in create_component().

// PyExpansionValve stub REMOVED — replaced by real ExpansionValve<Connected> in create_component().

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

    #[test]
    fn test_simulation_status_serialization() {
        let status = SimulationStatus::Converged;
        let json = serde_json::to_string(&status).unwrap();
        assert_eq!(json, "\"converged\"");

        let status = SimulationStatus::NonConverged;
        let json = serde_json::to_string(&status).unwrap();
        assert_eq!(json, "\"non_converged\"");
    }

    #[test]
    fn test_simulation_result_serialization() {
        let result = SimulationResult {
            input: "test.json".to_string(),
            status: SimulationStatus::Converged,
            convergence: Some(ConvergenceInfo {
                final_residual: 1e-8,
                tolerance: 1e-6,
                iterations: Some(25),
                strategy: Some("newton".into()),
                iteration_history: vec![],
            }),
            iterations: Some(25),
            state: Some(vec![StateEntry {
                edge: 0,
                pressure_bar: 10.0,
                enthalpy_kj_kg: 400.0,
                ..Default::default()
            }]),
            performance: None,
            error: None,
            failure_diagnostics: None,
            initialization_diagnostics: None,
            dof: None,
            elapsed_ms: 50,
            raw_state_vector: None,
            solved_variables: Vec::new(),
        };

        let json = serde_json::to_string_pretty(&result).unwrap();
        assert!(json.contains("\"status\": \"converged\""));
        assert!(json.contains("\"iterations\": 25"));
    }

    #[test]
    fn test_resolve_port_flooded_secondary_inlet_outlet() {
        // Regression: UI wires BrineSource → FloodedEvaporator:secondary_inlet.
        assert_eq!(
            resolve_port_index("FloodedEvaporator", "secondary_inlet", false).unwrap(),
            2
        );
        assert_eq!(
            resolve_port_index("FloodedEvaporator", "secondary_outlet", true).unwrap(),
            3
        );
        assert_eq!(
            resolve_port_index("FloodedEvaporator", "inlet", false).unwrap(),
            0
        );
        assert_eq!(
            resolve_port_index("FloodedEvaporator", "outlet", true).unwrap(),
            1
        );
        // Fallback arm (unknown HX type) must still accept secondary_* aliases.
        assert_eq!(
            resolve_port_index("SomeFutureHx", "secondary_inlet", false).unwrap(),
            2
        );
    }

    #[test]
    fn test_top_level_initialization_overrides_legacy_component_guesses() {
        let config = ScenarioConfig::from_json(
            r#"
            {
              "fluid": "R134a",
              "initialization": {
                "t_evap_guess_k": 271.15,
                "t_cond_guess_k": 315.15,
                "superheat_guess_k": 6.0
              }
            }
            "#,
        )
        .unwrap();

        let guesses =
            resolve_initialization_guesses(&config, Some(280.15), Some(330.15), Some(12.0));

        assert_eq!(guesses.t_evap_k, 271.15);
        assert_eq!(guesses.t_cond_k, 315.15);
        assert_eq!(guesses.superheat_k, 6.0);
    }

    #[test]
    fn test_bphx_geometry_uses_detailed_plate_pack() {
        use entropyk_components::heat_exchanger::BphxType;
        use serde_json::json;
        use std::collections::HashMap;

        let params = HashMap::from([
            ("n_plates".to_string(), json!(21)),
            ("plate_length_m".to_string(), json!(0.4)),
            ("plate_width_m".to_string(), json!(0.1)),
            ("channel_spacing_mm".to_string(), json!(1.8)),
            ("plate_thickness_mm".to_string(), json!(0.5)),
            ("chevron_angle_deg".to_string(), json!(55.0)),
            ("corrugation_pitch_mm".to_string(), json!(4.0)),
        ]);

        let geometry = bphx_geometry_from_params(&params, BphxType::Evaporator).unwrap();

        assert_eq!(geometry.n_plates, 21);
        assert!((geometry.dh - 0.0036).abs() < 1e-12);
        assert!((geometry.area - 1.6).abs() < 1e-12);
        assert!((geometry.chevron_angle - 55.0).abs() < 1e-12);
    }

    #[test]
    fn test_bphx_geometry_preserves_direct_manufacturer_data() {
        use entropyk_components::heat_exchanger::BphxType;
        use serde_json::json;
        use std::collections::HashMap;

        let params = HashMap::from([
            ("n_plates".to_string(), json!(42)),
            ("dh_m".to_string(), json!(0.0024)),
            ("area_m2".to_string(), json!(3.75)),
        ]);

        let geometry = bphx_geometry_from_params(&params, BphxType::Condenser).unwrap();

        assert_eq!(geometry.n_plates, 42);
        assert!((geometry.dh - 0.0024).abs() < 1e-12);
        assert!((geometry.area - 3.75).abs() < 1e-12);
    }

    #[test]
    fn test_bphx_geometry_rejects_partial_direct_override() {
        use entropyk_components::heat_exchanger::BphxType;
        use serde_json::json;
        use std::collections::HashMap;

        let params = HashMap::from([("dh_m".to_string(), json!(0.0024))]);
        let error = bphx_geometry_from_params(&params, BphxType::Condenser).unwrap_err();

        assert!(error
            .to_string()
            .contains("dh_m and area_m2 must be provided together"));
    }

    #[test]
    fn test_bphx_correlation_absence_preserves_automatic_mode() {
        let params = std::collections::HashMap::new();
        assert!(bphx_correlation_from_params(&params, "BphxEvaporator")
            .unwrap()
            .is_none());
    }

    #[test]
    fn test_bphx_correlation_preserves_accepted_names() {
        use entropyk_components::heat_exchanger::BphxCorrelation;
        use serde_json::json;

        for (name, expected) in [
            ("Longo2004", BphxCorrelation::Longo2004),
            ("SHAH1979", BphxCorrelation::Shah1979),
            ("shah2021", BphxCorrelation::Shah2021),
        ] {
            let params =
                std::collections::HashMap::from([("correlation".to_string(), json!(name))]);
            assert_eq!(
                bphx_correlation_from_params(&params, "BphxCondenser").unwrap(),
                Some(expected)
            );
        }
    }

    #[test]
    fn test_bphx_correlation_preserves_invalid_name_error() {
        use serde_json::json;

        let params = std::collections::HashMap::from([(
            "correlation".to_string(),
            json!("not-a-correlation"),
        )]);
        let error = bphx_correlation_from_params(&params, "BphxEvaporator").unwrap_err();
        assert_eq!(
            error.to_string(),
            "Configuration error: BphxEvaporator: unsupported correlation 'not-a-correlation'. Use 'Longo2004', 'Shah1979', or 'Shah2021'."
        );
    }

    #[test]
    fn test_bphx_dp_correlation_defaults_and_aliases() {
        use entropyk_components::heat_exchanger::BphxDpCorrelation;
        use serde_json::json;

        assert_eq!(
            bphx_dp_correlation_from_params(&std::collections::HashMap::new(), "BphxEvaporator")
                .unwrap(),
            BphxDpCorrelation::SimplifiedChannel
        );
        let params =
            std::collections::HashMap::from([("DpCorrelation".to_string(), json!("Martin1996"))]);
        assert_eq!(
            bphx_dp_correlation_from_params(&params, "BphxCondenser").unwrap(),
            BphxDpCorrelation::Martin1996
        );
    }

    #[test]
    fn test_bphx_dp_correlation_rejects_unknown() {
        use serde_json::json;

        let params =
            std::collections::HashMap::from([("dp_correlation".to_string(), json!("Amalfi2016"))]);
        let error = bphx_dp_correlation_from_params(&params, "BphxEvaporator").unwrap_err();
        assert!(
            error.to_string().contains("unsupported dp_correlation"),
            "{error}"
        );
    }

    #[test]
    fn test_bphx_dp_correlation_rejects_non_string() {
        use serde_json::json;

        let params = std::collections::HashMap::from([("dp_correlation".to_string(), json!(42))]);
        let error = bphx_dp_correlation_from_params(&params, "BphxEvaporator").unwrap_err();
        assert!(error.to_string().contains("must be a string"), "{error}");
    }

    fn comp_from_json(v: serde_json::Value) -> crate::config::ComponentConfig {
        serde_json::from_value(v).expect("valid component JSON")
    }

    #[test]
    fn test_estimate_hx_dp_pa_rated_tube_and_plain() {
        use serde_json::json;
        let config: ScenarioConfig = serde_json::from_value(json!({
            "fluid": "R134a",
            "circuits": [{
                "components": [
                    {"type": "Condenser", "name": "cond", "rated_pressure_drop_pa": 15000.0},
                    {"type": "Evaporator", "name": "evap", "dp_model": "msh", "tube_length_m": 6.0},
                    {"type": "Pipe", "name": "pipe"}
                ]
            }]
        }))
        .expect("valid scenario JSON");
        let indices = std::collections::HashMap::from([
            (
                "cond".to_string(),
                (petgraph::graph::NodeIndex::new(1), "Condenser".to_string()),
            ),
            (
                "evap".to_string(),
                (petgraph::graph::NodeIndex::new(3), "Evaporator".to_string()),
            ),
            (
                "pipe".to_string(),
                (petgraph::graph::NodeIndex::new(5), "Pipe".to_string()),
            ),
        ]);
        let map = estimate_hx_dp_pa(&config, &indices);
        assert_eq!(map.get(&1), Some(&15000.0));
        assert_eq!(
            map.get(&3),
            Some(
                &entropyk_components::heat_exchanger::two_phase_dp::DEFAULT_REFRIGERANT_DP_NOMINAL_PA
            )
        );
        assert!(!map.contains_key(&5));
    }

    #[test]
    fn test_boundary_mass_flow_seeding() {
        use serde_json::json;
        let backend = entropyk_fluids::TestBackend::new();
        // A source seeds the branch ṁ with its imposed value.
        let source = comp_from_json(
            json!({"type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.31}),
        );
        let seed = boundary_seed_from_component(&source, &backend, true)
            .expect("BrineSource seed should evaluate");
        assert_eq!(seed.3, 0.31);
        // A sink without explicit ṁ falls back to the default seed.
        let sink = comp_from_json(
            json!({"type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0, "fix_pressure": true}),
        );
        let seed = boundary_seed_from_component(&sink, &backend, false)
            .expect("BrineSink seed should evaluate");
        assert_eq!(seed.3, entropyk_solver::system::DEFAULT_MASS_FLOW_SEED_KG_S);
    }
}
