Model/infrastructure/postcodes_io/postcodes_io_client.py
Khalim Conn-Kowlessar 0bd2db4f03 feat(modelling_e2e): price gap measures via overlay + broaden prediction to nearby postcodes
Two reconciliations to make the modelling_e2e Lambda handler production-ready.

1. Price through the off-catalogue overlay, drop the workarounds
   The handler priced through a plain ProductPostgresRepository and excluded
   secondary_heating_removal / system_tune_up / system_tune_up_zoned to dodge
   ProductNotFound (and a poisoning pgEnum DataError). Those measures are now
   priced by catalogue_with_off_catalogue_overrides (already used by the e2e
   runner and PostgresUnitOfWork), so the exclusions are removed and ALL measure
   types are considered. This also fixes gas-boiler / single-glazed properties,
   which Dan's handler never excluded and so still crashed (the standard
   system_tune_up option is built unconditionally — the considered-measures
   exclusion never actually gated it).

2. Broaden the EPC-Prediction cohort to nearby real postcodes (ADR-0031)
   A property with no lodged EPC and no same-type comparable in its own postcode
   (e.g. the only flat among houses) used to gate out and fail the subtask. The
   gov EPC API cannot search by radius/outcode, so we resolve the real unit
   postcodes physically nearest the target via postcodes.io (keyless; already a
   trusted in-repo dependency) and walk them nearest-first until enough same-type
   comparables surface. New PostcodesIoClient (transient-failure retry with
   exponential backoff, soft-failing to the seed so broadening never breaks
   prediction) and EpcComparablePropertiesRepository.candidates_near. Wired into
   the handler and e2e runner; broadening is lazy (only on gate-out) and memoised
   per (postcode, property_type).

Validated live: property 728476 (gas boiler) prices system_tune_up at GBP295;
property 718580 (lone flat in BR6 6BS) now predicts via nearby BR6 postcodes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:25:18 +00:00

151 lines
6 KiB
Python

"""postcodes.io adapter — a coordinate (or seed postcode) → the real unit
postcodes physically near it.
The gov EPC API only searches a *full* real postcode — no outcode/prefix, no
radius, no lat/long (confirmed against its OpenAPI spec). So to broaden an
EPC-Prediction cohort beyond the target's own postcode we must first discover the
real unit postcodes around it. postcodes.io's free, keyless ``nearest`` endpoint
does exactly that: given a point it returns the unit postcodes within a radius,
nearest first.
Failure is deliberately non-fatal: any error (network, unknown seed, missing
coordinates) returns just the seed postcode, so broadening degrades to "no
broadening" rather than breaking prediction.
"""
from __future__ import annotations
import time
from typing import Any, Optional
import httpx
from domain.geospatial.coordinates import Coordinates
class PostcodesIoClient:
BASE_URL = "https://api.postcodes.io"
REQUEST_TIMEOUT = 10.0
# Transient failures (transport errors, 429s, 5xx) are retried with
# exponential backoff; everything else (and exhaustion) soft-fails to the
# seed, so broadening never breaks prediction.
MAX_RETRIES = 3
BACKOFF_BASE = 0.5
BACKOFF_MULTIPLIER = 2.0
MAX_BACKOFF = 8.0
def __init__(self, *, radius_m: int = 1000, limit: int = 30) -> None:
"""``radius_m`` bounds how far the broadened cohort reaches; ``limit``
caps how many nearby postcodes are returned (and so the per-gate-out
fetch cost)."""
self._radius_m = radius_m
self._limit = limit
def nearby(
self, postcode: str, coordinates: Optional[Coordinates] = None
) -> list[str]:
"""The real unit postcodes within ``radius_m`` of ``postcode`` — nearest
first, the seed always included — or just ``[postcode]`` when the seed's
coordinates cannot be resolved or the lookup fails.
``coordinates`` (the target's own, resolved from its UPRN) is used when
given, sparing a postcode→centroid round-trip; otherwise postcodes.io
resolves the seed postcode's centroid itself."""
point = coordinates if coordinates is not None else self._centroid_of(postcode)
if point is None:
return [postcode]
found = self._nearest_to(point)
ordered = [postcode] + [p for p in found if p != postcode]
return ordered[: self._limit]
def _centroid_of(self, postcode: str) -> Optional[Coordinates]:
result = self._get(f"/postcodes/{postcode.replace(' ', '')}")
if result is None:
return None
latitude: Any = result.get("latitude")
longitude: Any = result.get("longitude")
if latitude is None or longitude is None:
return None
return Coordinates(longitude=float(longitude), latitude=float(latitude))
def _nearest_to(self, point: Coordinates) -> list[str]:
results = self._get_list(
"/postcodes",
{
"lon": point.longitude,
"lat": point.latitude,
"radius": self._radius_m,
"limit": self._limit,
},
)
return [str(row["postcode"]) for row in results if row.get("postcode")]
def _get(self, path: str) -> Optional[dict[str, Any]]:
payload = self._call(path, None)
return payload if isinstance(payload, dict) else None
def _get_list(self, path: str, params: dict[str, Any]) -> list[dict[str, Any]]:
payload = self._call(path, params)
if not isinstance(payload, list):
return []
return [row for row in payload if isinstance(row, dict)]
def _call(self, path: str, params: Optional[dict[str, Any]]) -> Any:
"""One GET against postcodes.io, retrying transient failures (transport
errors, 429s, 5xx) with exponential backoff. Returns the parsed
``result`` payload, or None on a non-transient failure (e.g. an unknown
postcode's 404) or once retries are exhausted — broadening then falls
back to the seed alone."""
for attempt in range(self.MAX_RETRIES + 1):
try:
response = httpx.get(
f"{self.BASE_URL}{path}",
params=params,
timeout=self.REQUEST_TIMEOUT,
)
except httpx.TransportError:
if not self._sleep_before_retry(attempt, retry_after=None):
return None
continue
except httpx.HTTPError:
return None # non-transient client-side error (e.g. bad URL)
if self._is_transient(response.status_code):
if not self._sleep_before_retry(
attempt, retry_after=self._retry_after(response)
):
return None
continue
if not response.is_success:
return None
try:
body: Any = response.json()
except ValueError:
return None
return body.get("result") if isinstance(body, dict) else None
return None
def _sleep_before_retry(self, attempt: int, retry_after: Optional[float]) -> bool:
"""Sleep before the next attempt and report whether one remains; on the
final attempt, return False so the caller soft-fails instead of looping."""
if attempt >= self.MAX_RETRIES:
return False
if retry_after is not None:
delay = retry_after
else:
delay = self.BACKOFF_BASE * (self.BACKOFF_MULTIPLIER**attempt)
time.sleep(min(delay, self.MAX_BACKOFF))
return True
@staticmethod
def _is_transient(status_code: int) -> bool:
return status_code == 429 or status_code >= 500
@staticmethod
def _retry_after(response: httpx.Response) -> Optional[float]:
header = response.headers.get("Retry-After")
if header is None:
return None
try:
return float(header)
except (TypeError, ValueError):
return None