Prefer a MAIN-bearing prediction template so EPC-less dwellings predict 🟩

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) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-24 13:20:43 +00:00
parent abb822c1c7
commit e2915d8042
3 changed files with 78 additions and 2 deletions

View file

@ -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],

View file

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

View file

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