Merge pull request #1301 from Hestia-Homes/fix/prediction-main-bearing-template

Prefer a MAIN-bearing prediction template so EPC-less dwellings predict
This commit is contained in:
Daniel Roth 2026-06-24 14:35:03 +01:00 committed by GitHub
commit 514cfb5f9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 106 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

@ -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,81 @@ 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_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"