Model/tests/domain/epc_prediction/test_epc_prediction.py
Khalim Conn-Kowlessar 718455e971 feat(epc-prediction): physical-similarity-weighted categorical mode (#1224)
ADR-0029 decision 5: survivors were treated equally; now each neighbour's
vote in the cohort mode decays with its distance from the cohort's physical
centre (floor area from the median, age band from the modal band), so the
mode leans on the most representative neighbours instead of being swayed by
size/era outliers. Scales (size 20 m^2, age weight 0.5) chosen on the
validation corpus; the tight size kernel is load-bearing (looser scales
regress floor_insulation on the fixture).

Corpus (181 SAP-10.2 targets): wall_insulation 83.4->86.2%,
roof_construction 86.2->87.3%, floor_construction 78.8->81.2%,
floor_insulation 92.9->94.1%; net +7.5pp gained vs -1.1pp (two 1-cert dips,
both held on the fixture). Geometry/residuals untouched (template unchanged).

Gate (36-target fixture): zero regression across all 24 floors/ceilings;
ratcheted wall_insulation_type 0.7778->0.8333, floor_construction
0.7500->0.8125, floor_insulation 0.9062->0.9375. Dead _mode/_int_mode
removed (superseded by the weighted variants).

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

344 lines
13 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 datetime import date
from typing import Optional, Union
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
SapBuildingPart,
SapFloorDimension,
SapWindow,
)
from domain.epc_prediction.comparable_properties import (
Comparable,
ComparableProperties,
PredictionTarget,
)
from domain.epc_prediction.epc_prediction import (
EpcPrediction,
PredictionConfidence,
)
def _epc(
*,
building_parts: int = 1,
floor_area: float = 80.0,
wall_construction: Union[int, str] = 1,
wall_insulation_type: Union[int, str] = 1,
construction_age_band: str = "K",
roof_construction: Optional[int] = 1,
roof_insulation_thickness: Optional[Union[str, int]] = 100,
floor_construction: Optional[int] = 1,
floor_insulation: Optional[int] = 1,
glazing_type: Union[int, str] = 3,
) -> 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
part.wall_insulation_type = wall_insulation_type
part.construction_age_band = construction_age_band
part.roof_construction = roof_construction
part.roof_insulation_thickness = roof_insulation_thickness
floor_dim: SapFloorDimension = object.__new__(SapFloorDimension)
floor_dim.floor_construction = floor_construction
floor_dim.floor_insulation = floor_insulation
part.sap_floor_dimensions = [floor_dim]
parts.append(part)
epc.sap_building_parts = parts
window: SapWindow = object.__new__(SapWindow)
window.window_width = 1.0
window.window_height = 1.0
window.glazing_type = glazing_type
epc.sap_windows = [window]
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 _dated_cohort(
*dated: tuple[EpcPropertyData, date],
) -> ComparableProperties:
return ComparableProperties(
members=tuple(
Comparable(epc=e, certificate_number=str(i), registration_date=d)
for i, (e, d) in enumerate(dated)
)
)
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_sets_the_other_homogeneous_categoricals_to_the_cohort_mode() -> None:
# Arrange — the median-size template (members[0], 80 m²) is an atypical
# outlier on every categorical; the cohort majority disagrees. Age band,
# wall insulation, roof construction and floor construction are all
# homogeneous categoricals, so each should follow its mode, not the one
# template (ADR-0029 decision 4).
cohort = _cohort(
_epc(
floor_area=80.0,
construction_age_band="A",
wall_insulation_type=9,
roof_construction=7,
floor_construction=7,
),
_epc(
construction_age_band="K",
wall_insulation_type=1,
roof_construction=2,
floor_construction=3,
),
_epc(
construction_age_band="K",
wall_insulation_type=1,
roof_construction=2,
floor_construction=3,
),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — every categorical follows the cohort mode over the outlier
# template.
main = predicted.sap_building_parts[0]
assert main.construction_age_band == "K"
assert main.wall_insulation_type == 1
assert main.roof_construction == 2
assert main.sap_floor_dimensions[0].floor_construction == 3
def test_modes_roof_and_floor_insulation() -> None:
# Arrange — the median-size template (members[0]) is an outlier on roof
# insulation thickness and floor insulation; the cohort majority disagrees.
# These are independent fabric categoricals, so each should follow its
# cohort mode like the construction categoricals do.
cohort = _cohort(
_epc(floor_area=80.0, roof_insulation_thickness=25, floor_insulation=9),
_epc(roof_insulation_thickness=300, floor_insulation=2),
_epc(roof_insulation_thickness=300, floor_insulation=2),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — each follows the cohort mode over the outlier template.
main = predicted.sap_building_parts[0]
assert main.roof_insulation_thickness == 300
assert main.sap_floor_dimensions[0].floor_insulation == 2
def test_recency_weights_roof_insulation_mode() -> None:
# Arrange — an old majority (three 2015 certs at 100 mm) and a recent
# minority (two 2025 certs at 300 mm). Roof insulation is topped up over
# time, so the recent neighbours reflect the current state: the recency-
# weighted mode must pick 300 over the plain-majority 100.
cohort = _dated_cohort(
(_epc(roof_insulation_thickness=100), date(2015, 1, 1)),
(_epc(roof_insulation_thickness=100), date(2015, 1, 1)),
(_epc(roof_insulation_thickness=100), date(2015, 1, 1)),
(_epc(roof_insulation_thickness=300), date(2025, 1, 1)),
(_epc(roof_insulation_thickness=300), date(2025, 1, 1)),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — recency overrides the stale majority.
assert predicted.sap_building_parts[0].roof_insulation_thickness == 300
def test_categorical_mode_leans_on_size_similar_neighbours() -> None:
# Arrange — a count majority (three) carries wall-insulation 9, but two of
# them are 400 m² size outliers; the cohort centre (median 100 m²) holds
# wall-insulation 1. Physical-similarity weighting down-weights the outliers,
# so the size-representative value 1 wins over the plain-count majority 9.
cohort = _cohort(
_epc(floor_area=100.0, wall_insulation_type=1),
_epc(floor_area=100.0, wall_insulation_type=1),
_epc(floor_area=100.0, wall_insulation_type=9),
_epc(floor_area=400.0, wall_insulation_type=9),
_epc(floor_area=400.0, wall_insulation_type=9),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the size-similar value wins over the outlier-driven majority.
assert predicted.sap_building_parts[0].wall_insulation_type == 1
def test_categorical_mode_leans_on_age_similar_neighbours() -> None:
# Arrange — same size throughout (so size weighting is neutral). A count
# majority (three) carries wall-insulation 9, but two of them are age-band A
# outliers while the cohort's modal band is K. Age-similarity weighting
# down-weights the outliers, so the band-representative value 1 wins.
cohort = _cohort(
_epc(construction_age_band="K", wall_insulation_type=1),
_epc(construction_age_band="K", wall_insulation_type=1),
_epc(construction_age_band="K", wall_insulation_type=9),
_epc(construction_age_band="A", wall_insulation_type=9),
_epc(construction_age_band="A", wall_insulation_type=9),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the age-similar value wins over the outlier-driven majority.
assert predicted.sap_building_parts[0].wall_insulation_type == 1
def test_confidence_reports_cohort_size_and_unanimous_agreement() -> None:
# Arrange — a unanimous cohort: three neighbours, all cavity-walled (1).
cohort = _cohort(
_epc(wall_construction=1),
_epc(wall_construction=1),
_epc(wall_construction=1),
)
# Act
confidence: PredictionConfidence = EpcPrediction().confidence(cohort)
# Assert — three neighbours, total agreement on the wall construction.
assert confidence.cohort_size == 3
assert confidence.agreement("wall_construction") == 1.0
def test_confidence_agreement_is_the_modal_share_of_the_cohort() -> None:
# Arrange — three of four neighbours are cavity (1), one is solid brick (2),
# so the cohort is split on the wall construction.
cohort = _cohort(
_epc(wall_construction=1),
_epc(wall_construction=1),
_epc(wall_construction=1),
_epc(wall_construction=2),
)
# Act
confidence: PredictionConfidence = EpcPrediction().confidence(cohort)
# Assert — agreement is the modal value's share of the cohort: 3 of 4.
share: Optional[float] = confidence.agreement("wall_construction")
assert share is not None
assert abs(share - 0.75) <= 1e-9
def test_confidence_excludes_absent_component_values_from_the_denominator() -> None:
# Arrange — two neighbours lodge a roof construction (both code 2); one lodges
# none. The missing value must not dilute the agreement to 2/3.
cohort = _cohort(
_epc(roof_construction=2),
_epc(roof_construction=2),
_epc(roof_construction=None),
)
# Act
confidence: PredictionConfidence = EpcPrediction().confidence(cohort)
# Assert — agreement counts only the two present, unanimous values (1.0),
# while the cohort size still reflects all three neighbours.
share: Optional[float] = confidence.agreement("roof_construction")
assert share is not None
assert abs(share - 1.0) <= 1e-9
assert confidence.cohort_size == 3
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