Slice S0380.120: distinguish NI from explicit int(0) roof_insulation_thickness per RdSAP 10 §5.11.4

RdSAP 10 §5.11.4 (PDF p.44):

  "If retrofit insulation present of unknown thickness use 50 mm."

The cascade encoded "unknown thickness" via the cert's "NI" (Not-
Indicated) sentinel which `_parse_thickness_mm` collapses to int(0).
But that conflates two structurally different signals:

  (a) explicit int(0) — `_api_resolve_sloping_ceiling_thickness`
      returns this for cert 001479 Ext2 PS sloping ceiling age C, a
      per-BP "uninsulated" override of the dwelling-level description
      ("Pitched, insulated" from another BP).
  (b) string "NI" — the cert lodgement marker for "thickness not
      indicated; defer to description"; §5.11.4 should fire when the
      description carries an "insulated" signal.

Pre-slice the heat_transmission cascade dropped `roof_description`
whenever `roof_thickness == 0`, killing the §5.11.4 path in `u_roof`
(line 711) for the (b) case. 346 corpus certs lodge the NI +
"insulated (assumed)" pattern per the §5.11.4 test's arrange comment.

Fix: inspect the raw `part.roof_insulation_thickness` value (pre-
parse) — drop the description only when the lodgement is the literal
int(0), keep it for the "NI" string sentinel so `u_roof`'s §5.11.4
branch fires (`_described_as_insulated` + thickness=0 → return 0.68).

Test movement:
  test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4 → PASS
  test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly → PASS (cohort safe)
  cert 000565 e2e — 11/11 PASS (unaffected — explicit per-BP thicknesses)

Golden corpus impact: cert 0240 had this exact pattern (BP[1] NI + global
description includes "Pitched, insulated (assumed)"). The fix drops its
roof U from 2.30 → 0.68 for that BP, closing massive mapper-gap residuals:

  expected_sap_resid:                 -14    → -10     (Δ +4 SAP)
  expected_pe_resid_kwh_per_m2:    +12.49   → +0.054   (Δ −12.43 kWh/m²)
  expected_co2_resid_tonnes_per_yr:  +0.696 → +0.063   (Δ −0.633 t/yr)

Re-pinned per [[feedback-golden-residuals-near-zero]]: "Re-pin to the
new (smaller) value when a gap closes". The remaining 0240 residuals
(SAP -10 / PE +0.05 / CO2 +0.06) are tiny — the bulk of 0240's mapper
gap is now closed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 21:10:00 +00:00
parent a77f1a284d
commit f0305d5452
2 changed files with 25 additions and 12 deletions

View file

@ -74,9 +74,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0240-0200-5706-2365-8010",
actual_sap=73,
expected_sap_resid=-14,
expected_pe_resid_kwh_per_m2=+12.4933,
expected_co2_resid_tonnes_per_yr=+0.6957,
expected_sap_resid=-10,
expected_pe_resid_kwh_per_m2=+0.0542,
expected_co2_resid_tonnes_per_yr=+0.0626,
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_"

View file

@ -636,7 +636,8 @@ def heat_transmission_from_cert(
or _described_as_insulated(wall_description)
)
party_construction = _int_or_none(part.party_wall_construction)
roof_thickness = _parse_thickness_mm(getattr(part, "roof_insulation_thickness", None))
raw_roof_thickness = getattr(part, "roof_insulation_thickness", None)
roof_thickness = _parse_thickness_mm(raw_roof_thickness)
floor_ins_thickness = _parse_thickness_mm(getattr(part, "floor_insulation_thickness", None))
ground_fd = next(
@ -676,15 +677,27 @@ def heat_transmission_from_cert(
)
# When the per-bp `roof_insulation_thickness` is explicitly lodged
# as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling
# age C from Slice 91's `_api_resolve_sloping_ceiling_thickness`)
# the global `epc.roofs[].description` ("Pitched, insulated" from
# another bp) must NOT override the per-bp truth via u_roof's
# Table 18 footnote (2) assumed-insulation path. Drop the
# description in that case so the cascade returns the spec
# uninsulated U-value (Table 18 row 0). Cohort Summary mappers
# leave `epc.roofs` empty so description is None there anyway.
# age C from Slice 91's `_api_resolve_sloping_ceiling_thickness`,
# which returns the int 0 sentinel) the global
# `epc.roofs[].description` ("Pitched, insulated" from another bp)
# must NOT override the per-bp truth via u_roof's Table 18 footnote
# (2) assumed-insulation path. Drop the description in that case so
# the cascade returns the spec uninsulated U-value (Table 18 row 0).
# Cohort Summary mappers leave `epc.roofs` empty so description is
# None there anyway.
#
# The "NI" string sentinel is the OPPOSITE signal — it means
# "thickness not indicated; defer to description" per RdSAP 10
# §5.11.4 (PDF p.44): "If retrofit insulation present of unknown
# thickness use 50 mm". `_parse_thickness_mm` collapses BOTH int(0)
# and "NI" to 0, so we distinguish by inspecting the RAW lodgement
# value before the parse — explicit `int(0)` drops the description,
# `"NI"` keeps it so `u_roof`'s §5.11.4 branch can fire.
roof_thickness_explicitly_zero = (
isinstance(raw_roof_thickness, int) and raw_roof_thickness == 0
)
effective_roof_description = (
None if roof_thickness == 0 else roof_description
None if roof_thickness_explicitly_zero else roof_description
)
# RdSAP 10 §5.11 Table 18 page 45: column (3) "Flat roof" applies
# when the per-bp roof construction lodges as a flat roof and the