mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
113 lines
5.1 KiB
Python
113 lines
5.1 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Literal, Optional, Sequence
|
|
|
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
|
from domain.modelling.scoring.overlay_applicator import apply_simulations
|
|
from domain.modelling.simulation import EpcSimulation
|
|
from domain.property.site_notes import SiteNotes
|
|
|
|
SourcePath = Literal["site_notes", "epc_with_overlay", "predicted"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PropertyIdentity:
|
|
"""Identifies a single Property within a portfolio.
|
|
|
|
Keyed by `(portfolio_id, uprn)` or `(portfolio_id, landlord_property_id)` —
|
|
a UPRN is permanent but each portfolio gets its own Property against it
|
|
(CONTEXT.md: UPRN).
|
|
"""
|
|
|
|
portfolio_id: int
|
|
postcode: str
|
|
address: str
|
|
uprn: Optional[int] = None
|
|
landlord_property_id: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class Property:
|
|
"""The Ara modelling aggregate root for a single dwelling (ADR-0002).
|
|
|
|
Holds identity plus the source data the pipeline reasons about. Enrichments
|
|
(geospatial, solar) and modelling outputs (baseline performance, plans) are
|
|
added by later slices — this is the minimal-and-growing shape for First Run.
|
|
"""
|
|
|
|
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
|
|
# Resolved Landlord Overrides as Simulation Overlays, folded onto the lodged
|
|
# OR neighbour-synthesised EPC to form the Effective EPC (ADR-0032). Empty
|
|
# when the Property has no overrides — the EPC is then returned unchanged.
|
|
# Applied on the `epc_with_overlay` and `predicted` paths; never when Site
|
|
# Notes are the source.
|
|
landlord_overrides: Sequence[EpcSimulation] = field(default_factory=tuple)
|
|
# 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
|
|
# Planning protections resolved from the geospatial layer (ADR-0020); gate
|
|
# wall insulation (ADR-0019). Defaults to unrestricted when unknown.
|
|
planning_restrictions: PlanningRestrictions = PlanningRestrictions()
|
|
|
|
@property
|
|
def source_path(self) -> SourcePath:
|
|
"""Which of the three disjoint source paths models this Property (ADR-0001).
|
|
|
|
Site Notes, or the public EPC (with Landlord Overrides folded on), or —
|
|
as a last resort when neither real source exists — a neighbour-synthesised
|
|
EPC (EPC Prediction, ADR-0031). When both Site Notes and an EPC exist the
|
|
newer wins (Recency Tie-Break); on an equal date the survey wins, as it
|
|
reflects on-site observation. A real source always beats the prediction.
|
|
"""
|
|
if self.site_notes is not None and self.epc is not None:
|
|
epc_date = self.epc.registration_date or self.epc.inspection_date
|
|
if self.site_notes.surveyed_at >= epc_date:
|
|
return "site_notes"
|
|
return "epc_with_overlay"
|
|
if self.site_notes is not None:
|
|
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, an EPC, nor a predicted EPC; "
|
|
"no source path to model from"
|
|
)
|
|
|
|
@property
|
|
def effective_epc(self) -> EpcPropertyData:
|
|
"""The EpcPropertyData the modelling pipeline scores against.
|
|
|
|
Path 1: the Site Notes' surveyed data.
|
|
Path 2: the public EPC with any Landlord Overrides folded on as Simulation Overlays (ADR-0032) — returned
|
|
as-is when there are none.
|
|
Path 3: a neighbour-synthesised EPC (EPC Prediction gap-fill, ADR-0031), likewise with any Landlord Overrides
|
|
folded on: the cohort fills the unknown fields, the landlord's known
|
|
facts (wall/roof/glazing/heating/age) correct them. 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._with_overrides(self.predicted_epc)
|
|
assert self.epc is not None
|
|
return self._with_overrides(self.epc)
|
|
|
|
def _with_overrides(self, epc: EpcPropertyData) -> EpcPropertyData:
|
|
"""``epc`` with any Landlord Overrides folded on as Simulation Overlays,
|
|
or unchanged when there are none (ADR-0032)."""
|
|
if self.landlord_overrides:
|
|
return apply_simulations(epc, self.landlord_overrides)
|
|
return epc
|