"""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//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 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()