feat(baseline): wire BillDerivation into the orchestrator and persist the Bill (ADR-0014)

The PropertyBaselineOrchestrator now reads the current Fuel Rates snapshot
once per batch, builds a BillDerivation, and prices each scored property's
SapResult -> EnergyBreakdown into a Bill carried on PropertyBaselinePerformance
(None only on the stub no-calculator path). The Bill is flattened onto nullable
bill_* flat columns (per-section kwh+cost, standing charges, SEG credit, total)
on the postgres table, with bill_total_annual_bill_gbp as the not-null
discriminator on read-back. Section absent from the bill stays None, not 0.

Updated all four orchestrator construction sites to inject the FuelRatesRepository
port (handler + three test sites), and the FE migration doc to reflect the
prefixed columns and that they are now populated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 18:51:18 +00:00
parent f7dc9dbccb
commit f179950519
8 changed files with 307 additions and 27 deletions

View file

@ -23,6 +23,9 @@ from orchestration.ingestion_orchestrator import (
)
from orchestration.modelling_orchestrator import ModellingOrchestrator
from orchestration.task_orchestrator import TaskOrchestrator
from repositories.fuel_rates.fuel_rates_static_file_repository import (
FuelRatesStaticFileRepository,
)
from repositories.geospatial.geospatial_repository import GeospatialRepository
from repositories.materials.materials_repository import MaterialsRepository
from repositories.postgres_unit_of_work import PostgresUnitOfWork
@ -85,6 +88,7 @@ def build_first_run_pipeline(
# certs, lodged + divergence-logged at/above 10.2; a raise aborts the
# batch (ADR-0013 amendment).
rebaseliner=CalculatorRebaseliner(Sap10Calculator()),
fuel_rates=FuelRatesStaticFileRepository(),
),
modelling=ModellingOrchestrator(
scenario_repo=ScenarioRepository(),

View file

@ -37,26 +37,32 @@ Produced by **Bill Derivation**: the calculator's **delivered** kWh per end use
Per-section kWh is *delivered fuel* (demand ÷ efficiency — what the household pays for), distinct
from the recorded-demand `space_heating_kwh`/`water_heating_kwh` above which it supersedes.
All columns below are **nullable** (every one is `Optional[float]`, default `None`) and **FE-owned
(Drizzle)**. The `bill_` prefix is deliberate: it keeps the per-section columns from clashing with
the recorded-demand `space_heating_kwh` / `water_heating_kwh` above. The whole block is `None` for
one row together when no calculator ran (the stub path produced no `SapResult` to price); a section
absent from the bill leaves its two columns `None` (not `0` — it was not billed). `to_domain` uses
`bill_total_annual_bill_gbp IS NOT NULL` as the discriminator for "a bill was persisted".
| Column | Type | Notes |
|---|---|---|
| `fuel_rates_period` | text | which Fuel Rates snapshot priced this bill (e.g. `"2026-04 to 2026-06"`) — provenance |
| `heating_kwh` | float | delivered fuel kWh (main + secondary heating) |
| `heating_cost_gbp` | float | priced at the heating fuel's current rate |
| `hot_water_kwh` | float | |
| `hot_water_cost_gbp` | float | |
| `lighting_kwh` | float | |
| `lighting_cost_gbp` | float | |
| `appliances_kwh` | float | unregulated load — **0 until the appliances/cooking fields land on `SapResult`** (ADR-0014 TODO) |
| `appliances_cost_gbp` | float | |
| `cooking_kwh` | float | unregulated load — 0 until `SapResult` carries it |
| `cooking_cost_gbp` | float | |
| `pumps_fans_kwh` | float | |
| `pumps_fans_cost_gbp` | float | |
| `cooling_kwh` | float | mostly 0 in UK homes; carried for completeness as it affects the bill |
| `cooling_cost_gbp` | float | |
| `standing_charges_gbp` | float | daily standing charge × 365, once per distinct metered fuel (off-gas fuels have none) |
| `seg_credit_gbp` | float | SEG export credit on PV (subtracted) |
| `total_annual_bill_gbp` | float | Σ section costs + standing charges SEG |
| `bill_heating_kwh` | float, nullable | delivered fuel kWh (main + main-2 + secondary heating) |
| `bill_heating_cost_gbp` | float, nullable | priced at the heating fuel's current rate |
| `bill_hot_water_kwh` | float, nullable | |
| `bill_hot_water_cost_gbp` | float, nullable | |
| `bill_lighting_kwh` | float, nullable | |
| `bill_lighting_cost_gbp` | float, nullable | |
| `bill_appliances_kwh` | float, nullable | unregulated load — `None` until the appliances field lands on `SapResult` |
| `bill_appliances_cost_gbp` | float, nullable | |
| `bill_cooking_kwh` | float, nullable | unregulated load — `None` until `SapResult` carries it |
| `bill_cooking_cost_gbp` | float, nullable | |
| `bill_pumps_fans_kwh` | float, nullable | |
| `bill_pumps_fans_cost_gbp` | float, nullable | |
| `bill_cooling_kwh` | float, nullable | mostly absent in UK homes; carried for completeness as it affects the bill |
| `bill_cooling_cost_gbp` | float, nullable | |
| `bill_standing_charges_gbp` | float, nullable | daily standing charge × 365, once per distinct metered fuel (off-gas fuels have none) |
| `bill_seg_credit_gbp` | float, nullable | SEG export credit on PV (subtracted) |
| `bill_total_annual_bill_gbp` | float, nullable | Σ section costs + standing charges SEG; the not-null discriminator for a persisted bill |
The calculator is **load-bearing** (ADR-0013 amendment): for `sap_version < 10.2` the `effective_*`
columns hold the calculator's output (so `effective_* != lodged_*` legitimately); at/above 10.2 they
@ -65,7 +71,8 @@ batch rather than persisting a wrong row.
### Population timing
The bill columns are **defined now so the FE can create them**, but are populated only once the
`SapResult``EnergyBreakdown` adapter + `BillDerivation` wiring land (gated on the appliances /
cooking `SapResult` fields). Until then the SQLModel mirror in `infrastructure/postgres/` adds these
columns as nullable; the Drizzle migration can create them nullable in parallel.
The bill columns are now **populated**: the `PropertyBaselineOrchestrator` reads the current Fuel
Rates snapshot, builds a `BillDerivation`, and prices every scored property's `SapResult`
`EnergyBreakdown` into a `Bill` that `from_domain` flattens onto these columns. They stay `None`
together only on the stub (no-calculator) path. The appliances / cooking sections remain `None`
until those fields land on `SapResult`. The Drizzle migration creates all `bill_*` columns nullable.

View file

@ -1,7 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from domain.property_baseline.bill import Bill
from domain.property_baseline.performance import Performance
from domain.property_baseline.rebaseliner import RebaselineReason
@ -17,8 +19,10 @@ class PropertyBaselinePerformance:
Carries the part of the energy block that needs no derivation: annual
``space_heating_kwh`` / ``water_heating_kwh`` read off the EPC's RHI.
Fuel split and bills (the rest of EPC Energy Derivation) land in a
follow-up once a Fuel Rates source exists.
Carries the derived ``bill`` (ADR-0014): the calculator's delivered kWh per
end use priced at current Fuel Rates. It is ``None`` only when no calculator
ran (the stub path produced no ``SapResult`` to price).
"""
lodged: Performance
@ -26,3 +30,4 @@ class PropertyBaselinePerformance:
rebaseline_reason: RebaselineReason
space_heating_kwh: float
water_heating_kwh: float
bill: Optional[Bill] = None

View file

@ -5,10 +5,22 @@ from typing import ClassVar, Optional, cast
from sqlmodel import Field, SQLModel
from datatypes.epc.domain.epc import Epc
from domain.property_baseline.bill import Bill, BillSection, BillSectionCost
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
from domain.property_baseline.performance import Performance
from domain.property_baseline.rebaseliner import RebaselineReason
# Each Bill section's flat-column stem (``bill_{stem}_kwh`` / ``bill_{stem}_cost_gbp``).
_SECTION_COLUMN_STEM: dict[BillSection, str] = {
BillSection.HEATING: "heating",
BillSection.HOT_WATER: "hot_water",
BillSection.LIGHTING: "lighting",
BillSection.APPLIANCES: "appliances",
BillSection.COOKING: "cooking",
BillSection.PUMPS_FANS: "pumps_fans",
BillSection.COOLING: "cooling",
}
class PropertyBaselinePerformanceModel(SQLModel, table=True):
"""The ``property_baseline_performance`` row — one per Property (ADR-0004).
@ -38,11 +50,32 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True):
space_heating_kwh: float
water_heating_kwh: float
# Bill Derivation block (ADR-0014 §6). Nullable: all None when no calculator
# ran (stub path). The ``bill_`` prefix avoids clashing with the
# recorded-demand ``space_heating_kwh`` / ``water_heating_kwh`` above.
bill_heating_kwh: Optional[float] = Field(default=None)
bill_heating_cost_gbp: Optional[float] = Field(default=None)
bill_hot_water_kwh: Optional[float] = Field(default=None)
bill_hot_water_cost_gbp: Optional[float] = Field(default=None)
bill_lighting_kwh: Optional[float] = Field(default=None)
bill_lighting_cost_gbp: Optional[float] = Field(default=None)
bill_appliances_kwh: Optional[float] = Field(default=None)
bill_appliances_cost_gbp: Optional[float] = Field(default=None)
bill_cooking_kwh: Optional[float] = Field(default=None)
bill_cooking_cost_gbp: Optional[float] = Field(default=None)
bill_pumps_fans_kwh: Optional[float] = Field(default=None)
bill_pumps_fans_cost_gbp: Optional[float] = Field(default=None)
bill_cooling_kwh: Optional[float] = Field(default=None)
bill_cooling_cost_gbp: Optional[float] = Field(default=None)
bill_standing_charges_gbp: Optional[float] = Field(default=None)
bill_seg_credit_gbp: Optional[float] = Field(default=None)
bill_total_annual_bill_gbp: Optional[float] = Field(default=None)
@classmethod
def from_domain(
cls, baseline: PropertyBaselinePerformance, property_id: int
) -> "PropertyBaselinePerformanceModel":
return cls(
model = cls(
property_id=property_id,
lodged_sap_score=baseline.lodged.sap_score,
lodged_epc_band=baseline.lodged.epc_band.value,
@ -56,6 +89,26 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True):
space_heating_kwh=baseline.space_heating_kwh,
water_heating_kwh=baseline.water_heating_kwh,
)
model._write_bill(baseline.bill)
return model
def _write_bill(self, bill: Optional[Bill]) -> None:
"""Flatten the Bill onto the ``bill_*`` columns. When ``bill`` is None
(no calculator ran) every bill column is left None; a section absent from
the mapping leaves its two columns None (None != 0 it was not billed)."""
if bill is None:
return
for section, stem in _SECTION_COLUMN_STEM.items():
cost = bill.sections.get(section)
setattr(self, f"bill_{stem}_kwh", cost.kwh if cost is not None else None)
setattr(
self,
f"bill_{stem}_cost_gbp",
cost.cost_gbp if cost is not None else None,
)
self.bill_standing_charges_gbp = bill.standing_charges_gbp
self.bill_seg_credit_gbp = bill.seg_credit_gbp
self.bill_total_annual_bill_gbp = bill.total_gbp
def to_domain(self) -> PropertyBaselinePerformance:
return PropertyBaselinePerformance(
@ -74,4 +127,26 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True):
rebaseline_reason=cast(RebaselineReason, self.rebaseline_reason),
space_heating_kwh=self.space_heating_kwh,
water_heating_kwh=self.water_heating_kwh,
bill=self._read_bill(),
)
def _read_bill(self) -> Optional[Bill]:
"""Reconstruct the Bill from the ``bill_*`` columns. The total is the
not-None discriminator: a persisted bill always sets it, so its absence
means no calculator ran and the bill was None. A section is rebuilt only
when its kWh column is not None (paired with its cost)."""
if self.bill_total_annual_bill_gbp is None:
return None
sections: dict[BillSection, BillSectionCost] = {}
for section, stem in _SECTION_COLUMN_STEM.items():
kwh = cast(Optional[float], getattr(self, f"bill_{stem}_kwh"))
if kwh is None:
continue
cost_gbp = cast(float, getattr(self, f"bill_{stem}_cost_gbp"))
sections[section] = BillSectionCost(kwh=kwh, cost_gbp=cost_gbp)
return Bill(
sections=sections,
standing_charges_gbp=cast(float, self.bill_standing_charges_gbp),
seg_credit_gbp=cast(float, self.bill_seg_credit_gbp),
total_gbp=self.bill_total_annual_bill_gbp,
)

View file

@ -6,9 +6,12 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
RenewableHeatIncentive,
)
from domain.property_baseline.bill import EnergyBreakdown
from domain.property_baseline.bill_derivation import BillDerivation
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
from domain.property_baseline.performance import lodged_performance
from domain.property_baseline.rebaseliner import Rebaseliner
from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository
from repositories.unit_of_work import UnitOfWork
@ -32,11 +35,18 @@ class PropertyBaselineOrchestrator:
*,
unit_of_work: Callable[[], UnitOfWork],
rebaseliner: Rebaseliner,
fuel_rates: FuelRatesRepository,
) -> None:
self._unit_of_work = unit_of_work
self._rebaseliner = rebaseliner
self._fuel_rates = fuel_rates
def run(self, property_ids: list[int]) -> None:
# The Fuel Rates snapshot is a committed static file (no DB), so read it
# once before the unit opens and reuse the BillDerivation across the
# batch — every property prices against the same snapshot.
fuel_rates = self._fuel_rates.get_current()
bill_derivation = BillDerivation(fuel_rates)
with self._unit_of_work() as uow:
properties = uow.property.get_many(property_ids)
for property_id, prop in zip(property_ids, properties, strict=True):
@ -45,6 +55,15 @@ class PropertyBaselineOrchestrator:
rebaselined = self._rebaseliner.rebaseline(
property_id, effective_epc, lodged
)
# No SapResult (the stub path) means no scored picture to price,
# so the bill stays None.
bill = (
bill_derivation.derive(
EnergyBreakdown.from_sap_result(rebaselined.sap_result)
)
if rebaselined.sap_result is not None
else None
)
rhi = _require_rhi(effective_epc)
baseline = PropertyBaselinePerformance(
lodged=lodged,
@ -52,6 +71,7 @@ class PropertyBaselineOrchestrator:
rebaseline_reason=rebaselined.reason,
space_heating_kwh=rhi.space_heating_kwh,
water_heating_kwh=rhi.water_heating_kwh,
bill=bill,
)
uow.property_baseline.save(baseline, property_id)
uow.commit()

View file

@ -32,6 +32,9 @@ from orchestration.modelling_orchestrator import ModellingOrchestrator
from repositories.property_baseline.property_baseline_postgres_repository import (
PropertyBaselinePostgresRepository,
)
from repositories.fuel_rates.fuel_rates_static_file_repository import (
FuelRatesStaticFileRepository,
)
from repositories.geospatial.geospatial_repository import GeospatialRepository
from repositories.materials.materials_repository import MaterialsRepository
from repositories.postgres_unit_of_work import PostgresUnitOfWork
@ -113,6 +116,7 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
baseline=PropertyBaselineOrchestrator(
unit_of_work=unit_of_work,
rebaseliner=StubRebaseliner(),
fuel_rates=FuelRatesStaticFileRepository(),
),
modelling=ModellingOrchestrator(
scenario_repo=ScenarioRepository(),

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from datatypes.epc.domain.epc import Epc
@ -7,17 +9,31 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
RenewableHeatIncentive,
)
from domain.fuel_rates.fuel import Fuel
from domain.property_baseline.bill import BillSection
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
from domain.property_baseline.performance import Performance
from domain.property_baseline.rebaseliner import RebaselineNotImplemented, StubRebaseliner
from domain.property_baseline.rebaseliner import (
RebaselineNotImplemented,
RebaselineResult,
Rebaseliner,
StubRebaseliner,
)
from domain.property.property import Property, PropertyIdentity
from domain.sap10_calculator.calculator import SapResult
from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator
from repositories.fuel_rates.fuel_rates_static_file_repository import (
FuelRatesStaticFileRepository,
)
from tests.orchestration.fakes import (
FakePropertyBaselineRepo,
FakePropertyRepo,
FakeUnitOfWork,
)
if TYPE_CHECKING:
from datatypes.epc.domain.epc_property_data import EpcPropertyData
def _property(*, sap_version: float) -> Property:
epc = object.__new__(EpcPropertyData)
@ -47,13 +63,15 @@ def test_run_establishes_persists_and_commits_the_batch_once() -> None:
orchestrator = PropertyBaselineOrchestrator(
unit_of_work=lambda: uow,
rebaseliner=StubRebaseliner(),
fuel_rates=FuelRatesStaticFileRepository(),
)
# Act
orchestrator.run([10])
# Assert — one Baseline Performance persisted (both halves equal, kWh off the
# RHI), and the batch committed exactly once.
# RHI, no bill because the stub ran no calculator), and the batch committed
# exactly once.
lodged = Performance(
sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180
)
@ -65,6 +83,7 @@ def test_run_establishes_persists_and_commits_the_batch_once() -> None:
rebaseline_reason="none",
space_heating_kwh=5000.0,
water_heating_kwh=2000.0,
bill=None,
),
10,
)
@ -82,6 +101,7 @@ def test_run_raises_on_a_pre_sap10_property_and_does_not_commit() -> None:
orchestrator = PropertyBaselineOrchestrator(
unit_of_work=lambda: uow,
rebaseliner=StubRebaseliner(),
fuel_rates=FuelRatesStaticFileRepository(),
)
# Act / Assert — the raise propagates; the batch is neither persisted nor
@ -90,3 +110,85 @@ def test_run_raises_on_a_pre_sap10_property_and_does_not_commit() -> None:
orchestrator.run([10])
assert property_baseline_repo.saved == []
assert uow.commits == 0
_LIGHTING_KWH = 400.0
def _sap_result_with_lighting() -> SapResult:
"""A minimal scored picture carrying only lighting energy — enough for Bill
Derivation to produce one electric section. Mirrors the constructor shape in
tests/domain/property_baseline/test_energy_breakdown.py::_sap_result."""
return SapResult(
sap_score=72,
sap_score_continuous=72.0,
ecf=0.0,
total_fuel_cost_gbp=0.0,
co2_kg_per_yr=0.0,
space_heating_kwh_per_yr=0.0,
space_cooling_kwh_per_yr=0.0,
fabric_energy_efficiency_kwh_per_m2_yr=0.0,
main_heating_fuel_kwh_per_yr=0.0,
main_2_heating_fuel_kwh_per_yr=0.0,
secondary_heating_fuel_kwh_per_yr=0.0,
space_cooling_fuel_kwh_per_yr=0.0,
hot_water_kwh_per_yr=0.0,
pumps_fans_kwh_per_yr=0.0,
lighting_kwh_per_yr=_LIGHTING_KWH,
appliances_kwh_per_yr=0.0,
cooking_kwh_per_yr=0.0,
main_heating_fuel_code=None,
main_2_heating_fuel_code=None,
secondary_heating_fuel_code=None,
hot_water_fuel_code=None,
pv_exported_kwh_per_yr=0.0,
primary_energy_kwh_per_yr=0.0,
primary_energy_kwh_per_m2=0.0,
monthly=(),
intermediate={},
)
class _ScoringRebaseliner(Rebaseliner):
"""A rebaseliner that returns a fixed scored picture (a SapResult) so the
orchestrator's Bill Derivation wiring exercises (StubRebaseliner returns
sap_result=None, which never bills)."""
def __init__(self, result: SapResult) -> None:
self._result = result
def rebaseline(
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
) -> RebaselineResult:
return RebaselineResult(
effective=lodged, reason="none", sap_result=self._result
)
def test_run_derives_and_persists_a_bill_when_the_rebaseliner_scores() -> None:
# Arrange — a rebaseliner that hands back a SapResult with lighting energy,
# so the orchestrator prices it into a Bill at the committed snapshot.
property_baseline_repo = FakePropertyBaselineRepo()
uow = FakeUnitOfWork(
property=FakePropertyRepo({10: _property(sap_version=10.2)}),
property_baseline=property_baseline_repo,
)
orchestrator = PropertyBaselineOrchestrator(
unit_of_work=lambda: uow,
rebaseliner=_ScoringRebaseliner(_sap_result_with_lighting()),
fuel_rates=FuelRatesStaticFileRepository(),
)
# Act
orchestrator.run([10])
# Assert — the persisted baseline carries a populated bill; the LIGHTING
# section is the lighting kWh priced at the snapshot's electricity rate
# (read from the snapshot, not hard-coded).
rates = FuelRatesStaticFileRepository().get_current()
expected_cost = _LIGHTING_KWH * rates.unit_rate_p_per_kwh(Fuel.ELECTRICITY) / 100.0
(baseline, _) = property_baseline_repo.saved[0]
assert baseline.bill is not None
lighting = baseline.bill.sections[BillSection.LIGHTING]
assert lighting.kwh == _LIGHTING_KWH
assert abs(lighting.cost_gbp - expected_cost) <= 1e-9

View file

@ -4,6 +4,7 @@ from sqlalchemy import Engine
from sqlmodel import Session
from datatypes.epc.domain.epc import Epc
from domain.property_baseline.bill import Bill, BillSection, BillSectionCost
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
from domain.property_baseline.performance import Performance
from repositories.property_baseline.property_baseline_postgres_repository import (
@ -89,3 +90,65 @@ def test_get_for_property_returns_none_when_absent(db_engine: Engine) -> None:
# Assert
assert loaded is None
def _baseline_with_bill() -> PropertyBaselinePerformance:
lodged = Performance(
sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180
)
# A bill with two sections present (HEATING + LIGHTING) and the rest absent —
# proves the per-section flattening and the absent-section None round-trip.
bill = Bill(
sections={
BillSection.HEATING: BillSectionCost(kwh=8000.0, cost_gbp=459.2),
BillSection.LIGHTING: BillSectionCost(kwh=400.0, cost_gbp=98.68),
},
standing_charges_gbp=314.18,
seg_credit_gbp=12.5,
total_gbp=859.56,
)
return PropertyBaselinePerformance(
lodged=lodged,
effective=lodged,
rebaseline_reason="none",
space_heating_kwh=5000.0,
water_heating_kwh=2000.0,
bill=bill,
)
def test_baseline_with_a_bill_round_trips(db_engine: Engine) -> None:
# Arrange
baseline = _baseline_with_bill()
with Session(db_engine) as session:
PropertyBaselinePostgresRepository(session).save(baseline, property_id=11)
session.commit()
# Act
with Session(db_engine) as session:
loaded = PropertyBaselinePostgresRepository(session).get_for_property(11)
# Assert — the bill survives with its section costs intact; absent sections
# stay absent (not zero).
assert loaded == baseline
assert loaded is not None
assert loaded.bill is not None
assert set(loaded.bill.sections) == {BillSection.HEATING, BillSection.LIGHTING}
def test_baseline_without_a_bill_round_trips_as_none(db_engine: Engine) -> None:
# Arrange — the stub path persists no bill.
baseline = _baseline()
assert baseline.bill is None
with Session(db_engine) as session:
PropertyBaselinePostgresRepository(session).save(baseline, property_id=12)
session.commit()
# Act
with Session(db_engine) as session:
loaded = PropertyBaselinePostgresRepository(session).get_for_property(12)
# Assert
assert loaded == baseline
assert loaded is not None
assert loaded.bill is None