Model/domain/modelling/floor_recommendation.py
Khalim Conn-Kowlessar a0b6a952c3 feat(modelling): floor insulation-type overlay field + cascade pins (#1159)
Completes #1159 end-to-end with solid and suspended-floor before/after
cascade pins on cert 001431, both closing at delta 0.000000.

Adds floor_insulation_type_str to BuildingPartOverlay (the generic
field-fold applicator picks it up with no change) and has
recommend_floor_insulation set it to "Retro-fitted". 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.py: "retro" + no U-value supplied →
sealed). Without it the suspended-floor cascade left a +1.40 SAP gap
(the floor stayed "unsealed", wrong U-value); with it the cascade
closes exactly. Solid floors are unaffected by the seal logic and stay
at delta 0; both Elmhurst after-certs lodge "Retro-fitted", so setting
it uniformly is faithful.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:41:54 +00:00

91 lines
3.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.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 "suspended_floor_insulation"
if "solid" in text:
return "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,
)
return Recommendation(surface="Ground floor", options=(option,))