mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
commit
77133f4b42
15 changed files with 699 additions and 30 deletions
10
CONTEXT.md
10
CONTEXT.md
|
|
@ -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.8–0.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
|
||||
|
|
|
|||
|
|
@ -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 + (1−frac) × 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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
77
tests/domain/billing/test_off_peak_bill_integration.py
Normal file
77
tests/domain/billing/test_off_peak_bill_integration.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 + (1−frac)×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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue