Merge pull request #1321 from Hestia-Homes/fix/off-peak-bill-day-night-split

Fix/off peak bill day night split
This commit is contained in:
Jun-te Kim 2026-06-24 20:35:03 +01:00 committed by GitHub
commit 77133f4b42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 699 additions and 30 deletions

View file

@ -148,9 +148,17 @@ The process that translates an Optimised Package into cert-field changes and pro
_Avoid_: measure overrides (rejected during ADR-0009 grill — phantom mid-layer), package applier, retrofit simulator
**Bill Derivation**:
The deterministic process that derives a Property's annual energy **bill**, composed into per-end-use sections (heating, hot water, lighting, appliances, cooking, pumps/fans, …) plus a **total**, by pricing **SAP10 Calculation**'s delivered kWh per end use at **current Fuel Rates** — each end use billed at its fuel's rate, rolled up per fuel for **standing charges** (metered fuels only — gas/electricity; oil/LPG/solid have none) minus **SEG** export credit on PV. Implemented by `BillDerivation` in `domain/billing/` (a cross-stage concern — the Baseline stage derives the current bill, the Modelling stage re-runs it on the post-package end-state for post-retrofit bills; deterministic, ADR-0006). Reads Fuel Rates from a committed static snapshot via `FuelRatesRepository` (no live ETL yet). **Distinct from the calculator's `total_fuel_cost_gbp`**, which is the SAP-rating notional cost at RdSAP Table 32 standardised prices (~half the real electricity price) — not what the household pays. Raises on a fuel it has no rate for (e.g. house coal, heat network). ADR-0014.
The deterministic process that derives a Property's annual energy **bill**, composed into per-end-use sections (heating, hot water, lighting, appliances, cooking, pumps/fans, …) plus a **total**, by pricing **SAP10 Calculation**'s delivered kWh per end use at **current Fuel Rates** — each end use billed at its fuel's rate, rolled up per fuel for **standing charges** (metered fuels only — gas/electricity; oil/LPG/solid have none) minus **SEG** export credit on PV. Implemented by `BillDerivation` in `domain/billing/` (a cross-stage concern — the Baseline stage derives the current bill, the Modelling stage re-runs it on the post-package end-state for post-retrofit bills; deterministic, ADR-0006). Reads Fuel Rates from a committed static snapshot via `FuelRatesRepository` (no live ETL yet). **Distinct from the calculator's `total_fuel_cost_gbp`**, which is the SAP-rating notional cost at RdSAP Table 32 standardised prices (~half the real electricity price) — not what the household pays. Electricity on an **Off-Peak Meter** is billed day/night, each end use split by its own **High-Rate Fraction**. Raises on a fuel it has no rate for (e.g. house coal, heat network). ADR-0014.
_Avoid_: EPC Energy Derivation (renamed), EpcEnergyDerivationService (no "service" suffix), kWh prediction, baseline kWh, energy estimation
**Off-Peak Meter**:
A dwelling's dual-rate electricity meter (Economy-7-style) charging a cheaper **night** rate and a dearer **day** rate. It is a property of the **meter, not of any one end use**: *every* electric end use — heating, hot water, lighting, appliances, cooking — is billed on it, each split day/night by its own **High-Rate Fraction**. Derived from the RdSAP `meter_type` (or, in synthesis, from the heating SAP code per the heating-system cluster). The calculator already prices the whole meter this way; **Bill Derivation** mirrors it. Pricing an off-peak dwelling's electricity at the single standard rate is wrong in *both* directions — it over-bills night-shifted storage heat and under-bills daytime appliances.
_Avoid_: Economy 7 (the common brand, but the model is tariff-agnostic — 7/10/18/24-hour all map here), off-peak fuel (it is a meter arrangement, not a distinct fuel carrier)
**High-Rate Fraction**:
The fraction of one electric end use's annual kWh billed at the **day** (high) rate on an **Off-Peak Meter**; the remainder bills at the **night** (low) rate. A **calculator output** per end use (SAP 10.2 Table 12a — storage heating ≈ 0 / all-night, lighting & appliances ≈ 0.80.9 / mostly-day), reused by **Bill Derivation** rather than re-derived, so the bill's day/night split never diverges from the rating's. Unregulated loads SAP does not rate (appliances, cooking) inherit the Table-12a `ALL_OTHER_USES` fraction.
_Avoid_: day/night ratio, peak fraction, split factor
**UCL Correction**:
The per-band linear correction (Few et al. 2023, _Energy & Buildings_ 288 113024) that aligns EPC-modelled Primary Energy Intensity with metered consumption. Folded into ML training labels at fit time (per ADR-0007) rather than applied at runtime — the trained model emits metered-equivalent PEUI directly, avoiding the discontinuities at EPC band boundaries that arose when the per-band linear correction was applied post-prediction. Calibrated against gas-heated, non-PV homes in England and Wales rated under SAP 2012; the current implementation extrapolates it to all properties (open question §15.14).
_Avoid_: UCL adjustment, energy correction, metered correction

View file

@ -86,9 +86,7 @@ production migration is FE-owned (Drizzle); `docs/migrations/` updated.
**stubs them at 0 kWh**, so the bill total currently understates by the unregulated electricity
load. Khalim is adding the fields to `SapResult` directly; the adapter wires the
`APPLIANCES`/`COOKING` sections in as soon as they land.
- **Off-peak (Economy 7) day/night split** — the snapshot carries the E7 day/night rates, but
`FuelRates` exposes single-rate fuels only; the day/night accessor + the calculator's Table 12a
high/low-rate split land in a later slice.
- ~~**Off-peak (Economy 7) day/night split**~~ — **resolved, see the 2026-06-24 amendment below.**
- **Heat-network rate model** — heat-network certs raise `UnpricedFuel` for now (the one common gap).
- **Regional rates + Ofgem-cap ETL** — national snapshot now; both are later refinements behind the
same `FuelRatesRepository` port.
@ -152,3 +150,17 @@ Bill Derivation is no longer Baseline-only — the **Modelling** stage now re-ru
- **Plan-level first, per-measure savings next (telescoping cascade).** This slice fills the plan columns (`post_energy_bill`, `post_energy_consumption`, `energy_bill_savings`, `energy_consumption_savings`). Per-measure `recommendation.kwh_savings` / `energy_cost_savings` come from a **bill cascade over the role-3 best-practice order** (fabric → heating → renewables) — re-bill each cumulative prefix and diff, telescoping exactly to the plan totals (mirroring the SAP role-3 attribution; reuses the per-prefix `sap_result`s, no extra calls). Per-measure savings can be **negative** (ventilation increases energy) and still telescope. The legacy `recommendation.energy_savings` column is **vestigial** (legacy set it to `0`; the canonical delivered-energy field is `kwh_savings`) — left NULL.
- **Limitation carried over.** The "Appliances + cooking kWh stubbed at 0" deferral above still applies — Modelling's post-package bill understates by the same unregulated-electricity load until those fields land on `SapResult`. Baseline and Modelling share the gap, so baseline-vs-post savings remain consistent.
## Amendment (2026-06-24): off-peak is a whole-meter tariff, day/night split per end use from the calculator's Table 12a fractions
Resolves the §4 / Deferred "off-peak day/night split" — forced by a `modelling_e2e` failure (`no rate for fuel ELECTRICITY_OFF_PEAK`) on an off-peak-metered dwelling, where the snapshot's E7 day/night entry was deliberately skipped (no single `unit_rate_p_per_kwh`) and `BillDerivation` raised `UnpricedFuel`. Decided in a `/grill-with-docs` session.
- **Off-peak is a property of the METER, not of an end use.** An Economy-7-style dwelling has *one* dual-rate meter, and **every** electric end use — heating, hot water, *and* lighting / pumps-fans / appliances / cooking — bills on it (the calculator already prices the whole meter this way via Table 12a Grid 1 + Grid 2). The pre-amendment `EnergyBreakdown` modelled off-peak as a per-end-use *fuel* that only heating/HW could carry, hard-wiring the other electric lines to standard `ELECTRICITY` — a **latent under-pricing** of the daytime appliance/lighting load (standard 24.67p vs off-peak day 29.73p), the mirror of the heating over-pricing that folding-to-standard would cause. So the breakdown now models off-peak as a **whole-meter tariff**: on an off-peak meter, `from_sap_result` routes *all* electric lines to `Fuel.ELECTRICITY_OFF_PEAK`. This needs an **off-peak-meter signal on `SapResult`** (a fraction of 1.0 on the off-peak day rate is not the same as standard electricity).
- **The day/night split is the calculator's existing Table 12a high-rate fraction, surfaced as a calculator output.** Each end use's **High-Rate Fraction** (fraction of its kWh at the day/high rate; remainder at night/low) is already computed inside `cert_to_inputs` (`_main_space_heating_high_rate_fraction`, `water_heating_high_rate_fraction`, the Table-13 immersion blend, the HP-DHW exception, `other_use_high_rate_fraction` for Grid 2) and folded into a blended *Table-32* cost the bill discards. Per the "fuel is a calculator output" amendment above, the **fraction is now also calculator output** — threaded `CalculatorInputs → SapResult` per end use — so the bill **reuses** it rather than re-deriving a second day/night model that would drift from the rating. Unregulated loads SAP does not rate (appliances, cooking) have no Table 12a fraction; they **inherit the `ALL_OTHER_USES` fraction** (same Grid-2 row + daytime-weighted profile as lighting) — a documented extension, since SAP itself never splits them.
- **Representation: fraction-on-the-line; rates stay in `FuelRates`.** `EnergyLine` gains an optional `high_rate_fraction` (`None` = single-rate fuel). `FuelRates` gains a **day/night accessor** for `ELECTRICITY_OFF_PEAK` (the repository now reads the snapshot's existing `day_p_per_kwh` / `night_p_per_kwh`). `BillDerivation` prices an off-peak line at `kwh × (frac × day_rate + (1frac) × night_rate)`. Rejected: (a) **pre-splitting kWh into day/night lines** upstream — leaks rate-tier structure into the calculator/adapter and doubles the section roll-up; (b) the **blended day/night rate handed over by the calculator** — that is the calculator pricing at real rates, the exact boundary §2 draws.
- **Rejected shortcuts (both re-open closed decisions).** *Fold `ELECTRICITY_OFF_PEAK` → standard `ELECTRICITY`*: knowingly mis-prices in both directions (≈+45% on night-shifted storage heat, under-prices daytime loads). *A single hand-blended off-peak `unit_rate` in the snapshot*: reintroduces the legacy `AnnualBillSavings.py` **blended `PRICE_FACTOR`** anti-pattern this ADR's Context rewrote away from — the day/night weighting is per-end-use and per-dwelling, not a property of the rate.
- **Safety: no-op for unaffected certs.** Standard-tariff dwellings are unchanged (every fraction is 1.0 and the carrier stays `ELECTRICITY`). Off-peak dwellings currently **hard-crash** (`UnpricedFuel` aborts the batch, §1), so there is no passing bill value to regress — they move from crash → priced. SAP scores are untouched: the bill is output-only and never feeds the rating.

View file

@ -28,11 +28,17 @@ class BillSection(Enum):
@dataclass(frozen=True)
class EnergyLine:
"""One section's delivered energy on one fuel. A section may have more than
one line (e.g. gas main heating + electric secondary heating)."""
one line (e.g. gas main heating + electric secondary heating).
``high_rate_fraction`` is the calculator's High-Rate Fraction for this end
use the share of its kWh billed at the day (high) rate on an Off-Peak
Meter set only on ``ELECTRICITY_OFF_PEAK`` lines; ``None`` for single-rate
fuels, which bill at their flat unit rate."""
section: BillSection
fuel: Fuel
kwh: float
high_rate_fraction: Optional[float] = None
@dataclass(frozen=True)
@ -51,35 +57,74 @@ class EnergyBreakdown:
are billed at their resolved fuel (`sap_code_to_fuel`); lighting / pumps-
fans / appliances / cooking / cooling are electricity by construction. A
line is emitted only when its kWh is positive; PV export carries to
`exported_kwh` for the SEG credit. The `from_*` factory mirrors
`Performance.from_sap_result`; living on the target keeps the calculator
free of any `property_baseline` dependency."""
`exported_kwh` for the SEG credit.
On an **Off-Peak Meter** (`result.is_off_peak_meter`) the whole meter is
off-peak: every electric end use bills on `ELECTRICITY_OFF_PEAK`, each
carrying its calculator **High-Rate Fraction** for the day/night split
(appliances / cooking / cooling inherit the `ALL_OTHER_USES` fraction).
The `from_*` factory mirrors `Performance.from_sap_result`; living on the
target keeps the calculator free of any `property_baseline` dependency."""
off_peak = result.is_off_peak_meter
candidates = [
_fuelled_line(
BillSection.HEATING,
result.main_heating_fuel_code,
result.main_heating_fuel_kwh_per_yr,
high_rate_fraction=result.main_heating_high_rate_fraction,
off_peak_meter=off_peak,
),
_fuelled_line(
BillSection.HEATING,
result.main_2_heating_fuel_code,
result.main_2_heating_fuel_kwh_per_yr,
high_rate_fraction=result.main_2_heating_high_rate_fraction,
off_peak_meter=off_peak,
),
_fuelled_line(
BillSection.HEATING,
result.secondary_heating_fuel_code,
result.secondary_heating_fuel_kwh_per_yr,
high_rate_fraction=result.secondary_heating_high_rate_fraction,
off_peak_meter=off_peak,
),
_fuelled_line(
BillSection.HOT_WATER,
result.hot_water_fuel_code,
result.hot_water_kwh_per_yr,
high_rate_fraction=result.hot_water_high_rate_fraction,
off_peak_meter=off_peak,
),
_electric_line(
BillSection.LIGHTING,
result.lighting_kwh_per_yr,
high_rate_fraction=result.other_electricity_high_rate_fraction,
off_peak_meter=off_peak,
),
_electric_line(
BillSection.PUMPS_FANS,
result.pumps_fans_kwh_per_yr,
high_rate_fraction=result.pumps_fans_high_rate_fraction,
off_peak_meter=off_peak,
),
_electric_line(
BillSection.APPLIANCES,
result.appliances_kwh_per_yr,
high_rate_fraction=result.other_electricity_high_rate_fraction,
off_peak_meter=off_peak,
),
_electric_line(
BillSection.COOKING,
result.cooking_kwh_per_yr,
high_rate_fraction=result.other_electricity_high_rate_fraction,
off_peak_meter=off_peak,
),
_electric_line(
BillSection.COOLING,
result.space_cooling_fuel_kwh_per_yr,
high_rate_fraction=result.other_electricity_high_rate_fraction,
off_peak_meter=off_peak,
),
_electric_line(BillSection.LIGHTING, result.lighting_kwh_per_yr),
_electric_line(BillSection.PUMPS_FANS, result.pumps_fans_kwh_per_yr),
_electric_line(BillSection.APPLIANCES, result.appliances_kwh_per_yr),
_electric_line(BillSection.COOKING, result.cooking_kwh_per_yr),
_electric_line(BillSection.COOLING, result.space_cooling_fuel_kwh_per_yr),
]
return cls(
lines=[line for line in candidates if line is not None],
@ -88,7 +133,12 @@ class EnergyBreakdown:
def _fuelled_line(
section: BillSection, fuel_code: Optional[int], kwh: float
section: BillSection,
fuel_code: Optional[int],
kwh: float,
*,
high_rate_fraction: float,
off_peak_meter: bool,
) -> Optional[EnergyLine]:
"""An `EnergyLine` for a fuelled end use, or None when it has no energy. A
positive kWh with no resolved fuel code is a data gap raise rather than
@ -100,14 +150,50 @@ def _fuelled_line(
f"{section.value} has {kwh} kWh but no fuel code on the SapResult; "
"cannot attribute a billing fuel"
)
return EnergyLine(section=section, fuel=sap_code_to_fuel(fuel_code), kwh=kwh)
return _line(
section, sap_code_to_fuel(fuel_code), kwh,
high_rate_fraction=high_rate_fraction, off_peak_meter=off_peak_meter,
)
def _electric_line(section: BillSection, kwh: float) -> Optional[EnergyLine]:
def _electric_line(
section: BillSection,
kwh: float,
*,
high_rate_fraction: float,
off_peak_meter: bool,
) -> Optional[EnergyLine]:
"""An electricity `EnergyLine` for an electric end use, or None when zero."""
if kwh <= 0:
return None
return EnergyLine(section=section, fuel=Fuel.ELECTRICITY, kwh=kwh)
return _line(
section, Fuel.ELECTRICITY, kwh,
high_rate_fraction=high_rate_fraction, off_peak_meter=off_peak_meter,
)
def _line(
section: BillSection,
fuel: Fuel,
kwh: float,
*,
high_rate_fraction: float,
off_peak_meter: bool,
) -> EnergyLine:
"""Build a line, routing electricity to the Off-Peak Meter carrier. On an
off-peak meter every electric end use (and any end use whose own fuel code
already resolved to off-peak electricity) bills on `ELECTRICITY_OFF_PEAK`,
carrying its High-Rate Fraction for the day/night split. Non-electric fuels
and standard-meter electricity bill flat (no fraction)."""
is_electric = fuel in (Fuel.ELECTRICITY, Fuel.ELECTRICITY_OFF_PEAK)
if is_electric and (off_peak_meter or fuel is Fuel.ELECTRICITY_OFF_PEAK):
return EnergyLine(
section=section,
fuel=Fuel.ELECTRICITY_OFF_PEAK,
kwh=kwh,
high_rate_fraction=high_rate_fraction,
)
return EnergyLine(section=section, fuel=fuel, kwh=kwh)
@dataclass(frozen=True)

View file

@ -10,6 +10,7 @@ from domain.billing.bill import (
BillSection,
BillSectionCost,
EnergyBreakdown,
EnergyLine,
)
_DAYS_PER_YEAR: Final[float] = 365.0
@ -36,9 +37,7 @@ class BillDerivation:
fuels_used: set[Fuel] = set()
for line in breakdown.lines:
section_kwh[line.section] += line.kwh
section_cost_p[line.section] += (
line.kwh * self._rates.unit_rate_p_per_kwh(line.fuel)
)
section_cost_p[line.section] += line.kwh * self._unit_rate_p_per_kwh(line)
if line.kwh > 0:
fuels_used.add(line.fuel)
@ -69,3 +68,15 @@ class BillDerivation:
seg_credit_gbp=seg_credit_gbp,
total_gbp=total_gbp,
)
def _unit_rate_p_per_kwh(self, line: EnergyLine) -> float:
"""Price one line's fuel (p/kWh). An Off-Peak Meter line blends day/night
by its High-Rate Fraction; every other fuel bills at its flat unit rate."""
if line.fuel is Fuel.ELECTRICITY_OFF_PEAK:
if line.high_rate_fraction is None:
raise ValueError(
f"{line.section.value} bills on an off-peak meter but carries "
"no high-rate fraction; cannot split day/night"
)
return self._rates.off_peak_blended_p_per_kwh(line.high_rate_fraction)
return self._rates.unit_rate_p_per_kwh(line.fuel)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Optional
from domain.fuel_rates.fuel import Fuel, UnpricedFuel
@ -18,6 +19,28 @@ class FuelRate:
standing_charge_p_per_day: float
@dataclass(frozen=True)
class OffPeakRate:
"""An Off-Peak Meter's dual-rate tariff — a cheaper ``night`` (low) rate and
a dearer ``day`` (high) rate, plus the daily standing charge (ADR-0014).
Off-peak electricity has no single unit rate, so it does not live in the
single-rate ``FuelRates.rates`` map; each end use blends day/night by its own
High-Rate Fraction (a calculator output)."""
day_p_per_kwh: float
night_p_per_kwh: float
standing_charge_p_per_day: float
def blended_p_per_kwh(self, high_rate_fraction: float) -> float:
"""Effective p/kWh for an end use billing ``high_rate_fraction`` of its
kWh at the day (high) rate and the remainder at the night (low) rate."""
return (
high_rate_fraction * self.day_p_per_kwh
+ (1.0 - high_rate_fraction) * self.night_p_per_kwh
)
@dataclass(frozen=True)
class FuelRates:
"""A current Fuel Rates snapshot — the rate per billing Fuel plus the SEG
@ -32,11 +55,24 @@ class FuelRates:
period: str
seg_export_p_per_kwh: float
rates: Mapping[Fuel, FuelRate]
off_peak: Optional[OffPeakRate] = None
def off_peak_blended_p_per_kwh(self, high_rate_fraction: float) -> float:
"""Blended day/night p/kWh for an Off-Peak Meter end use at the given
High-Rate Fraction. Raises ``UnpricedFuel`` when the snapshot carries no
off-peak entry."""
if self.off_peak is None:
raise UnpricedFuel(Fuel.ELECTRICITY_OFF_PEAK)
return self.off_peak.blended_p_per_kwh(high_rate_fraction)
def unit_rate_p_per_kwh(self, fuel: Fuel) -> float:
return self._rate(fuel).unit_rate_p_per_kwh
def standing_charge_p_per_day(self, fuel: Fuel) -> float:
if fuel is Fuel.ELECTRICITY_OFF_PEAK:
if self.off_peak is None:
raise UnpricedFuel(fuel)
return self.off_peak.standing_charge_p_per_day
return self._rate(fuel).standing_charge_p_per_day
def _rate(self, fuel: Fuel) -> FuelRate:

View file

@ -355,6 +355,21 @@ class CalculatorInputs:
main_2_heating_fuel_code: Optional[int] = None
secondary_heating_fuel_code: Optional[int] = None
hot_water_fuel_code: Optional[int] = None
# Off-Peak Meter day/night billing metadata for ADR-0014 Bill Derivation —
# output-only (NOT fed into cost / CO2 / PE / sap_score, already priced via
# the per-end-use cost factor fields above). `is_off_peak_meter` routes every
# electric end use to the off-peak carrier; the per-end-use High-Rate
# Fractions (SAP 10.2 Table 12a) drive the day/night split. cert_to_inputs
# supplies them from the same Table-12a helpers the cost cascade uses;
# defaults (`False` + 1.0) keep synthetic / standard-tariff constructions a
# no-op. They thread byte-identical onto `SapResult`.
is_off_peak_meter: bool = False
main_heating_high_rate_fraction: float = 1.0
main_2_heating_high_rate_fraction: float = 1.0
secondary_heating_high_rate_fraction: float = 1.0
hot_water_high_rate_fraction: float = 1.0
pumps_fans_high_rate_fraction: float = 1.0
other_electricity_high_rate_fraction: float = 1.0
@dataclass(frozen=True)
@ -422,6 +437,24 @@ class SapResult:
primary_energy_kwh_per_m2: float
monthly: tuple[MonthlyEntry, ...]
intermediate: dict[str, float]
# Off-Peak Meter day/night billing metadata for ADR-0014 Bill Derivation
# (output-only — NOT fed into ecf / cost / CO2 / PE / sap_score, which the
# rating cascade already prices via the per-end-use cost factors). When the
# dwelling is on an off-peak meter, EVERY electric end use bills on it, each
# split day/night by its own **High-Rate Fraction** (SAP 10.2 Table 12a — the
# share billed at the day/high rate). The fractions are the calculator's own
# Table-12a values, surfaced so the bill reuses them rather than re-deriving
# a second day/night model. Defaults (`False` + 1.0) make a standard-tariff
# dwelling a no-op: the carrier stays `ELECTRICITY` and nothing splits.
is_off_peak_meter: bool = False
main_heating_high_rate_fraction: float = 1.0
main_2_heating_high_rate_fraction: float = 1.0
secondary_heating_high_rate_fraction: float = 1.0
hot_water_high_rate_fraction: float = 1.0
pumps_fans_high_rate_fraction: float = 1.0
# Lighting / appliances / cooking / cooling — the Grid 2 ALL_OTHER_USES split
# (appliances + cooking inherit it; SAP does not rate those unregulated loads).
other_electricity_high_rate_fraction: float = 1.0
def _time_constant_h(*, tmp_kj_per_m2_k: float, tfa_m2: float, hlc_w_per_k: float) -> float:
@ -857,6 +890,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
primary_energy_kwh_per_m2=primary_energy_per_m2,
monthly=monthly,
intermediate=intermediate,
is_off_peak_meter=inputs.is_off_peak_meter,
main_heating_high_rate_fraction=inputs.main_heating_high_rate_fraction,
main_2_heating_high_rate_fraction=inputs.main_2_heating_high_rate_fraction,
secondary_heating_high_rate_fraction=inputs.secondary_heating_high_rate_fraction,
hot_water_high_rate_fraction=inputs.hot_water_high_rate_fraction,
pumps_fans_high_rate_fraction=inputs.pumps_fans_high_rate_fraction,
other_electricity_high_rate_fraction=inputs.other_electricity_high_rate_fraction,
)

View file

@ -2819,6 +2819,76 @@ def _hot_water_fuel_cost_gbp_per_kwh(
return _fuel_cost_gbp_per_kwh(main, prices)
def _hot_water_high_rate_fraction(
water_heating_fuel: Optional[int],
main: Optional[MainHeatingDetail],
tariff: Tariff,
*,
water_heating_code: Optional[int] = None,
inherit_main_for_community_heating: bool = False,
cylinder_volume_l: Optional[float] = None,
occupancy_n: Optional[float] = None,
immersion_single: Optional[bool] = None,
) -> float:
"""ADR-0014 Bill Derivation — the hot-water High-Rate Fraction (the day/high-
rate share) on an Off-Peak Meter, mirroring `_hot_water_fuel_cost_gbp_per_kwh`
branch-for-branch so the bill's day/night HW split matches the rating's:
- community-heating HW inheriting a (non-electric) main, non-electric HW, or
STANDARD tariff 1.0 (single rate, no split);
- HP-DHW (the WHC inherits a PCDB Table 362 heat-pump main) Table 12a Grid 1
WH `ASHP_APP_N` fraction (0.70 at 7-/10-hour);
- electric immersion (WHC 903) with a known cylinder + occupancy the Table
13 dual-immersion fraction (§10.5 assumes a DUAL immersion on a dual meter);
- any other electric off-peak HW (e.g. heated by a storage main) 0.0 (the
timer charges it wholly at the night/low rate)."""
if inherit_main_for_community_heating:
return 1.0
if not _is_electric_water(water_heating_fuel) or tariff is Tariff.STANDARD:
return 1.0
if (
water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES
and main is not None
and main.main_heating_index_number is not None
and heat_pump_record(main.main_heating_index_number) is not None
):
return water_heating_high_rate_fraction(Table12aSystem.ASHP_APP_N, tariff)
if (
water_heating_code == _WHC_ELECTRIC_IMMERSION
and cylinder_volume_l is not None
and occupancy_n is not None
):
effective_single = (
immersion_single if immersion_single is not None else False
)
return electric_dhw_high_rate_fraction(
cylinder_volume_l=cylinder_volume_l,
occupancy_n=occupancy_n,
single_immersion=effective_single,
tariff=tariff,
)
return 0.0
def _secondary_high_rate_fraction(epc: EpcPropertyData, tariff: Tariff) -> float:
"""ADR-0014 Bill Derivation — the secondary-heating High-Rate Fraction on an
Off-Peak Meter. Non-electric or standard-tariff secondary 1.0. Electric
secondary heaters are portable/direct-acting (Table 4a room heaters), so they
take the Table 12a Grid 1 `OTHER_DIRECT_ACTING_ELECTRIC` row (1.0 at 7-hour,
0.50 at 10-hour) run on demand, mostly at the day/high rate. Tariffs Table
12a omits (18-/24-hour) fall back to 1.0 (high)."""
if tariff is Tariff.STANDARD:
return 1.0
if not is_electric_fuel_code(_secondary_fuel_code(epc)):
return 1.0
try:
return space_heating_high_rate_fraction(
Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, tariff
)
except NotImplementedError:
return 1.0
def _secondary_fraction(
main: Optional[MainHeatingDetail],
secondary_heating_type: object,
@ -3408,6 +3478,27 @@ def _other_fuel_cost_gbp_per_kwh(
return blended * _PENCE_TO_GBP
def _other_uses_high_rate_fraction(tariff: Tariff) -> float:
"""ADR-0014 Bill Derivation — the ALL_OTHER_USES High-Rate Fraction (the
day/high-rate share) for lighting / appliances / cooking / pumps on an
Off-Peak Meter, mirroring `_other_fuel_cost_gbp_per_kwh`'s tariff handling so
the bill's split matches the rating's: STANDARD 1.0 (single rate); 7-/10-
hour SAP 10.2 Table 12a Grid 2; 18-hour 1.0 (all other uses bill at the
high rate per SAP 10.2 Appendix F2); 24-hour 1.0 (a heating-only tariff
the Fuel Rates snapshot carries no separate non-heating rate, so other uses
bill at the off-peak day rate, a documented approximation for a rare tariff).
Pumps/fans reuse this fraction; the Table 12a Grid 2 MEV/MVHR `FANS_FOR_MECH_
VENT` distinction the SAP cost path applies is a small second-order effect on
a small load and is deferred for the bill."""
if tariff is Tariff.STANDARD:
return 1.0
try:
return other_use_high_rate_fraction(OtherUse.ALL_OTHER_USES, tariff)
except NotImplementedError:
return 1.0
def _pumps_fans_fuel_cost_gbp_per_kwh(
*,
tariff: Tariff,
@ -8154,6 +8245,39 @@ def cert_to_inputs(
eff,
)
# ADR-0014 Bill Derivation — Off-Peak Meter day/night billing metadata.
# The off-peak `_fuel_cost` path returns the zero `FuelCostResult` (deferring
# to the legacy scalar rates), so the bill's per-end-use High-Rate Fractions
# are sourced here from the SAME Table 12a helpers the scalar cost path uses,
# keeping the bill's day/night split identical to the rating's.
_billing_tariff = _rdsap_tariff(epc)
_is_off_peak_meter = _billing_tariff is not Tariff.STANDARD
_main_high_rate_fraction = _main_space_heating_high_rate_fraction(
main, _billing_tariff
)
_main_2_detail = (
epc.sap_heating.main_heating_details[1]
if epc.sap_heating and len(epc.sap_heating.main_heating_details) > 1
else None
)
_main_2_high_rate_fraction = _main_space_heating_high_rate_fraction(
_main_2_detail, _billing_tariff
)
_secondary_high_rate_frac = _secondary_high_rate_fraction(epc, _billing_tariff)
_hw_high_rate_fraction = _hot_water_high_rate_fraction(
_water_heating_fuel_code(epc),
_water_heating_main(epc),
_billing_tariff,
water_heating_code=(
epc.sap_heating.water_heating_code if epc.sap_heating else None
),
inherit_main_for_community_heating=_is_community_heating_hw_from_main(epc),
cylinder_volume_l=_hot_water_cylinder_volume_l(epc),
occupancy_n=wh_result.occupancy if wh_result is not None else None,
immersion_single=_immersion_is_single(epc),
)
_other_uses_fraction = _other_uses_high_rate_fraction(_billing_tariff)
return CalculatorInputs(
dimensions=dim,
heat_transmission=ht,
@ -8218,6 +8342,13 @@ def cert_to_inputs(
),
secondary_heating_fuel_code=_secondary_fuel_code(epc),
hot_water_fuel_code=_water_heating_fuel_code(epc),
is_off_peak_meter=_is_off_peak_meter,
main_heating_high_rate_fraction=_main_high_rate_fraction,
main_2_heating_high_rate_fraction=_main_2_high_rate_fraction,
secondary_heating_high_rate_fraction=_secondary_high_rate_frac,
hot_water_high_rate_fraction=_hw_high_rate_fraction,
pumps_fans_high_rate_fraction=_other_uses_fraction,
other_electricity_high_rate_fraction=_other_uses_fraction,
space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh(
main, _rdsap_tariff(epc), prices
),

View file

@ -5,7 +5,7 @@ from pathlib import Path
from typing import Any, Optional
from domain.fuel_rates.fuel import Fuel
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates, OffPeakRate
from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository
_DEFAULT_SNAPSHOT = Path(__file__).parent / "data" / "fuel_rates_2026_q2.json"
@ -14,10 +14,12 @@ _DEFAULT_SNAPSHOT = Path(__file__).parent / "data" / "fuel_rates_2026_q2.json"
class FuelRatesStaticFileRepository(FuelRatesRepository):
"""Reads Fuel Rates from a committed JSON snapshot (ADR-0014).
Only **single-rate** fuels (those lodging a ``unit_rate_p_per_kwh``) are
exposed. Off-peak (day/night) and the unpriced gaps (null entries house
coal, heat network) are skipped, so pricing them raises ``UnpricedFuel``.
The day/night accessor for off-peak lands in a later slice.
**Single-rate** fuels (those lodging a ``unit_rate_p_per_kwh``) populate the
``rates`` map. The **off-peak** entry a dual-rate meter lodging
``day_p_per_kwh`` / ``night_p_per_kwh`` loads as an ``OffPeakRate`` (each
end use blends day/night by its own High-Rate Fraction). The unpriced gaps
(null entries house coal, heat network) are skipped, so pricing them
raises ``UnpricedFuel``.
"""
def __init__(self, snapshot_path: Optional[Path] = None) -> None:
@ -27,11 +29,19 @@ class FuelRatesStaticFileRepository(FuelRatesRepository):
payload: dict[str, Any] = json.loads(self._snapshot_path.read_text())
fuels: dict[str, Any] = payload["fuels"]
rates: dict[Fuel, FuelRate] = {}
off_peak: Optional[OffPeakRate] = None
for name, entry in fuels.items():
if entry is None:
continue # an unpriced gap (house coal / heat network)
if name == Fuel.ELECTRICITY_OFF_PEAK.name:
off_peak = OffPeakRate(
day_p_per_kwh=float(entry["day_p_per_kwh"]),
night_p_per_kwh=float(entry["night_p_per_kwh"]),
standing_charge_p_per_day=float(entry["standing_charge_p_per_day"]),
)
continue
if "unit_rate_p_per_kwh" not in entry:
continue # off-peak day/night — priced in a later slice
continue # an unpriced gap with no single rate
rates[Fuel[name]] = FuelRate(
unit_rate_p_per_kwh=float(entry["unit_rate_p_per_kwh"]),
standing_charge_p_per_day=float(entry["standing_charge_p_per_day"]),
@ -40,4 +50,5 @@ class FuelRatesStaticFileRepository(FuelRatesRepository):
period=str(payload["period"]),
seg_export_p_per_kwh=float(payload["seg_export_p_per_kwh"]),
rates=rates,
off_peak=off_peak,
)

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import pytest
from domain.fuel_rates.fuel import Fuel, UnpricedFuel
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates, OffPeakRate
from domain.billing.bill import BillSection, EnergyBreakdown, EnergyLine
from domain.billing.bill_derivation import BillDerivation
@ -17,9 +17,33 @@ def _rates() -> FuelRates:
Fuel.ELECTRICITY: FuelRate(unit_rate_p_per_kwh=24.67, standing_charge_p_per_day=57.21),
Fuel.OIL: FuelRate(unit_rate_p_per_kwh=9.16, standing_charge_p_per_day=0.0),
},
off_peak=OffPeakRate(
day_p_per_kwh=29.73, night_p_per_kwh=13.89, standing_charge_p_per_day=56.99
),
)
def test_an_all_night_off_peak_heating_line_bills_at_the_night_rate() -> None:
# Arrange — 10,000 kWh of electric storage heating on an Off-Peak Meter,
# charged wholly overnight (high-rate fraction 0.0).
breakdown = EnergyBreakdown(
lines=[
EnergyLine(
section=BillSection.HEATING,
fuel=Fuel.ELECTRICITY_OFF_PEAK,
kwh=10000.0,
high_rate_fraction=0.0,
)
]
)
# Act
bill = BillDerivation(_rates()).derive(breakdown)
# Assert — every kWh at the night rate: 10000 × 13.89p = £1389.
assert bill.sections[BillSection.HEATING].cost_gbp == pytest.approx(1389.0)
def test_derive_prices_a_single_gas_heating_line_with_its_standing_charge() -> None:
# Arrange — 10,000 kWh of mains-gas heating.
breakdown = EnergyBreakdown(

View file

@ -23,6 +23,13 @@ def _sap_result(
appliances_kwh_per_yr: float = 0.0,
cooking_kwh_per_yr: float = 0.0,
pv_exported_kwh_per_yr: float = 0.0,
is_off_peak_meter: bool = False,
main_heating_high_rate_fraction: float = 1.0,
main_2_heating_high_rate_fraction: float = 1.0,
secondary_heating_high_rate_fraction: float = 1.0,
hot_water_high_rate_fraction: float = 1.0,
pumps_fans_high_rate_fraction: float = 1.0,
other_electricity_high_rate_fraction: float = 1.0,
) -> SapResult:
return SapResult(
sap_score=72,
@ -51,6 +58,13 @@ def _sap_result(
primary_energy_kwh_per_m2=0.0,
monthly=(),
intermediate={},
is_off_peak_meter=is_off_peak_meter,
main_heating_high_rate_fraction=main_heating_high_rate_fraction,
main_2_heating_high_rate_fraction=main_2_heating_high_rate_fraction,
secondary_heating_high_rate_fraction=secondary_heating_high_rate_fraction,
hot_water_high_rate_fraction=hot_water_high_rate_fraction,
pumps_fans_high_rate_fraction=pumps_fans_high_rate_fraction,
other_electricity_high_rate_fraction=other_electricity_high_rate_fraction,
)
@ -146,3 +160,74 @@ def test_positive_heating_kwh_with_no_fuel_code_raises() -> None:
# Act / Assert
with pytest.raises(ValueError, match="no fuel code"):
EnergyBreakdown.from_sap_result(result)
def test_off_peak_heating_line_carries_its_carrier_and_high_rate_fraction() -> None:
# Arrange — electric storage heating on an Off-Peak Meter (7-hour low-rate
# code 31), charged wholly overnight (main heating high-rate fraction 0.0).
result = _sap_result(
main_heating_fuel_kwh_per_yr=9000.0,
main_heating_fuel_code=31,
is_off_peak_meter=True,
main_heating_high_rate_fraction=0.0,
)
# Act
breakdown = EnergyBreakdown.from_sap_result(result)
# Assert — the heating line bills on the off-peak carrier with the
# calculator's high-rate fraction attached for the day/night split.
line = breakdown.lines[0]
assert (line.section, line.fuel, line.kwh, line.high_rate_fraction) == (
BillSection.HEATING,
Fuel.ELECTRICITY_OFF_PEAK,
9000.0,
0.0,
)
def test_off_peak_meter_routes_every_electric_use_to_the_off_peak_carrier() -> None:
# Arrange — a gas-heated dwelling that happens to be on an Off-Peak Meter:
# the gas heating stays gas, but ALL electric uses (lighting, appliances,
# pumps) bill on the off-peak carrier, the "other" uses sharing the
# ALL_OTHER_USES fraction and pumps/fans carrying their own.
result = _sap_result(
main_heating_fuel_kwh_per_yr=8000.0,
main_heating_fuel_code=1, # mains gas — single-rate, unaffected
lighting_kwh_per_yr=400.0,
appliances_kwh_per_yr=1900.0,
pumps_fans_kwh_per_yr=200.0,
is_off_peak_meter=True,
other_electricity_high_rate_fraction=0.90,
pumps_fans_high_rate_fraction=0.71,
)
# Act
breakdown = EnergyBreakdown.from_sap_result(result)
# Assert
by_section = {line.section: line for line in breakdown.lines}
assert by_section[BillSection.HEATING].fuel is Fuel.MAINS_GAS
assert by_section[BillSection.HEATING].high_rate_fraction is None
assert by_section[BillSection.LIGHTING].fuel is Fuel.ELECTRICITY_OFF_PEAK
assert by_section[BillSection.LIGHTING].high_rate_fraction == 0.90
assert by_section[BillSection.APPLIANCES].fuel is Fuel.ELECTRICITY_OFF_PEAK
assert by_section[BillSection.APPLIANCES].high_rate_fraction == 0.90
assert by_section[BillSection.PUMPS_FANS].high_rate_fraction == 0.71
def test_standard_meter_keeps_electric_uses_on_flat_rate_electricity() -> None:
# Arrange — electric heating on a standard meter: no day/night split anywhere.
result = _sap_result(
main_heating_fuel_kwh_per_yr=6000.0,
main_heating_fuel_code=30, # standard-tariff electricity
lighting_kwh_per_yr=400.0,
is_off_peak_meter=False,
)
# Act
breakdown = EnergyBreakdown.from_sap_result(result)
# Assert — every line is flat-rate ELECTRICITY with no high-rate fraction.
assert all(line.fuel is Fuel.ELECTRICITY for line in breakdown.lines)
assert all(line.high_rate_fraction is None for line in breakdown.lines)

View file

@ -0,0 +1,77 @@
"""End-to-end regression for the Off-Peak Meter day/night bill split (ADR-0014
2026-06-24 amendment).
Drives a real off-peak (Economy-7) storage-heating cert through the whole chain
`cert_to_inputs` `calculate_sap_from_inputs` `EnergyBreakdown.from_sap_result`
`BillDerivation.derive` against the committed Fuel Rates snapshot. Before the
amendment this raised `UnpricedFuel` ("no rate for fuel ELECTRICITY_OFF_PEAK"),
which aborted the `modelling_e2e` batch (e.g. property 717572). It must now price
the dwelling day/night instead of crashing.
"""
from __future__ import annotations
from dataclasses import replace
from domain.sap10_ml.tests._fixtures import (
make_building_part,
make_minimal_sap10_epc,
make_sap_heating,
)
from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs
from domain.fuel_rates.fuel import Fuel
from domain.billing.bill import BillSection, EnergyBreakdown
from domain.billing.bill_derivation import BillDerivation
from repositories.fuel_rates.fuel_rates_static_file_repository import (
FuelRatesStaticFileRepository,
)
_TYPICAL_TFA_M2 = 60.0
def _off_peak_storage_epc() -> EpcPropertyData:
# Integrated storage heaters (SAP code 408, Table 12a Grid 1 high-rate
# fraction 0.20) on a Dual / Economy-7 (7-hour) meter.
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2401,
main_heating_category=7, # electric storage heaters
sap_main_heating_code=408,
)
base = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[make_building_part(construction_age_band="E")],
sap_heating=make_sap_heating(main_heating_details=[main]),
)
return replace(
base,
sap_energy_source=replace(base.sap_energy_source, meter_type="1"),
)
def test_off_peak_storage_dwelling_bills_heating_day_night_not_crashes() -> None:
# Arrange — score the off-peak cert and price it at the committed snapshot.
result = calculate_sap_from_inputs(cert_to_inputs(_off_peak_storage_epc()))
rates = FuelRatesStaticFileRepository().get_current()
# Act — the previously-crashing path: off-peak electricity reaches the bill.
breakdown = EnergyBreakdown.from_sap_result(result)
bill = BillDerivation(rates).derive(breakdown)
# Assert — heating bills on the off-peak carrier at the 0.20-day/0.80-night
# blend (0.20×29.73 + 0.80×13.89 = 17.058 p/kWh), not a crash and not the
# flat standard rate (24.67 p) or pure night rate (13.89 p).
heating = bill.sections[BillSection.HEATING]
assert heating.kwh > 0.0
implied_rate_p = heating.cost_gbp / heating.kwh * 100.0
assert abs(implied_rate_p - 17.058) <= 1e-6
# The off-peak meter contributes one standing charge (56.99 p/day).
assert any(line.fuel is Fuel.ELECTRICITY_OFF_PEAK for line in breakdown.lines)
assert bill.total_gbp > 0.0

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import pytest
from domain.fuel_rates.fuel import Fuel, UnpricedFuel
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates, OffPeakRate
def _rates() -> FuelRates:
@ -14,6 +14,62 @@ def _rates() -> FuelRates:
)
def _off_peak_rates() -> FuelRates:
return FuelRates(
period="test",
seg_export_p_per_kwh=15.0,
rates={},
off_peak=OffPeakRate(
day_p_per_kwh=29.73, night_p_per_kwh=13.89, standing_charge_p_per_day=56.99
),
)
def test_off_peak_blended_rate_at_zero_high_fraction_is_the_night_rate() -> None:
# Arrange — an all-night load (storage heating, high-rate fraction 0.0).
rates = _off_peak_rates()
# Act
blended = rates.off_peak_blended_p_per_kwh(high_rate_fraction=0.0)
# Assert — every kWh bills at the cheap night rate.
assert blended == 13.89
def test_off_peak_blended_rate_at_full_high_fraction_is_the_day_rate() -> None:
# Arrange — an all-day load (high-rate fraction 1.0).
rates = _off_peak_rates()
# Act / Assert — every kWh bills at the dearer day rate.
assert rates.off_peak_blended_p_per_kwh(high_rate_fraction=1.0) == 29.73
def test_off_peak_blended_rate_blends_day_and_night_linearly() -> None:
# Arrange — 25% of the load at the day rate, 75% at night.
rates = _off_peak_rates()
# Act / Assert — 0.25 * 29.73 + 0.75 * 13.89 = 17.85
assert rates.off_peak_blended_p_per_kwh(high_rate_fraction=0.25) == pytest.approx(17.85)
def test_off_peak_blend_without_a_snapshot_off_peak_entry_raises_unpriced_fuel() -> None:
# Arrange — a snapshot that carries no off-peak entry at all.
rates = _rates()
# Act / Assert
with pytest.raises(UnpricedFuel) as excinfo:
rates.off_peak_blended_p_per_kwh(high_rate_fraction=0.5)
assert excinfo.value.fuel is Fuel.ELECTRICITY_OFF_PEAK
def test_off_peak_meter_standing_charge_reads_back() -> None:
# Arrange — the bill adds the off-peak meter's standing charge once per meter.
rates = _off_peak_rates()
# Act / Assert
assert rates.standing_charge_p_per_day(Fuel.ELECTRICITY_OFF_PEAK) == 56.99
def test_unit_rate_and_standing_charge_read_back_for_a_priced_fuel() -> None:
# Arrange
rates = _rates()

View file

@ -836,6 +836,46 @@ def test_integrated_storage_heater_408_bills_table_12a_grid1_high_rate_fraction(
assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.07458) <= 1e-9
def test_off_peak_storage_cert_surfaces_billing_meter_flag_and_main_fraction() -> None:
# Arrange — the same integrated-storage (code 408) Economy-7 cert. For
# ADR-0014 Bill Derivation, cert_to_inputs surfaces the Off-Peak Meter flag
# and the main-heating High-Rate Fraction — the SAME Table 12a Grid 1 value
# (0.20) the SAP cost path blends above, so the bill never re-derives it.
from dataclasses import replace
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2401,
main_heating_category=7, # electric storage heaters
sap_main_heating_code=408,
)
base = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[make_building_part(construction_age_band="E")],
sap_heating=make_sap_heating(main_heating_details=[main]),
)
epc = replace(
base,
sap_energy_source=replace(base.sap_energy_source, meter_type="1"), # Dual
)
# Act
inputs = cert_to_inputs(epc)
# Assert — whole-meter off-peak, main heating high-rate fraction 0.20.
assert inputs.is_off_peak_meter is True
assert inputs.main_heating_high_rate_fraction == 0.20
# Lighting / appliances / cooking + pumps bill at the 7-hour Table 12a
# Grid 2 ALL_OTHER_USES high-rate fraction (0.90), mostly at the day rate.
assert inputs.other_electricity_high_rate_fraction == 0.90
assert inputs.pumps_fans_high_rate_fraction == 0.90
def test_off_peak_immersion_unlodged_type_defaults_to_dual_table13_blend() -> None:
# Arrange — electric immersion DHW (WHC 903) on a Dual / Economy-7 (7-hour)
# meter with a cylinder present (Normal / 110 L), but the cert does NOT lodge
@ -883,6 +923,14 @@ def test_off_peak_immersion_unlodged_type_defaults_to_dual_table13_blend() -> No
rate = inputs.hot_water_fuel_cost_gbp_per_kwh
assert rate > 0.0550 + 1e-6 # NOT the 100%-low-rate bug value (5.50 p/kWh)
assert rate < 0.0900 # a small DUAL high-rate fraction, not single (~11 p)
# ADR-0014 Bill Derivation surfaces the SAME Table 13 fraction it bills the
# HW cost at: a small day-rate share (0 < frac < 0.3), not 0.0 (all-night
# fallback) nor 1.0 (single rate). Mirrors the cost-rate split exactly.
assert 0.0 < inputs.hot_water_high_rate_fraction < 0.3
# Back out the fraction the cost rate used and confirm the surfaced one
# matches it: rate_p = frac×15.29 + (1frac)×5.50.
_frac_from_cost = (rate * 100.0 - 5.50) / (15.29 - 5.50)
assert inputs.hot_water_high_rate_fraction == pytest.approx(_frac_from_cost)
def test_non_integrated_storage_heater_bills_100_percent_low_rate() -> None:

View file

@ -165,6 +165,37 @@ def test_fuel_codes_and_pv_export_thread_unchanged_onto_sap_result() -> None:
assert abs(result.pv_exported_kwh_per_yr - 850.0) <= 1e-9
def test_off_peak_meter_flag_and_high_rate_fractions_thread_onto_sap_result() -> None:
"""The Off-Peak Meter flag + per-end-use High-Rate Fractions surface on
SapResult unchanged (ADR-0014). Output-only metadata for Bill Derivation's
day/night split they must thread byte-identical from CalculatorInputs
through `calculate_sap_from_inputs` and NOT perturb the SAP cost/score."""
# Arrange — an off-peak dwelling: storage heating all-night (0.0), HW immersion
# all-night (0.0), other uses 0.90 / pumps 0.71 (7-hour Grid-2 fractions).
inputs = replace(
_baseline_inputs(),
is_off_peak_meter=True,
main_heating_high_rate_fraction=0.0,
main_2_heating_high_rate_fraction=0.5,
secondary_heating_high_rate_fraction=1.0,
hot_water_high_rate_fraction=0.0,
pumps_fans_high_rate_fraction=0.71,
other_electricity_high_rate_fraction=0.90,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert — threaded unchanged.
assert result.is_off_peak_meter is True
assert result.main_heating_high_rate_fraction == 0.0
assert result.main_2_heating_high_rate_fraction == 0.5
assert result.secondary_heating_high_rate_fraction == 1.0
assert result.hot_water_high_rate_fraction == 0.0
assert result.pumps_fans_high_rate_fraction == 0.71
assert result.other_electricity_high_rate_fraction == 0.90
def test_pv_export_collapses_none_input_to_zero_on_sap_result() -> None:
"""`pv_exported_kwh_per_yr` is 0.0 (not None) on SapResult for no-PV.

View file

@ -55,8 +55,21 @@ def test_dual_fuel_carries_a_derived_midpoint_rate() -> None:
assert rates.standing_charge_p_per_day(Fuel.DUAL_FUEL_MINERAL_AND_WOOD) == 0.0
def test_off_peak_remains_unpriced_pending_the_day_night_accessor() -> None:
# Arrange — off-peak still needs the day/night split a later slice adds (ADR-0014).
def test_off_peak_meter_prices_day_night_from_the_snapshot() -> None:
# Arrange — the committed snapshot's off-peak entry carries a day/night
# split (ADR-0014 2026-06-24 amendment); the repo loads it as an OffPeakRate.
rates = FuelRatesStaticFileRepository().get_current()
# Act / Assert — an all-night load bills at the night rate, an all-day load
# at the day rate, and the off-peak meter carries its own standing charge.
assert rates.off_peak_blended_p_per_kwh(high_rate_fraction=0.0) == 13.89
assert rates.off_peak_blended_p_per_kwh(high_rate_fraction=1.0) == 29.73
assert rates.standing_charge_p_per_day(Fuel.ELECTRICITY_OFF_PEAK) == 56.99
def test_off_peak_has_no_single_unit_rate() -> None:
# Arrange — off-peak has no single blended unit rate; callers must price it
# day/night via a High-Rate Fraction, so unit_rate_p_per_kwh still raises.
rates = FuelRatesStaticFileRepository().get_current()
# Act / Assert