Model/tests/domain/epc_prediction/test_prediction_comparison.py
Khalim Conn-Kowlessar 4afab2c3d8 feat(epc-prediction): roof-insulation +/-1-bucket reporting
Adds roof_insulation_thickness_pm1 (mirrors construction_age_band_pm1, issue
#1222): adjacent RdSAP thickness buckets (0/NI,12mm..400mm+) carry near-
identical roof U-values, so an off-by-one bucket is a SAP-neutral hit. 'ND'
(no-data) is off the ordered scale, so only an exact match counts there.
Honest measurement of SAP-relevant roof-insulation quality.

Corpus (150pc/514): exact 49.3% -> +/-1 53.7% (the misses are often multiple
buckets or ND, so the band gain is smaller than age's). Fixture: exact ==
+/-1 (0.4118) — its misses are all >1 bucket; gate floor added at 0.4118.

Also fixes two pre-existing pyright errors in the touched test file
(_epc main_fuel_type/main_heating_control were Optional but the
MainHeatingDetail attributes are non-optional Union[int, str]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:04:18 +00:00

365 lines
13 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: 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