Model/domain/epc_prediction/historic_conditioning.py
Khalim Conn-Kowlessar bbfcf1385b Resolve a historic EPC's stable attributes into cohort code spaces 🟩
property_type/built_form reuse the override code mapping; wall material
prefix -> RdSAP wall_construction; E&W age-band strings -> Table S1
letters; main_fuel display text (stripping " (not community)") -> modern
RdSAP-20/21 codes; TFA -> float. Unresolvable -> None (filter inactive).
Roof construction is deferred: its RdSAP code table isn't pinned in-repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:22:31 +00:00

123 lines
4.3 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
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,
)
# 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,
}
_NOT_COMMUNITY_SUFFIX = " (not community)"
@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]
def _wall_construction(description: str) -> Optional[int]:
material = description.split(",", 1)[0].strip()
return _WALL_MATERIAL_CONSTRUCTION.get(material)
def _main_fuel(description: str) -> Optional[int]:
base = description.strip().removesuffix(_NOT_COMMUNITY_SUFFIX)
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),
)