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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-18 19:38:12 +00:00
parent a820274d4f
commit 35a0c6e972
5 changed files with 790 additions and 8 deletions

View file

@ -0,0 +1,284 @@
"""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

View file

@ -59,6 +59,8 @@ _ID_COLUMNS: tuple[str, ...] = (
"address",
"postcode",
"property_type",
"built_form",
"property_age_band",
"walls",
"roof",
"floor",
@ -147,6 +149,8 @@ class PropertyScenarioData:
address: Optional[str] = None
postcode: Optional[str] = None
property_type: Optional[str] = None
built_form: Optional[str] = None
property_age_band: Optional[str] = None
walls: Optional[str] = None
roof: Optional[str] = None
floor: Optional[str] = None
@ -196,6 +200,8 @@ def _shape_row(prop: PropertyScenarioData) -> dict[str, Any]:
"address": _blank(prop.address),
"postcode": _blank(prop.postcode),
"property_type": _blank(prop.property_type),
"built_form": _blank(prop.built_form),
"property_age_band": _blank(prop.property_age_band),
"walls": _blank(prop.walls),
"roof": _blank(prop.roof),
"floor": _blank(prop.floor),

View file

@ -6,6 +6,15 @@ from typing import Any, Optional, Sequence
from sqlalchemy import text
from sqlmodel import Session
from domain.scenario_export.fabric_description import (
age_band_description,
built_form_description,
floor_description,
heating_description,
hot_water_description,
roof_description,
wall_description,
)
from domain.scenario_export.scenario_sheet import ExportMeasure, PropertyScenarioData
from repositories.scenario_export.scenario_export_repository import (
ScenarioExportRepository,
@ -60,19 +69,26 @@ _MEASURES_SQL = text(
# performance are matched by UPRN (the latest epc_property for the source),
# because the PasHub re-fetch lands records with property_id = NULL — a
# property_id join would silently fall back to a stale gov-EPC cert (wrong
# lodgement dates / property_type) or nothing. The descriptive prose elements
# have no PasHub equivalent (a survey lodges structured fields, not gov-EPC
# prose), so they stay on the property_id join as the best-available gov-EPC
# fallback. All three are read separately and composed per Property.
# lodgement dates / property_type) or nothing. "Latest" is ordered by the cert's
# effective lodgement date (registration → completion → inspection) DESC, NOT by
# `e.id`: row-id order does not track recency — a gov-EPC cert re-ingested after
# the PasHub survey lands with a HIGHER `e.id`, so an `e.id` sort silently picks
# the stale gov cert over the fresh 2026 survey (observed on 3 of portfolio 838).
# `e.id DESC` remains the tiebreak within one date (duplicate survey loads carry
# identical facts). The descriptive prose elements have no PasHub equivalent (a
# survey lodges structured fields, not gov-EPC prose), so they stay on the
# property_id join as the best-available gov-EPC fallback. All three are read
# separately and composed per Property.
_EPC_HEADER_SQL = text(
"WITH latest AS ("
" SELECT DISTINCT ON (p.id) p.id AS property_id, e.id AS epc_id"
" FROM property p JOIN epc_property e ON e.uprn = p.uprn"
" WHERE p.id = ANY(:property_ids) AND e.source = :source"
" ORDER BY p.id, e.id DESC)"
" ORDER BY p.id, COALESCE(e.registration_date, e.completion_date,"
" e.inspection_date) DESC NULLS LAST, e.id DESC)"
" SELECT l.property_id, e.property_type, e.total_floor_area_m2,"
" COALESCE(e.registration_date, e.completion_date, e.inspection_date),"
" e.habitable_rooms_count"
" e.habitable_rooms_count, e.built_form"
" FROM latest l JOIN epc_property e ON e.id = l.epc_id"
)
_EPC_ELEMENTS_SQL = text(
@ -86,13 +102,57 @@ _EPC_PERF_SQL = text(
" SELECT DISTINCT ON (p.id) p.id AS property_id, e.id AS epc_id"
" FROM property p JOIN epc_property e ON e.uprn = p.uprn"
" WHERE p.id = ANY(:property_ids) AND e.source = :source"
" ORDER BY p.id, e.id DESC)"
" ORDER BY p.id, COALESCE(e.registration_date, e.completion_date,"
" e.inspection_date) DESC NULLS LAST, e.id DESC)"
" SELECT l.property_id, perf.energy_rating_current,"
" perf.current_energy_efficiency_band"
" FROM latest l"
" JOIN epc_property_energy_performance perf ON perf.epc_property_id = l.epc_id"
)
# The structured SAP fabric on the same canonical (latest-by-date, UPRN-matched)
# cert the header reads: the MAIN building part (walls / roof / floor), the main
# heating detail (fuel / emitter / control / condensing) and the water-heating
# fields. A PasHub re-survey lodges these coded facts but no gov-EPC prose, so
# they supply walls/roof/floor/heating/hot_water where the prose fallback is
# blank. The main building part is chosen identifier='main' first, then the
# lowest part number / id (matching the SAP engine's `parts[0]` primary part).
# Windows and lighting are not read here (their per-element codes do not cleanly
# reconstruct the dwelling phrase); they stay on the gov-EPC prose fallback.
_EPC_STRUCTURED_FABRIC_SQL = text(
"WITH latest AS ("
" SELECT DISTINCT ON (p.id) p.id AS property_id, e.id AS epc_id"
" FROM property p JOIN epc_property e ON e.uprn = p.uprn"
" WHERE p.id = ANY(:property_ids) AND e.source = :source"
" ORDER BY p.id, COALESCE(e.registration_date, e.completion_date,"
" e.inspection_date) DESC NULLS LAST, e.id DESC),"
" main_part AS ("
" SELECT DISTINCT ON (l.property_id) l.property_id, bp.construction_age_band,"
" bp.wall_construction, bp.wall_insulation_type, bp.roof_construction_type,"
" bp.roof_insulation_location, bp.roof_insulation_thickness, bp.floor_type,"
" bp.floor_construction_type, bp.floor_insulation_type_str"
" FROM latest l JOIN epc_building_part bp ON bp.epc_property_id = l.epc_id"
" ORDER BY l.property_id,"
" (CASE WHEN lower(bp.identifier) = 'main' THEN 0 ELSE 1 END),"
" bp.building_part_number NULLS FIRST, bp.id),"
" main_heat AS ("
" SELECT DISTINCT ON (h.epc_property_id) h.epc_property_id, h.main_fuel_type,"
" h.heat_emitter_type, h.main_heating_control, h.condensing"
" FROM epc_main_heating_detail h"
" ORDER BY h.epc_property_id, h.main_heating_number NULLS FIRST, h.id)"
" SELECT l.property_id, mp.wall_construction, mp.wall_insulation_type,"
" mp.roof_construction_type, mp.roof_insulation_location,"
" mp.roof_insulation_thickness, mp.floor_type, mp.floor_construction_type,"
" mp.floor_insulation_type_str, mh.main_fuel_type, mh.heat_emitter_type,"
" mh.main_heating_control, mh.condensing, e.heating_water_heating_code,"
" e.heating_water_heating_fuel, e.has_hot_water_cylinder,"
" e.solar_water_heating, mp.construction_age_band"
" FROM latest l"
" JOIN epc_property e ON e.id = l.epc_id"
" LEFT JOIN main_part mp ON mp.property_id = l.property_id"
" LEFT JOIN main_heat mh ON mh.epc_property_id = l.epc_id"
)
# A landlord property_type override wins over any EPC-derived value (ADR-0065);
# the descriptive prose fields have no override prose, so only property_type is
# resolved this way (the override enum carries coded fabric facts, not prose).
@ -183,6 +243,8 @@ class ScenarioExportPostgresRepository(ScenarioExportRepository):
energy_consumption_savings=_float(row[11]),
valuation_increase=_float(row[12]),
property_type=facts.get("property_type"),
built_form=facts.get("built_form"),
property_age_band=facts.get("property_age_band"),
walls=facts.get("walls"),
roof=facts.get("roof"),
floor=facts.get("floor"),
@ -261,6 +323,7 @@ class ScenarioExportPostgresRepository(ScenarioExportRepository):
"lodgement_date": registration_date,
"is_expired": _is_expired(registration_date),
"number_of_rooms": row[4],
"built_form": built_form_description(row[5]),
}
)
@ -287,4 +350,23 @@ class ScenarioExportPostgresRepository(ScenarioExportRepository):
}
)
# Structured SAP fabric (walls/roof/floor/heating/hot_water) composed
# from the canonical cert's coded fields; overrides the gov-EPC prose
# where present (a re-survey lodges these but no prose), and leaves the
# prose in place where a field renders to nothing.
for row in connection.execute(_EPC_STRUCTURED_FABRIC_SQL, source_params).all():
rendered = {
"property_age_band": age_band_description(row[17]),
"walls": wall_description(row[1], row[2]),
"roof": roof_description(row[3], row[4], row[5]),
"floor": floor_description(row[6], row[7], row[8]),
"heating": heating_description(row[9], row[10], row[11], row[12]),
"hot_water": hot_water_description(
row[13], row[14], row[9], row[15], row[16]
),
}
facts.setdefault(row[0], {}).update(
{field: value for field, value in rendered.items() if value is not None}
)
return facts

View file

@ -0,0 +1,174 @@
"""Behaviour of the structured-fabric renderers (ADR-0065): composing client
sheet text from an EPC's SAP integer codes, decoding with the SAP engine's own
code space and normalising the ragged shapes a re-survey lodges."""
from __future__ import annotations
from domain.scenario_export.fabric_description import (
age_band_description,
built_form_description,
floor_description,
heating_description,
hot_water_description,
roof_description,
wall_description,
)
class TestBuiltFormDescription:
def test_lodged_display_text_passes_through(self) -> None:
assert built_form_description("Mid-terrace") == "Mid-terrace"
assert built_form_description("Semi-Detached") == "Semi-Detached"
def test_integer_code_is_decoded(self) -> None:
assert built_form_description(4) == "Mid-terrace"
assert built_form_description("1") == "Detached"
def test_absent_or_unknown_code_renders_nothing(self) -> None:
assert built_form_description(None) is None
assert built_form_description(99) is None
class TestAgeBandDescription:
def test_rdsap_letter_decodes_to_date_range_and_band(self) -> None:
assert age_band_description("B") == "1900-1929 (band B)"
assert age_band_description("A") == "before 1900 (band A)"
assert age_band_description("M") == "2023 onwards (band M)"
def test_lowercase_and_whitespace_are_tolerated(self) -> None:
assert age_band_description(" k ") == "2007-2011 (band K)"
def test_unknown_or_absent_band_renders_nothing(self) -> None:
assert age_band_description("Unknown") is None
assert age_band_description(None) is None
assert age_band_description("Z") is None
class TestWallDescription:
def test_construction_and_insulation_codes_decode_to_text(self) -> None:
assert wall_description(4, 2) == "Cavity wall, filled cavity"
assert wall_description(3, 4) == "Solid brick wall, as built"
assert wall_description(5, 1) == "Timber frame wall, external insulation"
def test_construction_only_when_insulation_unknown(self) -> None:
assert wall_description(4, None) == "Cavity wall"
assert wall_description(4, 99) == "Cavity wall"
def test_numeric_string_codes_are_accepted(self) -> None:
# JSONB can round-trip a code as a string; it still decodes.
assert wall_description("4", "2") == "Cavity wall, filled cavity"
def test_unknown_construction_renders_nothing(self) -> None:
assert wall_description(None, 2) is None
assert wall_description(99, 2) is None
class TestRoofDescription:
def test_construction_prose_with_thickness_and_location(self) -> None:
assert (
roof_description("Pitched roof, Access to loft", "Joists", 270)
== "Pitched roof, Access to loft; 270mm insulation at joists"
)
def test_thickness_string_with_unit_is_normalised(self) -> None:
# A minority of records lodge the thickness as "225mm" rather than 225.
assert (
roof_description("Pitched", "Joists", "225mm")
== "Pitched; 225mm insulation at joists"
)
def test_non_numeric_thickness_and_coded_location_are_dropped(self) -> None:
# thickness "As built" is not a measurement; a bare int location is a
# code, not a place — with neither, only the construction survives.
assert roof_description("Pitched", 2, "As built") == "Pitched"
def test_named_location_without_a_thickness_still_notes_insulation(self) -> None:
assert (
roof_description("Pitched", "Joists", "As built")
== "Pitched; insulation at joists"
)
def test_zero_thickness_reads_as_no_insulation(self) -> None:
assert roof_description("Flat roof", "Joists", 0) == "Flat roof; no insulation"
def test_unknown_location_is_omitted(self) -> None:
assert roof_description("Pitched", "Unknown", 100) == "Pitched; 100mm insulation"
def test_construction_only_when_no_insulation_detail(self) -> None:
assert roof_description("Pitched", None, None) == "Pitched"
def test_no_construction_renders_nothing(self) -> None:
assert roof_description(None, "Joists", 270) is None
class TestFloorDescription:
def test_composes_type_construction_and_insulation(self) -> None:
assert (
floor_description("Ground floor", "Suspended, timber", "As Built")
== "Ground floor, suspended, timber, insulation as built"
)
def test_normalises_casing_of_the_floor_type(self) -> None:
assert floor_description("Ground Floor", "Solid", None) == "Ground floor, solid"
def test_retrofitted_insulation_is_carried(self) -> None:
assert (
floor_description("Ground floor", "Solid", "Retro-fitted")
== "Ground floor, solid, insulation retro-fitted"
)
def test_empty_when_nothing_lodged(self) -> None:
assert floor_description(None, None, None) is None
class TestHeatingDescription:
def test_fuel_emitter_control_and_condensing(self) -> None:
assert (
heating_description(26, 1, 2106, True)
== "Mains gas, radiators (condensing); programmer, room thermostat and TRVs"
)
def test_condensing_false_or_absent_omits_the_flag(self) -> None:
assert heating_description(26, 1, 2104, None) == (
"Mains gas, radiators; programmer and room thermostat"
)
assert heating_description(26, 1, None, False) == "Mains gas, radiators"
def test_unknown_control_is_dropped(self) -> None:
assert heating_description(26, 1, 9999, True) == "Mains gas, radiators (condensing)"
def test_empty_when_no_fuel_or_emitter(self) -> None:
assert heating_description(None, None, 2106, True) is None
class TestHotWaterDescription:
def test_lodged_code_and_fuel_decode(self) -> None:
assert (
hot_water_description(901, 26, 26, False, False) == "From main system, mains gas"
)
assert (
hot_water_description(903, 29, 26, True, False)
== "Electric immersion, electricity"
)
def test_falls_back_to_main_system_when_no_code_and_no_cylinder(self) -> None:
# SAP Table 4a: a dwelling with no cylinder draws hot water from the main
# system; the main heating fuel names it.
assert (
hot_water_description(None, None, 26, False, False)
== "From main system, mains gas"
)
def test_cylinder_without_a_code(self) -> None:
assert (
hot_water_description(None, None, 29, True, False)
== "Hot water cylinder, electricity"
)
def test_solar_water_heating_is_appended(self) -> None:
assert hot_water_description(901, 26, 26, False, True) == (
"From main system, mains gas; with solar water heating"
)
def test_empty_when_nothing_lodged(self) -> None:
assert hot_water_description(None, None, None, None, False) is None

View file

@ -4,14 +4,16 @@ read-model, from persisted data only (no live EPC calls)."""
from __future__ import annotations
from typing import Optional
from typing import Optional, Union
from sqlalchemy import Engine
from sqlmodel import Session
from datatypes.epc.domain.epc import Epc
from infrastructure.postgres.epc_property_table import ( # registers epc tables
EpcBuildingPartModel,
EpcEnergyElementModel,
EpcMainHeatingDetailModel,
EpcPropertyEnergyPerformanceModel,
EpcPropertyModel,
)
@ -37,6 +39,7 @@ def _add_epc(
uprn: int = 100001,
source: str = "lodged",
property_type: Optional[str] = None,
built_form: Optional[str] = None,
total_floor_area_m2: float = 90.0,
registration_date: str = "2005-01-01",
habitable_rooms_count: int = 4,
@ -54,6 +57,7 @@ def _add_epc(
uprn=uprn,
source=source,
property_type=property_type,
built_form=built_form,
total_floor_area_m2=total_floor_area_m2,
registration_date=registration_date,
habitable_rooms_count=habitable_rooms_count,
@ -103,6 +107,66 @@ def _add_epc(
)
def _add_building_part(
session: Session,
*,
epc_id: int,
identifier: str = "main",
building_part_number: Optional[int] = None,
wall_construction: int = 4,
wall_insulation_type: int = 2,
roof_construction_type: Optional[str] = "Pitched roof, Access to loft",
roof_insulation_location: Optional[str] = "Joists",
roof_insulation_thickness: Optional[Union[str, int]] = 270,
floor_type: Optional[str] = "Ground floor",
floor_construction_type: Optional[str] = "Solid",
floor_insulation_type_str: Optional[str] = "As Built",
) -> None:
"""Seed one SAP building part on an EPC slot (the source of the structured
walls / roof / floor text)."""
session.add(
EpcBuildingPartModel(
epc_property_id=epc_id,
identifier=identifier,
construction_age_band="D",
wall_construction=wall_construction,
wall_insulation_type=wall_insulation_type,
wall_thickness_measured=False,
building_part_number=building_part_number,
roof_construction_type=roof_construction_type,
roof_insulation_location=roof_insulation_location,
roof_insulation_thickness=roof_insulation_thickness,
floor_type=floor_type,
floor_construction_type=floor_construction_type,
floor_insulation_type_str=floor_insulation_type_str,
)
)
def _add_main_heating(
session: Session,
*,
epc_id: int,
main_fuel_type: int = 26,
heat_emitter_type: int = 1,
main_heating_control: int = 2106,
condensing: Optional[bool] = True,
) -> None:
"""Seed the main heating detail on an EPC slot (the source of the structured
heating text)."""
session.add(
EpcMainHeatingDetailModel(
epc_property_id=epc_id,
has_fghrs=False,
main_fuel_type=main_fuel_type,
heat_emitter_type=heat_emitter_type,
emitter_temperature=1,
main_heating_control=main_heating_control,
condensing=condensing,
)
)
def test_returns_a_row_for_a_property_with_a_default_plan_under_the_scenario(
db_engine: Engine,
) -> None:
@ -361,6 +425,178 @@ def test_effective_epc_descriptive_fields_come_from_the_lodged_epc(
assert row.is_expired is True
def test_header_picks_the_freshest_cert_by_date_not_by_row_id(
db_engine: Engine,
) -> None:
# arrange — two lodged certs on one UPRN: a fresh 2026 survey lodged with a
# LOWER epc_id, and a stale 2013 gov cert re-ingested afterwards with a
# HIGHER epc_id. An `e.id DESC` sort would wrongly pick the stale gov cert;
# the freshest lodgement date must win (the portfolio-838 TFA regression).
with Session(db_engine) as session:
session.add(
PropertyRow(id=1, portfolio_id=100, landlord_property_id="LP1", uprn=100001)
)
session.add(
PlanModel(portfolio_id=100, property_id=1, scenario_id=5, is_default=True)
)
_add_epc(
session,
property_id=1,
epc_id=10, # fresh survey, LOWER id
uprn=100001,
total_floor_area_m2=76.62,
registration_date="2026-05-18",
)
_add_epc(
session,
property_id=1,
epc_id=20, # stale gov cert re-ingested later, HIGHER id
uprn=100001,
total_floor_area_m2=77.0,
registration_date="2013-10-09",
)
session.commit()
# act
with Session(db_engine) as session:
rows = ScenarioExportPostgresRepository(session).rows_for(
portfolio_id=100, scenario_id=5, property_ids=[1]
)
# assert — the fresh 2026 survey's floor area and lodgement win, not the
# higher-id 2013 gov cert.
assert rows[0].total_floor_area == 76.62
assert rows[0].lodgement_date == "2026-05-18"
def test_structured_fabric_supplies_walls_roof_floor_heating_and_hot_water(
db_engine: Engine,
) -> None:
# arrange — a lodged EPC carrying gov prose for every descriptive field AND
# the structured SAP fabric (building part + heating detail) a re-survey
# lodges. The structured fabric must win for walls/roof/floor/heating/
# hot_water; windows/lighting have no structured source and keep the prose.
with Session(db_engine) as session:
session.add(
PropertyRow(id=1, portfolio_id=100, landlord_property_id="LP1", uprn=100001)
)
session.add(
PlanModel(portfolio_id=100, property_id=1, scenario_id=5, is_default=True)
)
_add_epc(
session,
property_id=1,
epc_id=1,
source="lodged",
built_form="Mid-terrace",
elements={
"wall": "PROSE walls",
"roof": "PROSE roof",
"floor": "PROSE floor",
"main_heating": "PROSE heating",
"hot_water": "PROSE hot water",
"window": "Fully double glazed",
"lighting": "Low energy lighting in all fixed outlets",
},
)
_add_building_part(session, epc_id=1)
_add_main_heating(session, epc_id=1)
session.commit()
# act
with Session(db_engine) as session:
rows = ScenarioExportPostgresRepository(session).rows_for(
portfolio_id=100, scenario_id=5, property_ids=[1]
)
# assert — structured fabric replaces the prose for its five fields; windows
# and lighting (no structured source) keep the gov prose.
row = rows[0]
assert row.built_form == "Mid-terrace"
assert row.property_age_band == "1950-1966 (band D)"
assert row.walls == "Cavity wall, filled cavity"
assert row.roof == "Pitched roof, Access to loft; 270mm insulation at joists"
assert row.floor == "Ground floor, solid, insulation as built"
assert row.heating == (
"Mains gas, radiators (condensing); programmer, room thermostat and TRVs"
)
assert row.hot_water == "From main system, mains gas"
assert row.windows == "Fully double glazed"
assert row.lighting == "Low energy lighting in all fixed outlets"
def test_structured_fabric_reads_the_main_building_part_over_an_extension(
db_engine: Engine,
) -> None:
# arrange — two building parts on the cert: an extension (solid brick) and
# the main dwelling (cavity). The main part must supply the wall text.
with Session(db_engine) as session:
session.add(
PropertyRow(id=1, portfolio_id=100, landlord_property_id="LP1", uprn=100001)
)
session.add(
PlanModel(portfolio_id=100, property_id=1, scenario_id=5, is_default=True)
)
_add_epc(session, property_id=1, epc_id=1, source="lodged")
_add_building_part(
session,
epc_id=1,
identifier="extension_1",
building_part_number=2,
wall_construction=3, # solid brick — the extension
)
_add_building_part(
session,
epc_id=1,
identifier="main",
building_part_number=1,
wall_construction=4, # cavity — the main dwelling
)
session.commit()
# act
with Session(db_engine) as session:
rows = ScenarioExportPostgresRepository(session).rows_for(
portfolio_id=100, scenario_id=5, property_ids=[1]
)
# assert — the main (cavity) part wins, not the extension (solid brick).
assert rows[0].walls == "Cavity wall, filled cavity"
def test_prose_remains_where_the_structured_fabric_is_absent(
db_engine: Engine,
) -> None:
# arrange — a lodged EPC with gov prose but no building part and no heating
# detail (an older gov cert, not a re-survey).
with Session(db_engine) as session:
session.add(
PropertyRow(id=1, portfolio_id=100, landlord_property_id="LP1", uprn=100001)
)
session.add(
PlanModel(portfolio_id=100, property_id=1, scenario_id=5, is_default=True)
)
_add_epc(
session,
property_id=1,
epc_id=1,
source="lodged",
elements={"wall": "Cavity wall, as built", "roof": "Pitched, 250mm"},
)
session.commit()
# act
with Session(db_engine) as session:
rows = ScenarioExportPostgresRepository(session).rows_for(
portfolio_id=100, scenario_id=5, property_ids=[1]
)
# assert — with no structured fabric, the gov prose is the fallback.
row = rows[0]
assert row.walls == "Cavity wall, as built"
assert row.roof == "Pitched, 250mm"
def test_property_type_override_beats_the_lodged_epc(db_engine: Engine) -> None:
# arrange — the lodged EPC says Flat, but a landlord override says House
# (Effective-EPC precedence: override → lodged, ADR-0065).