diff --git a/domain/epc_prediction/prediction_comparison.py b/domain/epc_prediction/prediction_comparison.py index 76af5261..148038e4 100644 --- a/domain/epc_prediction/prediction_comparison.py +++ b/domain/epc_prediction/prediction_comparison.py @@ -11,6 +11,7 @@ runner, which has the calculator and the lodged SAP. from __future__ import annotations +from collections import Counter from dataclasses import dataclass from typing import Optional @@ -36,6 +37,7 @@ class PredictionComparison: building_parts_residual: int window_count_residual: int total_window_area_residual: float + door_count_residual: int def _main(epc: EpcPropertyData) -> SapBuildingPart: @@ -104,6 +106,57 @@ def _heating_hits( } +def _modal_glazing_type(epc: EpcPropertyData) -> Optional[object]: + """The most common glazing type across the dwelling's windows, or None when + none are lodged. A single dwelling-level glazing signal, robust to one + odd window.""" + types = [w.glazing_type for w in epc.sap_windows] + return Counter(types).most_common(1)[0][0] if types else None + + +def _has_pv(epc: EpcPropertyData) -> bool: + """True iff the dwelling lodges any photovoltaic supply (either path).""" + source = epc.sap_energy_source + return source.photovoltaic_supply is not None or bool( + source.photovoltaic_arrays + ) + + +def _renewables_and_fabric_hits( + predicted: EpcPropertyData, actual: EpcPropertyData +) -> dict[str, Optional[bool]]: + """Hits for the remaining fabric-insulation, glazing and renewables + components (ADR-0030). Presence flags (room-in-roof, PV, solar) are always + applicable — predicting absence when present is a real miss.""" + return { + "roof_insulation_thickness": _classify( + _main(predicted).roof_insulation_thickness, + _main(actual).roof_insulation_thickness, + ), + "floor_insulation": _classify( + _main_floor_insulation(predicted), _main_floor_insulation(actual) + ), + "has_room_in_roof": _classify( + _main(predicted).sap_room_in_roof is not None, + _main(actual).sap_room_in_roof is not None, + ), + "modal_glazing_type": _classify( + _modal_glazing_type(predicted), _modal_glazing_type(actual) + ), + "has_pv": _classify(_has_pv(predicted), _has_pv(actual)), + "solar_water_heating": _classify( + predicted.solar_water_heating, actual.solar_water_heating + ), + } + + +def _main_floor_insulation(epc: EpcPropertyData) -> Optional[int]: + """The main building part's ground-floor insulation code, or None when no + floor dimension is lodged.""" + dims = _main(epc).sap_floor_dimensions + return dims[0].floor_insulation if dims else None + + def _total_window_area(epc: EpcPropertyData) -> float: return sum(w.window_width * w.window_height for w in epc.sap_windows) @@ -136,7 +189,11 @@ def compare_prediction( ), } return PredictionComparison( - categorical_hits={**fabric_hits, **_heating_hits(predicted, actual)}, + categorical_hits={ + **fabric_hits, + **_heating_hits(predicted, actual), + **_renewables_and_fabric_hits(predicted, actual), + }, floor_area_residual=( predicted.total_floor_area_m2 - actual.total_floor_area_m2 ), @@ -149,4 +206,5 @@ def compare_prediction( total_window_area_residual=( _total_window_area(predicted) - _total_window_area(actual) ), + door_count_residual=predicted.door_count - actual.door_count, ) diff --git a/scripts/validate_epc_prediction.py b/scripts/validate_epc_prediction.py index fe42dc4d..1bc97c4e 100644 --- a/scripts/validate_epc_prediction.py +++ b/scripts/validate_epc_prediction.py @@ -125,6 +125,7 @@ def main() -> None: window_count_res: list[int] = [] window_area_res: list[float] = [] parts_res: list[int] = [] + door_res: list[int] = [] sap_vs_lodged: list[float] = [] sap_vs_calc_actual: list[float] = [] sap_vs_neighbour_mean: list[float] = [] @@ -163,6 +164,7 @@ def main() -> None: window_count_res.append(cmp.window_count_residual) window_area_res.append(cmp.total_window_area_residual) parts_res.append(cmp.building_parts_residual) + door_res.append(cmp.door_count_residual) sap_pred = _sap(calculator, predicted) lodged = actual.energy_rating_current @@ -190,6 +192,7 @@ def main() -> None: _residual("window_count", [float(x) for x in window_count_res]) _residual("total_window_area (m2)", window_area_res) _residual("building_parts", [float(x) for x in parts_res]) + _residual("door_count", [float(x) for x in door_res]) print() _sap_line("SAP |pred-calc − lodged|", sap_vs_lodged) _sap_line("SAP |pred-calc − calc(actual)|", sap_vs_calc_actual) diff --git a/tests/domain/epc_prediction/test_prediction_comparison.py b/tests/domain/epc_prediction/test_prediction_comparison.py index eb087a1d..e6a092e9 100644 --- a/tests/domain/epc_prediction/test_prediction_comparison.py +++ b/tests/domain/epc_prediction/test_prediction_comparison.py @@ -10,9 +10,12 @@ 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 @@ -24,10 +27,17 @@ def _epc( 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: Optional[int] = 20, main_heating_category: Optional[int] = 2, main_heating_control: Optional[Union[int, str]] = 2100, @@ -39,6 +49,8 @@ def _epc( ) -> 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) @@ -46,8 +58,13 @@ def _epc( 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 @@ -68,8 +85,15 @@ def _epc( 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 @@ -165,6 +189,60 @@ def test_classifies_the_heating_components() -> None: 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