Model/domain/modelling/generators/floor_recommendation.py
Khalim Conn-Kowlessar d58ac60d29 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>
2026-06-08 19:54:04 +00:00

93 lines
3.5 KiB
Python

"""The floor Recommendation Generator.
Detects an uninsulated ground floor and its construction (suspended timber vs
solid) and emits a Recommendation whose single Measure Option carries the
matching insulation Simulation Overlay and a priced Cost. A floor is one
construction, so — like a cavity wall — there is one Option, chosen by
detection. No scoring, no persistence (ADR-0016).
"""
from typing import Optional, Union
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
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
# Recommended ground-floor insulation depth (mm).
_RECOMMENDED_FLOOR_THICKNESS_MM = 100
# Insulating an as-built floor re-lodges its insulation as retro-fitted. The
# calculator keys on this for a suspended timber floor's sealed/unsealed
# determination (cert_to_inputs: "retro" + no U-value → sealed), so the
# overlay must set it or the suspended-floor cascade leaves a ~1.4 SAP gap
# (see test_elmhurst_cascade_pins).
_RETROFITTED_INSULATION = "Retro-fitted"
def _is_uninsulated(thickness: Optional[Union[str, int]]) -> bool:
"""A lodged floor-insulation thickness of nothing / blank / zero is an
uninsulated floor; any positive thickness is already insulated."""
if thickness is None:
return True
if isinstance(thickness, int):
return thickness == 0
return thickness.strip() in ("", "0")
def _floor_measure_type(construction_type: Optional[str]) -> Optional[str]:
"""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()
if "suspended" in text:
return MeasureType.SUSPENDED_FLOOR_INSULATION
if "solid" in text:
return MeasureType.SOLID_FLOOR_INSULATION
return None
def recommend_floor_insulation(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[Recommendation]:
"""Return a ground-floor insulation Recommendation for the main part's
uninsulated ground floor, else None."""
main: SapBuildingPart = next(
part
for part in epc.sap_building_parts
if part.identifier is BuildingPartIdentifier.MAIN
)
if not _is_uninsulated(main.floor_insulation_thickness):
return None
measure_type = _floor_measure_type(main.floor_construction_type)
if measure_type is None:
return None
product = products.get(measure_type)
area: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN)
cost = Cost(
total=area * product.unit_cost_per_m2,
contingency_rate=product.contingency_rate,
)
option = MeasureOption(
measure_type=measure_type,
description="Ground-floor insulation",
overlay=EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
floor_insulation_thickness=_RECOMMENDED_FLOOR_THICKNESS_MM,
floor_insulation_type_str=_RETROFITTED_INSULATION,
)
}
),
cost=cost,
material_id=product.id,
)
return Recommendation(surface="Ground floor", options=(option,))