From 4d5504fa104604125955753098226a3aef9703f0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 19:15:11 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20thread=20physical-state-change=20signal?= =?UTF-8?q?=20into=20rebaselining=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Property.physical_state_changed (true on Site Notes / Landlord Overrides / Prediction — trigger (b)/(c)) and pass it from the PropertyBaselineOrchestrator into the Rebaseliner. So an overridden or predicted SAP>=10.2 property now stores calc(effective) as its Effective baseline instead of echoing the lodged headline — closing the "81 in the DB" divergence between the displayed baseline and the modelled plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/property/property.py | 13 +++++ .../property_baseline_orchestrator.py | 5 +- .../test_property_physical_state_changed.py | 53 +++++++++++++++++++ .../test_property_baseline_orchestrator.py | 7 ++- 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/domain/property/test_property_physical_state_changed.py diff --git a/domain/property/property.py b/domain/property/property.py index f3cce2eb..a22ab46b 100644 --- a/domain/property/property.py +++ b/domain/property/property.py @@ -84,6 +84,19 @@ class Property: "no source path to model from" ) + @property + def physical_state_changed(self) -> bool: + """True when the Effective EPC was assembled from something other than a + pristine lodged cert — Site Notes superseded it, Landlord Overrides were + folded on, or it was synthesised by EPC Prediction (Rebaselining trigger + (b)/(c), CONTEXT.md). The Rebaseliner uses this to adopt the calculator's + output over the accredited lodged figure even at SAP >= 10.2. A pristine + lodged EPC with no overrides returns False.""" + path = self.source_path + if path == "site_notes" or path == "predicted": + return True + return bool(self.landlord_overrides) + @property def effective_epc(self) -> EpcPropertyData: """The EpcPropertyData the modelling pipeline scores against. diff --git a/orchestration/property_baseline_orchestrator.py b/orchestration/property_baseline_orchestrator.py index 4a290a5d..bc77605f 100644 --- a/orchestration/property_baseline_orchestrator.py +++ b/orchestration/property_baseline_orchestrator.py @@ -52,7 +52,10 @@ class PropertyBaselineOrchestrator: effective_epc = prop.effective_epc lodged = lodged_performance(effective_epc) rebaselined = self._rebaseliner.rebaseline( - property_id, effective_epc, lodged + property_id, + effective_epc, + lodged, + physical_state_changed=prop.physical_state_changed, ) # No SapResult (the stub path) means no scored picture to price, # so the bill stays None. diff --git a/tests/domain/property/test_property_physical_state_changed.py b/tests/domain/property/test_property_physical_state_changed.py new file mode 100644 index 00000000..238f32e9 --- /dev/null +++ b/tests/domain/property/test_property_physical_state_changed.py @@ -0,0 +1,53 @@ +"""`Property.physical_state_changed` — the Rebaselining trigger (b)/(c) signal. + +True when the Effective EPC was assembled from something other than a pristine +lodged cert: Landlord Overrides folded on, Site Notes superseding the cert, or an +EPC-Prediction synthesis. The PropertyBaselineOrchestrator threads it into the +Rebaseliner so an overridden / predicted SAP >= 10.2 picture rebaselines off the +calculator instead of echoing the (now-stale) accredited lodged figure. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.epc.property_overlays.wall_type_overlay import wall_overlay_for +from domain.property.property import Property, PropertyIdentity + +_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples" + + +def _epc() -> EpcPropertyData: + raw: dict[str, Any] = json.loads( + (_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text() + ) + return EpcPropertyDataMapper.from_api_response(raw) + + +def _identity() -> PropertyIdentity: + return PropertyIdentity( + portfolio_id=1, postcode="A0 0AA", address="1 Some Street", uprn=12345 + ) + + +def test_pristine_lodged_cert_with_no_overrides_is_unchanged() -> None: + prop = Property(identity=_identity(), epc=_epc()) + assert prop.physical_state_changed is False + + +def test_lodged_cert_with_landlord_overrides_is_changed() -> None: + overlay = wall_overlay_for("Solid brick, with internal insulation", 0) + assert overlay is not None + prop = Property(identity=_identity(), epc=_epc(), landlord_overrides=[overlay]) + assert prop.physical_state_changed is True + + +def test_predicted_property_is_changed() -> None: + # A neighbour-synthesised picture is assembled, not a real lodged cert, so it + # rebaselines off the calculator (trigger (c)). + prop = Property(identity=_identity(), epc=None, predicted_epc=_epc()) + assert prop.physical_state_changed is True diff --git a/tests/orchestration/test_property_baseline_orchestrator.py b/tests/orchestration/test_property_baseline_orchestrator.py index 85930741..76ee42ce 100644 --- a/tests/orchestration/test_property_baseline_orchestrator.py +++ b/tests/orchestration/test_property_baseline_orchestrator.py @@ -158,7 +158,12 @@ class _ScoringRebaseliner(Rebaseliner): self._result = result def rebaseline( - self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance + self, + property_id: int, + effective_epc: EpcPropertyData, + lodged: Performance, + *, + physical_state_changed: bool = False, ) -> RebaselineResult: return RebaselineResult( effective=lodged, reason="none", sap_result=self._result