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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 20:13:31 +00:00
parent d58ac60d29
commit 9ef97be958
14 changed files with 40 additions and 29 deletions

View file

@ -40,7 +40,7 @@ def _is_uninsulated(thickness: Optional[Union[str, int]]) -> bool:
return thickness.strip() in ("", "0")
def _floor_measure_type(construction_type: Optional[str]) -> Optional[str]:
def _floor_measure_type(construction_type: Optional[str]) -> Optional[MeasureType]:
"""Map the lodged floor construction to the insulation Measure Type, or
None when the construction is not a treatable suspended/solid floor."""
text = (construction_type or "").lower()

View file

@ -43,7 +43,7 @@ class _GlazingTarget:
`solar_transmittance` SAP10.2 Table U2 code, then heat-loss U and solar g).
"""
measure_type: str
measure_type: MeasureType
description: str
glazing_type: int
u_value: float

View file

@ -110,7 +110,7 @@ def _roof_recommendation(
epc: EpcPropertyData,
products: ProductRepository,
*,
measure_type: str,
measure_type: MeasureType,
description: str,
thickness_mm: int,
) -> Recommendation:

View file

@ -57,7 +57,7 @@ _SOLID_WALL_INSULATION_MM: Final[int] = 100
# Which solid-wall Options each construction can take (ADR-0019). Solid brick
# and system-built take both; timber-frame takes IWI only (EWI not
# constructable). The breathable cob/stone exclusions take neither (never keyed).
_CONSTRUCTABLE_OPTIONS: Final[dict[int, tuple[str, ...]]] = {
_CONSTRUCTABLE_OPTIONS: Final[dict[int, tuple[MeasureType, ...]]] = {
_WALL_SOLID_BRICK: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE),
_WALL_SYSTEM_BUILT: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE),
_WALL_TIMBER_FRAME: (_INTERNAL_MEASURE_TYPE,),
@ -75,7 +75,7 @@ _DESCRIPTION: Final[dict[str, str]] = {
def _solid_wall_option(
epc: EpcPropertyData, products: ProductRepository, measure_type: str
epc: EpcPropertyData, products: ProductRepository, measure_type: MeasureType
) -> MeasureOption:
"""Build one solid-wall Measure Option: its insulation overlay (100 mm at the
External/Internal `wall_insulation_type`) priced at the heat-loss wall area."""

View file

@ -24,17 +24,18 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.generators.ventilation_recommendation import (
recommend_ventilation,
)
from domain.modelling.measure_type import MeasureType
from domain.modelling.optimisation.optimiser import MeasureDependency, ScoredOption
from domain.modelling.recommendation import MeasureOption, Recommendation
from repositories.product.product_repository import ProductRepository
# The measure types that force a ventilation dependency (cf. legacy
# `assumptions.measures_needing_ventilation`).
MEASURES_NEEDING_VENTILATION: frozenset[str] = frozenset(
MEASURES_NEEDING_VENTILATION: frozenset[MeasureType] = frozenset(
{
"cavity_wall_insulation",
"internal_wall_insulation",
"external_wall_insulation",
MeasureType.CAVITY_WALL_INSULATION,
MeasureType.INTERNAL_WALL_INSULATION,
MeasureType.EXTERNAL_WALL_INSULATION,
}
)

View file

@ -24,6 +24,7 @@ from dataclasses import dataclass
from typing import Optional, Protocol, Sequence
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.measure_type import MeasureType
from domain.modelling.scoring.package_scorer import Score
from domain.modelling.recommendation import MeasureOption
from domain.modelling.simulation import EpcSimulation
@ -48,7 +49,7 @@ class MeasureDependency:
figure, the repair decision, and the persisted package. Held as data so
extending the triggers is a data edit, not control flow."""
triggers: frozenset[str]
triggers: frozenset[MeasureType]
required: ScoredOption
@ -300,11 +301,11 @@ def _inject(
"""``chosen`` plus every forced dependency whose triggers intersect the
chosen measure-types, de-duplicated by required measure-type (a dependency
several measures trigger is injected once)."""
chosen_types: set[str] = {s.option.measure_type for s in chosen}
chosen_types: set[MeasureType] = {s.option.measure_type for s in chosen}
injected: list[ScoredOption] = list(chosen)
present: set[str] = set(chosen_types)
present: set[MeasureType] = set(chosen_types)
for dependency in dependencies:
required_type: str = dependency.required.option.measure_type
required_type: MeasureType = dependency.required.option.measure_type
if dependency.triggers & chosen_types and required_type not in present:
injected.append(dependency.required)
present.add(required_type)

View file

@ -15,6 +15,7 @@ from typing import Optional
from datatypes.epc.domain.epc import Epc
from domain.billing.bill import Bill
from domain.modelling.measure_type import MeasureType
from domain.modelling.scoring.package_scorer import Score
from domain.modelling.recommendation import Cost
from domain.modelling.scoring.scoring import MeasureImpact
@ -33,7 +34,7 @@ class PlanMeasure:
billing has run (persisted as NULL ADR-0014 amendment). They are distinct
from `impact.energy_savings_kwh_per_yr`, which is *primary* energy."""
measure_type: str
measure_type: MeasureType
description: str
cost: Cost
impact: MeasureImpact

View file

@ -11,6 +11,7 @@ CONTEXT.md.
from dataclasses import dataclass
from typing import Optional
from domain.modelling.measure_type import MeasureType
from domain.modelling.simulation import EpcSimulation
@ -28,7 +29,7 @@ class Cost:
class MeasureOption:
"""One mutually-exclusive way to treat a Recommendation's surface."""
measure_type: str
measure_type: MeasureType
description: str
overlay: EpcSimulation
cost: Optional[Cost] = None

View file

@ -55,7 +55,7 @@ def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None:
# bundle, whose overlay is the absolute HHR end-state.
assert recommendation is not None
assert recommendation.surface == "Heating & Hot Water"
options = {o.measure_type: o for o in recommendation.options}
options = {o.measure_type.value: o for o in recommendation.options}
assert "high_heat_retention_storage_heaters" in options
assert options["high_heat_retention_storage_heaters"].overlay.heating == HeatingOverlay(
main_fuel_type=30,
@ -150,7 +150,7 @@ def test_gas_boiler_house_yields_an_ashp_bundle() -> None:
# Assert — the ASHP bundle carries the absolute heat-pump end-state.
assert recommendation is not None
options = {o.measure_type: o for o in recommendation.options}
options = {o.measure_type.value: o for o in recommendation.options}
assert "air_source_heat_pump" in options
assert options["air_source_heat_pump"].overlay.heating == HeatingOverlay(
main_fuel_type=30,

View file

@ -22,6 +22,7 @@ from domain.modelling.optimisation.optimiser import (
optimise_min_cost,
optimise_package,
)
from domain.modelling.measure_type import MeasureType
from domain.modelling.scoring.package_scorer import Score
from domain.modelling.recommendation import Cost, MeasureOption
from domain.modelling.simulation import (
@ -37,7 +38,7 @@ from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption:
return ScoredOption(
option=MeasureOption(
measure_type=measure_type,
measure_type=MeasureType(measure_type),
description=measure_type,
overlay=EpcSimulation(),
cost=Cost(total=cost, contingency_rate=0.0),
@ -70,7 +71,7 @@ def _scored_overlay(
) -> ScoredOption:
return ScoredOption(
option=MeasureOption(
measure_type=measure_type,
measure_type=MeasureType(measure_type),
description=measure_type,
overlay=overlay,
cost=Cost(total=cost, contingency_rate=0.0),
@ -524,10 +525,12 @@ class _VentStubScorer:
def _ventilation_dependency(*, cost: float) -> MeasureDependency:
"""A forced 'fabric requires ventilation' edge for the tests."""
return MeasureDependency(
triggers=frozenset({"cavity_wall_insulation", "external_wall_insulation"}),
triggers=frozenset(
{MeasureType.CAVITY_WALL_INSULATION, MeasureType.EXTERNAL_WALL_INSULATION}
),
required=ScoredOption(
option=MeasureOption(
measure_type="mechanical_ventilation",
measure_type=MeasureType.MECHANICAL_VENTILATION,
description="mechanical_ventilation",
overlay=_VENT_OVERLAY,
cost=Cost(total=cost, contingency_rate=0.0),
@ -549,10 +552,10 @@ def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() -
]
scorer = _VentStubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0)
dependency = MeasureDependency(
triggers=frozenset({"cavity_wall_insulation"}),
triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}),
required=ScoredOption(
option=MeasureOption(
measure_type="mechanical_ventilation",
measure_type=MeasureType.MECHANICAL_VENTILATION,
description="mechanical_ventilation",
overlay=_VENT_OVERLAY,
cost=Cost(total=300.0, contingency_rate=0.0),

View file

@ -9,6 +9,7 @@ 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
@ -28,7 +29,7 @@ def _bill(*, heating_kwh: float, total_gbp: float) -> Bill:
def _measure(measure_type: str, total: float, rate: float) -> PlanMeasure:
return PlanMeasure(
measure_type=measure_type,
measure_type=MeasureType(measure_type),
description=measure_type.replace("_", " "),
cost=Cost(total=total, contingency_rate=rate),
impact=MeasureImpact(

View file

@ -10,6 +10,7 @@ from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
)
from domain.modelling.measure_type import MeasureType
from domain.modelling.scoring.package_scorer import PackageScorer, Score
from domain.modelling.recommendation import MeasureOption
from domain.modelling.scoring.scoring import (
@ -59,7 +60,7 @@ class _CountingScorer(PackageScorer):
def _option(overlay: EpcSimulation) -> MeasureOption:
return MeasureOption(
measure_type="cavity_wall_insulation", description="opt", overlay=overlay
measure_type=MeasureType.CAVITY_WALL_INSULATION, description="opt", overlay=overlay
)

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from domain.modelling.measure_type import MeasureType
from domain.modelling.plan import Plan, PlanMeasure
from domain.modelling.recommendation import Cost
from domain.modelling.scoring.package_scorer import Score
@ -18,7 +19,7 @@ def _plan() -> Plan:
)
measures = (
PlanMeasure(
measure_type="cavity_wall_insulation",
measure_type=MeasureType.CAVITY_WALL_INSULATION,
description="Cavity wall insulation",
cost=Cost(total=500.0, contingency_rate=0.1),
impact=MeasureImpact(
@ -30,7 +31,7 @@ def _plan() -> Plan:
energy_cost_savings=120.0,
),
PlanMeasure(
measure_type="mechanical_ventilation",
measure_type=MeasureType.MECHANICAL_VENTILATION,
description="Mechanical extract ventilation",
cost=Cost(total=900.0, contingency_rate=0.26),
impact=MeasureImpact(

View file

@ -14,6 +14,7 @@ from sqlalchemy import Engine
from sqlmodel import Session, col, select
from datatypes.epc.domain.epc import Epc
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
@ -25,7 +26,7 @@ from repositories.plan.plan_postgres_repository import PlanPostgresRepository
def _plan() -> Plan:
measures: tuple[PlanMeasure, ...] = (
PlanMeasure(
measure_type="cavity_wall_insulation",
measure_type=MeasureType.CAVITY_WALL_INSULATION,
description="Cavity wall insulation",
cost=Cost(total=1000.0, contingency_rate=0.10),
impact=MeasureImpact(
@ -112,7 +113,7 @@ def test_save_persists_null_per_measure_savings_when_unbilled(
) -> None:
# Arrange — a Plan Measure whose per-measure bills were never derived.
measure = PlanMeasure(
measure_type="loft_insulation",
measure_type=MeasureType.LOFT_INSULATION,
description="Loft insulation",
cost=Cost(total=500.0, contingency_rate=0.20),
impact=MeasureImpact(