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>
193 lines
7.1 KiB
Python
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,
|
|
)
|