Normalize RdSAP building-part fabric mapping on the degraded 17.0 path

PRD #1385, building-part fabric family. The 17.0 mapper hardcoded
floor_insulation_thickness / sap_room_in_roof to None and omitted
flat_roof_insulation_thickness, all of which the 17.0 schema lodges and the
19.0/20.0/21.x paths read:

  * floor_insulation_thickness: "NI" (no measured thickness) -> None so u_floor
    reaches its age-band default, else pass through (mirrors the 19.0 note).
  * flat_roof_insulation_thickness: pass through (was dropped).
  * sap_room_in_roof: map to SapRoomInRoof(floor_area, construction_age_band),
    None if absent (mirrors 19.0).

17.0-only: the 17.1 SapBuildingPart schema has none of these fields, so its None
there is correct (verified against the dataclass). roof_insulation_thickness is
intentionally left raw -- the sloping-ceiling resolve helper moves roof U-values
and is a separate follow-up.

Verification: new test 3 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions pass; pyright unchanged (39 -> 39, pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-01 13:07:46 +00:00
parent 366adaf8c8
commit 7bb89a36f1
2 changed files with 89 additions and 2 deletions

View file

@ -741,11 +741,32 @@ class EpcPropertyDataMapper:
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=None,
# 17.0 lodges these three building-part fabric fields but the
# mapper hardcoded them to None / omitted them, unlike the
# 19.0/20.0/21.x paths. "NI" (no measured thickness) → None so
# u_floor reaches its age-band default rather than the
# explicit-zero path (see the 19.0 note). roof_insulation_
# thickness is left raw here (the sloping-ceiling resolve
# helper is a separate, roof-U-moving follow-up).
floor_insulation_thickness=(
None
if bp.floor_insulation_thickness == "NI"
else bp.floor_insulation_thickness
),
flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness,
roof_construction=bp.roof_construction,
roof_insulation_location=bp.roof_insulation_location,
roof_insulation_thickness=bp.roof_insulation_thickness,
sap_room_in_roof=None,
sap_room_in_roof=(
SapRoomInRoof(
floor_area=_measurement_value(
bp.sap_room_in_roof.floor_area
),
construction_age_band=bp.sap_room_in_roof.construction_age_band,
)
if bp.sap_room_in_roof
else None
),
)
# RdSAP 10 §6.1 — exclude the glazed conservatory BP from the
# fabric loop; carried as `sap_conservatory` below. Mirrors the

View file

@ -0,0 +1,66 @@
"""Regression: the RdSAP-Schema-17.0 mapper path must carry the lodged
building-part fabric fields, like the 19.0/20.0/21.x paths.
PRD #1385 (mapper normalization), building-part fabric family. 17.0 hardcoded
`floor_insulation_thickness` / `sap_room_in_roof` to None and omitted
`flat_roof_insulation_thickness`, all of which the 17.0 schema lodges and the
sibling paths read. (17.1 is NOT affected its SapBuildingPart schema has none
of these fields, so its None there is correct.) `roof_insulation_thickness` is
intentionally still passed raw; the sloping-ceiling resolve helper is a separate
roof-U-moving follow-up.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# Lodges a room-in-roof (floor_area 48.83, age band K).
_FIXTURE_RIR = Path("tests/fixtures/epc_prediction/BD24JG/cert-75aa70bdd22a.json")
# Lodges a flat-roof insulation thickness code ("AB").
_FIXTURE_FLAT_ROOF = Path("tests/fixtures/epc_prediction/BD24JG/cert-01f1488000e8.json")
def _load(path: Path) -> dict[str, Any]:
data: dict[str, Any] = json.loads(path.read_text())
assert data["schema_type"] == "RdSAP-Schema-17.0"
return data
def test_17_0_maps_lodged_room_in_roof() -> None:
epc = EpcPropertyDataMapper.from_api_response(_load(_FIXTURE_RIR))
rirs = [bp.sap_room_in_roof for bp in epc.sap_building_parts if bp.sap_room_in_roof]
assert rirs, "room-in-roof was dropped (mapped to None)"
assert rirs[0].floor_area == 48.83
assert rirs[0].construction_age_band == "K"
def test_17_0_maps_lodged_flat_roof_insulation_thickness() -> None:
epc = EpcPropertyDataMapper.from_api_response(_load(_FIXTURE_FLAT_ROOF))
vals = [
bp.flat_roof_insulation_thickness
for bp in epc.sap_building_parts
if bp.flat_roof_insulation_thickness is not None
]
assert "AB" in vals
def test_17_0_floor_insulation_thickness_flows_and_ni_maps_to_none() -> None:
# A measured thickness carries through (schema lodges None on the real certs;
# inject to prove the field is no longer hardcoded to None).
data = _load(_FIXTURE_RIR)
data["sap_building_parts"][0]["floor_insulation_thickness"] = "150"
epc = EpcPropertyDataMapper.from_api_response(data)
assert epc.sap_building_parts[0].floor_insulation_thickness == "150"
# The "NI" sentinel (no measured thickness) maps to None so u_floor reaches
# its age-band default, matching the 19.0 path.
data_ni = _load(_FIXTURE_RIR)
data_ni["sap_building_parts"][0]["floor_insulation_thickness"] = "NI"
epc_ni = EpcPropertyDataMapper.from_api_response(data_ni)
assert epc_ni.sap_building_parts[0].floor_insulation_thickness is None