From 0012ad162517d67737c7dfabf9d2447cb2aa4de8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 24 Jul 2026 14:29:14 +0000 Subject: [PATCH] =?UTF-8?q?Code=20a=20Manual-Entry=20electric=20"Direct=20?= =?UTF-8?q?acting"=20boiler=20to=20SAP=20191=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PasHub Manual-Entry electric boiler lodges "Direct acting" in its "Heating System (Boiler):" age-band cell. `_PASHUB_MANUAL_BOILER_AGE_BAND_TO_TABLE_4B` covers only gas/liquid Table 4b rows, so the descriptor resolved to None and the boiler stayed uncoded — taking the generic 0.80 gas-boiler seasonal efficiency AND, on a Dual meter, `_table_12a_system_for_main`'s None-fallback that bills the whole main-heating load at the off-peak LOW rate. The two errors pull opposite ways, so an identical mis-mapping over-rated one dwelling and under-rated another (fixture 497712825571 +9.5 on a Dual meter; 461386632387 -6.2 on a Single meter). A direct-acting electric "boiler with radiators" is SAP 10.2 Table 4a code 191 (direct-acting electric boiler, efficiency 1.00, §12 Rule 3 tariff). Code it directly in the manual-boiler resolver (gated on fuel Electricity + no PCDB product), so the single code value drives the efficiency table, the Table 12a pricing row and the tariff dispatch together. Both fixtures converge: 497712825571 → +0.6, 461386632387 → +0.4. LRHA within-0.5 52.4% → 53.4%, MAE 0.835. Renamed `_pashub_manual_boiler_table_4b_code` → `_pashub_manual_ boiler_code` (it now also returns the Table 4a electric code). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_pashub_sap_accuracy_lrha_wave3.py | 31 +++++++++++++- datatypes/epc/domain/mapper.py | 41 ++++++++++++++----- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py index 5a978c2b5..999ffebd0 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py @@ -96,11 +96,17 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json" # code-691 dwellings. Routed the resolved `_rdsap_tariff` into the secondary cost # path (mirroring the CO2 path, which already did this) → within-0.5 49.5% → # 52.4%, MAE 0.977. Storage-heater dwellings (7-hour by §12) are unchanged. +# 2026-07-24 (accuracy tail audit): a Manual-Entry electric "Direct acting" +# boiler was left uncoded (Table 4b covers only gas/liquid), so it took the 0.80 +# gas-boiler default AND, on a Dual meter, 100%-off-peak billing → ±6-9 mis-rate. +# Coded it to SAP Table 4a 191 (direct-acting electric boiler, eff 1.00): fixture +# 497712825571 +9.5 → +0.6, 461386632387 -6.2 → +0.4 → within-0.5 52.4% → 53.4%, +# MAE 0.835. # Treat the constants as a regression tripwire, NOT a cohort accuracy figure; # re-baseline (coverage growth is not loosening) as further mapper fixes unblock # fixtures. -_MIN_WITHIN_HALF: float = 0.52 -_MAX_SAP_MAE: float = 0.98 +_MIN_WITHIN_HALF: float = 0.53 +_MAX_SAP_MAE: float = 0.84 _KNOWN_GAP_REASON = ( "pashub `from_site_notes` mapper does not int-code a main-heating " @@ -161,6 +167,27 @@ def _evaluate(deal_id: str) -> Outcome: ) +@pytest.mark.skipif( + "497712825571" not in _BY_ID or "461386632387" not in _BY_ID, + reason="direct-acting electric boiler fixtures not in manifest", +) +@pytest.mark.parametrize("deal_id", ["497712825571", "461386632387"]) +def test_direct_acting_electric_boiler_codes_to_191(deal_id: str) -> None: + """Regression: a Manual-Entry electric "Direct acting" boiler is a + direct-acting electric wet system → SAP Table 4a code 191 (efficiency 1.00, + §12 Rule 3 tariff). Left uncoded it took the generic 0.80 gas-boiler default + AND, on a Dual meter, billed its whole load at the off-peak low rate — two + errors that pull opposite ways (fixture 497712825571 over-rated +9.5 on a + Dual meter; 461386632387 under-rated -6.2 on a Single meter). Coding it 191 + fixes both (the code drives the efficiency table, the Table 12a pricing row + and the §12 tariff together): 497712825571 +9.5 → +0.6, 461386632387 + -6.2 → +0.4. The bound is 1.0 (not 0.5) — the fix removes the ±6-9 mis-code, + leaving only sub-point residuals within the cohort's normal spread.""" + outcome = _evaluate(deal_id) + assert outcome.diff is not None + assert outcome.diff < 1.0, f"direct-acting electric boiler mis-coded: diff {outcome.diff:.2f}" + + @pytest.mark.skipif( "462024626384" not in _BY_ID, reason="fixture 462024626384 not in manifest" ) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 3f8cfb6f9..c3931ee2b 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -8578,14 +8578,24 @@ def _pashub_main_heating_control_code( return code +# SAP 10.2 Table 4a code 191 = "direct-acting electric boiler" (efficiency 1.00, +# §12 Rule 3 tariff dispatch). A Manual-Entry electric boiler lodges "Direct +# acting" in its "Heating System (Boiler):" age-band cell; Table 4b (gas/liquid +# only) does not cover it, so without an explicit code it stayed uncoded → billed +# at the generic 0.80 gas default AND, on a Dual meter, 100% off-peak low-rate — a +# large mis-rate in BOTH directions (over-rate on Dual, under-rate on Single). +_PASHUB_DIRECT_ACTING_ELECTRIC_BOILER_AGE_BAND: Final[str] = "Direct acting" +_SAP_DIRECT_ACTING_ELECTRIC_BOILER_CODE: Final[int] = 191 + # PasHub Manual-Entry boiler descriptor → SAP 10.2 Table 4b sub-row code # (`table_4b.py`), keyed on the surveyed "Heating System (Boiler):" cell # (`boiler_age_band`), which carries both the boiler type and the pre/post-1998 # age band. A Manual-Entry boiler has no PCDB `product_id`, so its efficiency # must come from Table 4b, not the generic 0.80 gas default (#1649 A). Only the -# cohort's observed gas/liquid descriptors are mapped; an unmapped descriptor -# (or an electric "Direct acting" boiler, which Table 4b does not cover) resolves -# to None, preserving the pre-existing behaviour (no block, no regression). +# cohort's observed gas/liquid descriptors are mapped; an unmapped gas/liquid +# descriptor resolves to None, preserving the pre-existing behaviour (no block, +# no regression). The electric "Direct acting" boiler is handled separately +# (Table 4a code 191, above) since it is not a Table 4b row. _PASHUB_MANUAL_BOILER_AGE_BAND_TO_TABLE_4B: Dict[str, int] = { # Gas boilers 1998 or later (Table 4b 101-109). "1998 or later - Condensing combi": 104, # 84/75 winter/summer @@ -8599,14 +8609,22 @@ _PASHUB_MANUAL_BOILER_AGE_BAND_TO_TABLE_4B: Dict[str, int] = { } -def _pashub_manual_boiler_table_4b_code(main: PasHubMainHeating) -> Optional[int]: +def _pashub_manual_boiler_code(main: PasHubMainHeating) -> Optional[int]: """Resolve a Manual-Entry boiler's surveyed descriptor to its SAP 10.2 - Table 4b code (#1649 A). Returns None for a PCDF-Search boiler (product_id - lodged — efficiency is the SEDBUK lookup) and for any descriptor the Table - 4b map does not cover (e.g. an electric boiler), leaving the calculator's - existing cascade untouched for those.""" + main-heating code. Returns None for a PCDF-Search boiler (product_id lodged + — efficiency is the SEDBUK lookup) and for any gas/liquid descriptor the + Table 4b map does not cover, leaving the calculator's existing cascade + untouched. An electric "Direct acting" boiler is a direct-acting electric + wet system → Table 4a code 191 (efficiency 1.00), NOT a Table 4b gas/liquid + row; without this it stayed uncoded and was billed at the 0.80 gas default + and (on a Dual meter) the off-peak low rate.""" if main.product_id > 0: return None + if ( + main.boiler_age_band == _PASHUB_DIRECT_ACTING_ELECTRIC_BOILER_AGE_BAND + and main.fuel == "Electricity" + ): + return _SAP_DIRECT_ACTING_ELECTRIC_BOILER_CODE return _PASHUB_MANUAL_BOILER_AGE_BAND_TO_TABLE_4B.get(main.boiler_age_band) @@ -8645,9 +8663,10 @@ def _pashub_main_heating_system_code(main: PasHubMainHeating) -> Optional[int]: return code if system in _PASHUB_BOILER_SYSTEM_TYPES: # PCDF-Search boiler → None (SEDBUK product efficiency); Manual-Entry - # boiler → its Table 4b code (or None for an uncovered/non-Table-4b - # descriptor, keeping the pre-existing cascade) (#1649 A). - return _pashub_manual_boiler_table_4b_code(main) + # boiler → its Table 4b code, Table 4a 191 for an electric direct-acting + # boiler, or None for an uncovered descriptor (keeping the pre-existing + # cascade) (#1649 A). + return _pashub_manual_boiler_code(main) return None