Files
Entropyk/docs/rating-and-seasonal-metrics.md
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
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>
2026-07-19 16:35:31 +02:00

764 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.