mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(epc-prediction): slice-5a predicted source path on Property
Add a `predicted_epc` slot to the Property aggregate and a "predicted" branch to SourcePath / source_path / effective_epc (ADR-0031 decisions 1+3). A neighbour-synthesised EpcPropertyData resolves as the Effective EPC ONLY when there is neither a lodged EPC nor Site Notes — a real source always wins (prediction is last-resort gap-fill). The slot is distinct from `epc` so a predicted picture coexists with any lodged one (provenance is structural, not a flag on EpcPropertyData); downstream consumers are untouched. 3 tests: predicted resolves when sole source; lodged EPC wins over predicted; Site Notes win over predicted. 10/10 green, pyright strict clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d1227fd0c6
commit
086187ddc7
2 changed files with 54 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue