feat(modelling): Plan Measure carries per-measure kwh/cost savings

`PlanMeasure` grows optional `kwh_savings` (delivered energy) and
`energy_cost_savings` (£) — its slice of the telescoping bill cascade, signed
so positive is a saving and `None` until billing runs. `RecommendationRow`
declares the matching live `recommendation.kwh_savings` /
`energy_cost_savings` columns and maps them in `from_domain` (None → NULL).
The vestigial `recommendation.energy_savings` stays undeclared (legacy = 0).
No FE migration — the columns already exist on the live table (ADR-0014 / 0017).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 17:58:06 +00:00
parent e79ffabfc5
commit 7e79c30af1
3 changed files with 59 additions and 1 deletions

View file

@ -29,12 +29,21 @@ def _total_consumption_kwh(bill: Bill) -> float:
@dataclass(frozen=True)
class PlanMeasure:
"""One selected Measure Option as it lands in a Plan: the measure, its
installed Cost, and its role-3 (final-package cascade) attributed impact."""
installed Cost, and its role-3 (final-package cascade) attributed impact.
`kwh_savings` (delivered energy) and `energy_cost_savings` (£) are this
measure's slice of the telescoping bill cascade — its marginal Bill delta
over the running package state. They can be negative (e.g. ventilation
increases energy) and telescope exactly to the Plan totals; `None` until
billing has run (persisted as NULL ADR-0014 amendment). They are distinct
from `impact.energy_savings_kwh_per_yr`, which is *primary* energy."""
measure_type: str
description: str
cost: Cost
impact: MeasureImpact
kwh_savings: Optional[float] = None
energy_cost_savings: Optional[float] = None
@dataclass(frozen=True)

View file

@ -103,6 +103,8 @@ class RecommendationRow(SQLModel, table=True):
estimated_cost: Optional[float] = Field(default=None)
sap_points: Optional[float] = Field(default=None)
co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr
kwh_savings: Optional[float] = Field(default=None) # delivered kWh/yr
energy_cost_savings: Optional[float] = Field(default=None) # £/yr
default: bool = True
already_installed: bool = False
@ -121,6 +123,8 @@ class RecommendationRow(SQLModel, table=True):
co2_equivalent_savings=(
measure.impact.co2_savings_kg_per_yr / _KG_PER_TONNE
),
kwh_savings=measure.kwh_savings,
energy_cost_savings=measure.energy_cost_savings,
default=True,
already_installed=False,
)

View file

@ -33,6 +33,8 @@ def _plan() -> Plan:
co2_savings_kg_per_yr=500.0,
energy_savings_kwh_per_yr=2000.0,
),
kwh_savings=1500.0,
energy_cost_savings=300.0,
),
)
return Plan(
@ -97,10 +99,53 @@ def test_save_persists_plan_and_its_measures_with_tonnes_and_band(
assert abs(rec.estimated_cost - 1000.0) <= 1e-9
assert abs(rec.sap_points - 8.0) <= 1e-9
assert abs(rec.co2_equivalent_savings - 0.5) <= 1e-9 # tonnes
assert rec.kwh_savings is not None
assert rec.energy_cost_savings is not None
assert abs(rec.kwh_savings - 1500.0) <= 1e-9 # delivered kWh saved/yr
assert abs(rec.energy_cost_savings - 300.0) <= 1e-9 # £/yr saved
assert rec.default is True
assert rec.already_installed is False
def test_save_persists_null_per_measure_savings_when_unbilled(
db_engine: Engine,
) -> None:
# Arrange — a Plan Measure whose per-measure bills were never derived.
measure = PlanMeasure(
measure_type="loft_insulation",
description="Loft insulation",
cost=Cost(total=500.0, contingency_rate=0.20),
impact=MeasureImpact(
sap_points=3.0, co2_savings_kg_per_yr=200.0, energy_savings_kwh_per_yr=800.0
),
)
plan = Plan(
measures=(measure,),
baseline=Score(
sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0
),
post_retrofit=Score(
sap_continuous=45.0, co2_kg_per_yr=3800.0, primary_energy_kwh_per_yr=19000.0
),
)
# Act
with Session(db_engine) as session:
plan_id: int = PlanPostgresRepository(session).save(
plan, property_id=11, scenario_id=7, portfolio_id=1, is_default=True
)
session.commit()
# Assert — the savings columns persist as NULL (ADR-0014 amendment)
with Session(db_engine) as session:
rec_rows = session.exec(
select(RecommendationRow).where(col(RecommendationRow.plan_id) == plan_id)
).all()
assert len(rec_rows) == 1
assert rec_rows[0].kwh_savings is None
assert rec_rows[0].energy_cost_savings is None
def test_save_is_idempotent_on_rerun_for_the_same_property_and_scenario(
db_engine: Engine,
) -> None: