Model/scripts/compare_epc_paths.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

135 lines
4.7 KiB
Python

"""Compare the two EpcPropertyData source paths for one real cert, to
separate MAPPER fidelity from CALCULATOR correctness.
For a cert captured under
``backend/epc_api/json_samples/real_life_examples/<schema>/uprn_<uprn>/``
this:
1. builds `EpcPropertyData` from the gov-EPC API json (`epc.json`), and
2. builds `EpcPropertyData` from the Elmhurst summary PDF
(`elmhurst_summary.pdf`) via `parse_site_notes_pdf`,
then deep-diffs the two and runs BOTH through `Sap10Calculator`. Where the
two objects match, any SAP gap is the calculator; where they differ, it's
input mapping / data entry. If `elmhurst_worksheet.pdf` is present its
printed SAP rating (258) is shown as the ground truth.
USAGE
-----
PYTHONPATH=/workspaces/model python scripts/compare_epc_paths.py <uprn>
Part of the `validate-cert-sap-accuracy` workflow — see that skill.
"""
from __future__ import annotations
import dataclasses
import json
import re
import sys
from pathlib import Path
from typing import Any, Optional
from unittest.mock import patch
import httpx
from backend.documents_parser.parser import parse_site_notes_pdf
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import Sap10Calculator
_ROOT = Path("backend/epc_api/json_samples/real_life_examples")
def _find_sample_dir(uprn: str) -> Path:
matches = list(_ROOT.glob(f"*/uprn_{uprn}"))
if not matches:
raise SystemExit(
f"no sample dir for UPRN {uprn} under {_ROOT} — capture it first "
f"with scripts/fetch_real_life_epc_sample.py {uprn}"
)
return matches[0]
def _gov_api_epc(epc_json: Path) -> EpcPropertyData:
data = json.loads(epc_json.read_text())
def _mock(*_a: object, **_k: object) -> httpx.Response:
return httpx.Response(
200, json={"data": data}, request=httpx.Request("GET", "x")
)
# Route the raw payload through the real mapper (httpx mocked, no network).
with patch("httpx.get", side_effect=_mock):
from infrastructure.epc_client.epc_client_service import EpcClientService
return EpcClientService(auth_token="t").get_by_certificate_number("x")
def _elmhurst_printed_sap(worksheet_pdf: Path) -> Optional[int]:
if not worksheet_pdf.exists():
return None
import fitz # pymupdf
text = "\n".join(p.get_text() for p in fitz.open(str(worksheet_pdf)))
for line in text.splitlines():
if "SAP rating" in line and "(258)" in line:
# value sits immediately before the "(258)" line ref
match = re.search(r"(\d+)\s*\(258\)", line)
if match:
return int(match.group(1))
return None
def _deep_diff(a: Any, b: Any, prefix: str, out: list[str]) -> None:
if dataclasses.is_dataclass(a) and dataclasses.is_dataclass(b):
for f in dataclasses.fields(a):
_deep_diff(getattr(a, f.name), getattr(b, f.name), f"{prefix}.{f.name}", out)
elif isinstance(a, list) and isinstance(b, list):
if len(a) != len(b):
out.append(f" {prefix}: LEN {len(a)} vs {len(b)}")
for i, (x, y) in enumerate(zip(a, b)):
_deep_diff(x, y, f"{prefix}[{i}]", out)
elif a != b:
out.append(f" {prefix}: API={a!r} ELM={b!r}")
def compare(uprn: str) -> None:
sample = _find_sample_dir(uprn)
print(f"=== {sample} ===")
gov = _gov_api_epc(sample / "epc.json")
summary = sample / "elmhurst_summary.pdf"
elm: Optional[EpcPropertyData] = None
if summary.exists():
elm = parse_site_notes_pdf(str(summary))
else:
print(" (no elmhurst_summary.pdf yet — gov-API side only)")
rg = Sap10Calculator().calculate(gov)
print("\nOUR ENGINE:")
print(
f" gov-API inputs → SAP {rg.sap_score} ({rg.sap_score_continuous:.2f})"
f" HW {rg.hot_water_kwh_per_yr:.0f} kWh cost £{rg.total_fuel_cost_gbp:.2f}"
)
if elm is not None:
re_ = Sap10Calculator().calculate(elm)
print(
f" Elmhurst-PDF inputs → SAP {re_.sap_score} ({re_.sap_score_continuous:.2f})"
f" HW {re_.hot_water_kwh_per_yr:.0f} kWh cost £{re_.total_fuel_cost_gbp:.2f}"
)
printed = _elmhurst_printed_sap(sample / "elmhurst_worksheet.pdf")
if printed is not None:
print(f" Elmhurst's OWN engine (worksheet 258): {printed}")
diffs: list[str] = []
_deep_diff(gov, elm, "epc", diffs)
print(f"\nFIELD DIFFS gov-API vs Elmhurst ({len(diffs)}):")
print("\n".join(diffs) if diffs else " (none — paths identical)")
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit(__doc__)
compare(sys.argv[1])
if __name__ == "__main__":
main()