mapper: Elmhurst path populates roof_construction (int) for cross-mapper parity

The gov-EPC API mapper sets BOTH roof_construction (int) and
roof_construction_type (str, derived via _API_ROOF_CONSTRUCTION_TO_STR),
but the Elmhurst mapper set only the string — leaving roof_construction
None on every site-notes cert. The SAP cascade reads the STRING (so SAP
cross-mapper parity always held), but consumers of the int (e.g.
domain/sap10_ml/transform.py ML aggregates `main_dwelling_roof_
construction`) silently saw None on the Elmhurst path.

New `_elmhurst_roof_construction_int` maps the Elmhurst roof-type code to
the same SAP10 int the API lodges (F→1, PN→3, PA→4, PS→8, S/A→7),
harvested from the committed Summary fixtures. Unlike the wall map it
returns None (not a strict-raise) for unmapped codes: the int is not
cascade-load-bearing, so an unknown roof must not block the cert (vaulted
5 / thatched 6 / NR omitted until a fixture surfaces them).

The 6 hand-built U985 reference fixtures gain the matching
roof_construction int (4/4/3 etc.) so test_from_elmhurst_site_notes_
matches_hand_built_* still asserts structural parity. SAP output is
unchanged (cascade reads the string). §4 suite green (2407 passed); the
two pre-existing stone-§5.6 sap10_ml failures are unrelated/out of scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 21:16:20 +00:00
parent 3684a142ac
commit f326e4eb53
8 changed files with 83 additions and 0 deletions

View file

@ -4361,6 +4361,32 @@ def test_elmhurst_foam_cylinder_insulation_still_maps_to_factory_code_1() -> Non
assert code == 1
def test_elmhurst_roof_construction_int_matches_api_codes() -> None:
# Arrange — cross-mapper structural parity: the gov-EPC API mapper
# populates BOTH roof_construction (int) and roof_construction_type
# (str derived via `_API_ROOF_CONSTRUCTION_TO_STR`), but the Elmhurst
# mapper set only the string, leaving the int None. The SAP cascade
# reads the string (so SAP parity held), but consumers of the int
# (e.g. domain/sap10_ml ML aggregates) saw None on every site-notes
# cert. `_elmhurst_roof_construction_int` closes the gap, mapping the
# Elmhurst roof code to the same SAP10 int the API lodges. Unmapped
# codes return None (not a raise) — the int is not cascade-load-
# bearing, so an unknown roof must not block the cert.
from datatypes.epc.domain.mapper import _elmhurst_roof_construction_int # pyright: ignore[reportPrivateUsage]
# Act / Assert — each Elmhurst roof code → the gov-EPC API int.
assert _elmhurst_roof_construction_int("F Flat") == 1
assert _elmhurst_roof_construction_int("PN Pitched (slates/tiles), no access") == 3
assert _elmhurst_roof_construction_int("PA Pitched (slates/tiles), access to loft") == 4
assert _elmhurst_roof_construction_int("PS Pitched, sloping ceiling") == 8
assert _elmhurst_roof_construction_int("S Same dwelling above") == 7
assert _elmhurst_roof_construction_int("A Another dwelling above") == 7
# Absent / unmapped → None (no raise; not cascade-load-bearing).
assert _elmhurst_roof_construction_int(None) is None
assert _elmhurst_roof_construction_int("") is None
assert _elmhurst_roof_construction_int("NR Non-residential space above") is None
def test_elmhurst_wall_is_basement_disambiguates_system_built_from_basement() -> None:
# Arrange — "SY System build" and "B Basement wall" both map to SAP10
# wall_construction=6 (canonical WALL_SYSTEM_BUILT). The explicit

View file

@ -2314,6 +2314,40 @@ def _elmhurst_dwelling_type(
return f"{position} flat"
# Elmhurst roof-type codes → SAP10 roof_construction integer, matching the
# gov-EPC API codes in `_API_ROOF_CONSTRUCTION_TO_STR` so the two
# front-ends populate the same field. Harvested from the committed
# Elmhurst Summary fixtures (corpus + cohort): F/PN/PA/PS/S/A. Vaulted (5)
# and thatched (6) are omitted until a fixture surfaces their Elmhurst
# codes. NR ("Non-residential space above") is intentionally left
# unmapped — the gov enum's code 7 is specifically "dwelling above".
_ELMHURST_ROOF_CODE_TO_SAP10: Dict[str, int] = {
"F": 1, # Flat
"PN": 3, # Pitched (slates/tiles), no access (to loft)
"PA": 4, # Pitched (slates/tiles), access to loft
"PS": 8, # Pitched, sloping ceiling
"S": 7, # Same dwelling above
"A": 7, # Another dwelling above
}
def _elmhurst_roof_construction_int(coded: Optional[str]) -> Optional[int]:
"""Map an Elmhurst roof_type string ('PA Pitched (slates/tiles),
access to loft') to the SAP10 `roof_construction` integer the gov-EPC
API lodges (4), so the site-notes and API front-ends populate the
same field (cross-mapper structural parity).
Returns None for an absent or unmapped code and, unlike
`_elmhurst_wall_construction_int`, does NOT raise. `roof_construction`
(int) is not read by the SAP cascade (which reads the string
`roof_construction_type`, populated on both paths), so an unmapped
roof code stays None the pre-existing Elmhurst behaviour rather
than blocking the cert."""
if not coded:
return None
return _ELMHURST_ROOF_CODE_TO_SAP10.get(_leading_code(coded))
def _elmhurst_wall_construction_int(coded: str) -> Optional[int]:
"""Map an Elmhurst wall_type string ('CA Cavity') to the SAP10
integer code (4). Returns None when the lodging is absent (empty
@ -3494,6 +3528,7 @@ def _map_elmhurst_building_part(
if walls.insulation_thickness_mm is not None
else None
),
roof_construction=_elmhurst_roof_construction_int(roof.roof_type),
roof_construction_type=_strip_code(roof.roof_type),
roof_insulation_location=_strip_code(roof.insulation),
roof_insulation_thickness=_resolve_sloping_ceiling_thickness(

View file

@ -65,6 +65,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
@ -98,6 +100,8 @@ def build_epc() -> EpcPropertyData:
)
extension_1 = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
@ -130,6 +134,8 @@ def build_epc() -> EpcPropertyData:
)
extension_2 = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_2,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=3,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -63,6 +63,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -64,6 +64,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
@ -133,6 +135,8 @@ def build_epc() -> EpcPropertyData:
)
extension = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -60,6 +60,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4, # "A As Built"
@ -130,6 +132,8 @@ def build_epc() -> EpcPropertyData:
)
extension = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -65,6 +65,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
@ -97,6 +99,8 @@ def build_epc() -> EpcPropertyData:
)
extension = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -69,6 +69,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=3,
construction_age_band="A",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,