fix(mapper): read sloping_ceiling_insulation_thickness for roof code 8

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 schema dataclasses
dropped that field, so `from_dict` discarded it and the cascade treated
the slope as uninsulated; worse, the pre-1950 None-fallback forced 0 mm
(U=2.30), over-stating roof heat loss ~74%.

Surface the field on SapBuildingPart (schemas 21.0.0 / 21.0.1) and prefer
it in `_api_resolve_sloping_ceiling_thickness` when it carries a NUMERIC
thickness: "100mm" now reaches Table 17 column (1a) "Insulated slope –
sloping ceiling, mineral wool/EPS" (RdSAP 10 §5.11.3 p.44 — 100 mm →
U=0.40) instead of 2.30. Categorical lodgements ("AB" As Built / "NI")
are not measured thicknesses, so they fall through to the existing
as-built rule (Table 18 col (3) via is_pitched_sloping_ceiling).

Cert 9884-3059-9202-7506 (code 8, age B, sloping 100 mm): SAP −5.54 → +0.06.
Cert 8036-2925-6600-0202: −4.94 → +1.55. No regressions in the roof-8
cohort (the "AB" certs are unchanged). Eval headline 43.8% → 44.3% within
0.5; golden fixtures incl. 6035 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 17:37:17 +00:00
parent 98f71d2554
commit bb8307413f
4 changed files with 130 additions and 11 deletions

View file

@ -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

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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