Slice 3 of Bill Derivation. sap_code_to_fuel(code) maps a SAP 10.2 / Table 32 fuel code to the canonical billing Fuel — bounded to the ~47 Table 32 codes (the carrier, orthogonal to the PCDB product index, so all PCDB heat pumps share one electricity code). Mains gas / LPG / oil+bioliquids / coal / smokeless / wood / electricity (standard + off-peak) / heat-network groupings; an unmapped code (dual fuel, grid-export) raises UnmappedSapCode rather than guessing. Also: ADR-0014 deferred/TODO section records the stubbed appliances+cooking (pending the SapResult fields), the off-peak day/night split, the heat-network rate gap, and regional rates / ETL. The SapResult -> EnergyBreakdown adapter (next slice) is gated on the appliances/cooking fields landing on SapResult. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
| Status |
|---|
| accepted |
Bill Derivation: whole-home annual bill from the calculator's delivered kWh × real Fuel Rates (not SAP prices)
Lifts the bills/fuel-split deferral in ADR-0004
and its migration note, and builds on ADR-0013
(the calculator is load-bearing). Decided in a /grill-with-docs session (2026-06-02).
Context
ADR-0004's amendment deferred fuel split + bills "because bills require a current Fuel Rates
source (Ofgem-cap ETL) that does not yet exist." A static snapshot lifts that blocker. The old
backend/ml_models/AnnualBillSavings.py is the fragile reference (a blended PRICE_FACTOR, two
disagreeing rate sources, a standing-charge precedence bug, a 10× unit slip) — we rewrite, not port.
Decisions
1. The bill is whole-home, composed per end use, from the calculator's delivered kWh
SAP10 Calculation already emits delivered (post-efficiency, billable) kWh for every regulated end
use — main/secondary heating, hot water, pumps/fans, lighting, cooling — and computes appliances +
cooking electricity internally (Appendix L L13-L20). BillDerivation consumes that per-end-use
breakdown and produces per-section costs + a total. The EPC lodges no per-end-use kWh, so the
calculator is the only source — which is why it is load-bearing for bills regardless of
sap_version (a raise aborts the batch, ADR-0013).
2. Bills use real Fuel Rates, not the calculator's total_fuel_cost_gbp
The calculator's fuel cost is the SAP-rating notional cost at RdSAP Table 32 standardised
prices — deliberately frozen for rating comparability, and ~half the real electricity price
(Table 32 elec ~13 p/kWh vs Ofgem Apr–Jun 2026 cap ~24.7 p/kWh). Billing on it would roughly halve
an electric/heat-pump home's bill. So BillDerivation re-prices the delivered kWh at current
Fuel Rates, and the calculator's total_fuel_cost_gbp is used only for the SAP rating.
3. Fuel Rates = committed static snapshot, read via FuelRatesRepository
A national snapshot (Ofgem-cap period for gas/electricity, DESNZ/NEP for off-gas fuels), keyed by a
canonical Fuel enum (MAINS_GAS, ELECTRICITY, ELECTRICITY_OFF_PEAK, OIL, LPG, SMOKELESS, WOOD_LOGS, WOOD_PELLETS, HEAT_NETWORK), each entry carrying unit_rate_p_per_kwh +
standing_charge_p_per_day, plus a top-level seg_export_p_per_kwh. The calculator's per-end-use
SAP fuel codes map to this enum via the existing is_gas_code / is_electric_fuel_code /
is_liquid_fuel_code helpers — so the snapshot and the calculator meet at one vocabulary, not raw
SAP codes. Read through a FuelRatesRepository port (ADR-0011: a Repo reads stored reference data
by key); an Ofgem-cap ETL automating the refresh is future, behind the same port — not a
prerequisite. National now; the 14 cap regions are a later refinement behind the same port.
4. Bill arithmetic
Total = Σ (per-end-use delivered kWh × that end use's fuel unit rate) + per-meter standing charges (metered fuels only — gas/electricity; oil/LPG/solid have none) − SEG export credit on PV. Off-peak electricity splits day/night via the calculator's existing Table 12a high/low-rate fractions.
5. Strict-raise on an unpriced fuel
BillDerivation raises on a fuel it has no rate for — same discipline as the calculator. Two
named gaps surface immediately rather than billing at a wrong default:
- House coal — no standard domestic price (its domestic sale is illegal in England).
- Communal / heat network — scheme-specific, no national tariff. The one common case (flats); a heat-network rate model is a named follow-up.
6. Persistence: flat per-section columns on property_baseline_performance
The energy block lands as flat typed columns on the existing row (ADR-0004's flat-column rule
holds — the SAP end-uses are a fixed enumerable set, so there is no column explosion and no
variable-shape JSON): per-section *_kwh + *_cost_gbp (heating, hot water, lighting, appliances,
cooking, pumps/fans), standing_charges_gbp, seg_credit_gbp, and total_annual_bill_gbp. The
production migration is FE-owned (Drizzle); docs/migrations/ updated.
Consequences
BillDerivationis named for the operation, no "Service" suffix (user preference).- A
Fuelenum + a SAP-code→Fuelmapping become first-class;FuelRates+FuelRatesRepository- a committed snapshot file are new.
- Carbon emissions are unaffected (they stay on Lodged/Effective Performance from the calculator's CO2 factors); this ADR is about £ bills only.
- The snapshot goes stale on the Ofgem-cap cadence (quarterly); the file records its period, and the ETL that automates refresh is the deferred follow-up.
Deferred / TODO
- Appliances + cooking kWh are computed inside
cert_to_inputs(Appendix L L13-L20) but not yet threaded ontoSapResult. Until they are, theSapResult→EnergyBreakdownadapter stubs them at 0 kWh, so the bill total currently understates by the unregulated electricity load. Khalim is adding the fields toSapResultdirectly; the adapter wires theAPPLIANCES/COOKINGsections in as soon as they land. - Off-peak (Economy 7) day/night split — the snapshot carries the E7 day/night rates, but
FuelRatesexposes single-rate fuels only; the day/night accessor + the calculator's Table 12a high/low-rate split land in a later slice. - Heat-network rate model — heat-network certs raise
UnpricedFuelfor now (the one common gap). - Regional rates + Ofgem-cap ETL — national snapshot now; both are later refinements behind the
same
FuelRatesRepositoryport.
Considered alternatives
- Bill from
RenewableHeatIncentiveheating+HW kWh only (CONTEXT's original scope) — rejected: the user wants the whole-home bill, and heating+HW omits lighting/appliances/cooking, which only the calculator supplies. - Bill at SAP Table 32 prices — rejected: standardised rating prices, ~half real electricity.
- JSON
bill_breakdownblock — rejected: end-uses are fixed-cardinality, so flat columns are clean and stay queryable (ADR-0004).
Amendment (2026-06-02): fuel is a calculator output; §3's mapping helpers corrected
Wiring the SapResult → EnergyBreakdown adapter forced the question §3 left implicit: where does
the fuel each end use burns come from? Resolved in a /grill-with-docs session.
-
Decision: per-end-use fuel is calculator output. The calculator resolves the fuel for each billable end use (it already uses it to derive the delivered kWh and the rating cost), so it emits the per-end-use fuel codes on
SapResult(main-1 / main-2 / secondary / hot water — the electric end uses are electricity by construction), alongsidepv_exported_kwhfor the SEG credit. These are the calculator's own fuel codes (which, per ADR-0015, may be raw API codes or already-Table-32 depending on the mapper), sosap_fuel.sap_code_to_fuelnormalizes them through the calculator's owntable_32.to_table_32_code(T32-first, then API-translate — the same normalization the calculator's pricing/classification uses) before the Table-32 →Fueldispatch.BillDerivation's adapter is then a pureSapResult → EnergyBreakdownmap and can never price the calculator's kWh at a fuel the calculator never used. Rejected: an adapter that re-reads rawEpcPropertyDatafuel fields and re-normalizes them — that duplicatescert_to_inputs(_main_fuel_code,_water_heating_fuel_code, HW→main default, CHP blend, theMissingMainFuelTypestrict-raise) and reopens divergence between the bill and the rating. -
§3 correction. §3 says the per-end-use fuel codes map to
Fuel"via the existingis_gas_code/is_electric_fuel_code/is_liquid_fuel_codehelpers." That is not what shipped: mapping isdomain/property_baseline/sap_fuel.py::sap_code_to_fuel, a bounded Table-32 fuel-code →Fueldispatch that strict-raisesUnmappedSapCodeon an unmapped code. The "meet at one vocabulary, not raw SAP codes" intent stands; the named helpers do not. -
Interim, pending ADR-0015. Fuel resolution sits in the calculator because
EpcPropertyDatais not yet a strict normalized type. Once ADR-0015 lands (mappers normalize at the boundary), attribution can move upstream and theSapResultfuel-code fields may be retired. -
COOLINGsection added. §1 listed cooling as an end use but §6's flat columns omitted it.BillSectiongainsCOOLING(kWh fromSapResult.space_cooling_fuel_kwh_per_yr, electricity by construction), so §6's layout gains acooling_kwh+cooling_cost_gbpcolumn pair (FE-owned Drizzle migration).
Amendment (2026-06-03): Bill Derivation is cross-stage; the Modelling stage prices the post-package end-state
Bill Derivation is no longer Baseline-only — the Modelling stage now re-runs it on the Optimised Package to produce post-retrofit bills and savings. Decided in a /grill-with-docs session.
-
Bill Derivation is a cross-stage domain concern → relocate to
domain/billing/.Bill/EnergyBreakdown/BillDerivation/sap_fuelwere underdomain/property_baseline/only because Baseline was built first. Two stages now consume them, and amodelling → property_baselineimport would couple two stages ADR-0011 keeps independent under a name that wrongly implies ownership. They move to a neutraldomain/billing/(Fuel/FuelRatesalready live in the shareddomain/fuel_rates/). Mechanical move + import rewrite; covered by the existing Baseline tests. -
Modelling bills the simulated end-state, never adjusts the baseline bill. The post-retrofit bill is
BillDerivation.derive(EnergyBreakdown.from_sap_result(post_package_sap_result)), where theSapResultcomes from scoring the fully-overlaidEpcPropertyData(all selected Simulation Overlays + injected dependencies). This is what makes fuel-switch measures correct for free: a measure that switches heating fuel (e.g. oil → electric ASHP) changes the heating fuel code on thatSapResult, sosap_code_to_fuelprices it at the new fuel automatically — no per-measure fuel bookkeeping. Savings arebaseline − post, both priced at the sameFuelRatessnapshot (read once per run), so the delta is never polluted by a rate change. -
No second calculator pass. The post-package
SapResultis the one the optimiser's whole-package re-score (role 2) already computed; it rides on theScore(Score.sap_result, populated byPackageScorer, ignored by the optimiser — so the optimiser staysScore-only and its stub-scorer tests are unaffected). Likewise the baselineSapResultis the one the orchestrator already scores for the role-3 cascade and the target gain. Billing reuses both — zero extracalculate. -
FuelRatesRepositoryis constructor-injected intoModellingOrchestrator, mirroring the Baseline orchestrator —get_current()once perrun(), oneBillDerivationreused across the batch. Not on theUnitOfWork(read-once reference data, ADR-0011). The extra per-pipeline read (Baseline + Modelling each resolve rates) is accepted; a shared/injected snapshot is a future optimisation. -
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-measurerecommendation.kwh_savings/energy_cost_savingscome 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-prefixsap_results, no extra calls). Per-measure savings can be negative (ventilation increases energy) and still telescope. The legacyrecommendation.energy_savingscolumn is vestigial (legacy set it to0; the canonical delivered-energy field iskwh_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.