From ac77624d67dd43aae9e3d56654e1e90b42869e02 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 14 Jun 2026 09:51:34 +0000 Subject: [PATCH] test(pv-battery): pin SAP cost-neutrality on export-capable standard tariff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end API-path regression pin for the battery behaviour validated by the user-simulated Elmhurst worksheet pair (cert 001431 "simulated case 35/36", 5 kWh, export-capable, mains-gas, standard tariff). The official SAP rating ("10a. Fuel costs - using Table 12 prices") values PV used-in- dwelling and PV exported identically at 13.19 p/kWh (export code 60 == import code 30, ADR-0010), so a battery only redistributes PV between two equally-priced lines: worksheet PV credit (252) = -455.6458 and SAP (258) = 88.0859 are IDENTICAL with/without the battery (ΔSAP = 0). Two tests over the committed RdSAP-21.0.1 corpus: - standard tariff (meter 2): toggling the battery holds continuous SAP EXACTLY constant, while at least one cert's primary energy DOES respond (proving the App-M1 §3c β-split is wired, not a dropped battery). - off-peak tariff (meter != 2): the battery STRICTLY raises SAP, because self-consumed PV displaces high-rate import (15.29) above the 13.19 export credit — confirming the standard-tariff neutrality is a price coincidence, not a no-op. Guards table_32 export price (code 60) and the battery β-split against silent regression. Complements the unit-level β tests in test_photovoltaic.py. Co-Authored-By: Claude Opus 4.8 --- .../test_pv_battery_sap_neutrality.py | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 tests/infrastructure/epc_client/test_pv_battery_sap_neutrality.py diff --git a/tests/infrastructure/epc_client/test_pv_battery_sap_neutrality.py b/tests/infrastructure/epc_client/test_pv_battery_sap_neutrality.py new file mode 100644 index 00000000..7a2c1add --- /dev/null +++ b/tests/infrastructure/epc_client/test_pv_battery_sap_neutrality.py @@ -0,0 +1,165 @@ +"""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}" + )