"""RdSAP10 Table 32 value-correctness tests. Locks unit prices, standing charges, PV export credit, and the Table 12 note (a) standing-charge gating against the published RdSAP10 specification at `domain/sap10_calculator/docs/specs/RdSAP 10 Specification 10-06-2025.pdf`, page 95 (Table 32). RdSAP10 §19.1: "The SAP rating for RdSAP 10 is to be calculated using Table 32 prices (not Table 12) for section 10a and 10b." ADR-0010 amended to target RdSAP10 for §10a cost following the §10a rewrite. """ from __future__ import annotations import pytest from domain.sap10_calculator.tables.table_12a import Tariff from domain.sap10_calculator.tables.table_32 import ( additional_standing_charges_gbp, standing_charge_gbp, unit_price_p_per_kwh, ) @pytest.mark.parametrize( "fuel_code, expected_p_per_kwh, fuel_name", [ # Gas fuels (1, 3.48, "mains gas"), (2, 7.60, "bulk LPG"), (3, 10.30, "bottled LPG (main heating)"), (5, 12.19, "bottled LPG (secondary)"), (9, 3.48, "LPG subject to Special Condition 11F"), (7, 7.60, "biogas (including anaerobic digestion)"), # Liquid fuels. Heating oil (4) and FAME (73) deliberately diverge # from the RdSAP 10 PDF p.95 (which lists 7.64 / 5.44) — the table # uses the operationally-canonical Elmhurst-worksheet values per # Slice S0380.131 (oil 7.64→5.44, two independent lodging engines # agree) and Slice S0380.168 (FAME 5.44→7.64, oil 3/4 worksheets). # See tables/table_32.py codes 4 / 73 + project-oil-price-spec- # divergence. (4, 5.44, "heating oil (worksheet-canonical, S0380.131)"), (71, 7.64, "bio-liquid HVO"), (73, 7.64, "bio-liquid FAME (worksheet-canonical, S0380.168)"), (75, 6.10, "B30K"), (76, 47.0, "bioethanol"), # Solid fuels (11, 3.67, "house coal"), (15, 3.64, "anthracite"), (12, 4.61, "manufactured smokeless fuel"), (20, 4.23, "wood logs"), (22, 5.81, "wood pellets (secondary)"), (23, 5.26, "wood pellets (main)"), (21, 3.07, "wood chips"), (10, 3.99, "dual fuel"), # Electricity (30, 13.19, "standard tariff"), (32, 15.29, "7-hour high rate"), (31, 5.50, "7-hour low rate"), (34, 14.68, "10-hour high rate"), (33, 7.50, "10-hour low rate"), (38, 13.67, "18-hour high rate"), (40, 7.41, "18-hour low rate"), (35, 6.61, "24-hour heating tariff"), (60, 13.19, "electricity sold to grid, PV"), # Heat networks — 4.24 p/kWh for the "4.24 group" (51, 4.24, "heat from boilers – mains gas"), (52, 4.24, "heat from boilers – LPG"), (53, 4.24, "heat from boilers – oil"), (54, 4.24, "heat from boilers – coal"), (55, 4.24, "heat from boilers – B30K"), (56, 4.24, "heat from boilers oil/biodiesel"), (57, 4.24, "heat from boilers HVO"), (58, 4.24, "heat from boilers FAME"), (41, 4.24, "heat from electric heat pump"), (42, 4.24, "heat recovered from waste combustion"), (43, 4.24, "heat from boilers – biomass"), (44, 4.24, "heat from boilers – biogas"), # Heat networks 2.97 p/kWh group (45, 2.97, "heat recovered from power station"), (46, 2.97, "low grade heat recovered from process"), (47, 2.97, "heat recovered from geothermal / natural"), (48, 2.97, "heat from CHP"), (49, 2.97, "high grade heat recovered from process"), ], ) def test_table_32_unit_prices_match_rdsap10_pdf_page_95( fuel_code: int, expected_p_per_kwh: float, fuel_name: str ) -> None: """RdSAP10 Table 32 unit prices, sourced from PDF page 95. These differ from SAP10.2 Table 12 by carrier (mains gas 3.64→3.48, std electricity 16.49→13.19, etc.) — see `tables/table_32.py` docstring for the spec citation. Two codes deliberately diverge from the PDF and use the Elmhurst- worksheet-canonical price instead (the PDF row is the outlier): heating oil (4) = 5.44 not 7.64 (Slice S0380.131), bio-liquid FAME (73) = 7.64 not 5.44 (Slice S0380.168). See project-oil-price-spec- divergence.""" # Arrange # Act actual = unit_price_p_per_kwh(fuel_code) # Assert assert actual == expected_p_per_kwh, ( f"{fuel_name} (code {fuel_code}): expected Table 32 price " f"{expected_p_per_kwh} p/kWh, got {actual}" ) def test_mains_gas_unit_price_is_3_48_p_per_kwh() -> None: """RdSAP10 Table 32 (PDF page 95) lists mains gas at 3.48 p/kWh. The SAP 10.2 Table 12 value (3.64 p/kWh) is ~5% higher; switching to Table 32 is part of the §10a rewrite per ADR-0010 amendment.""" # Arrange # Table 32 fuel code 1 = mains gas. fuel_code = 1 # Act price = unit_price_p_per_kwh(fuel_code) # Assert assert price == 3.48 def test_unit_price_translates_api_fuel_enum_via_api_fuel_to_table_32() -> None: """Cert payloads carry the gov API `main_fuel_type` enum (e.g. 0 = electricity), not Table 32 codes directly. `unit_price_p_per_kwh` accepts either form and translates the API enum via `API_FUEL_TO_TABLE_32`. The API enum stays stable across SAP10.2 ↔ RdSAP10 so the mapping is the same shape as `table_12.API_FUEL_TO_TABLE_12`. API enum 0 → Table 32 code 30 (standard electricity, 13.19 p/kWh). Picked because it's distinct from the default mains gas fallback (3.48), so the test actually exercises the translation path.""" # Arrange api_main_fuel_type_electricity = 0 # Act price = unit_price_p_per_kwh(api_main_fuel_type_electricity) # Assert assert price == 13.19 def test_unit_price_defaults_to_mains_gas_when_code_is_none() -> None: """Mirrors `table_12.unit_price_p_per_kwh` behaviour: unknown / missing fuel codes fall back to mains gas. cert_to_inputs occasionally has to resolve a price for a cert with a missing main_fuel_type.""" # Arrange fuel_code = None # Act price = unit_price_p_per_kwh(fuel_code) # Assert assert price == 3.48 @pytest.mark.parametrize( "fuel_code, expected_standing_gbp, fuel_name", [ # Gas fuels with standing charge (1, 120.0, "mains gas"), (2, 70.0, "bulk LPG"), (9, 120.0, "LPG subject to Special Condition 11F"), (7, 70.0, "biogas"), # Liquid + solid have no standing charge (4, 0.0, "heating oil"), (11, 0.0, "house coal"), (20, 0.0, "wood logs"), # Electricity tariffs (30, 54.0, "standard tariff"), (32, 24.0, "7-hour high rate"), (34, 23.0, "10-hour high rate"), (38, 40.0, "18-hour high rate"), (35, 70.0, "24-hour heating tariff"), # Low-rate codes themselves carry no standing — the high-rate row # carries the off-peak meter standing per Table 32 note (a). (31, 0.0, "7-hour low rate"), (33, 0.0, "10-hour low rate"), (40, 0.0, "18-hour low rate"), # PV export is a credit code — no standing (60, 0.0, "electricity sold to grid PV"), # Heat networks (51, 120.0, "heat networks default (note (l))"), ], ) def test_standing_charges_match_rdsap10_table_32_pdf_page_95( fuel_code: int, expected_standing_gbp: float, fuel_name: str ) -> None: """RdSAP10 Table 32 standing-charge column, PDF page 95. Only fuels with a published charge are pinned to non-zero; the rest return 0.0. Heat networks share the £120/yr default per note (l) — DHW-only on heat network would carry half (£60/yr) but that's an `additional_ standing_charges_gbp` concern, not raw-row data.""" # Arrange # Act actual = standing_charge_gbp(fuel_code) # Assert assert actual == expected_standing_gbp, ( f"{fuel_name} (code {fuel_code}): expected standing £{expected_standing_gbp}/yr, " f"got £{actual}/yr" ) def test_mains_gas_standing_charge_is_120_gbp_per_yr() -> None: """RdSAP10 Table 32 (PDF page 95) lists mains gas at £120/yr standing charge. Table 12 note (a) gates this into (251) when gas is used for space or water heating — applies to all 6 gas-heated fixtures and is the dominant missing line behind the 000490 cost gap.""" # Arrange fuel_code = 1 # Act standing = standing_charge_gbp(fuel_code) # Assert assert standing == 120.0 # Table 12 note (a) — for SAP rating / regulated: # - Std electricity standing → omitted # - Off-peak electricity standing → added if any off-peak in use # - Gas standing → added if gas used for space or water heating # `additional_standing_charges_gbp` applies this gating to (251). def test_additional_standing_charges_includes_gas_when_gas_main_heating() -> None: """Note (a) clause: gas standing is added when gas is used for space heating (main or secondary) or water heating. 6-fixture corpus all hit this clause — mains gas main + mains gas HW → £120/yr.""" # Arrange main_fuel_code = 1 # mains gas water_heating_fuel_code = 1 # mains gas tariff = Tariff.STANDARD # Act standing = additional_standing_charges_gbp( main_fuel_code=main_fuel_code, water_heating_fuel_code=water_heating_fuel_code, tariff=tariff, ) # Assert assert standing == 120.0 def test_additional_standing_charges_omits_std_electricity_standing() -> None: """Note (a) clause: standard-electricity standing (£54/yr code 30) is omitted from the SAP rating ECF. Direct-acting electric main + immersion HW on standard tariff → £0/yr.""" # Arrange main_fuel_code = 30 # std electricity water_heating_fuel_code = 30 # std electricity tariff = Tariff.STANDARD # Act standing = additional_standing_charges_gbp( main_fuel_code=main_fuel_code, water_heating_fuel_code=water_heating_fuel_code, tariff=tariff, ) # Assert assert standing == 0.0 def test_additional_standing_charges_adds_off_peak_electricity_standing() -> None: """Note (a) clause: off-peak electricity standing (£24/yr code 32 for E7 high rate) is added whenever an off-peak tariff is in use. The standing lives on the high-rate Table 32 code per the table layout.""" # Arrange main_fuel_code = 32 # 7-hour high rate water_heating_fuel_code = 32 tariff = Tariff.SEVEN_HOUR # Act standing = additional_standing_charges_gbp( main_fuel_code=main_fuel_code, water_heating_fuel_code=water_heating_fuel_code, tariff=tariff, ) # Assert assert standing == 24.0 def test_additional_standing_charges_includes_gas_when_only_water_heating_uses_gas() -> None: """Note (a) "or water heating" clause: gas HW with non-gas main still triggers the gas standing charge. Direct-acting electric main + gas HW on standard tariff → £120/yr (gas) + £0/yr (std elec).""" # Arrange main_fuel_code = 30 # std electricity water_heating_fuel_code = 1 # mains gas tariff = Tariff.STANDARD # Act standing = additional_standing_charges_gbp( main_fuel_code=main_fuel_code, water_heating_fuel_code=water_heating_fuel_code, tariff=tariff, ) # Assert assert standing == 120.0 def test_additional_standing_charges_zero_for_oil_only() -> None: """Heating oil has no standing charge in Table 32. Oil main + oil HW on standard tariff → £0/yr (note (a) gas rule doesn't fire; std elec omitted regardless).""" # Arrange main_fuel_code = 4 # heating oil water_heating_fuel_code = 4 # heating oil tariff = Tariff.STANDARD # Act standing = additional_standing_charges_gbp( main_fuel_code=main_fuel_code, water_heating_fuel_code=water_heating_fuel_code, tariff=tariff, ) # Assert assert standing == 0.0