"""Per-Property prediction comparison for the EPC Prediction validation harness (ADR-0029). `compare_prediction` scores a predicted `EpcPropertyData` against the actual one on the accuracy signals the leave-one-out harness aggregates: classification matches on the key categoricals (wall / roof / floor construction + insulation, construction age band) and residuals on the geometry (window area + count, building-parts count, floor area). Pure — the SAP residual is computed in the runner, which has the calculator and the lodged SAP. """ from __future__ import annotations from collections import Counter from dataclasses import dataclass from typing import Optional from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, MainHeatingDetail, SapBuildingPart, ) @dataclass(frozen=True) class PredictionComparison: """One Property's prediction accuracy: per-component classification hits + geometry residuals (predicted − actual). `categorical_hits` maps a component name to its hit: True / False, or `None` ("not applicable") when the actual lodges no value there, so the harness can keep it out of the classification-rate denominator rather than score a free win. Keyed by name (not flat fields) so the component set can grow without reshaping the runner — see ADR-0030 Component Accuracy.""" categorical_hits: dict[str, Optional[bool]] floor_area_residual: float building_parts_residual: int window_count_residual: int total_window_area_residual: float door_count_residual: int def _main(epc: EpcPropertyData) -> SapBuildingPart: return epc.sap_building_parts[0] def _main_floor_construction(epc: EpcPropertyData) -> Optional[int]: """The main building part's ground-floor construction code, or None when no floor dimension is lodged.""" dims = _main(epc).sap_floor_dimensions return dims[0].floor_construction if dims else None def _classify(predicted: object, actual: object) -> Optional[bool]: """A categorical hit: None ("not applicable") when the actual is absent, else whether the predicted value matches it.""" if actual is None: return None return predicted == actual # RdSAP construction age bands, oldest → newest. Adjacent bands carry near- # identical U-values, so an off-by-one is treated as a (SAP-neutral) ±1 hit. _AGE_BAND_ORDER: str = "ABCDEFGHIJKL" def _age_band_within_one(predicted: object, actual: object) -> Optional[bool]: """A ±1-band age hit: None when the actual is absent, True on an exact or adjacent-band match, else False (issue #1222 — exact match overstates the SAP impact of age-band misses).""" if actual is None: return None if predicted == actual: return True if ( isinstance(predicted, str) and isinstance(actual, str) and predicted in _AGE_BAND_ORDER and actual in _AGE_BAND_ORDER ): return ( abs(_AGE_BAND_ORDER.index(predicted) - _AGE_BAND_ORDER.index(actual)) <= 1 ) return False # RdSAP roof-insulation thickness buckets, thinnest → thickest. Uninsulated is # lodged as either 0 or "NI" (not insulated), so both map to the bottom rung; # "ND" (no data) is off the scale entirely. Adjacent buckets carry near-identical # roof U-values, so an off-by-one bucket is treated as a (SAP-neutral) ±1 hit — # the same measurement honesty as the construction age band (issue #1222). _ROOF_THICKNESS_ORDINAL: dict[object, int] = { 0: 0, "NI": 0, "12mm": 1, "25mm": 2, "50mm": 3, "75mm": 4, "100mm": 5, "125mm": 6, "150mm": 7, "175mm": 8, "200mm": 9, "225mm": 10, "250mm": 11, "270mm": 12, "300mm": 13, "350mm": 14, "400mm+": 15, } def _roof_insulation_within_one( predicted: object, actual: object ) -> Optional[bool]: """A ±1-bucket roof-insulation hit: None when the actual is absent, True on an exact or adjacent-bucket match, else False. Off the ordered scale (e.g. the "ND" no-data category) only an exact match counts.""" if actual is None: return None if predicted == actual: return True pred_rung = _ROOF_THICKNESS_ORDINAL.get(predicted) actual_rung = _ROOF_THICKNESS_ORDINAL.get(actual) if pred_rung is None or actual_rung is None: return False return abs(pred_rung - actual_rung) <= 1 def _main_heating_detail(epc: EpcPropertyData) -> Optional[MainHeatingDetail]: """The primary heating system's detail row, or None when none is lodged.""" details = epc.sap_heating.main_heating_details return details[0] if details else None def _heating_hits( predicted: EpcPropertyData, actual: EpcPropertyData ) -> dict[str, Optional[bool]]: """Classification hits for the heating components — the dominant SAP lever (ADR-0030). Main-system fields come off the primary `MainHeatingDetail`; hot-water + secondary fields off `SapHeating`.""" pred_main = _main_heating_detail(predicted) actual_main = _main_heating_detail(actual) pred_h = predicted.sap_heating actual_h = actual.sap_heating return { "heating_main_fuel": _classify( getattr(pred_main, "main_fuel_type", None), getattr(actual_main, "main_fuel_type", None), ), "heating_main_category": _classify( getattr(pred_main, "main_heating_category", None), getattr(actual_main, "main_heating_category", None), ), "heating_main_control": _classify( getattr(pred_main, "main_heating_control", None), getattr(actual_main, "main_heating_control", None), ), "water_heating_fuel": _classify( pred_h.water_heating_fuel, actual_h.water_heating_fuel ), "water_heating_code": _classify( pred_h.water_heating_code, actual_h.water_heating_code ), "has_hot_water_cylinder": _classify( predicted.has_hot_water_cylinder, actual.has_hot_water_cylinder ), "cylinder_insulation_type": _classify( pred_h.cylinder_insulation_type, actual_h.cylinder_insulation_type ), "secondary_heating_type": _classify( pred_h.secondary_heating_type, actual_h.secondary_heating_type ), } def _modal_glazing_type(epc: EpcPropertyData) -> Optional[object]: """The most common glazing type across the dwelling's windows, or None when none are lodged. A single dwelling-level glazing signal, robust to one odd window.""" types = [w.glazing_type for w in epc.sap_windows] return Counter(types).most_common(1)[0][0] if types else None def _has_pv(epc: EpcPropertyData) -> bool: """True iff the dwelling lodges any photovoltaic supply (either path).""" source = epc.sap_energy_source return source.photovoltaic_supply is not None or bool( source.photovoltaic_arrays ) def _renewables_and_fabric_hits( predicted: EpcPropertyData, actual: EpcPropertyData ) -> dict[str, Optional[bool]]: """Hits for the remaining fabric-insulation, glazing and renewables components (ADR-0030). Presence flags (room-in-roof, PV, solar) are always applicable — predicting absence when present is a real miss.""" return { "roof_insulation_thickness": _classify( _main(predicted).roof_insulation_thickness, _main(actual).roof_insulation_thickness, ), "roof_insulation_thickness_pm1": _roof_insulation_within_one( _main(predicted).roof_insulation_thickness, _main(actual).roof_insulation_thickness, ), "floor_insulation": _classify( _main_floor_insulation(predicted), _main_floor_insulation(actual) ), "has_room_in_roof": _classify( _main(predicted).sap_room_in_roof is not None, _main(actual).sap_room_in_roof is not None, ), "modal_glazing_type": _classify( _modal_glazing_type(predicted), _modal_glazing_type(actual) ), "has_pv": _classify(_has_pv(predicted), _has_pv(actual)), "solar_water_heating": _classify( predicted.solar_water_heating, actual.solar_water_heating ), } def _main_floor_insulation(epc: EpcPropertyData) -> Optional[int]: """The main building part's ground-floor insulation code, or None when no floor dimension is lodged.""" dims = _main(epc).sap_floor_dimensions return dims[0].floor_insulation if dims else None def _total_window_area(epc: EpcPropertyData) -> float: return sum(w.window_width * w.window_height for w in epc.sap_windows) def compare_prediction( predicted: EpcPropertyData, actual: EpcPropertyData ) -> PredictionComparison: """Compare a predicted picture against the actual one, field by field. All residuals are signed, predicted − actual.""" fabric_hits: dict[str, Optional[bool]] = { "wall_construction": _classify( _main(predicted).wall_construction, _main(actual).wall_construction, ), "wall_insulation_type": _classify( _main(predicted).wall_insulation_type, _main(actual).wall_insulation_type, ), "construction_age_band": _classify( _main(predicted).construction_age_band, _main(actual).construction_age_band, ), "construction_age_band_pm1": _age_band_within_one( _main(predicted).construction_age_band, _main(actual).construction_age_band, ), "roof_construction": _classify( _main(predicted).roof_construction, _main(actual).roof_construction, ), "floor_construction": _classify( _main_floor_construction(predicted), _main_floor_construction(actual), ), } return PredictionComparison( categorical_hits={ **fabric_hits, **_heating_hits(predicted, actual), **_renewables_and_fabric_hits(predicted, actual), }, floor_area_residual=( predicted.total_floor_area_m2 - actual.total_floor_area_m2 ), building_parts_residual=( len(predicted.sap_building_parts) - len(actual.sap_building_parts) ), window_count_residual=( len(predicted.sap_windows) - len(actual.sap_windows) ), total_window_area_residual=( _total_window_area(predicted) - _total_window_area(actual) ), door_count_residual=predicted.door_count - actual.door_count, )