//! 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, /// Weight applied to each load fraction; must have the same length as /// `load_fractions` and sum to 1.0. pub weights: Vec, } impl PartLoadStandard { /// Construct and validate a standard from its raw data. pub fn new( name: impl Into, reference: impl Into, load_fractions: Vec, weights: Vec, ) -> Result { 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 { 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 { 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 { 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, } 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 { 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 { 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::(), 1.0, epsilon = 1e-12); } #[test] fn eseer_weights_sum_to_one() { assert_relative_eq!(ESEER_WEIGHTS.iter().sum::(), 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 = 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 = 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); } }