"""Behaviour of EPC Prediction synthesis (ADR-0029): turn the selected ComparableProperty 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 datetime import date from typing import Optional, Union from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, MainHeatingDetail, SapBuildingPart, SapFloorDimension, SapHeating, SapWindow, ) from domain.geospatial.coordinates import Coordinates from domain.epc_prediction.comparable_properties import ( ComparableProperty, ComparableProperties, ) from domain.epc_prediction.epc_prediction import ( EpcPrediction, PredictionConfidence, ) from domain.epc_prediction.prediction_target import PredictionTarget def _epc( *, building_parts: int = 1, floor_area: float = 80.0, wall_construction: Union[int, str] = 1, wall_insulation_type: Union[int, str] = 1, construction_age_band: str = "K", roof_construction: Optional[int] = 1, roof_insulation_thickness: Optional[Union[str, int]] = 100, 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" 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 part.wall_insulation_type = wall_insulation_type part.construction_age_band = construction_age_band part.roof_construction = roof_construction part.roof_insulation_thickness = roof_insulation_thickness floor_dim: SapFloorDimension = object.__new__(SapFloorDimension) floor_dim.floor_construction = floor_construction floor_dim.floor_insulation = floor_insulation part.sap_floor_dimensions = [floor_dim] parts.append(part) epc.sap_building_parts = parts window: SapWindow = object.__new__(SapWindow) window.window_width = 1.0 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 def _cohort(*epcs: EpcPropertyData) -> ComparableProperties: return ComparableProperties( members=tuple( ComparableProperty(epc=e, certificate_number=str(i)) for i, e in enumerate(epcs) ) ) def _dated_cohort( *dated: tuple[EpcPropertyData, date], ) -> ComparableProperties: return ComparableProperties( members=tuple( ComparableProperty(epc=e, certificate_number=str(i), registration_date=d) for i, (e, d) in enumerate(dated) ) ) 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_template_is_the_member_closest_to_the_cohort_median_size() -> None: # Arrange — the cohort spans a wide range of sizes; members[0] is an atypical # tiny 20 m² outlier. A single neighbour's geometry is copied wholesale, so # the template must be the size-representative member (closest to the median), # not whoever happens to come first (ADR-0029 decision 4: closest on size). cohort = _cohort( _epc(floor_area=20.0), _epc(floor_area=80.0), _epc(floor_area=200.0), ) # Act predicted: EpcPropertyData = EpcPrediction().predict( PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort ) # Assert — the 80 m² member (the median) seeds the structure, not the 20 m² # outlier sitting at members[0]. assert predicted.total_floor_area_m2 == 80.0 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_sets_the_other_homogeneous_categoricals_to_the_cohort_mode() -> None: # Arrange — the median-size template (members[0], 80 m²) is an atypical # outlier on every categorical; the cohort majority disagrees. Age band, # wall insulation, roof construction and floor construction are all # homogeneous categoricals, so each should follow its mode, not the one # template (ADR-0029 decision 4). cohort = _cohort( _epc( floor_area=80.0, construction_age_band="A", wall_insulation_type=9, roof_construction=7, floor_construction=7, ), _epc( construction_age_band="K", wall_insulation_type=1, roof_construction=2, floor_construction=3, ), _epc( construction_age_band="K", wall_insulation_type=1, roof_construction=2, floor_construction=3, ), ) # Act predicted: EpcPropertyData = EpcPrediction().predict( PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort ) # Assert — every categorical follows the cohort mode over the outlier # template. main = predicted.sap_building_parts[0] assert main.construction_age_band == "K" assert main.wall_insulation_type == 1 assert main.roof_construction == 2 assert main.sap_floor_dimensions[0].floor_construction == 3 def test_modes_roof_and_floor_insulation() -> None: # Arrange — the median-size template (members[0]) is an outlier on roof # insulation thickness and floor insulation; the cohort majority disagrees. # These are independent fabric categoricals, so each should follow its # cohort mode like the construction categoricals do. cohort = _cohort( _epc(floor_area=80.0, roof_insulation_thickness=25, floor_insulation=9), _epc(roof_insulation_thickness=300, floor_insulation=2), _epc(roof_insulation_thickness=300, floor_insulation=2), ) # Act predicted: EpcPropertyData = EpcPrediction().predict( PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort ) # Assert — each follows the cohort mode over the outlier template. main = predicted.sap_building_parts[0] assert main.roof_insulation_thickness == 300 assert main.sap_floor_dimensions[0].floor_insulation == 2 def test_recency_weights_roof_insulation_mode() -> None: # Arrange — an old majority (three 2015 certs at 100 mm) and a recent # minority (two 2025 certs at 300 mm). Roof insulation is topped up over # time, so the recent neighbours reflect the current state: the recency- # weighted mode must pick 300 over the plain-majority 100. cohort = _dated_cohort( (_epc(roof_insulation_thickness=100), date(2015, 1, 1)), (_epc(roof_insulation_thickness=100), date(2015, 1, 1)), (_epc(roof_insulation_thickness=100), date(2015, 1, 1)), (_epc(roof_insulation_thickness=300), date(2025, 1, 1)), (_epc(roof_insulation_thickness=300), date(2025, 1, 1)), ) # Act predicted: EpcPropertyData = EpcPrediction().predict( PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort ) # Assert — recency overrides the stale majority. assert predicted.sap_building_parts[0].roof_insulation_thickness == 300 def test_floor_area_is_the_cohort_median_not_the_templates_own_area() -> None: # Arrange — an even-sized cohort whose median (70) falls between members, so # the size-representative template (the first member closest to the median, # 60 m²) does not itself sit on the median. The predicted floor area is a # point estimate of the target's size, best served by the cohort median (the # MAD-minimising estimator), decoupled from whichever template seeds the # structure. cohort = _cohort( _epc(floor_area=40.0), _epc(floor_area=60.0), _epc(floor_area=80.0), _epc(floor_area=100.0), ) # Act predicted: EpcPropertyData = EpcPrediction().predict( PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort ) # Assert — the floor area is the cohort median (70), not the template's 60. assert predicted.total_floor_area_m2 == 70.0 def test_floor_area_leans_toward_the_nearest_neighbours_size() -> None: # Arrange — three FAR neighbours are 60 m²; one neighbour AT the target is # 120 m². The plain median would be 60, but homes built together share a # footprint, so the geo-proximity-weighted median leans toward the near # neighbour's size. here = Coordinates(longitude=0.0, latitude=0.0) far = Coordinates(longitude=1.0, latitude=1.0) # ~150 km away cohort = ComparableProperties( members=( ComparableProperty(_epc(floor_area=60.0), "1", coordinates=far), ComparableProperty(_epc(floor_area=60.0), "2", coordinates=far), ComparableProperty(_epc(floor_area=60.0), "3", coordinates=far), ComparableProperty(_epc(floor_area=120.0), "4", coordinates=here), ) ) target = PredictionTarget( postcode="LS6 1AA", property_type="2", coordinates=here ) # Act predicted: EpcPropertyData = EpcPrediction().predict(target, cohort) # Assert — the near neighbour's size dominates the far majority. assert predicted.total_floor_area_m2 == 120.0 def test_floor_area_median_is_unweighted_without_target_coordinates() -> None: # Arrange — identical cohort, but the target has no coordinates, so geo # weighting is off and the floor area reduces to the plain cohort median (60). here = Coordinates(longitude=0.0, latitude=0.0) far = Coordinates(longitude=1.0, latitude=1.0) cohort = ComparableProperties( members=( ComparableProperty(_epc(floor_area=60.0), "1", coordinates=far), ComparableProperty(_epc(floor_area=60.0), "2", coordinates=far), ComparableProperty(_epc(floor_area=60.0), "3", coordinates=far), ComparableProperty(_epc(floor_area=120.0), "4", coordinates=here), ) ) target = PredictionTarget(postcode="LS6 1AA", property_type="2") # Act predicted: EpcPropertyData = EpcPrediction().predict(target, cohort) # Assert — without target coordinates, the plain median (60) wins. assert predicted.total_floor_area_m2 == 60.0 def test_categorical_mode_leans_on_size_similar_neighbours() -> None: # Arrange — a count majority (three) carries wall-insulation 9, but two of # them are 400 m² size outliers; the cohort centre (median 100 m²) holds # wall-insulation 1. Physical-similarity weighting down-weights the outliers, # so the size-representative value 1 wins over the plain-count majority 9. cohort = _cohort( _epc(floor_area=100.0, wall_insulation_type=1), _epc(floor_area=100.0, wall_insulation_type=1), _epc(floor_area=100.0, wall_insulation_type=9), _epc(floor_area=400.0, wall_insulation_type=9), _epc(floor_area=400.0, wall_insulation_type=9), ) # Act predicted: EpcPropertyData = EpcPrediction().predict( PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort ) # Assert — the size-similar value wins over the outlier-driven majority. assert predicted.sap_building_parts[0].wall_insulation_type == 1 def test_categorical_mode_leans_on_age_similar_neighbours() -> None: # Arrange — same size throughout (so size weighting is neutral). A count # majority (three) carries wall-insulation 9, but two of them are age-band A # outliers while the cohort's modal band is K. Age-similarity weighting # down-weights the outliers, so the band-representative value 1 wins. cohort = _cohort( _epc(construction_age_band="K", wall_insulation_type=1), _epc(construction_age_band="K", wall_insulation_type=1), _epc(construction_age_band="K", wall_insulation_type=9), _epc(construction_age_band="A", wall_insulation_type=9), _epc(construction_age_band="A", wall_insulation_type=9), ) # Act predicted: EpcPropertyData = EpcPrediction().predict( PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort ) # Assert — the age-similar value wins over the outlier-driven majority. assert predicted.sap_building_parts[0].wall_insulation_type == 1 def test_confidence_reports_cohort_size_and_unanimous_agreement() -> None: # Arrange — a unanimous cohort: three neighbours, all cavity-walled (1). cohort = _cohort( _epc(wall_construction=1), _epc(wall_construction=1), _epc(wall_construction=1), ) # Act confidence: PredictionConfidence = EpcPrediction().confidence(cohort) # Assert — three neighbours, total agreement on the wall construction. assert confidence.cohort_size == 3 assert confidence.agreement("wall_construction") == 1.0 def test_confidence_agreement_is_the_modal_share_of_the_cohort() -> None: # Arrange — three of four neighbours are cavity (1), one is solid brick (2), # so the cohort is split on the wall construction. cohort = _cohort( _epc(wall_construction=1), _epc(wall_construction=1), _epc(wall_construction=1), _epc(wall_construction=2), ) # Act confidence: PredictionConfidence = EpcPrediction().confidence(cohort) # Assert — agreement is the modal value's share of the cohort: 3 of 4. share: Optional[float] = confidence.agreement("wall_construction") assert share is not None assert abs(share - 0.75) <= 1e-9 def test_confidence_excludes_absent_component_values_from_the_denominator() -> None: # Arrange — two neighbours lodge a roof construction (both code 2); one lodges # none. The missing value must not dilute the agreement to 2/3. cohort = _cohort( _epc(roof_construction=2), _epc(roof_construction=2), _epc(roof_construction=None), ) # Act confidence: PredictionConfidence = EpcPrediction().confidence(cohort) # Assert — agreement counts only the two present, unanimous values (1.0), # while the cohort size still reflects all three neighbours. share: Optional[float] = confidence.agreement("roof_construction") assert share is not None assert abs(share - 1.0) <= 1e-9 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 # (single → double), so the recent neighbours reflect the current state: the # recency-weighted mode must pick double over the stale single-glazed # majority, like roof insulation thickness. cohort = _dated_cohort( (_epc(glazing_type=1), date(2015, 1, 1)), (_epc(glazing_type=1), date(2015, 1, 1)), (_epc(glazing_type=1), date(2015, 1, 1)), (_epc(glazing_type=3), date(2025, 1, 1)), (_epc(glazing_type=3), date(2025, 1, 1)), ) # Act predicted: EpcPropertyData = EpcPrediction().predict( PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort ) # Assert — every predicted window takes the recent glazing over the majority. assert all(window.glazing_type == 3 for window in predicted.sap_windows) def test_geo_proximity_weights_the_nearest_neighbour() -> None: # Arrange — same size + age (so similarity weighting is uniform). Three FAR # neighbours are cavity (1); one neighbour AT the target is solid brick (2). # wall construction is a geo-weighted component, so the near neighbour # outweighs the far majority. here = Coordinates(longitude=0.0, latitude=0.0) far = Coordinates(longitude=1.0, latitude=1.0) # ~150 km away cohort = ComparableProperties( members=( ComparableProperty(_epc(wall_construction=1), "1", coordinates=far), ComparableProperty(_epc(wall_construction=1), "2", coordinates=far), ComparableProperty(_epc(wall_construction=1), "3", coordinates=far), ComparableProperty(_epc(wall_construction=2), "4", coordinates=here), ) ) target = PredictionTarget( postcode="LS6 1AA", property_type="2", coordinates=here ) # Act predicted: EpcPropertyData = EpcPrediction().predict(target, cohort) # Assert — the near neighbour's wall wins over the far majority. assert predicted.sap_building_parts[0].wall_construction == 2 def test_geo_proximity_is_off_without_target_coordinates() -> None: # Arrange — identical cohort, but the target has no coordinates, so geo # weighting is disabled and the plain cohort majority (cavity, 1) wins. here = Coordinates(longitude=0.0, latitude=0.0) far = Coordinates(longitude=1.0, latitude=1.0) cohort = ComparableProperties( members=( ComparableProperty(_epc(wall_construction=1), "1", coordinates=far), ComparableProperty(_epc(wall_construction=1), "2", coordinates=far), ComparableProperty(_epc(wall_construction=1), "3", coordinates=far), ComparableProperty(_epc(wall_construction=2), "4", coordinates=here), ) ) target = PredictionTarget(postcode="LS6 1AA", property_type="2") # Act predicted: EpcPropertyData = EpcPrediction().predict(target, cohort) # Assert — without target coordinates, the majority wins (geo off). 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