mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
26de28aae8
commit
198122d145
4 changed files with 63 additions and 6 deletions
|
|
@ -90,6 +90,7 @@ def build_first_run_pipeline(
|
|||
modelling=ModellingOrchestrator(
|
||||
unit_of_work=unit_of_work,
|
||||
calculator=Sap10Calculator(),
|
||||
fuel_rates=FuelRatesStaticFileRepository(),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue