feat(modelling): MeasureType StrEnum as the canonical measure vocabulary

Introduce domain/modelling/measure_type.py — a StrEnum with one member per
modelled measure (the 15 the generators emit). A StrEnum so each member *is*
its string value: it persists straight into the `recommendation` varchar
column, is the optimiser's group-by key, and compares equal to the catalogue /
EPC strings — so it replaces the per-generator string constants with no
persistence or optimiser change.

Repoint every generator's measure-type constant/literal to a MeasureType
member (wall, solid_wall, roof, floor, glazing, lighting, ventilation,
heating, solar). Field annotations stay `str` for now; tightening them to
MeasureType is the next slice.

This is the enum the historical engine deferred (engine.py:970
"TODO - formalise property measure types into an enum") and the vocabulary the
forthcoming `considered_measures` allowlist will speak (mirroring the legacy
`inclusions`).

Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3
xfail; pyright clean on the enum + generators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 19:54:04 +00:00
parent 1b4806f8e4
commit d58ac60d29
11 changed files with 111 additions and 15 deletions

View file

@ -15,6 +15,7 @@ from datatypes.epc.domain.epc_property_data import (
SapBuildingPart,
)
from domain.building_geometry import ground_floor_area
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from repositories.product.product_repository import ProductRepository
@ -44,9 +45,9 @@ def _floor_measure_type(construction_type: Optional[str]) -> Optional[str]:
None when the construction is not a treatable suspended/solid floor."""
text = (construction_type or "").lower()
if "suspended" in text:
return "suspended_floor_insulation"
return MeasureType.SUSPENDED_FLOOR_INSULATION
if "solid" in text:
return "solid_floor_insulation"
return MeasureType.SOLID_FLOOR_INSULATION
return None

View file

@ -23,6 +23,7 @@ from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, WindowOverlay
from repositories.product.product_repository import ProductRepository
@ -52,7 +53,7 @@ class _GlazingTarget:
# Unrestricted: replace the units with double glazing (gt=5 "Double post 2022";
# U 4.80→1.40, g 0.85→0.72).
_DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
measure_type="double_glazing",
measure_type=MeasureType.DOUBLE_GLAZING,
description="Replace the single-glazed windows with double glazing",
glazing_type=5,
u_value=1.40,
@ -64,7 +65,7 @@ _DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
# solar gain). The external units can't be replaced on a protected/over-looked
# building, so this is the planning-picked Measure.
_SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
measure_type="secondary_glazing",
measure_type=MeasureType.SECONDARY_GLAZING,
description="Fit secondary glazing to the single-glazed windows",
glazing_type=11,
u_value=2.90,

View file

@ -18,14 +18,15 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingD
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.products import AshpCostInputs, AshpExistingSystem, Products
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
from repositories.product.product_repository import ProductRepository
_HEATING_SURFACE = "Heating & Hot Water"
_HHR_STORAGE_MEASURE_TYPE = "high_heat_retention_storage_heaters"
_ASHP_MEASURE_TYPE = "air_source_heat_pump"
_HHR_STORAGE_MEASURE_TYPE = MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS
_ASHP_MEASURE_TYPE = MeasureType.AIR_SOURCE_HEAT_PUMP
# Electricity main-fuel code (Elmhurst → SAP10 Table 12).
_ELECTRICITY_FUEL = 30

View file

@ -18,11 +18,12 @@ scoring (ADR-0016).
from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, LightingOverlay
from repositories.product.product_repository import ProductRepository
_LIGHTING_MEASURE_TYPE: Final[str] = "low_energy_lighting"
_LIGHTING_MEASURE_TYPE: Final[MeasureType] = MeasureType.LOW_ENERGY_LIGHTING
def recommend_lighting(

View file

@ -17,12 +17,13 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
)
from domain.building_geometry import roof_area
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from repositories.product.product_repository import ProductRepository
_LOFT_MEASURE_TYPE = "loft_insulation"
_SLOPING_CEILING_MEASURE_TYPE = "sloping_ceiling_insulation"
_LOFT_MEASURE_TYPE = MeasureType.LOFT_INSULATION
_SLOPING_CEILING_MEASURE_TYPE = MeasureType.SLOPING_CEILING_INSULATION
# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. The
# Elmhurst mapper resolves "As Built" to 0 for pitched/sloping/loft roofs.
_ROOF_UNINSULATED_MM = 0
@ -32,7 +33,7 @@ _ROOF_UNINSULATED_MM = 0
_RECOMMENDED_LOFT_THICKNESS_MM = 300
# Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021).
_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100
_FLAT_ROOF_MEASURE_TYPE = "flat_roof_insulation"
_FLAT_ROOF_MEASURE_TYPE = MeasureType.FLAT_ROOF_INSULATION
# Recommended flat-roof depth (mm); Elmhurst re-lodges 200 mm (ADR-0021).
_RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200

View file

@ -24,6 +24,7 @@ from datatypes.epc.domain.epc_property_data import (
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.products import Products, SolarCostInputs
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, SolarOverlay
from domain.modelling.solar_potential import (
@ -37,7 +38,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
from repositories.product.product_repository import ProductRepository
_SOLAR_SURFACE = "Solar PV"
_SOLAR_MEASURE_TYPE = "solar_pv"
_SOLAR_MEASURE_TYPE = MeasureType.SOLAR_PV
# The fixed, representative battery capacity for the with-battery variant
# (ADR-0026) — a flagged estimate (see the rate sheet), 5 kWh.

View file

@ -24,12 +24,13 @@ from datatypes.epc.domain.epc_property_data import (
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
from domain.building_geometry import gross_heat_loss_wall_area
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from repositories.product.product_repository import ProductRepository
_EXTERNAL_MEASURE_TYPE: Final[str] = "external_wall_insulation"
_INTERNAL_MEASURE_TYPE: Final[str] = "internal_wall_insulation"
_EXTERNAL_MEASURE_TYPE: Final[MeasureType] = MeasureType.EXTERNAL_WALL_INSULATION
_INTERNAL_MEASURE_TYPE: Final[MeasureType] = MeasureType.INTERNAL_WALL_INSULATION
# RdSAP `wall_construction` codes (consistent across paths for 1-5).
_WALL_SOLID_BRICK: Final[int] = 3

View file

@ -16,11 +16,12 @@ mirrors legacy ``Property.has_ventilation``.
from typing import Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, VentilationOverlay
from repositories.product.product_repository import ProductRepository
_VENTILATION_MEASURE_TYPE = "mechanical_ventilation"
_VENTILATION_MEASURE_TYPE = MeasureType.MECHANICAL_VENTILATION
# The SAP10.2 §2 mechanical-ventilation kind installed: decentralised MEV
# ("mechanical extract, decentralised (MEV dc)" → MechanicalVentilationKind

View file

@ -12,11 +12,12 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
)
from domain.building_geometry import gross_heat_loss_wall_area
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from repositories.product.product_repository import ProductRepository
_CAVITY_MEASURE_TYPE = "cavity_wall_insulation"
_CAVITY_MEASURE_TYPE = MeasureType.CAVITY_WALL_INSULATION
# RdSAP 10 Table 5 wall_construction: 4 = "Cavity". Table 6
# wall_insulation_type: 4 = "as-built / assumed" (uninsulated), 2 = "Filled

View file

@ -0,0 +1,35 @@
"""MeasureType — the canonical vocabulary of the measures the Modelling stage
models.
One member per Recommendation Generator option. A ``StrEnum`` so each member
*is* its string value: it persists straight into the ``recommendation`` varchar
column, is the optimiser's group-by key, and compares equal to the raw strings
the catalogue and EPC carry so it can replace the per-generator string
constants as the single source of truth without a persistence or optimiser
change. It is also the vocabulary the ``considered_measures`` allowlist speaks
(mirroring the legacy engine's ``inclusions``).
"""
from __future__ import annotations
from enum import StrEnum
class MeasureType(StrEnum):
"""A measure the Modelling stage can recommend (CONTEXT.md)."""
CAVITY_WALL_INSULATION = "cavity_wall_insulation"
EXTERNAL_WALL_INSULATION = "external_wall_insulation"
INTERNAL_WALL_INSULATION = "internal_wall_insulation"
LOFT_INSULATION = "loft_insulation"
SLOPING_CEILING_INSULATION = "sloping_ceiling_insulation"
FLAT_ROOF_INSULATION = "flat_roof_insulation"
SUSPENDED_FLOOR_INSULATION = "suspended_floor_insulation"
SOLID_FLOOR_INSULATION = "solid_floor_insulation"
DOUBLE_GLAZING = "double_glazing"
SECONDARY_GLAZING = "secondary_glazing"
LOW_ENERGY_LIGHTING = "low_energy_lighting"
MECHANICAL_VENTILATION = "mechanical_ventilation"
HIGH_HEAT_RETENTION_STORAGE_HEATERS = "high_heat_retention_storage_heaters"
AIR_SOURCE_HEAT_PUMP = "air_source_heat_pump"
SOLAR_PV = "solar_pv"

View file

@ -0,0 +1,52 @@
"""Slice 1 — `MeasureType`, the canonical enum of the measures the Modelling
stage actually models.
A `StrEnum` so each member *is* its persisted/optimiser string value (e.g.
`MeasureType.SOLAR_PV == "solar_pv"`), letting it flow through the `recommendation`
varchar column and the optimiser's group-by-type key unchanged. Replaces the
per-generator string constants as the single source of truth, and is the
vocabulary the `considered_measures` allowlist speaks.
"""
from domain.modelling.measure_type import MeasureType
# The full set of measures the generators emit today (one per Generator option).
_EXPECTED_VALUES = {
"cavity_wall_insulation",
"external_wall_insulation",
"internal_wall_insulation",
"loft_insulation",
"sloping_ceiling_insulation",
"flat_roof_insulation",
"suspended_floor_insulation",
"solid_floor_insulation",
"double_glazing",
"secondary_glazing",
"low_energy_lighting",
"mechanical_ventilation",
"high_heat_retention_storage_heaters",
"air_source_heat_pump",
"solar_pv",
}
def test_members_cover_exactly_the_modelled_measures() -> None:
# Arrange / Act
values = {member.value for member in MeasureType}
# Assert
assert values == _EXPECTED_VALUES
def test_member_is_its_string_value() -> None:
# Arrange / Act / Assert — a StrEnum member compares and persists as its str.
assert MeasureType.SOLAR_PV == "solar_pv"
assert MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS == (
"high_heat_retention_storage_heaters"
)
assert isinstance(MeasureType.SOLAR_PV, str)
def test_round_trips_through_its_value() -> None:
# Arrange / Act / Assert — a raw catalogue/DB string maps back to the member.
assert MeasureType("cavity_wall_insulation") is MeasureType.CAVITY_WALL_INSULATION