diff --git a/domain/modelling/generators/roof_recommendation.py b/domain/modelling/generators/roof_recommendation.py index 6b689733..8bbbd34b 100644 --- a/domain/modelling/generators/roof_recommendation.py +++ b/domain/modelling/generators/roof_recommendation.py @@ -18,12 +18,19 @@ 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. _ROOF_UNINSULATED_MM = 0 +# A roof is uninsulated when its lodged insulation thickness is 0 or absent — +# the Elmhurst mapper resolves "As Built" to 0 for pitched/sloping roofs and to +# None for flat roofs (ADR-0021). +_ROOF_UNINSULATED_THICKNESSES: tuple[Optional[int], ...] = (None, 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 def recommend_loft_insulation( @@ -62,3 +69,63 @@ def recommend_loft_insulation( material_id=product.id, ) return Recommendation(surface="Roof", options=(option,)) + + +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 + ) + roof_type: str = (main.roof_construction_type or "").lower() + + if "sloping ceiling" in roof_type: + if main.roof_insulation_thickness not in _ROOF_UNINSULATED_THICKNESSES: + 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, + ) + return None + + +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,)) diff --git a/tests/domain/modelling/fixtures/sloping_ceiling_001431_after.pdf b/tests/domain/modelling/fixtures/sloping_ceiling_001431_after.pdf new file mode 100644 index 00000000..4d506a80 Binary files /dev/null and b/tests/domain/modelling/fixtures/sloping_ceiling_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/sloping_ceiling_001431_before.pdf b/tests/domain/modelling/fixtures/sloping_ceiling_001431_before.pdf new file mode 100644 index 00000000..9e346c34 Binary files /dev/null and b/tests/domain/modelling/fixtures/sloping_ceiling_001431_before.pdf differ diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 5821b5c1..6bffc3e2 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -27,7 +27,10 @@ from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation from domain.modelling.generators.floor_recommendation import recommend_floor_insulation -from domain.modelling.generators.roof_recommendation import recommend_loft_insulation +from domain.modelling.generators.roof_recommendation import ( + recommend_loft_insulation, + recommend_roof_insulation, +) from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from domain.modelling.generators.wall_recommendation import recommend_cavity_wall from domain.geospatial.planning_restrictions import PlanningRestrictions @@ -336,6 +339,33 @@ def test_loft_overlay_reproduces_the_relodged_after() -> None: ) +def test_roof_generator_insulates_a_sloping_ceiling_pinning_its_after() -> None: + # Arrange — a pitched roof with an uninsulated sloping ceiling; the re-lodged + # after raises its insulation from As Built to 100 mm (ADR-0021). + before: EpcPropertyData = parse_recommendation_summary( + "sloping_ceiling_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "sloping_ceiling_001431_after.pdf" + ) + + # Act — the dispatcher detects "sloping ceiling" and offers the sloping + # measure (not loft). + recommendation: Recommendation | None = recommend_roof_insulation( + before, _AnyProduct() + ) + assert recommendation is not None + options: dict[str, MeasureOption] = { + option.measure_type: option for option in recommendation.options + } + + # Assert — one sloping-ceiling Option whose overlay reproduces the after. + assert set(options) == {"sloping_ceiling_insulation"} + _assert_overlay_reproduces_after( + before, after, options["sloping_ceiling_insulation"].overlay + ) + + def test_solid_floor_overlay_reproduces_the_relodged_after() -> None: # Arrange before: EpcPropertyData = parse_recommendation_summary(