mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
165 lines
6.6 KiB
Python
165 lines
6.6 KiB
Python
"""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}"
|
|
)
|