diff --git a/domain/modelling/generators/floor_recommendation.py b/domain/modelling/generators/floor_recommendation.py index b078a230..f4bd3296 100644 --- a/domain/modelling/generators/floor_recommendation.py +++ b/domain/modelling/generators/floor_recommendation.py @@ -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 diff --git a/domain/modelling/generators/glazing_recommendation.py b/domain/modelling/generators/glazing_recommendation.py index ac6c371c..5f0bb006 100644 --- a/domain/modelling/generators/glazing_recommendation.py +++ b/domain/modelling/generators/glazing_recommendation.py @@ -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, diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index c1ec0f3f..1c851541 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -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 diff --git a/domain/modelling/generators/lighting_recommendation.py b/domain/modelling/generators/lighting_recommendation.py index e6c7df63..d1628461 100644 --- a/domain/modelling/generators/lighting_recommendation.py +++ b/domain/modelling/generators/lighting_recommendation.py @@ -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( diff --git a/domain/modelling/generators/roof_recommendation.py b/domain/modelling/generators/roof_recommendation.py index 0d4f356d..a19921a5 100644 --- a/domain/modelling/generators/roof_recommendation.py +++ b/domain/modelling/generators/roof_recommendation.py @@ -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 diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py index 842e5a99..b74c3b63 100644 --- a/domain/modelling/generators/solar_recommendation.py +++ b/domain/modelling/generators/solar_recommendation.py @@ -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. diff --git a/domain/modelling/generators/solid_wall_recommendation.py b/domain/modelling/generators/solid_wall_recommendation.py index 3454341e..49addfa5 100644 --- a/domain/modelling/generators/solid_wall_recommendation.py +++ b/domain/modelling/generators/solid_wall_recommendation.py @@ -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 diff --git a/domain/modelling/generators/ventilation_recommendation.py b/domain/modelling/generators/ventilation_recommendation.py index 0fed5c7b..ff6d598c 100644 --- a/domain/modelling/generators/ventilation_recommendation.py +++ b/domain/modelling/generators/ventilation_recommendation.py @@ -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 diff --git a/domain/modelling/generators/wall_recommendation.py b/domain/modelling/generators/wall_recommendation.py index 86c81f41..2c562241 100644 --- a/domain/modelling/generators/wall_recommendation.py +++ b/domain/modelling/generators/wall_recommendation.py @@ -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 diff --git a/domain/modelling/measure_type.py b/domain/modelling/measure_type.py new file mode 100644 index 00000000..d28bff39 --- /dev/null +++ b/domain/modelling/measure_type.py @@ -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" diff --git a/tests/domain/modelling/test_measure_type.py b/tests/domain/modelling/test_measure_type.py new file mode 100644 index 00000000..60c1a816 --- /dev/null +++ b/tests/domain/modelling/test_measure_type.py @@ -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