From fbbdca49caede28c08bdd6e9ce1fa6fbb52914c1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 25 May 2026 21:21:52 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2090:=20API=20mapper=20translates=20party?= =?UTF-8?q?=5Fwall=5Fconstruction=20=E2=86=92=20SAP10=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- datatypes/epc/domain/epc_property_data.py | 10 ++-- datatypes/epc/domain/mapper.py | 72 ++++++++++++++++++++--- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index c75e730f..85f1527f 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -1,5 +1,5 @@ import re -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import date from enum import Enum from typing import Final, List, Optional, Union @@ -399,12 +399,12 @@ class SapBuildingPart: int, str ] # int from API, str from site notes TODO: make enum/mapping? 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 - sap_floor_dimensions: List[ - SapFloorDimension - ] # Not included in site notes; should this be optional? + sap_floor_dimensions: List[SapFloorDimension] = field(default_factory=list) # Optional building_part_number: Optional[int] = ( diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index d6d44462..58695b84 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -480,7 +480,9 @@ class EpcPropertyDataMapper: wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, 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=[ SapFloorDimension( room_height_m=fd.room_height.value, @@ -621,7 +623,9 @@ class EpcPropertyDataMapper: wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, 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=[ SapFloorDimension( room_height_m=_measurement_value(fd.room_height), @@ -758,7 +762,9 @@ class EpcPropertyDataMapper: wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, 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=[ SapFloorDimension( room_height_m=_measurement_value(fd.room_height), @@ -904,7 +910,9 @@ class EpcPropertyDataMapper: wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, 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=[ SapFloorDimension( room_height_m=_measurement_value(fd.room_height), @@ -1067,7 +1075,9 @@ class EpcPropertyDataMapper: wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, 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=[ SapFloorDimension( room_height_m=_measurement_value(fd.room_height), @@ -1257,7 +1267,9 @@ class EpcPropertyDataMapper: wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, 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=[ SapFloorDimension( room_height_m=_measurement_value(fd.room_height), @@ -1516,7 +1528,9 @@ class EpcPropertyDataMapper: wall_construction=bp.wall_construction, wall_insulation_type=bp.wall_insulation_type, 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=[ SapFloorDimension( 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)) +# 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]: """Map an Elmhurst wall-insulation-type string ('A As Built') to the SAP10 integer enum (4 = as-built). Returns None on unknown