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