From 2f5ca85854385e1c2a47ef447ae4a7f74ae541ef Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 10:30:49 +0000 Subject: [PATCH] S0380.215: capture dropped measured wall insulation thickness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- datatypes/epc/domain/epc_property_data.py | 6 +- datatypes/epc/domain/mapper.py | 60 ++++++++++++++++--- .../domain/tests/test_from_rdsap_schema.py | 51 ++++++++++++++++ datatypes/epc/schema/rdsap_schema_21_0_0.py | 5 ++ datatypes/epc/schema/rdsap_schema_21_0_1.py | 5 ++ .../rdsap/test_golden_fixtures.py | 24 ++++++-- 6 files changed, 137 insertions(+), 14 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 1048bed2..39386781 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index d7cb95b2..032a881e 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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], diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index e91ca73a..694726f1 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -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" diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index c8cc6e23..71d2cbf8 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -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 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 242d30b2..59ff41c9 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -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 diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 8ac38714..274c6f93 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -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(