"""Behaviour of the per-Property prediction comparison (ADR-0029): given a predicted EpcPropertyData and the actual one, report the accuracy signals the validation harness aggregates — classification matches on the key categoricals and residuals on the geometry. Pure; SAP residual is computed in the runner (it needs the calculator + lodged SAP). """ from typing import Optional, Union from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, MainHeatingDetail, PhotovoltaicSupply, SapBuildingPart, SapEnergySource, SapFloorDimension, SapHeating, SapRoomInRoof, SapWindow, ) from domain.epc_prediction.prediction_comparison import compare_prediction def _epc( *, wall_construction: int = 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, has_room_in_roof: bool = False, floor_area: float = 80.0, building_parts: int = 1, windows: Optional[list[tuple[float, float]]] = None, glazing_type: Union[int, str] = 3, door_count: int = 2, has_pv: bool = False, solar_water_heating: bool = False, main_fuel_type: Union[int, str] = 20, main_heating_category: Optional[int] = 2, main_heating_control: Union[int, str] = 2100, water_heating_fuel: Optional[int] = 20, water_heating_code: Optional[int] = 901, has_hot_water_cylinder: bool = True, cylinder_insulation_type: Optional[Union[int, str]] = 1, secondary_heating_type: Optional[Union[int, str]] = None, ) -> EpcPropertyData: epc: EpcPropertyData = object.__new__(EpcPropertyData) epc.total_floor_area_m2 = floor_area epc.door_count = door_count epc.solar_water_heating = solar_water_heating 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 part.sap_room_in_roof = ( object.__new__(SapRoomInRoof) if has_room_in_roof else None ) 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 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: SapHeating = object.__new__(SapHeating) heating.main_heating_details = [detail] heating.water_heating_fuel = water_heating_fuel heating.water_heating_code = water_heating_code heating.cylinder_insulation_type = cylinder_insulation_type heating.secondary_heating_type = secondary_heating_type epc.sap_heating = heating epc.has_hot_water_cylinder = has_hot_water_cylinder sap_windows: list[SapWindow] = [] for width, height in windows or []: w: SapWindow = object.__new__(SapWindow) w.window_width = width w.window_height = height w.glazing_type = glazing_type sap_windows.append(w) epc.sap_windows = sap_windows energy: SapEnergySource = object.__new__(SapEnergySource) energy.photovoltaic_supply = ( object.__new__(PhotovoltaicSupply) if has_pv else None ) energy.photovoltaic_arrays = None epc.sap_energy_source = energy return epc def test_scores_age_band_within_one_band() -> None: # Arrange — predicted age band K, actual J (adjacent). Adjacent RdSAP age # bands carry near-identical U-values, so an off-by-one is ~SAP-neutral: it # misses the exact hit but counts as a ±1-band hit (issue #1222). predicted = _epc(construction_age_band="K") actual = _epc(construction_age_band="J") # Act hits = compare_prediction(predicted, actual).categorical_hits # Assert assert hits["construction_age_band"] is False assert hits["construction_age_band_pm1"] is True def test_age_band_two_apart_misses_both() -> None: # Arrange — predicted K, actual H (three bands apart): a real miss on both. predicted = _epc(construction_age_band="K") actual = _epc(construction_age_band="H") # Act hits = compare_prediction(predicted, actual).categorical_hits # Assert assert hits["construction_age_band"] is False assert hits["construction_age_band_pm1"] is False def test_scores_roof_insulation_within_one_bucket() -> None: # Arrange — predicted 250mm, actual 270mm (adjacent RdSAP buckets). Adjacent # thicknesses carry near-identical roof U-values, so it misses the exact hit # but counts as a ±1-bucket hit, like the age band (issue #1222). predicted = _epc(roof_insulation_thickness="250mm") actual = _epc(roof_insulation_thickness="270mm") # Act hits = compare_prediction(predicted, actual).categorical_hits # Assert assert hits["roof_insulation_thickness"] is False assert hits["roof_insulation_thickness_pm1"] is True def test_roof_insulation_two_buckets_apart_misses_both() -> None: # Arrange — predicted 100mm, actual 200mm (three buckets apart: 100/150/200): # a real miss on both exact and ±1. predicted = _epc(roof_insulation_thickness="100mm") actual = _epc(roof_insulation_thickness="200mm") # Act hits = compare_prediction(predicted, actual).categorical_hits # Assert assert hits["roof_insulation_thickness"] is False assert hits["roof_insulation_thickness_pm1"] is False def test_roof_insulation_off_scale_no_data_only_exact_counts() -> None: # Arrange — actual is the off-scale "ND" (no-data) category; a non-equal # prediction can't be an adjacent-bucket hit. predicted = _epc(roof_insulation_thickness="200mm") actual = _epc(roof_insulation_thickness="ND") # Act hits = compare_prediction(predicted, actual).categorical_hits # Assert assert hits["roof_insulation_thickness"] is False assert hits["roof_insulation_thickness_pm1"] is False def test_flags_a_correct_main_wall_construction_classification() -> None: # Arrange — predicted and actual agree on cavity (1). predicted = _epc(wall_construction=1) actual = _epc(wall_construction=1) # Act comparison = compare_prediction(predicted, actual) # Assert assert comparison.categorical_hits["wall_construction"] is True def test_flags_an_incorrect_main_wall_construction_classification() -> None: # Arrange — predicted cavity (1), actual solid brick (2). predicted = _epc(wall_construction=1) actual = _epc(wall_construction=2) # Act comparison = compare_prediction(predicted, actual) # Assert assert comparison.categorical_hits["wall_construction"] is False def test_classifies_the_extra_homogeneous_categoricals() -> None: # Arrange — predicted agrees on age band, wall insulation, roof and floor # construction with the actual; only wall insulation differs. predicted = _epc( construction_age_band="K", wall_insulation_type=2, roof_construction=3, floor_construction=1, ) actual = _epc( construction_age_band="K", wall_insulation_type=1, roof_construction=3, floor_construction=1, ) # Act comparison = compare_prediction(predicted, actual) # Assert assert comparison.categorical_hits["construction_age_band"] is True assert comparison.categorical_hits["wall_insulation_type"] is False assert comparison.categorical_hits["roof_construction"] is True assert comparison.categorical_hits["floor_construction"] is True def test_classifies_the_heating_components() -> None: # Arrange — predicted and actual agree on everything heating except the main # fuel (predicted oil 28, actual gas 20) and secondary heating (predicted # none, actual a wood stove 693). Heating is the dominant SAP lever, so each # heating component is scored (ADR-0030 Component Accuracy). predicted = _epc( main_fuel_type=28, main_heating_category=2, main_heating_control=2100, water_heating_fuel=20, water_heating_code=901, has_hot_water_cylinder=True, cylinder_insulation_type=1, secondary_heating_type=None, ) actual = _epc( main_fuel_type=20, main_heating_category=2, main_heating_control=2100, water_heating_fuel=20, water_heating_code=901, has_hot_water_cylinder=True, cylinder_insulation_type=1, secondary_heating_type=693, ) # Act hits = compare_prediction(predicted, actual).categorical_hits # Assert assert hits["heating_main_fuel"] is False assert hits["heating_main_category"] is True assert hits["heating_main_control"] is True assert hits["water_heating_fuel"] is True assert hits["water_heating_code"] is True assert hits["has_hot_water_cylinder"] is True assert hits["cylinder_insulation_type"] is True # Secondary heating is absent in the prediction but present in the actual — # a real miss (predicted None ≠ actual 693), not "not applicable". assert hits["secondary_heating_type"] is False def test_classifies_fabric_insulation_and_room_in_roof() -> None: # Arrange — predicted and actual disagree on roof insulation thickness and on # whether there's a room-in-roof, but agree on floor insulation. predicted = _epc( roof_insulation_thickness=100, floor_insulation=1, has_room_in_roof=False, ) actual = _epc( roof_insulation_thickness=270, floor_insulation=1, has_room_in_roof=True, ) # Act hits = compare_prediction(predicted, actual).categorical_hits # Assert assert hits["roof_insulation_thickness"] is False assert hits["floor_insulation"] is True # Room-in-roof presence is always applicable — predicting "no RR" when there # is one is a real miss, not "not applicable". assert hits["has_room_in_roof"] is False def test_classifies_glazing_renewables_and_door_count() -> None: # Arrange — predicted glazing type, PV and solar disagree with the actual; # door count is over-predicted by one. predicted = _epc( windows=[(1.0, 1.0), (1.0, 1.0)], glazing_type=3, has_pv=False, solar_water_heating=False, door_count=3, ) actual = _epc( windows=[(1.0, 1.0), (1.0, 1.0)], glazing_type=4, has_pv=True, solar_water_heating=True, door_count=2, ) # Act comparison = compare_prediction(predicted, actual) hits = comparison.categorical_hits # Assert assert hits["modal_glazing_type"] is False assert hits["has_pv"] is False assert hits["solar_water_heating"] is False assert comparison.door_count_residual == 1 def test_categorical_hit_is_not_applicable_when_actual_is_absent() -> None: # Arrange — the actual lodges no roof construction (a flat under another # dwelling). A hit there is not applicable, not a free win, so it must not # count towards the roof classification rate. predicted = _epc(roof_construction=3) actual = _epc(roof_construction=None) # Act comparison = compare_prediction(predicted, actual) # Assert assert comparison.categorical_hits["roof_construction"] is None def test_reports_the_floor_area_residual_as_predicted_minus_actual() -> None: # Arrange — predicted 90 m², actual 100 m² (a 10 m² under-prediction). predicted = _epc(floor_area=90.0) actual = _epc(floor_area=100.0) # Act comparison = compare_prediction(predicted, actual) # Assert — signed residual, predicted − actual. assert abs(comparison.floor_area_residual - (-10.0)) <= 1e-9 def test_reports_the_building_parts_count_residual() -> None: # Arrange — predicted a single part; the actual has a main + an extension. predicted = _epc(building_parts=1) actual = _epc(building_parts=2) # Act comparison = compare_prediction(predicted, actual) # Assert — predicted − actual. assert comparison.building_parts_residual == -1 def test_reports_window_count_and_total_area_residuals() -> None: # Arrange — predicted 2 windows (3 m² total); actual 1 window (1 m²). predicted = _epc(windows=[(1.0, 1.0), (2.0, 1.0)]) actual = _epc(windows=[(1.0, 1.0)]) # Act comparison = compare_prediction(predicted, actual) # Assert assert comparison.window_count_residual == 1 assert abs(comparison.total_window_area_residual - 2.0) <= 1e-9