Model/domain/scenario_export/fabric_description.py
Khalim Conn-Kowlessar 35a0c6e972 Source export fabric from structured SAP fields; fix stale-cert selection
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>
2026-07-18 19:38:12 +00:00

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