mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Add the Ara modelling aggregate root (ADR-0002): domain/property/ with PropertyIdentity, SiteNotes, Property, Properties. Property.source_path implements the two disjoint source paths + Recency Tie-Break (ADR-0001; survey wins on an equal date); effective_epc resolves to the surveyed data (Site Notes path) or the public EPC (epc_with_overlay path — Landlord Overrides overlay is a later slice). Pure dataclasses, no infrastructure imports. PropertyRepository port + PropertyPostgresRepository hydrate the aggregate whole from a defensive view of the FE-owned 'property' table (identity columns) plus the EPC slice via EpcRepository.get_for_property. Reads only from repos (ADR-0003). 8 domain + 1 hydration test; pyright strict clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
73 lines
2.6 KiB
Python
73 lines
2.6 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Literal, Optional
|
|
|
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
|
from domain.property.site_notes import SiteNotes
|
|
|
|
SourcePath = Literal["site_notes", "epc_with_overlay"]
|
|
|
|
|
|
@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
|
|
|
|
@property
|
|
def source_path(self) -> SourcePath:
|
|
"""Which of the two disjoint source paths models this Property (ADR-0001).
|
|
|
|
Site Notes alone, or the public EPC (with Landlord Overrides, once that
|
|
slice lands). When both exist the newer wins (Recency Tie-Break); on an
|
|
equal date the survey wins, as it reflects on-site observation.
|
|
"""
|
|
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"
|
|
raise ValueError(
|
|
"Property has neither Site Notes nor an 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 (Landlord
|
|
Overrides overlay is a later slice — returned as-is for now).
|
|
"""
|
|
if self.source_path == "site_notes":
|
|
assert self.site_notes is not None
|
|
return self.site_notes.to_epc_property_data()
|
|
assert self.epc is not None
|
|
return self.epc
|