diff --git a/domain/epc_prediction/prediction_target.py b/domain/epc_prediction/prediction_target.py index faa5bd35f..93a91fc6e 100644 --- a/domain/epc_prediction/prediction_target.py +++ b/domain/epc_prediction/prediction_target.py @@ -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) diff --git a/tests/domain/epc_prediction/test_comparable_properties.py b/tests/domain/epc_prediction/test_comparable_properties.py index 10921b7af..db6b386ca 100644 --- a/tests/domain/epc_prediction/test_comparable_properties.py +++ b/tests/domain/epc_prediction/test_comparable_properties.py @@ -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