mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Pure-domain select_comparables: property type is an always-hard filter; built form and known Landlord Overrides (e.g. solid brick) are conditioning filters on the filter-then-relax ladder — applied while >= minimum_cohort survive, relaxed otherwise (the mixed-street border case degrades gracefully). PredictionTarget (known inputs) + Comparable (epc + register metadata) + ComparableProperties (selected cohort). Weighting (recency x similarity) follows in the synthesis slice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
3.5 KiB
Python
100 lines
3.5 KiB
Python
"""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 `Comparable`s, `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 typing import Callable, Optional, Union
|
|
|
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
|
|
|
# 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 Comparable:
|
|
"""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)."""
|
|
|
|
epc: EpcPropertyData
|
|
certificate_number: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PredictionTarget:
|
|
"""The known inputs for the Property whose EPC we are predicting — the fields
|
|
guaranteed at ingestion (plus any Landlord Overrides, added as they're used).
|
|
`built_form` is often but not always known.
|
|
"""
|
|
|
|
postcode: str
|
|
property_type: str
|
|
built_form: Optional[str] = None
|
|
# A known Landlord Override (e.g. solid brick) conditions cohort selection —
|
|
# matching comparables are emphasised while enough remain (ADR-0029).
|
|
wall_construction: Optional[Union[int, str]] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ComparableProperties:
|
|
"""The selected reference cohort for a `PredictionTarget`."""
|
|
|
|
members: tuple[Comparable, ...]
|
|
|
|
|
|
def _maybe_filter(
|
|
cohort: list[Comparable],
|
|
predicate: Callable[[Comparable], bool],
|
|
*,
|
|
active: bool,
|
|
minimum_cohort: int,
|
|
) -> list[Comparable]:
|
|
"""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[Comparable],
|
|
*,
|
|
minimum_cohort: int = _DEFAULT_MINIMUM_COHORT,
|
|
) -> ComparableProperties:
|
|
"""Select the Comparable Properties for `target` from the raw postcode
|
|
cohort. Property type is an always-hard filter (a flat is never a comparable
|
|
for a house); built form is a conditioning filter on the relax ladder."""
|
|
cohort = [
|
|
c for c in candidates 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 _main_wall_construction(comparable: Comparable) -> 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
|