//! Seasonal bin-method ratings — **SCOP** (heating) and **SEER** (cooling).
//!
//! Implements the EN 14825 temperature-bin method on top of the genuinely coupled
//! cycle solve. For each temperature bin of a climate, the machine is re-solved
//! with its *outdoor* heat-exchanger secondary inlet driven to the bin
//! temperature, yielding a real full-capacity duty and COP at that condition. A
//! linear building load line gives the demand at each bin; part-load cycling
//! degradation and (for heating) an electric backup heater are applied; and the
//! bins are aggregated into the seasonal metric
//!
//! ```text
//! SCOP or SEER = Σ (hoursⱼ · demandⱼ) / Σ (hoursⱼ · demandⱼ / COPⱼ)
//! ```
//!
//! Nothing is imposed: every bin's efficiency emerges from the coupled
//! heat-exchanger ↔ secondary balance. The climate (bin table), the building load
//! line and the degradation/backup coefficients are all data, so a revised norm or
//! a different climate is a config change, not a code change.
//!
//! # Physics summary (per bin `j` at outdoor temperature `Tⱼ`)
//!
//! 1. **Demand** from the linear building load line
//!    (heating: `demand = design_load · (T_threshold − Tⱼ)/(T_threshold − T_design)`;
//!    cooling: `demand = design_load · (Tⱼ − T_threshold)/(T_design − T_threshold)`),
//!    clamped to `≥ 0`.
//! 2. **Full-capacity solve** with the outdoor secondary inlet set to `Tⱼ` gives
//!    the machine's useful duty `Q_full` and compressor power `W_full`, hence
//!    `COP_full = Q_full / W_full`.
//! 3. **Capacity ratio** `CR = demand / Q_full`.
//!    - `CR ≤ 1`: the machine modulates/cycles to match demand. Cycling losses are
//!      captured by a part-load factor `PLF = 1 − Cd·(1 − CR)`, giving
//!      `COP_bin = COP_full · PLF`.
//!    - `CR > 1` (heating only): the machine runs full and an electric backup of
//!      efficiency `backup_cop` covers the deficit, so
//!      `COP_bin = demand / (W_full + (demand − Q_full)/backup_cop)`.
//!      For cooling the load is capped at capacity (`CR = 1`, no backup).

use std::path::{Path, PathBuf};

use rayon::prelude::*;
use serde::{Deserialize, Serialize};

use entropyk::rating::{scop, BinClimateStandard, BinPerformance};

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

/// Which seasonal bin metric to compute.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeasonalMode {
    /// Seasonal Coefficient Of Performance (heating). Useful duty = heating.
    Scop,
    /// Seasonal Energy Efficiency Ratio (cooling). Useful duty = cooling.
    Seer,
}

impl SeasonalMode {
    fn label(self) -> &'static str {
        match self {
            SeasonalMode::Scop => "SCOP",
            SeasonalMode::Seer => "SEER",
        }
    }

    /// The heat-exchanger side whose secondary inlet is driven by the outdoor bin
    /// temperature, by default (heat pump: outdoor = evaporator; chiller: outdoor
    /// = condenser).
    fn default_outdoor_side(self) -> OutdoorSide {
        match self {
            SeasonalMode::Scop => OutdoorSide::Evaporator,
            SeasonalMode::Seer => OutdoorSide::Condenser,
        }
    }
}

/// Which heat exchanger the outdoor (ambient) temperature drives.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutdoorSide {
    /// The evaporator secondary inlet follows the outdoor temperature.
    Evaporator,
    /// The condenser secondary inlet follows the outdoor temperature.
    Condenser,
}

impl OutdoorSide {
    /// Component types matched when applying the outdoor temperature override.
    #[allow(dead_code)]
    fn matches(self, component_type: &str) -> bool {
        match self {
            OutdoorSide::Evaporator => {
                matches!(component_type, "Evaporator" | "FloodedEvaporator")
            }
            OutdoorSide::Condenser => component_type == "Condenser",
        }
    }
}

