S0380.209: API-path wall U — as-built "insulated (assumed)" uses age-band row, not 50mm

The EPC renders a recent-band as-built wall as "<material>, as built,
insulated (assumed)". The API mapper populates epc.walls with that string,
and heat_transmission's wall_ins_present gate keyed off the "insulated"
substring → routed the wall to the RdSAP 50 mm "insulation of unknown
thickness" bucket (e.g. sandstone band J U=0.25) instead of the as-built
age-band row (U=0.35).

Per RdSAP 10 Table 8/9 footnote the 50 mm row applies ONLY when insulation
is "known to have been increased subsequently (otherwise 'as built'
applies)". An "as built ... (assumed)" description is the EPC's age-band
assumption — it only renders on RECENT bands (an old band renders "no
insulation (assumed)"), so the as-built row applies. Genuine retrofit is
signalled by wall_insulation_type (External/Internal/Filled), which the
gate still checks independently.

Worksheet-validated by two new Elmhurst worksheets, both As Built band J:
  - simulated case 9: sandstone   → (29a) U 0.35
  - simulated case 10: solid brick → (29a) U 0.35
both the as-built row, NOT 50 mm (0.25).

Fix: restrict the description-based gate to genuine retrofit via the new
local `_described_as_retrofit_insulated` (excludes "as built"/"(assumed)").
The cavity filled-row routing inside `u_wall` (which uses
`_described_as_insulated` directly) is untouched — the 3 cavity API certs
(0390/0535/7536) are unaffected.

test_heat_transmission: the old `..._uses_50mm_row` test asserted 50 mm via
an IMPOSSIBLE band-B + "insulated (assumed)" combination; corrected to a
valid recent-band (J) scenario asserting the as-built row (35 W/K).

Golden 0240: walls 24.45 → 34.23 W/K (U 0.25 → 0.35). SAP integer 72
unchanged; PE residual re-pinned +1.8687 → +5.5044, CO2 +0.0907 → +0.2757.
This spec-correct fix REMOVED the wall under-count that was masking the
Ext1 vaulted-roof over-count (cascade U 0.68 via the same "insulated
(assumed)" description vs case-9 sloping-ceiling 0.25) — that roof
over-count is the next slice; fixing both lands SAP cont ≈ 72.31 (=
Elmhurst case 9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 20:42:18 +00:00
parent fe59c4d8a2
commit 844fc22f67
4 changed files with 89 additions and 31 deletions

View file

@ -300,6 +300,36 @@ def _parse_thickness_mm(value: Any) -> Optional[int]:
return int(digits) if digits else None
def _described_as_retrofit_insulated(description: Optional[str]) -> bool:
"""True only when the description asserts insulation KNOWN to have
been added subsequently i.e. genuine retrofit, not the age-band
as-built assumption.
RdSAP 10 Table 8/9 footnote routes a wall to the 50 mm "insulation
of unknown thickness" row ONLY when insulation is "known to have been
increased subsequently (otherwise 'as built' applies)". A description
rendered as "as built ... insulated (assumed)" is the EPC's age-band
assumption it renders only on recent age bands where as-built
construction already includes insulation (an old band renders "no
insulation (assumed)"). For those the spec uses the as-built age-band
U-value, NOT the 50 mm retrofit row.
Worksheet evidence: simulated case 9 (sandstone, band J, As Built
U 0.35) and case 10 (solid brick, band J, As Built U 0.35); both
Elmhurst worksheets return the as-built row, not the 50 mm bucket
(which gives ~0.25). Genuine retrofit is signalled by
`wall_insulation_type` (External/Internal/Filled), checked
independently by the `wall_ins_present` gate so excluding the
"as built"/"(assumed)" description here loses no real retrofit signal.
"""
if description is None:
return False
if not _described_as_insulated(description):
return False
desc = description.lower()
return "as built" not in desc and "assumed" not in desc
def _joined_descriptions(elements: list[Any]) -> Optional[str]:
if not elements:
return None
@ -665,14 +695,21 @@ def heat_transmission_from_cert(
wall_construction = _int_or_none(part.wall_construction)
wall_ins_type = _int_or_none(part.wall_insulation_type)
wall_ins_thickness = _parse_thickness_mm(part.wall_insulation_thickness)
# Per RdSAP 10 Table 6 footnote, a wall with "insulated (assumed)"
# or "partial insulation (assumed)" in its description has retrofit
# insulation the assessor hasn't measured the thickness of — even
# when wall_insulation_type=4 ("as-built / assumed"). Treat as
# present so the 50 mm bucket routes correctly.
# RdSAP 10 Table 8/9 footnote: the 50 mm "insulation of unknown
# thickness" row applies only when insulation is "known to have
# been increased subsequently (otherwise 'as built' applies)".
# Genuine retrofit is signalled by `wall_insulation_type`
# (External/Internal/Filled ≠ NONE). An "as built ... insulated
# (assumed)" description is the EPC age-band assumption (it only
# renders on recent bands where as-built already includes
# insulation) → use the as-built age-band row, NOT 50 mm.
# Worksheet-validated by simulated case 9 (sandstone J → 0.35)
# and case 10 (solid brick J → 0.35), both As Built. So the
# description signal is restricted to genuine (non-assumed)
# retrofit via `_described_as_retrofit_insulated`.
wall_ins_present = (
(wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE)
or _described_as_insulated(wall_description)
or _described_as_retrofit_insulated(wall_description)
)
party_construction = _int_or_none(part.party_wall_construction)
raw_roof_thickness = getattr(part, "roof_insulation_thickness", None)
@ -1172,7 +1209,7 @@ def _alt_wall_w_per_k(
alt_thickness = _parse_thickness_mm(alt_wall.wall_insulation_thickness)
alt_insulation_present = (
alt_wall.wall_insulation_type != _WALL_INSULATION_NONE
or _described_as_insulated(wall_description)
or _described_as_retrofit_insulated(wall_description)
)
alt_u = u_wall(
country=country,

View file

@ -52,14 +52,11 @@ def _described_as_insulated(description: Optional[str]) -> bool:
otherwise. Looks for "insulated" or "partial insulation" substrings,
with "no insulation" taking precedence as a hard negation.
Two consumers:
- `u_wall` uses this to route cavity walls to the Filled-cavity row
of Table 6 (in lieu of the bucketed cascade).
- `heat_transmission_from_cert` uses this to set `wall_ins_present`
for non-cavity walls so the 50 mm bucket routing fires per the
RdSAP 10 Table 6 footnote ("If a wall is known to have additional
insulation but the insulation thickness is unknown, use the row
in the table for 50 mm insulation").
Consumer: `u_wall` uses this to route cavity walls to the Filled-
cavity row of Table 6 (in lieu of the bucketed cascade). For the
non-cavity `wall_ins_present` gate, `heat_transmission_from_cert`
further restricts this to genuine (non-assumed) retrofit via its
local `_described_as_retrofit_insulated`.
"""
if description is None:
return False

View file

@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0240-0200-5706-2365-8010",
actual_sap=73,
expected_sap_resid=-1,
expected_pe_resid_kwh_per_m2=+1.8687,
expected_co2_resid_tonnes_per_yr=+0.0907,
expected_pe_resid_kwh_per_m2=+5.5044,
expected_co2_resid_tonnes_per_yr=+0.2757,
notes=(
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + "
"RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_"
@ -183,7 +183,26 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"exact. For 0240 this raises HW fuel slightly → PE +1.6893 → "
"+1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). The lodged "
"73 carries Elmhurst's own residual; case 6 is the spec "
"authority per [[feedback-worksheet-not-api-reference]]."
"authority per [[feedback-worksheet-not-api-reference]]. "
"Slice S0380.209 fixed the API-path wall U: the EPC renders "
"this cert's sandstone (band J, As Built) wall as 'insulated "
"(assumed)', which the cascade wrongly routed to the 50 mm "
"retrofit row (U 0.25). Per RdSAP 10 Table 8/9 footnote the "
"50 mm row is only for insulation 'known to have been "
"increased subsequently'; an 'as built ... (assumed)' "
"description is the age-band assumption (renders only on "
"recent bands) → as-built row U 0.35. Worksheet-validated by "
"simulated case 9 (sandstone J → 0.35) + case 10 (solid brick "
"J → 0.35). walls 24.45 → 34.23 W/K → PE +1.8687 → +5.5044, "
"CO2 +0.0907 → +0.2757 (SAP 72 unchanged). This spec-correct "
"fix REMOVED the wall under-count that was masking the Ext1 "
"vaulted-roof over-count (cascade U 0.68 via the same "
"'insulated (assumed)' description vs case-9 sloping-ceiling "
"0.25) — that roof over-count is the next slice; fixing both "
"lands SAP cont ≈ 72.31 (= Elmhurst case 9). The lodged 73 "
"requires a 2013+ pump (case 7); 0240's API lodges the pump "
"as Unknown (code 0 → 115, proven 0=Unknown across 9 API+"
"Summary pairs), so 73 is unreachable from the lodged inputs."
),
),
_GoldenExpectation(

View file

@ -168,21 +168,26 @@ def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnot
assert result.floor_w_per_k == pytest.approx(31.0, abs=2.0)
def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnote() -> None:
# Arrange — 128 corpus certs lodge solid-brick walls with
# wall_insulation_type=4 ("as-built / assumed") AND description
# "Solid brick, as built, insulated (assumed)". The description
# signals retrofit insulation that the assessor hasn't measured the
# thickness of; RdSAP 10 Table 6 footnote routes this to the 50 mm
# row. Without the description signal, type=4 alone would set
# wall_ins_present=False and the cascade would return the as-built
# U=1.7. With it, U = 0.55 at band B.
def test_solid_brick_as_built_insulated_assumed_uses_as_built_row_per_table9_footnote() -> None:
# Arrange — an "as built, insulated (assumed)" description only renders
# on RECENT age bands (where as-built construction already includes
# insulation per Building Regs); an old band renders "no insulation
# (assumed)". RdSAP 10 Table 8/9 footnote routes to the 50 mm row only
# when insulation is "known to have been increased subsequently
# (otherwise 'as built' applies)" — an age-band assumption is NOT
# known retrofit, so the as-built row applies.
#
# Worksheet-validated: simulated case 9 (sandstone, band J, As Built
# → U 0.35) and case 10 (solid brick, band J, As Built → U 0.35) both
# return the as-built row, NOT the 50 mm bucket (which would give
# U=0.25). This was previously asserted at 55 W/K via an IMPOSSIBLE
# band-B + "insulated (assumed)" combination.
# Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single
# storey → gross_wall = 100 m². walls_w_per_k expected = 0.55 × 100
# = 55 W/K.
# storey → gross_wall = 100 m². walls_w_per_k expected = 0.35 × 100
# = 35 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="B",
construction_age_band="J",
wall_construction=3,
wall_insulation_type=4,
party_wall_construction=1,
@ -211,7 +216,7 @@ def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnot
result = heat_transmission_from_cert(epc)
# Assert
assert result.walls_w_per_k == pytest.approx(55.0, abs=1.0)
assert result.walls_w_per_k == pytest.approx(35.0, abs=1.0)
def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row() -> None: