//! Standardized part-load and seasonal performance ratings.
//!
//! This module turns a set of part-load operating points (each characterised by a
//! load fraction and an efficiency figure — EER for cooling, COP for heating) into
//! the standardized seasonal metrics used to *qualify* chillers and heat pumps:
//!
//! - **IPLV / NPLV** — Integrated / Non-standard Part Load Value, per
//!   *AHRI Standard 550/590* (I-P and SI editions). Four-point weighted average at
//!   100 / 75 / 50 / 25 % load.
//! - **ESEER** — European Seasonal Energy Efficiency Ratio, per *Eurovent*. Same
//!   four load points, different weights.
//! - **SCOP / SEER** — Seasonal Coefficient Of Performance / Seasonal Energy
//!   Efficiency Ratio, per *EN 14825*, computed by a temperature-bin method. A
//!   reference "average" climate bin table is provided.
//!
//! All formulas take *already-solved* efficiency values as input — computing the
//! part-load operating points themselves (by re-solving the cycle at each rating
//! condition) is the caller's responsibility. This keeps the metric math pure,
//! deterministic and trivially unit-testable.
//!
//! # Modular, data-driven standards
//!
//! Regulatory rating standards are periodically revised (AHRI and Eurovent re-fit
//! their part-load weights; EN 14825 updates its climate bins). To keep pace
//! **without changing code**, the weighting schemes are expressed as *data*, not
//! hard-coded arithmetic:
//!
//! - [`PartLoadStandard`] — a named `{ load_fractions, weights }` table driving any
//!   weighted part-load metric (IPLV, NPLV, ESEER, and user-defined variants such
//!   as SEER weightings). Built-in presets: [`PartLoadStandard::ahri_550_590_iplv`],
//!   [`PartLoadStandard::eurovent_eseer`]. Look one up by id with
//!   [`PartLoadStandard::builtin`], or deserialize a custom one from JSON and call
//!   [`PartLoadStandard::validate`].
//! - [`BinClimateStandard`] — a named temperature-bin table (hours per bin) driving
//!   the SCOP/SEER bin method. Built-in preset:
//!   [`BinClimateStandard::en_14825_average`].
//!
//! When a standard changes, update the preset here or ship a JSON file — callers
//! select the standard by name/file at run time, so the surrounding solve and CLI
//! stay untouched. The legacy `IPLV_WEIGHTS` / `ESEER_WEIGHTS` constants and the
//! `PartLoadEfficiencies::iplv` / `eseer` helpers are retained as thin wrappers
//! over the corresponding presets for backward compatibility.
//!
//! # References
//! - AHRI Standard 550/590 (2023): *Performance Rating of Water-Chilling and Heat
//!   Pump Water-Heating Packages Using the Vapor Compression Cycle.*
//! - AHRI Standard 551/591 (SI): metric counterpart of 550/590.
//! - Eurovent: ESEER definition for liquid chilling packages.
//! - EN 14825:2018: *Air conditioners, liquid chilling packages and heat pumps …
//!   Testing and rating at part load conditions and calculation of seasonal
//!   performance.*

use serde::{Deserialize, Serialize};

/// The four standardized part-load fractions used by AHRI 550/590 and Eurovent.
pub const STANDARD_LOAD_FRACTIONS: [f64; 4] = [1.0, 0.75, 0.50, 0.25];

/// AHRI 550/590 IPLV weighting coefficients for the 100/75/50/25 % load points.
///
/// `IPLV = 0.01·A + 0.42·B + 0.45·C + 0.12·D`, where A/B/C/D are the efficiencies
/// at 100/75/50/25 % load respectively.
pub const IPLV_WEIGHTS: [f64; 4] = [0.01, 0.42, 0.45, 0.12];

/// Eurovent ESEER weighting coefficients for the 100/75/50/25 % load points.
///
/// `ESEER = 0.03·EER₁₀₀ + 0.33·EER₇₅ + 0.41·EER₅₀ + 0.23·EER₂₅`.
pub const ESEER_WEIGHTS: [f64; 4] = [0.03, 0.33, 0.41, 0.23];

/// Tolerance applied when checking that a standard's weights sum to 1.0.
const WEIGHT_SUM_TOL: f64 = 1e-6;

/// Error produced when constructing or applying a rating standard with
/// inconsistent data.
#[derive(Debug, Clone, PartialEq)]
pub enum RatingError {
    /// The standard defines no load points.
    Empty,
    /// `load_fractions` and `weights` have mismatched lengths.
    LengthMismatch {
        /// Number of load fractions supplied.
        fractions: usize,
        /// Number of weights supplied.
        weights: usize,
    },
    /// The weights do not sum to 1.0 within [`WEIGHT_SUM_TOL`].
    WeightsNotNormalized {
        /// The actual (out-of-range) sum.
        sum: f64,
    },
    /// The number of supplied efficiencies does not match the number of load
    /// points in the standard.
    EfficiencyCountMismatch {
        /// Load points the standard expects.
        expected: usize,
        /// Efficiencies actually supplied.
        got: usize,
    },
}

impl std::fmt::Display for RatingError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RatingError::Empty => write!(f, "rating standard defines no load points"),
            RatingError::LengthMismatch { fractions, weights } => write!(
                f,
                "rating standard has {fractions} load fractions but {weights} weights"
            ),
            RatingError::WeightsNotNormalized { sum } => {
                write!(f, "rating standard weights sum to {sum}, expected 1.0")
            }
            RatingError::EfficiencyCountMismatch { expected, got } => write!(
                f,
                "expected {expected} efficiencies for this standard, got {got}"
            ),
        }
    }
}

impl std::error::Error for RatingError {}

/// A data-driven part-load weighting standard (IPLV, NPLV, ESEER, SEER, …).
///
/// A weighted seasonal metric is fully described by *which* part-load points are
/// measured (`load_fractions`) and *how* they are weighted (`weights`). Encoding
/// the standard as data — rather than hard-coding the coefficients — means that
/// when a standard is revised you update a table or ship a JSON file instead of
/// changing code. The number of load points is arbitrary (four for AHRI/Eurovent,
/// but any N is accepted), so a future standard with more or fewer points needs
/// no code change.
///
/// # Adding a new standard without recompiling
///
/// Author a JSON file and deserialize it, then validate:
///
/// ```
/// use entropyk::rating::PartLoadStandard;
/// let json = r#"{
///   "name": "Custom SEER weighting",
///   "reference": "EN 14825 moderate cooling season (illustrative)",
///   "load_fractions": [1.0, 0.74, 0.47, 0.21],
///   "weights": [0.03, 0.27, 0.41, 0.29]
/// }"#;
/// let std: PartLoadStandard = serde_json::from_str(json).unwrap();
/// std.validate().unwrap();
/// let seer = std.integrate(&[3.0, 4.0, 5.0, 4.5]).unwrap();
/// # assert!(seer > 0.0);
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PartLoadStandard {
    /// Human-readable name, e.g. "AHRI 550/590 IPLV".
    pub name: String,
    /// Citation / provenance of the coefficients.
    #[serde(default)]
    pub reference: String,
    /// Part-load fractions the points are measured at, e.g. `[1.0, 0.75, 0.5, 0.25]`.
    pub load_fractions: Vec<f64>,
    /// Weight applied to each load fraction; must have the same length as
    /// `load_fractions` and sum to 1.0.
    pub weights: Vec<f64>,
}

impl PartLoadStandard {
    /// Construct and validate a standard from its raw data.
    pub fn new(
        name: impl Into<String>,
        reference: impl Into<String>,
        load_fractions: Vec<f64>,
        weights: Vec<f64>,
    ) -> Result<Self, RatingError> {
        let std = Self {
            name: name.into(),
            reference: reference.into(),
            load_fractions,
            weights,
        };
        std.validate()?;
        Ok(std)
    }

    /// Check the standard is internally consistent: non-empty, equal-length
    /// fractions/weights, and weights that sum to 1.0.
    pub fn validate(&self) -> Result<(), RatingError> {
        if self.load_fractions.is_empty() || self.weights.is_empty() {
            return Err(RatingError::Empty);
        }
        if self.load_fractions.len() != self.weights.len() {
            return Err(RatingError::LengthMismatch {
                fractions: self.load_fractions.len(),
                weights: self.weights.len(),
            });
        }
        let sum: f64 = self.weights.iter().sum();
        if (sum - 1.0).abs() > WEIGHT_SUM_TOL {
            return Err(RatingError::WeightsNotNormalized { sum });
        }
        Ok(())
    }

    /// Number of part-load points this standard weights.
    pub fn len(&self) -> usize {
        self.load_fractions.len()
    }

    /// Whether the standard defines no load points.
    pub fn is_empty(&self) -> bool {
        self.load_fractions.is_empty()
    }

