S0380.215: capture dropped measured wall insulation thickness

The schema-21 SapBuildingPart never declared `wall_insulation_thickness_
measured`, so `from_dict` silently discarded it. When a cert lodges
`wall_insulation_thickness == "measured"` the actual value (mm) lives in
that dropped field, so the cascade fell back to the 50 mm "insulation
present, unknown thickness" default instead of the lodged measurement.

Cert 2130 Ext1 lodges solid brick band B + INTERNAL insulation
"measured"/100 mm. Per RdSAP 10 §5.7 Table 8 (insulated-wall U by age band
+ insulation thickness) the 100 mm row gives U=0.32; the unknown-thickness
fallback gave 0.55. New `_api_resolve_wall_insulation_thickness` substitutes
the measured value for the "measured" sentinel; the existing
`_insulation_bucket`/Table-8 path then computes the correct U. Field added
to schema 21.0.0/21.0.1 SapBuildingPart; domain field widened to
Union[str, int] to match `roof_insulation_thickness`. Isolated: 2130 Ext1
is the only bp lodging "measured" across all 47 fixtures.

This spec-correct fix EXPOSED an offsetting under-count it had been masking
(per the repo's no-special-handling rule — the pre-fix +1 was two bugs
cancelling): 2130 cont SAP 83.35 → 83.78 (resid +1 → +2), PE -7.56 →
-11.72, CO2 -0.045 → -0.095. The exposed -11.72 PE (~-746 kWh/yr) is the
deferred gas-combi-PE + PV-β-credit under-count from S0380.45/.49, now
un-masked — the next slice. Re-pinned 2130 with the cause documented.

Suite: 2391 passed, 1 skipped. Zero new pyright errors (mapper 32=32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 10:30:49 +00:00
parent e097ce2cef
commit 2f5ca85854
6 changed files with 137 additions and 14 deletions

View file

@ -472,7 +472,11 @@ class SapBuildingPart:
)
wall_dry_lined: Optional[bool] = None # Don't think we have this in site notes
wall_thickness_mm: Optional[int] = None
wall_insulation_thickness: Optional[str] = None
# Union[str, int]: a numeric mm value when the API lodges
# `wall_insulation_thickness == "measured"` (resolved from the
# separate measured field), else the lodged string ("NI", a numeric
# string, etc.). Mirrors `roof_insulation_thickness`.
wall_insulation_thickness: Optional[Union[str, int]] = None
sap_alternative_wall_1: Optional[SapAlternativeWall] = None
sap_alternative_wall_2: Optional[SapAlternativeWall] = None

View file

