diff --git a/domain/billing/bill.py b/domain/billing/bill.py index 0e06cf27..5aff24cf 100644 --- a/domain/billing/bill.py +++ b/domain/billing/bill.py @@ -127,3 +127,9 @@ class Bill: standing_charges_gbp: float seg_credit_gbp: float total_gbp: float + + @property + def total_consumption_kwh(self) -> float: + """Total delivered energy (kWh) across the billed sections. Standing + charges and the SEG credit are £, not energy, so they don't count.""" + return sum((section.kwh for section in self.sections.values()), 0.0) diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py index 50db6ddf..cfaaf9ff 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -20,12 +20,6 @@ from domain.modelling.recommendation import Cost from domain.modelling.scoring.scoring import MeasureImpact -def _total_consumption_kwh(bill: Bill) -> float: - """A Bill's total delivered energy (kWh) — the sum of its section kWh - (standing charges and the SEG credit are £, not energy).""" - return sum((section.kwh for section in bill.sections.values()), 0.0) - - @dataclass(frozen=True) class PlanMeasure: """One selected Measure Option as it lands in a Plan: the measure, its @@ -112,7 +106,7 @@ class Plan: @property def post_energy_consumption(self) -> Optional[float]: """The post-package total delivered energy (kWh), or None if not billed.""" - return None if self.post_bill is None else _total_consumption_kwh(self.post_bill) + return None if self.post_bill is None else self.post_bill.total_consumption_kwh @property def energy_consumption_savings(self) -> Optional[float]: @@ -120,6 +114,7 @@ class Plan: both bills were derived.""" if self.baseline_bill is None or self.post_bill is None: return None - return _total_consumption_kwh(self.baseline_bill) - _total_consumption_kwh( - self.post_bill + return ( + self.baseline_bill.total_consumption_kwh + - self.post_bill.total_consumption_kwh ) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 48395c6a..7fc9b491 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -22,8 +22,9 @@ from domain.modelling.generators.roof_recommendation import recommend_loft_insul from domain.modelling.scenario import Scenario from domain.modelling.scoring.scoring import ( MeasureImpact, + cascade_scores, independent_option_impacts, - marginal_impacts, + marginals_from_scores, ) from domain.modelling.generators.wall_recommendation import recommend_cavity_wall from domain.sap10_calculator.calculator import SapCalculator @@ -139,22 +140,31 @@ class ModellingOrchestrator: ordered: list[MeasureOption] = sorted( (scored.option for scored in package.selected), key=_best_practice_key ) - impacts: list[MeasureImpact] = marginal_impacts( + # Score the baseline + every cumulative prefix once (cascade[0] is the + # baseline, cascade[-1] the whole package), then reuse those Scores for + # both the marginal attribution and the per-measure bill cascade. + cascade: list[Score] = cascade_scores( scorer, effective_epc, [option.overlay for option in ordered] ) - baseline: Score = scorer.score(effective_epc, []) + impacts: list[MeasureImpact] = marginals_from_scores(cascade) + # Bill every prefix at one Fuel Rates snapshot; consecutive Bill deltas + # are each measure's marginal energy/cost saving — negative for + # ventilation — telescoping exactly to the Plan totals (ADR-0014). The + # Plan's baseline/post Bills are the cascade endpoints, so the + # per-measure savings and the headline savings share one source. + bills: list[Bill] = [_bill_for(bill_derivation, score) for score in cascade] measures: tuple[PlanMeasure, ...] = tuple( - _plan_measure(option, impact) - for option, impact in zip(ordered, impacts, strict=True) + _plan_measure(option, impact, before, after) + for option, impact, before, after in zip( + ordered, impacts, bills[:-1], bills[1:], 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, + baseline=cascade[0], post_retrofit=package.score, - baseline_bill=_bill_for(bill_derivation, baseline), - post_bill=_bill_for(bill_derivation, package.score), + baseline_bill=bills[0], + post_bill=bills[-1], ) @@ -232,7 +242,12 @@ def _best_practice_key(option: MeasureOption) -> int: return len(_BEST_PRACTICE_ORDER) -def _plan_measure(option: MeasureOption, impact: MeasureImpact) -> PlanMeasure: +def _plan_measure( + option: MeasureOption, impact: MeasureImpact, before: Bill, after: Bill +) -> PlanMeasure: + """Assemble a Plan Measure, attributing this measure's marginal bill saving + as the delta between the running package Bill before and after it (delivered + kWh and £). Signed so positive is a saving; ventilation is negative.""" if option.cost is None: raise ValueError( f"measure option {option.measure_type!r} has no cost; cannot persist" @@ -242,4 +257,6 @@ def _plan_measure(option: MeasureOption, impact: MeasureImpact) -> PlanMeasure: description=option.description, cost=option.cost, impact=impact, + kwh_savings=before.total_consumption_kwh - after.total_consumption_kwh, + energy_cost_savings=before.total_gbp - after.total_gbp, ) diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index bb96e332..5cbc4fbb 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -318,6 +318,21 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( assert wall_sap is not None and vent_sap is not None assert wall_sap > 0.0 assert vent_sap <= 0.0 + # Per-measure bill savings (telescoping cascade, ADR-0014 amendment): each + # measure carries its delivered-kWh and £ saving, and they telescope exactly + # to the Plan's headline savings. Ventilation increases energy, so its + # savings are negative — and the telescoping still holds. + for rec in rec_rows: + assert rec.kwh_savings is not None + assert rec.energy_cost_savings is not None + vent_kwh: float | None = by_type["mechanical_ventilation"].kwh_savings + assert vent_kwh is not None and vent_kwh < 0.0 + kwh_total: float = sum(rec.kwh_savings or 0.0 for rec in rec_rows) + cost_total: float = sum(rec.energy_cost_savings or 0.0 for rec in rec_rows) + assert plan.energy_consumption_savings is not None + assert plan.energy_bill_savings is not None + assert abs(kwh_total - plan.energy_consumption_savings) <= 1e-6 + assert abs(cost_total - plan.energy_bill_savings) <= 1e-6 def test_modelling_recommends_nothing_when_already_at_the_target_band(