Model/tests/orchestration/test_ingestion_prediction.py
Khalim Conn-Kowlessar 5727ac53c1 feat(epc-prediction): slice-5e ingestion wiring (gate → predict → persist)
Wire EPC Prediction gap-fill into IngestionOrchestrator (ADR-0031). When the
predictor collaborators are injected (ComparablesRepo + PredictionAttributesReader
+ EpcPrediction), an EPC-less Property is predicted from its postcode cohort and
persisted to the predicted slot; the eligibility gate (unknown property_type) and
"a lodged EPC is never predicted over" both hold. The two-phase contract is kept:
prediction attributes (Landlord Overrides) resolve in the unit prep phase, the
cohort fetch + select + predict run in the no-unit IO phase, persistence in the
write phase. All three collaborators are OPTIONAL — unwired, ingestion behaves
exactly as before (existing tests unchanged).

3 tests (predict+persist, gate, lodged-wins); 228 pass across orchestration +
epc_prediction + repositories; pyright strict clean. Production composition-root
wiring (real ComparableProperties + override-attributes adapters) is part of the
Jun-te handover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 04:03:02 +00:00

204 lines
7 KiB
Python

"""IngestionOrchestrator predicts an EPC for an EPC-less Property and persists it
to the predicted slot (ADR-0031 slice-5e). Tested against fakes — no IO. The
prediction collaborators are optional; when unwired, ingestion is unchanged."""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from typing import Any, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.epc_prediction.comparable_properties import Comparable
from domain.epc_prediction.epc_prediction import EpcPrediction
from domain.epc_prediction.prediction_target import PredictionTargetAttributes
from domain.geospatial.coordinates import Coordinates
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.geospatial.spatial_reference import SpatialReference
from domain.property.property import Property, PropertyIdentity
from orchestration.ingestion_orchestrator import IngestionOrchestrator
from repositories.geospatial.geospatial_repository import GeospatialRepository
from tests.orchestration.fakes import (
FakeEpcRepo,
FakePropertyRepo,
FakeSolarRepo,
FakeUnitOfWork,
)
_JSON_SAMPLES = Path(__file__).resolve().parents[2] / "backend/epc_api/json_samples"
def _epc() -> EpcPropertyData:
raw: dict[str, Any] = json.loads(
(_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text()
)
return EpcPropertyDataMapper.from_api_response(raw)
def _property(uprn: Optional[int], postcode: str = "A0 0AA") -> Property:
return Property(
identity=PropertyIdentity(
portfolio_id=1, postcode=postcode, address="1 Some Street", uprn=uprn
)
)
class _FakeEpcFetcher:
def __init__(self, epc: Optional[EpcPropertyData]) -> None:
self.epc = epc
def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]:
return self.epc
class _FakeGeospatialRepo(GeospatialRepository):
def __init__(self, coordinates: Optional[Coordinates]) -> None:
self._reference = (
SpatialReference(coordinates=coordinates, restrictions=PlanningRestrictions())
if coordinates is not None
else None
)
def coordinates_for(self, uprn: int) -> Optional[Coordinates]:
return self._reference.coordinates if self._reference is not None else None
def spatial_for(self, uprn: int) -> Optional[SpatialReference]:
return self._reference
class _FakeSolarFetcher:
def get_building_insights(
self, longitude: float, latitude: float
) -> dict[str, Any]:
return {}
class _FakeComparablesRepo:
def __init__(self, candidates: list[Comparable]) -> None:
self._candidates = candidates
self.searched: list[str] = []
def candidates_for(self, postcode: str) -> list[Comparable]:
self.searched.append(postcode)
return self._candidates
class _FakeAttributesReader:
def __init__(self, attributes: PredictionTargetAttributes) -> None:
self._attributes = attributes
def attributes_for(self, property_id: int) -> PredictionTargetAttributes:
return self._attributes
def _cohort() -> list[Comparable]:
# Three same-type neighbours (property_type "0"), distinct addresses so the
# dedupe keeps all three.
return [
Comparable(
epc=_epc(),
certificate_number=f"CERT-{i}",
address=f"{i} Some Street",
registration_date=date(2023, 1, i + 1),
)
for i in range(3)
]
def test_epc_less_property_is_predicted_and_persisted_to_the_predicted_slot() -> None:
# Arrange — no lodged EPC, a known property type, and a same-type cohort.
epc_repo = FakeEpcRepo()
uow = FakeUnitOfWork(
property=FakePropertyRepo({10: _property(uprn=12345)}),
epc=epc_repo,
solar=FakeSolarRepo(),
)
comparables_repo = _FakeComparablesRepo(_cohort())
orchestrator = IngestionOrchestrator(
unit_of_work=lambda: uow,
epc_fetcher=_FakeEpcFetcher(None),
geospatial_repo=_FakeGeospatialRepo(
Coordinates(longitude=-0.1, latitude=51.5)
),
solar_fetcher=_FakeSolarFetcher(),
comparables_repo=comparables_repo,
prediction_attributes_reader=_FakeAttributesReader(
PredictionTargetAttributes(property_type="0")
),
epc_prediction=EpcPrediction(),
)
# Act
orchestrator.run([10])
# Assert — a prediction was synthesised from the postcode cohort and persisted
# to the predicted slot; the lodged slot stays empty.
assert comparables_repo.searched == ["A0 0AA"]
predicted = epc_repo.get_predicted_for_property(10)
assert predicted is not None
assert predicted.property_type == "0"
assert epc_repo.get_for_property(10) is None
assert uow.commits == 1
def test_unknown_property_type_gates_the_property_out_of_prediction() -> None:
# Arrange — EPC-less, but the property type could not be resolved.
epc_repo = FakeEpcRepo()
uow = FakeUnitOfWork(
property=FakePropertyRepo({10: _property(uprn=12345)}),
epc=epc_repo,
solar=FakeSolarRepo(),
)
comparables_repo = _FakeComparablesRepo(_cohort())
orchestrator = IngestionOrchestrator(
unit_of_work=lambda: uow,
epc_fetcher=_FakeEpcFetcher(None),
geospatial_repo=_FakeGeospatialRepo(Coordinates(longitude=-0.1, latitude=51.5)),
solar_fetcher=_FakeSolarFetcher(),
comparables_repo=comparables_repo,
prediction_attributes_reader=_FakeAttributesReader(
PredictionTargetAttributes(property_type=None)
),
epc_prediction=EpcPrediction(),
)
# Act
orchestrator.run([10])
# Assert — gated out: no cohort fetched, nothing predicted or persisted.
assert comparables_repo.searched == []
assert epc_repo.get_predicted_for_property(10) is None
assert epc_repo.get_for_property(10) is None
def test_a_lodged_epc_is_not_predicted_over() -> None:
# Arrange — a real EPC is fetched, so prediction must not run.
lodged = _epc()
epc_repo = FakeEpcRepo()
uow = FakeUnitOfWork(
property=FakePropertyRepo({10: _property(uprn=12345)}),
epc=epc_repo,
solar=FakeSolarRepo(),
)
comparables_repo = _FakeComparablesRepo(_cohort())
orchestrator = IngestionOrchestrator(
unit_of_work=lambda: uow,
epc_fetcher=_FakeEpcFetcher(lodged),
geospatial_repo=_FakeGeospatialRepo(Coordinates(longitude=-0.1, latitude=51.5)),
solar_fetcher=_FakeSolarFetcher(),
comparables_repo=comparables_repo,
prediction_attributes_reader=_FakeAttributesReader(
PredictionTargetAttributes(property_type="0")
),
epc_prediction=EpcPrediction(),
)
# Act
orchestrator.run([10])
# Assert — the lodged EPC is saved; no cohort fetch, no predicted slot.
assert comparables_repo.searched == []
assert epc_repo.get_for_property(10) == lodged
assert epc_repo.get_predicted_for_property(10) is None