    /// Integrate the seasonal metric: the weighted sum of `efficiencies`, which
    /// must be ordered to match `load_fractions`.
    pub fn integrate(&self, efficiencies: &[f64]) -> Result<f64, RatingError> {
        if efficiencies.len() != self.weights.len() {
            return Err(RatingError::EfficiencyCountMismatch {
                expected: self.weights.len(),
                got: efficiencies.len(),
            });
        }
        Ok(efficiencies
            .iter()
            .zip(self.weights.iter())
            .map(|(e, w)| e * w)
            .sum())
    }

    /// **AHRI 550/590** IPLV/NPLV preset (four points, weights
    /// `[0.01, 0.42, 0.45, 0.12]`).
    pub fn ahri_550_590_iplv() -> Self {
        Self {
            name: "AHRI 550/590 IPLV".to_string(),
            reference: "AHRI Standard 550/590 — Integrated Part Load Value".to_string(),
            load_fractions: STANDARD_LOAD_FRACTIONS.to_vec(),
            weights: IPLV_WEIGHTS.to_vec(),
        }
    }

    /// **Eurovent** ESEER preset (four points, weights `[0.03, 0.33, 0.41, 0.23]`).
    pub fn eurovent_eseer() -> Self {
        Self {
            name: "Eurovent ESEER".to_string(),
            reference: "Eurovent — European Seasonal Energy Efficiency Ratio".to_string(),
            load_fractions: STANDARD_LOAD_FRACTIONS.to_vec(),
            weights: ESEER_WEIGHTS.to_vec(),
        }
    }

    /// Look up a built-in standard by a case-insensitive identifier.
    ///
    /// Recognised: `"iplv"`, `"nplv"`, `"ahri_550_590"` → AHRI IPLV;
    /// `"eseer"`, `"eurovent"` → Eurovent ESEER. Returns `None` for unknown ids
    /// (the caller should then try loading a custom standard from a file).
    pub fn builtin(id: &str) -> Option<Self> {
        match id
            .to_ascii_lowercase()
            .replace([' ', '-', '/'], "_")
            .as_str()
        {
            "iplv" | "nplv" | "ahri" | "ahri_550_590" | "ahri_551_591" => {
                Some(Self::ahri_550_590_iplv())
            }
            "eseer" | "eurovent" => Some(Self::eurovent_eseer()),
            _ => None,
        }
    }

    /// Ids of all built-in part-load standards (for help/discovery).
    pub fn builtin_ids() -> &'static [&'static str] {
        &["iplv", "nplv", "eseer"]
    }
}

/// Efficiency figures at the four standardized part-load points.
///
/// The values are EER (cooling) or COP (heating), consistently one or the other.
/// Fields are named by the fraction of full load they correspond to.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PartLoadEfficiencies {
    /// Efficiency at 100 % load (point A).
    pub at_100: f64,
    /// Efficiency at 75 % load (point B).
    pub at_75: f64,
    /// Efficiency at 50 % load (point C).
    pub at_50: f64,
    /// Efficiency at 25 % load (point D).
    pub at_25: f64,
}

impl PartLoadEfficiencies {
    /// Create part-load efficiencies from the four values, ordered
    /// `[100 %, 75 %, 50 %, 25 %]`.
    pub fn new(at_100: f64, at_75: f64, at_50: f64, at_25: f64) -> Self {
        Self {
            at_100,
            at_75,
            at_50,
            at_25,
        }
    }

    /// The four efficiencies as an array ordered `[100 %, 75 %, 50 %, 25 %]`.
    pub fn as_array(&self) -> [f64; 4] {
        [self.at_100, self.at_75, self.at_50, self.at_25]
    }

    /// Weighted sum of the four efficiencies with the supplied weights (which are
    /// expected to sum to 1.0).
    fn weighted(&self, weights: &[f64; 4]) -> f64 {
        self.as_array()
            .iter()
            .zip(weights.iter())
            .map(|(e, w)| e * w)
            .sum()
    }

    /// Integrate these efficiencies against an arbitrary [`PartLoadStandard`].
    ///
    /// This is the modular entry point: pass any built-in or custom standard
    /// (four load points, in the canonical `[100, 75, 50, 25] %` order these
    /// efficiencies are stored in) to obtain its weighted seasonal value.
    ///
    /// Returns an error if the standard does not define exactly four load points.
    pub fn integrate(&self, standard: &PartLoadStandard) -> Result<f64, RatingError> {
        standard.integrate(&self.as_array())
    }

