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