Slice 90: API mapper translates party_wall_construction → SAP10 enum

The GOV.UK API `party_wall_construction` field uses a different enum
from the regular `wall_construction` field — RdSAP 10 Table 15 (p.31
"U-values of party walls") defines 5 categories that the API encodes
as integer codes 0..5 plus a "NA" string for extensions without a
party wall. The cascade's `u_party_wall` consumes the SAP10
`wall_construction` enum directly, so passing the raw API code gave
wildly wrong U-values (API code 2 = "Cavity masonry unfilled" →
should produce U=0.5, but cascade interpreted code 2 as SAP10
WALL_STONE_SANDSTONE → 0.0 W/m²K).

Impact on cert 001479 (the only golden fixture with party=2 lodged):

  Before: party_walls = 0.00 W/K (cascade applied U=0.0)
  After:  party_walls = 16.21 W/K (cascade applies U=0.5)

  API mapper → cascade SAP delta:
  Before Slice 90: +3.0752
  After  Slice 90: +1.5298

The remaining party-wall shortfall (16.21 vs target 17.07 W/K, -0.87
W/K) is the room_height_m +0.25 SAP convention not yet applied to
the API path — Slice 92 will close that.

Translation table (per `_API_PARTY_WALL_CONSTRUCTION_TO_SAP10`):
  0 → None (no party wall present; party_wall_length=0 anyway)
  1 → SAP10 code 3 (Solid Brick) → u_party_wall = 0.0
  2 → SAP10 code 4 (Cavity)      → u_party_wall = 0.5
  3 → SAP10 code 4 (Cavity)      → cascade emits 0.5 (TODO: 0.2 for
                                    cavity filled needs cascade extension)
  4 → None (Unable, house)       → u_party_wall default 0.25
  5 → None (Unable, flat)        → TODO: spec says 0.0 for flats

Schema change: `SapBuildingPart.party_wall_construction` is now
`Optional[Union[int, str]]` (was `Union[int, str]`) — the "0 sentinel
for Unable" convention was already in cohort hand-builts but the type
forbade the cleaner `None` representation. To preserve the dataclass
"no-default after default" rule, `sap_floor_dimensions` gets a
`field(default_factory=list)`.

Translation applied across all 6 from_rdsap_schema_* mappers + the
flagship `from_rdsap_schema_21_0_1` used by 001479.

Pyright: mapper.py 35 → 33 (cleared 7 cohort party_wall type errors
that were pre-existing, balanced against the schema change). Cohort
cascade pins remain GREEN (66 of 66); no new test regression.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-25 21:21:52 +00:00
parent 006e9842c9
commit fbbdca49ca
2 changed files with 70 additions and 12 deletions

View file

@ -1,5 +1,5 @@
import re import re
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import date from datetime import date
from enum import Enum from enum import Enum
from typing import Final, List, Optional, Union from typing import Final, List, Optional, Union
@ -399,12 +399,12 @@ class SapBuildingPart:
int, str int, str
] # int from API, str from site notes TODO: make enum/mapping? ] # int from API, str from site notes TODO: make enum/mapping?
wall_thickness_measured: bool wall_thickness_measured: bool
party_wall_construction: Union[int, str] # TODO: make enum/mapping? party_wall_construction: Optional[Union[int, str]] = (
None # TODO: make enum/mapping?
)
# Floor # Floor
sap_floor_dimensions: List[ sap_floor_dimensions: List[SapFloorDimension] = field(default_factory=list)
SapFloorDimension
] # Not included in site notes; should this be optional?
# Optional # Optional
building_part_number: Optional[int] = ( building_part_number: Optional[int] = (

View file

@ -480,7 +480,9 @@ class EpcPropertyDataMapper:
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
wall_thickness_measured=bp.wall_thickness_measured == "Y", wall_thickness_measured=bp.wall_thickness_measured == "Y",
party_wall_construction=bp.party_wall_construction, party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction
),
sap_floor_dimensions=[ sap_floor_dimensions=[
SapFloorDimension( SapFloorDimension(
room_height_m=fd.room_height.value, room_height_m=fd.room_height.value,
@ -621,7 +623,9 @@ class EpcPropertyDataMapper:
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
wall_thickness_measured=bp.wall_thickness_measured == "Y", wall_thickness_measured=bp.wall_thickness_measured == "Y",
party_wall_construction=bp.party_wall_construction, party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction
),
sap_floor_dimensions=[ sap_floor_dimensions=[
SapFloorDimension( SapFloorDimension(
room_height_m=_measurement_value(fd.room_height), room_height_m=_measurement_value(fd.room_height),
@ -758,7 +762,9 @@ class EpcPropertyDataMapper:
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
wall_thickness_measured=bp.wall_thickness_measured == "Y", wall_thickness_measured=bp.wall_thickness_measured == "Y",
party_wall_construction=bp.party_wall_construction, party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction
),
sap_floor_dimensions=[ sap_floor_dimensions=[
SapFloorDimension( SapFloorDimension(
room_height_m=_measurement_value(fd.room_height), room_height_m=_measurement_value(fd.room_height),
@ -904,7 +910,9 @@ class EpcPropertyDataMapper:
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
wall_thickness_measured=bp.wall_thickness_measured == "Y", wall_thickness_measured=bp.wall_thickness_measured == "Y",
party_wall_construction=bp.party_wall_construction, party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction
),
sap_floor_dimensions=[ sap_floor_dimensions=[
SapFloorDimension( SapFloorDimension(
room_height_m=_measurement_value(fd.room_height), room_height_m=_measurement_value(fd.room_height),
@ -1067,7 +1075,9 @@ class EpcPropertyDataMapper:
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
wall_thickness_measured=bp.wall_thickness_measured == "Y", wall_thickness_measured=bp.wall_thickness_measured == "Y",
party_wall_construction=bp.party_wall_construction, party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction
),
sap_floor_dimensions=[ sap_floor_dimensions=[
SapFloorDimension( SapFloorDimension(
room_height_m=_measurement_value(fd.room_height), room_height_m=_measurement_value(fd.room_height),
@ -1257,7 +1267,9 @@ class EpcPropertyDataMapper:
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
wall_thickness_measured=bp.wall_thickness_measured == "Y", wall_thickness_measured=bp.wall_thickness_measured == "Y",
party_wall_construction=bp.party_wall_construction, party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction
),
sap_floor_dimensions=[ sap_floor_dimensions=[
SapFloorDimension( SapFloorDimension(
room_height_m=_measurement_value(fd.room_height), room_height_m=_measurement_value(fd.room_height),
@ -1516,7 +1528,9 @@ class EpcPropertyDataMapper:
wall_construction=bp.wall_construction, wall_construction=bp.wall_construction,
wall_insulation_type=bp.wall_insulation_type, wall_insulation_type=bp.wall_insulation_type,
wall_thickness_measured=bp.wall_thickness_measured == "Y", wall_thickness_measured=bp.wall_thickness_measured == "Y",
party_wall_construction=bp.party_wall_construction, party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction
),
sap_floor_dimensions=[ sap_floor_dimensions=[
SapFloorDimension( SapFloorDimension(
room_height_m=_measurement_value(fd.room_height), room_height_m=_measurement_value(fd.room_height),
@ -1889,6 +1903,50 @@ def _elmhurst_party_wall_construction_int(coded: str) -> Optional[int]:
return _ELMHURST_PARTY_WALL_CODE_TO_SAP10.get(_leading_code(coded)) return _ELMHURST_PARTY_WALL_CODE_TO_SAP10.get(_leading_code(coded))
# GOV.UK API party_wall_construction enum → SAP10 wall_construction
# integer (the domain `u_party_wall` consumes). The API uses a different
# enum from the regular wall_construction field — RdSAP 10 Table 15
# (p.31 "U-values of party walls") defines 5 categories, mapped to the
# nearest SAP10 wall_construction code that `u_party_wall` resolves to
# the spec U-value:
# 0 = "Not applicable" / no party wall (detached etc.) → cascade
# returns 0.25 by default but party_wall_length is 0 so the
# contribution is 0 regardless.
# 1 = "Solid masonry / timber frame / system built" → SAP10 code 3
# (WALL_SOLID_BRICK) → u_party_wall = 0.0 (Table 15 row 1).
# 2 = "Cavity masonry unfilled" → SAP10 code 4 (WALL_CAVITY) →
# u_party_wall = 0.5 (Table 15 row 2).
# 3 = "Cavity masonry filled" → spec U=0.2 (Table 15 row 3) — not
# yet representable; the cascade only emits 0.0 / 0.5 / 0.25 from
# the current u_party_wall, so this code rounds up to the
# conservative 0.5 (matches the cavity-unfilled W/K).
# 4 = "Unable to determine, house or bungalow" → None (cascade
# default 0.25).
# 5 = "Unable to determine, flat or maisonette" → cascade should
# return 0.0 per Table 15 footnote * — not yet handled; leave as
# None for now and revisit when a flat fixture surfaces.
# The 'NA' string (commonly lodged on extensions that don't carry a
# party wall) maps to None.
_API_PARTY_WALL_CONSTRUCTION_TO_SAP10: Dict[int, Optional[int]] = {
0: None,
1: 3, # Solid masonry / timber / system → U=0.0
2: 4, # Cavity masonry unfilled → U=0.5
3: 4, # Cavity masonry filled (cascade falls through to 0.5 — TODO)
4: None, # Unable to determine, house — cascade default 0.25
5: None, # Unable to determine, flat — TODO: u_party_wall=0.0 path
}
def _api_party_wall_construction_int(value: Union[int, str, None]) -> Optional[int]:
"""Translate the GOV.UK API `party_wall_construction` integer code
(or 'NA' string) to the SAP10 wall_construction integer the cascade
consumes. See `_API_PARTY_WALL_CONSTRUCTION_TO_SAP10` for the
enum semantics (RdSAP 10 Table 15)."""
if value is None or isinstance(value, str):
return None
return _API_PARTY_WALL_CONSTRUCTION_TO_SAP10.get(value)
def _elmhurst_wall_insulation_int(coded: str) -> Optional[int]: def _elmhurst_wall_insulation_int(coded: str) -> Optional[int]:
"""Map an Elmhurst wall-insulation-type string ('A As Built') to """Map an Elmhurst wall-insulation-type string ('A As Built') to
the SAP10 integer enum (4 = as-built). Returns None on unknown the SAP10 integer enum (4 = as-built). Returns None on unknown