"""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, SapBuildingPart 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, ) -> 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 epc.sap_building_parts = [main] 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