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