mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Closes out the cohort-broadening work with its decision record and consolidates the retry plumbing. ADR-0034 documents broadening the EPC-Prediction cohort to the real unit postcodes nearest the target (via postcodes.io) when its own postcode holds no same-type comparable — extending ADR-0031 decision 5. Records why postcodes.io was chosen over council[] (whole-LA, no property_type in rows), a bulk Code-Point Open / ONSPD dataset, and the OS Places radius API, and the lazy / nearest-first early-stop / soft-fail policy. Broadening-specific docstrings now cite 0034. Retry consolidation: extract the EPC client's call_with_retry into a shared infrastructure/http_retry.py keyed off a generic TransientHttpError marker, so the mechanism (exponential backoff, Retry-After) is shared while each client keeps its own transient policy. EpcRateLimitError now subclasses TransientHttpError (still an EpcApiError); PostcodesIoClient routes through the same helper, raising TransientHttpError on 429/5xx and soft-failing to the seed once exhausted (the EPC client propagates instead). Direct tests for the shared helper; EPC + postcodes.io suites repointed at the shared sleep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
145 lines
5.8 KiB
Python
145 lines
5.8 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
|
|
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from domain.geospatial.coordinates import Coordinates
|
|
from infrastructure.http_retry import TransientHttpError, call_with_retry
|
|
|
|
|
|
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:
|
|
"""The parsed ``result`` payload for a postcodes.io GET, retried on
|
|
transient failures via the shared ``call_with_retry``, 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. The soft-fail is
|
|
the difference from the EPC client, which lets the error propagate."""
|
|
try:
|
|
return call_with_retry(
|
|
lambda: self._fetch(path, params),
|
|
max_retries=self.MAX_RETRIES,
|
|
backoff_base=self.BACKOFF_BASE,
|
|
backoff_multiplier=self.BACKOFF_MULTIPLIER,
|
|
max_backoff=self.MAX_BACKOFF,
|
|
)
|
|
except (TransientHttpError, httpx.HTTPError):
|
|
return None
|
|
|
|
def _fetch(self, path: str, params: Optional[dict[str, Any]]) -> Any:
|
|
"""One GET. Raises ``TransientHttpError`` on a 429/5xx (and lets
|
|
``httpx.TransportError`` propagate) so ``call_with_retry`` retries it;
|
|
returns None for a non-transient non-success (e.g. 404) or unparseable
|
|
body."""
|
|
response = httpx.get(
|
|
f"{self.BASE_URL}{path}",
|
|
params=params,
|
|
timeout=self.REQUEST_TIMEOUT,
|
|
)
|
|
if self._is_transient(response.status_code):
|
|
raise TransientHttpError(
|
|
f"postcodes.io {response.status_code} on {path}",
|
|
retry_after=self._retry_after(response),
|
|
)
|
|
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
|
|
|
|
@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
|