diff --git a/.idea/Model.iml b/.idea/Model.iml index 09f2e496..c6561970 100644 --- a/.idea/Model.iml +++ b/.idea/Model.iml @@ -7,7 +7,7 @@ - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index fb10c6b0..50cad4ca 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/backend/onboarders/__init__.py b/backend/onboarders/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/onboarders/epc_descriptions.py b/backend/onboarders/epc_descriptions.py index d2237880..c6fe9de9 100644 --- a/backend/onboarders/epc_descriptions.py +++ b/backend/onboarders/epc_descriptions.py @@ -1,3 +1,4 @@ +import pandas as pd import re from collections.abc import Mapping from enum import Enum @@ -211,12 +212,6 @@ class EpcEfficiency(Enum): NA = "N/A" -EfficiencyRule = Union[ - EpcEfficiency, - Callable[[EpcConstructionAgeBand], EpcEfficiency], -] - - def cavity_filled_efficiency(age_band: EpcConstructionAgeBand) -> EpcEfficiency: """" Maps cavity filled to efficiency based on construction age band. @@ -343,6 +338,16 @@ WALL_DESCRIPTION_EFFICIENCIES: Mapping[EpcWallDescriptions, WallEfficiencyRule] # Cob (special case) EpcWallDescriptions.cob_as_built_average: EpcEfficiency.AVERAGE, EpcWallDescriptions.cob_as_built_good: EpcEfficiency.GOOD, + + # Unknown mappings which are unhandled + EpcWallDescriptions.cavity_as_built_unknown: EpcEfficiency.NA, + EpcWallDescriptions.solid_brick_as_built_unknown: EpcEfficiency.NA, + EpcWallDescriptions.system_as_built_unknown: EpcEfficiency.NA, + EpcWallDescriptions.timber_frame_as_built_unknown: EpcEfficiency.NA, + EpcWallDescriptions.granite_as_built_unknown: EpcEfficiency.NA, + EpcWallDescriptions.sandstone_as_built_unknown: EpcEfficiency.NA, + EpcWallDescriptions.cob_as_built_unknown: EpcEfficiency.NA, + } @@ -676,3 +681,37 @@ ROOF_DESCRIPTION_EFFICIENCIES: Mapping[EpcRoofDescriptions, RoofEfficiencyRule] EpcRoofDescriptions.sloping_pitched_no_insulation: EpcEfficiency.VERY_POOR, } + + +def resolve_roof_efficiency( + description: EpcRoofDescriptions, + age_band: EpcConstructionAgeBand | None, + insulation_thickness: int | None, +) -> EpcEfficiency: + """ + Resolve roof efficiency from description + age band + insulation thickness. + """ + + # Unknown / holding descriptions → efficiency unknown + if description in description.unknown_descriptions: + return EpcEfficiency.NA + + rule = ROOF_DESCRIPTION_EFFICIENCIES.get(description) + + if rule is None: + return EpcEfficiency.NA + + # Fixed efficiency + if isinstance(rule, EpcEfficiency): + return rule + + # Callable rule + if age_band is None or pd.isnull(age_band): + return EpcEfficiency.NA + + try: + # Try (thickness, age_band) + return rule(insulation_thickness, age_band) + except TypeError: + # Fallback to (age_band) + return rule(age_band) diff --git a/backend/onboarders/mappings/as_built_roof_classifiers.py b/backend/onboarders/mappings/as_built_roof_classifiers.py new file mode 100644 index 00000000..7c672ce5 --- /dev/null +++ b/backend/onboarders/mappings/as_built_roof_classifiers.py @@ -0,0 +1,55 @@ +from backend.onboarders.epc_descriptions import EpcConstructionAgeBand, EpcRoofDescriptions + + +def classify_flat_roof(age_band: EpcConstructionAgeBand) -> EpcRoofDescriptions: + """ + For a flat, as built roof, these are the breakdowns: + + 2023 onwards → Flat, insulated + 2003–2022 → Flat, insulated + 1983–2002 → Flat, insulated + 1976–1982 → Flat, limited insulation + 1967–1975 → Flat, limited insulation + 1950–1966 and earlier → Flat, no insulation + :param age_band: Input age band + :return: EpcRoofDescriptions + """ + + year = age_band.start_year() + + if year >= 1983: + return EpcRoofDescriptions.flat_insulated + + if year >= 1967: + return EpcRoofDescriptions.flat_limited_insulation + + return EpcRoofDescriptions.flat_no_insulation + + +def classify_sloping_ceiling_roof(age_band: EpcConstructionAgeBand) -> EpcRoofDescriptions: + """ + For a sloping ceiling, as built roof, these are the breakdowns: + 2023 onwards → Sloping pitched, insulated + 2003–2022 → Sloping pitched, insulated + 1983–2002 → Sloping pitched, insulated + 1976–1982 → Sloping pitched, limited insulation + 1967–1975 and earlier → Sloping pitched, no insulation + :param age_band: Input age band + :return: EpcRoofDescriptions + """ + year = age_band.start_year() + + if year >= 1983: + return EpcRoofDescriptions.sloping_pitched_insulated + + if year >= 1976: + return EpcRoofDescriptions.sloping_pitched_limited_insulation + + return EpcRoofDescriptions.sloping_pitched_no_insulation + + +AS_BUILT_ROOF_CLASSIFIERS = { + # Only need to apply this to flat and sloping ceiling roofs + "Flat": classify_flat_roof, + "PitchedWithSlopingCeiling": classify_sloping_ceiling_roof, +} diff --git a/backend/onboarders/mappings/as_built_wall_classifiers.py b/backend/onboarders/mappings/as_built_wall_classifiers.py index e69de29b..f907a533 100644 --- a/backend/onboarders/mappings/as_built_wall_classifiers.py +++ b/backend/onboarders/mappings/as_built_wall_classifiers.py @@ -0,0 +1,112 @@ +from backend.onboarders.epc_descriptions import EpcConstructionAgeBand, EpcWallDescriptions + + +def map_cavity_wall_insulation(age_band: EpcConstructionAgeBand): + if age_band.start_year() < 1976: + return EpcWallDescriptions.cavity_no_insulation_assumed + + if age_band == EpcConstructionAgeBand.from_1976_to_1982: + return EpcWallDescriptions.cavity_partial_insulated_assumed + + if age_band in EpcConstructionAgeBand.from_year_onwards(1983): + return EpcWallDescriptions.cavity_insulated_assumed + + raise NotImplementedError(f"Age band {age_band} not handled for cavity wall as built insulation mapping") + + +def map_solid_wall_insulation(age_band: EpcConstructionAgeBand): + if age_band.start_year() < 1976: + return EpcWallDescriptions.solid_brick_no_insulation_assumed + + if age_band == EpcConstructionAgeBand.from_1976_to_1982: + return EpcWallDescriptions.solid_brick_partial_insulated_assumed + + if age_band in EpcConstructionAgeBand.from_year_onwards(1983): + return EpcWallDescriptions.solid_brick_insulated_assumed + + raise NotImplementedError( + f"Age band {age_band.value} not handled for solid wall insulation mapping" + ) + + +def map_timber_frame_wall_insulation(age_band: EpcConstructionAgeBand): + if age_band.start_year() < 1950: + return EpcWallDescriptions.timber_frame_no_insulation_assumed + + if age_band.start_year() < 1976: + return EpcWallDescriptions.timber_frame_partial_insulated_assumed + + if age_band in EpcConstructionAgeBand.from_year_onwards(1976): + return EpcWallDescriptions.timber_frame_insulated_assumed + + raise NotImplementedError( + f"Age band {age_band.value} not handled for timber frame wall insulation mapping" + ) + + +def map_system_build_wall_insulation(age_band: EpcConstructionAgeBand): + if age_band.start_year() < 1976: + return EpcWallDescriptions.system_no_insulation_assumed + + if age_band == EpcConstructionAgeBand.from_1976_to_1982: + return EpcWallDescriptions.system_partial_insulated_assumed + + if age_band in EpcConstructionAgeBand.from_year_onwards(1983): + return EpcWallDescriptions.system_insulated_assumed + + raise NotImplementedError( + f"Age band {age_band.value} not handled for system build wall insulation mapping" + ) + + +def map_granite_wall_insulation(age_band: EpcConstructionAgeBand): + if age_band.start_year() < 1976: + return EpcWallDescriptions.granite_whinstone_no_insulation_assumed + + if age_band == EpcConstructionAgeBand.from_1976_to_1982: + return EpcWallDescriptions.granite_whinstone_partial_insulated_assumed + + if age_band in EpcConstructionAgeBand.from_year_onwards(1983): + return EpcWallDescriptions.granite_whinestone_insulated_assumed + + raise NotImplementedError( + f"Age band {age_band.value} not handled for granite wall insulation mapping" + ) + + +def map_sandstone_wall_insulation(age_band: EpcConstructionAgeBand): + if age_band.start_year() < 1976: + return EpcWallDescriptions.sandstone_limestone_no_insulation_assumed + + if age_band == EpcConstructionAgeBand.from_1976_to_1982: + return EpcWallDescriptions.sandstone_limestone_partial_insulated_assumed + + if age_band in EpcConstructionAgeBand.from_year_onwards(1983): + return EpcWallDescriptions.sandstone_limestone_insulated_assumed + + raise NotImplementedError( + f"Age band {age_band.value} not handled for sandstone wall insulation mapping" + ) + + +def map_cob_wall_insulation(age_band: EpcConstructionAgeBand): + if age_band.start_year() < 1983: + return EpcWallDescriptions.cob_as_built_average + + if age_band in EpcConstructionAgeBand.from_year_onwards(1983): + return EpcWallDescriptions.cob_as_built_good + + raise NotImplementedError( + f"Age band {age_band.value} not handled for cob wall insulation mapping" + ) + + +AS_BUILT_WALL_CLASSIFIERS = { + "Cavity": map_cavity_wall_insulation, + "Solid Brick": map_solid_wall_insulation, + "Timber Frame": map_timber_frame_wall_insulation, + "System": map_system_build_wall_insulation, + "Granite": map_granite_wall_insulation, + "Sandstone": map_sandstone_wall_insulation, + "Cob": map_cob_wall_insulation, +} diff --git a/backend/onboarders/parity.py b/backend/onboarders/parity.py index c3b4184d..69a64a89 100644 --- a/backend/onboarders/parity.py +++ b/backend/onboarders/parity.py @@ -1,3 +1,4 @@ +import re from numpy import nan from tqdm import tqdm import pandas as pd @@ -5,8 +6,9 @@ from backend.onboarders.mappings.property_type import parity_map as property_map from backend.onboarders.mappings.age_band import parity_map as age_band_map from backend.onboarders.mappings.built_form import parity_map as built_form_map from backend.onboarders.epc_descriptions import EpcWallDescriptions, EpcConstructionAgeBand, EpcEfficiency, \ - WALL_DESCRIPTION_EFFICIENCIES -from onboarders.epc_descriptions import EpcRoofDescriptions + WALL_DESCRIPTION_EFFICIENCIES, EpcRoofDescriptions, resolve_roof_efficiency +from backend.onboarders.mappings.as_built_wall_classifiers import AS_BUILT_WALL_CLASSIFIERS +from backend.onboarders.mappings.as_built_roof_classifiers import AS_BUILT_ROOF_CLASSIFIERS tqdm.pandas() @@ -97,117 +99,6 @@ wall_mapping = { ('Cob', 'AsBuilt'): None, } - -def map_cavity_wall_insulation(age_band: EpcConstructionAgeBand): - if age_band.start_year() < 1976: - return EpcWallDescriptions.cavity_no_insulation_assumed - - if age_band == EpcConstructionAgeBand.from_1976_to_1982: - return EpcWallDescriptions.cavity_partial_insulated_assumed - - if age_band in EpcConstructionAgeBand.from_year_onwards(1983): - return EpcWallDescriptions.cavity_insulated_assumed - - raise NotImplementedError(f"Age band {age_band} not handled for cavity wall as built insulation mapping") - - -def map_solid_wall_insulation(age_band: EpcConstructionAgeBand): - if age_band.start_year() < 1976: - return EpcWallDescriptions.solid_brick_no_insulation_assumed - - if age_band == EpcConstructionAgeBand.from_1976_to_1982: - return EpcWallDescriptions.solid_brick_partial_insulated_assumed - - if age_band in EpcConstructionAgeBand.from_year_onwards(1983): - return EpcWallDescriptions.solid_brick_insulated_assumed - - raise NotImplementedError( - f"Age band {age_band.value} not handled for solid wall insulation mapping" - ) - - -def map_timber_frame_wall_insulation(age_band: EpcConstructionAgeBand): - if age_band.start_year() < 1950: - return EpcWallDescriptions.timber_frame_no_insulation_assumed - - if age_band.start_year() < 1976: - return EpcWallDescriptions.timber_frame_partial_insulated_assumed - - if age_band in EpcConstructionAgeBand.from_year_onwards(1976): - return EpcWallDescriptions.timber_frame_insulated_assumed - - raise NotImplementedError( - f"Age band {age_band.value} not handled for timber frame wall insulation mapping" - ) - - -def map_system_build_wall_insulation(age_band: EpcConstructionAgeBand): - if age_band.start_year() < 1976: - return EpcWallDescriptions.system_no_insulation_assumed - - if age_band == EpcConstructionAgeBand.from_1976_to_1982: - return EpcWallDescriptions.system_partial_insulated_assumed - - if age_band in EpcConstructionAgeBand.from_year_onwards(1983): - return EpcWallDescriptions.system_insulated_assumed - - raise NotImplementedError( - f"Age band {age_band.value} not handled for system build wall insulation mapping" - ) - - -def map_granite_wall_insulation(age_band: EpcConstructionAgeBand): - if age_band.start_year() < 1976: - return EpcWallDescriptions.granite_whinstone_no_insulation_assumed - - if age_band == EpcConstructionAgeBand.from_1976_to_1982: - return EpcWallDescriptions.granite_whinstone_partial_insulated_assumed - - if age_band in EpcConstructionAgeBand.from_year_onwards(1983): - return EpcWallDescriptions.granite_whinestone_insulated_assumed - - raise NotImplementedError( - f"Age band {age_band.value} not handled for granite wall insulation mapping" - ) - - -def map_sandstone_wall_insulation(age_band: EpcConstructionAgeBand): - if age_band.start_year() < 1976: - return EpcWallDescriptions.sandstone_limestone_no_insulation_assumed - - if age_band == EpcConstructionAgeBand.from_1976_to_1982: - return EpcWallDescriptions.sandstone_limestone_partial_insulated_assumed - - if age_band in EpcConstructionAgeBand.from_year_onwards(1983): - return EpcWallDescriptions.sandstone_limestone_insulated_assumed - - raise NotImplementedError( - f"Age band {age_band.value} not handled for sandstone wall insulation mapping" - ) - - -def map_cob_wall_insulation(age_band: EpcConstructionAgeBand): - if age_band.start_year() < 1983: - return EpcWallDescriptions.cob_as_built_average - - if age_band in EpcConstructionAgeBand.from_year_onwards(1983): - return EpcWallDescriptions.cob_as_built_good - - raise NotImplementedError( - f"Age band {age_band.value} not handled for cob wall insulation mapping" - ) - - -AS_BUILT_WALL_CLASSIFIERS = { - "Cavity": map_cavity_wall_insulation, - "Solid Brick": map_solid_wall_insulation, - "Timber Frame": map_timber_frame_wall_insulation, - "System": map_system_build_wall_insulation, - "Granite": map_granite_wall_insulation, - "Sandstone": map_sandstone_wall_insulation, - "Cob": map_cob_wall_insulation, -} - WALL_UNKNOWN_AGE_FALLBACK = { "Cavity": EpcWallDescriptions.cavity_as_built_unknown, "Solid Brick": EpcWallDescriptions.solid_brick_as_built_unknown, @@ -378,60 +269,6 @@ roof_mapping = { ('PitchedWithSlopingCeiling', 'Unknown'): None, # } - -def classify_flat_roof(age_band: EpcConstructionAgeBand) -> EpcRoofDescriptions: - """ - For a flat, as built roof, these are the breakdowns: - - 2023 onwards → Flat, insulated - 2003–2022 → Flat, insulated - 1983–2002 → Flat, insulated - 1976–1982 → Flat, limited insulation - 1967–1975 → Flat, limited insulation - 1950–1966 and earlier → Flat, no insulation - :param age_band: Input age band - :return: EpcRoofDescriptions - """ - - year = age_band.start_year() - - if year >= 1983: - return EpcRoofDescriptions.flat_insulated - - if year >= 1967: - return EpcRoofDescriptions.flat_limited_insulation - - return EpcRoofDescriptions.flat_no_insulation - - -def classify_sloping_ceiling_roof(age_band: EpcConstructionAgeBand) -> EpcRoofDescriptions: - """ - For a sloping ceiling, as built roof, these are the breakdowns: - 2023 onwards → Sloping pitched, insulated - 2003–2022 → Sloping pitched, insulated - 1983–2002 → Sloping pitched, insulated - 1976–1982 → Sloping pitched, limited insulation - 1967–1975 and earlier → Sloping pitched, no insulation - :param age_band: Input age band - :return: EpcRoofDescriptions - """ - year = age_band.start_year() - - if year >= 1983: - return EpcRoofDescriptions.sloping_pitched_insulated - - if year >= 1976: - return EpcRoofDescriptions.sloping_pitched_limited_insulation - - return EpcRoofDescriptions.sloping_pitched_no_insulation - - -AS_BUILT_ROOF_CLASSIFIERS = { - # Only need to apply this to flat and sloping ceiling roofs - "Flat": classify_flat_roof, - "PitchedWithSlopingCeiling": classify_sloping_ceiling_roof, -} - ROOF_UNKNOWN_AGE_FALLBACK = { "Flat": EpcRoofDescriptions.flat_as_built_unknown, "PitchedWithSlopingCeiling": EpcRoofDescriptions.sloping_pitched_as_built_unknown, @@ -478,13 +315,45 @@ data["landlord_roof_description"] = data.progress_apply( assert data["landlord_roof_description"].isnull().sum() == 0, ( "Some roof descriptions could not be resolved" ) -# TODO: 1) Map energy efficiency -# TODO: 2) Flag sloped ceilings + + +def extract_insulation_thickness(value: str | None) -> int | None: + """ + Extract insulation thickness in mm from a string like 'mm150'. + Returns None if not present or not parseable. + """ + if value is None or pd.isnull(value): + return None + + match = re.search(r"(\d+)", str(value)) + if not match: + return None + + return int(match.group(1)) + + +data["roof_insulation_thickness_mm"] = data["Roof Insulation"].apply( + extract_insulation_thickness +) + +data["landlord_roof_efficiency"] = data.progress_apply( + lambda row: resolve_roof_efficiency( + description=row.landlord_roof_description, + age_band=row.construction_age_band, + insulation_thickness=row.roof_insulation_thickness_mm, + ), + axis=1, +) + +assert data["landlord_roof_efficiency"].isnull().sum() == 0 + +# Flag sloping ceiling +data["has_sloping_ceiling"] = data["Roof Construction"].apply( + lambda x: x == "PitchedWithSlopingCeiling" +) # Variables we want to map -# 'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type', -# 'Attachment', 'Construction Years', -# 'Roof Construction', 'Roof Insulation', +# 'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', # 'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating', # 'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN', # 'Total Floor Area (m2)' diff --git a/backend/onboarders/tests/test_roof_remapping.py b/backend/onboarders/tests/test_roof_remapping.py index e69de29b..a08471f9 100644 --- a/backend/onboarders/tests/test_roof_remapping.py +++ b/backend/onboarders/tests/test_roof_remapping.py @@ -0,0 +1,175 @@ +import pytest + +from backend.onboarders.epc_descriptions import ( + EpcConstructionAgeBand, + EpcRoofDescriptions, + EpcEfficiency, + resolve_roof_efficiency, +) + +from backend.onboarders.mappings.as_built_roof_classifiers import ( + classify_flat_roof, + classify_sloping_ceiling_roof, +) + + +# --------------------------------------------------------------------- +# As-built roof description classification +# --------------------------------------------------------------------- + +@pytest.mark.parametrize( + "age_band, expected", + [ + (EpcConstructionAgeBand.before_1900, EpcRoofDescriptions.flat_no_insulation), + (EpcConstructionAgeBand.from_1950_to_1966, EpcRoofDescriptions.flat_no_insulation), + (EpcConstructionAgeBand.from_1967_to_1975, EpcRoofDescriptions.flat_limited_insulation), + (EpcConstructionAgeBand.from_1976_to_1982, EpcRoofDescriptions.flat_limited_insulation), + (EpcConstructionAgeBand.from_1983_to_1990, EpcRoofDescriptions.flat_insulated), + (EpcConstructionAgeBand.from_2007_to_2011, EpcRoofDescriptions.flat_insulated), + (EpcConstructionAgeBand.from_2023_onwards, EpcRoofDescriptions.flat_insulated), + ], +) +def test_classify_flat_roof(age_band, expected): + assert classify_flat_roof(age_band) == expected + + +@pytest.mark.parametrize( + "age_band, expected", + [ + (EpcConstructionAgeBand.before_1900, EpcRoofDescriptions.sloping_pitched_no_insulation), + (EpcConstructionAgeBand.from_1967_to_1975, EpcRoofDescriptions.sloping_pitched_no_insulation), + (EpcConstructionAgeBand.from_1976_to_1982, EpcRoofDescriptions.sloping_pitched_limited_insulation), + (EpcConstructionAgeBand.from_1983_to_1990, EpcRoofDescriptions.sloping_pitched_insulated), + (EpcConstructionAgeBand.from_2012_to_2022, EpcRoofDescriptions.sloping_pitched_insulated), + (EpcConstructionAgeBand.from_2023_onwards, EpcRoofDescriptions.sloping_pitched_insulated), + ], +) +def test_classify_sloping_ceiling_roof(age_band, expected): + assert classify_sloping_ceiling_roof(age_band) == expected + + +# --------------------------------------------------------------------- +# Roof efficiency — fixed & age-band driven +# --------------------------------------------------------------------- + +@pytest.mark.parametrize( + "description, age_band, expected", + [ + # Flat roof, no insulation + (EpcRoofDescriptions.flat_no_insulation, EpcConstructionAgeBand.before_1900, EpcEfficiency.VERY_POOR), + + # Flat roof, limited insulation (age-band driven) + (EpcRoofDescriptions.flat_limited_insulation, EpcConstructionAgeBand.from_1976_to_1982, EpcEfficiency.POOR), + ( + EpcRoofDescriptions.flat_limited_insulation, EpcConstructionAgeBand.from_1967_to_1975, + EpcEfficiency.VERY_POOR), + + # Flat roof, insulated (age-band driven) + (EpcRoofDescriptions.flat_insulated, EpcConstructionAgeBand.from_1983_to_1990, EpcEfficiency.AVERAGE), + (EpcRoofDescriptions.flat_insulated, EpcConstructionAgeBand.from_2003_to_2006, EpcEfficiency.GOOD), + (EpcRoofDescriptions.flat_insulated, EpcConstructionAgeBand.from_2023_onwards, EpcEfficiency.VERY_GOOD), + + # Pitched, insulated assumed (loft) + (EpcRoofDescriptions.pitched_insulated_assumed, EpcConstructionAgeBand.from_1996_to_2002, EpcEfficiency.GOOD), + (EpcRoofDescriptions.pitched_insulated_assumed, EpcConstructionAgeBand.from_2007_to_2011, + EpcEfficiency.VERY_GOOD), + ], +) +def test_roof_efficiency_age_band_only(description, age_band, expected): + assert resolve_roof_efficiency( + description=description, + age_band=age_band, + insulation_thickness=None, + ) == expected + + +# --------------------------------------------------------------------- +# Roof efficiency — insulation thickness driven +# --------------------------------------------------------------------- + +@pytest.mark.parametrize( + "description, thickness, expected", + [ + # Loft insulation + (EpcRoofDescriptions.loft_12mm_insulation, 12, EpcEfficiency.VERY_POOR), + (EpcRoofDescriptions.loft_25mm_insulation, 25, EpcEfficiency.POOR), + (EpcRoofDescriptions.loft_75mm_insulation, 75, EpcEfficiency.AVERAGE), + (EpcRoofDescriptions.loft_150mm_insulation, 150, EpcEfficiency.GOOD), + (EpcRoofDescriptions.loft_300mm_insulation, 300, EpcEfficiency.VERY_GOOD), + + # Flat insulated — thickness overrides age band + (EpcRoofDescriptions.flat_insulated, 50, EpcEfficiency.POOR), + (EpcRoofDescriptions.flat_insulated, 100, EpcEfficiency.AVERAGE), + (EpcRoofDescriptions.flat_insulated, 200, EpcEfficiency.GOOD), + (EpcRoofDescriptions.flat_insulated, 300, EpcEfficiency.VERY_GOOD), + + # Sloping ceiling + (EpcRoofDescriptions.sloping_pitched_insulated, 75, EpcEfficiency.AVERAGE), + (EpcRoofDescriptions.sloping_pitched_insulated, 150, EpcEfficiency.GOOD), + (EpcRoofDescriptions.sloping_pitched_insulated, 350, EpcEfficiency.VERY_GOOD), + ], +) +def test_roof_efficiency_thickness_based(description, thickness, expected): + assert resolve_roof_efficiency( + description=description, + age_band=EpcConstructionAgeBand.before_1900, # should be ignored + insulation_thickness=thickness, + ) == expected + + +# --------------------------------------------------------------------- +# Thatched roofs +# --------------------------------------------------------------------- + +@pytest.mark.parametrize( + "description, age_band, expected", + [ + (EpcRoofDescriptions.thatched, EpcConstructionAgeBand.before_1900, EpcEfficiency.AVERAGE), + (EpcRoofDescriptions.thatched, EpcConstructionAgeBand.from_2003_to_2006, EpcEfficiency.GOOD), + (EpcRoofDescriptions.thatched, EpcConstructionAgeBand.from_2023_onwards, EpcEfficiency.VERY_GOOD), + ], +) +def test_thatched_efficiency_age_band(description, age_band, expected): + assert resolve_roof_efficiency( + description=description, + age_band=age_band, + insulation_thickness=None, + ) == expected + + +@pytest.mark.parametrize( + "thickness, expected", + [ + (12, EpcEfficiency.AVERAGE), + (50, EpcEfficiency.GOOD), + (150, EpcEfficiency.GOOD), + (200, EpcEfficiency.VERY_GOOD), + ], +) +def test_thatched_efficiency_thickness(thickness, expected): + assert resolve_roof_efficiency( + description=EpcRoofDescriptions.thatched_with_additional_insulation, + age_band=EpcConstructionAgeBand.before_1900, + insulation_thickness=thickness, + ) == expected + + +# --------------------------------------------------------------------- +# Unknown / holding descriptions +# --------------------------------------------------------------------- + +@pytest.mark.parametrize( + "description", + [ + EpcRoofDescriptions.flat_as_built_unknown, + EpcRoofDescriptions.loft_as_built_unknown, + EpcRoofDescriptions.thatched_as_built_unknown, + EpcRoofDescriptions.sloping_pitched_as_built_unknown, + ], +) +def test_unknown_roof_descriptions_return_na(description): + assert resolve_roof_efficiency( + description=description, + age_band=None, + insulation_thickness=None, + ) == EpcEfficiency.NA diff --git a/backend/onboarders/tests/test_wall_remapping.py b/backend/onboarders/tests/test_wall_remapping.py index e69de29b..eaac5afb 100644 --- a/backend/onboarders/tests/test_wall_remapping.py +++ b/backend/onboarders/tests/test_wall_remapping.py @@ -0,0 +1,163 @@ +import pytest + +from backend.onboarders.epc_descriptions import ( + EpcConstructionAgeBand, + EpcWallDescriptions, + EpcEfficiency, + resolve_wall_efficiency, +) + +from backend.onboarders.mappings.as_built_wall_classifiers import ( + map_cavity_wall_insulation, + map_solid_wall_insulation, + map_timber_frame_wall_insulation, + map_system_build_wall_insulation, + map_granite_wall_insulation, + map_sandstone_wall_insulation, + map_cob_wall_insulation, +) + + +# --------------------------------------------------------------------- +# As-built wall description classification +# --------------------------------------------------------------------- + +@pytest.mark.parametrize( + "age_band, expected", + [ + (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.cavity_no_insulation_assumed), + (EpcConstructionAgeBand.from_1950_to_1966, EpcWallDescriptions.cavity_no_insulation_assumed), + (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.cavity_partial_insulated_assumed), + (EpcConstructionAgeBand.from_1983_to_1990, EpcWallDescriptions.cavity_insulated_assumed), + (EpcConstructionAgeBand.from_2023_onwards, EpcWallDescriptions.cavity_insulated_assumed), + ], +) +def test_map_cavity_wall_insulation(age_band, expected): + assert map_cavity_wall_insulation(age_band) == expected + + +@pytest.mark.parametrize( + "age_band, expected", + [ + (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.solid_brick_no_insulation_assumed), + (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.solid_brick_partial_insulated_assumed), + (EpcConstructionAgeBand.from_1996_to_2002, EpcWallDescriptions.solid_brick_insulated_assumed), + ], +) +def test_map_solid_wall_insulation(age_band, expected): + assert map_solid_wall_insulation(age_band) == expected + + +@pytest.mark.parametrize( + "age_band, expected", + [ + (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.timber_frame_no_insulation_assumed), + (EpcConstructionAgeBand.from_1950_to_1966, EpcWallDescriptions.timber_frame_partial_insulated_assumed), + (EpcConstructionAgeBand.from_1983_to_1990, EpcWallDescriptions.timber_frame_insulated_assumed), + ], +) +def test_map_timber_frame_wall_insulation(age_band, expected): + assert map_timber_frame_wall_insulation(age_band) == expected + + +@pytest.mark.parametrize( + "age_band, expected", + [ + (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.system_no_insulation_assumed), + (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.system_partial_insulated_assumed), + (EpcConstructionAgeBand.from_2003_to_2006, EpcWallDescriptions.system_insulated_assumed), + ], +) +def test_map_system_wall_insulation(age_band, expected): + assert map_system_build_wall_insulation(age_band) == expected + + +@pytest.mark.parametrize( + "age_band, expected", + [ + (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.granite_whinstone_no_insulation_assumed), + (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.granite_whinstone_partial_insulated_assumed), + (EpcConstructionAgeBand.from_2012_to_2022, EpcWallDescriptions.granite_whinestone_insulated_assumed), + ], +) +def test_map_granite_wall_insulation(age_band, expected): + assert map_granite_wall_insulation(age_band) == expected + + +@pytest.mark.parametrize( + "age_band, expected", + [ + (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.sandstone_limestone_no_insulation_assumed), + (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.sandstone_limestone_partial_insulated_assumed), + (EpcConstructionAgeBand.from_2007_to_2011, EpcWallDescriptions.sandstone_limestone_insulated_assumed), + ], +) +def test_map_sandstone_wall_insulation(age_band, expected): + assert map_sandstone_wall_insulation(age_band) == expected + + +@pytest.mark.parametrize( + "age_band, expected", + [ + (EpcConstructionAgeBand.before_1900, EpcWallDescriptions.cob_as_built_average), + (EpcConstructionAgeBand.from_1976_to_1982, EpcWallDescriptions.cob_as_built_average), + (EpcConstructionAgeBand.from_1983_to_1990, EpcWallDescriptions.cob_as_built_good), + ], +) +def test_map_cob_wall_insulation(age_band, expected): + assert map_cob_wall_insulation(age_band) == expected + + +# --------------------------------------------------------------------- +# Wall efficiency resolution +# --------------------------------------------------------------------- + +@pytest.mark.parametrize( + "description, age_band, expected", + [ + # Fixed efficiencies + (EpcWallDescriptions.cavity_no_insulation_assumed, None, EpcEfficiency.POOR), + (EpcWallDescriptions.cavity_partial_insulated_assumed, None, EpcEfficiency.AVERAGE), + (EpcWallDescriptions.cavity_insulated_assumed, None, EpcEfficiency.GOOD), + + # Function-based efficiencies + ( + EpcWallDescriptions.cavity_filled_cavity, + EpcConstructionAgeBand.from_2023_onwards, + EpcEfficiency.VERY_GOOD, + ), + ( + EpcWallDescriptions.cavity_filled_cavity, + EpcConstructionAgeBand.from_1991_to_1995, + EpcEfficiency.GOOD, + ), + ( + EpcWallDescriptions.solid_brick_internal_insulation, + EpcConstructionAgeBand.from_2003_to_2006, + EpcEfficiency.VERY_GOOD, + ), + ( + EpcWallDescriptions.solid_brick_internal_insulation, + EpcConstructionAgeBand.from_1950_to_1966, + EpcEfficiency.GOOD, + ), + ], +) +def test_resolve_wall_efficiency(description, age_band, expected): + assert resolve_wall_efficiency(description, age_band) == expected + + +@pytest.mark.parametrize( + "description", + [ + EpcWallDescriptions.cavity_as_built_unknown, + EpcWallDescriptions.solid_brick_as_built_unknown, + EpcWallDescriptions.system_as_built_unknown, + EpcWallDescriptions.timber_frame_as_built_unknown, + EpcWallDescriptions.granite_as_built_unknown, + EpcWallDescriptions.sandstone_as_built_unknown, + EpcWallDescriptions.cob_as_built_unknown, + ], +) +def test_unknown_wall_descriptions_return_na(description): + assert resolve_wall_efficiency(description, None) == EpcEfficiency.NA diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf index 5a67b793..b97a2f4d 100644 --- a/infrastructure/terraform/main.tf +++ b/infrastructure/terraform/main.tf @@ -86,7 +86,7 @@ resource "aws_db_instance" "default" { # Temporary to enfore immediate change apply_immediately = true # Set up storage type to gp3 for better performance - storage_type = "gp3" + storage_type = "gp3" } # Set up the bucket that recieve the csv uploads of epc to be retrofit @@ -244,7 +244,7 @@ module "lambda_heating_cost_prediction_ecr" { } module "lambda_hot_water_cost_prediction_ecr" { - ecr_name = "hot-water-cost-prediction-${var.stage}" + ecr_name = "hot-water-fcost-prediction-${var.stage}" source = "./modules/ecr" } diff --git a/pytest.ini b/pytest.ini index 1422657b..fe2c7d67 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,4 @@ [pytest] pythonpath = . addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial -testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests +testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/onboarders/tests