diff --git a/domain/epc_prediction/epc_prediction.py b/domain/epc_prediction/epc_prediction.py index 2d7a47e8..a04a2975 100644 --- a/domain/epc_prediction/epc_prediction.py +++ b/domain/epc_prediction/epc_prediction.py @@ -19,6 +19,7 @@ from typing import Callable, Iterable, Optional, Union from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, + MainHeatingDetail, SapBuildingPart, ) from domain.epc_prediction.comparable_properties import ( @@ -65,9 +66,31 @@ class EpcPrediction: predicted.total_floor_area_m2 = _median_floor_area(comparables.members) self._apply_categorical_modes(predicted, comparables) self._apply_glazing_mode(predicted, comparables) + self._apply_heating_donor(predicted, comparables) self._apply_overrides(predicted, target) return predicted + @staticmethod + def _apply_heating_donor( + predicted: EpcPropertyData, comparables: ComparableProperties + ) -> None: + """Replace the structural template's heating with a coherent donor's whole + `SapHeating` cluster (ADR-0029; issue #1225). Heating sub-fields can't be + field-moded without breaking system coherence (e.g. a fuel that doesn't + match the emitter), so the cluster is copied as a unit from a single + neighbour: the one matching the cohort's modal heating *signature* (main + fuel + category + cylinder presence), the most recent among those matches + (a recent cert reflects the current system). This makes the predicted + heating both representative and internally coherent, rather than whatever + the size-representative template happened to carry. No donor (no neighbour + lodges a main heating system) leaves the template's heating in place.""" + donor = _heating_donor(comparables.members) + if donor is None: + return + predicted.sap_heating = copy.deepcopy(donor.epc.sap_heating) + predicted.has_hot_water_cylinder = donor.epc.has_hot_water_cylinder + predicted.solar_water_heating = donor.epc.solar_water_heating + @staticmethod def _apply_glazing_mode( predicted: EpcPropertyData, comparables: ComparableProperties @@ -368,3 +391,41 @@ def _comparable_modal_glazing( odd window, matching how the harness scores `modal_glazing_type`.""" types = [window.glazing_type for window in comparable.epc.sap_windows] return Counter(types).most_common(1)[0][0] if types else None + + +def _main_heating_detail(comparable: Comparable) -> Optional[MainHeatingDetail]: + """The primary heating system's detail row, or None when none is lodged.""" + details = comparable.epc.sap_heating.main_heating_details + return details[0] if details else None + + +def _heating_signature( + comparable: Comparable, +) -> Optional[tuple[Union[int, str], Optional[int], bool]]: + """The donor-matching signature — main fuel + heating category + cylinder + presence: the coarse identity of the heating system. None when no main heating + system is lodged, so the comparable is not a donor candidate.""" + detail = _main_heating_detail(comparable) + if detail is None: + return None + return ( + detail.main_fuel_type, + detail.main_heating_category, + comparable.epc.has_hot_water_cylinder, + ) + + +def _heating_donor(members: tuple[Comparable, ...]) -> Optional[Comparable]: + """The coherent heating donor: the comparable whose heating signature is the + cohort mode, breaking ties toward the most recent cert (then certificate + number, for determinism). None when no neighbour lodges a heating system.""" + signed = [(c, _heating_signature(c)) for c in members] + signatures = [sig for _, sig in signed if sig is not None] + if not signatures: + return None + modal = Counter(signatures).most_common(1)[0][0] + matches = [c for c, sig in signed if sig == modal] + return max( + matches, + key=lambda c: (c.registration_date or date.min, c.certificate_number), + ) diff --git a/tests/domain/epc_prediction/test_component_accuracy_gate.py b/tests/domain/epc_prediction/test_component_accuracy_gate.py index 82443816..f14942f7 100644 --- a/tests/domain/epc_prediction/test_component_accuracy_gate.py +++ b/tests/domain/epc_prediction/test_component_accuracy_gate.py @@ -37,12 +37,12 @@ _RATE_FLOORS: dict[str, float] = { "roof_construction": 0.7222, "floor_construction": 0.8125, "heating_main_fuel": 0.9722, - "heating_main_category": 0.8889, - "heating_main_control": 0.7500, - "water_heating_fuel": 0.9167, - "water_heating_code": 0.8889, + "heating_main_category": 0.9444, + "heating_main_control": 0.8056, + "water_heating_fuel": 0.9722, + "water_heating_code": 0.9444, "has_hot_water_cylinder": 0.8889, - "cylinder_insulation_type": 0.1667, + "cylinder_insulation_type": 0.5000, "secondary_heating_type": 0.0000, "roof_insulation_thickness": 0.4118, "floor_insulation": 0.9375, diff --git a/tests/domain/epc_prediction/test_epc_prediction.py b/tests/domain/epc_prediction/test_epc_prediction.py index 06a6ae5a..8aaec7f3 100644 --- a/tests/domain/epc_prediction/test_epc_prediction.py +++ b/tests/domain/epc_prediction/test_epc_prediction.py @@ -10,8 +10,10 @@ from typing import Optional, Union from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, + MainHeatingDetail, SapBuildingPart, SapFloorDimension, + SapHeating, SapWindow, ) from domain.epc_prediction.comparable_properties import ( @@ -37,6 +39,13 @@ def _epc( floor_construction: Optional[int] = 1, floor_insulation: Optional[int] = 1, glazing_type: Union[int, str] = 3, + main_fuel_type: Union[int, str] = 1, + main_heating_category: Optional[int] = 1, + main_heating_control: Union[int, str] = 1, + water_heating_fuel: Optional[int] = 1, + water_heating_code: Optional[int] = 1, + has_hot_water_cylinder: bool = True, + solar_water_heating: bool = False, ) -> EpcPropertyData: epc: EpcPropertyData = object.__new__(EpcPropertyData) epc.property_type = "2" @@ -61,6 +70,19 @@ def _epc( window.window_height = 1.0 window.glazing_type = glazing_type epc.sap_windows = [window] + heating: SapHeating = object.__new__(SapHeating) + detail: MainHeatingDetail = object.__new__(MainHeatingDetail) + detail.main_fuel_type = main_fuel_type + detail.main_heating_category = main_heating_category + detail.main_heating_control = main_heating_control + heating.main_heating_details = [detail] + heating.water_heating_fuel = water_heating_fuel + heating.water_heating_code = water_heating_code + heating.cylinder_insulation_type = 1 + heating.secondary_heating_type = None + epc.sap_heating = heating + epc.has_hot_water_cylinder = has_hot_water_cylinder + epc.solar_water_heating = solar_water_heating return epc @@ -348,6 +370,42 @@ def test_confidence_excludes_absent_component_values_from_the_denominator() -> N assert confidence.cohort_size == 3 +def test_heating_is_a_coherent_donor_not_the_structural_template() -> None: + # Arrange — the size-representative template (median 80 m²) runs an atypical + # system (fuel 99, no cylinder), but the cohort's modal heating signature is a + # gas system (fuel 1) with a cylinder, including a recent 2024 cert. Heating + # sub-fields can't be field-moded, so the whole SapHeating cluster must be + # copied from the coherent modal donor — the most recent among the matches — + # not inherited from the structural template. + cohort = _dated_cohort( + ( + _epc( + floor_area=80.0, + main_fuel_type=99, + main_heating_control=99, + has_hot_water_cylinder=False, + ), + date(2016, 1, 1), + ), + (_epc(main_fuel_type=1, main_heating_control=5), date(2018, 1, 1)), + (_epc(main_fuel_type=1, main_heating_control=5), date(2019, 1, 1)), + (_epc(main_fuel_type=1, main_heating_control=7), date(2024, 1, 1)), + ) + + # Act + predicted: EpcPropertyData = EpcPrediction().predict( + PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort + ) + + # Assert — heating comes coherently from the modal-signature donor (gas + + # cylinder), the most recent match (control 7 from 2024), not the template's + # fuel 99. + detail = predicted.sap_heating.main_heating_details[0] + assert detail.main_fuel_type == 1 + assert detail.main_heating_control == 7 + assert predicted.has_hot_water_cylinder is True + + def test_glazing_follows_the_recency_weighted_cohort_mode() -> None: # Arrange — an old majority single-glazed (type 1, 2015) and a recent # minority double-glazed (type 3, 2025). Glazing is retrofitted over time