diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index a58dcd57..8905d0b6 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2520,6 +2520,25 @@ _ELECTRIC_ROOM_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset( {691, 692, 693, 694, 699} ) +# SAP 10.2 Table 4a electric boilers (PDF p.170, codes 191-196) → their +# Table 12a Grid 1 SH rows (PDF p.191). NOTE the boiler families do NOT all +# share a row — read the spec exactly: +# 191 Direct-acting electric boiler → "Direct-acting electric boiler +# (a)" row: 7-hour 0.90, 10-hour 0.50 (NOT the "Other direct-acting +# electric heating" 1.00/0.50 room-heater row). +# 192 Electric CPSU → "Electric CPSU" row: Appendix F +# (no flat Table 12a fraction — left as a documented gap, see below). +# 193/194 Electric dry core storage boiler → "Electric dry core or water +# 195/196 Electric water storage boiler storage boiler" row: 7-hour +# 0.00 (charged wholly off-peak — identical to the 100%-low-rate the +# None fallback already gave; mapped EXPLICITLY so the spec-correct +# 0.00 is pinned and can't be "fixed" up to a wrong direct-acting 1.00). +_DIRECT_ACTING_ELECTRIC_BOILER_CODES: Final[frozenset[int]] = frozenset({191}) +_ELECTRIC_STORAGE_BOILER_CODES: Final[frozenset[int]] = frozenset( + {193, 194, 195, 196} +) +_ELECTRIC_CPSU_CODES: Final[frozenset[int]] = frozenset({192}) + def _table_12a_system_for_main( main: Optional[MainHeatingDetail], @@ -2539,8 +2558,9 @@ def _table_12a_system_for_main( - Storage heaters (cat 7): 408 → INTEGRATED_STORAGE_DIRECT (0.20), all others → OTHER_STORAGE_HEATERS (0.00) — wired - Underfloor heating (421-422) — TODO - - Direct-acting electric (191) / CPSU (192) / electric storage - boiler (193, 195) — TODO + - Direct-acting electric boiler (191) → 0.90/0.50; electric storage + boilers (193/194/195/196) → 0.00 — wired + - Electric CPSU (192) — Appendix F high-rate cascade still TODO """ if main is None: return None @@ -2601,6 +2621,19 @@ def _table_12a_system_for_main( if code == 408: return Table12aSystem.INTEGRATED_STORAGE_DIRECT return Table12aSystem.OTHER_STORAGE_HEATERS + # Electric boilers (Table 4a codes 191-196) — resolve the misleading TODO + # that lumped them as one "direct-acting" family. They split across THREE + # distinct Table 12a Grid 1 rows (see `_DIRECT_ACTING_ELECTRIC_BOILER_CODES` + # et al). 191 alone is direct-acting (0.90/0.50); 193-196 are storage + # boilers (0.00 = 100% low, the spec-correct value the None fallback gave — + # so this is a forward guard, not a corpus mover); 192 CPSU needs Appendix F + # and is left to fall through to None (the off-peak-low fallback) until the + # Appendix-F high-rate cascade is implemented. + if code is not None and _is_electric_main(main): + if code in _DIRECT_ACTING_ELECTRIC_BOILER_CODES: + return Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER + if code in _ELECTRIC_STORAGE_BOILER_CODES: + return Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE return None diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 01dbdcad..e7169dfe 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -65,6 +65,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S, # pyright: ignore[reportPrivateUsage] dimensions_from_cert, _table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage] + _table_12a_system_for_main, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] _is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] @@ -911,6 +912,49 @@ def test_no_system_electric_heaters_assumed_code_699_bills_direct_acting_split() assert abs(inputs.space_heating_fuel_cost_gbp_per_kwh - 0.1109) <= 1e-9 +def test_electric_boilers_191_195_map_to_distinct_table_12a_grid1_rows() -> None: + # Arrange — SAP 10.2 Table 4a electric boilers (PDF p.170) do NOT share + # one Table 12a Grid 1 row (PDF p.191). Read exactly: code 191 + # "Direct-acting electric boiler (a)" → 7-hour 0.90 / 10-hour 0.50 (its + # OWN row, NOT the 1.00/0.50 "Other direct-acting electric heating" + # room-heater row); code 195 "Electric water storage boiler" → the + # "Electric dry core or water storage boiler" row → 7-hour 0.00 (charged + # wholly off-peak = 100% low rate, identical to the None fallback). This + # pins the spec-correct 0.00 so 195 can't be mis-"fixed" up to a wrong + # direct-acting fraction. + from domain.sap10_calculator.tables.table_12a import ( + Table12aSystem, + Tariff, + space_heating_high_rate_fraction, + ) + + def _electric_boiler_main(code: int) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=0, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, # boiler + sap_main_heating_code=code, + ) + + # Act + direct_acting = _table_12a_system_for_main(_electric_boiler_main(191)) + storage = _table_12a_system_for_main(_electric_boiler_main(195)) + + # Assert — distinct rows with their published fractions. + assert direct_acting is Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER + assert storage is Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE + assert ( + space_heating_high_rate_fraction(direct_acting, Tariff.SEVEN_HOUR) == 0.90 + ) + assert ( + space_heating_high_rate_fraction(direct_acting, Tariff.TEN_HOUR) == 0.50 + ) + assert space_heating_high_rate_fraction(storage, Tariff.SEVEN_HOUR) == 0.00 + + def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None: # Arrange — heat-network main (Table 4a code 301 = community heating, # category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution