Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -0,0 +1,921 @@
//! 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);
}
}