diff --git a/domain/modelling/generators/roof_recommendation.py b/domain/modelling/generators/roof_recommendation.py index 8bbbd34b..c7ab223e 100644 --- a/domain/modelling/generators/roof_recommendation.py +++ b/domain/modelling/generators/roof_recommendation.py @@ -1,9 +1,13 @@ """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 +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). -No scoring, no persistence — impact is produced later by scoring (ADR-0016). +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 @@ -19,12 +23,9 @@ 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. +# 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 -# 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). @@ -33,44 +34,6 @@ _RECOMMENDED_LOFT_THICKNESS_MM = 300 _RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100 -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, - material_id=product.id, - ) - return Recommendation(surface="Roof", options=(option,)) - - def recommend_roof_insulation( epc: EpcPropertyData, products: ProductRepository ) -> Optional[Recommendation]: @@ -85,8 +48,14 @@ def recommend_roof_insulation( ) 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 not in _ROOF_UNINSULATED_THICKNESSES: + if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM: return None return _roof_recommendation( epc, @@ -95,7 +64,19 @@ def recommend_roof_insulation( description="Sloping-ceiling insulation (insulate at the rafters)", thickness_mm=_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM, ) - return None + + 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( diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index e8e5d042..795f012e 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -18,7 +18,7 @@ from domain.modelling.optimisation.optimiser import ( from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.plan import Plan, PlanMeasure from domain.modelling.recommendation import MeasureOption, Recommendation -from domain.modelling.generators.roof_recommendation import recommend_loft_insulation +from domain.modelling.generators.roof_recommendation import recommend_roof_insulation from domain.modelling.scenario import Scenario from domain.modelling.scoring.scoring import ( MeasureImpact, @@ -203,7 +203,7 @@ def _candidate_recommendations( found = ( recommend_cavity_wall(effective_epc, products), recommend_solid_wall(effective_epc, products, planning_restrictions), - recommend_loft_insulation(effective_epc, products), + recommend_roof_insulation(effective_epc, products), recommend_floor_insulation(effective_epc, products), ) return [recommendation for recommendation in found if recommendation is not None] diff --git a/tests/domain/modelling/fixtures/loft_thatched_001431_after.pdf b/tests/domain/modelling/fixtures/loft_thatched_001431_after.pdf new file mode 100644 index 00000000..a568b1e5 Binary files /dev/null and b/tests/domain/modelling/fixtures/loft_thatched_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/loft_thatched_001431_before.pdf b/tests/domain/modelling/fixtures/loft_thatched_001431_before.pdf new file mode 100644 index 00000000..86b436d1 Binary files /dev/null and b/tests/domain/modelling/fixtures/loft_thatched_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 6bffc3e2..d6e38983 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -28,7 +28,6 @@ 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, recommend_roof_insulation, ) from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation @@ -328,7 +327,7 @@ def test_loft_overlay_reproduces_the_relodged_after() -> None: after: EpcPropertyData = parse_recommendation_summary( "loft_001431_after.pdf" ) - recommendation: Recommendation | None = recommend_loft_insulation( + recommendation: Recommendation | None = recommend_roof_insulation( before, _AnyProduct() ) assert recommendation is not None @@ -366,6 +365,33 @@ def test_roof_generator_insulates_a_sloping_ceiling_pinning_its_after() -> None: ) +def test_roof_generator_insulates_a_thatched_roof_as_loft_pinning_its_after() -> None: + # Arrange — a thatched pitched roof. Thatch is NOT excluded: the covering + # doesn't block insulating the loft floor, so it takes loft (joist) + # insulation, re-lodged None → 300 mm (ADR-0021). + before: EpcPropertyData = parse_recommendation_summary( + "loft_thatched_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "loft_thatched_001431_after.pdf" + ) + + # Act — the dispatcher routes a thatched roof to the loft branch. + 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 loft Option whose overlay reproduces the after. + assert set(options) == {"loft_insulation"} + _assert_overlay_reproduces_after( + before, after, options["loft_insulation"].overlay + ) + + def test_solid_floor_overlay_reproduces_the_relodged_after() -> None: # Arrange before: EpcPropertyData = parse_recommendation_summary( diff --git a/tests/domain/modelling/test_roof_recommendation.py b/tests/domain/modelling/test_roof_recommendation.py index baa2845b..fa27d03b 100644 --- a/tests/domain/modelling/test_roof_recommendation.py +++ b/tests/domain/modelling/test_roof_recommendation.py @@ -11,7 +11,7 @@ from datatypes.epc.domain.epc_property_data import ( from domain.modelling.scoring.overlay_applicator import apply_simulations from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation -from domain.modelling.generators.roof_recommendation import recommend_loft_insulation +from domain.modelling.generators.roof_recommendation import recommend_roof_insulation from repositories.product.product_repository import ProductRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, @@ -35,7 +35,7 @@ def test_uninsulated_loft_yields_a_loft_insulation_recommendation() -> None: _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0 # Act - recommendation: Recommendation | None = recommend_loft_insulation( + recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) @@ -55,7 +55,7 @@ def test_already_insulated_loft_yields_no_recommendation() -> None: _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 300 # Act - recommendation: Recommendation | None = recommend_loft_insulation( + recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) @@ -69,7 +69,7 @@ def test_loft_option_carries_cost_from_roof_area_and_product() -> None: _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0 # Act - recommendation: Recommendation | None = recommend_loft_insulation( + recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() )