From 9427354d8846cd8c531cf541a641dc679640aa16 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 13:37:14 +0000 Subject: [PATCH] Slice S0380.136: route _is_electric_main / _is_electric_water via the canonical T32-first normaliser (dual-fuel closure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_is_electric_main` and `_is_electric_water` hand-rolled a literal set check `code in {10, 25, 29}` ∪ `{30..40}` to classify a fuel code as electricity. The set conflated two enums: - {10, 25, 29} — API enum codes (epc_codes.csv row main_fuel): 10 = electricity (backwards compat) 25 = electricity (community) 29 = electricity (not community) - {30, 31, ..., 40} — Table 32 codes (RdSAP 10 spec p.95): 30 = standard tariff 31/32 = 7-hour low/high 33/34 = 10-hour low/high 35 = 24-hour heating 38/40 = 18-hour high/low API enum codes 1-29 collide with Table 32 codes 1-29 for unrelated fuels — API 10 = "electricity" vs Table 32 10 = "dual fuel (mineral + wood)". S0380.135's EES dispatch sets `main_fuel_type` to Table 32 codes (BDI → 10 for dual fuel), so a dual-fuel main was silently mis-classified as electric. The `_space_heating_fuel_cost_gbp_per_kwh` tariff branch then re-routed solid fuel 6's space heating cost through the 18-hour-low electric rate (5.50 p/kWh) instead of dual-fuel 3.99 p/kWh — solid fuel 6 SAP residual −7.38 → −11.37 in S0380.135. The fix promotes the existing `table_32._is_electric_code` to public `is_electric_fuel_code` and routes both `_is_electric_main` and `_is_electric_water` through it. The canonical helper normalises a fuel code via T32-first then API-translate fallback (same convention as `unit_price_p_per_kwh`), so a Table-32-code-10 dual-fuel main classifies as non-electric correctly. Subtle behaviour change: API enum code 25 ("electricity (community)") maps via API_FUEL_TO_TABLE_32 to Table 32 code 41 ("heat from electric heat pump (community)") which is a heat network billed at the heat- network rate (4.24 p/kWh single rate), not at the off-peak electric tariff. Pre-S0380.136 the literal-set check would have treated this as direct electric and applied the Table 12a high/low-rate split — that was wrong; community heat networks don't have an off-peak split. The new canonical helper correctly excludes code 41 from _ELECTRIC_FUEL_CODES. Heating-systems corpus impact: solid fuel 6 (Dual Fuel Anthracite Wood, SAP 160): ΔSAP −11.3731 → +1.9493 (now in cluster with other solid-fuel) Δcost +£268.44 → −£44.91 ΔPE unchanged (PE wasn't affected by the cost mis-routing) No other corpus variants moved — none have `main_fuel_type` in the ambiguous API/T32 collision range that was previously mis-classified. Extended handover suite: 879 pass / 0 fail (+2 from new AAA tests covering both `_is_electric_main` and `_is_electric_water` dual-fuel non-electric classification + API code 29 → electric / API code 25 → heat-network non-electric semantics). Pyright net-zero on touched files (43 → 43). No golden fixture impact — no golden cert lodges `main_fuel_type=10` (dual fuel) on the cascade path. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 2 +- .../sap10_calculator/rdsap/cert_to_inputs.py | 35 ++++----- .../rdsap/tests/test_cert_to_inputs.py | 74 +++++++++++++++++++ domain/sap10_calculator/tables/table_32.py | 16 +++- 4 files changed, 105 insertions(+), 22 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index e4857632..9e036869 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -170,7 +170,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983), _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+1.5867, expected_cost_resid_gbp=-36.5606, expected_co2_resid_kg=-78.9461, expected_pe_resid_kwh=+151.1685), _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+1.7045, expected_cost_resid_gbp=-39.2732, expected_co2_resid_kg=-52.5294, expected_pe_resid_kwh=+160.0328), - _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=-11.3731, expected_cost_resid_gbp=+268.4432, expected_co2_resid_kg=+4.8671, expected_pe_resid_kwh=+87.0778), + _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+1.9493, expected_cost_resid_gbp=-44.9072, expected_co2_resid_kg=+4.8671, expected_pe_resid_kwh=+87.0778), _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+2.0439, expected_cost_resid_gbp=-47.0520, expected_co2_resid_kg=-91.3569, expected_pe_resid_kwh=+44.3084), _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+1.8115, expected_cost_resid_gbp=-41.7407, expected_co2_resid_kg=+26.9399, expected_pe_resid_kwh=+87.6830), _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+1.7052, expected_cost_resid_gbp=-39.2906, expected_co2_resid_kg=+28.0233, expected_pe_resid_kwh=+154.9673), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 6dce0a8f..e1530bdc 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -106,6 +106,7 @@ from domain.sap10_calculator.tables.table_12a import ( ) from domain.sap10_calculator.tables.table_32 import ( additional_standing_charges_gbp, + is_electric_fuel_code, unit_price_p_per_kwh as table_32_unit_price_p_per_kwh, ) from domain.sap10_calculator.worksheet.fuel_cost import FuelCostResult, fuel_cost @@ -1142,28 +1143,24 @@ def _is_off_peak_meter(meter_type: object, *, fuel_is_electric: bool) -> bool: def _is_electric_main(main: Optional[MainHeatingDetail]) -> bool: - """Main heating fuel is electricity (codes 29 or 10 in API enum; - Table 32 codes 30-40).""" - code = _main_fuel_code(main) - if code is None: - return False - # API codes that route to electricity - if code in {10, 25, 29}: - return True - # Table 32 electricity codes directly - if code in {30, 31, 32, 33, 34, 35, 36, 38, 39, 40}: - return True - return False + """Main heating fuel is electricity. Delegates to the canonical + Table-32-first normalisation in `table_32.is_electric_fuel_code`. + + Pre-S0380.136 this hand-rolled a literal set check + `code in {10, 25, 29}` (API codes) ∪ `{30..40}` (Table 32 codes). + That silently mis-classified dual-fuel mains (Table 32 code 10 = + "dual fuel mineral+wood", S0380.135 EES dict BDI → 10) as electric, + re-routing space-heating cost to the 7-hour low electric rate + (5.50 p/kWh) instead of dual-fuel 3.99 p/kWh — solid fuel 6 SAP + residual −7.38 → −11.37. + """ + return is_electric_fuel_code(_main_fuel_code(main)) def _is_electric_water(water_heating_fuel: Optional[int]) -> bool: - if water_heating_fuel is None: - return False - if water_heating_fuel in {10, 25, 29}: - return True - if water_heating_fuel in {30, 31, 32, 33, 34, 35, 36, 38, 39, 40}: - return True - return False + """Same as `_is_electric_main` for the water-heating fuel code. + See its docstring for the API/Table 32 collision rationale.""" + return is_electric_fuel_code(water_heating_fuel) # RdSAP 10 Table 32 (page 95) — (high_rate_p, low_rate_p) per tariff. 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 4f7f879b..6cf66365 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -40,6 +40,8 @@ from domain.sap10_calculator.exceptions import ( from domain.sap10_calculator.rdsap.cert_to_inputs import ( _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] + _is_electric_main, # pyright: ignore[reportPrivateUsage] + _is_electric_water, # pyright: ignore[reportPrivateUsage] _main_floor_u_value, # pyright: ignore[reportPrivateUsage] _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] @@ -1058,6 +1060,78 @@ def test_heat_emitter_code_2_underfloor_in_screed_routes_to_responsiveness_0p75_ assert abs(result - 0.75) <= 1e-9 +def test_is_electric_main_dual_fuel_table_32_code_10_is_not_electric() -> None: + # Arrange — API enum code 10 = "electricity (backwards compat)"; Table + # 32 code 10 = "dual fuel (mineral + wood)". Same integer, different + # meaning depending on the enum. Pre-S0380.136 `_is_electric_main` used + # a literal set check `code in {10, 25, 29}` that assumed the value was + # an API code — it silently mis-classified a Table 32 dual-fuel main + # (the S0380.135 EES dispatch BDI → 10) as electric, re-routing space- + # heating cost through the 7-hour low electric rate (5.50 p/kWh) instead + # of dual-fuel 3.99 p/kWh. solid fuel 6 SAP residual −7.38 → −11.37. + # + # The fix delegates to `table_32.is_electric_fuel_code` which normalises + # via T32-first then API-translate fallback (mirrors the pattern in + # `unit_price_p_per_kwh`). + dual_fuel_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=10, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2105, + main_heating_category=4, sap_main_heating_code=160, + ) + + # Act / Assert — dual fuel (Table 32 10) must NOT be electric + assert _is_electric_main(dual_fuel_main) is False + + # Sanity — Table 32 electric codes 30-40 still classify as electric + for t32_electric in (30, 31, 32, 33, 34, 35, 38, 40, 60): + electric_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=t32_electric, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2105, + main_heating_category=2, sap_main_heating_code=191, + ) + assert _is_electric_main(electric_main) is True, ( + f"Table 32 code {t32_electric} should be electric" + ) + + # API enum code 29 = "electricity (not community)" routes to Table 32 + # code 30 (standard electric tariff) — classifies as electric. + api_electric_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=29, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2105, + main_heating_category=2, sap_main_heating_code=191, + ) + assert _is_electric_main(api_electric_main) is True + + # API enum code 25 = "electricity (community)" routes to Table 32 code + # 41 = "heat from electric heat pump (community)", which is a heat + # network billed at the heat-network rate (4.24 p/kWh single rate) + # rather than an off-peak electric tariff. `is_electric_fuel_code` + # correctly classifies it as NOT electric for tariff-handling purposes + # (the Table 12a high/low-rate split doesn't apply to heat networks). + # Pre-S0380.136 the literal-set check `code in {10, 25, 29}` mis- + # treated it as direct electric and applied the off-peak split. + community_electric_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=25, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2105, + main_heating_category=2, sap_main_heating_code=301, + ) + assert _is_electric_main(community_electric_main) is False + + +def test_is_electric_water_dual_fuel_table_32_code_10_is_not_electric() -> None: + # Arrange — same API/Table 32 collision as `_is_electric_main` per + # S0380.136 docstring. + + # Act / Assert — dual fuel WH fuel must NOT be electric + assert _is_electric_water(10) is False + + # Sanity — Table 32 electric codes still classify as electric + for t32_electric in (30, 31, 32, 33, 34, 35, 38, 40, 60): + assert _is_electric_water(t32_electric) is True, ( + f"Table 32 code {t32_electric} should be electric" + ) + + def test_responsiveness_solid_fuel_sap_code_160_returns_0p50_per_table_4a() -> None: # Arrange — SAP 10.2 Table 4a (PDF p.169, "Heating systems"): # diff --git a/domain/sap10_calculator/tables/table_32.py b/domain/sap10_calculator/tables/table_32.py index f07b04e6..e66736e8 100644 --- a/domain/sap10_calculator/tables/table_32.py +++ b/domain/sap10_calculator/tables/table_32.py @@ -194,7 +194,19 @@ def _is_gas_code(fuel_code: Optional[int]) -> bool: return code is not None and code in _GAS_FUEL_CODES -def _is_electric_code(fuel_code: Optional[int]) -> bool: +def is_electric_fuel_code(fuel_code: Optional[int]) -> bool: + """Whether the fuel code maps to a Table 32 electricity row, after + normalising via T32-first then API-translate fallback. + + Use this in preference to ad-hoc literal-set checks like + `code in {10, 25, 29}`: those mix API enum codes (where 10 is + "electricity backwards-compat") and Table 32 codes (where 10 is + "dual fuel mineral+wood"), so a Table-32-code-10 dual-fuel main + silently mis-classifies as electric. The S0380.135 EES-code → + Table 32 mapper lookups set `main_fuel_type` to Table 32 codes + (BDI → 10 = dual fuel), so the literal-set checks fail loudly here + unless normalised through `_to_table_32_code` first. + """ code = _to_table_32_code(fuel_code) return code is not None and code in _ELECTRIC_FUEL_CODES @@ -221,7 +233,7 @@ def additional_standing_charges_gbp( gas_code = main_fuel_code if _is_gas_code(main_fuel_code) else water_heating_fuel_code total += standing_charge_gbp(gas_code) if tariff is not Tariff.STANDARD and ( - _is_electric_code(main_fuel_code) or _is_electric_code(water_heating_fuel_code) + is_electric_fuel_code(main_fuel_code) or is_electric_fuel_code(water_heating_fuel_code) ): off_peak_code = _OFF_PEAK_STANDING_CODE.get(tariff) if off_peak_code is not None: