mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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>
216 lines
7.8 KiB
Python
216 lines
7.8 KiB
Python
"""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
|