mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
320 lines
11 KiB
Python
320 lines
11 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 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
|
|
|
|
|
|
def _historic(**overrides: str):
|
|
import dataclasses
|
|
|
|
from datatypes.epc.domain.historic_epc import HistoricEpc
|
|
|
|
fields = {f.name: "" for f in dataclasses.fields(HistoricEpc)}
|
|
fields.update(overrides)
|
|
return HistoricEpc(**fields)
|
|
|
|
|
|
class _FakeHistoricReader:
|
|
def __init__(self, record: Optional[Any]) -> None:
|
|
self._record = record
|
|
self.lookups: list[tuple[str, str]] = []
|
|
|
|
def record_for_uprn(self, uprn: str, postcode: str) -> Optional[Any]:
|
|
self.lookups.append((uprn, postcode))
|
|
return self._record
|
|
|
|
|
|
def test_expired_historic_epc_conditions_prediction_and_labels_the_source() -> None:
|
|
# Arrange — EPC-less, NO landlord overrides (property type unresolved), but
|
|
# the historic backup holds this UPRN's expired cert: its stable attributes
|
|
# supply the cohort gate and the persisted source is "expired" (ADR-0054).
|
|
epc_repo = FakeEpcRepo()
|
|
uow = FakeUnitOfWork(
|
|
property=FakePropertyRepo({10: _property(uprn=12345)}),
|
|
epc=epc_repo,
|
|
solar=FakeSolarRepo(),
|
|
)
|
|
comparables_repo = _FakeComparablesRepo(_cohort())
|
|
historic_reader = _FakeHistoricReader(_historic(property_type="House"))
|
|
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(),
|
|
historic_epc_reader=historic_reader,
|
|
)
|
|
|
|
# Act
|
|
orchestrator.run([10])
|
|
|
|
# Assert — looked up by exact UPRN + postcode; predicted via the historic
|
|
# gate; persisted to the predicted slot labelled "expired".
|
|
assert historic_reader.lookups == [("12345", "A0 0AA")]
|
|
assert comparables_repo.searched == ["A0 0AA"]
|
|
assert epc_repo.get_predicted_for_property(10) is not None
|
|
assert epc_repo.sources == ["expired"]
|
|
assert epc_repo.get_for_property(10) is None
|
|
|
|
|
|
def test_landlord_overrides_win_over_the_expired_historic_cert() -> None:
|
|
# Arrange — the landlord says Flat ("2") today; the 2009 cert said House.
|
|
# Overrides speak to current state so the target stays a flat — and the
|
|
# all-house cohort therefore yields no comparables and no prediction.
|
|
epc_repo = FakeEpcRepo()
|
|
uow = FakeUnitOfWork(
|
|
property=FakePropertyRepo({10: _property(uprn=12345)}),
|
|
epc=epc_repo,
|
|
solar=FakeSolarRepo(),
|
|
)
|
|
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=_FakeComparablesRepo(_cohort()),
|
|
prediction_attributes_reader=_FakeAttributesReader(
|
|
PredictionTargetAttributes(property_type="2")
|
|
),
|
|
epc_prediction=EpcPrediction(),
|
|
historic_epc_reader=_FakeHistoricReader(_historic(property_type="House")),
|
|
)
|
|
|
|
# Act
|
|
orchestrator.run([10])
|
|
|
|
# Assert — the historic "House" did not overwrite the landlord's flat.
|
|
assert epc_repo.get_predicted_for_property(10) is None
|
|
|
|
|
|
def test_no_historic_record_keeps_the_plain_predicted_source() -> None:
|
|
# Arrange — historic reader wired but the shard has no row for this UPRN.
|
|
epc_repo = FakeEpcRepo()
|
|
uow = FakeUnitOfWork(
|
|
property=FakePropertyRepo({10: _property(uprn=12345)}),
|
|
epc=epc_repo,
|
|
solar=FakeSolarRepo(),
|
|
)
|
|
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=_FakeComparablesRepo(_cohort()),
|
|
prediction_attributes_reader=_FakeAttributesReader(
|
|
PredictionTargetAttributes(property_type="0")
|
|
),
|
|
epc_prediction=EpcPrediction(),
|
|
historic_epc_reader=_FakeHistoricReader(None),
|
|
)
|
|
|
|
# Act
|
|
orchestrator.run([10])
|
|
|
|
# Assert — an ordinary prediction, labelled "predicted".
|
|
assert epc_repo.get_predicted_for_property(10) is not None
|
|
assert epc_repo.sources == ["predicted"]
|