From 198122d1454ad8f2952661d0d79d154c36909165 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:30:47 +0000 Subject: [PATCH] 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 --- applications/ara_first_run/handler.py | 1 + infrastructure/postgres/plan_table.py | 8 +++++ orchestration/modelling_orchestrator.py | 35 ++++++++++++++++--- ...test_ara_first_run_pipeline_integration.py | 25 +++++++++++-- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/applications/ara_first_run/handler.py b/applications/ara_first_run/handler.py index 837730b6..8f4f9afa 100644 --- a/applications/ara_first_run/handler.py +++ b/applications/ara_first_run/handler.py @@ -90,6 +90,7 @@ def build_first_run_pipeline( modelling=ModellingOrchestrator( unit_of_work=unit_of_work, calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), ), ) diff --git a/infrastructure/postgres/plan_table.py b/infrastructure/postgres/plan_table.py index 0b7f670a..da43f506 100644 --- a/infrastructure/postgres/plan_table.py +++ b/infrastructure/postgres/plan_table.py @@ -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, ) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 64617607..48395c6a 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -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]: diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index cca8473a..bb96e332 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -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