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(