Model/tests/domain/epc_prediction/test_epc_prediction.py
Khalim Conn-Kowlessar 4fa20ae76b fix(epc-prediction): size-representative template selection (ADR-0029)
Template (the comparable whose structure/geometry is copied wholesale)
was members[0] — an arbitrary draw from the API search order. With floor
area varying widely within a property_type cohort (NG71AA houses span
51-340 m2), this made the copied geometry noisy and systematically large.

Pick the member whose floor area is closest to the cohort median instead,
implementing ADR-0029 decision 4's unimplemented "closest on size"
criterion while keeping the structure coherent (it is still one real
property, so floor dims / windows / parts stay internally consistent for
the calculator).

Smoke corpus (29 leave-one-out predictions):
  floor_area  mean|.| 68.0 -> 37.9 m2  (bias +46.8 -> -3.9)
  window_area mean|.| 11.1 -> 7.3 m2
  parts       mean|.| 1.00 -> 0.38
  SAP |pred-calc - calc(actual)| MAE 7.19 -> 4.86

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:05:40 +00:00

119 lines
4.3 KiB
Python

"""Behaviour of EPC Prediction synthesis (ADR-0029): turn the selected
Comparable Properties into a predicted EpcPropertyData. Hybrid — copy a coherent
representative template's structure (building parts, windows, geometry), set the
homogeneous categoricals to the recency-weighted cohort mode, apply Landlord
Overrides on top. Pure domain logic.
"""
from typing import Union
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart
from domain.epc_prediction.comparable_properties import (
Comparable,
ComparableProperties,
PredictionTarget,
)
from domain.epc_prediction.epc_prediction import EpcPrediction
def _epc(
*,
building_parts: int = 1,
floor_area: float = 80.0,
wall_construction: Union[int, str] = 1,
) -> EpcPropertyData:
epc: EpcPropertyData = object.__new__(EpcPropertyData)
epc.property_type = "2"
epc.built_form = "4"
epc.total_floor_area_m2 = floor_area
parts: list[SapBuildingPart] = []
for _ in range(building_parts):
part: SapBuildingPart = object.__new__(SapBuildingPart)
part.wall_construction = wall_construction
parts.append(part)
epc.sap_building_parts = parts
return epc
def _cohort(*epcs: EpcPropertyData) -> ComparableProperties:
return ComparableProperties(
members=tuple(
Comparable(epc=e, certificate_number=str(i)) for i, e in enumerate(epcs)
)
)
def test_predicts_a_picture_by_copying_a_representative_template() -> None:
# Arrange — a single comparable with a distinctive structure (2 building
# parts, 92 m²); with nothing else to go on it is the template.
template = _epc(building_parts=2, floor_area=92.0)
target = PredictionTarget(postcode="LS6 1AA", property_type="2")
# Act
predicted: EpcPropertyData = EpcPrediction().predict(target, _cohort(template))
# Assert — the structure is copied wholesale (and it is a copy, not the same
# object — the baseline must never be mutated).
assert len(predicted.sap_building_parts) == 2
assert predicted.total_floor_area_m2 == 92.0
assert predicted is not template
def test_template_is_the_member_closest_to_the_cohort_median_size() -> None:
# Arrange — the cohort spans a wide range of sizes; members[0] is an atypical
# tiny 20 m² outlier. A single neighbour's geometry is copied wholesale, so
# the template must be the size-representative member (closest to the median),
# not whoever happens to come first (ADR-0029 decision 4: closest on size).
cohort = _cohort(
_epc(floor_area=20.0),
_epc(floor_area=80.0),
_epc(floor_area=200.0),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the 80 m² member (the median) seeds the structure, not the 20 m²
# outlier sitting at members[0].
assert predicted.total_floor_area_m2 == 80.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,
# not the one template, so the prediction is robust to an atypical template.
cohort = _cohort(
_epc(wall_construction=2),
_epc(wall_construction=1),
_epc(wall_construction=1),
_epc(wall_construction=1),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — cavity (the mode) wins over the solid-brick template.
assert predicted.sap_building_parts[0].wall_construction == 1
def test_applies_a_known_wall_override_over_the_mode() -> None:
# Arrange — the cohort mode is cavity (1), but we KNOW the target is solid
# brick (2), a Landlord Override. The known value must win over the estimate.
cohort = _cohort(
_epc(wall_construction=1),
_epc(wall_construction=1),
_epc(wall_construction=1),
)
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", wall_construction=2
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(target, cohort)
# Assert — the known override overrides the cohort mode.
assert predicted.sap_building_parts[0].wall_construction == 2