From abb822c1c78e4079eb5c3570dbd72c9b2b7f0060 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 24 Jun 2026 13:16:54 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Prefer=20a=20MAIN-bearing=20prediction=20te?= =?UTF-8?q?mplate=20so=20EPC-less=20dwellings=20predict=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc_prediction/test_epc_prediction.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/domain/epc_prediction/test_epc_prediction.py b/tests/domain/epc_prediction/test_epc_prediction.py index 00a6ce57..17ea7400 100644 --- a/tests/domain/epc_prediction/test_epc_prediction.py +++ b/tests/domain/epc_prediction/test_epc_prediction.py @@ -9,6 +9,7 @@ from datetime import date from typing import Optional, Union from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, EpcPropertyData, MainHeatingDetail, SapBuildingPart, @@ -32,6 +33,7 @@ from domain.epc_prediction.prediction_target import PredictionTarget def _epc( *, building_parts: int = 1, + identifier: BuildingPartIdentifier = BuildingPartIdentifier.MAIN, floor_area: float = 80.0, wall_construction: Union[int, str] = 1, wall_insulation_type: Union[int, str] = 1, @@ -57,6 +59,7 @@ def _epc( parts: list[SapBuildingPart] = [] for _ in range(building_parts): part: SapBuildingPart = object.__new__(SapBuildingPart) + part.identifier = identifier part.wall_construction = wall_construction part.wall_insulation_type = wall_insulation_type part.construction_age_band = construction_age_band @@ -148,6 +151,31 @@ def test_template_is_the_member_closest_to_the_cohort_median_size() -> None: assert predicted.total_floor_area_m2 == 80.0 +def test_template_skips_a_main_less_member_so_the_prediction_has_a_main_part() -> None: + # Arrange — the size-median member is OTHER-only (the gov API lodged its part + # with a null identifier), but the cohort holds MAIN-bearing neighbours at + # other sizes. The structural template must be a MAIN-bearing member so the + # predicted dwelling presents a main dwelling — else the modelling handler's + # MAIN-part guard rejects an otherwise-rich cohort as "not predictable". + cohort = _cohort( + _epc(floor_area=80.0, identifier=BuildingPartIdentifier.OTHER), + _epc(floor_area=30.0, identifier=BuildingPartIdentifier.MAIN), + _epc(floor_area=200.0, identifier=BuildingPartIdentifier.MAIN), + ) + + # Act + predicted: EpcPropertyData = EpcPrediction().predict( + PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort + ) + + # Assert — the predicted dwelling has a MAIN part (seeded from a MAIN-bearing + # neighbour), not the OTHER-only median member. + assert any( + part.identifier is BuildingPartIdentifier.MAIN + for part in predicted.sap_building_parts + ) + + def test_sets_main_wall_construction_to_the_cohort_mode() -> None: # Arrange — the template (members[0]) is solid brick (2), but the cohort # majority is cavity (1). The homogeneous categorical should follow the mode, From e2915d8042bd240a3a64bc43046d808f3ec13a73 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 24 Jun 2026 13:20:43 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Prefer=20a=20MAIN-bearing=20prediction=20te?= =?UTF-8?q?mplate=20so=20EPC-less=20dwellings=20predict=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A predicted EPC is seeded by deep-copying one representative neighbour's structure. _template chose the member whose floor area was closest to the cohort median, ignoring building-part labels. When that member's only part was lodged with a null identifier (mapped to OTHER), the prediction had no MAIN part and the modelling_e2e handler rejected it as "not predictable" — discarding an otherwise-rich same-type cohort. Restrict the template to MAIN-bearing members (median still over the whole cohort); fall back to closest-on-size only when none are MAIN-bearing, so an all-unlabelled cohort is left for the handler's MAIN-part guard to reject rather than silently relabelling real data. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc_prediction/epc_prediction.py | 28 ++++++++++- .../epc_prediction/test_epc_prediction.py | 50 +++++++++++++++++++ .../domain/epc_prediction/test_validation.py | 2 + 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/domain/epc_prediction/epc_prediction.py b/domain/epc_prediction/epc_prediction.py index 59de4201..4261df6d 100644 --- a/domain/epc_prediction/epc_prediction.py +++ b/domain/epc_prediction/epc_prediction.py @@ -18,6 +18,7 @@ from datetime import date from typing import Callable, Iterable, Optional, Union from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, EpcPropertyData, MainHeatingDetail, SapBuildingPart, @@ -157,13 +158,27 @@ class EpcPrediction: the member whose floor area is closest to the cohort median. A single neighbour's geometry is copied wholesale, so a size-representative template keeps the prediction off the cohort's size outliers (ADR-0029 - decision 4: closest on size).""" + decision 4: closest on size). + + The template must also present a MAIN building part: its structure is + copied wholesale, so seeding from a member whose only part is OTHER (the + gov API lodged its identifier as null) gives a prediction with no main + dwelling — which the modelling handler then rejects as not-predictable, + discarding an otherwise-rich cohort. Candidates are therefore restricted + to the MAIN-bearing members; the median is still taken over the whole + cohort (the size centre is a property of the cohort, not the template + pool). When no member is MAIN-bearing the whole same-type cohort is + unlabelled, so the closest-on-size fallback is left for that guard to + reject rather than silently relabelling real data.""" members: tuple[ComparableProperty, ...] = comparables.members median_area: float = statistics.median( c.epc.total_floor_area_m2 for c in members ) + candidates: list[ComparableProperty] = [ + c for c in members if _has_main_part(c) + ] or list(members) return min( - members, + candidates, key=lambda c: abs(c.epc.total_floor_area_m2 - median_area), ) @@ -303,6 +318,15 @@ def _main_floor_attr(comparable: ComparableProperty, attr: str) -> Optional[int] return value +def _has_main_part(comparable: ComparableProperty) -> bool: + """Whether a comparable carries a MAIN building part — i.e. it can seed a + prediction that presents a main dwelling (see `EpcPrediction._template`).""" + return any( + part.identifier is BuildingPartIdentifier.MAIN + for part in comparable.epc.sap_building_parts + ) + + def _geo_weighted_floor_area( members: tuple[ComparableProperty, ...], target_coordinates: Optional[Coordinates], diff --git a/tests/domain/epc_prediction/test_epc_prediction.py b/tests/domain/epc_prediction/test_epc_prediction.py index 17ea7400..6cbb3de4 100644 --- a/tests/domain/epc_prediction/test_epc_prediction.py +++ b/tests/domain/epc_prediction/test_epc_prediction.py @@ -176,6 +176,56 @@ def test_template_skips_a_main_less_member_so_the_prediction_has_a_main_part() - ) +def test_an_all_other_cohort_predicts_without_a_main_part() -> None: + # Arrange — every member is OTHER-only (the whole same-type cohort was lodged + # with null identifiers). There is no MAIN-bearing template to seed from, so + # the prediction must NOT silently relabel real data; it falls back to the + # size-closest member and yields a MAIN-less picture, which the modelling + # handler then rejects as not-predictable (the honest "fail" decision). + cohort = _cohort( + _epc(floor_area=80.0, identifier=BuildingPartIdentifier.OTHER), + _epc(floor_area=82.0, identifier=BuildingPartIdentifier.OTHER), + ) + + # Act + predicted: EpcPropertyData = EpcPrediction().predict( + PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort + ) + + # Assert — no MAIN part is conjured; the picture stays as lodged. + assert not any( + part.identifier is BuildingPartIdentifier.MAIN + for part in predicted.sap_building_parts + ) + + +def test_template_is_the_size_closest_member_among_the_main_bearing_ones() -> None: + # Arrange — the size-median member is OTHER-only (80 m²). Of the MAIN-bearing + # members, the 78 m² one (distinctively a 2-part structure) is nearest the + # cohort median; the 30 m² one is far. The template must be the size-closest + # *MAIN-bearing* member, while the cohort median stays a property of the whole + # cohort (78 m², not the 54 m² median of just the MAIN-bearing subset). + cohort = _cohort( + _epc(floor_area=80.0, identifier=BuildingPartIdentifier.OTHER), + _epc( + floor_area=78.0, + identifier=BuildingPartIdentifier.MAIN, + building_parts=2, + ), + _epc(floor_area=30.0, identifier=BuildingPartIdentifier.MAIN), + ) + + # Act + predicted: EpcPropertyData = EpcPrediction().predict( + PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort + ) + + # Assert — structure copied from the 78 m² MAIN-bearing member (its 2 parts), + # and the size estimate is the full-cohort median (78 m²), not the subset's. + assert len(predicted.sap_building_parts) == 2 + assert predicted.total_floor_area_m2 == 78.0 + + def test_sets_main_wall_construction_to_the_cohort_mode() -> None: # Arrange — the template (members[0]) is solid brick (2), but the cohort # majority is cavity (1). The homogeneous categorical should follow the mode, diff --git a/tests/domain/epc_prediction/test_validation.py b/tests/domain/epc_prediction/test_validation.py index e335eb8b..2cdad977 100644 --- a/tests/domain/epc_prediction/test_validation.py +++ b/tests/domain/epc_prediction/test_validation.py @@ -8,6 +8,7 @@ from datetime import date from typing import Optional, Union from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, EpcPropertyData, MainHeatingDetail, SapBuildingPart, @@ -39,6 +40,7 @@ def _comparable( epc.solar_water_heating = False epc.has_hot_water_cylinder = True part: SapBuildingPart = object.__new__(SapBuildingPart) + part.identifier = BuildingPartIdentifier.MAIN part.wall_construction = wall_construction part.wall_insulation_type = 1 part.construction_age_band = "K"