"""Regression pin: a PV battery is SAP-cost-NEUTRAL on an export-capable STANDARD-tariff dwelling, but still moves primary energy / CO2 and DOES help the rating on an off-peak tariff. SPEC EVIDENCE — the user-simulated Elmhurst P960 worksheet pair at `sap worksheets/golden fixture debugging/simulated case 35 (without battery)` and `simulated case 36 (with battery)` (cert 001431, 5 kWh, export-capable, mains-gas, standard tariff). The official SAP rating uses "10a. Fuel costs - using Table 12 prices", where PV used-in-dwelling and PV exported are BOTH priced at 13.19 p/kWh (export code 60 == import code 30, ADR-0010). A battery only redistributes PV between those two equally- priced lines (worksheet split 1128/2326 -> 1951/1504 kWh), so the PV credit (252) = -455.6458 and the SAP (258) = 88.0859 are IDENTICAL with and without the battery: ΔSAP = exactly 0. The battery DOES move the EPC's consumer £-bill (a SEPARATE "10a ... using BEDF prices (595)" block at import 27.67 / export 5.81, £696 -> £515/yr) — but that block never feeds (258). It also moves CO2 (272) / PE (286), since used vs exported carry different factors. This guards two things against silent regression: 1. The export price == import price (13.19) that makes the credit split- invariant on standard tariff — if someone changes table_32 code 60, the standard-tariff ΔSAP would stop being 0 and Test A fails. 2. The Appendix M1 §3c battery β-split is actually wired — if a refactor dropped the battery, PE would stop responding (Test A's PE assertion) AND the off-peak benefit would vanish (Test B). The β-coefficient formula itself is unit-tested in tests/domain/sap10_calculator/worksheet/test_photovoltaic.py; this is the end-to-end API-path (`from_api_response` -> cert_to_inputs -> calculator) counterpart over the committed RdSAP-21.0.1 corpus. """ from __future__ import annotations import json from copy import deepcopy from pathlib import Path from typing import Any, cast import pytest from datatypes.epc.domain.mapper import EpcPropertyDataMapper from domain.sap10_calculator.calculator import calculate_sap_from_inputs from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, cert_to_inputs, ) _CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") # RdSAP meter_type 2 = Single (standard tariff). Anything else (1 Dual / 4 # Dual-24h / 5 18-hour) is an off-peak tariff where self-consumed PV # displaces a higher unit rate than the export credit — see _rdsap_tariff. _STANDARD_METER_TYPE = 2 def _load_corpus() -> list[dict[str, Any]]: if not _CORPUS.exists(): return [] return [ json.loads(line) for line in _CORPUS.read_text().splitlines() if line.strip() ] def _energy_source(doc: dict[str, Any]) -> dict[str, Any]: es = doc.get("sap_energy_source") return cast("dict[str, Any]", es) if isinstance(es, dict) else {} def _has_real_pv_and_battery(doc: dict[str, Any]) -> bool: es = _energy_source(doc) if not es.get("is_dwelling_export_capable"): return False if (es.get("pv_battery_count") or 0) < 1: return False pv = es.get("photovoltaic_supply") # A bare "no details" array / "none_or_no_details" dict carries no PV. if isinstance(pv, dict) and "none_or_no_details" in pv: return False return True def _sap_pe(doc: dict[str, Any]) -> tuple[float, float]: epc = EpcPropertyDataMapper.from_api_response(doc) result = calculate_sap_from_inputs( cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) ) return result.sap_score_continuous, result.primary_energy_kwh_per_m2 def _with_battery_zeroed(doc: dict[str, Any]) -> dict[str, Any]: zeroed = deepcopy(doc) zeroed["sap_energy_source"]["pv_battery_count"] = 0 return zeroed def test_battery_is_sap_cost_neutral_on_export_capable_standard_tariff() -> None: # Arrange — every export-capable, standard-tariff (meter 2) corpus cert # that carries both a real PV array and a battery (the worksheet case # 35/36 profile: mains-gas, single-rate meter). corpus = _load_corpus() if not corpus: pytest.skip(f"no corpus at {_CORPUS}") standard_battery_certs = [ doc for doc in corpus if _has_real_pv_and_battery(doc) and _energy_source(doc).get("meter_type") == _STANDARD_METER_TYPE ] assert standard_battery_certs, ( "no export-capable standard-tariff PV+battery cert in the corpus — " "this regression pin has nothing to guard" ) # Act / Assert — toggling the battery off leaves the continuous SAP # EXACTLY unchanged (export price == import price 13.19 makes the # onsite/export split cost-irrelevant), while at least one cert's # primary energy DOES respond (proving the battery is modelled, not # silently dropped). pe_deltas: list[float] = [] for doc in standard_battery_certs: sap_on, pe_on = _sap_pe(doc) sap_off, pe_off = _sap_pe(_with_battery_zeroed(doc)) assert abs(sap_on - sap_off) <= 1e-9, ( "battery changed SAP on a standard-tariff export-capable cert: " f"{sap_on} vs {sap_off}" ) pe_deltas.append(pe_on - pe_off) assert min(pe_deltas) < -1e-6, ( "no standard-tariff battery cert showed a primary-energy effect — " "the App-M1 §3c battery β-split may have been dropped" ) def test_battery_improves_sap_on_export_capable_off_peak_tariff() -> None: # Arrange — export-capable PV+battery certs on an OFF-PEAK tariff # (meter_type != 2). Here self-consumed PV displaces high-rate import # (e.g. 7-hour high 15.29 p/kWh) which exceeds the 13.19 export credit, # so a battery (more self-consumption) lowers cost. corpus = _load_corpus() if not corpus: pytest.skip(f"no corpus at {_CORPUS}") off_peak_battery_certs = [ doc for doc in corpus if _has_real_pv_and_battery(doc) and _energy_source(doc).get("meter_type") != _STANDARD_METER_TYPE ] if not off_peak_battery_certs: pytest.skip("no export-capable off-peak PV+battery cert in the corpus") # Act / Assert — the battery STRICTLY raises the SAP, confirming the # standard-tariff neutrality above is a price coincidence (import == # export) and not a dropped-battery no-op. for doc in off_peak_battery_certs: sap_on, _ = _sap_pe(doc) sap_off, _ = _sap_pe(_with_battery_zeroed(doc)) assert sap_on - sap_off > 1e-6, ( "battery did not improve SAP on an off-peak export-capable cert: " f"{sap_on} vs {sap_off}" )