diff --git a/.idea/Model.iml b/.idea/Model.iml index c6561970..09f2e496 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 50cad4ca..fb10c6b0 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/asset_list/app.py b/asset_list/app.py index 969b0184..63dc0601 100644 --- a/asset_list/app.py +++ b/asset_list/app.py @@ -59,6 +59,74 @@ def app(): Property UPRN """ + # Fairhive + data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Fairhive" + data_filename = "Fairhive Asset list.xlsx" + sheet_name = "Sheet1" + postcode_column = 'POSTCODE' + address1_column = "ADDRESS" + address1_method = None + fulladdress_column = 'ADDRESS' + address_cols_to_concat = [] + missing_postcodes_method = None + landlord_year_built = None + landlord_os_uprn = None + landlord_property_type = "PROPERTY TYPE" + landlord_built_form = None + landlord_wall_construction = None + landlord_roof_construction = None + landlord_heating_system = None + landlord_existing_pv = None + landlord_property_id = "Row ID" + landlord_sap = None + outcomes_filename = None + outcomes_sheetname = None + outcomes_postcode = None + outcomes_houseno = None + outcomes_id = None + outcomes_address = None + master_filepaths = [] + master_id_colnames = [] + master_to_asset_list_filepath = None + phase = False + ecosurv_landlords = None + asset_list_header = 0 + landlord_block_reference = None + + # Hyde + data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Hyde/Minor Works" + data_filename = "Hyde Group - Domna Minor Works Programme List.xlsx" + sheet_name = "Sheet1" + postcode_column = 'Postcode' + address1_column = None + address1_method = "house_number_extraction" + fulladdress_column = 'Address' + address_cols_to_concat = [] + missing_postcodes_method = None + landlord_year_built = "Age" + landlord_os_uprn = None + landlord_property_type = "Property Type" + landlord_built_form = "Property Type" + landlord_wall_construction = "Walls" + landlord_roof_construction = "Roofs" + landlord_heating_system = "Heating" + landlord_existing_pv = "Renewables" + landlord_property_id = "Organisation Reference" + landlord_sap = "SAP (10)" + outcomes_filename = None + outcomes_sheetname = None + outcomes_postcode = None + outcomes_houseno = None + outcomes_id = None + outcomes_address = None + master_filepaths = [] + master_id_colnames = [] + master_to_asset_list_filepath = None + phase = False + ecosurv_landlords = None + asset_list_header = 0 + landlord_block_reference = None + data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/NCHA/20260129 SAL" data_filename = "NCHA ASSET LIST 1.xlsx" sheet_name = "NCHA ASSET LIST" diff --git a/asset_list/mappings/built_form.py b/asset_list/mappings/built_form.py index a9defdef..d6466539 100644 --- a/asset_list/mappings/built_form.py +++ b/asset_list/mappings/built_form.py @@ -520,4 +520,14 @@ BUILT_FORM_MAPPINGS = { '2.EXT.WALL FLAT': 'mid-terrace', '2 EXT. WALL FLAT': 'mid-terrace', + 'Maisonette: Detached: Ground Floor': 'detached', + 'Maisonette: Enclosed End Terrace: Top Floor': 'enclosed end-terrace', + 'Flat: End Terrace: Basement': 'end-terrace', + 'Flat: Mid Terrace: Basement': 'mid-terrace', + 'Flat: Enclosed Mid Terrace: Basement': 'enclosed mid-terrace', + 'House: Semi Detached: Top Floor': 'semi-detached', + 'House: End Terrace: Ground Floor': 'end-terrace', + 'Maisonette: Enclosed End Terrace: Mid Floor': 'enclosed end-terrace', + 'Bungalow: EnclosedEndTerrace': 'enclosed end-terrace' + } diff --git a/asset_list/mappings/exising_pv.py b/asset_list/mappings/exising_pv.py index e67fafb4..defce35f 100644 --- a/asset_list/mappings/exising_pv.py +++ b/asset_list/mappings/exising_pv.py @@ -17,5 +17,10 @@ EXISTING_PV_MAPPINGS = { 'PV: 10% roof area, PV: 2kWp array': 'already has PV', 'PV: 50% roof area': 'already has PV', 'Solar PV': 'already has PV', - 'SOLAR PV': 'already has PV' + 'SOLAR PV': 'already has PV', + + 'PV: 40% roof area, PV: 2kWp array': 'already has PV', + 'PV: 33% roof area, PV: 2kWp array': 'already has PV', + 'PV: 30% roof area': 'already has PV' + } diff --git a/asset_list/mappings/heating_systems.py b/asset_list/mappings/heating_systems.py index ffd1b198..272d6279 100644 --- a/asset_list/mappings/heating_systems.py +++ b/asset_list/mappings/heating_systems.py @@ -494,6 +494,10 @@ HEATING_MAPPINGS = { 'Gas (including LPG) room heaters: Gas fire, open flue, 1980 or later (open fronted), sitting proud of, ' 'and sealed to, fireplace opening': 'room heaters', 'Boiler: A rated Regular Boiler, System 2: Boiler: C rated Regular Boiler': 'boiler - other fuel', - 'Boiler: G rated Combi': 'gas condensing combi' + 'Boiler: G rated Combi': 'gas condensing combi', + + 'Boiler: A rated Combi, System 2: Boiler: A rated Combi': 'gas combi boiler', + 'System 2: Boiler: A rated Regular Boiler, Boiler: A rated Regular Boiler': 'gas boiler, radiators', + 'Boiler: A rated Combi, System 2: Boiler: C rated Combi': 'gas combi boiler' } diff --git a/asset_list/mappings/property_type.py b/asset_list/mappings/property_type.py index 703cb8ef..6f808c9a 100644 --- a/asset_list/mappings/property_type.py +++ b/asset_list/mappings/property_type.py @@ -429,6 +429,16 @@ PROPERTY_MAPPING = { 'Mid-terrace': 'unknown', 'MID - TERRACE': 'unknown', 'COMOFF': 'unknown', - 'LOTS': 'unknown' + 'LOTS': 'unknown', + + 'Maisonette: Detached: Ground Floor': 'maisonette', + 'Maisonette: Enclosed End Terrace: Top Floor': 'maisonette', + 'Flat: End Terrace: Basement': 'flat', + 'Bungalow: EnclosedEndTerrace': 'bungalow', + 'Flat: Mid Terrace: Basement': 'flat', + 'House: Semi Detached: Top Floor': 'house', + 'House: End Terrace: Ground Floor': 'house', + 'Maisonette: Enclosed End Terrace: Mid Floor': 'maisonette', + 'Flat: Enclosed Mid Terrace: Basement': 'flat' } diff --git a/asset_list/mappings/roof.py b/asset_list/mappings/roof.py index 0857b046..cf829a5f 100644 --- a/asset_list/mappings/roof.py +++ b/asset_list/mappings/roof.py @@ -301,4 +301,13 @@ ROOF_CONSTRUCTION_MAPPINGS = { 'PitchedWithSlopingCeiling: As Built': 'pitched insulated', 'PitchedNormalLoftAccess: As Built': 'pitched unknown insulation', + 'Flat: 150mm, Flat: Unknown': 'flat insulated', + 'AnotherDwellingAbove: Unknown, Flat: Unknown': 'another dwelling above', + 'AnotherDwellingAbove, AnotherDwellingAbove: Unknown': 'another dwelling above', + 'PitchedNormalNoLoftAccess: Unknown, PitchedWithSlopingCeiling: As Built': 'pitched unknown access to loft', + 'Flat: No Insulation': 'flat uninsulated', + 'AnotherDwellingAbove: Unknown, PitchedNormalLoftAccess: 250mm': 'another dwelling above', + 'PitchedNormalLoftAccess: 175mm': 'pitched insulated', + 'AnotherDwellingAbove: 300mm': 'another dwelling above' + } diff --git a/backend/onboarders/epc_descriptions.py b/backend/onboarders/epc_descriptions.py index be704308..37f70df8 100644 --- a/backend/onboarders/epc_descriptions.py +++ b/backend/onboarders/epc_descriptions.py @@ -1,4 +1,5 @@ import re +from collections.abc import Mapping from enum import Enum from typing import Callable, Union, List @@ -105,6 +106,71 @@ class EpcWallDescriptions(Enum): cob_as_built_unknown = "Cob, as built, unknown insulation" +class EpcRoofDescriptions(Enum): + # Loft + # Known insulation at joists - we have 12, 25, 50, 75, 100, 125, 150, 175, 200, 225, 250, 270, 300, 350, + # 400+ as options + loft_12mm_insulation: str = "Pitched, 12 mm loft insulation" + loft_25mm_insulation: str = "Pitched, 25 mm loft insulation" + loft_50mm_insulation: str = "Pitched, 50 mm loft insulation" + loft_75mm_insulation: str = "Pitched, 75 mm loft insulation" + loft_100mm_insulation: str = "Pitched, 100 mm loft insulation" + loft_125mm_insulation: str = "Pitched, 125 mm loft insulation" + loft_150mm_insulation: str = "Pitched, 150 mm loft insulation" + loft_175mm_insulation: str = "Pitched, 175 mm loft insulation" + loft_200mm_insulation: str = "Pitched, 200 mm loft insulation" + loft_250mm_insulation: str = "Pitched, 250 mm loft insulation" + loft_270mm_insulation: str = "Pitched, 270 mm loft insulation" + loft_300mm_insulation: str = "Pitched, 300 mm loft insulation" + loft_350mm_insulation: str = "Pitched, 350 mm loft insulation" + loft_400mm_plus_insulation: str = "Pitched, 400+ mm loft insulation" + # Insulated at rafters "Pitched, insulated at rafters" + # Rafters + # 400mm, 350mm = very good + # 200-300mm = good + # 125-175 = average + # 50-100 = poor + # 25 and below= very poor + loft_insulated_at_rafters: str = "Pitched, insulated at rafters" + # another dwelling above + another_dwelling_above: str = "(another dwelling above)" + # flat roof, which if there is observed insulation is just "flat, insulated", however there is a + # different efficiency rating depending on insulation thickness + # categories: + # 12mm = very poor & has limited insulation description + # 25, 50 = poor & has limited insulation description + # 75, 100, 125mm = average (Flat, insulated) + # 150, 175, 200, 225, 250mm = good (Flat, insulated) + # 270mm+ = very good (Flat, insulated) + # As built 2023 = Flat, insulated, Very good + # 2003 - 2006, up to 2012-2022 = Flat insulated, Good + # 1983-1990, 1996-2002 = Flat, insulated, Average + # 1976-1982 = Flat, limited insulation, poor + # 1967 - 1975 = Flat, limited insulation, Very Poor + # 1950-1966 and earlier bands = flat, no insulation, very poor + + flat_insulated = "Flat, insulated" + flat_limited_insulation = "Flat, limited insulation" + flat_no_insulation = "Flat, no insulation" + + # Thatched roof descriptions + # With Loft insulation at joists + # Thatched + 12mm = thatched, with additional insulation, average + # Thatched + 25, 50, 100, 150mm = thatched, with additional insulation, good + # Thatched + 175mm+ = thatched, with additional insulation, very good + # With loft insulation at rafters [out of scope atm] + # Unknown insulation + # Pre 1900, 1930-1949, 1967-1975, 1983-1990, 1996-2002 = "Thatched", Average + # 2003-2006, 2012-2022 = "Thatched", Good + # 2023 onwards = "Thatched", Very Good + thatched = "Thatched" # We see this for no insulation, has average performance + thatched_with_additional_insulation: str = "Thatched, with additional insulation" + + # TODO: + # Sloping ceiling + # Pitched, as built + + class EpcEfficiency(Enum): VERY_POOR = "Very Poor" POOR = "Poor" @@ -181,7 +247,12 @@ def timber_granite_sandstone_internal_external_efficiency(age_band: EpcConstruct return EpcEfficiency.GOOD -WALL_DESCRIPTION_METADATA = { +WallEfficiencyRule = Union[ + EpcEfficiency, + Callable[[EpcConstructionAgeBand, int | None], EpcEfficiency], +] + +WALL_DESCRIPTION_EFFICIENCIES: Mapping[EpcWallDescriptions, WallEfficiencyRule] = { # Note: all function mappings have been defined based on Elmhurst # Cavity # value mappings @@ -248,9 +319,75 @@ def resolve_wall_efficiency( description: EpcWallDescriptions, age_band: EpcConstructionAgeBand, ) -> EpcEfficiency: - rule = WALL_DESCRIPTION_METADATA[description] + rule = WALL_DESCRIPTION_EFFICIENCIES[description] if isinstance(rule, EpcEfficiency): return rule return rule(age_band) + + +RoofEfficiencyRule = Union[ + EpcEfficiency, + Callable[[EpcConstructionAgeBand, int | None], EpcEfficiency], +] + + +def flat_limited_insulation_efficiency(age_band: EpcConstructionAgeBand) -> EpcEfficiency: + pass + + +def flat_insulated_efficiency(age_band: EpcConstructionAgeBand) -> EpcEfficiency: + pass + + +def flat_limited_efficiency( + age_band: EpcConstructionAgeBand, + insulation_thickness: int | None, +) -> EpcEfficiency: + """ + If we have an insulation thickness, 12mm results in a very poor rating. 25mm or above results in a poor rating. + If we don't have an insulation thickness, we fall back to age band, where + - 1976 - 1982 = Flat, limited insulation, poor efficiency + - 1967 - 1975 = Flat, limited insulation, Very Poor + :param age_band: + :param insulation_thickness: + :return: + """ + + if insulation_thickness is not None: + if insulation_thickness >= 25: + return EpcEfficiency.POOR + return EpcEfficiency.VERY_POOR + + if age_band == EpcConstructionAgeBand.from_1976_to_1982: + return EpcEfficiency.POOR + + if age_band == EpcConstructionAgeBand.from_1967_to_1975: + return EpcEfficiency.VERY_POOR + + raise ValueError("Cannot determine flat limited insulation efficiency without insulation thickness or age band") + + +ROOF_DESCRIPTION_EFFICIENCIES: Mapping[EpcRoofDescriptions, RoofEfficiencyRule] = { + # Flat roof + EpcRoofDescriptions.flat_no_insulation: EpcEfficiency.VERY_POOR, + EpcRoofDescriptions.flat_limited_insulation: flat_limited_insulation_efficiency, + EpcRoofDescriptions.flat_insulated: flat_insulated_efficiency, + + # Loft: + EpcRoofDescriptions.loft_12mm_insulation: EpcEfficiency.VERY_POOR, + EpcRoofDescriptions.loft_25mm_insulation: EpcEfficiency.POOR, + EpcRoofDescriptions.loft_50mm_insulation: EpcEfficiency.POOR, + EpcRoofDescriptions.loft_75mm_insulation: EpcEfficiency.AVERAGE, + EpcRoofDescriptions.loft_100mm_insulation: EpcEfficiency.AVERAGE, + EpcRoofDescriptions.loft_125mm_insulation: EpcEfficiency.AVERAGE, + EpcRoofDescriptions.loft_150mm_insulation: EpcEfficiency.GOOD, + EpcRoofDescriptions.loft_175mm_insulation: EpcEfficiency.GOOD, + EpcRoofDescriptions.loft_200mm_insulation: EpcEfficiency.GOOD, + EpcRoofDescriptions.loft_250mm_insulation: EpcEfficiency.GOOD, + EpcRoofDescriptions.loft_270mm_insulation: EpcEfficiency.VERY_GOOD, + EpcRoofDescriptions.loft_300mm_insulation: EpcEfficiency.VERY_GOOD, + EpcRoofDescriptions.loft_350mm_insulation: EpcEfficiency.VERY_GOOD, + EpcRoofDescriptions.loft_400mm_plus_insulation: EpcEfficiency.VERY_GOOD, +} diff --git a/backend/onboarders/mappings/as_built_wall_classifiers.py b/backend/onboarders/mappings/as_built_wall_classifiers.py index e0ef193f..e69de29b 100644 --- a/backend/onboarders/mappings/as_built_wall_classifiers.py +++ b/backend/onboarders/mappings/as_built_wall_classifiers.py @@ -1,204 +0,0 @@ -def map_cavity_wall_insulation(age_band): - if age_band in [ - 'England and Wales: before 1900', - 'England and Wales: 1900-1929', - 'England and Wales: 1930-1949', - 'England and Wales: 1950-1966', - 'England and Wales: 1967-1975' - ]: - return EpcWallDescriptions.cavity_no_insulation_assumed - - if age_band in [ - 'England and Wales: 1976-1982' - ]: - return EpcWallDescriptions.cavity_partial_insulated_assumed - - if age_band in [ - 'England and Wales: 1983-1990', - 'England and Wales: 1991-1995', - 'England and Wales: 1996-2002', - 'England and Wales: 2003-2006', - 'England and Wales: 2007-2011', - 'England and Wales: 2012-2022', - 'England and Wales: 2023 onwards', - ]: - 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): - if age_band in [ - 'England and Wales: before 1900', 'England and Wales: 1900-1929', 'England and Wales: 1930-1949', - 'England and Wales: 1967-1975' - ]: - return EpcWallDescriptions.solid_brick_no_insulation_assumed - - if age_band in [ - 'England and Wales: 1976-1982' - ]: - return EpcWallDescriptions.solid_brick_partial_insulated_assumed - - if age_band in [ - 'England and Wales: 1983-1990', 'England and Wales: 1991-1995', 'England and Wales: 1996-2002', - 'England and Wales: 2003-2006', 'England and Wales: 2007-2011', 'England and Wales: 2012-2022', - 'England and Wales: 2023 onwards', - ]: - return EpcWallDescriptions.solid_brick_insulated_assumed - - -def map_timber_frame_wall_insulation(age_band): - # No insulation (Poor) - if age_band in [ - 'England and Wales: before 1900', - 'England and Wales: 1900-1929', - 'England and Wales: 1930-1949', - ]: - return EpcWallDescriptions.timber_frame_no_insulation_assumed - - # Partial insulation (Average) - if age_band in [ - 'England and Wales: 1950-1966', - 'England and Wales: 1967-1975', - ]: - return EpcWallDescriptions.timber_frame_partial_insulated_assumed - - # Insulated (Good) - if age_band in [ - 'England and Wales: 1976-1982', - 'England and Wales: 1983-1990', - 'England and Wales: 1991-1995', - 'England and Wales: 1996-2002', - 'England and Wales: 2003-2006', - 'England and Wales: 2007-2011', - 'England and Wales: 2012-2022', - 'England and Wales: 2023 onwards', - ]: - return EpcWallDescriptions.timber_frame_insulated_assumed - - # TODO: Unknown / pre-1930 handling - raise NotImplementedError(f"Age band {age_band} not handled for timber frame wall insulation mapping") - - -def map_system_build_wall_insulation(age_band): - # No insulation (Poor) - if age_band in [ - 'England and Wales: before 1900', - 'England and Wales: 1900-1929', - 'England and Wales: 1930-1949', - 'England and Wales: 1950-1966', - 'England and Wales: 1967-1975', - ]: - return EpcWallDescriptions.system_no_insulation_assumed - - # Partial insulation (Average) - if age_band in [ - 'England and Wales: 1976-1982', - ]: - return EpcWallDescriptions.system_partial_insulated_assumed - - # Insulated (Good) - if age_band in [ - 'England and Wales: 1983-1990', - 'England and Wales: 1991-1995', - 'England and Wales: 1996-2002', - 'England and Wales: 2003-2006', - 'England and Wales: 2007-2011', - 'England and Wales: 2012-2022', - 'England and Wales: 2023 onwards', - ]: - return EpcWallDescriptions.system_insulated_assumed - - # TODO: Unknown / early system build handling - raise NotImplementedError(f"Age band {age_band} not handled for system build wall insulation mapping") - - -def map_granite_wall_insulation(age_band): - # No insulation (Very Poor) - if age_band in [ - 'England and Wales: before 1900', - 'England and Wales: 1900-1929', - 'England and Wales: 1930-1949', - 'England and Wales: 1950-1966', - 'England and Wales: 1967-1975', - ]: - return EpcWallDescriptions.granite_whinstone_no_insulation_assumed - - # Partial insulation (Average) - if age_band in [ - 'England and Wales: 1976-1982', - ]: - return EpcWallDescriptions.granite_whinstone_partial_insulated_assumed - - # Insulated (Good) - if age_band in [ - 'England and Wales: 1983-1990', - 'England and Wales: 1991-1995', - 'England and Wales: 1996-2002', - 'England and Wales: 2003-2006', - 'England and Wales: 2007-2011', - 'England and Wales: 2012-2022', - 'England and Wales: 2023 onwards', - ]: - return EpcWallDescriptions.granite_whinestone_insulated_assumed - - raise NotImplementedError(f"Age band {age_band} not handled for granite wall insulation mapping") - - -def map_sandstone_wall_insulation(age_band): - # No insulation (Very Poor) - if age_band in [ - 'England and Wales: before 1900', - 'England and Wales: 1900-1929', - 'England and Wales: 1930-1949', - 'England and Wales: 1950-1966', - 'England and Wales: 1967-1975', - ]: - return EpcWallDescriptions.sandstone_limestone_no_insulation_assumed - - # Partial insulation (Average) - if age_band in [ - 'England and Wales: 1976-1982', - ]: - return EpcWallDescriptions.sandstone_limestone_partial_insulated_assumed - - # Insulated (Good) - if age_band in [ - 'England and Wales: 1983-1990', - 'England and Wales: 1991-1995', - 'England and Wales: 1996-2002', - 'England and Wales: 2003-2006', - 'England and Wales: 2007-2011', - 'England and Wales: 2012-2022', - 'England and Wales: 2023 onwards', - ]: - return EpcWallDescriptions.sandstone_limestone_insulated_assumed - - raise NotImplementedError(f"Age band {age_band} not handled for sandstone wall insulation mapping") - - -def map_cob_wall_insulation(age_band): - # Cob, as built (Average) - if age_band in [ - 'England and Wales: before 1900', - 'England and Wales: 1900-1929', - 'England and Wales: 1930-1949', - 'England and Wales: 1950-1966', - 'England and Wales: 1967-1975', - 'England and Wales: 1976-1982', - ]: - return EpcWallDescriptions.cob_as_built_average - - # Cob, as built (Good) - if age_band in [ - 'England and Wales: 1983-1990', - 'England and Wales: 1991-1995', - 'England and Wales: 1996-2002', - 'England and Wales: 2003-2006', - 'England and Wales: 2007-2011', - 'England and Wales: 2012-2022', - 'England and Wales: 2023 onwards', - ]: - return EpcWallDescriptions.cob_as_built_good - - raise NotImplementedError(f"Age band {age_band} not handled for cob wall insulation mapping") diff --git a/backend/onboarders/parity.py b/backend/onboarders/parity.py index 3e17ecce..a77e76a8 100644 --- a/backend/onboarders/parity.py +++ b/backend/onboarders/parity.py @@ -3,7 +3,9 @@ import pandas as pd 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 +from backend.onboarders.epc_descriptions import EpcWallDescriptions, EpcConstructionAgeBand, EpcEfficiency, \ + WALL_DESCRIPTION_EFFICIENCIES +from onboarders.epc_descriptions import EpcRoofDescriptions tqdm.pandas() @@ -49,7 +51,6 @@ assert pd.isnull(data["built_form"]).sum() == 0, "Some built forms were not mapp # ------------ Wall Construction ------------ - # Unique combindations wall_mapping = { # Cavity walls @@ -241,16 +242,220 @@ def fill_as_built(row): return classifier(row.construction_age_band) +def resolve_wall_efficiency( + description: EpcWallDescriptions, + age_band: EpcConstructionAgeBand | None, +) -> EpcEfficiency: + # Unknown / holding descriptions → efficiency unknown + if "unknown insulation" in description.value.lower(): + return EpcEfficiency.NA + + rule = WALL_DESCRIPTION_EFFICIENCIES.get(description) + + if rule is None: + return EpcEfficiency.NA + + if isinstance(rule, EpcEfficiency): + return rule + + # Rule needs age band but we don't have one + if age_band is None or pd.isnull(age_band): + return EpcEfficiency.NA + + return rule(age_band) + + data["landlord_wall_description"] = data.progress_apply(fill_as_built, axis=1) assert data["landlord_wall_description"].isnull().sum() == 0, ( "Some wall descriptions could not be resolved" ) +data["landlord_wall_efficiency"] = data.progress_apply( + lambda row: resolve_wall_efficiency( + row.landlord_wall_description, + row.construction_age_band, + ), + axis=1, +) +# Sanity check +assert data["landlord_wall_efficiency"].isnull().sum() == 0 + +# ------------ Roof Construction ------------ + +roof_aggs = data[["Roof Construction", "Roof Insulation"]].drop_duplicates().to_dict("records") + +[ + # Dwelling above + + # Pitched, loft + + {'Roof Construction': 'PitchedNormalLoftAccess', 'Roof Insulation': nan}, + {'Roof Construction': 'PitchedNormalLoftAccess', 'Roof Insulation': 'AsBuilt'}, + {'Roof Construction': 'PitchedNormalLoftAccess', 'Roof Insulation': 'Unknown'}, + + # Flat + {'Roof Construction': 'Flat', 'Roof Insulation': 'AsBuilt'}, + {'Roof Construction': 'Flat', 'Roof Insulation': 'mm100'}, + {'Roof Construction': 'Flat', 'Roof Insulation': 'mm150'}, + {'Roof Construction': 'Flat', 'Roof Insulation': nan}, + + {'Roof Construction': 'Flat', 'Roof Insulation': 'Unknown'}, + + # Thatched + {'Roof Construction': 'PitchedThatched', 'Roof Insulation': 'mm150'}, + {'Roof Construction': 'PitchedThatched', 'Roof Insulation': 'Unknown'}, + {'Roof Construction': 'PitchedThatched', 'Roof Insulation': 'mm50'}, + {'Roof Construction': 'PitchedThatched', 'Roof Insulation': 'mm300'}, + + # Sloping + {'Roof Construction': 'PitchedWithSlopingCeiling', 'Roof Insulation': 'AsBuilt'}, + {'Roof Construction': 'PitchedWithSlopingCeiling', 'Roof Insulation': 'mm150'}, + {'Roof Construction': 'PitchedWithSlopingCeiling', 'Roof Insulation': 'mm100'}, + {'Roof Construction': 'PitchedWithSlopingCeiling', 'Roof Insulation': nan}, + {'Roof Construction': 'PitchedWithSlopingCeiling', 'Roof Insulation': 'mm50'}, + {'Roof Construction': 'PitchedWithSlopingCeiling', 'Roof Insulation': 'NoInsulation'}, + {'Roof Construction': 'PitchedWithSlopingCeiling', 'Roof Insulation': 'Unknown'}, + + # Pitched no loft access + {'Roof Construction': 'PitchedNormalNoLoftAccess', 'Roof Insulation': nan}, + {'Roof Construction': 'PitchedNormalNoLoftAccess', 'Roof Insulation': 'Unknown'}, + {'Roof Construction': 'PitchedNormalNoLoftAccess', 'Roof Insulation': 'AsBuilt'} +] + +roof_mapping = { + # Dwelling above + ('AnotherDwellingAbove', 'Another Dwelling Above'): EpcRoofDescriptions.another_dwelling_above, + ('SameDwellingAbove', 'Same Dwelling Above'): EpcRoofDescriptions.another_dwelling_above, + # Pitched, normal loft access, with a loft thickness + ('PitchedNormalLoftAccess', 'mm25'): EpcRoofDescriptions.loft_25mm_insulation, + ('PitchedNormalLoftAccess', 'mm50'): EpcRoofDescriptions.loft_50mm_insulation, + ('PitchedNormalLoftAccess', 'mm75'): EpcRoofDescriptions.loft_75mm_insulation, + ('PitchedNormalLoftAccess', 'mm100'): EpcRoofDescriptions.loft_100mm_insulation, + ('PitchedNormalLoftAccess', 'mm150'): EpcRoofDescriptions.loft_150mm_insulation, + ('PitchedNormalLoftAccess', 'mm200'): EpcRoofDescriptions.loft_200mm_insulation, + ('PitchedNormalLoftAccess', 'mm250'): EpcRoofDescriptions.loft_250mm_insulation, + ('PitchedNormalLoftAccess', 'mm270'): EpcRoofDescriptions.loft_270mm_insulation, + ('PitchedNormalLoftAccess', 'mm300'): EpcRoofDescriptions.loft_300mm_insulation, + ('PitchedNormalLoftAccess', 'mm350'): EpcRoofDescriptions.loft_350mm_insulation, + ('PitchedNormalLoftAccess', 'mm400'): EpcRoofDescriptions.loft_400mm_plus_insulation, + + # Pitched, no loft access, with a loft thickness + ('PitchedNormalNoLoftAccess', 'mm25'): EpcRoofDescriptions.loft_25mm_insulation, + ('PitchedNormalNoLoftAccess', 'mm50'): EpcRoofDescriptions.loft_50mm_insulation, + ('PitchedNormalNoLoftAccess', 'mm75'): EpcRoofDescriptions.loft_75mm_insulation, + ('PitchedNormalNoLoftAccess', 'mm100'): EpcRoofDescriptions.loft_100mm_insulation, + ('PitchedNormalNoLoftAccess', 'mm150'): EpcRoofDescriptions.loft_150mm_insulation, + ('PitchedNormalNoLoftAccess', 'mm200'): EpcRoofDescriptions.loft_200mm_insulation, + ('PitchedNormalNoLoftAccess', 'mm250'): EpcRoofDescriptions.loft_250mm_insulation, + ('PitchedNormalNoLoftAccess', 'mm270'): EpcRoofDescriptions.loft_270mm_insulation, + ('PitchedNormalNoLoftAccess', 'mm300'): EpcRoofDescriptions.loft_300mm_insulation, + ('PitchedNormalNoLoftAccess', 'mm350'): EpcRoofDescriptions.loft_350mm_insulation, + ('PitchedNormalNoLoftAccess', 'mm400'): EpcRoofDescriptions.loft_400mm_plus_insulation, + + # Flat + ('Flat', 'NoInsulation'): EpcRoofDescriptions.flat_no_insulation, + # Flat - limited insulation + ('Flat', '12mm'): EpcRoofDescriptions.flat_limited_insulation, + ('Flat', 'mm25'): EpcRoofDescriptions.flat_limited_insulation, + ('Flat', 'mm50'): EpcRoofDescriptions.flat_limited_insulation, + # Flat insulated + ('Flat', 'mm75'): EpcRoofDescriptions.flat_insulated, + ('Flat', 'mm100'): EpcRoofDescriptions.flat_insulated, + ('Flat', 'mm150'): EpcRoofDescriptions.flat_insulated, + ('Flat', 'mm200'): EpcRoofDescriptions.flat_insulated, + ('Flat', 'mm250'): EpcRoofDescriptions.flat_insulated, + ('Flat', 'mm300'): EpcRoofDescriptions.flat_insulated, + ('Flat', 'mm350'): EpcRoofDescriptions.flat_insulated, + ('Flat', 'mm400'): EpcRoofDescriptions.flat_insulated, + + # 12mm = very poor & has limited insulation description + # 25, 50 = poor & has limited insulation description + # 75, 100, 125mm = average (Flat, insulated) + # 150, 175, 200, 225, 250mm = good (Flat, insulated) + # 270mm+ = very good (Flat, insulated) + + # {'Roof Construction': 'Flat', 'Roof Insulation': 'mm50'}, + +} + + +def classify_flat_roof(age_band: EpcConstructionAgeBand): + # # flat roof, which if there is observed insulation is just "flat, insulated", however there is a + # # different efficiency rating depending on insulation thickness + # # categories: + # # 12mm = very poor & has limited insulation description + # # 25, 50 = poor & has limited insulation description + # # 75, 100, 125mm = average (Flat, insulated) + # # 150, 175, 200, 225, 250mm = good (Flat, insulated) + # # 270mm+ = very good (Flat, insulated) + # # As built 2023 = Flat, insulated, Very good + # # 2003 - 2006, up to 2012-2022 = Flat insulated, Good + # # 1983-1990, 1996-2002 = Flat, insulated, Average + # # 1976-1982 = Flat, limited insulation, poor + # # 1967 - 1975 = Flat, limited insulation, Very Poor + # # 1950-1966 and earlier bands = flat, no insulation, very poor + raise NotImplementedError("Flat roof classification not implemented yet") + + +def classify_pitched_loft_unknown(age_band: EpcConstructionAgeBand): + raise NotImplementedError("Pitched loft (unknown insulation) not implemented yet") + + +def classify_thatched_roof(age_band: EpcConstructionAgeBand): + raise NotImplementedError("Thatched roof classification not implemented yet") + + +def classify_sloping_ceiling_roof(age_band: EpcConstructionAgeBand): + raise NotImplementedError("Sloping ceiling roof classification not implemented yet") + + +AS_BUILT_ROOF_CLASSIFIERS = { + "Flat": classify_flat_roof, + "PitchedNormalLoftAccess": classify_pitched_loft_unknown, + "PitchedNormalNoLoftAccess": classify_pitched_loft_unknown, + "PitchedThatched": classify_thatched_roof, + "PitchedWithSlopingCeiling": classify_sloping_ceiling_roof, +} + + +def fill_roof_as_built(row): + # Already resolved + if row.landlord_roof_description is not None: + return row.landlord_roof_description + + roof_type = row["Roof Construction"] + + classifier = AS_BUILT_ROOF_CLASSIFIERS.get(roof_type) + if classifier is None: + raise NotImplementedError(f"No roof classifier for roof type '{roof_type}'") + + if pd.isnull(row.construction_age_band): + raise NotImplementedError( + f"Missing age band for roof classification ({roof_type})" + ) + + return classifier(row.construction_age_band) + + +data["landlord_roof_description"] = ( + data[["Roof Construction", "Roof Insulation"]] + .progress_apply(tuple, axis=1) + .map(roof_mapping) +) + +data["landlord_roof_description"] = data.progress_apply( + fill_roof_as_built, + axis=1, +) + +for _, row in data.iterrows(): + fill_roof_as_built(row) + # Variables we want to map # 'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type', -# 'Attachment', 'Construction Years', 'Wall Construction', -# 'Wall Insulation', 'Roof Construction', 'Roof Insulation', +# 'Attachment', 'Construction Years', +# 'Roof Construction', 'Roof Insulation', # '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 new file mode 100644 index 00000000..e69de29b diff --git a/backend/onboarders/tests/test_wall_remapping.py b/backend/onboarders/tests/test_wall_remapping.py new file mode 100644 index 00000000..e69de29b