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