Model/repositories/comparable_properties/epc_comparable_properties_repository.py
Khalim Conn-Kowlessar 7ca1f815f6 refactor(epc-prediction): PR review — rename ComparableProperty, relocate PredictionTarget
Two review points from @dancafc:

1) Rename the `Comparable` dataclass → `ComparableProperty` (it models one
   comparable *property*; the collection stays `ComparableProperties`). Applied
   across domain, repositories, orchestration, harness, scripts, and tests with a
   word-boundary rename so `ComparableProperties` is untouched.

2) Move `PredictionTarget` out of comparable_properties.py into prediction_target.py
   (where `PredictionTargetAttributes` + `build_prediction_target` already live).
   comparable_properties.py now imports it; no import cycle (prediction_target no
   longer depends on comparable_properties). Importers updated.

92 tests pass across the touched suites; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:34:44 +00:00

82 lines
3 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
from datetime import date
from typing import 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,
)
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 EpcComparablePropertiesRepository(ComparablePropertiesRepository):
def __init__(
self, epc_client: CohortEpcClient, geospatial: CohortGeospatial
) -> None:
self._epc_client = epc_client
self._geospatial = geospatial
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
)
return [self._comparable(result, coordinates) for result in results]
def _comparable(
self, result: EpcSearchResult, coordinates: dict[int, Coordinates]
) -> ComparableProperty:
epc: EpcPropertyData = self._epc_client.get_by_certificate_number(
result.certificate_number
)
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