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>
922 lines
33 KiB
Rust
922 lines
33 KiB
Rust
//! 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);
|
||
}
|
||
}
|