Model/domain/epc_prediction/historic_conditioning.py
Khalim Conn-Kowlessar 817c00720e The historic roof description conditions the cohort by form family 🟩
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>
2026-07-06 12:05:56 +00:00

193 lines
7.1 KiB
Python

"""Resolve an expired Historic EPC's *stable* attributes into the cohort code
spaces that condition EPC Prediction (ADR-0054).
The historic dump carries display text ("Semi-Detached", "Cavity wall, as
built, no insulation (assumed)"); the Comparable Properties cohort carries
gov-EPC codes. This module is the anti-corruption layer between the two: one
resolver per whitelisted stable attribute, each returning None on an
unresolvable value so its cohort filter is simply inactive. Volatile
attributes (heating system, hot water, glazing, PV, insulation states,
lighting) are deliberately absent — a 14+-year-old observation of them is not
evidence about today.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Optional
from datatypes.epc.domain.historic_epc import HistoricEpc
from domain.epc.property_overrides.override_code_mapping import (
built_form_to_code,
property_type_to_code,
)
from domain.epc_prediction.prediction_target import (
PredictionTarget,
PredictionTargetAttributes,
)
# RdSAP `wall_construction` codes by material prefix — the same table
# `wall_type_overlay.py` pins (source: domain/sap10_ml/rdsap_uvalues.py).
# The historic description's material is everything before the first comma.
_WALL_MATERIAL_CONSTRUCTION: dict[str, int] = {
"Granite or whin": 1,
"Sandstone": 2,
"Solid brick": 3,
"Cavity wall": 4,
"Timber frame": 5,
"System built": 6,
"Cob": 7,
"Park home wall": 8,
}
# RdSAP Table S1 band letters — the code space cohort building parts carry
# (`sap_building_parts[].construction_age_band` is "A".."M" from the API).
# The old register lodged the full England-and-Wales display strings; a
# pre-2012 cert cannot carry the post-2012 bands, but they cost nothing.
_AGE_BAND_LETTERS: dict[str, str] = {
"England and Wales: before 1900": "A",
"England and Wales: 1900-1929": "B",
"England and Wales: 1930-1949": "C",
"England and Wales: 1950-1966": "D",
"England and Wales: 1967-1975": "E",
"England and Wales: 1976-1982": "F",
"England and Wales: 1983-1990": "G",
"England and Wales: 1991-1995": "H",
"England and Wales: 1996-2002": "I",
"England and Wales: 2003-2006": "J",
"England and Wales: 2007 onwards": "K",
"England and Wales: 2007-2011": "K",
"England and Wales: 2012-2022": "L",
"England and Wales: 2023 onwards": "M",
}
# Modern RdSAP-20/21 `main_fuel` codes (epc_codes.csv), keyed by the base fuel
# description — the same family `main_fuel_overlay.py` pins. The old register
# suffixes private fuels with " (not community)", stripped before lookup.
_FUEL_CODES: dict[str, int] = {
"mains gas": 26,
"mains gas (community)": 20,
"LPG": 27,
"bottled LPG": 3,
"LPG special condition": 17,
"oil": 28,
"electricity": 29,
"electricity (community)": 25,
"house coal": 33,
"smokeless coal": 15,
"dual fuel (mineral and wood)": 10,
"wood logs": 6,
"bulk wood pellets": 7,
"wood chips": 8,
"biomass (community)": 31,
}
# Roof FORM families by the description's pre-comma half. Only forms whose
# API code grouping is pinned (see comparable_properties.ROOF_FORM_BY_CONSTRUCTION
# for the empirical evidence); roof rooms and thatch stay None — never guess.
_ROOF_FORM_BY_PREFIX: dict[str, str] = {
"Pitched": "pitched",
"Flat": "flat",
"(another dwelling above)": "dwelling_above",
"(another premises above)": "premises_above",
}
_NOT_COMMUNITY_SUFFIX = " (not community)"
# Pre-RdSAP-17 lodgements carry a deprecation rider after the fuel name; the
# fuel named is the same physical fuel. Dominant in the pre-2012 dump (a
# 65-shard scan: 636/663 otherwise-unresolved values were these variants).
_LEGACY_SUFFIX = " - this is for backwards compatibility only and should not be used"
# SAP-style lodgements prefix the category; map the known forms to the base
# description the code table keys on.
_LEGACY_ALIASES: dict[str, str] = {
"Gas: mains gas": "mains gas",
"Electricity: electricity, unspecified tariff": "electricity",
"dual fuel - mineral + wood": "dual fuel (mineral and wood)",
}
@dataclass(frozen=True)
class HistoricConditioning:
"""The expired cert's stable attributes, in cohort code space; None means
"unresolved — do not condition on this"."""
property_type: Optional[str]
built_form: Optional[str]
wall_construction: Optional[int]
construction_age_band: Optional[str]
main_fuel: Optional[int]
total_floor_area_m2: Optional[float]
roof_form: Optional[str] = None
def _wall_construction(description: str) -> Optional[int]:
material = description.split(",", 1)[0].strip()
return _WALL_MATERIAL_CONSTRUCTION.get(material)
def _roof_form(description: str) -> Optional[str]:
form = description.split(",", 1)[0].strip()
return _ROOF_FORM_BY_PREFIX.get(form)
def _main_fuel(description: str) -> Optional[int]:
base = description.strip().removesuffix(_NOT_COMMUNITY_SUFFIX)
base = base.removesuffix(_LEGACY_SUFFIX)
base = _LEGACY_ALIASES.get(base, base)
return _FUEL_CODES.get(base)
def _floor_area(raw: str) -> Optional[float]:
try:
area = float(raw)
except ValueError:
return None
return area if area > 0 else None
def conditioning_from_historic(record: HistoricEpc) -> HistoricConditioning:
return HistoricConditioning(
property_type=property_type_to_code(record.property_type),
built_form=built_form_to_code(record.built_form),
wall_construction=_wall_construction(record.walls_description),
construction_age_band=_AGE_BAND_LETTERS.get(record.construction_age_band),
main_fuel=_main_fuel(record.main_fuel),
total_floor_area_m2=_floor_area(record.total_floor_area),
roof_form=_roof_form(record.roof_description),
)
def attributes_with_historic_fallback(
attributes: Optional[PredictionTargetAttributes],
conditioning: HistoricConditioning,
) -> PredictionTargetAttributes:
"""Landlord Overrides speak to *current* state, so they always win; the
expired observation only fills the gaps they left (ADR-0054) — including
supplying the hard `property_type` gate when no override resolved one."""
if attributes is None:
attributes = PredictionTargetAttributes(property_type=None)
return PredictionTargetAttributes(
property_type=attributes.property_type or conditioning.property_type,
built_form=attributes.built_form or conditioning.built_form,
wall_construction=(
attributes.wall_construction
if attributes.wall_construction is not None
else conditioning.wall_construction
),
)
def target_with_conditioning(
target: PredictionTarget, conditioning: HistoricConditioning
) -> PredictionTarget:
"""The target enriched with the attributes only the expired cert observes:
age band, main fuel, and the ±5% floor-area band (ADR-0054)."""
return replace(
target,
construction_age_band=conditioning.construction_age_band,
main_fuel=conditioning.main_fuel,
total_floor_area_m2=conditioning.total_floor_area_m2,
roof_form=conditioning.roof_form,
)