diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 93af44e6..a2b988c9 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2183,6 +2183,41 @@ def _space_heating_fuel_cost_gbp_per_kwh( return blended * _PENCE_TO_GBP +def _main_space_heating_high_rate_fraction( + main: Optional[MainHeatingDetail], + tariff: Tariff, +) -> float: + """SAP 10.2 Appendix M1 §3a (PDF p.93) — the fraction of the main + space-heating fuel that is billed at the HIGH rate in Section 10a, + i.e. carries an "electricity not at the low-rate" fuel code (30, 32, + 34, 35 or 38). Only this high-rate portion of E_space,m may enter the + PV-eligible demand D_PV,m; the low-rate portion (code 31/33/36/37/39) + is excluded. + + Mirrors `_space_heating_fuel_cost_gbp_per_kwh`'s rate split exactly so + the D_PV inclusion and the §10a billing stay consistent: + - non-electric main, or STANDARD tariff → 1.0 (no off-peak split; + the eligible-code gate in `_pv_eligible_demand_monthly_kwh` + already excludes non-electric fuels, and a STANDARD-tariff + electric main bills 100% at code 30). + - electric main on an off-peak tariff whose Table 12a Grid 1 SH row + is wired → the published high-rate fraction. Electric STORAGE + heaters (Table 12a `_table_12a_system_for_main` → None, charged + wholly off-peak) and any system whose Grid 1 SH row is not yet + wired bill 100% at the low rate → fraction 0.0, so E_space,m is + excluded from D_PV entirely (worksheet (240) high-rate cost = 0). + """ + if not _is_electric_main(main) or tariff is Tariff.STANDARD: + return 1.0 + system = _table_12a_system_for_main(main) + if system is None: + return 0.0 + try: + return space_heating_high_rate_fraction(system, tariff) + except NotImplementedError: + return 0.0 + + def _hot_water_fuel_cost_gbp_per_kwh( water_heating_fuel: Optional[int], main: Optional[MainHeatingDetail], @@ -2474,6 +2509,7 @@ def _pv_eligible_demand_monthly_kwh( main_fuel_code_table_12: Optional[int], secondary_fuel_code_table_12: Optional[int], water_heating_fuel_code_table_12: Optional[int], + main_space_high_rate_fraction: float = 1.0, ) -> tuple[float, ...]: """SAP 10.2 Appendix M1 §3a (p.93) — monthly PV-eligible demand D_PV,m. Always includes lighting + appliances + cooking + electric @@ -2482,6 +2518,18 @@ def _pv_eligible_demand_monthly_kwh( (codes 30, 32, 34, 35, 38 per spec). Includes E_water,m only when the water heating fuel code is 30 (standard electricity) per spec. + `main_space_high_rate_fraction` scales the main-heating contribution + by the portion billed at the HIGH rate (code 30) in Section 10a. + Per the §3a inclusion rule "(211) should be included only where the + fuel code applied to it in Section 10a is 30, 32, 34, 35 or 38 (i.e. + electricity not at the low-rate)", off-peak electric mains (e.g. + storage heaters charged wholly at the low rate, fraction 0.0) must + NOT add their (211) to D_PV. Defaults to 1.0 → unchanged for + STANDARD-tariff electric mains and the gas-main / electric-secondary + cohort. Without this, off-peak storage-heater dwellings over-counted + D_PV by the full (211) in winter, inflating R_PV,m → β → the onsite + PV split (case 19: β_Jan 0.894 → 0.792, matching worksheet 0.791). + Secondary space heating is included on the same footing as main: Appendix M1 §3a counts E_space,m as the dwelling's total electric space-heating demand, which for a gas-main / electric-secondary @@ -2516,7 +2564,7 @@ def _pv_eligible_demand_monthly_kwh( + pumps_fans_monthly_kwh[m] ) if include_main_space: - d += main_1_fuel_monthly_kwh[m] + d += main_space_high_rate_fraction * main_1_fuel_monthly_kwh[m] if include_secondary_space: d += secondary_fuel_monthly_kwh[m] if include_water: @@ -6721,6 +6769,12 @@ def cert_to_inputs( ) if epc.sap_heating.water_heating_fuel is not None else None ), + # SAP 10.2 Appendix M1 §3a — exclude the low-rate portion of an + # off-peak electric main from D_PV (the §10a high/low split that + # `_space_heating_fuel_cost_gbp_per_kwh` already bills). + main_space_high_rate_fraction=_main_space_heating_high_rate_fraction( + main, _rdsap_tariff(epc), + ), ) pv_split = pv_split_monthly( epv_monthly_kwh=pv_monthly_kwh, 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 c0d9d685..bde4bb44 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -59,7 +59,9 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _is_electric_water, # pyright: ignore[reportPrivateUsage] _is_off_peak_meter, # pyright: ignore[reportPrivateUsage] _main_floor_u_value, # pyright: ignore[reportPrivateUsage] + _main_space_heating_high_rate_fraction, # pyright: ignore[reportPrivateUsage] _other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] + _pv_eligible_demand_monthly_kwh, # pyright: ignore[reportPrivateUsage] _primary_loss_applies, # pyright: ignore[reportPrivateUsage] _rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage] _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] @@ -1766,6 +1768,90 @@ def test_other_fuel_cost_for_18_hour_tariff_uses_18_hour_high_rate() -> None: ) +def test_main_space_high_rate_fraction_zero_for_off_peak_storage_heaters() -> None: + # Arrange — SAP 10.2 Appendix M1 §3a (PDF p.93): E_space,m (211) is + # included in D_PV "only where the fuel code applied to it in Section + # 10a is 30, 32, 34, 35 or 38 (i.e. electricity not at the low-rate)". + # Electric STORAGE heaters (code 402) on a 7-hour off-peak tariff are + # charged wholly at the low rate (Table 12a Grid 1 SH fraction 0.00 / + # `_table_12a_system_for_main` → None) — worksheet (240) high-rate + # cost = 0 — so none of (211) may enter D_PV. + from domain.sap10_calculator.tables.table_12a import Tariff + + storage_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2402, + main_heating_category=7, + sap_main_heating_code=402, + ) + + # Act + off_peak = _main_space_heating_high_rate_fraction( + storage_main, Tariff.SEVEN_HOUR + ) + standard = _main_space_heating_high_rate_fraction( + storage_main, Tariff.STANDARD + ) + gas = _main_space_heating_high_rate_fraction( + _gas_boiler_detail(sap_main_heating_code=102), Tariff.SEVEN_HOUR + ) + + # Assert + assert abs(off_peak - 0.0) <= 1e-9 + # STANDARD tariff has no high/low split → 100% high rate. + assert abs(standard - 1.0) <= 1e-9 + # Non-electric main never carries an off-peak split. + assert abs(gas - 1.0) <= 1e-9 + + +def test_pv_eligible_demand_excludes_low_rate_main_space_heating() -> None: + # Arrange — SAP 10.2 Appendix M1 §3a (PDF p.93). A main billed wholly + # at the low rate (high-rate fraction 0.0) must contribute zero to + # D_PV even though its Table-12 code (30) is in the eligible set; the + # secondary (also code 30) at its full high-rate fraction stays in. + main_1 = tuple(float(100 + m) for m in range(12)) + secondary = tuple(float(10 + m) for m in range(12)) + base = tuple(float(5) for _ in range(12)) # lighting et al. + + # Act + excluded = _pv_eligible_demand_monthly_kwh( + lighting_monthly_kwh=base, + appliances_monthly_kwh=(0.0,) * 12, + cooking_monthly_kwh=(0.0,) * 12, + electric_shower_monthly_kwh=(0.0,) * 12, + pumps_fans_monthly_kwh=(0.0,) * 12, + main_1_fuel_monthly_kwh=main_1, + secondary_fuel_monthly_kwh=secondary, + hot_water_monthly_kwh=(0.0,) * 12, + main_fuel_code_table_12=30, + secondary_fuel_code_table_12=30, + water_heating_fuel_code_table_12=26, # gas → no E_water + main_space_high_rate_fraction=0.0, + ) + included = _pv_eligible_demand_monthly_kwh( + lighting_monthly_kwh=base, + appliances_monthly_kwh=(0.0,) * 12, + cooking_monthly_kwh=(0.0,) * 12, + electric_shower_monthly_kwh=(0.0,) * 12, + pumps_fans_monthly_kwh=(0.0,) * 12, + main_1_fuel_monthly_kwh=main_1, + secondary_fuel_monthly_kwh=secondary, + hot_water_monthly_kwh=(0.0,) * 12, + main_fuel_code_table_12=30, + secondary_fuel_code_table_12=30, + water_heating_fuel_code_table_12=26, + main_space_high_rate_fraction=1.0, + ) + + # Assert — excluded drops the full (211); secondary stays in both. + for m in range(12): + assert abs(excluded[m] - (base[m] + secondary[m])) <= 1e-9 + assert abs(included[m] - (base[m] + secondary[m] + main_1[m])) <= 1e-9 + + def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None: # Arrange — RdSAP 10 §17 page 85 row 10-2 lodges 18-hour meter # as the bare "18-hour" or "18 Hour" form (Elmhurst Summary §14.2