From f0305d54522fc96fb86a204c1e0d03216e6a5072 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 21:10:00 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.120:=20distinguish=20NI=20from=20?= =?UTF-8?q?explicit=20int(0)=20roof=5Finsulation=5Fthickness=20per=20RdSAP?= =?UTF-8?q?=2010=20=C2=A75.11.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../rdsap/tests/test_golden_fixtures.py | 6 ++-- .../worksheet/heat_transmission.py | 31 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 9ef94470..e8cb2bc8 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -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_" diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 71abd946..1c0a6f0c 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -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