diff --git a/CONTEXT.md b/CONTEXT.md index f92c5b53..e21d4501 100644 --- a/CONTEXT.md +++ b/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 diff --git a/docs/adr/0014-bill-derivation-from-real-fuel-rates.md b/docs/adr/0014-bill-derivation-from-real-fuel-rates.md index 0d195f77..759086bc 100644 --- a/docs/adr/0014-bill-derivation-from-real-fuel-rates.md +++ b/docs/adr/0014-bill-derivation-from-real-fuel-rates.md @@ -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. diff --git a/domain/billing/bill.py b/domain/billing/bill.py index 5aff24cf..a524e145 100644 --- a/domain/billing/bill.py +++ b/domain/billing/bill.py @@ -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) diff --git a/domain/billing/bill_derivation.py b/domain/billing/bill_derivation.py index c1a09c64..a73b7d4e 100644 --- a/domain/billing/bill_derivation.py +++ b/domain/billing/bill_derivation.py @@ -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) diff --git a/domain/fuel_rates/fuel_rates.py b/domain/fuel_rates/fuel_rates.py index a5b2eb73..2266b75a 100644 --- a/domain/fuel_rates/fuel_rates.py +++ b/domain/fuel_rates/fuel_rates.py @@ -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: diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 74b038e0..c30234a7 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -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, ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 952b0a77..469223ab 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 ), diff --git a/repositories/fuel_rates/fuel_rates_static_file_repository.py b/repositories/fuel_rates/fuel_rates_static_file_repository.py index 1f53617d..e8057b39 100644 --- a/repositories/fuel_rates/fuel_rates_static_file_repository.py +++ b/repositories/fuel_rates/fuel_rates_static_file_repository.py @@ -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, ) diff --git a/tests/domain/billing/test_bill_derivation.py b/tests/domain/billing/test_bill_derivation.py index cce045ee..14efea71 100644 --- a/tests/domain/billing/test_bill_derivation.py +++ b/tests/domain/billing/test_bill_derivation.py @@ -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( diff --git a/tests/domain/billing/test_energy_breakdown.py b/tests/domain/billing/test_energy_breakdown.py index 4c64da29..f754bc66 100644 --- a/tests/domain/billing/test_energy_breakdown.py +++ b/tests/domain/billing/test_energy_breakdown.py @@ -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) diff --git a/tests/domain/billing/test_off_peak_bill_integration.py b/tests/domain/billing/test_off_peak_bill_integration.py new file mode 100644 index 00000000..69d06148 --- /dev/null +++ b/tests/domain/billing/test_off_peak_bill_integration.py @@ -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 diff --git a/tests/domain/fuel_rates/test_fuel_rates.py b/tests/domain/fuel_rates/test_fuel_rates.py index a7319274..55472aeb 100644 --- a/tests/domain/fuel_rates/test_fuel_rates.py +++ b/tests/domain/fuel_rates/test_fuel_rates.py @@ -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() diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index b269c448..c6e53595 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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: diff --git a/tests/domain/sap10_calculator/test_calculator.py b/tests/domain/sap10_calculator/test_calculator.py index 5444a140..7ab7d3c0 100644 --- a/tests/domain/sap10_calculator/test_calculator.py +++ b/tests/domain/sap10_calculator/test_calculator.py @@ -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. diff --git a/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py b/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py index 1bce0362..fd5f038f 100644 --- a/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py +++ b/tests/repositories/fuel_rates/test_fuel_rates_static_file_repository.py @@ -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