mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
1b4806f8e4
commit
d58ac60d29
11 changed files with 111 additions and 15 deletions
|
|
@ -15,6 +15,7 @@ from datatypes.epc.domain.epc_property_data import (
|
||||||
SapBuildingPart,
|
SapBuildingPart,
|
||||||
)
|
)
|
||||||
from domain.building_geometry import ground_floor_area
|
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.recommendation import Cost, MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||||
from repositories.product.product_repository import ProductRepository
|
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."""
|
None when the construction is not a treatable suspended/solid floor."""
|
||||||
text = (construction_type or "").lower()
|
text = (construction_type or "").lower()
|
||||||
if "suspended" in text:
|
if "suspended" in text:
|
||||||
return "suspended_floor_insulation"
|
return MeasureType.SUSPENDED_FLOOR_INSULATION
|
||||||
if "solid" in text:
|
if "solid" in text:
|
||||||
return "solid_floor_insulation"
|
return MeasureType.SOLID_FLOOR_INSULATION
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from typing import Final, Optional
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
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.recommendation import Cost, MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import EpcSimulation, WindowOverlay
|
from domain.modelling.simulation import EpcSimulation, WindowOverlay
|
||||||
from repositories.product.product_repository import ProductRepository
|
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";
|
# Unrestricted: replace the units with double glazing (gt=5 "Double post 2022";
|
||||||
# U 4.80→1.40, g 0.85→0.72).
|
# U 4.80→1.40, g 0.85→0.72).
|
||||||
_DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
|
_DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
|
||||||
measure_type="double_glazing",
|
measure_type=MeasureType.DOUBLE_GLAZING,
|
||||||
description="Replace the single-glazed windows with double glazing",
|
description="Replace the single-glazed windows with double glazing",
|
||||||
glazing_type=5,
|
glazing_type=5,
|
||||||
u_value=1.40,
|
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
|
# solar gain). The external units can't be replaced on a protected/over-looked
|
||||||
# building, so this is the planning-picked Measure.
|
# building, so this is the planning-picked Measure.
|
||||||
_SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
|
_SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
|
||||||
measure_type="secondary_glazing",
|
measure_type=MeasureType.SECONDARY_GLAZING,
|
||||||
description="Fit secondary glazing to the single-glazed windows",
|
description="Fit secondary glazing to the single-glazed windows",
|
||||||
glazing_type=11,
|
glazing_type=11,
|
||||||
u_value=2.90,
|
u_value=2.90,
|
||||||
|
|
|
||||||
|
|
@ -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 datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
|
||||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
from domain.modelling.products import AshpCostInputs, AshpExistingSystem, Products
|
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.recommendation import Cost, MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
|
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
|
||||||
from repositories.product.product_repository import ProductRepository
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
_HEATING_SURFACE = "Heating & Hot Water"
|
_HEATING_SURFACE = "Heating & Hot Water"
|
||||||
|
|
||||||
_HHR_STORAGE_MEASURE_TYPE = "high_heat_retention_storage_heaters"
|
_HHR_STORAGE_MEASURE_TYPE = MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS
|
||||||
_ASHP_MEASURE_TYPE = "air_source_heat_pump"
|
_ASHP_MEASURE_TYPE = MeasureType.AIR_SOURCE_HEAT_PUMP
|
||||||
|
|
||||||
# Electricity main-fuel code (Elmhurst → SAP10 Table 12).
|
# Electricity main-fuel code (Elmhurst → SAP10 Table 12).
|
||||||
_ELECTRICITY_FUEL = 30
|
_ELECTRICITY_FUEL = 30
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,12 @@ scoring (ADR-0016).
|
||||||
from typing import Final, Optional
|
from typing import Final, Optional
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
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.recommendation import Cost, MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import EpcSimulation, LightingOverlay
|
from domain.modelling.simulation import EpcSimulation, LightingOverlay
|
||||||
from repositories.product.product_repository import ProductRepository
|
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(
|
def recommend_lighting(
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,13 @@ from datatypes.epc.domain.epc_property_data import (
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
)
|
)
|
||||||
from domain.building_geometry import roof_area
|
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.recommendation import Cost, MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||||
from repositories.product.product_repository import ProductRepository
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
_LOFT_MEASURE_TYPE = "loft_insulation"
|
_LOFT_MEASURE_TYPE = MeasureType.LOFT_INSULATION
|
||||||
_SLOPING_CEILING_MEASURE_TYPE = "sloping_ceiling_insulation"
|
_SLOPING_CEILING_MEASURE_TYPE = MeasureType.SLOPING_CEILING_INSULATION
|
||||||
# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. The
|
# 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.
|
# Elmhurst mapper resolves "As Built" to 0 for pitched/sloping/loft roofs.
|
||||||
_ROOF_UNINSULATED_MM = 0
|
_ROOF_UNINSULATED_MM = 0
|
||||||
|
|
@ -32,7 +33,7 @@ _ROOF_UNINSULATED_MM = 0
|
||||||
_RECOMMENDED_LOFT_THICKNESS_MM = 300
|
_RECOMMENDED_LOFT_THICKNESS_MM = 300
|
||||||
# Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021).
|
# Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021).
|
||||||
_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100
|
_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 depth (mm); Elmhurst re-lodges 200 mm (ADR-0021).
|
||||||
_RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200
|
_RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from datatypes.epc.domain.epc_property_data import (
|
||||||
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
|
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
|
||||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
from domain.modelling.products import Products, SolarCostInputs
|
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.recommendation import Cost, MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import EpcSimulation, SolarOverlay
|
from domain.modelling.simulation import EpcSimulation, SolarOverlay
|
||||||
from domain.modelling.solar_potential import (
|
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
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
_SOLAR_SURFACE = "Solar PV"
|
_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
|
# The fixed, representative battery capacity for the with-battery variant
|
||||||
# (ADR-0026) — a flagged estimate (see the rate sheet), 5 kWh.
|
# (ADR-0026) — a flagged estimate (see the rate sheet), 5 kWh.
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,13 @@ from datatypes.epc.domain.epc_property_data import (
|
||||||
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
|
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
|
||||||
from domain.building_geometry import gross_heat_loss_wall_area
|
from domain.building_geometry import gross_heat_loss_wall_area
|
||||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
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.recommendation import Cost, MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||||
from repositories.product.product_repository import ProductRepository
|
from repositories.product.product_repository import ProductRepository
|
||||||
|
|
||||||
_EXTERNAL_MEASURE_TYPE: Final[str] = "external_wall_insulation"
|
_EXTERNAL_MEASURE_TYPE: Final[MeasureType] = MeasureType.EXTERNAL_WALL_INSULATION
|
||||||
_INTERNAL_MEASURE_TYPE: Final[str] = "internal_wall_insulation"
|
_INTERNAL_MEASURE_TYPE: Final[MeasureType] = MeasureType.INTERNAL_WALL_INSULATION
|
||||||
|
|
||||||
# RdSAP `wall_construction` codes (consistent across paths for 1-5).
|
# RdSAP `wall_construction` codes (consistent across paths for 1-5).
|
||||||
_WALL_SOLID_BRICK: Final[int] = 3
|
_WALL_SOLID_BRICK: Final[int] = 3
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,12 @@ mirrors legacy ``Property.has_ventilation``.
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
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.recommendation import Cost, MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import EpcSimulation, VentilationOverlay
|
from domain.modelling.simulation import EpcSimulation, VentilationOverlay
|
||||||
from repositories.product.product_repository import ProductRepository
|
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
|
# The SAP10.2 §2 mechanical-ventilation kind installed: decentralised MEV
|
||||||
# ("mechanical extract, decentralised (MEV dc)" → MechanicalVentilationKind
|
# ("mechanical extract, decentralised (MEV dc)" → MechanicalVentilationKind
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,12 @@ from datatypes.epc.domain.epc_property_data import (
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
)
|
)
|
||||||
from domain.building_geometry import gross_heat_loss_wall_area
|
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.recommendation import Cost, MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||||
from repositories.product.product_repository import ProductRepository
|
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
|
# RdSAP 10 Table 5 wall_construction: 4 = "Cavity". Table 6
|
||||||
# wall_insulation_type: 4 = "as-built / assumed" (uninsulated), 2 = "Filled
|
# wall_insulation_type: 4 = "as-built / assumed" (uninsulated), 2 = "Filled
|
||||||
|
|
|
||||||
35
domain/modelling/measure_type.py
Normal file
35
domain/modelling/measure_type.py
Normal 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"
|
||||||
52
tests/domain/modelling/test_measure_type.py
Normal file
52
tests/domain/modelling/test_measure_type.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue