Model/domain/modelling/roof_recommendation.py
Khalim Conn-Kowlessar 44d62c0c9b feat(modelling): loft overlay 270→300 mm + Elmhurst cascade pin (#1158)
Completes #1158 end-to-end. recommend_loft_insulation now emits a
300 mm overlay (was 270 mm). The Elmhurst before/after re-lodgement of
the loft-insulation measure on cert 001431 lodges the after-cert at
300 mm roof insulation; pinning before→overlay→after requires the
overlay to match that depth — at 270 mm the cascade left a +0.173 SAP
residual, at 300 mm it closes at delta 0.000000 on SAP/CO2/PE.

Adds test_loft_overlay_reproduces_the_relodged_after and updates the
roof generator unit test's thickness assertion to 300.

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

63 lines
2.4 KiB
Python

"""The roof Recommendation Generator.
Detects an uninsulated loft on an EpcPropertyData and emits a Recommendation
whose Measure Option carries the loft-insulation Simulation Overlay and a priced
Cost (roof area x the Product's fully-loaded unit cost, with its contingency).
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"
# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft.
_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
def recommend_loft_insulation(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[Recommendation]:
"""Return a loft-insulation Recommendation for the main roof when it is
uninsulated, else None. The Option's cost is the roof area priced at the
Product's fully-loaded unit cost, with its contingency."""
main = next(
part
for part in epc.sap_building_parts
if part.identifier is BuildingPartIdentifier.MAIN
)
if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM:
return None
product = products.get(_LOFT_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=_LOFT_MEASURE_TYPE,
description="Loft insulation (top up to recommended depth)",
overlay=EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
roof_insulation_thickness=_RECOMMENDED_LOFT_THICKNESS_MM
)
}
),
cost=cost,
)
return Recommendation(surface="Roof", options=(option,))