diff --git a/domain/epc/wall_type_overlay.py b/domain/epc/wall_type_overlay.py new file mode 100644 index 00000000..edcb429a --- /dev/null +++ b/domain/epc/wall_type_overlay.py @@ -0,0 +1,60 @@ +"""Map a Landlord-Override `WallType` value to a wall Simulation Overlay (ADR-0032). + +A `WallType` value is one full EPC wall description — *material* (cavity, solid +brick, …) combined with *insulation state* (as built / with internal insulation +/ filled cavity / …). The calculator scores the wall from the RdSAP +`wall_construction` (material) and `wall_insulation_type` (state) **int codes**, +never the description string, so the overlay decomposes the value into those two +codes and emits an `EpcSimulation` targeting the override's building part. The +result folds onto the lodged EPC via `apply_simulations`, exactly as a wall +Measure's overlay does. Unresolvable material/state → None (no overlay). +""" + +from __future__ import annotations + +from typing import Optional + +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier +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, + "Solid brick": 3, +} + +# RdSAP `wall_insulation_type` codes by insulation-state suffix +# (domain/sap10_ml/rdsap_uvalues.py): external 1, filled-cavity 2, internal 3, +# as-built/uninsulated 4, cavity+external 6, cavity+internal 7. +_STATE_INSULATION: dict[str, int] = { + "as built, no insulation (assumed)": 4, + "with internal insulation": 3, + "with external insulation": 1, + "filled cavity": 2, + "filled cavity and internal insulation": 7, + "filled cavity and external insulation": 6, +} + + +def wall_overlay_for( + wall_type_value: str, building_part: int +) -> Optional[EpcSimulation]: + material, _, state = wall_type_value.partition(", ") + construction = _MATERIAL_CONSTRUCTION.get(material) + insulation = _STATE_INSULATION.get(state) + if construction is None or insulation is None: + return None + + identifier = ( + BuildingPartIdentifier.MAIN + if building_part == 0 + else BuildingPartIdentifier.extension(building_part) + ) + return EpcSimulation( + building_parts={ + identifier: BuildingPartOverlay( + wall_construction=construction, + wall_insulation_type=insulation, + ) + } + ) diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 7d951ac5..c4708e7a 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -25,6 +25,10 @@ class BuildingPartOverlay: A `None` field means "leave the baseline value unchanged". """ + # The wall material (RdSAP `wall_construction` code). Left `None` by Measures + # — insulating a wall doesn't change its material — but set by a Landlord + # Override that corrects the construction itself (ADR-0032). + wall_construction: Optional[int] = None wall_insulation_type: Optional[int] = None # Added solid-wall insulation depth (mm) — drives the calculator's Table 6 # bucket / §5.8 documentary U-value for EWI (`wall_insulation_type=1`) and diff --git a/tests/domain/epc/test_wall_type_overlay.py b/tests/domain/epc/test_wall_type_overlay.py new file mode 100644 index 00000000..92c7a1f8 --- /dev/null +++ b/tests/domain/epc/test_wall_type_overlay.py @@ -0,0 +1,72 @@ +"""The Landlord-Override `WallType` → wall Simulation Overlay mapping (ADR-0032). + +A `WallType` value decomposes into the RdSAP `wall_construction` (material) and +`wall_insulation_type` (state) int codes the calculator reads; the overlay +targets the override's building part. Unresolvable values produce no overlay. +""" + +from __future__ import annotations + +import pytest + +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier +from domain.epc.wall_type_overlay import wall_overlay_for + + +def test_solid_brick_with_internal_insulation_overlays_main_wall() -> None: + # Act + simulation = wall_overlay_for("Solid brick, with internal insulation", 0) + + # Assert — solid brick (wall_construction 3) + internal insulation (type 3) + # on the main building part. + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + assert overlay.wall_construction == 3 + assert overlay.wall_insulation_type == 3 + + +@pytest.mark.parametrize( + ("wall_type_value", "construction", "insulation"), + [ + ("Cavity wall, as built, no insulation (assumed)", 4, 4), + ("Cavity wall, with internal insulation", 4, 3), + ("Cavity wall, with external insulation", 4, 1), + ("Cavity wall, filled cavity", 4, 2), + ("Cavity wall, filled cavity and internal insulation", 4, 7), + ("Cavity wall, filled cavity and external insulation", 4, 6), + ("Solid brick, as built, no insulation (assumed)", 3, 4), + ("Solid brick, with external insulation", 3, 1), + ], +) +def test_material_and_state_decompose_to_their_gov_codes( + wall_type_value: str, construction: int, insulation: int +) -> None: + # Act + simulation = wall_overlay_for(wall_type_value, 0) + + # Assert + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + assert overlay.wall_construction == construction + assert overlay.wall_insulation_type == insulation + + +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) + + # Assert + assert simulation is not None + assert BuildingPartIdentifier.EXTENSION_1 in simulation.building_parts + + +@pytest.mark.parametrize( + "wall_type_value", + ["Unknown", "Granite or whin, as built, no insulation (assumed)", ""], +) +def test_unresolvable_wall_type_produces_no_overlay(wall_type_value: str) -> None: + # Act + simulation = wall_overlay_for(wall_type_value, 0) + + # Assert + assert simulation is None