mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Tighten the recommendation/plan vocabulary off generic str: MeasureOption.measure_type and PlanMeasure.measure_type are now MeasureType (also _GlazingTarget.measure_type, MeasureDependency.triggers -> frozenset[MeasureType], and the optimiser's chosen/required-type locals). Because MeasureType is a StrEnum the change is transparent to persistence (the `recommendation` varchar column), the optimiser group-by key, and every `== "solar_pv"`-style comparison — so pyright now enforces the enum at every construction site with no runtime behaviour change. The catalogue boundary stays str: ProductRepository.get(measure_type: str) and Product.measure_type are unchanged (they map arbitrary DB/JSON rows), so the fake product repos in tests need no edit. Test construction helpers coerce their str arg via MeasureType(...); direct constructions use members. Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3 xfail; pyright clean on production + tests (pre-existing moto + property- override-rowcount baselines untouched). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
97 lines
3.8 KiB
Python
97 lines
3.8 KiB
Python
"""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.billing.bill import Bill, BillSection, BillSectionCost
|
||
from domain.modelling.measure_type import MeasureType
|
||
from domain.modelling.scoring.package_scorer import Score
|
||
from domain.modelling.plan import Plan, PlanMeasure
|
||
from domain.modelling.recommendation import Cost
|
||
from domain.modelling.scoring.scoring import MeasureImpact
|
||
|
||
|
||
def _bill(*, heating_kwh: float, total_gbp: float) -> Bill:
|
||
return Bill(
|
||
sections={
|
||
BillSection.HEATING: BillSectionCost(kwh=heating_kwh, cost_gbp=total_gbp)
|
||
},
|
||
standing_charges_gbp=0.0,
|
||
seg_credit_gbp=0.0,
|
||
total_gbp=total_gbp,
|
||
)
|
||
|
||
|
||
def _measure(measure_type: str, total: float, rate: float) -> PlanMeasure:
|
||
return PlanMeasure(
|
||
measure_type=MeasureType(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)
|
||
|
||
|
||
def test_plan_derives_post_bill_and_savings_from_the_baseline_and_post_bills() -> None:
|
||
# Arrange — a Plan whose baseline and post-package Bills have been derived.
|
||
baseline = Score(
|
||
sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0
|
||
)
|
||
post = Score(
|
||
sap_continuous=70.0, co2_kg_per_yr=3600.0, primary_energy_kwh_per_yr=18000.0
|
||
)
|
||
plan = Plan(
|
||
measures=(),
|
||
baseline=baseline,
|
||
post_retrofit=post,
|
||
baseline_bill=_bill(heating_kwh=10000.0, total_gbp=2000.0),
|
||
post_bill=_bill(heating_kwh=6000.0, total_gbp=1400.0),
|
||
)
|
||
|
||
# Act / Assert — plan-level energy/bill figures (ADR-0014 amendment).
|
||
assert plan.post_energy_bill == 1400.0
|
||
assert plan.energy_bill_savings == 600.0 # 2000 − 1400
|
||
assert plan.post_energy_consumption == 6000.0 # Σ post section kWh
|
||
assert plan.energy_consumption_savings == 4000.0 # 10000 − 6000
|
||
|
||
|
||
def test_plan_energy_figures_are_none_without_bills() -> None:
|
||
# Arrange — a Plan with no bills derived (the figures persist as NULL).
|
||
score = Score(
|
||
sap_continuous=55.0, co2_kg_per_yr=3000.0, primary_energy_kwh_per_yr=15000.0
|
||
)
|
||
plan = Plan(measures=(), baseline=score, post_retrofit=score)
|
||
|
||
# Act / Assert
|
||
assert plan.post_energy_bill is None
|
||
assert plan.energy_bill_savings is None
|
||
assert plan.post_energy_consumption is None
|
||
assert plan.energy_consumption_savings is None
|