Model/domain/modelling/generators/floor_recommendation.py
Khalim Conn-Kowlessar fb3516ee95 Floor insulation is withheld when it cannot lower the derived floor U 🟩
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:06:45 +00:00

104 lines
4 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.generators.floor_u_value_gate import floor_insulation_lowers_u
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[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()
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
# Floor U-Value Gate (ADR-0051, floor arm): withhold the Option when it
# cannot lower the floor's derived U — e.g. an at-regs modern age band,
# where the as-built and insulated cascades resolve to the same value.
if not floor_insulation_lowers_u(
main,
epc.country_code,
insulation_thickness_mm=_RECOMMENDED_FLOOR_THICKNESS_MM,
):
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,))