mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
abb822c1c7
commit
e2915d8042
3 changed files with 78 additions and 2 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue