Model/domain/epc_prediction/prediction_comparison.py
Khalim Conn-Kowlessar ffaedd8d14 feat(epc-prediction): ±1-band age scoring + window_count cosmetic (#1222)
Measurement honesty so we optimise SAP-relevant accuracy, not SAP-neutral
misses (ADR-0030 Component Accuracy):
- Add construction_age_band_pm1: an exact-or-adjacent-band hit. Adjacent
  RdSAP age bands carry near-identical U-values, so an off-by-one is
  ~SAP-neutral. Full corpus: exact 78.5% but ±1-band 91.7% (fixture
  63.9% -> 83.3%) — most age misses are adjacent.
- Drop window_count from the gate's residual ceilings (cosmetic): the
  predicted picture clusters at a mapper-default 4 windows vs actuals 1-21,
  but total_window_area (the SAP-relevant signal) stays tight at ~3.4 m2.

Gate: + construction_age_band_pm1 floor 0.8333; window_count no longer gated.

Closes #1222

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:01:20 +00:00

240 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
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,
),
"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,
)