Land an override on the EPC's part at its position when the label is absent 🟩

A Landlord Override's building_part is a positional index (0=main, 1=extension
1…, ADR-0004), but the gov-API EPC can label that slot differently (e.g. lodge
the 2nd part as 'other', not 'extension_1'). The previous fix skipped such
orphaned overrides, silently discarding the landlord's correction. Now the
override falls back onto the EPC's part at that position (via _resolve_part), so
the correction lands; only a position the EPC models no part at is skipped
(no geometry to model a wholly-absent part). Replaces the skip-only behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-23 17:09:11 +00:00
parent 3f6f17bb85
commit 9956df07ff
2 changed files with 73 additions and 17 deletions

View file

@ -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):

View file

@ -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: