# 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.
