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>
223 lines
7.2 KiB
Python
223 lines
7.2 KiB
Python
"""PostcodesIoClient — coordinate/seed postcode → the real unit postcodes near
|
|
it, via postcodes.io's keyless nearest endpoint. Failure degrades to the seed
|
|
alone so broadening never breaks prediction."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Iterator, Optional
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from domain.geospatial.coordinates import Coordinates
|
|
from infrastructure.postcodes_io.postcodes_io_client import PostcodesIoClient
|
|
|
|
_MODULE = "infrastructure.postcodes_io.postcodes_io_client"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_sleep() -> Iterator[MagicMock]:
|
|
"""Never actually sleep during backoff — just record the calls."""
|
|
with patch(f"{_MODULE}.time.sleep") as sleep:
|
|
yield sleep
|
|
|
|
|
|
def _response(
|
|
payload: Any,
|
|
*,
|
|
status_code: int = 200,
|
|
headers: Optional[dict[str, str]] = None,
|
|
) -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
resp.is_success = 200 <= status_code < 300
|
|
resp.headers = headers if headers is not None else {}
|
|
resp.json.return_value = payload
|
|
return resp
|
|
|
|
|
|
def _nearest_payload(postcodes: list[str]) -> dict[str, Any]:
|
|
return {"result": [{"postcode": p} for p in postcodes]}
|
|
|
|
|
|
def test_nearby_with_coordinates_skips_the_centroid_lookup() -> None:
|
|
"""When the target's own coordinates are passed, only the radius search is
|
|
issued — no postcode→centroid round-trip — and the seed leads the result."""
|
|
# Arrange
|
|
client = PostcodesIoClient(radius_m=500, limit=10)
|
|
coords = Coordinates(longitude=0.1, latitude=51.3)
|
|
|
|
with patch(f"{_MODULE}.httpx.get") as mock_get:
|
|
mock_get.return_value = _response(
|
|
_nearest_payload(["BR6 6BS", "BR6 6BU", "BR6 6NX"])
|
|
)
|
|
|
|
# Act
|
|
result = client.nearby("BR6 6BS", coords)
|
|
|
|
# Assert — one call (the radius search), seed first, neighbours follow
|
|
assert result == ["BR6 6BS", "BR6 6BU", "BR6 6NX"]
|
|
assert mock_get.call_count == 1
|
|
_, kwargs = mock_get.call_args
|
|
assert kwargs["params"]["lat"] == 51.3
|
|
assert kwargs["params"]["lon"] == 0.1
|
|
|
|
|
|
def test_nearby_resolves_the_seed_centroid_when_no_coordinates_given() -> None:
|
|
"""Without coordinates the client first resolves the seed's own centroid via
|
|
postcodes.io, then runs the radius search from it."""
|
|
# Arrange
|
|
client = PostcodesIoClient()
|
|
centroid = {"result": {"latitude": 51.3, "longitude": 0.1}}
|
|
|
|
with patch(f"{_MODULE}.httpx.get") as mock_get:
|
|
mock_get.side_effect = [
|
|
_response(centroid),
|
|
_response(_nearest_payload(["BR6 6BS", "BR6 6BU"])),
|
|
]
|
|
|
|
# Act
|
|
result = client.nearby("BR6 6BS")
|
|
|
|
# Assert — two calls: centroid then radius
|
|
assert result == ["BR6 6BS", "BR6 6BU"]
|
|
assert mock_get.call_count == 2
|
|
|
|
|
|
def test_nearby_dedupes_the_seed_and_caps_at_limit() -> None:
|
|
"""The seed always leads exactly once even when the radius search echoes it,
|
|
and the result is capped at ``limit``."""
|
|
# Arrange
|
|
client = PostcodesIoClient(limit=3)
|
|
coords = Coordinates(longitude=0.1, latitude=51.3)
|
|
|
|
with patch(f"{_MODULE}.httpx.get") as mock_get:
|
|
mock_get.return_value = _response(
|
|
_nearest_payload(["BR6 6BS", "BR6 6BU", "BR6 6NX", "BR6 6AA"])
|
|
)
|
|
|
|
# Act
|
|
result = client.nearby("BR6 6BS", coords)
|
|
|
|
# Assert
|
|
assert result == ["BR6 6BS", "BR6 6BU", "BR6 6NX"]
|
|
assert result.count("BR6 6BS") == 1
|
|
|
|
|
|
def test_nearby_returns_just_the_seed_after_exhausting_retries(
|
|
_no_sleep: MagicMock,
|
|
) -> None:
|
|
"""A persistent network error is retried, then degrades to broadening-off:
|
|
only the seed comes back, and the retries were actually attempted."""
|
|
# Arrange
|
|
client = PostcodesIoClient()
|
|
coords = Coordinates(longitude=0.1, latitude=51.3)
|
|
|
|
with patch(
|
|
f"{_MODULE}.httpx.get", side_effect=httpx.ConnectError("down")
|
|
) as mock_get:
|
|
# Act
|
|
result = client.nearby("BR6 6BS", coords)
|
|
|
|
# Assert — one initial try + MAX_RETRIES, sleeping between each.
|
|
assert result == ["BR6 6BS"]
|
|
assert mock_get.call_count == client.MAX_RETRIES + 1
|
|
assert _no_sleep.call_count == client.MAX_RETRIES
|
|
|
|
|
|
def test_nearby_retries_a_transport_error_then_succeeds(_no_sleep: MagicMock) -> None:
|
|
"""A transient transport error is retried, and the subsequent success is
|
|
returned in full."""
|
|
# Arrange
|
|
client = PostcodesIoClient()
|
|
coords = Coordinates(longitude=0.1, latitude=51.3)
|
|
|
|
with patch(f"{_MODULE}.httpx.get") as mock_get:
|
|
mock_get.side_effect = [
|
|
httpx.ReadTimeout("slow"),
|
|
_response(_nearest_payload(["BR6 6BS", "BR6 6BU"])),
|
|
]
|
|
|
|
# Act
|
|
result = client.nearby("BR6 6BS", coords)
|
|
|
|
# Assert
|
|
assert result == ["BR6 6BS", "BR6 6BU"]
|
|
assert mock_get.call_count == 2
|
|
assert _no_sleep.call_count == 1
|
|
|
|
|
|
def test_nearby_retries_a_429_honouring_retry_after(_no_sleep: MagicMock) -> None:
|
|
"""A 429 is retried, and the server's Retry-After drives the backoff delay."""
|
|
# Arrange
|
|
client = PostcodesIoClient()
|
|
coords = Coordinates(longitude=0.1, latitude=51.3)
|
|
|
|
with patch(f"{_MODULE}.httpx.get") as mock_get:
|
|
mock_get.side_effect = [
|
|
_response(None, status_code=429, headers={"Retry-After": "2"}),
|
|
_response(_nearest_payload(["BR6 6BS", "BR6 6BU"])),
|
|
]
|
|
|
|
# Act
|
|
result = client.nearby("BR6 6BS", coords)
|
|
|
|
# Assert — succeeded on the retry, having slept the advertised 2 seconds.
|
|
assert result == ["BR6 6BS", "BR6 6BU"]
|
|
assert mock_get.call_count == 2
|
|
_no_sleep.assert_called_once_with(2.0)
|
|
|
|
|
|
def test_nearby_retries_a_server_error_then_succeeds(_no_sleep: MagicMock) -> None:
|
|
"""A 5xx is treated as transient and retried."""
|
|
# Arrange
|
|
client = PostcodesIoClient()
|
|
coords = Coordinates(longitude=0.1, latitude=51.3)
|
|
|
|
with patch(f"{_MODULE}.httpx.get") as mock_get:
|
|
mock_get.side_effect = [
|
|
_response(None, status_code=503),
|
|
_response(_nearest_payload(["BR6 6BS"])),
|
|
]
|
|
|
|
# Act
|
|
result = client.nearby("BR6 6BS", coords)
|
|
|
|
# Assert
|
|
assert result == ["BR6 6BS"]
|
|
assert mock_get.call_count == 2
|
|
|
|
|
|
def test_nearby_returns_just_the_seed_when_centroid_unresolvable() -> None:
|
|
"""An unknown seed (no coordinates, centroid lookup fails) yields the seed
|
|
alone rather than raising."""
|
|
# Arrange
|
|
client = PostcodesIoClient()
|
|
|
|
with patch(f"{_MODULE}.httpx.get") as mock_get:
|
|
mock_get.return_value = _response(None, status_code=404)
|
|
|
|
# Act
|
|
result: list[str] = client.nearby("ZZ99 9ZZ")
|
|
|
|
# Assert — a 404 is non-transient, so no retry was attempted.
|
|
assert result == ["ZZ99 9ZZ"]
|
|
assert mock_get.call_count == 1
|
|
|
|
|
|
def test_nearby_tolerates_a_null_nearest_result() -> None:
|
|
"""postcodes.io returns ``result: null`` when a point has no neighbours; the
|
|
client treats that as an empty neighbour set (seed only)."""
|
|
# Arrange
|
|
client = PostcodesIoClient()
|
|
coords: Optional[Coordinates] = Coordinates(longitude=0.1, latitude=51.3)
|
|
|
|
with patch(f"{_MODULE}.httpx.get") as mock_get:
|
|
mock_get.return_value = _response({"result": None})
|
|
|
|
# Act
|
|
result = client.nearby("BR6 6BS", coords)
|
|
|
|
# Assert
|
|
assert result == ["BR6 6BS"]
|