mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
365 lines
13 KiB
Python
365 lines
13 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,
|
||
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
|