Model/domain/modelling/generators/floor_recommendation.py
Khalim Conn-Kowlessar 31da90f5eb feat(modelling): persist recommendation.material_id from the catalogue
Expand half of the recommendation_materials retirement (ADR-0017). A
Plan Measure installs a single Product, so thread its catalogue id end to
end — Product.id -> MeasureOption.material_id -> PlanMeasure.material_id
-> recommendation.material_id — replacing the per-material BOM child
table with one nullable column on the row. ProductPostgresRepository
reads the id from MaterialRow; the four fabric generators set it on their
Option; the orchestrator carries it onto the Plan Measure; the mirror
declares + maps the column. Optional throughout (the JSON stopgap
catalogue carries no ids -> NULL).

The multi-measure integration test now pins each persisted measure's
material_id to its seeded MaterialRow id. Migration spec (live column
must be added before this deploys; contraction is the owner's next step)
in docs/migrations/recommendation-material-id.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:26:58 +00:00

92 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,
material_id=product.id,
)
return Recommendation(surface="Ground floor", options=(option,))