diff --git a/domain/epc/roof_type_overlay.py b/domain/epc/roof_type_overlay.py new file mode 100644 index 00000000..88837988 --- /dev/null +++ b/domain/epc/roof_type_overlay.py @@ -0,0 +1,41 @@ +"""Map a Landlord-Override `RoofType` value to a roof Simulation Overlay (ADR-0032). + +The calculator derives the roof U-value from the building part's loft-insulation +depth, so a `roof_type` override moves the score only via +`BuildingPartOverlay.roof_insulation_thickness` (mm). The resolvable family is +the explicit `"Pitched, N mm loft insulation"` values — N is parsed out. +Everything else (flat roofs, room-in-roof, "Unknown loft insulation", +"Another Premises Above" — a flat with a dwelling above, no roof to insulate) has +no clean loft depth, so it produces no overlay. +""" + +from __future__ import annotations + +import re +from typing import Optional + +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation + +_LOFT_MM = re.compile(r"(\d+)\+?\s*mm loft insulation") + + +def roof_overlay_for( + roof_type_value: str, building_part: int +) -> Optional[EpcSimulation]: + match = _LOFT_MM.search(roof_type_value) + if match is None: + return None + + identifier = ( + BuildingPartIdentifier.MAIN + if building_part == 0 + else BuildingPartIdentifier.extension(building_part) + ) + return EpcSimulation( + building_parts={ + identifier: BuildingPartOverlay( + roof_insulation_thickness=int(match.group(1)) + ) + } + ) diff --git a/domain/epc/wall_type_overlay.py b/domain/epc/wall_type_overlay.py index edcb429a..63ac6aa0 100644 --- a/domain/epc/wall_type_overlay.py +++ b/domain/epc/wall_type_overlay.py @@ -19,8 +19,16 @@ from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation # RdSAP `wall_construction` codes by material prefix (domain/sap10_ml/rdsap_uvalues.py). _MATERIAL_CONSTRUCTION: dict[str, int] = { - "Cavity wall": 4, + "Granite or whin": 1, + "Sandstone": 2, "Solid brick": 3, + "Cavity wall": 4, + "Timber frame": 5, + "System built": 6, + "Cob": 7, + "Park home wall": 8, + "Curtain wall": 9, + "Curtain Wall": 9, } # RdSAP `wall_insulation_type` codes by insulation-state suffix diff --git a/repositories/property/landlord_override_overlays.py b/repositories/property/landlord_override_overlays.py index 3f73163c..dc998e04 100644 --- a/repositories/property/landlord_override_overlays.py +++ b/repositories/property/landlord_override_overlays.py @@ -10,18 +10,29 @@ override produces an overlay only where a component mapping exists and resolves from __future__ import annotations +from typing import Callable, Optional + +from domain.epc.roof_type_overlay import roof_overlay_for from domain.epc.wall_type_overlay import wall_overlay_for from domain.modelling.simulation import EpcSimulation from repositories.property.property_overrides_reader import ResolvedPropertyOverrides -_WALL_TYPE = "wall_type" +# Each fabric component maps its override value (+ building part) to an overlay, +# or None when the value isn't resolvable. property_type / built_form_type carry +# no SAP weight at the EpcPropertyData layer (ADR-0032), so they have no mapper. +_COMPONENT_OVERLAYS: dict[str, Callable[[str, int], Optional[EpcSimulation]]] = { + "wall_type": wall_overlay_for, + "roof_type": roof_overlay_for, +} def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]: overlays: list[EpcSimulation] = [] for row in overrides.rows: - if row.override_component == _WALL_TYPE: - overlay = wall_overlay_for(row.override_value, row.building_part) - if overlay is not None: - overlays.append(overlay) + mapper = _COMPONENT_OVERLAYS.get(row.override_component) + if mapper is None: + continue + overlay = mapper(row.override_value, row.building_part) + if overlay is not None: + overlays.append(overlay) return overlays diff --git a/tests/domain/epc/test_roof_type_overlay.py b/tests/domain/epc/test_roof_type_overlay.py new file mode 100644 index 00000000..b2ca85a1 --- /dev/null +++ b/tests/domain/epc/test_roof_type_overlay.py @@ -0,0 +1,61 @@ +"""The Landlord-Override `RoofType` → roof Simulation Overlay mapping (ADR-0032). + +Only the explicit `"Pitched, N mm loft insulation"` family resolves — its loft +depth maps to `roof_insulation_thickness`. Roofs with no clean loft depth +(flat, room-in-roof, "Unknown", a dwelling above) produce no overlay. +""" + +from __future__ import annotations + +import pytest + +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier +from domain.epc.roof_type_overlay import roof_overlay_for + + +def test_pitched_loft_depth_maps_to_roof_insulation_thickness() -> None: + # Act + simulation = roof_overlay_for("Pitched, 300 mm loft insulation", 0) + + # Assert + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + assert overlay.roof_insulation_thickness == 300 + + +@pytest.mark.parametrize( + ("roof_type_value", "expected_mm"), + [ + ("Pitched, 75 mm loft insulation", 75), + ("Pitched, 0 mm loft insulation", 0), + ("Pitched, 400+ mm loft insulation", 400), + ], +) +def test_each_loft_depth_is_parsed(roof_type_value: str, expected_mm: int) -> None: + # Act + simulation = roof_overlay_for(roof_type_value, 0) + + # Assert + assert simulation is not None + assert simulation.building_parts[ + BuildingPartIdentifier.MAIN + ].roof_insulation_thickness == expected_mm + + +@pytest.mark.parametrize( + "roof_type_value", + [ + "Another Premises Above", + "Pitched, Unknown loft insulation", + "Flat, insulated", + "", + ], +) +def test_roof_without_a_clean_loft_depth_produces_no_overlay( + roof_type_value: str, +) -> None: + # Act + simulation = roof_overlay_for(roof_type_value, 0) + + # Assert + assert simulation is None diff --git a/tests/domain/epc/test_wall_type_overlay.py b/tests/domain/epc/test_wall_type_overlay.py index 92c7a1f8..4fe0c320 100644 --- a/tests/domain/epc/test_wall_type_overlay.py +++ b/tests/domain/epc/test_wall_type_overlay.py @@ -51,6 +51,29 @@ def test_material_and_state_decompose_to_their_gov_codes( assert overlay.wall_insulation_type == insulation +@pytest.mark.parametrize( + ("wall_type_value", "construction"), + [ + ("Timber frame, as built, no insulation (assumed)", 5), + ("Granite or whin, as built, no insulation (assumed)", 1), + ("Sandstone, as built, no insulation (assumed)", 2), + ("System built, as built, no insulation (assumed)", 6), + ("Cob, with internal insulation", 7), + ], +) +def test_more_wall_materials_decompose_to_their_construction_code( + wall_type_value: str, construction: int +) -> None: + # Act + simulation = wall_overlay_for(wall_type_value, 0) + + # Assert + assert simulation is not None + assert simulation.building_parts[BuildingPartIdentifier.MAIN].wall_construction == ( + construction + ) + + def test_overlay_targets_the_extension_building_part() -> None: # Act — building_part 1 is the first extension. simulation = wall_overlay_for("Solid brick, with internal insulation", 1) @@ -62,7 +85,13 @@ def test_overlay_targets_the_extension_building_part() -> None: @pytest.mark.parametrize( "wall_type_value", - ["Unknown", "Granite or whin, as built, no insulation (assumed)", ""], + [ + "Unknown", + # material maps, but the "(assumed) insulated" state is deferred (ADR-0032 + # — its wall_insulation_type code needs Elmhurst validation), so still None. + "Solid brick, as built, insulated (assumed)", + "", + ], ) def test_unresolvable_wall_type_produces_no_overlay(wall_type_value: str) -> None: # Act diff --git a/tests/repositories/property/test_landlord_override_overlays.py b/tests/repositories/property/test_landlord_override_overlays.py new file mode 100644 index 00000000..a2eb695b --- /dev/null +++ b/tests/repositories/property/test_landlord_override_overlays.py @@ -0,0 +1,46 @@ +"""Mapping resolved overrides → Simulation Overlays (ADR-0032). + +`overlays_from` turns the faithful value-space snapshot into the domain overlays +that fold onto the lodged EPC — per component, partial, skipping unmapped rows. +""" + +from __future__ import annotations + +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier +from repositories.property.landlord_override_overlays import overlays_from +from repositories.property.property_overrides_reader import ( + ResolvedPropertyOverride, + ResolvedPropertyOverrides, +) + + +def test_roof_type_row_produces_a_roof_overlay() -> None: + # Arrange + overrides = ResolvedPropertyOverrides( + rows=(ResolvedPropertyOverride("roof_type", 0, "Pitched, 300 mm loft insulation"),) + ) + + # Act + overlays = overlays_from(overrides) + + # Assert + assert len(overlays) == 1 + main = overlays[0].building_parts[BuildingPartIdentifier.MAIN] + assert main.roof_insulation_thickness == 300 + + +def test_wall_and_roof_rows_each_produce_an_overlay() -> None: + # Arrange + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride("wall_type", 0, "Solid brick, with internal insulation"), + ResolvedPropertyOverride("roof_type", 0, "Pitched, 300 mm loft insulation"), + ResolvedPropertyOverride("property_type", 0, "House"), # not overlaid + ) + ) + + # Act + overlays = overlays_from(overrides) + + # Assert — wall + roof map; property_type is skipped. + assert len(overlays) == 2