Model/scripts/fetch_real_life_epc_sample.py
Jun-te Kim 5c11fd35c8 Validate SAP calculator vs Elmhurst; fix reduced-field window U; add accuracy harness
Reduced-field window U: heat_transmission derived the synthesised-window
raw U from u_window(all None) -> the 2.5 placeholder regardless of glazing.
Now routes the (uniform) glazing_type code through u_window (RdSAP Table 24)
so e.g. double pre-2002 reads 2.8, not 2.5. Only the pre-SAP10 reduced-field
path is affected (21.0.1 certs carry per-window U upstream) — the RdSAP-21.0.1
corpus gauge is unchanged at 66.9% within-0.5.

test_real_cert_sap_accuracy: pin uprn_10002468137 (RdSAP-17.1, all-electric
storage heaters) at SAP 61, validated against Elmhurst on identical inputs
(dual off-peak immersion, 110 L cylinder, 2 baths). Our engine reproduces
Elmhurst's fuel cost to the penny; lodged 55 is the old SAP-2012 schema.

Tooling to grow the accuracy corpus:
- scripts/fetch_real_life_epc_sample.py — capture a cert by UPRN into the corpus.
- scripts/compare_epc_paths.py — diff gov-API vs Elmhurst-summary EpcPropertyData
  and run both through the engine, localising mapper vs calculator differences.
- skill validate-cert-sap-accuracy — the end-to-end loop (capture -> Elmhurst
  inputs -> human builds -> compare -> reconcile -> pin in the test).
- skill epc-to-elmhurst-rdsap-inputs reference: corrected immersion (code 1=dual),
  cylinder size (code 2 = Normal/110 L), and bath-count (WWHRS sub-tab) mappings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:26:11 +00:00

130 lines
4.4 KiB
Python

"""Capture a real EPC certificate by UPRN for the SAP accuracy test suite.
Resolves a UPRN to its latest lodged certificate via the GOV.UK EPB
register, downloads the full ``data`` payload (the exact shape
``EpcPropertyDataMapper.from_api_response`` consumes), and freezes it
under the schema-bucketed sample tree the accuracy test reads:
backend/epc_api/json_samples/real_life_examples/<schema_type>/uprn_<uprn>/epc.json
It also prints the lodged SAP rating and what ``Sap10Calculator``
currently produces, so a new case can be added to
``tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py`` with
the right ``schema`` / ``sap_score`` straight away.
USAGE
-----
PYTHONPATH=/workspaces/model python scripts/fetch_real_life_epc_sample.py <uprn> [<uprn> ...]
Token is read from ``backend/.env`` (``OPEN_EPC_API_TOKEN``, falling
back to ``EPC_AUTH_TOKEN``). Re-running overwrites the sample.
"""
from __future__ import annotations
import json
import os
import pathlib
import sys
from typing import Any
import httpx
from dotenv import load_dotenv
_BASE = "https://api.get-energy-performance-data.communities.gov.uk"
_SAMPLES_ROOT = pathlib.Path(
"backend/epc_api/json_samples/real_life_examples"
)
def _headers() -> dict[str, str]:
load_dotenv("backend/.env")
token = os.environ.get("OPEN_EPC_API_TOKEN") or os.environ["EPC_AUTH_TOKEN"]
return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
def _latest_cert_number(uprn: int, headers: dict[str, str]) -> str:
resp = httpx.get(
f"{_BASE}/api/domestic/search",
params={"uprn": uprn},
headers=headers,
timeout=30.0,
)
resp.raise_for_status()
rows: list[dict[str, Any]] = resp.json().get("data", [])
if not rows:
raise SystemExit(f"UPRN {uprn}: no certificates found")
latest = max(rows, key=lambda r: r["registrationDate"])
return str(latest["certificateNumber"])
def _fetch_cert_data(cert_num: str, headers: dict[str, str]) -> dict[str, Any]:
resp = httpx.get(
f"{_BASE}/api/certificate",
params={"certificate_number": cert_num},
headers=headers,
timeout=30.0,
)
resp.raise_for_status()
data: dict[str, Any] = resp.json()["data"]
return data
def _report(uprn: int, cert_num: str, data: dict[str, Any]) -> None:
"""Print lodged rating + current calculator output for the captured cert."""
from infrastructure.epc_client.epc_client_service import EpcClientService
from domain.sap10_calculator.calculator import Sap10Calculator
from unittest.mock import patch
def _mock(*_a: object, **_k: object) -> httpx.Response:
return httpx.Response(
200, json={"data": data}, request=httpx.Request("GET", "x")
)
print(f" schema_type : {data.get('schema_type')}")
print(f" lodged rating : {data.get('energy_rating_current')}")
service = EpcClientService(auth_token="test-token")
try:
with patch("httpx.get", side_effect=_mock):
epc = service.get_by_certificate_number(cert_num)
except ValueError as exc:
# Full-SAP (vs RdSAP) certs aren't supported by the mapper, so the
# calculator front-end can't consume them. Captured for reference
# but NOT addable to the RdSAP accuracy suite.
print(f" NOT MAPPABLE : {exc}")
return
result = Sap10Calculator().calculate(epc)
print(f" calc sap_score : {result.sap_score}")
print(f" space_heating_kwh : {result.space_heating_kwh_per_yr:.4f}")
print(f" main_heating_kwh : {result.main_heating_fuel_kwh_per_yr:.4f}")
print(f" hot_water_kwh : {result.hot_water_kwh_per_yr:.4f}")
print(f" co2_kg_per_yr : {result.co2_kg_per_yr:.4f}")
def capture(uprn: int) -> None:
headers = _headers()
cert_num = _latest_cert_number(uprn, headers)
data = _fetch_cert_data(cert_num, headers)
schema_type = str(data.get("schema_type") or "unknown-schema")
out_dir = _SAMPLES_ROOT / schema_type / f"uprn_{uprn}"
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / "epc.json"
out.write_text(json.dumps(data, indent=2))
print(f"UPRN {uprn} -> cert {cert_num}")
print(f" wrote : {out}")
_report(uprn, cert_num, data)
def main() -> None:
if len(sys.argv) < 2:
raise SystemExit(__doc__)
for arg in sys.argv[1:]:
capture(int(arg))
if __name__ == "__main__":
main()