diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py index 76a98ad2..50db6ddf 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -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) diff --git a/infrastructure/postgres/plan_table.py b/infrastructure/postgres/plan_table.py index da43f506..b76c32d1 100644 --- a/infrastructure/postgres/plan_table.py +++ b/infrastructure/postgres/plan_table.py @@ -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, ) diff --git a/tests/repositories/plan/test_plan_postgres_repository.py b/tests/repositories/plan/test_plan_postgres_repository.py index 975a7e38..050c9b55 100644 --- a/tests/repositories/plan/test_plan_postgres_repository.py +++ b/tests/repositories/plan/test_plan_postgres_repository.py @@ -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: