"""The roof Recommendation Generator. Dispatches the MAIN roof to its single applicable insulation Measure by roof type (ADR-0021): a sloping or vaulted ceiling (rafters, 100 mm), a flat roof (200 mm), or — the fallback — a loft / thatched / unlodged pitched roof (joists, 300 mm); a no-access roof gets nothing. The pitched branches resolve a lodged sentinel thickness ("ND" / nothing) to the age band's as-built state (ADR-0047). 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). The room-in-roof branch lands in a later slice. No scoring, no persistence — impact is produced later by scoring (ADR-0016). """ from typing import Final, Optional, Union from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) from domain.building_geometry import roof_area 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 _LOFT_MEASURE_TYPE = MeasureType.LOFT_INSULATION _SLOPING_CEILING_MEASURE_TYPE = MeasureType.SLOPING_CEILING_INSULATION # 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 # Age bands whose as-built pitched roof is uninsulated (ADR-0047: A-D # uninsulated, E-F limited insulation, G+ insulated) — the resolution for a # cert lodging the "ND" (Not Defined) sentinel or no thickness at all. _AS_BUILT_UNINSULATED_AGE_BANDS: Final[frozenset[str]] = frozenset( {"A", "B", "C", "D"} ) def _pitched_roof_is_uninsulated( thickness: Union[str, int, None], age_band: Optional[str] ) -> bool: """The pitched-roof uninsulated trigger, sentinel-aware (ADR-0047). `roof_insulation_thickness` lodges numerics AND string sentinels; the sentinels are data, not noise, and must never make an undefined cert MORE eligible than an explicitly-lodged one. An explicit 0 is uninsulated; the "ND" (Not Defined) sentinel — or nothing lodged — resolves to the age band's as-built state (only A-D built without pitched-roof insulation). Anything else ("NI" = insulation present at unknown thickness per RdSAP 10 §5.11.4, or a measured depth) means insulation is there. Eligibility only — the calculator resolves the same sentinels separately for the U-cascade (deliberate two-home split, ADR-0047).""" if isinstance(thickness, int): return thickness == 0 if thickness is None or thickness == "ND": return ( age_band is not None and age_band.upper() in _AS_BUILT_UNINSULATED_AGE_BANDS ) return False _FLAT_ROOF_MEASURE_TYPE = MeasureType.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`. # A vaulted ceiling follows the slope like a sloping ceiling (no loft # void) — same disjunction the calculator's `is_sloping_ceiling` uses # (heat_transmission.py), so generator and scorer read one story (ADR-0047). if "sloping ceiling" in roof_type or "vaulted" in roof_type: if not _pitched_roof_is_uninsulated( main.roof_insulation_thickness, main.construction_age_band ): 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 # Sentinel resolution needs a KNOWN pitched/thatched roof (ADR-0047). An # unlodged type reaching this fallback can be a party ceiling — gov-API # codes 6/7 ("(another dwelling above)" / insulated thatch) map the type # to None and real code-7 certs lodge "ND" — with zero roof heat loss # (RdSAP 10 Table 18), so only an explicit 0 may trigger it. known_pitched: bool = "pitched" in roof_type or "thatch" in roof_type uninsulated: bool = ( _pitched_roof_is_uninsulated( main.roof_insulation_thickness, main.construction_age_band ) if known_pitched else main.roof_insulation_thickness == 0 ) if not uninsulated: 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: MeasureType, 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,))