test(pv-battery): pin SAP cost-neutrality on export-capable standard tariff

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-14 09:51:34 +00:00
parent e7177a8bd4
commit ac77624d67

View file

@ -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}"
)