mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-19 17:03:02 +00:00
# Conflicts: # repositories/comparable_properties/epc_comparable_properties_repository.py # tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py
191 lines
7.8 KiB
Python
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
|