/// Seasonal rating configuration (JSON).
///
/// The climate (bin table) is selected — in order of precedence — by an inline
/// [`climate`](Self::climate) object, a [`climate_file`](Self::climate_file) path,
/// a [`climate_name`](Self::climate_name) built-in id, or (for SCOP) the default
/// EN 14825 average heating season.
#[derive(Debug, Clone, Deserialize)]
pub struct SeasonalConfig {
    /// Path to the base cycle configuration (a `run` scenario file), resolved
    /// relative to this config's directory when not absolute.
    pub base_config: PathBuf,

    /// Inline custom [`BinClimateStandard`]. Highest precedence.
    #[serde(default)]
    pub climate: Option<BinClimateStandard>,
    /// Path to a custom [`BinClimateStandard`] JSON file (resolved relative to
    /// this config's directory). Overrides [`climate_name`](Self::climate_name).
    #[serde(default)]
    pub climate_file: Option<PathBuf>,
    /// Built-in climate id (e.g. `"en_14825_average"`).
    #[serde(default)]
    pub climate_name: Option<String>,

    /// Which heat-exchanger side the outdoor temperature drives. Defaults by mode
    /// (SCOP → evaporator, SEER → condenser).
    #[serde(default)]
    pub outdoor_side: Option<OutdoorSide>,

    /// Building design load [W] at the design outdoor temperature.
    pub design_load_w: f64,
    /// Design outdoor temperature [°C] where demand equals `design_load_w`.
    pub design_outdoor_c: f64,
    /// Outdoor temperature [°C] at which building demand reaches zero
    /// (heating/cooling threshold). Default 16 °C.
    #[serde(default = "default_threshold_c")]
    pub threshold_outdoor_c: f64,

    /// Cycling degradation coefficient `Cd` for part-load operation
    /// (`PLF = 1 − Cd·(1 − CR)`). Default 0.25; set per the applicable standard or
    /// measurement.
    #[serde(default = "default_cd")]
    pub cd: f64,
    /// Efficiency (COP) of the electric backup heater used when demand exceeds
    /// capacity (heating only). Default 1.0 (resistive).
    #[serde(default = "default_backup_cop")]
    pub backup_cop: f64,
}

fn default_threshold_c() -> f64 {
    16.0
}
fn default_cd() -> f64 {
    0.25
}
fn default_backup_cop() -> f64 {
    1.0
}

/// Per-bin result row.
#[derive(Debug, Clone, Serialize)]
pub struct BinResult {
    /// Outdoor bin temperature [°C].
    pub temperature_c: f64,
    /// Annual hours in this bin.
    pub hours: f64,
    /// Building demand at this bin [W].
    pub demand_w: f64,
    /// Machine full-capacity useful duty at this bin [W] (heating or cooling).
    pub capacity_w: Option<f64>,
    /// Compressor power at full capacity [W].
    pub power_w: Option<f64>,
    /// Full-capacity COP at this bin.
    pub full_cop: Option<f64>,
    /// Capacity ratio `demand / capacity` (before clamping).
    pub capacity_ratio: Option<f64>,
    /// Fraction of demand supplied by the electric backup heater (heating only).
    pub backup_fraction: f64,
    /// Effective COP after part-load degradation / backup.
    pub effective_cop: Option<f64>,
    /// Solver status ("converged", "non_converged", "error", "no_demand").
    pub status: String,
    /// Error message when the bin solve failed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// Full seasonal rating report.
#[derive(Debug, Clone, Serialize)]
pub struct SeasonalReport {
    /// Base configuration path.
    pub base_config: String,
    /// Metric label ("SCOP" or "SEER").
    pub metric: String,
    /// Climate name.
    pub climate: String,
    /// Climate citation / provenance.
    pub climate_reference: String,
    /// Total annual hours across the climate's bins.
    pub total_hours: f64,
    /// Total seasonal useful energy delivered [kWh].
    pub seasonal_useful_kwh: f64,
    /// Total seasonal electrical energy consumed [kWh].
    pub seasonal_electric_kwh: f64,
    /// Per-bin rows (ascending temperature).
    pub bins: Vec<BinResult>,
    /// The integrated seasonal metric, `None` if any demanded bin failed.
    pub integrated_value: Option<f64>,
}

impl SeasonalConfig {
    /// Resolve the effective climate, honouring precedence `climate` >
    /// `climate_file` > `climate_name` > default (EN 14825 average).
    pub fn resolve_climate(
        &self,
        mode: SeasonalMode,
        config_dir: Option<&Path>,
    ) -> CliResult<BinClimateStandard> {
        let climate = if let Some(inline) = &self.climate {
            inline.clone()
        } else if let Some(file) = &self.climate_file {
            let path = if file.is_absolute() {
                file.clone()
            } else {
                config_dir
                    .map(|d| d.join(file))
                    .unwrap_or_else(|| file.clone())
            };
            let raw = std::fs::read_to_string(&path).map_err(|e| {
                CliError::Config(format!(
                    "Failed to read climate file {}: {}",
                    path.display(),
                    e
                ))
            })?;
            serde_json::from_str(&raw)
                .map_err(|e| CliError::Config(format!("Invalid climate file: {}", e)))?
        } else if let Some(name) = &self.climate_name {
            BinClimateStandard::builtin(name).ok_or_else(|| {
                CliError::Config(format!(
                    "Unknown built-in climate '{}'. Known ids: {}",
                    name,
                    BinClimateStandard::builtin_ids().join(", ")
                ))
            })?
        } else {
            // SEER has no shipped cooling-season preset; require an explicit climate.
            if mode == SeasonalMode::Seer {
                return Err(CliError::Config(
                    "SEER requires an explicit cooling-season climate (set 'climate', \
                     'climate_file' or 'climate_name')"
                        .to_string(),
                ));
            }
            BinClimateStandard::en_14825_average()
        };
        climate
            .validate()
            .map_err(|e| CliError::Config(format!("Invalid climate: {}", e)))?;
        Ok(climate)
    }

    /// Building demand [W] at outdoor temperature `t_c`, from the linear load line
    /// (clamped to `≥ 0`).
    fn demand_at(&self, mode: SeasonalMode, t_c: f64) -> f64 {
        let span = self.design_outdoor_c - self.threshold_outdoor_c;
        if span.abs() < f64::EPSILON {
            return 0.0;
        }
        let ratio = match mode {
            // Heating: colder ⇒ more demand (design is the cold extreme).
            SeasonalMode::Scop => {
                (self.threshold_outdoor_c - t_c)
                    / (self.threshold_outdoor_c - self.design_outdoor_c)
            }
            // Cooling: hotter ⇒ more demand (design is the hot extreme).
            SeasonalMode::Seer => {
                (t_c - self.threshold_outdoor_c)
                    / (self.design_outdoor_c - self.threshold_outdoor_c)
            }
        };
        (self.design_load_w * ratio).max(0.0)
    }
}

/// Applies the outdoor-temperature override to a clone of the base config.
fn apply_outdoor_temp(base: &ScenarioConfig, side: OutdoorSide, temp_c: f64) -> ScenarioConfig {
    let target_keyword = match side {
        OutdoorSide::Evaporator => "evap",
        OutdoorSide::Condenser => "cond",
    };
    let mut config = base.clone();
    for circuit in &mut config.circuits {
        for comp in &mut circuit.components {
            if !comp.name.to_lowercase().contains(target_keyword) {
                continue;
            }
            match comp.component_type.as_str() {
                "BrineSource" => {
                    comp.params.insert("t_set_c".into(), json_num(temp_c));
                }
                "AirSource" => {
                    comp.params.insert("t_dry_c".into(), json_num(temp_c));
                }
                _ => {}
            }
        }
    }
    config
}

fn json_num(x: f64) -> serde_json::Value {
    serde_json::Number::from_f64(x)
        .map(serde_json::Value::Number)
        .unwrap_or(serde_json::Value::Null)
}

fn status_label(status: &SimulationStatus) -> &'static str {
    match status {
        SimulationStatus::Converged => "converged",
        SimulationStatus::Timeout => "timeout",
        SimulationStatus::NonConverged => "non_converged",
        SimulationStatus::Error => "error",
    }
}

/// Solves one bin at full capacity and derives its effective COP.
fn solve_bin(
    base: &ScenarioConfig,
    config: &SeasonalConfig,
    mode: SeasonalMode,
    side: OutdoorSide,
    temperature_c: f64,
    hours: f64,
) -> BinResult {
    let demand_w = config.demand_at(mode, temperature_c);

    // Bins with no building demand contribute no energy — skip the solve.
    if demand_w <= 0.0 {
        return BinResult {
            temperature_c,
            hours,
            demand_w,
            capacity_w: None,
            power_w: None,
            full_cop: None,
            capacity_ratio: None,
            backup_fraction: 0.0,
            effective_cop: None,
            status: "no_demand".to_string(),
            error: None,
        };
    }

    let scenario = apply_outdoor_temp(base, side, temperature_c);
    let json = match serde_json::to_string(&scenario) {
        Ok(j) => j,
        Err(e) => return bin_error(temperature_c, hours, demand_w, format!("serialize: {e}")),
    };

    let result = match simulate_from_json(&json) {
        Ok(r) => r,
        Err(e) => return bin_error(temperature_c, hours, demand_w, format!("{e}")),
    };

    let status = status_label(&result.status).to_string();
    if result.status != SimulationStatus::Converged {
        return BinResult {
            temperature_c,
            hours,
            demand_w,
            capacity_w: None,
            power_w: None,
            full_cop: None,
            capacity_ratio: None,
            backup_fraction: 0.0,
            effective_cop: None,
            status,
            error: result.error,
        };
    }

    let perf = result.performance.as_ref();
    let useful_kw = match mode {
        SeasonalMode::Scop => perf.and_then(|p| p.q_heating_kw),
        SeasonalMode::Seer => perf.and_then(|p| p.q_cooling_kw),
    };
    let power_kw = perf.and_then(|p| p.compressor_power_kw);

    let (capacity_w, power_w) = match (useful_kw, power_kw) {
        (Some(q), Some(w)) if q > 0.0 && w > 0.0 => (q * 1000.0, w * 1000.0),
        _ => {
            return BinResult {
                temperature_c,
                hours,
                demand_w,
                capacity_w: useful_kw.map(|q| q * 1000.0),
                power_w: power_kw.map(|w| w * 1000.0),
                full_cop: None,
                capacity_ratio: None,
                backup_fraction: 0.0,
                effective_cop: None,
                status,
                error: Some("non-physical capacity or power at this bin".to_string()),
            }
        }
    };

    let full_cop = capacity_w / power_w;
    let capacity_ratio = demand_w / capacity_w;

    let (effective_cop, backup_fraction) = if capacity_ratio <= 1.0 {
        // Part load: cycling degradation, no backup.
        let plf = 1.0 - config.cd * (1.0 - capacity_ratio);
        (full_cop * plf, 0.0)
    } else if mode == SeasonalMode::Scop {
        // Demand exceeds capacity: machine runs full, electric backup covers the rest.
        let deficit = demand_w - capacity_w;
        let elec = power_w + deficit / config.backup_cop.max(f64::EPSILON);
        (demand_w / elec, deficit / demand_w)
    } else {
        // Cooling: cap at capacity (no backup); treat as full load.
        (full_cop, 0.0)
    };

    BinResult {
        temperature_c,
        hours,
        demand_w,
        capacity_w: Some(capacity_w),
        power_w: Some(power_w),
        full_cop: Some(full_cop),
        capacity_ratio: Some(capacity_ratio),
        backup_fraction,
        effective_cop: Some(effective_cop),
        status,
        error: None,
    }
}

fn bin_error(temperature_c: f64, hours: f64, demand_w: f64, msg: String) -> BinResult {
    BinResult {
        temperature_c,
        hours,
        demand_w,
        capacity_w: None,
        power_w: None,
        full_cop: None,
        capacity_ratio: None,
        backup_fraction: 0.0,
        effective_cop: None,
        status: "error".to_string(),
        error: Some(msg),
    }
}

/// Runs the seasonal rating: solves every demanded bin (in parallel) and
/// aggregates the SCOP/SEER.
pub fn seasonal(
    config: &SeasonalConfig,
    base: &ScenarioConfig,
    climate: &BinClimateStandard,
    mode: SeasonalMode,
) -> CliResult<SeasonalReport> {
    let side = config
        .outdoor_side
        .unwrap_or_else(|| mode.default_outdoor_side());

    let mut bins: Vec<BinResult> = climate
        .bins
        .par_iter()
        .map(|b| solve_bin(base, config, mode, side, b.temperature_c, b.hours))
        .collect();
    bins.sort_by(|a, b| {
        a.temperature_c
            .partial_cmp(&b.temperature_c)
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    // A demanded bin that failed to converge makes the seasonal value unreliable.
    let any_demanded_failed = bins
        .iter()
        .any(|b| b.demand_w > 0.0 && b.effective_cop.is_none());

    let perf: Vec<BinPerformance> = bins
        .iter()
        .filter_map(|b| {
            b.effective_cop.map(|cop| BinPerformance {
                bin: entropyk::rating::TemperatureBin {
                    temperature_c: b.temperature_c,
                    hours: b.hours,
                },
                demand_w: b.demand_w,
                cop,
            })
        })
        .collect();

    let integrated_value = if any_demanded_failed {
        None
    } else {
        scop(&perf)
    };

    // Seasonal energies (kWh): Σ h·demand and Σ h·demand/cop.
    let seasonal_useful_kwh: f64 =
        perf.iter().map(|p| p.bin.hours * p.demand_w).sum::<f64>() / 1000.0;
    let seasonal_electric_kwh: f64 = perf
        .iter()
        .map(|p| p.bin.hours * p.demand_w / p.cop)
        .sum::<f64>()
        / 1000.0;

    Ok(SeasonalReport {
        base_config: config.base_config.display().to_string(),
        metric: mode.label().to_string(),
        climate: climate.name.clone(),
        climate_reference: climate.reference.clone(),
        total_hours: climate.total_hours(),
        seasonal_useful_kwh,
        seasonal_electric_kwh,
        bins,
        integrated_value,
    })
}

/// Loads a seasonal config from JSON, resolves the base scenario and climate,
/// runs the rating, and optionally writes the JSON report.
pub fn run_seasonal(
    config_path: &Path,
    output: Option<&Path>,
    mode: SeasonalMode,
) -> CliResult<SeasonalReport> {
    let raw = std::fs::read_to_string(config_path).map_err(|e| {
        CliError::Config(format!("Failed to read {}: {}", config_path.display(), e))
    })?;
    let config: SeasonalConfig = serde_json::from_str(&raw)
        .map_err(|e| CliError::Config(format!("Invalid seasonal config: {}", e)))?;

    let config_dir = config_path.parent();

    let base_path = if config.base_config.is_absolute() {
        config.base_config.clone()
    } else {
        config_dir
            .map(|d| d.join(&config.base_config))
            .unwrap_or_else(|| config.base_config.clone())
    };
    let base = ScenarioConfig::from_file(&base_path)?;

    let climate = config.resolve_climate(mode, config_dir)?;

    let report = seasonal(&config, &base, &climate, mode)?;

    if let Some(out) = output {
        let json = serde_json::to_string_pretty(&report)
            .map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
        std::fs::write(out, json)
            .map_err(|e| CliError::Config(format!("Failed to write {}: {}", out.display(), e)))?;
    }

    Ok(report)
}

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

    fn cfg() -> SeasonalConfig {
        SeasonalConfig {
            base_config: PathBuf::from("x.json"),
            climate: None,
            climate_file: None,
            climate_name: None,
            outdoor_side: None,
            design_load_w: 10_000.0,
            design_outdoor_c: -10.0,
            threshold_outdoor_c: 16.0,
            cd: 0.25,
            backup_cop: 1.0,
        }
    }

    #[test]
    fn demand_line_is_linear_and_clamped_for_heating() {
        let c = cfg();
        // At design temperature, demand == design load.
        assert!((c.demand_at(SeasonalMode::Scop, -10.0) - 10_000.0).abs() < 1e-9);
        // At threshold, demand == 0.
        assert!(c.demand_at(SeasonalMode::Scop, 16.0).abs() < 1e-9);
        // Above threshold, clamped to 0 (no heating needed).
        assert_eq!(c.demand_at(SeasonalMode::Scop, 20.0), 0.0);
        // Midpoint (3 °C) → half load.
        assert!((c.demand_at(SeasonalMode::Scop, 3.0) - 5_000.0).abs() < 1e-6);
    }

    #[test]
    fn demand_line_reverses_for_cooling() {
        let mut c = cfg();
        c.design_outdoor_c = 35.0;
        c.threshold_outdoor_c = 16.0;
        // Hot design → full load; threshold → zero; below threshold clamped.
        assert!((c.demand_at(SeasonalMode::Seer, 35.0) - 10_000.0).abs() < 1e-9);
        assert!(c.demand_at(SeasonalMode::Seer, 16.0).abs() < 1e-9);
        assert_eq!(c.demand_at(SeasonalMode::Seer, 10.0), 0.0);
    }

    #[test]
    fn part_load_degrades_cop_via_cd() {
        // With CR=0.5 and Cd=0.25, PLF = 1 - 0.25*0.5 = 0.875.
        let c = cfg();
        let cr = 0.5;
        let plf = 1.0 - c.cd * (1.0 - cr);
        assert!((plf - 0.875).abs() < 1e-12);
    }

    #[test]
    fn default_outdoor_side_follows_mode() {
        assert_eq!(
            SeasonalMode::Scop.default_outdoor_side(),
            OutdoorSide::Evaporator
        );
        assert_eq!(
            SeasonalMode::Seer.default_outdoor_side(),
            OutdoorSide::Condenser
        );
    }

    #[test]
    fn seer_requires_explicit_climate() {
        let c = cfg();
        assert!(c.resolve_climate(SeasonalMode::Seer, None).is_err());
        // SCOP falls back to the EN 14825 average season.
        let climate = c.resolve_climate(SeasonalMode::Scop, None).unwrap();
        assert!((climate.total_hours() - 4910.0).abs() < 1e-6);
    }

    #[test]
    fn resolve_climate_precedence_and_builtin() {
        let mut c = cfg();
        c.climate_name = Some("average".to_string());
        let climate = c.resolve_climate(SeasonalMode::Scop, None).unwrap();
        assert_eq!(climate.total_hours(), 4910.0);

        // Inline wins over name.
        c.climate = Some(BinClimateStandard {
            name: "tiny".to_string(),
            reference: String::new(),
            bins: vec![entropyk::rating::TemperatureBin {
                temperature_c: 0.0,
                hours: 100.0,
            }],
        });
        let climate2 = c.resolve_climate(SeasonalMode::Scop, None).unwrap();
        assert_eq!(climate2.name, "tiny");
    }

    #[test]
    fn outdoor_side_matches_expected_components() {
        assert!(OutdoorSide::Evaporator.matches("Evaporator"));
        assert!(OutdoorSide::Evaporator.matches("FloodedEvaporator"));
        assert!(!OutdoorSide::Evaporator.matches("Condenser"));
        assert!(OutdoorSide::Condenser.matches("Condenser"));
        assert!(!OutdoorSide::Condenser.matches("Evaporator"));
    }

    #[test]
    fn apply_outdoor_temp_sets_only_matching_side() {
        let base = ScenarioConfig::from_json(
            r#"{
                "fluid": "R134a",
                "circuits": [{ "id": 0, "components": [
                    { "type": "Condenser", "name": "cond", "ua": 700.0, "secondary_fluid": "Water" },
                    { "type": "Evaporator", "name": "evap", "ua": 1400.0, "secondary_fluid": "Water" },
                    { "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.4 },
                    { "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
                    { "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.5 },
                    { "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
                ], "edges": [] }],
                "solver": { "strategy": "fallback", "max_iterations": 100, "tolerance": 1e-6 }
            }"#,
        )
        .unwrap();
        let out = apply_outdoor_temp(&base, OutdoorSide::Evaporator, -7.0);
        let comps = &out.circuits[0].components;
        let evap_src = comps.iter().find(|c| c.name == "evap_water_in").unwrap();
        let cond_src = comps.iter().find(|c| c.name == "cond_water_in").unwrap();
        assert_eq!(evap_src.params.get("t_set_c").unwrap().as_f64(), Some(-7.0));
        // Condenser (indoor) is untouched.
        assert_eq!(cond_src.params.get("t_set_c").unwrap().as_f64(), Some(30.0));
    }
}
