mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +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 typing import Callable, Iterable, Optional, Union
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
MainHeatingDetail,
|
MainHeatingDetail,
|
||||||
SapBuildingPart,
|
SapBuildingPart,
|
||||||
|
|
@ -157,13 +158,27 @@ class EpcPrediction:
|
||||||
the member whose floor area is closest to the cohort median. A single
|
the member whose floor area is closest to the cohort median. A single
|
||||||
neighbour's geometry is copied wholesale, so a size-representative
|
neighbour's geometry is copied wholesale, so a size-representative
|
||||||
template keeps the prediction off the cohort's size outliers (ADR-0029
|
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
|
members: tuple[ComparableProperty, ...] = comparables.members
|
||||||
median_area: float = statistics.median(
|
median_area: float = statistics.median(
|
||||||
c.epc.total_floor_area_m2 for c in members
|
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(
|
return min(
|
||||||
members,
|
candidates,
|
||||||
key=lambda c: abs(c.epc.total_floor_area_m2 - median_area),
|
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
|
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(
|
def _geo_weighted_floor_area(
|
||||||
members: tuple[ComparableProperty, ...],
|
members: tuple[ComparableProperty, ...],
|
||||||
target_coordinates: Optional[Coordinates],
|
target_coordinates: Optional[Coordinates],
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from datetime import date
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
MainHeatingDetail,
|
MainHeatingDetail,
|
||||||
SapBuildingPart,
|
SapBuildingPart,
|
||||||
|
|
@ -32,6 +33,7 @@ from domain.epc_prediction.prediction_target import PredictionTarget
|
||||||
def _epc(
|
def _epc(
|
||||||
*,
|
*,
|
||||||
building_parts: int = 1,
|
building_parts: int = 1,
|
||||||
|
identifier: BuildingPartIdentifier = BuildingPartIdentifier.MAIN,
|
||||||
floor_area: float = 80.0,
|
floor_area: float = 80.0,
|
||||||
wall_construction: Union[int, str] = 1,
|
wall_construction: Union[int, str] = 1,
|
||||||
wall_insulation_type: Union[int, str] = 1,
|
wall_insulation_type: Union[int, str] = 1,
|
||||||
|
|
@ -57,6 +59,7 @@ def _epc(
|
||||||
parts: list[SapBuildingPart] = []
|
parts: list[SapBuildingPart] = []
|
||||||
for _ in range(building_parts):
|
for _ in range(building_parts):
|
||||||
part: SapBuildingPart = object.__new__(SapBuildingPart)
|
part: SapBuildingPart = object.__new__(SapBuildingPart)
|
||||||
|
part.identifier = identifier
|
||||||
part.wall_construction = wall_construction
|
part.wall_construction = wall_construction
|
||||||
part.wall_insulation_type = wall_insulation_type
|
part.wall_insulation_type = wall_insulation_type
|
||||||
part.construction_age_band = construction_age_band
|
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
|
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:
|
def test_sets_main_wall_construction_to_the_cohort_mode() -> None:
|
||||||
# Arrange — the template (members[0]) is solid brick (2), but the cohort
|
# Arrange — the template (members[0]) is solid brick (2), but the cohort
|
||||||
# majority is cavity (1). The homogeneous categorical should follow the mode,
|
# 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 typing import Optional, Union
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
MainHeatingDetail,
|
MainHeatingDetail,
|
||||||
SapBuildingPart,
|
SapBuildingPart,
|
||||||
|
|
@ -39,6 +40,7 @@ def _comparable(
|
||||||
epc.solar_water_heating = False
|
epc.solar_water_heating = False
|
||||||
epc.has_hot_water_cylinder = True
|
epc.has_hot_water_cylinder = True
|
||||||
part: SapBuildingPart = object.__new__(SapBuildingPart)
|
part: SapBuildingPart = object.__new__(SapBuildingPart)
|
||||||
|
part.identifier = BuildingPartIdentifier.MAIN
|
||||||
part.wall_construction = wall_construction
|
part.wall_construction = wall_construction
|
||||||
part.wall_insulation_type = 1
|
part.wall_insulation_type = 1
|
||||||
part.construction_age_band = "K"
|
part.construction_age_band = "K"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue