Model/tests/domain/epc_prediction/test_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

322 lines
11 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.

"""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: Optional[int] = 20,
main_heating_category: Optional[int] = 2,
main_heating_control: Optional[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_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