mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
The "latest EPC per UPRN" selection ordered by e.id DESC, but row-id does not track recency: a gov-EPC cert re-ingested after a PasHub survey lands with a higher e.id, so the header/perf reads silently picked the stale gov cert (wrong TFA, lodgement, property_type, current band) on 3 of portfolio 838. Order by the effective lodgement date (registration -> completion -> inspection) DESC NULLS LAST, e.id DESC tiebreak. Compose the descriptive fabric from the structured SAP fields a re-survey lodges (no gov-EPC prose): walls/roof/floor from the main epc_building_part, heating and hot water from epc_main_heating_detail + the water-heating codes, decoded with the SAP engine's own code space (fabric_description.py). Structured fabric wins where present and falls back to the gov-EPC prose otherwise; windows and lighting stay on prose (their per-element codes do not reconstruct the dwelling phrase). Add built_form (epc_property, code-decoded) and property_age_band (building part construction_age_band -> RdSAP date range) columns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
284 lines
10 KiB
Python
284 lines
10 KiB
Python
"""Human-readable fabric descriptions from an EPC's structured SAP fields
|
|
(ADR-0065).
|
|
|
|
The PasHub re-survey lodges *structured* fabric — SAP building parts, a main
|
|
heating detail, coded fuel/water-heating — but no gov-EPC descriptive prose, so
|
|
the export's descriptive columns would be blank for a re-surveyed Property. This
|
|
module composes the client-sheet text for walls / roof / floor (from the main
|
|
``epc_building_part``) and heating / hot water (from ``epc_main_heating_detail``
|
|
and the ``epc_property`` water-heating fields), decoding the SAP integer codes
|
|
with the same code space the SAP engine uses. Windows and lighting are left on
|
|
the gov-EPC prose fallback (their per-element codes do not cleanly reconstruct
|
|
the dwelling-level phrase).
|
|
|
|
Pure logic: the repository reads the structured rows and calls these; each
|
|
renderer returns ``None`` when it has nothing to say, so the caller can fall
|
|
back to the gov-EPC prose.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
# --- SAP integer-code → display-text maps ---------------------------------
|
|
# wall_construction (SAP10 codes, domain/sap10_ml/rdsap_uvalues.py:127-142).
|
|
_WALL_CONSTRUCTION: dict[int, str] = {
|
|
1: "Granite or whinstone",
|
|
2: "Sandstone",
|
|
3: "Solid brick",
|
|
4: "Cavity",
|
|
5: "Timber frame",
|
|
6: "System build",
|
|
7: "Cob",
|
|
8: "Park home",
|
|
9: "Curtain wall",
|
|
10: "Unknown",
|
|
11: "Cavity (filled party wall)",
|
|
}
|
|
# wall_insulation_type (RdSAP schema codes, rdsap_uvalues.py:145-158).
|
|
_WALL_INSULATION: dict[int, str] = {
|
|
1: "external insulation",
|
|
2: "filled cavity",
|
|
3: "internal insulation",
|
|
4: "as built",
|
|
5: "no insulation",
|
|
6: "filled cavity + external insulation",
|
|
7: "filled cavity + internal insulation",
|
|
}
|
|
# heat_emitter_type (SAP10.2, mapper.py _ELMHURST_HEAT_EMITTER_TO_SAP10).
|
|
_HEAT_EMITTER: dict[int, str] = {
|
|
1: "radiators",
|
|
2: "underfloor heating (in screed)",
|
|
3: "underfloor heating (timber floor)",
|
|
4: "warm air",
|
|
5: "fan coils",
|
|
}
|
|
# main_fuel_type / water_heating_fuel (RdSAP "(not community)" family; invert of
|
|
# main_fuel_overlay._FUEL_CODES).
|
|
_FUEL: dict[int, str] = {
|
|
3: "bottled LPG",
|
|
6: "wood logs",
|
|
10: "dual fuel (mineral and wood)",
|
|
15: "smokeless coal",
|
|
17: "LPG (special condition)",
|
|
20: "mains gas (community)",
|
|
25: "electricity (community)",
|
|
26: "mains gas",
|
|
27: "LPG (bulk)",
|
|
28: "oil",
|
|
29: "electricity",
|
|
31: "biomass (community)",
|
|
33: "house coal",
|
|
}
|
|
# main_heating_control (SAP10.2 Table 4e Group 1; comments in
|
|
# heating_recommendation._CONTROL_FEATURES_BY_CODE).
|
|
_HEATING_CONTROL: dict[int, str] = {
|
|
2101: "no time or thermostatic control",
|
|
2102: "programmer, no room thermostat",
|
|
2103: "room thermostat only",
|
|
2104: "programmer and room thermostat",
|
|
2105: "programmer and at least two room thermostats",
|
|
2106: "programmer, room thermostat and TRVs",
|
|
2107: "programmer, TRVs and bypass",
|
|
2108: "programmer, TRVs and flow switch",
|
|
2109: "programmer, TRVs and boiler energy manager",
|
|
2111: "TRVs and bypass",
|
|
2113: "room thermostat and TRVs",
|
|
}
|
|
# water_heating_code (SAP Table 4a; water_heating_overlay docstring).
|
|
_WATER_HEATING_SYSTEM: dict[int, str] = {
|
|
901: "from main system",
|
|
903: "electric immersion",
|
|
911: "gas boiler / circulator",
|
|
}
|
|
# RdSAP England-&-Wales construction age-band letters → the date range the EPC
|
|
# prints (domain/epc/property_overrides/construction_age_band.py). The lodged
|
|
# `epc_building_part.construction_age_band` carries the bare letter.
|
|
_AGE_BAND: dict[str, str] = {
|
|
"A": "before 1900",
|
|
"B": "1900-1929",
|
|
"C": "1930-1949",
|
|
"D": "1950-1966",
|
|
"E": "1967-1975",
|
|
"F": "1976-1982",
|
|
"G": "1983-1990",
|
|
"H": "1991-1995",
|
|
"I": "1996-2002",
|
|
"J": "2003-2006",
|
|
"K": "2007-2011",
|
|
"L": "2012-2022",
|
|
"M": "2023 onwards",
|
|
}
|
|
|
|
|
|
def _as_int(value: Any) -> Optional[int]:
|
|
"""A SAP code as ``int``. Accepts the raw int, a numeric string, or ``None``;
|
|
a non-numeric label (e.g. an insulation ``"As built"`` string in a numeric
|
|
slot) yields ``None``."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bool):
|
|
return None
|
|
if isinstance(value, int):
|
|
return value
|
|
try:
|
|
return int(str(value).strip())
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _clean_text(value: Any) -> Optional[str]:
|
|
"""A lodged free-text field trimmed to a non-empty string, else ``None``.
|
|
A bare integer (a code lodged in a text slot) is not display text."""
|
|
if value is None or isinstance(value, (int, float, bool)):
|
|
return None
|
|
text = str(value).strip()
|
|
return text or None
|
|
|
|
|
|
# built_form codes (epc_codes.csv; override_code_mapping._BUILT_FORM_CODES).
|
|
# The lodged value is usually the display text already; a minority of records
|
|
# carry the bare integer code instead.
|
|
_BUILT_FORM: dict[int, str] = {
|
|
1: "Detached",
|
|
2: "Semi-detached",
|
|
3: "End-terrace",
|
|
4: "Mid-terrace",
|
|
5: "Enclosed end-terrace",
|
|
6: "Enclosed mid-terrace",
|
|
}
|
|
|
|
|
|
def built_form_description(built_form: Any) -> Optional[str]:
|
|
"""The dwelling's built form. Decodes the integer code where a record
|
|
lodges one, otherwise passes the lodged display text through unchanged."""
|
|
code = _as_int(built_form)
|
|
if code is not None:
|
|
return _BUILT_FORM.get(code)
|
|
return _clean_text(built_form)
|
|
|
|
|
|
def age_band_description(band: Any) -> Optional[str]:
|
|
"""The construction age band as its date range, e.g. ``"1900-1929 (band B)"``.
|
|
The lodged value is a bare RdSAP letter; an unrecognised or ``"Unknown"``
|
|
band renders nothing."""
|
|
letter = _clean_text(band)
|
|
if letter is None:
|
|
return None
|
|
date_range = _AGE_BAND.get(letter.strip().upper())
|
|
return f"{date_range} (band {letter.strip().upper()})" if date_range else None
|
|
|
|
|
|
def wall_description(construction: Any, insulation: Any) -> Optional[str]:
|
|
"""A wall part's text, e.g. ``"Cavity wall, filled cavity"``. The
|
|
construction names the wall; the insulation qualifies it when known."""
|
|
cons = _WALL_CONSTRUCTION.get(_as_int(construction) or 0)
|
|
if cons is None:
|
|
return None
|
|
ins = _WALL_INSULATION.get(_as_int(insulation) or 0)
|
|
return f"{cons} wall, {ins}" if ins else f"{cons} wall"
|
|
|
|
|
|
def _insulation_thickness_mm(thickness: Any) -> Optional[int]:
|
|
"""A roof-insulation thickness in mm from a numeric code or a ``"225mm"``
|
|
string; a non-numeric label (``"As built"``, ``"ND"``) or ``None`` yields
|
|
``None``. Zero is a real value (no insulation) and is preserved."""
|
|
as_int = _as_int(thickness)
|
|
if as_int is not None:
|
|
return as_int
|
|
text = _clean_text(thickness)
|
|
if text is None:
|
|
return None
|
|
digits = "".join(ch for ch in text if ch.isdigit())
|
|
return int(digits) if digits else None
|
|
|
|
|
|
def roof_description(
|
|
construction_type: Any, insulation_location: Any, insulation_thickness: Any
|
|
) -> Optional[str]:
|
|
"""A roof part's text, e.g. ``"Pitched roof (Slates or tiles), Access to
|
|
loft; 270mm insulation at joists"``. The construction is already lodged as
|
|
prose; the insulation clause is appended when a thickness or a named
|
|
location is present."""
|
|
cons = _clean_text(construction_type)
|
|
if cons is None:
|
|
return None
|
|
thickness_mm = _insulation_thickness_mm(insulation_thickness)
|
|
location = _clean_text(insulation_location)
|
|
if location is not None and location.lower() in {"unknown", "none"}:
|
|
location = None
|
|
if thickness_mm == 0:
|
|
return f"{cons}; no insulation"
|
|
if thickness_mm is None and location is None:
|
|
return cons
|
|
clause = "insulation" if thickness_mm is None else f"{thickness_mm}mm insulation"
|
|
if location is not None:
|
|
clause = f"{clause} at {location.lower()}"
|
|
return f"{cons}; {clause}"
|
|
|
|
|
|
def floor_description(
|
|
floor_type: Any, construction_type: Any, insulation: Any
|
|
) -> Optional[str]:
|
|
"""A floor part's text, e.g. ``"Ground floor, suspended timber, insulation
|
|
as built"``. Composed from the lodged floor type, construction, and
|
|
insulation status (all lodged as prose)."""
|
|
parts: list[str] = []
|
|
location = _clean_text(floor_type)
|
|
if location is not None:
|
|
parts.append(location[0].upper() + location[1:].lower())
|
|
construction = _clean_text(construction_type)
|
|
if construction is not None:
|
|
parts.append(construction.lower())
|
|
ins = _clean_text(insulation)
|
|
if ins is not None:
|
|
parts.append(f"insulation {ins.lower()}")
|
|
return ", ".join(parts) or None
|
|
|
|
|
|
def heating_description(
|
|
fuel: Any, emitter: Any, control: Any, condensing: Any
|
|
) -> Optional[str]:
|
|
"""The main heating system's text from its SAP codes, e.g. ``"Mains gas,
|
|
radiators (condensing); programmer, room thermostat and TRVs"``. Reports
|
|
only what the codes state — the appliance archetype is not lodged on a
|
|
re-survey, so no boiler/heat-pump type is asserted."""
|
|
fuel_text = _FUEL.get(_as_int(fuel) or 0)
|
|
emitter_text = _HEAT_EMITTER.get(_as_int(emitter) or 0)
|
|
lead_parts = [p for p in (fuel_text, emitter_text) if p]
|
|
if not lead_parts:
|
|
return None
|
|
lead = ", ".join(p[0].upper() + p[1:] if i == 0 else p for i, p in enumerate(lead_parts))
|
|
if condensing is True:
|
|
lead = f"{lead} (condensing)"
|
|
control_text = _HEATING_CONTROL.get(_as_int(control) or 0)
|
|
return f"{lead}; {control_text}" if control_text else lead
|
|
|
|
|
|
def hot_water_description(
|
|
water_heating_code: Any,
|
|
water_heating_fuel: Any,
|
|
main_fuel: Any,
|
|
has_cylinder: Any,
|
|
solar: Any,
|
|
) -> Optional[str]:
|
|
"""The hot-water system's text from its SAP codes, e.g. ``"From main
|
|
system, mains gas"``. Where the water-heating code is not lodged on the
|
|
re-survey, it falls back to the SAP default (a dwelling with no cylinder
|
|
draws hot water from the main system) using the main heating fuel."""
|
|
system = _WATER_HEATING_SYSTEM.get(_as_int(water_heating_code) or 0)
|
|
fuel = _FUEL.get(_as_int(water_heating_fuel) or 0) or _FUEL.get(_as_int(main_fuel) or 0)
|
|
if system is None:
|
|
# No lodged water-heating code: a dwelling without a cylinder inherits
|
|
# hot water from the main system (SAP Table 4a code 901).
|
|
if has_cylinder is True:
|
|
system = "hot water cylinder"
|
|
elif has_cylinder is False:
|
|
system = "from main system"
|
|
else:
|
|
return None
|
|
text = f"{system}, {fuel}" if fuel else system
|
|
text = text[0].upper() + text[1:]
|
|
if solar is True:
|
|
text = f"{text}; with solar water heating"
|
|
return text
|