mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
commit
514cfb5f9b
3 changed files with 106 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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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