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