@ -560,7 +560,10 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=None,
roof_construction=bp.roof_construction,
@ -693,7 +696,10 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=None,
roof_construction=bp.roof_construction,
@ -826,7 +832,10 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -985,7 +994,10 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -1161,7 +1173,10 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -1378,7 +1393,10 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -1640,7 +1658,10 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -2928,6 +2949,31 @@ def _parse_rir_insulation_thickness_mm(value: Any) -> Optional[int]:
return int(m.group(1)) if m else None
def _api_resolve_wall_insulation_thickness(
wall_insulation_thickness: Union[str, int, None],
wall_insulation_thickness_measured: Union[str, int, None],
) -> Union[str, int, None]:
"""Resolve the wall insulation thickness for the cascade.
When the cert lodges `wall_insulation_thickness == "measured"` the
actual value sits in the separate `wall_insulation_thickness_measured`
field (mm). RdSAP 10 §5.7/Table 8 use the measured thickness to pick
the insulated-wall U-value row; without it the cascade falls back to
the 50 mm "insulation present, unknown thickness" default (e.g. cert
2130 Ext1: solid brick band B + internal insulation lodged 100 mm
Table 8 U=0.32, not the 50 mm default 0.55).
Any other lodgement (numeric string, "NI", None) passes through
unchanged."""
if (
isinstance(wall_insulation_thickness, str)
and wall_insulation_thickness.strip().lower() == "measured"
and wall_insulation_thickness_measured is not None
):
return wall_insulation_thickness_measured
return wall_insulation_thickness
def _api_resolve_sloping_ceiling_thickness(
roof_construction: Optional[int],
roof_insulation_thickness: Union[str, int, None],

View file

@ -697,3 +697,54 @@ class TestFromRdSapSchema21_0_1:
assert rhi.impact_of_cavity_insulation_kwh == -122.0
assert rhi.impact_of_solid_wall_insulation_kwh == -3560.0
# ---------------------------------------------------------------------------
# Measured wall insulation thickness (`wall_insulation_thickness == "measured"`)
# ---------------------------------------------------------------------------
class TestApiResolveWallInsulationThickness:
"""`wall_insulation_thickness == "measured"` resolves to the separate
`wall_insulation_thickness_measured` field (previously dropped by
`from_dict`, leaving the cascade on the 50 mm unknown-thickness
default). Cert 2130 Ext1 lodges solid brick band B + internal
insulation "measured"/100 mm RdSAP 10 Table 8 U=0.32, not 0.55."""
def test_measured_string_resolves_to_measured_value(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import (
_api_resolve_wall_insulation_thickness,
)
# Act
resolved = _api_resolve_wall_insulation_thickness("measured", 100)
# Assert
assert resolved == 100
def test_non_measured_lodgement_passes_through_unchanged(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import (
_api_resolve_wall_insulation_thickness,
)
# Act
ni: object = _api_resolve_wall_insulation_thickness("NI", 100)
none_thk: object = _api_resolve_wall_insulation_thickness(None, None)
# Assert
assert ni == "NI"
assert none_thk is None
def test_measured_without_value_passes_through(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import (
_api_resolve_wall_insulation_thickness,
)
# Act
resolved: object = _api_resolve_wall_insulation_thickness("measured", None)
# Assert
assert resolved == "measured"

View file

@ -241,6 +241,11 @@ class SapBuildingPart:
sap_alternative_wall_2: Optional[SapAlternativeWall] = None
wall_thickness: Optional[int] = None
wall_insulation_thickness: Optional[str] = None
# Lodged measured insulation thickness (mm) backing a
# `wall_insulation_thickness == "measured"` lodgement. Previously
# undeclared, so `from_dict` silently dropped it and the cascade fell
# back to the 50 mm "insulation present, unknown thickness" default.
wall_insulation_thickness_measured: Optional[Union[str, int]] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None

View file

@ -279,6 +279,11 @@ class SapBuildingPart:
sap_alternative_wall_2: Optional[SapAlternativeWall] = None
wall_thickness: Optional[int] = None
wall_insulation_thickness: Optional[str] = None
# Lodged measured insulation thickness (mm) backing a
# `wall_insulation_thickness == "measured"` lodgement. Previously
# undeclared, so `from_dict` silently dropped it and the cascade fell
# back to the 50 mm "insulation present, unknown thickness" default.
wall_insulation_thickness_measured: Optional[Union[str, int]] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None

View file

@ -434,9 +434,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="2130-1033-4050-5007-8395",
actual_sap=82,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-7.5579,
expected_co2_resid_tonnes_per_yr=-0.0454,
expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-11.7236,
expected_co2_resid_tonnes_per_yr=-0.0947,
notes=(
"End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, "
"postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays "
@ -445,9 +445,21 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"from -38.63 to -9.70. Slice S0380.49 wired effective monthly "
"Table 12e PE factor (vs annual 1.501/0.501) into the PV "
"split: residual closed -9.70 → -8.22. SAP integer shifted "
"+1 (82 → 83) via the cohort cascade interaction. Remaining "
"-8.22 residual sits in gas combi PE under-count + secondary "
"heating credit (deferred)."
"+1 (82 → 83) via the cohort cascade interaction. "
"Slice S0380.215 fixed the dropped measured wall insulation: "
"Ext1 lodges solid-brick band B + INTERNAL insulation "
"`wall_insulation_thickness='measured'` with the actual 100 mm "
"in the separate `wall_insulation_thickness_measured` field "
"that the schema didn't declare, so `from_dict` discarded it "
"and the cascade fell back to the 50 mm unknown-thickness "
"default (U=0.55). Wiring it through → RdSAP 10 Table 8 U=0.32 "
"(less wall loss). This SPEC-CORRECT fix EXPOSED the offsetting "
"PV-β / gas-combi-PE under-count it had been masking: cont SAP "
"83.35 → 83.78 (resid +1 → +2), PE -7.56 → -11.72, CO2 -0.045 "
"→ -0.095. The exposed -11.72 PE (~-746 kWh/yr) is the same "
"deferred gas-combi-PE + PV-β-credit under-count from S0380.45/"
".49 — now un-masked. Closing it is the next slice (needs the "
"deferred PV/combi-PE work + ideally a 2130 worksheet)."
),
),
_GoldenExpectation(