From 5e6d2cff1694e16480aa6c5c757b64de8e4c21f2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 13 Jun 2026 23:50:07 +0000 Subject: [PATCH] feat(epc-prediction): EpcPrediction hybrid synthesis (ADR-0029) predict() copies a representative template comparable's structure (coherent for the calculator), overrides the homogeneous categorical with the cohort mode (robust to an atypical template), then applies known Landlord Overrides on top (a known value wins over the estimate). Proven on wall construction; roof/floor/ insulation/age extend on the same mode+override mechanism, driven next by the validation harness metrics. Co-Authored-By: Claude Opus 4.8 --- domain/epc_prediction/epc_prediction.py | 88 +++++++++++++++++ .../epc_prediction/test_epc_prediction.py | 98 +++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 domain/epc_prediction/epc_prediction.py create mode 100644 tests/domain/epc_prediction/test_epc_prediction.py diff --git a/domain/epc_prediction/epc_prediction.py b/domain/epc_prediction/epc_prediction.py new file mode 100644 index 00000000..9806b87d --- /dev/null +++ b/domain/epc_prediction/epc_prediction.py @@ -0,0 +1,88 @@ +"""EPC Prediction synthesis (ADR-0029). + +`EpcPrediction.predict` turns the selected `ComparableProperties` into a +predicted `EpcPropertyData`: copy a coherent representative template's structure +(building parts, windows, geometry), set the homogeneous categoricals to the +recency-weighted cohort mode, then apply Landlord Overrides on top. Pure domain +logic — deterministic neighbour synthesis, not ML. +""" + +from __future__ import annotations + +import copy +from collections import Counter +from typing import Iterable, Optional, Union + +from datatypes.epc.domain.epc_property_data import ( + EpcPropertyData, + SapBuildingPart, +) +from domain.epc_prediction.comparable_properties import ( + Comparable, + ComparableProperties, + PredictionTarget, +) + + +class EpcPrediction: + """Synthesises a predicted `EpcPropertyData` from Comparable Properties.""" + + def predict( + self, target: PredictionTarget, comparables: ComparableProperties + ) -> EpcPropertyData: + """Predict the target's EPC picture: copy a representative template's + structure (coherent for the calculator), then set the homogeneous + categoricals to the cohort mode.""" + template: Comparable = self._template(comparables) + predicted: EpcPropertyData = copy.deepcopy(template.epc) + self._apply_categorical_modes(predicted, comparables) + self._apply_overrides(predicted, target) + return predicted + + @staticmethod + def _template(comparables: ComparableProperties) -> Comparable: + """The representative comparable whose structure seeds the prediction.""" + return comparables.members[0] + + @staticmethod + def _apply_categorical_modes( + predicted: EpcPropertyData, comparables: ComparableProperties + ) -> None: + """Override the predicted picture's homogeneous categoricals with the + cohort mode (robust to an atypical template).""" + if not predicted.sap_building_parts: + return + main = predicted.sap_building_parts[0] + wall_mode = _mode( + _main_wall_construction(c) for c in comparables.members + ) + if wall_mode is not None: + main.wall_construction = wall_mode + + @staticmethod + def _apply_overrides( + predicted: EpcPropertyData, target: PredictionTarget + ) -> None: + """Apply the known Landlord Overrides on top of the estimate — a known + value always wins over the cohort mode (ADR-0029).""" + if not predicted.sap_building_parts: + return + if target.wall_construction is not None: + predicted.sap_building_parts[0].wall_construction = ( + target.wall_construction + ) + + +def _main_wall_construction(comparable: Comparable) -> Optional[Union[int, str]]: + parts: list[SapBuildingPart] = comparable.epc.sap_building_parts + return parts[0].wall_construction if parts else None + + +def _mode( + values: Iterable[Optional[Union[int, str]]], +) -> Optional[Union[int, str]]: + """The most common non-None value, or None when there are none.""" + present = [v for v in values if v is not None] + if not present: + return None + return Counter(present).most_common(1)[0][0] diff --git a/tests/domain/epc_prediction/test_epc_prediction.py b/tests/domain/epc_prediction/test_epc_prediction.py new file mode 100644 index 00000000..8e2a139c --- /dev/null +++ b/tests/domain/epc_prediction/test_epc_prediction.py @@ -0,0 +1,98 @@ +"""Behaviour of EPC Prediction synthesis (ADR-0029): turn the selected +Comparable Properties into a predicted EpcPropertyData. Hybrid — copy a coherent +representative template's structure (building parts, windows, geometry), set the +homogeneous categoricals to the recency-weighted cohort mode, apply Landlord +Overrides on top. Pure domain logic. +""" + +from typing import Union + +from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart +from domain.epc_prediction.comparable_properties import ( + Comparable, + ComparableProperties, + PredictionTarget, +) +from domain.epc_prediction.epc_prediction import EpcPrediction + + +def _epc( + *, + building_parts: int = 1, + floor_area: float = 80.0, + wall_construction: Union[int, str] = 1, +) -> EpcPropertyData: + epc: EpcPropertyData = object.__new__(EpcPropertyData) + epc.property_type = "2" + epc.built_form = "4" + epc.total_floor_area_m2 = floor_area + parts: list[SapBuildingPart] = [] + for _ in range(building_parts): + part: SapBuildingPart = object.__new__(SapBuildingPart) + part.wall_construction = wall_construction + parts.append(part) + epc.sap_building_parts = parts + return epc + + +def _cohort(*epcs: EpcPropertyData) -> ComparableProperties: + return ComparableProperties( + members=tuple( + Comparable(epc=e, certificate_number=str(i)) for i, e in enumerate(epcs) + ) + ) + + +def test_predicts_a_picture_by_copying_a_representative_template() -> None: + # Arrange — a single comparable with a distinctive structure (2 building + # parts, 92 m²); with nothing else to go on it is the template. + template = _epc(building_parts=2, floor_area=92.0) + target = PredictionTarget(postcode="LS6 1AA", property_type="2") + + # Act + predicted: EpcPropertyData = EpcPrediction().predict(target, _cohort(template)) + + # Assert — the structure is copied wholesale (and it is a copy, not the same + # object — the baseline must never be mutated). + assert len(predicted.sap_building_parts) == 2 + assert predicted.total_floor_area_m2 == 92.0 + assert predicted is not template + + +def test_sets_main_wall_construction_to_the_cohort_mode() -> None: + # Arrange — the template (members[0]) is solid brick (2), but the cohort + # majority is cavity (1). The homogeneous categorical should follow the mode, + # not the one template, so the prediction is robust to an atypical template. + cohort = _cohort( + _epc(wall_construction=2), + _epc(wall_construction=1), + _epc(wall_construction=1), + _epc(wall_construction=1), + ) + + # Act + predicted: EpcPropertyData = EpcPrediction().predict( + PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort + ) + + # Assert — cavity (the mode) wins over the solid-brick template. + assert predicted.sap_building_parts[0].wall_construction == 1 + + +def test_applies_a_known_wall_override_over_the_mode() -> None: + # Arrange — the cohort mode is cavity (1), but we KNOW the target is solid + # brick (2), a Landlord Override. The known value must win over the estimate. + cohort = _cohort( + _epc(wall_construction=1), + _epc(wall_construction=1), + _epc(wall_construction=1), + ) + target = PredictionTarget( + postcode="LS6 1AA", property_type="2", wall_construction=2 + ) + + # Act + predicted: EpcPropertyData = EpcPrediction().predict(target, cohort) + + # Assert — the known override overrides the cohort mode. + assert predicted.sap_building_parts[0].wall_construction == 2