"""SAP 10.2 Table 12a — high-rate fractions for off-peak tariffs. Locks the `Tariff` enum, the `tariff_from_meter_type` cert resolver, and the per-system / per-use high-rate-fraction lookups against the published SAP10.2 specification at `domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf`, page 191. RdSAP10 §19.1 cross-references Table 12a in SAP10.2 for off-peak splitting — the table itself is not duplicated in the RdSAP10 PDF. """ from __future__ import annotations import pytest from domain.sap10_calculator.tables.table_12a import ( OtherUse, Table12aSystem, Tariff, other_use_high_rate_fraction, space_heating_high_rate_fraction, tariff_from_meter_type, water_heating_high_rate_fraction, ) def test_tariff_enum_has_five_members() -> None: """Table 12a columns: standard (no off-peak split), 7-hour, 10-hour, 18-hour, 24-hour. Worksheet-shape fidelity: TEN_HOUR is included for spec completeness even though RdSAP10 meter_type enum (1..5) doesn't route to it — see ADR-0010 §3 unreachable-branch policy.""" # Arrange # Act members = set(Tariff) # Assert assert members == { Tariff.STANDARD, Tariff.SEVEN_HOUR, Tariff.TEN_HOUR, Tariff.EIGHTEEN_HOUR, Tariff.TWENTY_FOUR_HOUR, } @pytest.mark.parametrize( "meter_type, expected", [ # RdSAP cert meter_type string forms ("Single", Tariff.STANDARD), ("Standard", Tariff.STANDARD), ("Dual", Tariff.SEVEN_HOUR), ("Dual (24 hour)", Tariff.TWENTY_FOUR_HOUR), ("Off-peak 18 hour", Tariff.EIGHTEEN_HOUR), # RdSAP 10 §17 page 85 (Electricity meter row 10-2): # "Dual/single/10-hour/18-hour/24-hour/unknown". The Elmhurst # Summary §14.2 lodges the bare form "18 Hour" (not the # "Off-peak 18 hour" alias above). Per §12 page 62: "if the # meter is dual 18-hour/24-hour it is 18-hour/24-hour tariff", # so the bare lodging routes directly to EIGHTEEN_HOUR. ("18 Hour", Tariff.EIGHTEEN_HOUR), # Per Q11b: "Unknown" maps to STANDARD (no off-peak heuristic). ("Unknown", Tariff.STANDARD), ("", Tariff.STANDARD), # Numeric forms (cert sometimes lodges integers per S-B9 finding) (2, Tariff.STANDARD), (1, Tariff.SEVEN_HOUR), (4, Tariff.TWENTY_FOUR_HOUR), (5, Tariff.EIGHTEEN_HOUR), (3, Tariff.STANDARD), # None / missing → STANDARD (None, Tariff.STANDARD), ], ) def test_tariff_from_meter_type_maps_cert_codes( meter_type: object, expected: Tariff ) -> None: """RdSAP cert `meter_type` field carries either a string or an int enum (1..5). Per Q11b grilling: "Unknown" (code 3) maps to STANDARD rather than the legacy off-peak heuristic — spec-faithful since RdSAP10 has no rule for unresolved tariffs.""" # Arrange # Act tariff = tariff_from_meter_type(meter_type) # Assert assert tariff is expected @pytest.mark.parametrize( "system, tariff, expected_fraction, label", [ # Integrated storage+direct (storage heaters 408, underfloor 422/423) (Table12aSystem.INTEGRATED_STORAGE_DIRECT, Tariff.SEVEN_HOUR, 0.20, "integrated 408/422/423 7-hr"), # Other storage heaters (Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.SEVEN_HOUR, 0.00, "other storage 7-hr"), (Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.TWENTY_FOUR_HOUR, 0.00, "other storage 24-hr"), # Electric dry core / water storage boiler / Electricaire (Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE, Tariff.SEVEN_HOUR, 0.00, "electric dry core 7-hr"), # Direct-acting electric boiler (Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.SEVEN_HOUR, 0.90, "direct-acting boiler 7-hr"), (Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.TEN_HOUR, 0.50, "direct-acting boiler 10-hr"), # Underfloor heating (above insulation / timber / below floor) (Table12aSystem.UNDERFLOOR_HEATING, Tariff.SEVEN_HOUR, 0.90, "underfloor 7-hr"), (Table12aSystem.UNDERFLOOR_HEATING, Tariff.TEN_HOUR, 0.50, "underfloor 10-hr"), # Ground/water source heat pump — Appendix N calculated (Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR, 0.80, "GSHP App N 7-hr"), (Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR, 0.80, "GSHP App N 10-hr"), # GSHP otherwise (Table12aSystem.GSHP_OTHER, Tariff.SEVEN_HOUR, 0.70, "GSHP otherwise 7-hr"), (Table12aSystem.GSHP_OTHER, Tariff.TEN_HOUR, 0.60, "GSHP otherwise 10-hr"), # Air source heat pump — Appendix N (Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR, 0.80, "ASHP App N 7-hr"), (Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR, 0.80, "ASHP App N 10-hr"), # ASHP otherwise (Table12aSystem.ASHP_OTHER, Tariff.SEVEN_HOUR, 0.90, "ASHP otherwise 7-hr"), (Table12aSystem.ASHP_OTHER, Tariff.TEN_HOUR, 0.60, "ASHP otherwise 10-hr"), # Other direct-acting electric (incl secondary) (Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.SEVEN_HOUR, 1.00, "other direct-acting 7-hr"), (Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.TEN_HOUR, 0.50, "other direct-acting 10-hr"), ], ) def test_space_heating_high_rate_fraction_matches_table_12a_grid_1( system: Table12aSystem, tariff: Tariff, expected_fraction: float, label: str ) -> None: """Table 12a Grid 1 SH column, verbatim from SAP10.2 PDF page 191. Each (system, tariff) pair pinned to its published high-rate fraction. Tariff columns not listed for a row (e.g. integrated storage at 10-hr) are out-of-domain and raise — covered separately.""" # Arrange # Act fraction = space_heating_high_rate_fraction(system, tariff) # Assert assert fraction == expected_fraction, ( f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}" ) def test_space_heating_high_rate_fraction_returns_one_for_standard_tariff() -> None: """STANDARD tariff = no off-peak split. Every system bills 100% at the (single) unit price, so high-rate fraction collapses to 1.0. This is the passthrough path every gas-heated fixture in scope A will exercise.""" # Arrange # System choice is irrelevant on STANDARD — pick a representative one. system = Table12aSystem.OTHER_STORAGE_HEATERS # Act fraction = space_heating_high_rate_fraction(system, Tariff.STANDARD) # Assert assert fraction == 1.0 @pytest.mark.parametrize( "system, tariff, expected_fraction, label", [ # Heat-pump WH (App N + otherwise) — same fractions for 7-hr / 10-hr (Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR, 0.70, "GSHP App N WH 7-hr"), (Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR, 0.70, "GSHP App N WH 10-hr"), (Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR, 0.17, "GSHP other off-peak immersion 7-hr"), (Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR, 0.17, "GSHP other off-peak immersion 10-hr"), (Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR, 0.70, "GSHP other no immersion 7-hr"), (Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR, 0.70, "GSHP other no immersion 10-hr"), (Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR, 0.70, "ASHP App N WH 7-hr"), (Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR, 0.70, "ASHP App N WH 10-hr"), (Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR, 0.17, "ASHP other off-peak immersion 7-hr"), (Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR, 0.17, "ASHP other off-peak immersion 10-hr"), (Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR, 0.70, "ASHP other no immersion 7-hr"), (Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR, 0.70, "ASHP other no immersion 10-hr"), ], ) def test_water_heating_high_rate_fraction_matches_table_12a_grid_1( system: Table12aSystem, tariff: Tariff, expected_fraction: float, label: str ) -> None: """Table 12a Grid 1 WH column, verbatim from SAP10.2 PDF page 191. Heat-pump WH carries 0.70 high-rate by default (or 0.17 when paired with off-peak immersion). Immersion / HP-DHW-only WH (Table 13) and Electric CPSU (Appendix F) are out-of-scope until a fixture lands.""" # Arrange # Act fraction = water_heating_high_rate_fraction(system, tariff) # Assert assert fraction == expected_fraction, ( f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}" ) def test_water_heating_high_rate_fraction_returns_one_for_standard_tariff() -> None: """STANDARD-tariff passthrough — water heating bills 100% at the single rate.""" # Arrange system = Table12aSystem.ASHP_OTHER_NO_IMMERSION # Act fraction = water_heating_high_rate_fraction(system, Tariff.STANDARD) # Assert assert fraction == 1.0 def test_water_heating_high_rate_fraction_for_immersion_raises() -> None: """`IMMERSION_OR_HP_DHW_ONLY` sources its fraction from Table 13, which lives in a separate spec section. Defer until first immersion fixture lands (per Q5 deferred list).""" # Arrange system = Table12aSystem.IMMERSION_OR_HP_DHW_ONLY # Act / Assert with pytest.raises(NotImplementedError): water_heating_high_rate_fraction(system, Tariff.SEVEN_HOUR) def test_water_heating_high_rate_fraction_for_electric_cpsu_raises() -> None: """`ELECTRIC_CPSU` sources its fraction from Appendix F. Defer until first CPSU fixture lands.""" # Arrange system = Table12aSystem.ELECTRIC_CPSU # Act / Assert with pytest.raises(NotImplementedError): water_heating_high_rate_fraction(system, Tariff.TEN_HOUR) @pytest.mark.parametrize( "use, tariff, expected_fraction, label", [ (OtherUse.FANS_FOR_MECH_VENT, Tariff.SEVEN_HOUR, 0.71, "fans 7-hr"), (OtherUse.FANS_FOR_MECH_VENT, Tariff.TEN_HOUR, 0.58, "fans 10-hr"), (OtherUse.ALL_OTHER_USES, Tariff.SEVEN_HOUR, 0.90, "all other 7-hr"), (OtherUse.ALL_OTHER_USES, Tariff.TEN_HOUR, 0.80, "all other 10-hr"), ], ) def test_other_use_high_rate_fraction_matches_table_12a_grid_2( use: OtherUse, tariff: Tariff, expected_fraction: float, label: str ) -> None: """Table 12a Grid 2 (PDF page 191) — "Other electricity uses" sub- table for fans/MV vs all-other-uses-and-locally-generated. Lighting + pumps + locally-generated PV credit all bill via ALL_OTHER_USES.""" # Arrange # Act fraction = other_use_high_rate_fraction(use, tariff) # Assert assert fraction == expected_fraction, ( f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}" ) def test_other_use_high_rate_fraction_returns_one_for_standard_tariff() -> None: """STANDARD passthrough.""" # Arrange use = OtherUse.ALL_OTHER_USES # Act fraction = other_use_high_rate_fraction(use, Tariff.STANDARD) # Assert assert fraction == 1.0