Merge branch 'main' into feature/pashub-heat-emitter-code-1556

This commit is contained in:
Daniel Roth 2026-07-14 16:34:28 +01:00 committed by GitHub
commit abab5bf9ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 76 additions and 5 deletions

View file

@ -5939,7 +5939,9 @@ def _map_main_building_part(
wall_construction=main.walls_construction_type,
wall_insulation_type=main.walls_insulation_type,
wall_thickness_measured=main.wall_thickness_mm is not None,
party_wall_construction=main.party_wall_construction_type,
party_wall_construction=_pashub_party_wall_construction_int(
main.party_wall_construction_type
),
sap_floor_dimensions=_map_floor_dimensions(measurements.main_building.floors),
wall_thickness_mm=main.wall_thickness_mm,
roof_insulation_location=roof_location,
@ -5963,7 +5965,9 @@ def _map_extension_building_part(
wall_construction=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=ext_c.party_wall_construction_type,
party_wall_construction=_pashub_party_wall_construction_int(
ext_c.party_wall_construction_type
),
sap_floor_dimensions=_map_floor_dimensions(ext_m.floors),
wall_thickness_mm=ext_c.wall_thickness_mm,
roof_insulation_location=roof_location,
@ -7093,6 +7097,33 @@ def _pashub_heat_emitter_code(emitter_label: str) -> Union[int, str]:
if code is None:
raise UnmappedPasHubLabel("heat emitter", emitter_label)
return code
# PasHub surveyed `party_wall_construction_type` label → SAP10 party-wall code
# (RdSAP 10 §5.10 Table 15) that `u_party_wall` consumes. Same target codes as
# the GOV.UK-API (`_API_PARTY_WALL_CONSTRUCTION_TO_SAP10`) and Elmhurst
# (`_ELMHURST_PARTY_WALL_CODE_TO_SAP10`) siblings — PasHub just lodges the full
# human label. `0` is the explicit "unknown" sentinel (u_party_wall → 0.25 house
# / 0.0 flat); `None` is "no party wall / no lodging".
_PASHUB_PARTY_WALL_CONSTRUCTION_TO_SAP10: Dict[str, Optional[int]] = {
"Solid Masonry, Timber Frame, or System Built": 3, # U=0.0
"Cavity Masonry, Unfilled": 4, # U=0.5
"Cavity Masonry, Filled": 11, # U=0.2 (WALL_CAVITY_FILLED_PARTY)
"Unable to determine": 0, # unknown sentinel → u_party_wall default
"Not applicable": None, # no party wall
}
def _pashub_party_wall_construction_int(label: Optional[str]) -> Optional[int]:
"""Resolve a PasHub surveyed `party_wall_construction_type` label to its SAP10
party-wall code at the mapper boundary. A blank label is "no lodging" and
returns None; a non-empty label the lookup does not cover strict-raises
`UnmappedPasHubLabel` so the coverage gap is fixed here rather than silently
defaulting every party wall to the U=0.25 house value downstream (#1559)."""
if label is None or not label.strip():
return None
key = label.strip()
if key not in _PASHUB_PARTY_WALL_CONSTRUCTION_TO_SAP10:
raise UnmappedPasHubLabel("party wall construction", label)
return _PASHUB_PARTY_WALL_CONSTRUCTION_TO_SAP10[key]
def _resolve_elmhurst_underfloor_subtype(

View file

@ -1,7 +1,7 @@
import json
import os
from datetime import date
from typing import Any, Dict
from typing import Any, Dict, Optional
import pytest
@ -19,7 +19,7 @@ from datatypes.epc.domain.epc_property_data import (
ShowerOutlet,
ShowerOutlets,
)
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from datatypes.epc.domain.mapper import EpcPropertyDataMapper, UnmappedPasHubLabel
from datatypes.epc.schema.tests.helpers import from_dict
from datatypes.epc.surveys.pashub_rdsap_site_notes import PasHubRdSapSiteNotes
@ -34,6 +34,46 @@ def load(filename: str) -> Dict[str, Any]:
return json.load(f) # type: ignore[no-any-return]
class TestPartyWallConstructionCoding:
"""`from_site_notes` must int-code the PAS Hub `party_wall_construction_type`
label to the SAP10 code `u_party_wall` consumes (RdSAP 10 Table 15), not copy
the raw label. A raw label reads as `None` and every party wall silently takes
the U=0.25 house default phantom heat loss on the ~165/205 "solid" party
walls that should be U=0.0 (Guinness cohort, issue #1559).
"""
@pytest.mark.parametrize(
"label, expected_code",
[
("Solid Masonry, Timber Frame, or System Built", 3), # U=0.0
("Cavity Masonry, Unfilled", 4), # U=0.5
("Cavity Masonry, Filled", 11), # U=0.2 (party-wall-only code)
("Unable to determine", 0), # unknown sentinel → u_party_wall 0.25 (house)
("Not applicable", None), # no party wall
("", None), # no lodging
],
)
def test_label_maps_to_sap_code(
self, label: str, expected_code: Optional[int]
) -> None:
data = load("pashub_rdsap_site_notes_example1.json")
data["building_construction"]["main_building"][
"party_wall_construction_type"
] = label
survey = from_dict(PasHubRdSapSiteNotes, data)
result = EpcPropertyDataMapper.from_site_notes(survey)
assert result.sap_building_parts[0].party_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"][
"party_wall_construction_type"
] = "Reinforced Adamantium"
survey = from_dict(PasHubRdSapSiteNotes, data)
with pytest.raises(UnmappedPasHubLabel):
EpcPropertyDataMapper.from_site_notes(survey)
class TestFromSiteNotesExample1:
"""
Fixture: pashub_rdsap_site_notes_example1.json
@ -472,7 +512,7 @@ class TestFromSiteNotesExample1:
wall_construction="Cavity",
wall_insulation_type="As built",
wall_thickness_measured=True,
party_wall_construction="Cavity Masonry, Unfilled",
party_wall_construction=4, # "Cavity Masonry, Unfilled" → SAP10 code 4 (U=0.5)
sap_floor_dimensions=[
SapFloorDimension(
room_height_m=2.37,