Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
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>
This commit is contained in:
2026-07-19 16:35:31 +02:00
parent 88620790d6
commit 5bd180b5b8
1363 changed files with 101041 additions and 58547 deletions

View File

@@ -0,0 +1,763 @@
# 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.