mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +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_construction,
|
||||||
bp.roof_insulation_thickness,
|
bp.roof_insulation_thickness,
|
||||||
bp.construction_age_band,
|
bp.construction_age_band,
|
||||||
|
bp.sloping_ceiling_insulation_thickness,
|
||||||
),
|
),
|
||||||
sap_room_in_roof=_api_build_room_in_roof(
|
sap_room_in_roof=_api_build_room_in_roof(
|
||||||
bp.sap_room_in_roof,
|
bp.sap_room_in_roof,
|
||||||
|
|
@ -1730,6 +1731,7 @@ class EpcPropertyDataMapper:
|
||||||
bp.roof_construction,
|
bp.roof_construction,
|
||||||
bp.roof_insulation_thickness,
|
bp.roof_insulation_thickness,
|
||||||
bp.construction_age_band,
|
bp.construction_age_band,
|
||||||
|
bp.sloping_ceiling_insulation_thickness,
|
||||||
),
|
),
|
||||||
sap_room_in_roof=_api_build_room_in_roof(
|
sap_room_in_roof=_api_build_room_in_roof(
|
||||||
bp.sap_room_in_roof,
|
bp.sap_room_in_roof,
|
||||||
|
|
@ -3203,25 +3205,53 @@ def _api_resolve_wall_insulation_thickness(
|
||||||
return 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(
|
def _api_resolve_sloping_ceiling_thickness(
|
||||||
roof_construction: Optional[int],
|
roof_construction: Optional[int],
|
||||||
roof_insulation_thickness: Union[str, int, None],
|
roof_insulation_thickness: Union[str, int, None],
|
||||||
age_band: Optional[str],
|
age_band: Optional[str],
|
||||||
|
sloping_ceiling_insulation_thickness: Union[str, int, None] = None,
|
||||||
) -> Union[str, int, None]:
|
) -> Union[str, int, None]:
|
||||||
"""Apply Slice 57's pre-1950 sloping-ceiling-roof rule to the API
|
"""Resolve the roof-insulation thickness the cascade should see for a
|
||||||
path: when a "Pitched, sloping ceiling" roof carries no insulation
|
"Pitched, sloping ceiling" (`roof_construction == 8`) API building part.
|
||||||
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.
|
|
||||||
|
|
||||||
Observed on cert 001479 Ext2: age C, roof_construction=8 (PS),
|
A code-8 roof's ceiling follows the slope, so its insulation is lodged
|
||||||
roof_insulation_thickness=None — worksheet U=2.30 (uninsulated PS
|
in the dedicated `sloping_ceiling_insulation_thickness` field, NOT
|
||||||
sloping ceiling); without this rule the cascade returns U=0.40."""
|
`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:
|
if roof_insulation_thickness is not None:
|
||||||
return roof_insulation_thickness
|
return roof_insulation_thickness
|
||||||
if roof_construction != 8: # 8 = Pitched, sloping ceiling
|
if roof_construction != 8:
|
||||||
return roof_insulation_thickness
|
return roof_insulation_thickness
|
||||||
if age_band is None or age_band.upper() not in _PRE_1950_AGE_CODES:
|
if age_band is None or age_band.upper() not in _PRE_1950_AGE_CODES:
|
||||||
return roof_insulation_thickness
|
return roof_insulation_thickness
|
||||||
|
|
|
||||||
|
|
@ -765,6 +765,81 @@ class TestApiResolveWallInsulationThickness:
|
||||||
assert resolved == lodged_thickness
|
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
|
# Glazing-type label cleaning — pdftotext gap-column wrap
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,13 @@ class SapBuildingPart:
|
||||||
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
|
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
|
||||||
floor_insulation_thickness: Optional[str] = None
|
floor_insulation_thickness: Optional[str] = None
|
||||||
flat_roof_insulation_thickness: Optional[Union[str, int]] = 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
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,13 @@ class SapBuildingPart:
|
||||||
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
|
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
|
||||||
floor_insulation_thickness: Optional[str] = None
|
floor_insulation_thickness: Optional[str] = None
|
||||||
flat_roof_insulation_thickness: Optional[Union[str, int]] = 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
|
@dataclass
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue