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>
174 lines
6.9 KiB
Python
174 lines
6.9 KiB
Python
"""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
|