mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
98f71d2554
commit
bb8307413f
4 changed files with 130 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue