Model/scripts/hyde_epc_schema_versions.py
Jun-te Kim 0e85da1507 Resolve a landlord mains-gas override to the primary fuel code 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:15:54 +00:00

159 lines
6 KiB
Python

"""Tally the EPC schema versions across the hyde list (manipulation_filled UPRNs).
For every resolved UPRN we look up its EPC certificate's ``schemaType`` (e.g.
``RdSAP-Schema-21.0.1``, ``RdSAP-Schema-17.1``, ``SAP-Schema-16.2``). The
gov EPC ``/api/domestic/search`` endpoint returns ``schemaType`` per row, so one
search-per-postcode covers every UPRN in that postcode — far cheaper than a
certificate fetch per UPRN. The latest cert (max registrationDate) wins per UPRN.
Outputs: a per-schema-version tally with one example UPRN each, plus a CSV
mapping every UPRN -> schema version.
python -m scripts.hyde_epc_schema_versions
python -m scripts.hyde_epc_schema_versions --workers 8 --out scripts/hyde_schema_versions.csv
Reads OPEN_EPC_API_TOKEN from backend/.env. Run from the worktree root.
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from collections import Counter, defaultdict
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any, Optional
import httpx
from dotenv import load_dotenv
_REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import trap
from scripts.fill_domna_addresses import clean_postcode # noqa: E402
from scripts.finalise_to_property_table import load_rows # noqa: E402
_BASE = "https://api.get-energy-performance-data.communities.gov.uk"
_SEARCH = f"{_BASE}/api/domestic/search"
NOT_IN_EPC = "NOT_IN_EPC"
_DEFAULT_IN = _REPO_ROOT / "scripts" / "manipulation_filled.xlsx"
_DEFAULT_OUT = _REPO_ROOT / "scripts" / "hyde_schema_versions.csv"
def search_postcode(
client: httpx.Client, postcode: str, headers: dict[str, str]
) -> list[dict[str, Any]]:
"""Return the search rows for a postcode, retrying on rate-limit (429)."""
for attempt in range(5):
resp = client.get(_SEARCH, params={"postcode": postcode}, headers=headers, timeout=30)
if resp.status_code == 429:
retry_after = float(resp.headers.get("Retry-After", "2"))
time.sleep(min(retry_after, 10) * (attempt + 1))
continue
# 400 = malformed postcode (data-entry typo), 404 = no certs — skip both.
if resp.status_code in (400, 404):
return []
resp.raise_for_status()
return resp.json().get("data", [])
return []
def build_uprn_schema_map(
postcodes: list[str], token: str, workers: int
) -> dict[int, tuple[str, str]]:
"""Map UPRN -> (schemaType, registrationDate) for the latest cert per UPRN.
One search per postcode (concurrent); later we look our UPRNs up in here.
"""
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
by_uprn: dict[int, tuple[str, str]] = {}
done = 0
total = len(postcodes)
def fetch(pc: str) -> list[dict[str, Any]]:
with httpx.Client() as client:
return search_postcode(client, pc, headers)
with ThreadPoolExecutor(max_workers=workers) as pool:
for rows in pool.map(fetch, postcodes):
for row in rows:
uprn = row.get("uprn")
schema = row.get("schemaType")
reg = row.get("registrationDate") or ""
if uprn is None or not schema:
continue
prev = by_uprn.get(int(uprn))
# Keep the latest-registered cert's schema for this UPRN.
if prev is None or reg > prev[1]:
by_uprn[int(uprn)] = (str(schema), str(reg))
done += 1
if done % 250 == 0:
print(f" searched {done}/{total} postcodes, {len(by_uprn)} uprns seen")
return by_uprn
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--in", dest="inp", type=Path, default=_DEFAULT_IN)
parser.add_argument("--out", type=Path, default=_DEFAULT_OUT)
parser.add_argument("--workers", type=int, default=8)
return parser.parse_args()
def main() -> int:
args = _parse_args()
load_dotenv(_REPO_ROOT / "backend" / ".env")
token = os.environ.get("OPEN_EPC_API_TOKEN")
if not token:
print("OPEN_EPC_API_TOKEN not set (backend/.env)")
return 2
_, rows = load_rows(args.inp, include_unmatched=False)
pairs: list[tuple[int, str, str]] = [] # (uprn, postcode_clean, address)
for r in rows:
uprn = r["address2uprn_uprn"]
if uprn:
pairs.append((int(uprn), clean_postcode(r["postcode"]), r["address2uprn_address"]))
postcodes = sorted({pc for _, pc, _ in pairs if pc})
print(f"{len(pairs)} UPRNs across {len(postcodes)} unique postcodes")
by_uprn = build_uprn_schema_map(postcodes, token, args.workers)
print(f"EPC search returned schema for {len(by_uprn)} distinct UPRNs")
# Resolve each hyde UPRN to its schema version.
tally: Counter[str] = Counter()
example: dict[str, tuple[int, str]] = {}
out_lines: list[tuple[int, str, str, str]] = [] # uprn, schema, postcode, address
seen: set[int] = set()
for uprn, pc, address in pairs:
if uprn in seen:
continue
seen.add(uprn)
schema = by_uprn.get(uprn, (NOT_IN_EPC, ""))[0]
tally[schema] += 1
example.setdefault(schema, (uprn, address))
out_lines.append((uprn, schema, pc, address))
# Write the full per-UPRN mapping.
import csv
with args.out.open("w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(["uprn", "schema_version", "postcode", "matched_address"])
w.writerows(out_lines)
print(f"\nSchema versions across {len(seen)} distinct UPRNs:\n")
print(f" {'schema version':<26} {'count':>7} example UPRN")
print(f" {'-'*26} {'-'*7} {'-'*12}")
for schema, count in tally.most_common():
ex_uprn, ex_addr = example[schema]
print(f" {schema:<26} {count:>7} {ex_uprn} ({ex_addr})")
print(f"\nFull mapping -> {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())