"""Comparable Properties selection for EPC Prediction (ADR-0029). Given a `PredictionTarget` (the known inputs for an EPC-less Property) and the raw postcode cohort of candidate `ComparableProperty` objects, `select_comparables` chooses the reference cohort EPC Prediction synthesises from. Pure domain logic — the cohort IO (postcode search → per-cert fetch) lives behind a repository port. """ from __future__ import annotations from dataclasses import dataclass from datetime import date from typing import Callable, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.epc_prediction.prediction_target import PredictionTarget from domain.geospatial.coordinates import Coordinates # Default floor on the cohort: a conditioning filter (built form, a known # override) is applied only while at least this many comparables survive it, # else it is relaxed (ADR-0029 filter-then-relax ladder). _DEFAULT_MINIMUM_COHORT = 5 # Half-width of the floor-area conditioning band: an expired Historic EPC's # observed floor area keeps comparables within ±20% of it — a coarse # dwelling-size filter, not a precision match. Evidence (ADR-0054 amendment, # 439-pair harness): the historic TFA agrees with the newly lodged one within # ±5% only 45% of the time (remeasurement + extensions) but within ±20% 82%. FLOOR_AREA_TOLERANCE = 0.20 # RdSAP Table S1 band letters in chronological order, for band-distance checks. _AGE_BAND_ORDER = "ABCDEFGHIJKLM" # roof_construction codes by FORM family. Pinned empirically — a 7,974-cert # co-occurrence sweep of code x roofs[0].description over single-building-part # certs (scripts/roof_construction_code_sweep.py): 1=Flat 98%, 4=Pitched 99%, # 5/8=Pitched 88%, 3="(another dwelling above)" 100% — plus 7/9 = "(another # premises above)", established by the #1452 heat-loss suppression fix. ROOF_FORM_BY_CONSTRUCTION: dict[int, str] = { 1: "flat", 3: "dwelling_above", 4: "pitched", 5: "pitched", 7: "premises_above", 8: "pitched", 9: "premises_above", } def roof_form_of_construction(code: object) -> Optional[str]: """The form family of a roof_construction code, or None when unknown.""" return ROOF_FORM_BY_CONSTRUCTION.get(code) if isinstance(code, int) else None def age_bands_within_one(candidate: object, target_band: object) -> bool: """Whether two Table S1 band letters are at most one band apart. Assessors re-band constantly (harness: 52% exact agreement historic-vs-new, 90% within one band), so the age filter keeps the band NEIGHBOURHOOD.""" if not (isinstance(candidate, str) and isinstance(target_band, str)): return False if candidate not in _AGE_BAND_ORDER or target_band not in _AGE_BAND_ORDER: return False return abs(_AGE_BAND_ORDER.index(candidate) - _AGE_BAND_ORDER.index(target_band)) <= 1 @dataclass(frozen=True) class ComparableProperty: """One candidate neighbour: its structured `EpcPropertyData` picture plus the register metadata not carried on the cert (identity for leave-one-out exclusion; recency + address for weighting + re-lodgement dedup).""" epc: EpcPropertyData certificate_number: str address: Optional[str] = None registration_date: Optional[date] = None # Resolved from the neighbour's UPRN at the boundary (the harness / modelling # orchestrator), so the pure predictor can weight by physical distance to the # target without an IO dependency. None when no UPRN/coordinate is available. coordinates: Optional[Coordinates] = None @dataclass(frozen=True) class ComparableProperties: """The selected reference cohort for a `PredictionTarget`.""" members: tuple[ComparableProperty, ...] def _maybe_filter( cohort: list[ComparableProperty], predicate: Callable[[ComparableProperty], bool], *, active: bool, minimum_cohort: int, ) -> list[ComparableProperty]: """Apply a conditioning filter only while it leaves at least `minimum_cohort` comparables; otherwise relax it (keep the pre-filter cohort) — the filter-then-relax ladder (ADR-0029).""" if not active: return cohort filtered = [c for c in cohort if predicate(c)] return filtered if len(filtered) >= minimum_cohort else cohort def select_comparables( target: PredictionTarget, candidates: list[ComparableProperty], *, minimum_cohort: int = _DEFAULT_MINIMUM_COHORT, ) -> ComparableProperties: """Select the ComparableProperty Properties for `target` from the raw postcode cohort. The register lists every historical lodgement, so first dedupe each address to its latest cert (one comparable per real neighbour); then property type is an always-hard filter (a flat is never a comparable for a house) and built form is a conditioning filter on the relax ladder.""" cohort = _dedupe_to_latest_per_address(candidates) cohort = [ c for c in cohort if c.epc.property_type == target.property_type ] cohort = _maybe_filter( cohort, lambda c: c.epc.built_form == target.built_form, active=target.built_form is not None, minimum_cohort=minimum_cohort, ) cohort = _maybe_filter( cohort, lambda c: _main_wall_construction(c) == target.wall_construction, active=target.wall_construction is not None, minimum_cohort=minimum_cohort, ) cohort = _maybe_filter( cohort, lambda c: _main_roof_form(c) == target.roof_form, active=target.roof_form is not None, minimum_cohort=minimum_cohort, ) cohort = _maybe_filter( cohort, lambda c: age_bands_within_one( _main_construction_age_band(c), target.construction_age_band ), active=target.construction_age_band is not None, minimum_cohort=minimum_cohort, ) cohort = _maybe_filter( cohort, lambda c: _main_fuel_type(c) == target.main_fuel, active=target.main_fuel is not None, minimum_cohort=minimum_cohort, ) target_area = target.total_floor_area_m2 cohort = _maybe_filter( cohort, lambda c: target_area is not None and abs(c.epc.total_floor_area_m2 - target_area) <= FLOOR_AREA_TOLERANCE * target_area, active=target_area is not None, minimum_cohort=minimum_cohort, ) return ComparableProperties(members=tuple(cohort)) def _dedupe_to_latest_per_address( candidates: list[ComparableProperty], ) -> list[ComparableProperty]: """Collapse the register's re-lodgements: keep one comparable per address — the latest by registration date (ties broken by certificate number, for determinism) — so a re-lodged neighbour does not count more than once. Candidates with no address are passed through untouched (each is its own neighbour). Input order is otherwise preserved.""" latest: dict[str, ComparableProperty] = {} passthrough: list[ComparableProperty] = [] for c in candidates: if c.address is None: passthrough.append(c) continue incumbent = latest.get(c.address) if incumbent is None or _recency_key(c) > _recency_key(incumbent): latest[c.address] = c return list(latest.values()) + passthrough def _recency_key(comparable: ComparableProperty) -> tuple[date, str]: """Sort key making the most recent (then highest cert number) win. A missing registration date sorts oldest.""" return ( comparable.registration_date or date.min, comparable.certificate_number, ) def _main_wall_construction(comparable: ComparableProperty) -> object: """The main building part's wall construction, or None when no part lodged.""" parts = comparable.epc.sap_building_parts return parts[0].wall_construction if parts else None def _main_roof_form(comparable: ComparableProperty) -> Optional[str]: """The main building part's roof-form family, or None when unresolvable.""" parts = comparable.epc.sap_building_parts return roof_form_of_construction(parts[0].roof_construction) if parts else None def _main_construction_age_band(comparable: ComparableProperty) -> object: """The main building part's Table S1 band letter, or None when no part lodged.""" parts = comparable.epc.sap_building_parts return parts[0].construction_age_band if parts else None def _main_fuel_type(comparable: ComparableProperty) -> object: """The primary heating fuel code, or None when no main heating lodged.""" details = comparable.epc.sap_heating.main_heating_details return details[0].main_fuel_type if details else None