    /// Integrated Part Load Value per **AHRI 550/590**.
    ///
    /// When the part-load points are measured at the *standard* rating conditions
    /// this is the IPLV; measured at any other condition set it is the NPLV
    /// (Non-standard Part Load Value) — the arithmetic is identical.
    ///
    /// ```
    /// use entropyk::rating::PartLoadEfficiencies;
    /// let eff = PartLoadEfficiencies::new(4.0, 5.0, 6.0, 5.5);
    /// let iplv = eff.iplv();
    /// assert!((iplv - (0.01*4.0 + 0.42*5.0 + 0.45*6.0 + 0.12*5.5)).abs() < 1e-12);
    /// ```
    pub fn iplv(&self) -> f64 {
        self.weighted(&IPLV_WEIGHTS)
    }

    /// Non-standard Part Load Value (alias of [`Self::iplv`]; identical formula,
    /// used when points are taken at non-standard conditions).
    pub fn nplv(&self) -> f64 {
        self.iplv()
    }

    /// European Seasonal Energy Efficiency Ratio per **Eurovent**.
    ///
    /// ```
    /// use entropyk::rating::PartLoadEfficiencies;
    /// let eff = PartLoadEfficiencies::new(3.0, 4.0, 5.0, 4.5);
    /// let eseer = eff.eseer();
    /// assert!((eseer - (0.03*3.0 + 0.33*4.0 + 0.41*5.0 + 0.23*4.5)).abs() < 1e-12);
    /// ```
    pub fn eseer(&self) -> f64 {
        self.weighted(&ESEER_WEIGHTS)
    }
}

/// A standardized full-load rating condition (secondary-fluid temperatures).
///
/// Temperatures are the *secondary* (heat-transfer-fluid) side conditions that
/// define the operating envelope. Refrigerant regimes emerge from the coupled
/// heat-exchanger solve, so only the secondary conditions are prescribed here.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct RatingCondition {
    /// Human-readable standard/condition name.
    pub name: &'static str,
    /// Evaporator-side secondary fluid leaving (supply) temperature [°C].
    pub evap_secondary_out_c: f64,
    /// Evaporator-side secondary fluid entering (return) temperature [°C].
    pub evap_secondary_in_c: f64,
    /// Condenser / gas-cooler side secondary fluid entering temperature [°C].
    pub cond_secondary_in_c: f64,
}

impl RatingCondition {
    /// **AHRI 550/590** water-cooled chiller full-load condition:
    /// chilled-water 6.7 °C supply / 12.2 °C return, condenser water 29.4 °C entering.
    pub const AHRI_550_590_WATER_COOLED: RatingCondition = RatingCondition {
        name: "AHRI 550/590 water-cooled full load",
        evap_secondary_out_c: 6.7,
        evap_secondary_in_c: 12.2,
        cond_secondary_in_c: 29.4,
    };

    /// **AHRI 550/590** air-cooled chiller full-load condition:
    /// chilled-water 6.7 °C supply / 12.2 °C return, ambient air 35.0 °C entering.
    pub const AHRI_550_590_AIR_COOLED: RatingCondition = RatingCondition {
        name: "AHRI 550/590 air-cooled full load",
        evap_secondary_out_c: 6.7,
        evap_secondary_in_c: 12.2,
        cond_secondary_in_c: 35.0,
    };

    /// **EN 14511** water-cooled chiller condition A:
    /// chilled-water 7 °C supply / 12 °C return, condenser water 30 °C entering.
    pub const EN_14511_WATER_COOLED_A: RatingCondition = RatingCondition {
        name: "EN 14511 water-cooled condition A",
        evap_secondary_out_c: 7.0,
        evap_secondary_in_c: 12.0,
        cond_secondary_in_c: 30.0,
    };

    /// **EN 14511** air-cooled chiller condition A:
    /// chilled-water 7 °C supply / 12 °C return, ambient air 35 °C entering.
    pub const EN_14511_AIR_COOLED_A: RatingCondition = RatingCondition {
        name: "EN 14511 air-cooled condition A",
        evap_secondary_out_c: 7.0,
        evap_secondary_in_c: 12.0,
        cond_secondary_in_c: 35.0,
    };
}

/// A single temperature bin for the EN 14825 seasonal (SCOP) bin method.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TemperatureBin {
    /// Outdoor dry-bulb bin temperature [°C].
    pub temperature_c: f64,
    /// Number of hours per year spent in this bin.
    pub hours: f64,
}

/// EN 14825 **average** heating-season reference bin table (Strasbourg reference).
///
/// This is Table 5 of Annex III to Commission Regulation (EU) No 813/2013
/// ("European reference heating season under average climate conditions"),
/// reproduced verbatim as Table A.4 of EN 14825:2018. The 26 bins with non-zero
/// hours (Tj = −10 °C … +15 °C) are listed; the standard's all-zero bins below
/// −10 °C are omitted. Hours sum to exactly 4910 h.
pub const EN_14825_AVERAGE_BINS: [TemperatureBin; 26] = [
    TemperatureBin {
        temperature_c: -10.0,
        hours: 1.0,
    },
    TemperatureBin {
        temperature_c: -9.0,
        hours: 25.0,
    },
    TemperatureBin {
        temperature_c: -8.0,
        hours: 23.0,
    },
    TemperatureBin {
        temperature_c: -7.0,
        hours: 24.0,
    },
    TemperatureBin {
        temperature_c: -6.0,
        hours: 27.0,
    },
    TemperatureBin {
        temperature_c: -5.0,
        hours: 68.0,
    },
    TemperatureBin {
        temperature_c: -4.0,
        hours: 91.0,
    },
    TemperatureBin {
        temperature_c: -3.0,
        hours: 89.0,
    },
    TemperatureBin {
        temperature_c: -2.0,
        hours: 165.0,
    },
    TemperatureBin {
        temperature_c: -1.0,
        hours: 173.0,
    },
    TemperatureBin {
        temperature_c: 0.0,
        hours: 240.0,
    },
    TemperatureBin {
        temperature_c: 1.0,
        hours: 280.0,
    },
    TemperatureBin {
        temperature_c: 2.0,
        hours: 320.0,
    },
    TemperatureBin {
        temperature_c: 3.0,
        hours: 357.0,
    },
    TemperatureBin {
        temperature_c: 4.0,
        hours: 356.0,
    },
    TemperatureBin {
        temperature_c: 5.0,
        hours: 303.0,
    },
    TemperatureBin {
        temperature_c: 6.0,
        hours: 330.0,
    },
    TemperatureBin {
        temperature_c: 7.0,
        hours: 326.0,
    },
    TemperatureBin {
        temperature_c: 8.0,
        hours: 348.0,
    },
    TemperatureBin {
        temperature_c: 9.0,
        hours: 335.0,
    },
    TemperatureBin {
        temperature_c: 10.0,
        hours: 315.0,
    },
    TemperatureBin {
        temperature_c: 11.0,
        hours: 215.0,
    },
    TemperatureBin {
        temperature_c: 12.0,
        hours: 169.0,
    },
    TemperatureBin {
        temperature_c: 13.0,
        hours: 151.0,
    },
    TemperatureBin {
        temperature_c: 14.0,
        hours: 105.0,
    },
    TemperatureBin {
        temperature_c: 15.0,
        hours: 74.0,
    },
];

/// A data-driven climate bin standard for the SCOP/SEER bin method.
///
/// The bin *set* (which outdoor temperatures, and how many hours per year at
/// each) is defined by the applicable standard and climate zone — EN 14825
/// specifies average / warmer / colder heating reference seasons, and separate
/// cooling seasons for SEER, and these tables are revised over time. Holding the
/// bins as data means a new climate or a revised table is just another
/// [`BinClimateStandard`] value (a preset here or a JSON file), with the SCOP/SEER
/// arithmetic unchanged.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BinClimateStandard {
    /// Human-readable name, e.g. "EN 14825 average heating season".
    pub name: String,
    /// Citation / provenance of the bin table.
    #[serde(default)]
    pub reference: String,
    /// The temperature bins (outdoor temperature + annual hours).
    pub bins: Vec<TemperatureBin>,
}

impl BinClimateStandard {
    /// **EN 14825** average heating-season reference climate (Strasbourg, 4910 h).
    pub fn en_14825_average() -> Self {
        Self {
            name: "EN 14825 average heating season".to_string(),
            reference: "EN 14825:2018 Table A.4 / EU 813/2013 Annex III Table 5".to_string(),
            bins: EN_14825_AVERAGE_BINS.to_vec(),
        }
    }

    /// Look up a built-in climate by a case-insensitive identifier.
    ///
    /// Recognised: `"en_14825_average"`, `"average"` → EN 14825 average season.
    pub fn builtin(id: &str) -> Option<Self> {
        match id
            .to_ascii_lowercase()
            .replace([' ', '-', '/'], "_")
            .as_str()
        {
            "en_14825_average" | "average" | "en14825" => Some(Self::en_14825_average()),
            _ => None,
        }
    }

    /// Ids of all built-in climate standards (for help/discovery).
    pub fn builtin_ids() -> &'static [&'static str] {
        &["en_14825_average"]
    }

    /// Total annual hours across all bins.
    pub fn total_hours(&self) -> f64 {
        self.bins.iter().map(|b| b.hours).sum()
    }

    /// Check the climate defines at least one bin.
    pub fn validate(&self) -> Result<(), RatingError> {
        if self.bins.is_empty() {
            return Err(RatingError::Empty);
        }
        Ok(())
    }
}

/// A bin paired with the seasonal building heating demand and the machine COP at
/// that bin's outdoor temperature.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct BinPerformance {
    /// The temperature bin (outdoor temperature + annual hours).
    pub bin: TemperatureBin,
    /// Building heating demand at this bin temperature [W] (part-load ratio × design load).
    pub demand_w: f64,
    /// Machine COP at this bin temperature (including any degradation/backup effect).
    pub cop: f64,
}

/// Seasonal Coefficient Of Performance per the **EN 14825** bin method.
///
/// `SCOP = Σ (hours·demand) / Σ (hours·demand / COP)` — i.e. the ratio of the total
/// seasonal heating energy delivered to the total electrical energy consumed,
/// summed over all temperature bins. Bins with zero demand or zero hours are
/// ignored.
///
/// Returns `None` if the total electrical energy works out to zero (no valid bins
/// with positive demand and COP).
pub fn scop(bins: &[BinPerformance]) -> Option<f64> {
    let mut heat_energy = 0.0;
    let mut elec_energy = 0.0;
    for b in bins {
        if b.bin.hours <= 0.0 || b.demand_w <= 0.0 || b.cop <= 0.0 {
            continue;
        }
        let heat = b.bin.hours * b.demand_w;
        heat_energy += heat;
        elec_energy += heat / b.cop;
    }
    if elec_energy > 0.0 {
        Some(heat_energy / elec_energy)
    } else {
        None
    }
}

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

    #[test]
    fn iplv_weights_sum_to_one() {
        assert_relative_eq!(IPLV_WEIGHTS.iter().sum::<f64>(), 1.0, epsilon = 1e-12);
    }

    #[test]
    fn eseer_weights_sum_to_one() {
        assert_relative_eq!(ESEER_WEIGHTS.iter().sum::<f64>(), 1.0, epsilon = 1e-12);
    }

    #[test]
    fn iplv_matches_ahri_formula() {
        let eff = PartLoadEfficiencies::new(4.0, 5.2, 6.1, 5.4);
        let expected = 0.01 * 4.0 + 0.42 * 5.2 + 0.45 * 6.1 + 0.12 * 5.4;
        assert_relative_eq!(eff.iplv(), expected, epsilon = 1e-12);
        // NPLV is the same arithmetic.
        assert_relative_eq!(eff.nplv(), expected, epsilon = 1e-12);
    }

    #[test]
    fn eseer_matches_eurovent_formula() {
        let eff = PartLoadEfficiencies::new(3.1, 4.2, 5.3, 4.7);
        let expected = 0.03 * 3.1 + 0.33 * 4.2 + 0.41 * 5.3 + 0.23 * 4.7;
        assert_relative_eq!(eff.eseer(), expected, epsilon = 1e-12);
    }

    #[test]
    fn constant_efficiency_gives_same_iplv_and_eseer() {
        // If efficiency is identical at every load, both seasonal metrics equal it
        // (weights sum to 1).
        let eff = PartLoadEfficiencies::new(5.0, 5.0, 5.0, 5.0);
        assert_relative_eq!(eff.iplv(), 5.0, epsilon = 1e-12);
        assert_relative_eq!(eff.eseer(), 5.0, epsilon = 1e-12);
    }

    #[test]
    fn iplv_weights_part_load_most_heavily() {
        // A machine that is much better at 50 % load should see a big IPLV lift,
        // because the 50 % point carries 45 % weight.
        let base = PartLoadEfficiencies::new(4.0, 4.0, 4.0, 4.0);
        let good_partload = PartLoadEfficiencies::new(4.0, 4.0, 8.0, 4.0);
        let lift = good_partload.iplv() - base.iplv();
        assert_relative_eq!(lift, 0.45 * 4.0, epsilon = 1e-12);
    }

    #[test]
    fn scop_of_constant_cop_equals_cop() {
        let bins: Vec<BinPerformance> = EN_14825_AVERAGE_BINS
            .iter()
            .map(|&bin| BinPerformance {
                bin,
                demand_w: 5000.0,
                cop: 3.5,
            })
            .collect();
        assert_relative_eq!(scop(&bins).unwrap(), 3.5, epsilon = 1e-12);
    }

    #[test]
    fn scop_is_hours_and_demand_weighted() {
        // Two bins: cold bin (few hours, low COP) + mild bin (many hours, high COP).
        // SCOP must be pulled toward the mild bin because it carries far more
        // heating energy.
        let bins = [
            BinPerformance {
                bin: TemperatureBin {
                    temperature_c: -7.0,
                    hours: 10.0,
                },
                demand_w: 8000.0,
                cop: 2.0,
            },
            BinPerformance {
                bin: TemperatureBin {
                    temperature_c: 7.0,
                    hours: 1000.0,
                },
                demand_w: 3000.0,
                cop: 4.5,
            },
        ];
        let s = scop(&bins).unwrap();
        // Manual: heat = 10*8000 + 1000*3000 = 80_000 + 3_000_000 = 3_080_000
        //         elec = 80_000/2.0 + 3_000_000/4.5 = 40_000 + 666_666.67
        let heat = 10.0 * 8000.0 + 1000.0 * 3000.0;
        let elec = 10.0 * 8000.0 / 2.0 + 1000.0 * 3000.0 / 4.5;
        assert_relative_eq!(s, heat / elec, epsilon = 1e-9);
        assert!(s > 4.0, "SCOP should be dominated by the mild high-COP bin");
    }

    #[test]
    fn scop_ignores_invalid_bins_and_handles_empty() {
        assert!(scop(&[]).is_none());
        let bins = [BinPerformance {
            bin: TemperatureBin {
                temperature_c: 0.0,
                hours: 0.0,
            },
            demand_w: 5000.0,
            cop: 3.0,
        }];
        assert!(scop(&bins).is_none());
    }

    #[test]
    fn en14825_average_bins_hours_sum() {
        let total: f64 = EN_14825_AVERAGE_BINS.iter().map(|b| b.hours).sum();
        assert_relative_eq!(total, 4910.0, epsilon = 1e-9);
    }

    #[test]
    fn standard_conditions_are_ordered_physically() {
        // Evaporator supply must be colder than return (chiller extracts heat).
        for c in [
            RatingCondition::AHRI_550_590_WATER_COOLED,
            RatingCondition::AHRI_550_590_AIR_COOLED,
            RatingCondition::EN_14511_WATER_COOLED_A,
            RatingCondition::EN_14511_AIR_COOLED_A,
        ] {
            assert!(
                c.evap_secondary_out_c < c.evap_secondary_in_c,
                "{}: supply must be colder than return",
                c.name
            );
            assert!(
                c.cond_secondary_in_c > c.evap_secondary_in_c,
                "{}: condenser side must be warmer than evaporator side",
                c.name
            );
        }
    }

    // ---- Modular, data-driven standards ----

    #[test]
    fn partload_standard_presets_reproduce_legacy_constants() {
        let iplv_std = PartLoadStandard::ahri_550_590_iplv();
        let eseer_std = PartLoadStandard::eurovent_eseer();
        iplv_std.validate().unwrap();
        eseer_std.validate().unwrap();
        assert_eq!(iplv_std.weights, IPLV_WEIGHTS.to_vec());
        assert_eq!(eseer_std.weights, ESEER_WEIGHTS.to_vec());
        assert_eq!(iplv_std.load_fractions, STANDARD_LOAD_FRACTIONS.to_vec());

        // The generic integrate() must agree with the legacy helpers bit-for-bit.
        let eff = PartLoadEfficiencies::new(4.0, 5.2, 6.1, 5.4);
        assert_relative_eq!(
            eff.integrate(&iplv_std).unwrap(),
            eff.iplv(),
            epsilon = 1e-12
        );
        assert_relative_eq!(
            eff.integrate(&eseer_std).unwrap(),
            eff.eseer(),
            epsilon = 1e-12
        );
    }

    #[test]
    fn partload_standard_builtin_lookup_is_case_and_separator_insensitive() {
        for id in ["iplv", "IPLV", "nplv", "AHRI-550/590", "ahri 550 590"] {
            let s = PartLoadStandard::builtin(id).unwrap_or_else(|| panic!("id {id} not found"));
            assert_eq!(s.weights, IPLV_WEIGHTS.to_vec());
        }
        for id in ["eseer", "Eurovent"] {
            assert_eq!(
                PartLoadStandard::builtin(id).unwrap().weights,
                ESEER_WEIGHTS.to_vec()
            );
        }
        assert!(PartLoadStandard::builtin("does-not-exist").is_none());
    }

    #[test]
    fn partload_standard_custom_from_json_round_trips_and_integrates() {
        // A user-supplied SEER-style weighting with four points but different
        // fractions and weights — no code change required.
        let json = r#"{
            "name": "Custom SEER",
            "reference": "illustrative",
            "load_fractions": [1.0, 0.74, 0.47, 0.21],
            "weights": [0.03, 0.27, 0.41, 0.29]
        }"#;
        let std: PartLoadStandard = serde_json::from_str(json).unwrap();
        std.validate().unwrap();
        let value = std.integrate(&[3.0, 4.0, 5.0, 4.5]).unwrap();
        let expected = 0.03 * 3.0 + 0.27 * 4.0 + 0.41 * 5.0 + 0.29 * 4.5;
        assert_relative_eq!(value, expected, epsilon = 1e-12);
    }

    #[test]
    fn partload_standard_supports_arbitrary_point_counts() {
        // Three points, not four — accepted as long as it is internally consistent.
        let std =
            PartLoadStandard::new("3-point", "test", vec![1.0, 0.5, 0.25], vec![0.2, 0.5, 0.3])
                .unwrap();
        assert_eq!(std.len(), 3);
        let value = std.integrate(&[4.0, 6.0, 5.0]).unwrap();
        assert_relative_eq!(value, 0.2 * 4.0 + 0.5 * 6.0 + 0.3 * 5.0, epsilon = 1e-12);
    }

    #[test]
    fn partload_standard_validation_rejects_bad_data() {
        // Length mismatch.
        assert_eq!(
            PartLoadStandard::new("bad", "", vec![1.0, 0.5], vec![1.0]).unwrap_err(),
            RatingError::LengthMismatch {
                fractions: 2,
                weights: 1
            }
        );
        // Weights that do not sum to 1.
        match PartLoadStandard::new("bad", "", vec![1.0, 0.5], vec![0.3, 0.3]).unwrap_err() {
            RatingError::WeightsNotNormalized { sum } => {
                assert_relative_eq!(sum, 0.6, epsilon = 1e-12)
            }
            other => panic!("unexpected error: {other:?}"),
        }
        // Empty.
        assert_eq!(
            PartLoadStandard::new("bad", "", vec![], vec![]).unwrap_err(),
            RatingError::Empty
        );
    }

    #[test]
    fn partload_standard_integrate_rejects_wrong_efficiency_count() {
        let std = PartLoadStandard::ahri_550_590_iplv();
        assert_eq!(
            std.integrate(&[4.0, 5.0, 6.0]).unwrap_err(),
            RatingError::EfficiencyCountMismatch {
                expected: 4,
                got: 3
            }
        );
    }

    #[test]
    fn bin_climate_standard_preset_matches_reference_table() {
        let climate = BinClimateStandard::en_14825_average();
        climate.validate().unwrap();
        assert_eq!(climate.bins, EN_14825_AVERAGE_BINS.to_vec());
        assert_relative_eq!(climate.total_hours(), 4910.0, epsilon = 1e-9);

        assert_eq!(
            BinClimateStandard::builtin("average").unwrap().bins.len(),
            EN_14825_AVERAGE_BINS.len()
        );
        assert!(BinClimateStandard::builtin("unknown").is_none());
    }

    #[test]
    fn bin_climate_standard_custom_from_json_drives_scop() {
        // Swap in a small custom climate; SCOP must use exactly those bins.
        let json = r#"{
            "name": "Tiny climate",
            "bins": [
                { "temperature_c": -5.0, "hours": 100.0 },
                { "temperature_c": 5.0,  "hours": 900.0 }
            ]
        }"#;
        let climate: BinClimateStandard = serde_json::from_str(json).unwrap();
        climate.validate().unwrap();
        assert_relative_eq!(climate.total_hours(), 1000.0, epsilon = 1e-12);

        let bins: Vec<BinPerformance> = climate
            .bins
            .iter()
            .map(|&bin| BinPerformance {
                bin,
                demand_w: 4000.0,
                cop: 3.0,
            })
            .collect();
        assert_relative_eq!(scop(&bins).unwrap(), 3.0, epsilon = 1e-12);
    }
}
