mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
roof_construction codes group by FORM (empirical: 1=Flat 98%, 4/5/8= Pitched 88-99%, 3=dwelling-above 100% over 7,974 certs; 7/9=premises- above per #1452), so the filter matches families — an exact-code filter would wrongly drop pitched neighbours lodged as 5/8. Historic prefixes map to the same families; roof rooms and thatch stay unconditioned. Harness ladder replay and telemetry mirror the new filter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
214 lines
8.5 KiB
Python
214 lines
8.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 `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
|