From 326066ee1256e80de90700b058412009aa6e3649 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 13:36:17 +0000 Subject: [PATCH] Slice S0380.176: Table 4b combi sub-row dispatch for (61)m MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §4 line 7702 (PDF p.137) defines (61)m as "Combi loss for each month from Table 3a, 3b or 3c (enter '0' if not a combi boiler)". Table 4b sub-rows 128 / 129 / 130 are explicit combi sub- rows per the spec row names: 128: Combi oil boiler, pre-1998 129: Combi oil boiler, 1998 or later 130: Condensing combi oil boiler Pre-slice `_table_3a_combi_loss_default_applies` gated only on `main_heating_category ∈ {1, 2, 3, 6}`. The Elmhurst mapper leaves `main_heating_category=None` on Table 4b liquid-fuel boilers (FAME, HVO, B30K) — the cascade fell through to (61)m=0 despite the lodged SAP code being a combi sub-row, under-counting (62)m by 600 kWh/yr for FAME combi certs. Extended the helper with a `_TABLE_4B_COMBI_OR_CPSU_CODES` fall- through (set already exists for the symmetric `_primary_loss_ applies` Table 4b non-combi branch — see S0380.146). The set carries the canonical combi + CPSU sub-row codes (103/104/107/108/112/113/ 118/120-123/128-130). For cylinder-lodged certs the existing `if epc.has_hot_water_cylinder: combi_loss_override = zero_monthly` guard in `_water_heating_worksheet_and_gains` still pre-empts the combi-loss fall-through correctly — non-combi codes with cylinders remain (61)m=0. Closures (heating-systems corpus 001431): oil 3 (code 128, FAME, no cylinder) ALL EXACT (±0.0000): ΔSAP_c +2.5863 → -0.0000 Δcost -£61.89 → -£0.00 ΔCO2 -14.58 → +0.00 ΔPE -967.10 → +0.00 oil 4 (code 129, FAME, no cylinder) ALL EXACT (±0.0000): ΔSAP_c +2.5603 → +0.0000 Δcost -£56.66 → +£0.00 ΔCO2 -13.35 → +0.00 ΔPE -884.90 → +0.00 Oil 6 (code 126, NOT a combi, with cylinder) unchanged — the fix is gated on the combi sub-row set. Cohort moves from 9 pinned residuals to 7. 933 pass + 0 fail (+1 new mapper test). Pyright net-zero on cert_ to_inputs.py + tests. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 15 +++- .../sap10_calculator/rdsap/cert_to_inputs.py | 16 ++++- .../rdsap/tests/test_cert_to_inputs.py | 72 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 20628972..3bae9dc7 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -433,9 +433,20 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # 32 code-73 price flip (5.44 → 7.64) per S0380.131's TODO. oil 6 # (B30K) carries a cascade-side residual (HW kWh / SH demand / # CO2/PE blend) — see open fronts in the post-S0380.168 handover. + # + # Slice S0380.176 closed oil 3 + oil 4 fully via Table 4b combi sub- + # row dispatch in `_table_3a_combi_loss_default_applies`. Pre-slice + # the helper gated only on `main_heating_category` ∈ {1, 2, 3, 6}; + # the Elmhurst mapper leaves `main_heating_category=None` on Table + # 4b liquid-fuel boilers, so the cascade fell through to (61)m=0 + # despite codes 128/129 being explicit combi sub-rows per SAP 10.2 + # Table 4b row names ("Combi oil boiler, pre-/post-1998"). Adding + # the `_TABLE_4B_COMBI_OR_CPSU_CODES` fall-through lands (61)m at + # the spec Table 3a row 1 keep-hot 600 kWh/yr default. Both oil 3 + # and oil 4 now EXACT on SAP / cost / CO2 / PE. _CorpusExpectation(variant='oil 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), - _CorpusExpectation(variant='oil 3', block='11a', expected_sap_resid=+2.5863, expected_cost_resid_gbp=-61.8906, expected_co2_resid_kg=-14.5815, expected_pe_resid_kwh=-967.0971), - _CorpusExpectation(variant='oil 4', block='11a', expected_sap_resid=+2.5603, expected_cost_resid_gbp=-56.6586, expected_co2_resid_kg=-13.3489, expected_pe_resid_kwh=-884.8990), + _CorpusExpectation(variant='oil 3', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), + _CorpusExpectation(variant='oil 4', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil 6', block='11a', expected_sap_resid=+3.0518, expected_cost_resid_gbp=-69.7943, expected_co2_resid_kg=-240.6595, expected_pe_resid_kwh=-1112.6558), _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0b0dfdae..1322ccea 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4794,10 +4794,24 @@ def _table_3a_combi_loss_default_applies(main: Optional[MainHeatingDetail]) -> b boiler family or a community heat network — outside that set the spec's "enter '0' if not a combi boiler" rule fires and the cascade must zero (61)m. + + The `main_heating_category` route covers the Open EPC API path where + the cert lodges a SAP 10.2 boiler / heat-network category integer. + The `_TABLE_4B_COMBI_OR_CPSU_CODES` fall-through covers the Elmhurst- + path case where the mapper leaves `main_heating_category=None` but + the cert lodges a Table 4b combi sub-row directly (oil 3 / oil 4 in + heating-systems corpus 001431 — SAP codes 128 / 129 "Combi oil + boiler, pre-/post-1998", FAME fuel — Elmhurst's mapper artifact + leaves the category unset). """ if main is None: return False - return main.main_heating_category in _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES + if main.main_heating_category in _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES: + return True + code = main.sap_main_heating_code + if isinstance(code, int) and code in _TABLE_4B_COMBI_OR_CPSU_CODES: + return True + return False def _water_heating_worksheet_and_gains( diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 9aa4b190..d8e448ad 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -4460,6 +4460,78 @@ def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_ ) +def test_table_4b_combi_oil_boiler_applies_table_3a_combi_loss_per_sap_4_line_7702() -> None: + """SAP 10.2 §4 line 7702: "Combi loss for each month from Table 3a, + 3b or 3c (enter '0' if not a combi boiler)". Table 4b sub-rows 128 + ("Combi oil boiler, pre-1998") and 129 ("Combi oil boiler, 1998 or + later") are explicitly combi boilers per the Table 4b row names. + + Pre-slice `_table_3a_combi_loss_default_applies` gated only on + `main_heating_category` ∈ {1, 2, 3, 6}. The Elmhurst mapper leaves + `main_heating_category=None` on Table 4b liquid-fuel boilers (FAME, + HVO, B30K — codes 128/129/130 combi sub-rows and 124/125/126/127 + regular sub-rows). For combi-without-cylinder certs the (61)m + keep-hot default fell through to zero instead of the spec's 600 + kWh/yr Table 3a row 1. + + Worksheet evidence for heating-systems corpus 001431 oil 3 (Table + 4b code 128 + WHC=901 + no cylinder + FAME fuel): + (61)m Jan ≈ 50.96 kWh, annual ≈ 600 kWh/yr + (62)m Jan = 251.39 = 0.85 × (45) + (46) + (61) + (219) HW fuel sum ≈ 3787 kWh/yr (= (62)m / (217)m via Eq D1) + + Pre-slice cascade for oil 3: (61)m = 0, (62)m sum = 1935.37 (≡ (45) + sum, off by -600 vs ws 2535.37), (219) = 2876 (off by -911 vs ws + 3787). + """ + # Arrange — synthesise oil 3 / oil 4 cert shape: Table 4b code 128 + # combi oil boiler (FAME fuel = Table 32 code 73), no cylinder, + # WHC=901 ("HW from main heating"), main_heating_category=None + # (Elmhurst mapper artifact). + combi_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=73, # FAME fuel + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=None, # Elmhurst leaves None on Table 4b + sap_main_heating_code=128, # Combi oil boiler, pre-1998 + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=False, # combi → no cylinder + sap_building_parts=[make_building_part()], + sap_heating=make_sap_heating( + main_heating_details=[combi_main], + water_heating_code=901, + water_heating_fuel=73, + ), + ) + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=0.62, # summer eff for Table 4b code 128 + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — (61)m must be Table 3a row 1 default (600 kWh/yr + # prorated by days-in-month), NOT zero. Sum must land at ~600. + assert wh_result is not None + annual_combi_loss = sum(wh_result.combi_loss_monthly_kwh) + assert abs(annual_combi_loss - 600.0) < 1e-3, ( + f"(61)m sum: got {annual_combi_loss!r}, want 600.0 — Table 4b " + f"combi sub-row 128 must apply Table 3a row 1 keep-hot default " + f"per SAP 10.2 §4 line 7702. Pre-slice cascade gated on " + f"main_heating_category ∈ {{1, 2, 3, 6}} only; the Elmhurst-" + f"path Table 4b combi codes (128/129/130) need a fall-through." + ) + + def test_lighting_co2_factor_blends_table_12a_grid_2_with_table_12d_dual_rate_on_off_peak_certs() -> None: """SAP 10.2 Table 12a Grid 2 (PDF p.191) + Table 12d (PDF p.194) — "other electricity uses" (lighting, pumps + fans, electric shower) on