Model/domain/property/property.py
Khalim Conn-Kowlessar b3f4609c2d feat(modelling): wire Valuation Uplift onto the Plan
The Plan derives its Valuation Uplift (ADR-0018) from its baseline -> post
band jump and works+contingency cost, given one external input — the
Property's current market value (a Property Valuation, mostly absent).
`Plan.valuation` / `Plan.baseline_epc_rating` are derived like the other
headline figures; `PlanModel.from_domain` maps the £ forms to the live
plan.valuation_* columns (NULL when no value — the percentage is not
persisted on those columns). `Property.current_market_value` is the new
optional source; the orchestrator threads it onto the Plan. `run_one`
takes a `current_market_value` so the harness can value the uplift, and
the sense-check table shows the average % (always) plus the £ forms when
known.

Sourcing the current market value (upload / default) remains deferred
(ADR-0018); it is None throughout until that lands, so the columns stay
NULL at scale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:59:04 +00:00

76 lines
2.8 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
# 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
@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