"""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 @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, ) 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