"""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 ComparableProperty 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[ComparableProperty]) -> None: self._candidates = candidates self.searched: list[str] = [] def candidates_for(self, postcode: str) -> list[ComparableProperty]: 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[ComparableProperty]: # Three same-type neighbours (property_type "0"), distinct addresses so the # dedupe keeps all three. return [ ComparableProperty( 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