mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
e7177a8bd4
commit
ac77624d67
1 changed files with 165 additions and 0 deletions
|
|
@ -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}"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue