mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
S0380.215: capture dropped measured wall insulation thickness
The schema-21 SapBuildingPart never declared `wall_insulation_thickness_ measured`, so `from_dict` silently discarded it. When a cert lodges `wall_insulation_thickness == "measured"` the actual value (mm) lives in that dropped field, so the cascade fell back to the 50 mm "insulation present, unknown thickness" default instead of the lodged measurement. Cert 2130 Ext1 lodges solid brick band B + INTERNAL insulation "measured"/100 mm. Per RdSAP 10 §5.7 Table 8 (insulated-wall U by age band + insulation thickness) the 100 mm row gives U=0.32; the unknown-thickness fallback gave 0.55. New `_api_resolve_wall_insulation_thickness` substitutes the measured value for the "measured" sentinel; the existing `_insulation_bucket`/Table-8 path then computes the correct U. Field added to schema 21.0.0/21.0.1 SapBuildingPart; domain field widened to Union[str, int] to match `roof_insulation_thickness`. Isolated: 2130 Ext1 is the only bp lodging "measured" across all 47 fixtures. This spec-correct fix EXPOSED an offsetting under-count it had been masking (per the repo's no-special-handling rule — the pre-fix +1 was two bugs cancelling): 2130 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 deferred gas-combi-PE + PV-β-credit under-count from S0380.45/.49, now un-masked — the next slice. Re-pinned 2130 with the cause documented. Suite: 2391 passed, 1 skipped. Zero new pyright errors (mapper 32=32). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e097ce2cef
commit
2f5ca85854
6 changed files with 137 additions and 14 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue