diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index bf29c169..432a5abe 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1455,6 +1455,7 @@ class EpcPropertyDataMapper: bp.roof_construction, bp.roof_insulation_thickness, bp.construction_age_band, + bp.sloping_ceiling_insulation_thickness, ), sap_room_in_roof=_api_build_room_in_roof( bp.sap_room_in_roof, @@ -1730,6 +1731,7 @@ class EpcPropertyDataMapper: bp.roof_construction, bp.roof_insulation_thickness, bp.construction_age_band, + bp.sloping_ceiling_insulation_thickness, ), sap_room_in_roof=_api_build_room_in_roof( bp.sap_room_in_roof, @@ -3203,25 +3205,53 @@ def _api_resolve_wall_insulation_thickness( return wall_insulation_thickness +def _api_thickness_is_numeric(value: Union[str, int, None]) -> bool: + """True when an insulation-thickness lodgement carries a measured value + (an int, or a string whose leading characters are digits, e.g. "100mm"). + Categorical sentinels ("AB" As Built, "NI" Not Insulated) and None are + NOT numeric. Mirrors the cascade's `_parse_thickness_mm` digit-prefix + rule so the two agree on what counts as an observed thickness.""" + if isinstance(value, int): + return True + return isinstance(value, str) and value.strip()[:1].isdigit() + + def _api_resolve_sloping_ceiling_thickness( roof_construction: Optional[int], roof_insulation_thickness: Union[str, int, None], age_band: Optional[str], + sloping_ceiling_insulation_thickness: Union[str, int, None] = None, ) -> Union[str, int, None]: - """Apply Slice 57's pre-1950 sloping-ceiling-roof rule to the API - path: when a "Pitched, sloping ceiling" roof carries no insulation - thickness lodgement on a pre-1950 dwelling (age bands A-D), set - the thickness to 0 mm so the cascade's `u_roof` returns the - uninsulated Table 16 row (U=2.30) rather than the age-band default - (e.g. U=0.40 for age C pitched-with-loft). Mirrors the Elmhurst - `_resolve_sloping_ceiling_thickness` for the API code-based path. + """Resolve the roof-insulation thickness the cascade should see for a + "Pitched, sloping ceiling" (`roof_construction == 8`) API building part. - Observed on cert 001479 Ext2: age C, roof_construction=8 (PS), - roof_insulation_thickness=None — worksheet U=2.30 (uninsulated PS - sloping ceiling); without this rule the cascade returns U=0.40.""" + A code-8 roof's ceiling follows the slope, so its insulation is lodged + in the dedicated `sloping_ceiling_insulation_thickness` field, NOT + `roof_insulation_thickness` (which stays None — the loft-joist field is + meaningless for a slope-following ceiling). When that field carries a + NUMERIC thickness it wins: feeding e.g. "100mm" lets `u_roof` reach + Table 17 column (1a) "Insulated slope – sloping ceiling, mineral + wool/EPS" (RdSAP 10 §5.11.3 page 44 — 100 mm → U=0.40), instead of + treating the slope as uninsulated (U=2.30). Cert 9884-3059-9202-7506 + (code 8, age B, sloping 100 mm) over-stated roof heat loss ~74% before + this preference. A categorical lodgement ("AB" As Built / "NI") is NOT + a measured thickness, so it falls through to the as-built rule below + (Table 18 column (3) age-band default via `is_pitched_sloping_ceiling`, + or the description signal) rather than masking it. + + Otherwise the original Slice 57 rule applies: a code-8 roof with NO + thickness lodged anywhere on a pre-1950 dwelling (age bands A-D) gets + 0 mm so `u_roof` returns the uninsulated Table 16 row (U=2.30) rather + than the age-band default. Observed on cert 001479 Ext2 (age C, code 8, + both thickness fields None) — worksheet U=2.30.""" + if ( + roof_construction == 8 # 8 = Pitched, sloping ceiling + and _api_thickness_is_numeric(sloping_ceiling_insulation_thickness) + ): + return sloping_ceiling_insulation_thickness if roof_insulation_thickness is not None: return roof_insulation_thickness - if roof_construction != 8: # 8 = Pitched, sloping ceiling + if roof_construction != 8: return roof_insulation_thickness if age_band is None or age_band.upper() not in _PRE_1950_AGE_CODES: return roof_insulation_thickness diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index c1ef3ad3..c9fe69b6 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -765,6 +765,81 @@ class TestApiResolveWallInsulationThickness: assert resolved == lodged_thickness +class TestApiResolveSlopingCeilingThickness: + """A "Pitched, sloping ceiling" (`roof_construction == 8`) lodges its + insulation in the dedicated `sloping_ceiling_insulation_thickness` + field, NOT `roof_insulation_thickness` (which stays None — the loft- + joist field is meaningless for a slope-following ceiling). The cascade + must read the sloping-ceiling field so it reaches Table 17 column (1a) + (RdSAP 10 §5.11.3 page 44) — e.g. 100 mm → U=0.40 — rather than the + uninsulated 2.30. Cert 9884-3059-9202-7506 lodges code 8 / age B / + sloping_ceiling 100 mm; before this fix the pre-1950 None-fallback + forced 0 mm (U=2.30) and over-stated roof heat loss ~74%.""" + + def test_sloping_ceiling_thickness_used_for_code_8(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import ( + _api_resolve_sloping_ceiling_thickness, # pyright: ignore[reportPrivateUsage] + ) + + # Act — code 8, no loft-joist thickness, age B (pre-1950), but the + # sloping ceiling carries a lodged 100 mm. + resolved: object = _api_resolve_sloping_ceiling_thickness( + 8, None, "B", "100mm" + ) + + # Assert — the lodged sloping-ceiling thickness wins over the + # pre-1950 None → 0 mm fallback. + assert resolved == "100mm" + + def test_pre_1950_none_fallback_unchanged_without_sloping_field( + self, + ) -> None: + # Arrange + from datatypes.epc.domain.mapper import ( + _api_resolve_sloping_ceiling_thickness, # pyright: ignore[reportPrivateUsage] + ) + + # Act — code 8, no thickness anywhere, pre-1950 age. + resolved: object = _api_resolve_sloping_ceiling_thickness( + 8, None, "C", None + ) + + # Assert — existing Slice 57 behaviour preserved: 0 mm (U=2.30). + assert resolved == 0 + + def test_as_built_sloping_field_falls_through_to_pre_1950_zero(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import ( + _api_resolve_sloping_ceiling_thickness, # pyright: ignore[reportPrivateUsage] + ) + + # Act — code 8, age B (pre-1950), sloping lodged "AB" (As Built — + # categorical, NOT a measured thickness). + resolved: object = _api_resolve_sloping_ceiling_thickness( + 8, None, "B", "AB" + ) + + # Assert — "AB" is not a numeric thickness, so it must NOT win; the + # Slice 57 pre-1950 None → 0 mm (U=2.30) rule still applies. + assert resolved == 0 + + def test_sloping_field_ignored_for_non_code_8(self) -> None: + # Arrange + from datatypes.epc.domain.mapper import ( + _api_resolve_sloping_ceiling_thickness, # pyright: ignore[reportPrivateUsage] + ) + + # Act — code 5 (vaulted) is not a sloping-ceiling code-8; the + # sloping field must not be consumed here. + resolved: object = _api_resolve_sloping_ceiling_thickness( + 5, "200mm", "C", "100mm" + ) + + # Assert — the regular roof_insulation_thickness passes through. + assert resolved == "200mm" + + # --------------------------------------------------------------------------- # Glazing-type label cleaning — pdftotext gap-column wrap # --------------------------------------------------------------------------- diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 6db6fa50..da2125be 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -254,6 +254,13 @@ class SapBuildingPart: wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = None + # Lodged insulation thickness (e.g. "100mm") for a "Pitched, sloping + # ceiling" roof (roof_construction == 8), whose ceiling follows the + # slope so the insulation is NOT at the loft joists. Previously + # undeclared → dropped by `from_dict`, leaving the cascade to treat + # the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by + # `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a). + sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None @dataclass diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index e508c161..c5f456de 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -292,6 +292,13 @@ class SapBuildingPart: wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None floor_insulation_thickness: Optional[str] = None flat_roof_insulation_thickness: Optional[Union[str, int]] = None + # Lodged insulation thickness (e.g. "100mm") for a "Pitched, sloping + # ceiling" roof (roof_construction == 8), whose ceiling follows the + # slope so the insulation is NOT at the loft joists. Previously + # undeclared → dropped by `from_dict`, leaving the cascade to treat + # the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by + # `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a). + sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None @dataclass