Model/tests/domain/epc_prediction/test_prediction_comparison.py
Khalim Conn-Kowlessar f3ad6343a3 feat(epc-prediction): leave-one-out validation harness (ADR-0029)
Pure compare_prediction (TDD): wall-construction classification hit + signed
residuals on floor area, window count, total window area, building-parts count.
Plus validate_epc_prediction.py (IO plumbing): drops each cert from its postcode
cohort, predicts from the rest on guaranteed inputs only, aggregates the metrics,
and reports SAP three ways (pred-calc vs lodged / vs calc-on-actual / vs the
neighbour-mean baseline). Smoke run: wall 90.9%, floor-area mean|·| 42.6 m2 (a
real signal — template-copied floor area is noisy), SAP pred-calc edges baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:55:05 +00:00

101 lines
3.3 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
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
SapBuildingPart,
SapWindow,
)
from domain.epc_prediction.prediction_comparison import compare_prediction
def _epc(
*,
wall_construction: int = 1,
floor_area: float = 80.0,
building_parts: int = 1,
windows: Optional[list[tuple[float, float]]] = 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
parts.append(part)
epc.sap_building_parts = parts
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.wall_construction_correct 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.wall_construction_correct is False
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