mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
62a968119c
commit
0ebd9cc7fd
2 changed files with 120 additions and 0 deletions
73
domain/modelling/plan.py
Normal file
73
domain/modelling/plan.py
Normal 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
|
||||
47
tests/domain/modelling/test_plan.py
Normal file
47
tests/domain/modelling/test_plan.py
Normal 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 (69–80)
|
||||
Loading…
Add table
Reference in a new issue