Model/tests/domain/epc_prediction/test_prediction_comparison.py
Khalim Conn-Kowlessar cd43c52cf9 feat(epc-prediction): score the heating components (ADR-0030 Component Accuracy)
Heating is the dominant SAP lever (ablating it to actual cut the SAP error
~7 -> ~4.5) yet was entirely unscored. Add the heating group to
compare_prediction's categorical_hits: main fuel / category / control (off
the primary MainHeatingDetail), water-heating fuel / code, has-cylinder,
cylinder insulation, secondary heating (off SapHeating).

Template-copied baseline on the 40-postcode corpus (no predictor change
yet — this just makes the signal visible):
  heating_main_fuel        93.4%
  heating_main_category    92.7%
  water_heating_fuel/code  91.7% / 92.4%
  heating_main_control     62.1%   <- weak
  has_hot_water_cylinder   78.5%
  cylinder_insulation_type 35.8% (n=120)   <- weak
  secondary_heating_type   16.8% (n=125)   <- weak

Fuel/category predict well from the template; controls, cylinder, and
secondary heating are poor and now drive the next predictor slices.

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

216 lines
7.8 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,
SapBuildingPart,
SapFloorDimension,
SapHeating,
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,
floor_construction: Optional[int] = 1,
floor_area: float = 80.0,
building_parts: int = 1,
windows: Optional[list[tuple[float, float]]] = None,
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
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
floor_dim: SapFloorDimension = object.__new__(SapFloorDimension)
floor_dim.floor_construction = floor_construction
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
sap_windows.append(w)
epc.sap_windows = sap_windows
return epc
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_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