Some checks failed
CI / check (push) Has been cancelled
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop. Co-authored-by: Cursor <cursoragent@cursor.com>
764 lines
31 KiB
Markdown
764 lines
31 KiB
Markdown
# Seasonal & Part-Load Ratings — IPLV / NPLV / ESEER / SCOP / SEER
|
||
|
||
> Modular, data-driven standardized ratings for chillers and heat pumps.
|
||
>
|
||
> Crate: [`entropyk::rating`](../crates/entropyk/src/rating.rs) · CLI:
|
||
> [`entropyk-cli rate`](../crates/cli/src/rate.rs)
|
||
|
||
This document is the definitive reference for how Entropyk turns a machine's
|
||
part-load performance into the **standardized seasonal metrics** used to qualify
|
||
HVAC/R equipment, and — most importantly — how those standards are kept
|
||
**modular** so that when a norm is revised (AHRI, Eurovent and EN periodically
|
||
re-fit their coefficients and climate tables) you can adapt **without recompiling**.
|
||
|
||
---
|
||
|
||
## Table of contents
|
||
|
||
1. [Concepts & scope](#1-concepts--scope)
|
||
2. [Design philosophy: standards are data, not code](#2-design-philosophy-standards-are-data-not-code)
|
||
3. [Library API (`entropyk::rating`)](#3-library-api-entropykrating)
|
||
- [`PartLoadStandard`](#31-partloadstandard)
|
||
- [`PartLoadEfficiencies`](#32-partloadefficiencies)
|
||
- [`BinClimateStandard`, `BinPerformance`, `scop`](#33-binclimatestandard-binperformance-and-scop)
|
||
- [`RatingCondition`](#34-ratingcondition)
|
||
- [`RatingError`](#35-ratingerror)
|
||
- [Legacy constants](#36-legacy-constants)
|
||
4. [The `rate` CLI command](#4-the-rate-cli-command)
|
||
- [Configuration schema](#41-configuration-schema)
|
||
- [Selecting the standard (precedence)](#42-selecting-the-standard-precedence)
|
||
- [How a part-load point is solved](#43-how-a-part-load-point-is-solved)
|
||
- [Report format](#44-report-format)
|
||
- [Worked examples](#45-worked-examples)
|
||
5. [Custom standard JSON schema](#5-custom-standard-json-schema)
|
||
6. [How to change a standard when the norm changes](#6-how-to-change-a-standard-when-the-norm-changes)
|
||
7. [The `scop` / `seer` CLI commands (bin method)](#7-the-scop--seer-cli-commands-bin-method)
|
||
- [Configuration schema](#71-configuration-schema)
|
||
- [How a bin is solved](#72-how-a-bin-is-solved)
|
||
- [Report format](#73-report-format)
|
||
- [Worked example (SCOP)](#74-worked-example-scop)
|
||
- [Adding another bin-based metric](#75-adding-another-bin-based-metric)
|
||
8. [Validation & testing](#8-validation--testing)
|
||
9. [Reference tables](#9-reference-tables)
|
||
10. [Standards references](#10-standards-references)
|
||
|
||
---
|
||
|
||
## 1. Concepts & scope
|
||
|
||
A single full-load efficiency figure (EER or COP at rated conditions) is a poor
|
||
predictor of real energy use, because equipment spends most of its life at part
|
||
load. Regulators therefore define **seasonal** metrics that weight several
|
||
part-load operating points:
|
||
|
||
| Metric | Family | Standard | What it weights |
|
||
| :--- | :--- | :--- | :--- |
|
||
| **IPLV** — Integrated Part Load Value | Weighted part-load | AHRI 550/590 | EER at 100/75/50/25 % load |
|
||
| **NPLV** — Non-standard Part Load Value | Weighted part-load | AHRI 550/590 | Same formula as IPLV, taken at non-standard conditions |
|
||
| **ESEER** — European SEER | Weighted part-load | Eurovent | EER at 100/75/50/25 % load, different weights |
|
||
| **SCOP** — Seasonal COP | Temperature-bin | EN 14825 | COP across a climate's hourly temperature bins |
|
||
| **SEER** — Seasonal EER | Temperature-bin / weighted | EN 14825 | EER across the cooling season |
|
||
|
||
Entropyk models these as **two calculation families**:
|
||
|
||
- **Weighted part-load** (`IPLV`, `NPLV`, `ESEER`, and weighted SEER variants):
|
||
a weighted average of efficiencies measured at a small set of load fractions.
|
||
Implemented by [`PartLoadStandard`](#31-partloadstandard).
|
||
- **Temperature-bin** (`SCOP`, bin-based `SEER`): energy summed over a climate's
|
||
hourly temperature bins, `Σ(h·demand) / Σ(h·demand/COP)`. Implemented by
|
||
[`BinClimateStandard`](#33-binclimatestandard-binperformance-and-scop) +
|
||
[`scop`](#33-binclimatestandard-binperformance-and-scop).
|
||
|
||
> **Purity guarantee.** All the metric arithmetic takes *already-solved*
|
||
> efficiency values as input. Computing the part-load operating points — by
|
||
> re-solving the coupled cycle at each rating condition — is the caller's job (the
|
||
> `rate` CLI command does this). This keeps the metric math deterministic and
|
||
> trivially unit-testable, and keeps the *physics* honest: nothing is imposed, all
|
||
> efficiencies emerge from a genuine coupled solve.
|
||
|
||
---
|
||
|
||
## 2. Design philosophy: standards are data, not code
|
||
|
||
Rating standards change. AHRI and Eurovent re-fit their part-load weights; EN
|
||
14825 revises its climate bins and reference seasons. If those coefficients were
|
||
hard-coded arithmetic, every revision would mean a code change, a rebuild, and a
|
||
re-release.
|
||
|
||
Instead, a standard in Entropyk is a **plain data record**:
|
||
|
||
- A weighted part-load standard is just `{ load_fractions, weights }` plus a name
|
||
and a citation.
|
||
- A climate standard is just `{ bins }` (temperature + annual hours) plus a name
|
||
and a citation.
|
||
|
||
Because they are data, a standard can be:
|
||
|
||
- a **built-in preset** (a function returning the record — e.g.
|
||
`PartLoadStandard::ahri_550_590_iplv()`),
|
||
- looked up by **name** in a small registry (`PartLoadStandard::builtin("iplv")`),
|
||
- or **loaded from a JSON file** at run time and validated
|
||
(`serde_json::from_str::<PartLoadStandard>(...)?.validate()?`).
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 3. Library API (`entropyk::rating`)
|
||
|
||
```rust
|
||
use entropyk::rating::{
|
||
PartLoadStandard, PartLoadEfficiencies, BinClimateStandard,
|
||
BinPerformance, TemperatureBin, RatingCondition, RatingError, scop,
|
||
};
|
||
```
|
||
|
||
### 3.1 `PartLoadStandard`
|
||
|
||
The data-driven weighting standard behind IPLV / NPLV / ESEER / weighted SEER.
|
||
|
||
```rust
|
||
pub struct PartLoadStandard {
|
||
pub name: String, // e.g. "AHRI 550/590 IPLV"
|
||
pub reference: String, // citation / provenance (optional in JSON)
|
||
pub load_fractions: Vec<f64>, // e.g. [1.0, 0.75, 0.50, 0.25]
|
||
pub weights: Vec<f64>, // same length; must sum to 1.0
|
||
}
|
||
```
|
||
|
||
**Construction & validation**
|
||
|
||
| Method | Description |
|
||
| :--- | :--- |
|
||
| `new(name, reference, load_fractions, weights) -> Result<Self, RatingError>` | Build **and** validate in one step. |
|
||
| `validate() -> Result<(), RatingError>` | Non-empty, equal-length fractions/weights, weights sum to `1.0` within `1e-6`. |
|
||
| `len() -> usize` / `is_empty() -> bool` | Number of load points. |
|
||
|
||
**Integration**
|
||
|
||
| Method | Description |
|
||
| :--- | :--- |
|
||
| `integrate(&self, efficiencies: &[f64]) -> Result<f64, RatingError>` | Weighted sum `Σ eᵢ·wᵢ`. `efficiencies` must match `weights` in count **and order**. |
|
||
|
||
**Built-in presets & registry**
|
||
|
||
| Method | Returns |
|
||
| :--- | :--- |
|
||
| `ahri_550_590_iplv()` | AHRI IPLV/NPLV — weights `[0.01, 0.42, 0.45, 0.12]`. |
|
||
| `eurovent_eseer()` | Eurovent ESEER — weights `[0.03, 0.33, 0.41, 0.23]`. |
|
||
| `builtin(id: &str) -> Option<Self>` | Case/separator-insensitive lookup (see ids below). |
|
||
| `builtin_ids() -> &'static [&'static str]` | `["iplv", "nplv", "eseer"]` (for help text). |
|
||
|
||
Recognised `builtin` ids (case-insensitive; spaces, `-` and `/` are normalised to
|
||
`_`):
|
||
|
||
| Id(s) | Standard |
|
||
| :--- | :--- |
|
||
| `iplv`, `nplv`, `ahri`, `ahri_550_590`, `ahri_551_591` | AHRI 550/590 IPLV |
|
||
| `eseer`, `eurovent` | Eurovent ESEER |
|
||
|
||
**Example — built-in**
|
||
|
||
```rust
|
||
let iplv = PartLoadStandard::ahri_550_590_iplv();
|
||
let value = iplv.integrate(&[4.0, 5.0, 6.0, 5.5]).unwrap();
|
||
// value == 0.01*4.0 + 0.42*5.0 + 0.45*6.0 + 0.12*5.5
|
||
```
|
||
|
||
**Example — custom, from JSON, no code change**
|
||
|
||
```rust
|
||
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)?;
|
||
std.validate()?;
|
||
let seer = std.integrate(&[3.0, 4.0, 5.0, 4.5])?;
|
||
```
|
||
|
||
### 3.2 `PartLoadEfficiencies`
|
||
|
||
A convenience holder for the classic **four-point** case, ordered
|
||
`[100 %, 75 %, 50 %, 25 %]`.
|
||
|
||
```rust
|
||
pub struct PartLoadEfficiencies { pub at_100: f64, pub at_75: f64, pub at_50: f64, pub at_25: f64 }
|
||
```
|
||
|
||
| Method | Description |
|
||
| :--- | :--- |
|
||
| `new(at_100, at_75, at_50, at_25)` | Construct. |
|
||
| `as_array() -> [f64; 4]` | `[100, 75, 50, 25]` order. |
|
||
| `integrate(&self, std: &PartLoadStandard) -> Result<f64, RatingError>` | **Modular entry point** — apply any four-point standard. |
|
||
| `iplv() -> f64` | Convenience wrapper (AHRI weights). |
|
||
| `nplv() -> f64` | Alias of `iplv()` (identical arithmetic). |
|
||
| `eseer() -> f64` | Convenience wrapper (Eurovent weights). |
|
||
|
||
```rust
|
||
let eff = PartLoadEfficiencies::new(4.0, 5.2, 6.1, 5.4);
|
||
let iplv = eff.iplv(); // legacy helper
|
||
let iplv2 = eff.integrate(&PartLoadStandard::ahri_550_590_iplv())?; // modular
|
||
assert!((iplv - iplv2).abs() < 1e-12);
|
||
```
|
||
|
||
### 3.3 `BinClimateStandard`, `BinPerformance` and `scop`
|
||
|
||
The temperature-bin method behind **SCOP** (and bin-based **SEER**).
|
||
|
||
```rust
|
||
pub struct TemperatureBin { pub temperature_c: f64, pub hours: f64 }
|
||
|
||
pub struct BinClimateStandard {
|
||
pub name: String,
|
||
pub reference: String,
|
||
pub bins: Vec<TemperatureBin>,
|
||
}
|
||
|
||
pub struct BinPerformance {
|
||
pub bin: TemperatureBin,
|
||
pub demand_w: f64, // building demand at this bin temperature [W]
|
||
pub cop: f64, // machine COP at this bin temperature
|
||
}
|
||
|
||
pub fn scop(bins: &[BinPerformance]) -> Option<f64>;
|
||
```
|
||
|
||
`BinClimateStandard` methods:
|
||
|
||
| Method | Description |
|
||
| :--- | :--- |
|
||
| `en_14825_average()` | EN 14825 **average** heating season (Strasbourg reference, 4910 h). |
|
||
| `builtin(id) -> Option<Self>` | Ids: `en_14825_average`, `average`, `en14825`. |
|
||
| `builtin_ids() -> &'static [&'static str]` | For help text. |
|
||
| `total_hours() -> f64` | Sum of `hours` across all bins. |
|
||
| `validate() -> Result<(), RatingError>` | At least one bin. |
|
||
|
||
`scop(...)` computes:
|
||
|
||
```
|
||
SCOP = Σ (hoursⱼ · demandⱼ) / Σ (hoursⱼ · demandⱼ / COPⱼ)
|
||
```
|
||
|
||
Bins with non-positive hours, demand or COP are ignored. Returns `None` if no
|
||
valid bin remains (total electrical energy would be zero).
|
||
|
||
**Example — SCOP over a climate**
|
||
|
||
```rust
|
||
let climate = BinClimateStandard::en_14825_average();
|
||
let bins: Vec<BinPerformance> = climate.bins.iter().map(|&bin| BinPerformance {
|
||
bin,
|
||
demand_w: building_demand_at(bin.temperature_c), // your building model
|
||
cop: machine_cop_at(bin.temperature_c), // from a coupled solve
|
||
}).collect();
|
||
let scop = scop(&bins).expect("valid climate");
|
||
```
|
||
|
||
> **Swapping the climate** is just choosing a different `BinClimateStandard` —
|
||
> a preset, or a JSON file with a different `bins` table (warmer/colder season, a
|
||
> revised reference table, or a local TMY-derived climate). The `scop`
|
||
> arithmetic never changes.
|
||
|
||
### 3.4 `RatingCondition`
|
||
|
||
Named full-load rating envelopes (secondary-fluid temperatures) used to define
|
||
where the 100 % point is measured. Refrigerant regimes emerge from the coupled
|
||
heat-exchanger solve, so only the **secondary** conditions are prescribed.
|
||
|
||
```rust
|
||
pub struct RatingCondition {
|
||
pub name: &'static str,
|
||
pub evap_secondary_out_c: f64, // chilled-fluid supply
|
||
pub evap_secondary_in_c: f64, // chilled-fluid return
|
||
pub cond_secondary_in_c: f64, // condenser/gas-cooler entering
|
||
}
|
||
```
|
||
|
||
Provided constants:
|
||
|
||
| Constant | Chilled supply/return | Condenser entering |
|
||
| :--- | :--- | :--- |
|
||
| `AHRI_550_590_WATER_COOLED` | 6.7 / 12.2 °C | 29.4 °C (water) |
|
||
| `AHRI_550_590_AIR_COOLED` | 6.7 / 12.2 °C | 35.0 °C (air) |
|
||
| `EN_14511_WATER_COOLED_A` | 7.0 / 12.0 °C | 30.0 °C (water) |
|
||
| `EN_14511_AIR_COOLED_A` | 7.0 / 12.0 °C | 35.0 °C (air) |
|
||
|
||
### 3.5 `RatingError`
|
||
|
||
```rust
|
||
pub enum RatingError {
|
||
Empty, // no load points
|
||
LengthMismatch { fractions: usize, weights: usize },
|
||
WeightsNotNormalized { sum: f64 }, // |Σw − 1| > 1e-6
|
||
EfficiencyCountMismatch { expected: usize, got: usize },
|
||
}
|
||
```
|
||
|
||
Implements `Display` + `std::error::Error`, so it composes with `?` and `anyhow`.
|
||
|
||
### 3.6 Legacy constants
|
||
|
||
Kept for backward compatibility (the presets are built from them):
|
||
|
||
| Constant | Value |
|
||
| :--- | :--- |
|
||
| `STANDARD_LOAD_FRACTIONS` | `[1.0, 0.75, 0.50, 0.25]` |
|
||
| `IPLV_WEIGHTS` | `[0.01, 0.42, 0.45, 0.12]` |
|
||
| `ESEER_WEIGHTS` | `[0.03, 0.33, 0.41, 0.23]` |
|
||
| `EN_14825_AVERAGE_BINS` | 26 bins, −10…+15 °C, sum 4910 h |
|
||
|
||
---
|
||
|
||
## 4. The `rate` CLI command
|
||
|
||
`rate` re-solves a full cycle configuration at each standardized part-load point
|
||
and aggregates the resulting EERs into the chosen seasonal metric. Every point is
|
||
a **genuine coupled solve** (condensing/evaporating pressures, capacity and power
|
||
all emerge from the heat-exchanger ↔ secondary balance), so the integrated value
|
||
is real simulation output — not imposed design points.
|
||
|
||
```bash
|
||
# Pretty table to stdout
|
||
entropyk-cli rate --config crates/cli/examples/rate_chiller_iplv_ahri.json
|
||
|
||
# Also write a JSON report
|
||
entropyk-cli rate -c rate_config.json -o rate_report.json
|
||
|
||
# Machine-readable JSON only
|
||
entropyk-cli --quiet rate -c rate_config.json
|
||
```
|
||
|
||
### 4.1 Configuration schema
|
||
|
||
```jsonc
|
||
{
|
||
"base_config": "chiller_r134a_emergent_pressure.json", // required; path to a `run` scenario
|
||
|
||
// --- standard selection (see precedence below); all optional ---
|
||
"metric": "iplv", // built-in enum: "iplv" (default) | "eseer"
|
||
"standard_name": "eseer", // built-in id (overrides `metric`)
|
||
"standard_file": "my_standard.json", // custom PartLoadStandard JSON (overrides `standard_name`)
|
||
"standard": { // inline PartLoadStandard (highest precedence)
|
||
"name": "…", "reference": "…",
|
||
"load_fractions": [1.0, 0.75, 0.5, 0.25],
|
||
"weights": [0.01, 0.42, 0.45, 0.12]
|
||
},
|
||
|
||
// --- the part-load points (usually four) ---
|
||
"points": [
|
||
{
|
||
"load_fraction": 1.00, // required; used to match the standard's fractions
|
||
"condenser_secondary_inlet_c": 29.4, // optional overrides applied to the base config
|
||
"evaporator_secondary_inlet_c": 12.0,
|
||
"condenser_secondary_mass_flow_kg_s": 0.36,
|
||
"evaporator_secondary_mass_flow_kg_s": 0.48,
|
||
"compressor_speed_hz": 50.0
|
||
}
|
||
// … 0.75, 0.50, 0.25 …
|
||
]
|
||
}
|
||
```
|
||
|
||
**`base_config`** is resolved relative to the rating file's directory when not
|
||
absolute. **`standard_file`** is likewise resolved relative to the rating file.
|
||
|
||
**Per-point overrides** are written into the base config's matching components
|
||
before re-solving. They map to these component params (see the
|
||
[`run` documentation](./CLI_TUTORIAL.md) for the full param list):
|
||
|
||
| Rating override | Component(s) matched | Param set |
|
||
| :--- | :--- | :--- |
|
||
| `condenser_secondary_inlet_c` | `Condenser` | `secondary_inlet_temp_c` |
|
||
| `condenser_secondary_mass_flow_kg_s` | `Condenser` | `secondary_mass_flow_kg_s` |
|
||
| `evaporator_secondary_inlet_c` | `Evaporator`, `FloodedEvaporator` | `secondary_inlet_temp_c` |
|
||
| `evaporator_secondary_mass_flow_kg_s` | `Evaporator`, `FloodedEvaporator` | `secondary_mass_flow_kg_s` |
|
||
| `compressor_speed_hz` | `IsentropicCompressor`, `Compressor` | `speed_hz` |
|
||
|
||
Overrides apply to **all** matching components (single-circuit chillers, the
|
||
common case, have exactly one each). Anything not overridden is inherited from the
|
||
base config, so every point runs the *same machine* through a different operating
|
||
envelope.
|
||
|
||
### 4.2 Selecting the standard (precedence)
|
||
|
||
Highest to lowest:
|
||
|
||
1. **`standard`** — an inline `PartLoadStandard` object.
|
||
2. **`standard_file`** — a JSON `PartLoadStandard`, resolved relative to the config.
|
||
3. **`standard_name`** — a built-in id (`iplv`, `nplv`, `eseer`, …).
|
||
4. **`metric`** — the enum (`iplv` default, or `eseer`).
|
||
|
||
The resolved standard is **validated** before use; an invalid standard (bad
|
||
lengths, weights not summing to 1, unknown id) fails fast with a clear error.
|
||
|
||
### 4.3 How a part-load point is solved
|
||
|
||
For each point, in parallel (rayon):
|
||
|
||
1. **Clone** the base `ScenarioConfig`.
|
||
2. **Apply overrides** to matching components' params.
|
||
3. **Serialize** and **re-solve** via the standard `run` pipeline
|
||
(`simulate_from_json`).
|
||
4. **Extract** `EER = performance.cop`, plus cooling capacity and power.
|
||
|
||
Aggregation then matches each of the standard's `load_fractions` to the **nearest**
|
||
part-load point (within a tolerance of `0.02`) and integrates with the standard's
|
||
weights. If any required load fraction has no converged point within tolerance,
|
||
the integrated value is reported as `null` while the per-point rows are still
|
||
shown.
|
||
|
||
### 4.4 Report format
|
||
|
||
```rust
|
||
pub struct RateReport {
|
||
pub base_config: String,
|
||
pub metric: String, // the standard's NAME, e.g. "AHRI 550/590 IPLV"
|
||
pub standard_reference: String, // the standard's citation
|
||
pub points: Vec<RatePointResult>, // ordered by descending load fraction
|
||
pub integrated_value: Option<f64>,
|
||
}
|
||
|
||
pub struct RatePointResult {
|
||
pub load_fraction: f64,
|
||
pub status: String, // "converged" | "timeout" | "non_converged" | "error"
|
||
pub eer: Option<f64>,
|
||
pub q_cooling_kw: Option<f64>,
|
||
pub power_kw: Option<f64>,
|
||
pub error: Option<String>, // present only on failure
|
||
}
|
||
```
|
||
|
||
### 4.5 Worked examples
|
||
|
||
**Built-in AHRI IPLV** — [`rate_chiller_iplv_ahri.json`](../crates/cli/examples/rate_chiller_iplv_ahri.json):
|
||
|
||
```bash
|
||
entropyk-cli rate -c crates/cli/examples/rate_chiller_iplv_ahri.json
|
||
```
|
||
|
||
```text
|
||
Base config: chiller_r134a_emergent_pressure.json
|
||
Standard: AHRI 550/590 IPLV
|
||
Reference: AHRI Standard 550/590 — Integrated Part Load Value
|
||
|
||
Load[%] Status Q_cool[kW] Power[kW] EER[-]
|
||
──────────────────────────────────────────────────────────
|
||
100 converged 7.439 1.819 4.090
|
||
75 converged 6.363 1.117 5.697
|
||
50 converged 4.938 0.542 9.108
|
||
25 converged 2.938 0.223 13.183
|
||
──────────────────────────────────────────────────────────
|
||
|
||
AHRI 550/590 IPLV = 8.114
|
||
```
|
||
|
||
**External custom standard** —
|
||
[`rate_chiller_custom_standard.json`](../crates/cli/examples/rate_chiller_custom_standard.json)
|
||
loads its weights from [`standard_custom_iplv.json`](../crates/cli/examples/standard_custom_iplv.json)
|
||
(`standard_file`). Same points, revised weights `[0.02, 0.40, 0.45, 0.13]`:
|
||
|
||
```text
|
||
Standard: Custom IPLV (revised weights)
|
||
…
|
||
Custom IPLV (revised weights) = 8.173
|
||
```
|
||
|
||
The only difference between the two runs is the weighting file — the physics
|
||
(per-point EERs) is identical.
|
||
|
||
---
|
||
|
||
## 5. Custom standard JSON schema
|
||
|
||
A `PartLoadStandard` file (used by `standard_file`, or inline under `standard`):
|
||
|
||
```json
|
||
{
|
||
"name": "Custom IPLV (revised weights)",
|
||
"reference": "Illustrative custom weighting — edit to track a revised norm",
|
||
"load_fractions": [1.0, 0.75, 0.50, 0.25],
|
||
"weights": [0.02, 0.40, 0.45, 0.13]
|
||
}
|
||
```
|
||
|
||
Rules enforced by `validate()`:
|
||
|
||
- `load_fractions` and `weights` are **non-empty** and have the **same length**.
|
||
- `weights` **sum to 1.0** within `1e-6`.
|
||
- `reference` is optional (defaults to empty).
|
||
- Any number of points is allowed — the `rate` command matches them to your
|
||
configured `points` by nearest `load_fraction`.
|
||
|
||
---
|
||
|
||
## 6. How to change a standard when the norm changes
|
||
|
||
This is the core requirement the design serves. Three escalating options, none of
|
||
which require touching the solver or the CLI:
|
||
|
||
**Option A — no rebuild: ship a JSON file.**
|
||
Edit or add a `PartLoadStandard` JSON file and point `standard_file` at it (or
|
||
inline it under `standard`). This is the recommended path for tracking a revised
|
||
norm in the field.
|
||
|
||
```jsonc
|
||
// rate_config.json
|
||
{ "base_config": "chiller.json", "standard_file": "ahri_550_590_2026.json", "points": [ … ] }
|
||
```
|
||
|
||
**Option B — a new built-in preset (small code change, for standards you ship).**
|
||
Add a constructor and register it in the lookup, in
|
||
[`crates/entropyk/src/rating.rs`](../crates/entropyk/src/rating.rs):
|
||
|
||
```rust
|
||
impl PartLoadStandard {
|
||
pub fn ahri_550_590_2026_iplv() -> Self {
|
||
Self {
|
||
name: "AHRI 550/590-2026 IPLV".into(),
|
||
reference: "AHRI Standard 550/590 (2026 revision)".into(),
|
||
load_fractions: vec![1.0, 0.75, 0.50, 0.25],
|
||
weights: vec![/* new coefficients */],
|
||
}
|
||
}
|
||
}
|
||
|
||
// in builtin():
|
||
"iplv_2026" | "ahri_550_590_2026" => Some(Self::ahri_550_590_2026_iplv()),
|
||
```
|
||
|
||
Then add the id to `builtin_ids()` and a unit test asserting the weights and that
|
||
they sum to 1.
|
||
|
||
**Option C — revise an existing preset's coefficients.**
|
||
If a standard simply re-fits the weights of an existing metric, update the
|
||
corresponding constant (`IPLV_WEIGHTS` / `ESEER_WEIGHTS`) or the preset body, and
|
||
update the affected unit tests. The `validate()` invariant (weights sum to 1)
|
||
guards against typos.
|
||
|
||
> Whichever option you choose, the surrounding cycle solve, the `rate` command and
|
||
> the report format stay untouched — only the *data* changes.
|
||
|
||
---
|
||
|
||
## 7. The `scop` / `seer` CLI commands (bin method)
|
||
|
||
While `rate` integrates a handful of weighted part-load points, **SCOP** (seasonal
|
||
heating) and **SEER** (seasonal cooling) follow the EN 14825 **bin method**: the
|
||
machine is re-solved at every outdoor temperature bin of a climate, the building
|
||
demand is derived from a linear load line, and the season is aggregated by an
|
||
energy ratio.
|
||
|
||
```bash
|
||
# Seasonal heating COP (SCOP) — defaults to the EN 14825 average heating season
|
||
cargo run -p entropyk-cli -- scop --config crates/cli/examples/scop_heatpump_r134a.json
|
||
|
||
# Seasonal cooling EER (SEER) — requires an explicit cooling climate
|
||
cargo run -p entropyk-cli -- seer --config my_seer.json --output seer.json
|
||
|
||
# Machine-readable JSON only
|
||
cargo run -p entropyk-cli -- --quiet scop --config scop_heatpump_r134a.json
|
||
```
|
||
|
||
Both commands share one module (`entropyk_cli::seasonal`) and one config shape;
|
||
the subcommand only fixes the *mode* (which heat-exchanger side the outdoor
|
||
temperature drives and which duty is the useful output):
|
||
|
||
| Mode | Subcommand | Useful duty | Outdoor side (default) | Default climate |
|
||
|--------|------------|--------------------|------------------------|-----------------------|
|
||
| SCOP | `scop` | heating (condenser)| evaporator | `en_14825_average` |
|
||
| SEER | `seer` | cooling (evaporator)| condenser | *(explicit required)* |
|
||
|
||
### 7.1 Configuration schema
|
||
|
||
```jsonc
|
||
{
|
||
"base_config": "heatpump_r134a_air_source.json", // base run scenario (rel. to this file)
|
||
|
||
// Climate selection — precedence: climate > climate_file > climate_name > default
|
||
"climate_name": "en_14825_average", // built-in BinClimateStandard id
|
||
// "climate_file": "my_cooling_bins.json", // external BinClimateStandard JSON
|
||
// "climate": { ...inline BinClimateStandard... },
|
||
|
||
"outdoor_side": "evaporator", // "evaporator" | "condenser"; defaults by mode
|
||
|
||
// Building load line (linear demand vs outdoor temperature)
|
||
"design_load_w": 6000.0, // demand [W] at design_outdoor_c
|
||
"design_outdoor_c": -10.0, // outdoor temp where demand = design_load_w
|
||
"threshold_outdoor_c": 16.0, // outdoor temp where demand reaches 0 (default 16)
|
||
|
||
"cd": 0.25, // cycling degradation coeff (PLF = 1 - Cd*(1-CR)); default 0.25
|
||
"backup_cop": 1.0 // COP of electric backup for heating deficits; default 1.0
|
||
}
|
||
```
|
||
|
||
Only `base_config`, `design_load_w` and `design_outdoor_c` are required. The
|
||
outdoor temperature is applied to the matching heat exchanger's
|
||
`secondary_inlet_temp_c` — so the base config's secondary stream **flow** and
|
||
**cp** (e.g. air cp 1006 J/kg·K) are kept and only the inlet temperature is swept.
|
||
|
||
### 7.2 How a bin is solved
|
||
|
||
For each bin at outdoor temperature `Tj`, with hours `h`:
|
||
|
||
1. **Demand** from the linear load line —
|
||
heating: `demand = design_load · (threshold − Tj) / (threshold − design)`;
|
||
cooling: `demand = design_load · (Tj − threshold) / (design − threshold)`,
|
||
clamped at ≥ 0.
|
||
2. **Full-load solve** — the base cycle is re-solved with the outdoor-side
|
||
`secondary_inlet_temp_c = Tj` (a genuine coupled solve, *no* injected regime),
|
||
giving `Q_full`, `W_full`, `COP_full`.
|
||
3. **Capacity ratio** `CR = demand / Q_full`.
|
||
- `CR ≤ 1` (part load): cycling degradation `PLF = 1 − Cd·(1 − CR)`,
|
||
`COP_bin = COP_full · PLF`.
|
||
- `CR > 1` (heating deficit): electric backup covers `demand − Q_full`,
|
||
`COP_bin = demand / (W_full + (demand − Q_full)/backup_cop)`; the reported
|
||
`backup` fraction is `(demand − Q_full)/demand`. For cooling the capacity
|
||
simply caps at `Q_full`.
|
||
4. **Aggregate** — seasonal useful energy `Σ demand·h`, seasonal electric energy
|
||
`Σ (demand/COP_bin)·h`, and `SCOP/SEER = useful / electric`.
|
||
|
||
Bins whose demand is 0 (above the threshold for heating) contribute no energy and
|
||
are skipped in the aggregation. If a *demanded* bin fails to converge, the
|
||
integrated value is reported as unavailable rather than silently biased.
|
||
|
||
### 7.3 Report format
|
||
|
||
`SeasonalReport` (also the `--quiet` / `--output` JSON):
|
||
|
||
| Field | Meaning |
|
||
|--------------------------|-----------------------------------------------------|
|
||
| `base_config` | base scenario path |
|
||
| `metric` | `"SCOP"` or `"SEER"` |
|
||
| `climate` | climate standard name |
|
||
| `climate_reference` | standard citation |
|
||
| `total_hours` | Σ bin hours |
|
||
| `bins[]` | per-bin rows (see below) |
|
||
| `seasonal_useful_kwh` | Σ demand·hours / 1000 |
|
||
| `seasonal_electric_kwh` | Σ electric·hours / 1000 |
|
||
| `integrated_value` | SCOP/SEER, or `null` if a demanded bin failed |
|
||
|
||
Each `bins[]` row: `temperature_c`, `hours`, `demand_w`, `capacity_w`,
|
||
`full_cop`, `backup_fraction`, `effective_cop`.
|
||
|
||
### 7.4 Worked example (SCOP)
|
||
|
||
`crates/cli/examples/scop_heatpump_r134a.json` runs the emergent air-source heat
|
||
pump (`heatpump_r134a_air_source.json`, evaporator = outdoor air) over the EN 14825
|
||
average heating season:
|
||
|
||
```
|
||
T[°C] h Demand[kW] Cap[kW] COP_full backup COP_eff
|
||
-10.0 1 6.000 4.967 3.649 17% 2.506
|
||
-7.0 24 5.308 5.419 3.775 0% 3.756
|
||
2.0 320 3.231 6.918 4.169 0% 3.613
|
||
7.0 326 2.077 7.840 4.392 0% 3.585
|
||
15.0 74 0.231 9.445 4.751 0% 3.592
|
||
|
||
Seasonal useful: 12393.7 kWh Seasonal electric: 3441.1 kWh
|
||
SCOP = 3.602
|
||
```
|
||
|
||
Full-load COP climbs monotonically as the outdoor air warms (less lift); at the
|
||
coldest bins the capacity falls short of demand and electric backup kicks in
|
||
(non-zero `backup` %), which is exactly where the effective COP is lowest.
|
||
|
||
### 7.5 Adding another bin-based metric
|
||
|
||
Because the climate is a data-driven `BinClimateStandard`, adding a new season
|
||
(SEER climate, a regional heating season, a future EN revision) means authoring a
|
||
bin table — a preset, a built-in id, or an external JSON `climate_file` — with no
|
||
code change. See §5 for the JSON schema (the same `BinClimateStandard` shape used
|
||
by `climate_file`/`climate`).
|
||
|
||
---
|
||
|
||
## 8. Validation & testing
|
||
|
||
The rating math is pure and exhaustively unit-tested. Run:
|
||
|
||
```bash
|
||
# Library: metric math, presets, validation, registries (+ doctests)
|
||
cargo test -p entropyk --lib rating
|
||
cargo test -p entropyk --doc rating
|
||
|
||
# CLI: override application, standard resolution & precedence, aggregation
|
||
cargo test -p entropyk-cli rate
|
||
|
||
# CLI seasonal: bin method demand/degradation/backup + end-to-end SCOP
|
||
cargo test -p entropyk-cli --lib seasonal
|
||
cargo test -p entropyk-cli --test single_run test_scop_command
|
||
```
|
||
|
||
Notable guarantees covered by tests:
|
||
|
||
- Presets reproduce the legacy constants bit-for-bit; `integrate()` agrees with
|
||
`iplv()`/`eseer()` to `1e-12`.
|
||
- `validate()` rejects length mismatches, non-normalized weights and empty
|
||
standards with the right `RatingError`.
|
||
- `builtin()` lookup is case- and separator-insensitive.
|
||
- Custom standards with a different point count integrate correctly.
|
||
- CLI standard **precedence** (`standard` > `standard_file` > `standard_name` >
|
||
`metric`) resolves as specified; unknown ids error clearly.
|
||
- End-to-end: the emergent chiller re-solves four points and produces a genuine
|
||
IPLV, with per-point EER increasing monotonically as the pressure lift falls.
|
||
- Seasonal bin method: demand tracks the linear load line, cycling degradation and
|
||
electric backup apply only where expected, and the end-to-end SCOP over the 26
|
||
EN 14825 bins is a physical energy ratio bounded by the per-bin COPs.
|
||
|
||
> **Build note.** In environments where the Python bindings can't build, restrict
|
||
> to the relevant crates: `cargo test -p entropyk -p entropyk-cli …` (avoid
|
||
> `--workspace`).
|
||
|
||
---
|
||
|
||
## 9. Reference tables
|
||
|
||
**AHRI 550/590 IPLV weights** (`IPLV_WEIGHTS`)
|
||
|
||
| Load | 100 % | 75 % | 50 % | 25 % |
|
||
| :--- | :--- | :--- | :--- | :--- |
|
||
| Weight | 0.01 | 0.42 | 0.45 | 0.12 |
|
||
|
||
**Eurovent ESEER weights** (`ESEER_WEIGHTS`)
|
||
|
||
| Load | 100 % | 75 % | 50 % | 25 % |
|
||
| :--- | :--- | :--- | :--- | :--- |
|
||
| Weight | 0.03 | 0.33 | 0.41 | 0.23 |
|
||
|
||
**EN 14825 average heating season** (`EN_14825_AVERAGE_BINS`) — 26 bins from
|
||
−10 °C to +15 °C, total **4910 h** (Strasbourg reference; EU 813/2013 Annex III
|
||
Table 5 / EN 14825:2018 Table A.4).
|
||
|
||
**Secondary-fluid cp defaults** (used by `run` when only a mass flow is given):
|
||
air `1006 J/(kg·K)`, water `4186 J/(kg·K)`. Provide `secondary_cp_j_per_kgk` or
|
||
`secondary_capacity_rate_w_per_k` to override.
|
||
|
||
---
|
||
|
||
## 10. Standards 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 14511** — rating conditions for air conditioners, liquid chilling packages
|
||
and heat pumps.
|
||
- **EN 14825:2018** — *Testing and rating at part load conditions and calculation
|
||
of seasonal performance* (SCOP/SEER bin method).
|
||
- **Commission Regulation (EU) No 813/2013**, Annex III — European reference
|
||
heating seasons (average/warmer/colder).
|
||
|
||
---
|
||
|
||
### See also
|
||
|
||
- [`crates/entropyk/src/rating.rs`](../crates/entropyk/src/rating.rs) — the module.
|
||
- [`crates/cli/src/rate.rs`](../crates/cli/src/rate.rs) — the `rate` command.
|
||
- [CLI_TUTORIAL.md](./CLI_TUTORIAL.md) — building and running `run` scenarios (the
|
||
`base_config` for a rating).
|
||
- [DOCUMENTATION.md](../DOCUMENTATION.md) — the definitive technical manual.
|