mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): attribute per-measure bill savings via a telescoping cascade
`_plan_for` now scores the baseline + every cumulative prefix once (`cascade_scores`, best-practice order) and reuses those Scores for both the role-3 marginal attribution and a per-measure bill cascade: bill each prefix at one Fuel Rates snapshot and take consecutive Bill deltas as each measure's marginal delivered-kWh and £ saving. Saving is signed (ventilation is negative) and telescopes exactly to the Plan headline savings, because the Plan's baseline/post Bills are now the same cascade endpoints (`bills[0]` / `bills[-1]`) — which also drops the redundant standalone baseline `calculate`. `recommendation.kwh_savings` / `energy_cost_savings` are filled from these. Adds `Bill.total_consumption_kwh` (shared by Plan + the orchestrator). Pinned end-to-end on the real calculator: Σ per-measure savings == the Plan totals (ADR-0014 amendment). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7e79c30af1
commit
b976c3abd2
4 changed files with 53 additions and 20 deletions
|
|
@ -127,3 +127,9 @@ class Bill:
|
||||||
standing_charges_gbp: float
|
standing_charges_gbp: float
|
||||||
seg_credit_gbp: float
|
seg_credit_gbp: float
|
||||||
total_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)
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,6 @@ from domain.modelling.recommendation import Cost
|
||||||
from domain.modelling.scoring.scoring import MeasureImpact
|
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)
|
@dataclass(frozen=True)
|
||||||
class PlanMeasure:
|
class PlanMeasure:
|
||||||
"""One selected Measure Option as it lands in a Plan: the measure, its
|
"""One selected Measure Option as it lands in a Plan: the measure, its
|
||||||
|
|
@ -112,7 +106,7 @@ class Plan:
|
||||||
@property
|
@property
|
||||||
def post_energy_consumption(self) -> Optional[float]:
|
def post_energy_consumption(self) -> Optional[float]:
|
||||||
"""The post-package total delivered energy (kWh), or None if not billed."""
|
"""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
|
@property
|
||||||
def energy_consumption_savings(self) -> Optional[float]:
|
def energy_consumption_savings(self) -> Optional[float]:
|
||||||
|
|
@ -120,6 +114,7 @@ class Plan:
|
||||||
both bills were derived."""
|
both bills were derived."""
|
||||||
if self.baseline_bill is None or self.post_bill is None:
|
if self.baseline_bill is None or self.post_bill is None:
|
||||||
return None
|
return None
|
||||||
return _total_consumption_kwh(self.baseline_bill) - _total_consumption_kwh(
|
return (
|
||||||
self.post_bill
|
self.baseline_bill.total_consumption_kwh
|
||||||
|
- self.post_bill.total_consumption_kwh
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,9 @@ from domain.modelling.generators.roof_recommendation import recommend_loft_insul
|
||||||
from domain.modelling.scenario import Scenario
|
from domain.modelling.scenario import Scenario
|
||||||
from domain.modelling.scoring.scoring import (
|
from domain.modelling.scoring.scoring import (
|
||||||
MeasureImpact,
|
MeasureImpact,
|
||||||
|
cascade_scores,
|
||||||
independent_option_impacts,
|
independent_option_impacts,
|
||||||
marginal_impacts,
|
marginals_from_scores,
|
||||||
)
|
)
|
||||||
from domain.modelling.generators.wall_recommendation import recommend_cavity_wall
|
from domain.modelling.generators.wall_recommendation import recommend_cavity_wall
|
||||||
from domain.sap10_calculator.calculator import SapCalculator
|
from domain.sap10_calculator.calculator import SapCalculator
|
||||||
|
|
@ -139,22 +140,31 @@ class ModellingOrchestrator:
|
||||||
ordered: list[MeasureOption] = sorted(
|
ordered: list[MeasureOption] = sorted(
|
||||||
(scored.option for scored in package.selected), key=_best_practice_key
|
(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]
|
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(
|
measures: tuple[PlanMeasure, ...] = tuple(
|
||||||
_plan_measure(option, impact)
|
_plan_measure(option, impact, before, after)
|
||||||
for option, impact in zip(ordered, impacts, strict=True)
|
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(
|
return Plan(
|
||||||
measures=measures,
|
measures=measures,
|
||||||
baseline=baseline,
|
baseline=cascade[0],
|
||||||
post_retrofit=package.score,
|
post_retrofit=package.score,
|
||||||
baseline_bill=_bill_for(bill_derivation, baseline),
|
baseline_bill=bills[0],
|
||||||
post_bill=_bill_for(bill_derivation, package.score),
|
post_bill=bills[-1],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -232,7 +242,12 @@ def _best_practice_key(option: MeasureOption) -> int:
|
||||||
return len(_BEST_PRACTICE_ORDER)
|
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:
|
if option.cost is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"measure option {option.measure_type!r} has no cost; cannot persist"
|
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,
|
description=option.description,
|
||||||
cost=option.cost,
|
cost=option.cost,
|
||||||
impact=impact,
|
impact=impact,
|
||||||
|
kwh_savings=before.total_consumption_kwh - after.total_consumption_kwh,
|
||||||
|
energy_cost_savings=before.total_gbp - after.total_gbp,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 is not None and vent_sap is not None
|
||||||
assert wall_sap > 0.0
|
assert wall_sap > 0.0
|
||||||
assert vent_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(
|
def test_modelling_recommends_nothing_when_already_at_the_target_band(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue