Historic stable attributes condition cohort selection on the relax ladder 🟥

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-06 08:24:36 +00:00
parent bbfcf1385b
commit 248aa5ad6a
2 changed files with 128 additions and 1 deletions

View file

@ -34,6 +34,12 @@ class PredictionTarget:
# The target Property's own coordinates (resolved from its UPRN), against
# which neighbours are distance-weighted. None disables geo-weighting.
coordinates: Optional[Coordinates] = None
# Stable attributes observed on an expired Historic EPC condition selection
# the same way (ADR-0054): RdSAP Table S1 band letter, modern main_fuel
# code, and a ±5% floor-area band. None leaves each filter inactive.
construction_age_band: Optional[str] = None
main_fuel: Optional[int] = None
total_floor_area_m2: Optional[float] = None
@dataclass(frozen=True)

View file

@ -8,7 +8,12 @@ enough remain, weighted by recency × similarity. Pure domain logic.
from datetime import date
from typing import Optional, Union
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
SapBuildingPart,
SapHeating,
)
from domain.epc_prediction.comparable_properties import (
ComparableProperty,
ComparableProperties,
@ -25,6 +30,9 @@ def _comparable(
wall_construction: Optional[Union[int, str]] = None,
address: Optional[str] = None,
registration_date: Optional[date] = None,
construction_age_band: Optional[str] = None,
main_fuel: Optional[int] = None,
total_floor_area_m2: Optional[float] = None,
) -> ComparableProperty:
"""A ComparableProperty carrying only the fields under test (opaque EpcPropertyData
with property_type / built_form / main wall set the partial-instance idiom)."""
@ -34,7 +42,17 @@ def _comparable(
main: SapBuildingPart = object.__new__(SapBuildingPart)
if wall_construction is not None:
main.wall_construction = wall_construction
if construction_age_band is not None:
main.construction_age_band = construction_age_band
epc.sap_building_parts = [main]
if main_fuel is not None:
detail: MainHeatingDetail = object.__new__(MainHeatingDetail)
detail.main_fuel_type = main_fuel
heating: SapHeating = object.__new__(SapHeating)
heating.main_heating_details = [detail]
epc.sap_heating = heating
if total_floor_area_m2 is not None:
epc.total_floor_area_m2 = total_floor_area_m2
return ComparableProperty(
epc=epc,
certificate_number=certificate_number,
@ -171,3 +189,106 @@ def test_known_wall_override_relaxes_when_too_few_match() -> None:
# Assert — relaxed: all eight houses retained.
assert len(result.members) == 8
def test_historic_age_band_conditions_the_cohort() -> None:
# Arrange — the expired cert observed band C (1930-1949); 5 band-C houses +
# 2 band-G houses in the cohort (ADR-0054).
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", construction_age_band="C"
)
candidates = [
_comparable(
property_type="2", construction_age_band="C", certificate_number=f"C{i}"
)
for i in range(5)
] + [
_comparable(
property_type="2", construction_age_band="G", certificate_number=f"G{i}"
)
for i in range(2)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert — only the same-age-band comparables remain.
assert {c.certificate_number for c in result.members} == {
"C0", "C1", "C2", "C3", "C4"
}
def test_historic_main_fuel_conditions_the_cohort() -> None:
# Arrange — the expired cert observed mains gas (26); 5 gas + 2 electric
# (29) houses in the cohort (ADR-0054).
target = PredictionTarget(postcode="LS6 1AA", property_type="2", main_fuel=26)
candidates = [
_comparable(property_type="2", main_fuel=26, certificate_number=f"G{i}")
for i in range(5)
] + [
_comparable(property_type="2", main_fuel=29, certificate_number=f"E{i}")
for i in range(2)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert
assert {c.certificate_number for c in result.members} == {
"G0", "G1", "G2", "G3", "G4"
}
def test_floor_area_band_keeps_comparables_within_5_percent() -> None:
# Arrange — the expired cert observed 100 m²; the band is ±5% (ADR-0054):
# 96 and 104.9 are in, 90 and 111 are out.
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", total_floor_area_m2=100.0
)
candidates = [
_comparable(property_type="2", total_floor_area_m2=96.0, certificate_number="A"),
_comparable(
property_type="2", total_floor_area_m2=104.9, certificate_number="B"
),
_comparable(property_type="2", total_floor_area_m2=90.0, certificate_number="X"),
_comparable(
property_type="2", total_floor_area_m2=111.0, certificate_number="Y"
),
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=2
)
# Assert
assert {c.certificate_number for c in result.members} == {"A", "B"}
def test_floor_area_band_relaxes_when_too_few_match() -> None:
# Arrange — only one comparable inside the ±5% band (< k=2): the band must
# relax rather than starve the cohort (graceful degradation, ADR-0029).
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", total_floor_area_m2=100.0
)
candidates = [
_comparable(property_type="2", total_floor_area_m2=99.0, certificate_number="A"),
_comparable(
property_type="2", total_floor_area_m2=140.0, certificate_number="X"
),
_comparable(
property_type="2", total_floor_area_m2=150.0, certificate_number="Y"
),
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=2
)
# Assert — relaxed: all three retained.
assert len(result.members) == 3