Model/domain/modelling/generators/roof_recommendation.py
Khalim Conn-Kowlessar f33bb9d52d feat(modelling): room-in-roof safety guard defers the roof generator
A room-in-roof carries its insulation on its own sloping/stud/gable surfaces
(RdSAP 10 §3.10, Table 17/18), which the roof overlay's flat
roof_insulation_thickness bump cannot model. Without a guard a RR with an
uninsulated loft fell through to the loft fallback and mis-recommended 300 mm
loft insulation. Return None when the main part lodges a sap_room_in_roof,
deferring until a dedicated RR branch lands (ADR-0021).

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

138 lines
5.7 KiB
Python

"""The roof Recommendation Generator.
Dispatches the MAIN roof to its single applicable insulation Measure by roof
type (ADR-0021): a sloping ceiling (rafters, 100 mm), or — the fallback — a
loft / thatched / unlodged pitched roof (joists, 300 mm); a no-access roof gets
nothing. Each emits one "Roof" Recommendation whose Option carries the
insulation Simulation Overlay (raising `roof_insulation_thickness`) and a priced
Cost (roof area x the Product's fully-loaded unit cost, with its contingency).
Flat-roof and room-in-roof branches land in later slices. No scoring, no
persistence — impact is produced later by scoring (ADR-0016).
"""
from typing import Optional
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
)
from domain.building_geometry import roof_area
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from repositories.product.product_repository import ProductRepository
_LOFT_MEASURE_TYPE = "loft_insulation"
_SLOPING_CEILING_MEASURE_TYPE = "sloping_ceiling_insulation"
# 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.
_ROOF_UNINSULATED_MM = 0
# Recommended loft-insulation depth (mm). Elmhurst re-lodges a loft-insulation
# measure at 300 mm; pinning the before→after cascade (000490/001431) requires
# the overlay to match that depth exactly (see test_elmhurst_cascade_pins).
_RECOMMENDED_LOFT_THICKNESS_MM = 300
# Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021).
_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100
_FLAT_ROOF_MEASURE_TYPE = "flat_roof_insulation"
# Recommended flat-roof depth (mm); Elmhurst re-lodges 200 mm (ADR-0021).
_RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200
def recommend_roof_insulation(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[Recommendation]:
"""Return the single roof-insulation Recommendation for the MAIN roof,
dispatched by roof type (ADR-0021): a sloping ceiling is insulated at the
rafters to 100 mm. Returns None when the roof type has no applicable measure
or the roof is already insulated."""
main = next(
part
for part in epc.sap_building_parts
if part.identifier is BuildingPartIdentifier.MAIN
)
# Room-in-roof safety guard (ADR-0021): a room-in-roof carries its
# insulation on its own sloping/stud/gable surfaces (RdSAP 10 §3.10, Table
# 17/18), which the loft/sloping overlay's flat `roof_insulation_thickness`
# bump cannot model. Without this guard a RR with an uninsulated loft would
# fall through to the loft fallback and mis-recommend loft insulation.
# Defer until a dedicated RR branch lands.
if main.sap_room_in_roof is not None:
return None
roof_type: str = (main.roof_construction_type or "").lower()
# Dispatch by roof type (ADR-0021). Order matters: a sloping ceiling is
# tested before the loft fallback, and "no access" before it too, because
# "no access to loft" contains "loft". Loft is the fallback — it covers a
# plain pitched loft, a thatched roof (the covering doesn't block insulating
# the loft floor), and an unlodged roof type (the modal UK case), matching
# the pre-dispatcher behaviour of firing on `roof_insulation_thickness == 0`.
if "sloping ceiling" in roof_type:
if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM:
return None
return _roof_recommendation(
epc,
products,
measure_type=_SLOPING_CEILING_MEASURE_TYPE,
description="Sloping-ceiling insulation (insulate at the rafters)",
thickness_mm=_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM,
)
if "flat" in roof_type:
# A flat roof lodges no thickness when uninsulated ("As Built" → None
# on the Elmhurst path); a lodged thickness means it's already done.
if main.roof_insulation_thickness is not None:
return None
return _roof_recommendation(
epc,
products,
measure_type=_FLAT_ROOF_MEASURE_TYPE,
description="Flat-roof insulation",
thickness_mm=_RECOMMENDED_FLAT_ROOF_THICKNESS_MM,
)
if "no access" in roof_type:
return None # the roof void can't be reached to insulate it
if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM:
return None
return _roof_recommendation(
epc,
products,
measure_type=_LOFT_MEASURE_TYPE,
description="Loft insulation (top up to recommended depth)",
thickness_mm=_RECOMMENDED_LOFT_THICKNESS_MM,
)
def _roof_recommendation(
epc: EpcPropertyData,
products: ProductRepository,
*,
measure_type: str,
description: str,
thickness_mm: int,
) -> Recommendation:
"""Build a single-Option "Roof" Recommendation: the measure's insulation
overlay (raising `roof_insulation_thickness` to the recommended depth)
priced at the roof area."""
product = products.get(measure_type)
area: float = roof_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=description,
overlay=EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
roof_insulation_thickness=thickness_mm
)
}
),
cost=cost,
material_id=product.id,
)
return Recommendation(surface="Roof", options=(option,))