Model/tests/domain/modelling/test_plan.py
Khalim Conn-Kowlessar 9ef97be958 refactor(modelling): type measure_type fields as MeasureType
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>
2026-06-08 20:13:31 +00:00

97 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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