"""Behaviour of the roof Recommendation Generator: detecting an uninsulated loft and emitting a Recommendation whose Measure Option carries the loft- insulation Simulation Overlay and a priced Cost. Mirrors the wall generator. """ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, SapBuildingPart, SapRoomInRoof, ) 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_roof_insulation from repositories.product.product_repository import ProductRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) class _StubProducts(ProductRepository): def get(self, measure_type: str) -> Product: return Product( measure_type=measure_type, unit_cost_per_m2=30.0, contingency_rate=0.10 ) def _part(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> SapBuildingPart: return next(p for p in epc.sap_building_parts if p.identifier is identifier) def test_uninsulated_loft_yields_a_loft_insulation_recommendation() -> None: # Arrange baseline: EpcPropertyData = build_epc() _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0 # Act recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) # Assert assert recommendation is not None assert recommendation.surface == "Roof" assert len(recommendation.options) == 1 option = recommendation.options[0] assert option.measure_type == "loft_insulation" simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) assert _part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 300 def test_already_insulated_loft_yields_no_recommendation() -> None: # Arrange baseline: EpcPropertyData = build_epc() # MAIN roof already 300 mm _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 300 # Act recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) # Assert assert recommendation is None def test_room_in_roof_yields_no_recommendation_pending_a_dedicated_branch() -> None: # Arrange — an uninsulated loft the fallback would otherwise top up, but the # part is a room-in-roof. The simple loft/sloping overlay can't model RR # insulation (its sloping/stud/gable surfaces carry their own U-values via # Table 17/18), so the generator must defer rather than mis-fire loft. baseline: EpcPropertyData = build_epc() main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) main.roof_insulation_thickness = 0 main.sap_room_in_roof = SapRoomInRoof(floor_area=9.0, construction_age_band="D") # Act recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) # Assert assert recommendation is None def test_vaulted_ceiling_with_nd_thickness_on_pre_1950_dwelling_yields_sloping_ceiling_insulation() -> None: # Arrange — the 742265 shape (ADR-0047): a "Pitched (vaulted ceiling)" MAIN # roof lodging the "ND" sentinel on an age-band-B dwelling. ND resolves to # the age band's as-built state (A-D: uninsulated), and a vaulted ceiling # has no loft void, so the one applicable Measure is rafter insulation. baseline: EpcPropertyData = build_epc() main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) main.roof_construction_type = "Pitched (vaulted ceiling)" main.roof_insulation_thickness = "ND" main.construction_age_band = "B" # Act recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) # Assert assert recommendation is not None assert recommendation.surface == "Roof" assert len(recommendation.options) == 1 option = recommendation.options[0] assert option.measure_type == "sloping_ceiling_insulation" simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) assert ( _part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 100 ) def test_vaulted_ceiling_with_nd_thickness_on_limited_insulation_age_band_yields_no_recommendation() -> None: # Arrange — same vaulted-ND shape, but age band E: as-built carries limited # insulation (ADR-0047), and ND must never make an undefined cert MORE # eligible than an explicit thin lodgement (which is not eligible today). baseline: EpcPropertyData = build_epc() main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) main.roof_construction_type = "Pitched (vaulted ceiling)" main.roof_insulation_thickness = "ND" main.construction_age_band = "E" # Act recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) # Assert assert recommendation is None def test_vaulted_ceiling_with_ni_thickness_yields_no_recommendation() -> None: # Arrange — "NI" means insulation PRESENT at unknown thickness (RdSAP 10 # §5.11.4: score as 50 mm), never "no insulation" — even on an age band # whose as-built state is uninsulated (ADR-0047, the ND ≠ NI distinction). baseline: EpcPropertyData = build_epc() main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) main.roof_construction_type = "Pitched (vaulted ceiling)" main.roof_insulation_thickness = "NI" main.construction_age_band = "B" # Act recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) # Assert assert recommendation is None def test_vaulted_ceiling_with_measured_thickness_yields_no_recommendation() -> None: # Arrange — an explicitly-lodged depth means the rafters are already # insulated; the sentinel-aware trigger must not loosen the numeric rule. baseline: EpcPropertyData = build_epc() main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) main.roof_construction_type = "Pitched (vaulted ceiling)" main.roof_insulation_thickness = 100 main.construction_age_band = "B" # Act recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) # Assert assert recommendation is None def test_loft_roof_with_nd_thickness_on_pre_1950_dwelling_yields_loft_insulation() -> None: # Arrange — the loft fallback shares the sentinel semantics (ADR-0047): # "ND" on an age-band-C pitched loft resolves to as-built uninsulated, so # the joist measure fires just as it does for an explicit 0. The lodged # type is the gov-API code-4 string — sentinel resolution needs a KNOWN # pitched roof (an unlodged type could be a party ceiling; see below). baseline: EpcPropertyData = build_epc() main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) main.roof_construction_type = "Pitched (slates/tiles), access to loft" main.roof_insulation_thickness = "ND" main.construction_age_band = "C" # Act recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) # Assert assert recommendation is not None option = recommendation.options[0] assert option.measure_type == "loft_insulation" simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) assert ( _part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 300 ) def test_unlodged_roof_type_with_nd_thickness_yields_no_recommendation() -> None: # Arrange — a part with NO lodged roof type reaching the loft fallback can # be a party ceiling ("(another dwelling above)", gov-API codes 6/7 map the # type to None and real code-7 certs lodge "ND"): zero roof heat loss, so # insulating it contradicts the dwelling. Sentinel resolution therefore # requires a KNOWN pitched/thatched roof; an unlodged type keeps the # explicit-0-only trigger (ADR-0047). baseline: EpcPropertyData = build_epc() main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) main.roof_construction_type = None main.roof_insulation_thickness = "ND" main.construction_age_band = "B" # Act recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) # Assert assert recommendation is None def test_loft_option_carries_cost_from_roof_area_and_product() -> None: # Arrange baseline: EpcPropertyData = build_epc() # MAIN roof area 14.85 m^2 _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0 # Act recommendation: Recommendation | None = recommend_roof_insulation( baseline, _StubProducts() ) # Assert assert recommendation is not None cost = recommendation.options[0].cost assert cost is not None assert abs(cost.total - 14.85 * 30.0) <= 0.01 assert abs(cost.contingency_rate - 0.10) <= 1e-9