"""Behaviour of ComparableProperty Properties selection (ADR-0029): given a prediction target's known inputs and the raw postcode cohort, choose + weight the comparables EPC Prediction will synthesise from. Filter-then-relax ladder: hard filters on identity (property type, built form) + known overrides while 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, MainHeatingDetail, SapBuildingPart, SapHeating, ) from domain.epc_prediction.comparable_properties import ( ComparableProperty, ComparableProperties, select_comparables, ) from domain.epc_prediction.prediction_target import PredictionTarget def _comparable( *, property_type: str, certificate_number: str, built_form: str = "1", 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, roof_construction: Optional[int] = None, ) -> ComparableProperty: """A ComparableProperty carrying only the fields under test (opaque EpcPropertyData with property_type / built_form / main wall set — the partial-instance idiom).""" epc: EpcPropertyData = object.__new__(EpcPropertyData) epc.property_type = property_type epc.built_form = built_form 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 if roof_construction is not None: main.roof_construction = roof_construction 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, address=address, registration_date=registration_date, ) def test_selects_only_candidates_of_the_same_property_type() -> None: # Arrange — a target house (property_type "2"); cohort of 2 houses + 1 flat. target = PredictionTarget(postcode="LS6 1AA", property_type="2") candidates = [ _comparable(property_type="2", certificate_number="A"), _comparable(property_type="2", certificate_number="B"), _comparable(property_type="1", certificate_number="C"), ] # Act result: ComparableProperties = select_comparables(target, candidates) # Assert — the flat is excluded; the two houses remain. assert {c.certificate_number for c in result.members} == {"A", "B"} def test_dedupes_re_lodgements_to_the_latest_cert_per_address() -> None: # Arrange — a register cohort with one address (FLAT 3) lodged three times. # Comparables are one-per-real-neighbour, so a re-lodged address must not # count three times towards the mode; the latest cert is its current state. target = PredictionTarget(postcode="LS6 1AA", property_type="2") candidates = [ _comparable( property_type="2", certificate_number="OLD", address="FLAT 3", registration_date=date(2020, 4, 6), ), _comparable( property_type="2", certificate_number="MID", address="FLAT 3", registration_date=date(2021, 2, 1), ), _comparable( property_type="2", certificate_number="NEW", address="FLAT 3", registration_date=date(2025, 1, 20), ), _comparable( property_type="2", certificate_number="OTHER", address="FLAT 5", registration_date=date(2024, 9, 27), ), ] # Act result: ComparableProperties = select_comparables(target, candidates) # Assert — FLAT 3 collapses to its latest cert; FLAT 5 is untouched. assert {c.certificate_number for c in result.members} == {"NEW", "OTHER"} def test_filters_to_the_known_built_form_when_enough_remain() -> None: # Arrange — a mid-terrace target (built_form "4"); cohort of 5 mid-terraces # + 2 detached, all houses. The built form is known and leaves ≥ k, so it is # applied as a hard filter. target = PredictionTarget( postcode="LS6 1AA", property_type="2", built_form="4" ) candidates = [ _comparable(property_type="2", built_form="4", certificate_number=f"T{i}") for i in range(5) ] + [ _comparable(property_type="2", built_form="1", certificate_number=f"D{i}") for i in range(2) ] # Act result: ComparableProperties = select_comparables( target, candidates, minimum_cohort=5 ) # Assert — only the five mid-terraces survive. assert {c.certificate_number for c in result.members} == { "T0", "T1", "T2", "T3", "T4" } def test_known_wall_override_emphasises_matching_comparables() -> None: # Arrange — a mixed street: 5 solid-brick (code 2) + 3 cavity (code 1) houses. # We KNOW the target is solid brick (a Landlord Override), and the filter # leaves ≥ k, so cavity neighbours are dropped (the border-property case). target = PredictionTarget( postcode="LS6 1AA", property_type="2", wall_construction=2 ) candidates = [ _comparable(property_type="2", wall_construction=2, certificate_number=f"S{i}") for i in range(5) ] + [ _comparable(property_type="2", wall_construction=1, certificate_number=f"C{i}") for i in range(3) ] # Act result: ComparableProperties = select_comparables( target, candidates, minimum_cohort=5 ) # Assert — only the solid-brick comparables remain. assert {c.certificate_number for c in result.members} == { "S0", "S1", "S2", "S3", "S4" } def test_known_wall_override_relaxes_when_too_few_match() -> None: # Arrange — only 2 solid-brick but 6 cavity houses; the override would leave # 2 (< k=5), so it relaxes to keep the full type cohort (graceful degradation). target = PredictionTarget( postcode="LS6 1AA", property_type="2", wall_construction=2 ) candidates = [ _comparable(property_type="2", wall_construction=2, certificate_number=f"S{i}") for i in range(2) ] + [ _comparable(property_type="2", wall_construction=1, certificate_number=f"C{i}") for i in range(6) ] # Act result: ComparableProperties = select_comparables( target, candidates, minimum_cohort=5 ) # 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_20_percent() -> None: # Arrange — the expired cert observed 100 m²; the band is ±20% (ADR-0054 as # amended: the 439-pair harness put historic-vs-new TFA agreement at only # 45% within ±5% but 82% within ±20% — a coarse dwelling-size filter, not a # precision match): 96, 118 and 84 are in, 125 and 79 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=118.0, certificate_number="B" ), _comparable(property_type="2", total_floor_area_m2=84.0, certificate_number="C"), _comparable( property_type="2", total_floor_area_m2=125.0, certificate_number="X" ), _comparable(property_type="2", total_floor_area_m2=79.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", "C"} def test_historic_age_band_conditions_within_one_band() -> None: # Arrange — the expired cert observed band C, but assessors re-band ±1 # constantly (harness: 52% exact agreement, 90% within one band), so the # filter keeps the band NEIGHBOURHOOD: B/C/D match, G does not (ADR-0054 as # amended). target = PredictionTarget( postcode="LS6 1AA", property_type="2", construction_age_band="C" ) near = ["B", "C", "D", "D", "B"] candidates = [ _comparable( property_type="2", construction_age_band=band, certificate_number=f"N{i}", ) for i, band in enumerate(near) ] + [ _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 — the five band-neighbourhood comparables survive; band G drops. assert {c.certificate_number for c in result.members} == { "N0", "N1", "N2", "N3", "N4" } def test_historic_roof_form_conditions_the_cohort_by_family() -> None: # Arrange — the expired cert observed a pitched roof. The API's # roof_construction codes group into FORM families (empirical sweep: # 4/5/8 = pitched, 1 = flat), so all pitched-family comparables match and # the flat one drops (ADR-0054 as amended). target = PredictionTarget(postcode="LS6 1AA", property_type="2", roof_form="pitched") candidates = [ _comparable(property_type="2", roof_construction=4, certificate_number="P4a"), _comparable(property_type="2", roof_construction=4, certificate_number="P4b"), _comparable(property_type="2", roof_construction=5, certificate_number="P5"), _comparable(property_type="2", roof_construction=8, certificate_number="P8"), _comparable(property_type="2", roof_construction=4, certificate_number="P4c"), _comparable(property_type="2", roof_construction=1, certificate_number="F1"), ] # Act result: ComparableProperties = select_comparables( target, candidates, minimum_cohort=5 ) # Assert — the whole pitched family survives; the flat roof drops. assert {c.certificate_number for c in result.members} == { "P4a", "P4b", "P4c", "P5", "P8" } 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