From 0ebd9cc7fde7d565fde0ba5f0ec009e77d116d03 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 11:40:27 +0000 Subject: [PATCH] feat(modelling): domain Plan + PlanMeasure types (#1157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of #1157. The per-Property output of one Scenario's modelling run, per ADR-0017. - PlanMeasure: a selected Measure Option frozen with its installed Cost and role-3 (final-package cascade) attributed MeasureImpact — the output counterpart of a Recommendation's candidate Option. - Plan: the selected Plan Measures + baseline/post-retrofit Scores. Single-phase (ADR-0005); derives the persisted headline figures — cost_of_works, contingency_cost, co2_savings_kg_per_yr (kg; the mapper converts to tonnes), post_sap_continuous, and post_epc_rating (band from the rounded SAP via Epc.from_sap_score). 1 unit test, pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/plan.py | 73 +++++++++++++++++++++++++++++ tests/domain/modelling/test_plan.py | 47 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 domain/modelling/plan.py create mode 100644 tests/domain/modelling/test_plan.py diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py new file mode 100644 index 00000000..405d6253 --- /dev/null +++ b/domain/modelling/plan.py @@ -0,0 +1,73 @@ +"""Plan and Plan Measure — the Modelling stage's persisted output (ADR-0017). + +A **Plan** is the per-Property output of one Scenario's modelling run: the +selected **Optimised Package** (its **Plan Measures**) plus the Property's +post-retrofit figures. It is single-phase — multi-phase is deferred +(ADR-0005) — so the headline figures are flat on the Plan. + +A **Plan Measure** is the *output* counterpart of a Recommendation's candidate +Option: the one Option the Optimiser kept, frozen with its installed **Cost** +and its final-package (role-3) attributed **impact**. See CONTEXT.md. +""" + +from dataclasses import dataclass + +from datatypes.epc.domain.epc import Epc +from domain.modelling.package_scorer import Score +from domain.modelling.recommendation import Cost +from domain.modelling.scoring import MeasureImpact + + +@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.""" + + measure_type: str + description: str + cost: Cost + impact: MeasureImpact + + +@dataclass(frozen=True) +class Plan: + """A Property's Plan for one Scenario: the selected Plan Measures and the + baseline / post-retrofit whole-package Scores. The persisted headline + figures are derived from these (cost aggregates, CO₂ saving, post band).""" + + measures: tuple[PlanMeasure, ...] + baseline: Score + post_retrofit: Score + + @property + def cost_of_works(self) -> float: + """Sum of the Plan Measures' fully-loaded Costs.""" + return sum((measure.cost.total for measure in self.measures), 0.0) + + @property + def contingency_cost(self) -> float: + """Sum of each Plan Measure's contingency (its Cost total × its + per-Measure-Type contingency rate).""" + return sum( + ( + measure.cost.total * measure.cost.contingency_rate + for measure in self.measures + ), + 0.0, + ) + + @property + def post_sap_continuous(self) -> float: + """The whole-package re-score's un-rounded SAP rating.""" + return self.post_retrofit.sap_continuous + + @property + def post_epc_rating(self) -> Epc: + """The post-retrofit EPC band, from the rounded SAP rating.""" + return Epc.from_sap_score(round(self.post_retrofit.sap_continuous)) + + @property + def co2_savings_kg_per_yr(self) -> float: + """Whole-package CO₂ reduction (kg/yr) vs the baseline re-score. The + persistence mapper converts to tonnes for the live column contract.""" + return self.baseline.co2_kg_per_yr - self.post_retrofit.co2_kg_per_yr diff --git a/tests/domain/modelling/test_plan.py b/tests/domain/modelling/test_plan.py new file mode 100644 index 00000000..fe6828ba --- /dev/null +++ b/tests/domain/modelling/test_plan.py @@ -0,0 +1,47 @@ +"""Behaviour of the Plan / PlanMeasure domain types: the per-Property output +of one Scenario's modelling run. A Plan carries its selected Plan Measures +(the Optimised Package) plus the baseline/post-retrofit Scores, and derives +the persisted headline figures (cost aggregates, CO₂ saving, post-retrofit +band). Single-phase, flat post-retrofit figures (ADR-0005 / ADR-0017). +""" + +from __future__ import annotations + +from datatypes.epc.domain.epc import Epc +from domain.modelling.package_scorer import Score +from domain.modelling.plan import Plan, PlanMeasure +from domain.modelling.recommendation import Cost +from domain.modelling.scoring import MeasureImpact + + +def _measure(measure_type: str, total: float, rate: float) -> PlanMeasure: + return PlanMeasure( + measure_type=measure_type, + description=measure_type.replace("_", " "), + cost=Cost(total=total, contingency_rate=rate), + impact=MeasureImpact( + sap_points=1.0, co2_savings_kg_per_yr=1.0, energy_savings_kwh_per_yr=1.0 + ), + ) + + +def test_plan_aggregates_cost_and_savings_and_bands_the_post_sap() -> None: + # Arrange + measures: tuple[PlanMeasure, ...] = ( + _measure("cavity_wall_insulation", total=1000.0, rate=0.10), + _measure("loft_insulation", total=500.0, rate=0.20), + ) + baseline = Score( + sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0 + ) + post = Score( + sap_continuous=70.4, co2_kg_per_yr=3600.0, primary_energy_kwh_per_yr=18000.0 + ) + plan = Plan(measures=measures, baseline=baseline, post_retrofit=post) + + # Act / Assert + assert abs(plan.cost_of_works - 1500.0) <= 1e-9 + assert abs(plan.contingency_cost - 200.0) <= 1e-9 # 1000*0.10 + 500*0.20 + assert abs(plan.co2_savings_kg_per_yr - 400.0) <= 1e-9 # baseline - post + assert abs(plan.post_sap_continuous - 70.4) <= 1e-9 + assert plan.post_epc_rating is Epc.C # round(70.4) = 70 → band C (69–80)