feat(modelling): derive + persist plan-level post-retrofit bills (#1152 follow-up)

ModellingOrchestrator gains a constructor-injected FuelRatesRepository (mirrors
Baseline): run() resolves get_current() once and reuses one BillDerivation across
the batch. _plan_for prices the baseline and post-package end-states from the
SapResults already on their Scores (no extra calculate) and passes the Bills to
Plan. PlanRow mirror + from_domain gain the four live columns post_energy_bill /
energy_bill_savings / post_energy_consumption / energy_consumption_savings.
Pipeline/handler wire the fuel-rates repo. Integration tests assert the columns
persist: the multi-measure (fallback) plan shows positive bill+consumption
savings; the already-at-target zero-measure plan shows the current bill with
exactly zero savings. Fuel-switch measures price at the new fuel for free (we
bill the simulated end-state). 183 modelling/billing/orchestration/repo tests
pass, pyright strict clean. Plan-level only; per-measure savings next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 17:30:47 +00:00
parent 26de28aae8
commit 198122d145
4 changed files with 63 additions and 6 deletions

View file

@ -90,6 +90,7 @@ def build_first_run_pipeline(
modelling=ModellingOrchestrator(
unit_of_work=unit_of_work,
calculator=Sap10Calculator(),
fuel_rates=FuelRatesStaticFileRepository(),
),
)

View file

@ -41,6 +41,10 @@ class PlanRow(SQLModel, table=True):
co2_savings: Optional[float] = Field(default=None) # tonnes/yr
cost_of_works: Optional[float] = Field(default=None)
contingency_cost: Optional[float] = Field(default=None)
post_energy_bill: Optional[float] = Field(default=None) # £/yr
energy_bill_savings: Optional[float] = Field(default=None) # £/yr
post_energy_consumption: Optional[float] = Field(default=None) # delivered kWh/yr
energy_consumption_savings: Optional[float] = Field(default=None) # kWh/yr
@classmethod
def from_domain(
@ -63,6 +67,10 @@ class PlanRow(SQLModel, table=True):
co2_savings=plan.co2_savings_kg_per_yr / _KG_PER_TONNE,
cost_of_works=plan.cost_of_works,
contingency_cost=plan.contingency_cost,
post_energy_bill=plan.post_energy_bill,
energy_bill_savings=plan.energy_bill_savings,
post_energy_consumption=plan.post_energy_consumption,
energy_consumption_savings=plan.energy_consumption_savings,
)

View file

@ -5,6 +5,8 @@ from typing import Final, Optional
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.billing.bill import Bill, EnergyBreakdown
from domain.billing.bill_derivation import BillDerivation
from domain.modelling.generators.floor_recommendation import recommend_floor_insulation
from domain.modelling.optimisation.measure_dependency import ventilation_dependency
from domain.modelling.optimisation.optimiser import (
@ -25,6 +27,7 @@ from domain.modelling.scoring.scoring import (
)
from domain.modelling.generators.wall_recommendation import recommend_cavity_wall
from domain.sap10_calculator.calculator import SapCalculator
from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository
from repositories.product.product_repository import ProductRepository
from repositories.unit_of_work import UnitOfWork
@ -73,14 +76,19 @@ class ModellingOrchestrator:
*,
unit_of_work: Callable[[], UnitOfWork],
calculator: SapCalculator,
fuel_rates: FuelRatesRepository,
) -> None:
self._unit_of_work = unit_of_work
self._calculator = calculator
self._fuel_rates = fuel_rates
def run(
self, property_ids: list[int], scenario_ids: list[int], portfolio_id: int
) -> None:
scorer = PackageScorer(self._calculator)
# Resolve Fuel Rates once and reuse the BillDerivation across the batch,
# so every baseline/post bill is priced at the same snapshot (ADR-0014).
bill_derivation = BillDerivation(self._fuel_rates.get_current())
with self._unit_of_work() as uow:
properties = uow.property.get_many(property_ids)
scenarios: list[Scenario] = uow.scenario.get_many(scenario_ids)
@ -88,7 +96,7 @@ class ModellingOrchestrator:
effective_epc: EpcPropertyData = prop.effective_epc
for scenario in scenarios:
plan = self._plan_for(
scorer, effective_epc, uow.product, scenario
scorer, bill_derivation, effective_epc, uow.product, scenario
)
uow.plan.save(
plan,
@ -102,12 +110,13 @@ class ModellingOrchestrator:
def _plan_for(
self,
scorer: PackageScorer,
bill_derivation: BillDerivation,
effective_epc: EpcPropertyData,
products: ProductRepository,
scenario: Scenario,
) -> Plan:
"""Generate → score → optimise → re-score/repair → attribute → assemble
the Plan for one Property + Scenario."""
"""Generate → score → optimise → re-score/repair → attribute → bill →
assemble the Plan for one Property + Scenario."""
groups: list[list[ScoredOption]] = _scored_candidate_groups(
scorer, effective_epc, products
)
@ -138,11 +147,29 @@ class ModellingOrchestrator:
_plan_measure(option, impact)
for option, impact in zip(ordered, impacts, strict=True)
)
# Price the unmodified and post-package end-states at the same Fuel
# Rates, reusing SapResults already scored — no extra calculate.
return Plan(
measures=measures, baseline=baseline, post_retrofit=package.score
measures=measures,
baseline=baseline,
post_retrofit=package.score,
baseline_bill=_bill_for(bill_derivation, baseline),
post_bill=_bill_for(bill_derivation, package.score),
)
def _bill_for(bill_derivation: BillDerivation, score: Score) -> Bill:
"""Derive the annual Bill for a scored end-state, pricing the delivered
energy off the Score's SapResult. The real PackageScorer always attaches the
SapResult; a missing one is a wiring error, so raise rather than bill at a
default (ADR-0014)."""
if score.sap_result is None:
raise ValueError(
"cannot derive a bill: the Score carries no SapResult to price"
)
return bill_derivation.derive(EnergyBreakdown.from_sap_result(score.sap_result))
def _candidate_recommendations(
effective_epc: EpcPropertyData, products: ProductRepository
) -> list[Recommendation]:

View file

@ -158,6 +158,7 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
modelling=ModellingOrchestrator(
unit_of_work=unit_of_work,
calculator=Sap10Calculator(),
fuel_rates=FuelRatesStaticFileRepository(),
),
)
command = _FakeCommand(portfolio_id=1, property_ids=[10], scenario_ids=[7])
@ -258,7 +259,9 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
# Act
ModellingOrchestrator(
unit_of_work=unit_of_work, calculator=Sap10Calculator()
unit_of_work=unit_of_work,
calculator=Sap10Calculator(),
fuel_rates=FuelRatesStaticFileRepository(),
).run(property_ids=[30], scenario_ids=[7], portfolio_id=1)
# Assert — one Plan with three Plan Measures: the wall + floor the Optimiser
@ -282,6 +285,15 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
assert plan.post_epc_rating is not None
assert plan.cost_of_works is not None
assert plan.cost_of_works > 0.0
# Plan-level energy/bill figures derived from the post-package bill vs the
# baseline bill at the run's Fuel Rates (ADR-0014 amendment). The package
# improves the property, so it consumes less energy and costs less to run.
assert plan.post_energy_bill is not None and plan.post_energy_bill > 0.0
assert plan.post_energy_consumption is not None
assert plan.post_energy_consumption > 0.0
assert plan.energy_bill_savings is not None and plan.energy_bill_savings > 0.0
assert plan.energy_consumption_savings is not None
assert plan.energy_consumption_savings > 0.0
by_type = {rec.type: rec for rec in rec_rows}
assert set(by_type) == {
@ -377,7 +389,9 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band(
# Act
ModellingOrchestrator(
unit_of_work=unit_of_work, calculator=Sap10Calculator()
unit_of_work=unit_of_work,
calculator=Sap10Calculator(),
fuel_rates=FuelRatesStaticFileRepository(),
).run(property_ids=[31], scenario_ids=[8], portfolio_id=1)
# Assert — a Plan is persisted with no measures and zero cost; the
@ -396,3 +410,10 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band(
assert rec_rows == []
assert plan.cost_of_works == 0.0
assert plan.post_epc_rating is Epc.D
# No measures → post bill equals the baseline bill → zero savings, but the
# post-retrofit bill/consumption are still the (non-zero) current figures.
assert plan.post_energy_bill is not None and plan.post_energy_bill > 0.0
assert plan.post_energy_consumption is not None
assert plan.post_energy_consumption > 0.0
assert plan.energy_bill_savings == 0.0
assert plan.energy_consumption_savings == 0.0