Map PasHub walls_construction_type labels to SAP10 codes

`from_site_notes` copied the raw `walls_construction_type` survey label
onto the building part; the calculator's `_int_or_none` read it as `None`,
so every wall fell back to the age-band + thickness U-value and lost the
solid/cavity/timber/system-built distinction.

Add `_pashub_wall_construction_int` (mirroring `_pashub_party_wall_
construction_int`) mapping the four surveyed labels to the WALL_* codes
`u_wall` consumes, strict-raising `UnmappedPasHubLabel` on an unknown
label and passing a blank label through as the empty-string "no lodging"
sentinel (the field is `Union[int, str]`). On the Guinness GMCA cohort
this trims SAP MAE 3.22 -> 3.11 and lifts within-1.0 12.9% -> 14.9%.

Closes #1560

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-14 16:11:36 +00:00
parent 42543b0f42
commit aa338dd152
2 changed files with 78 additions and 5 deletions

View file

@ -5936,7 +5936,9 @@ def _map_main_building_part(
return SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band=_extract_age_band(main.age_range),
wall_construction=main.walls_construction_type,
wall_construction=_pashub_wall_construction_int(
main.walls_construction_type
),
wall_insulation_type=main.walls_insulation_type,
wall_thickness_measured=main.wall_thickness_mm is not None,
party_wall_construction=_pashub_party_wall_construction_int(
@ -5962,7 +5964,9 @@ def _map_extension_building_part(
return SapBuildingPart(
identifier=BuildingPartIdentifier.extension(ext_c.id),
construction_age_band=_extract_age_band(ext_c.age_range),
wall_construction=ext_c.walls_construction_type,
wall_construction=_pashub_wall_construction_int(
ext_c.walls_construction_type
),
wall_insulation_type=ext_c.walls_insulation_type,
wall_thickness_measured=ext_c.wall_thickness_mm is not None,
party_wall_construction=_pashub_party_wall_construction_int(
@ -7126,6 +7130,36 @@ def _pashub_party_wall_construction_int(label: Optional[str]) -> Optional[int]:
return _PASHUB_PARTY_WALL_CONSTRUCTION_TO_SAP10[key]
# PasHub surveyed `walls_construction_type` label → SAP10 `wall_construction`
# code (the WALL_* constants in domain.sap10_ml.rdsap_uvalues) that `u_wall`
# consumes. Same target code-space as the GOV.UK-API (`_api_wall_construction_
# code`) and Elmhurst (`_ELMHURST_WALL_CODE_TO_SAP10`) siblings — PasHub just
# lodges the full human label.
_PASHUB_WALL_CONSTRUCTION_TO_SAP10: Dict[str, int] = {
"Solid brick": 3, # WALL_SOLID_BRICK
"Cavity": 4, # WALL_CAVITY
"Timber frame": 5, # WALL_TIMBER_FRAME
"System Build (i.e Any Other)": 6, # WALL_SYSTEM_BUILT
}
def _pashub_wall_construction_int(label: Optional[str]) -> Union[int, str]:
"""Resolve a PasHub surveyed `walls_construction_type` label to its SAP10
`wall_construction` code at the mapper boundary. A blank label is "no lodging"
and passes through as the empty string (the `SapBuildingPart.wall_construction`
field is `Union[int, str]`, and the calculator's `_int_or_none` resolves a
non-numeric string to None the age-band/thickness U-value fallback); a
non-empty label the lookup does not cover strict-raises `UnmappedPasHubLabel`
so the coverage gap is fixed here rather than silently dropping the
construction distinction downstream (#1560)."""
if label is None or not label.strip():
return ""
key = label.strip()
if key not in _PASHUB_WALL_CONSTRUCTION_TO_SAP10:
raise UnmappedPasHubLabel("wall construction", label)
return _PASHUB_WALL_CONSTRUCTION_TO_SAP10[key]
def _resolve_elmhurst_underfloor_subtype(
main_floor: ElmhurstFloorDetails,
main_age_band: str,

View file

@ -74,6 +74,45 @@ class TestPartyWallConstructionCoding:
EpcPropertyDataMapper.from_site_notes(survey)
class TestWallConstructionCoding:
"""`from_site_notes` must int-code the PAS Hub `walls_construction_type` label
to the SAP10 `wall_construction` code `u_wall` consumes (WALL_* constants),
not copy the raw label. A raw label reads as `None`, so the wall U-value falls
back to age band + thickness only and loses the solid/cavity/timber/system
distinction (Guinness cohort, issue #1560).
"""
@pytest.mark.parametrize(
"label, expected_code",
[
("Solid brick", 3), # WALL_SOLID_BRICK
("Cavity", 4), # WALL_CAVITY
("Timber frame", 5), # WALL_TIMBER_FRAME
("System Build (i.e Any Other)", 6), # WALL_SYSTEM_BUILT
("", ""), # no lodging — passes through (calc nulls it via _int_or_none)
],
)
def test_label_maps_to_sap_code(
self, label: str, expected_code: "int | str"
) -> None:
data = load("pashub_rdsap_site_notes_example1.json")
data["building_construction"]["main_building"][
"walls_construction_type"
] = label
survey = from_dict(PasHubRdSapSiteNotes, data)
result = EpcPropertyDataMapper.from_site_notes(survey)
assert result.sap_building_parts[0].wall_construction == expected_code
def test_unknown_label_strict_raises(self) -> None:
data = load("pashub_rdsap_site_notes_example1.json")
data["building_construction"]["main_building"][
"walls_construction_type"
] = "Woven Basket"
survey = from_dict(PasHubRdSapSiteNotes, data)
with pytest.raises(UnmappedPasHubLabel):
EpcPropertyDataMapper.from_site_notes(survey)
class TestFromSiteNotesExample1:
"""
Fixture: pashub_rdsap_site_notes_example1.json
@ -259,8 +298,8 @@ class TestFromSiteNotesExample1:
assert result.sap_building_parts[0].construction_age_band == "I"
def test_wall_construction(self, result: EpcPropertyData) -> None:
# main_building.walls_construction_type: "Cavity"
assert result.sap_building_parts[0].wall_construction == "Cavity"
# main_building.walls_construction_type: "Cavity" → SAP10 code 4 (WALL_CAVITY)
assert result.sap_building_parts[0].wall_construction == 4
def test_wall_insulation_type(self, result: EpcPropertyData) -> None:
# main_building.walls_insulation_type: "As built"
@ -509,7 +548,7 @@ class TestFromSiteNotesExample1:
SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="I",
wall_construction="Cavity",
wall_construction=4, # "Cavity" → SAP10 code 4 (WALL_CAVITY)
wall_insulation_type="As built",
wall_thickness_measured=True,
party_wall_construction=4, # "Cavity Masonry, Unfilled" → SAP10 code 4 (U=0.5)