feat(modelling): domain Plan + PlanMeasure types (#1157)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 11:40:27 +00:00
parent 62a968119c
commit 0ebd9cc7fd
2 changed files with 120 additions and 0 deletions

73
domain/modelling/plan.py Normal file
View file

@ -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

View file

@ -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 (6980)