diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 6a50fde4..848f1a8f 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -9,10 +9,12 @@ then discarded). See ADR-0016. import copy from dataclasses import fields -from typing import Optional, Sequence +from typing import Final, Optional, Sequence from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, EpcPropertyData, + SapBuildingPart, SapVentilation, SapWindow, WindowTransmissionDetails, @@ -29,6 +31,39 @@ from domain.modelling.simulation import ( ) +# A Landlord Override's building part is a POSITIONAL index (0=main, 1=extension +# 1…, ADR-0004), translated to a `BuildingPartIdentifier` upstream. This recovers +# that position so an override can fall back onto the part the EPC actually models +# at that slot when the gov-API labelled it differently (e.g. lodged a 2nd part as +# `other` rather than `extension_1`). +_POSITION_BY_IDENTIFIER: Final[dict[BuildingPartIdentifier, int]] = { + BuildingPartIdentifier.MAIN: 0, + BuildingPartIdentifier.EXTENSION_1: 1, + BuildingPartIdentifier.EXTENSION_2: 2, + BuildingPartIdentifier.EXTENSION_3: 3, + BuildingPartIdentifier.EXTENSION_4: 4, +} + + +def _resolve_part( + epc: EpcPropertyData, + parts_by_id: dict[BuildingPartIdentifier, SapBuildingPart], + identifier: BuildingPartIdentifier, +) -> Optional[SapBuildingPart]: + """The building part an overlay targets: the EPC's part with that identifier + when present, else the part at the override's positional index (so a + correction for `extension_1` still lands on the EPC's 2nd part even when the + gov-API lodged it under a different label). ``None`` when the EPC models no + part at that position.""" + part = parts_by_id.get(identifier) + if part is not None: + return part + position = _POSITION_BY_IDENTIFIER.get(identifier) + if position is None or position >= len(epc.sap_building_parts): + return None + return epc.sap_building_parts[position] + + def apply_simulations( baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] ) -> EpcPropertyData: @@ -41,11 +76,10 @@ def apply_simulations( for simulation in simulations: for identifier, overlay in simulation.building_parts.items(): - # A Landlord Override can target a building part the lodged (or - # predicted) EPC never carried — e.g. an `extension_1` override on a - # property whose EPC has only `main`. Skip the orphaned part rather - # than crashing the whole property's modelling on a `KeyError`. - part = parts_by_id.get(identifier) + part = _resolve_part(result, parts_by_id, identifier) + # No part at this position — the EPC models fewer parts than the + # override's index. We have no geometry to model the missing part, so + # skip it rather than crash the whole property's modelling. if part is None: continue for overlay_field in fields(overlay): diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 2e7059ec..8f6c960f 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -55,12 +55,37 @@ def test_apply_writes_targeted_building_part_and_leaves_others_untouched() -> No ) -def test_override_targeting_a_part_absent_from_the_epc_is_skipped() -> None: - # A Landlord Override can reference a building part the lodged (or predicted) - # EPC never carried — e.g. an `extension_1` override on a property whose EPC - # has only `main`. The orphaned part is skipped, not crashed on, so the rest - # of the overlay still folds and the property still models. - # Arrange — build_epc() has MAIN + EXTENSION_1 but no EXTENSION_2. +def test_override_for_an_absent_semantic_part_lands_on_the_part_at_that_position() -> ( + None +): + # `building_part` is a POSITIONAL index (0=main, 1=extension 1…, ADR-0004). The + # gov-API EPC can label its parts differently (e.g. a 2nd part lodged as `other` + # rather than `extension_1`). An `extension_1` override must still land on the + # part at position 1 — the landlord's correction is applied, not dropped. + # Arrange — build_epc() is [MAIN, EXTENSION_1]; relabel the 2nd part to OTHER so + # the EXTENSION_1 identifier is absent but position 1 still exists. + baseline: EpcPropertyData = build_epc() + baseline.sap_building_parts[1].identifier = BuildingPartIdentifier.OTHER + simulation = EpcSimulation( + building_parts={ + BuildingPartIdentifier.EXTENSION_1: BuildingPartOverlay( + wall_insulation_type=3 + ) + } + ) + + # Act + result: EpcPropertyData = apply_simulations(baseline, [simulation]) + + # Assert — the override folded onto the part at position 1 (the OTHER part). + assert _part(result, BuildingPartIdentifier.OTHER).wall_insulation_type == 3 + + +def test_override_with_no_part_at_that_position_is_skipped() -> None: + # When there is genuinely no part at the override's position (the EPC models + # fewer parts than the index), the override is skipped rather than crashing — + # we cannot model an extension we have no geometry for. + # Arrange — build_epc() has 2 parts (positions 0, 1); position 2 is absent. baseline: EpcPropertyData = build_epc() simulation = EpcSimulation( building_parts={ @@ -74,12 +99,9 @@ def test_override_targeting_a_part_absent_from_the_epc_is_skipped() -> None: # Act result: EpcPropertyData = apply_simulations(baseline, [simulation]) - # Assert — the present part got its overlay; the absent one was not added. + # Assert — the present part got its overlay; nothing was added for position 2. assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == 1 - assert all( - part.identifier is not BuildingPartIdentifier.EXTENSION_2 - for part in result.sap_building_parts - ) + assert len(result.sap_building_parts) == len(baseline.sap_building_parts) def test_flat_roof_construction_type_folds_onto_the_part() -> None: