diff --git a/domain/property/property.py b/domain/property/property.py index f6b8957d..70acd711 100644 --- a/domain/property/property.py +++ b/domain/property/property.py @@ -7,7 +7,7 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.property.site_notes import SiteNotes -SourcePath = Literal["site_notes", "epc_with_overlay"] +SourcePath = Literal["site_notes", "epc_with_overlay", "predicted"] @dataclass(frozen=True) @@ -38,6 +38,11 @@ class Property: identity: PropertyIdentity epc: Optional[EpcPropertyData] = None site_notes: Optional[SiteNotes] = None + # A neighbour-synthesised EpcPropertyData (EPC Prediction gap-fill, ADR-0031), + # held in its own slot so it coexists with any lodged `epc` (provenance is + # structural). Used as the Effective EPC only as a last resort — when there is + # neither a lodged EPC nor Site Notes; a real source always wins. + predicted_epc: Optional[EpcPropertyData] = None # The current open-market value (a Property Valuation) — externally sourced # and mostly absent; feeds the Plan's Valuation Uplift £ forms (ADR-0018). current_market_value: Optional[float] = None @@ -62,8 +67,11 @@ class Property: return "site_notes" if self.epc is not None: return "epc_with_overlay" + if self.predicted_epc is not None: + return "predicted" raise ValueError( - "Property has neither Site Notes nor an EPC; no source path to model from" + "Property has neither Site Notes, an EPC, nor a predicted EPC; " + "no source path to model from" ) @property @@ -71,10 +79,15 @@ class Property: """The EpcPropertyData the modelling pipeline scores against. Path 1: the Site Notes' surveyed data. Path 2: the public EPC (Landlord - Overrides overlay is a later slice — returned as-is for now). + Overrides overlay is a later slice — returned as-is for now). Path 3: a + neighbour-synthesised EPC (EPC Prediction gap-fill, ADR-0031), used only + when neither real source is present. """ if self.source_path == "site_notes": assert self.site_notes is not None return self.site_notes.to_epc_property_data() + if self.source_path == "predicted": + assert self.predicted_epc is not None + return self.predicted_epc assert self.epc is not None return self.epc diff --git a/tests/domain/property/test_property.py b/tests/domain/property/test_property.py index 01d7edfd..31cfc0ed 100644 --- a/tests/domain/property/test_property.py +++ b/tests/domain/property/test_property.py @@ -98,6 +98,44 @@ def test_effective_epc_follows_the_selected_source_path() -> None: assert epc_property.effective_epc is public_epc +def test_source_path_is_predicted_when_only_a_predicted_epc_is_present() -> None: + # Arrange — no lodged EPC, no Site Notes; just a neighbour-synthesised picture + # (EPC Prediction gap-fill, ADR-0031). + predicted = _epc() + prop = Property(identity=_identity(), predicted_epc=predicted) + + # Act / Assert — predicted is the last-resort source, not a raise + assert prop.source_path == "predicted" + assert prop.effective_epc is predicted + + +def test_a_lodged_epc_wins_over_a_predicted_epc() -> None: + # Arrange — both a real lodged EPC and a neighbour-synthesised one are present; + # the real source must win (prediction is last-resort only, ADR-0031). + lodged = _epc() + predicted = _epc() + prop = Property(identity=_identity(), epc=lodged, predicted_epc=predicted) + + # Act / Assert + assert prop.source_path == "epc_with_overlay" + assert prop.effective_epc is lodged + + +def test_site_notes_win_over_a_predicted_epc() -> None: + # Arrange — Site Notes and a predicted EPC are present; the survey wins. + survey_epc = _epc() + predicted = _epc() + prop = Property( + identity=_identity(), + site_notes=SiteNotes(surveyed_at=date(2024, 6, 1), epc=survey_epc), + predicted_epc=predicted, + ) + + # Act / Assert + assert prop.source_path == "site_notes" + assert prop.effective_epc is survey_epc + + def test_property_with_no_source_raises() -> None: # Arrange prop = Property(identity=_identity())