Model/repositories/comparable_properties/epc_comparable_properties_repository.py
Khalim Conn-Kowlessar 4f4ec32e51 Merge remote-tracking branch 'origin/main' into feature/e2e-runs
# Conflicts:
#	repositories/comparable_properties/epc_comparable_properties_repository.py
#	tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py
2026-06-23 17:07:27 +00:00

191 lines
7.8 KiB
Python

"""EPC-API + geospatial adapter for the ComparableProperties port (ADR-0031).
Assembles a postcode's candidate cohort: the EPC search lists the postcode's
lodged certs, each is fetched + mapped to `EpcPropertyData`, and the certs' UPRNs
are resolved to coordinates in one batched geospatial read (closely-numbered
UPRNs share a partition). Register metadata the cert itself doesn't carry
(address, registration date) is threaded off the search row.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
from typing import Callable, Optional, Protocol
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.search.epc_search_result import EpcSearchResult
from domain.epc_prediction.comparable_properties import ComparableProperty
from domain.geospatial.coordinates import Coordinates
from repositories.comparable_properties.comparable_properties_repository import (
ComparablePropertiesRepository,
)
# The same default floor `select_comparables` uses: keep walking nearby postcodes
# until this many candidates match, so the broadened cohort is big enough for the
# downstream relax ladder rather than stopping at the first stray match.
_DEFAULT_MINIMUM_COHORT = 5
class CohortEpcClient(Protocol):
"""The slice of the EPC-API client the cohort fetch needs (e.g.
`EpcClientService`)."""
def search_by_postcode(self, postcode: str) -> list[EpcSearchResult]: ...
def get_by_certificate_number(self, cert_num: str) -> EpcPropertyData: ...
class CohortGeospatial(Protocol):
"""The geospatial slice the cohort fetch needs — batch UPRN→coordinate."""
def coordinates_for_uprns(
self, uprns: list[int]
) -> dict[int, Coordinates]: ...
class NearbyPostcodes(Protocol):
"""Resolves the real unit postcodes physically near a seed postcode (e.g.
`PostcodesIoClient`). The gov EPC API cannot search by radius, so this is how
the cohort reaches beyond the target's own postcode (ADR-0034)."""
def nearby(
self, postcode: str, coordinates: Optional[Coordinates] = None
) -> list[str]: ...
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class SkippedCohortCert:
"""A postcode-cohort cert the mapper could not consume, so it was excluded
from the cohort rather than sinking the whole prediction. The certificate
number + error are captured so the gap surfaces (subtask outputs + logs) and
can be closed by extending the mapper later (ADR-0031)."""
certificate_number: str
error: str
class EpcComparablePropertiesRepository(ComparablePropertiesRepository):
def __init__(
self,
epc_client: CohortEpcClient,
geospatial: CohortGeospatial,
nearby_postcodes: Optional[NearbyPostcodes] = None,
) -> None:
self._epc_client = epc_client
self._geospatial = geospatial
self._nearby_postcodes = nearby_postcodes
# Cohort certs skipped because they are not yet mappable. Accumulates
# across every postcode the instance serves; the caller reads it after
# the run to report the mapper gaps (see modelling_e2e handler).
self.skipped: list[SkippedCohortCert] = []
def candidates_for(self, postcode: str) -> list[ComparableProperty]:
results: list[EpcSearchResult] = self._epc_client.search_by_postcode(
postcode
)
uprns: list[int] = [r.uprn for r in results if r.uprn is not None]
coordinates: dict[int, Coordinates] = self._geospatial.coordinates_for_uprns(
uprns
)
cohort: list[ComparableProperty] = []
for result in results:
comparable = self._comparable_or_skip(result, coordinates)
if comparable is not None:
cohort.append(comparable)
return cohort
def candidates_near(
self,
postcode: str,
coordinates: Optional[Coordinates] = None,
*,
enough: Optional[Callable[[ComparableProperty], bool]] = None,
minimum: int = _DEFAULT_MINIMUM_COHORT,
) -> list[ComparableProperty]:
"""The broadened cohort: candidates drawn from the real unit postcodes
nearest ``postcode`` (ADR-0034), for when the target's own postcode holds
no same-type comparables. Postcodes are visited nearest first and each
candidate is deduped by certificate number across them.
``enough`` lets the caller stop the walk early — once ``minimum``
candidates satisfy it (e.g. they match the target's property type) the
remaining, further-away postcodes are not fetched, so a dense area
resolves in one or two searches instead of the whole radius. Without a
configured ``NearbyPostcodes`` source this degrades to the seed postcode
alone."""
postcodes = (
self._nearby_postcodes.nearby(postcode, coordinates)
if self._nearby_postcodes is not None
else [postcode]
)
candidates: list[ComparableProperty] = []
seen_certs: set[str] = set()
matches = 0
for nearby_postcode in postcodes:
for candidate in self.candidates_for(nearby_postcode):
if candidate.certificate_number in seen_certs:
continue
seen_certs.add(candidate.certificate_number)
candidates.append(candidate)
if enough is not None and enough(candidate):
matches += 1
if enough is not None and matches >= minimum:
break
return candidates
# Mapper-shape errors: the cert's lodged data does not fit the schema/mapper.
# `ValueError` — missing required field / unmapped code (`UnmappedApiCode`);
# `AttributeError` — a field lodged in the wrong shape (e.g. `photovoltaic_supply`
# as a list where a `PhotovoltaicSupply` object is expected → "'list' object has
# no attribute 'none_or_no_details'"); `KeyError`/`TypeError` — analogous
# structural mismatches. Transient API failures (`EpcApiError` &c.) subclass
# `Exception` directly, NOT these, so they still propagate and fail the run loud.
_UNMAPPABLE_CERT_ERRORS = (ValueError, AttributeError, KeyError, TypeError)
def _comparable_or_skip(
self, result: EpcSearchResult, coordinates: dict[int, Coordinates]
) -> Optional[ComparableProperty]:
"""Map one cohort cert, or record + skip it when the mapper raises a
mapper-shape error (see ``_UNMAPPABLE_CERT_ERRORS``). One unmappable cert
must NOT abort the whole cohort; transient API errors still propagate."""
try:
epc: EpcPropertyData = self._epc_client.get_by_certificate_number(
result.certificate_number
)
except self._UNMAPPABLE_CERT_ERRORS as exc:
self.skipped.append(
SkippedCohortCert(
certificate_number=result.certificate_number,
error=f"{type(exc).__name__}: {exc}",
)
)
logger.warning(
"skipping unmappable cohort cert %s: %s",
result.certificate_number,
exc,
)
return None
resolved: Optional[Coordinates] = (
coordinates.get(result.uprn) if result.uprn is not None else None
)
return ComparableProperty(
epc=epc,
certificate_number=result.certificate_number,
address=result.address_line_1,
registration_date=_parse_date(result.registration_date),
coordinates=resolved,
)
def _parse_date(value: str) -> Optional[date]:
"""The register's ISO registration date, or None when unparseable (the
predictor falls back to an unweighted recency)."""
try:
return date.fromisoformat(value[:10])
except ValueError:
return None