diff --git a/asset_list/AssetList.py b/asset_list/AssetList.py index 446ff4d0..3731fc77 100644 --- a/asset_list/AssetList.py +++ b/asset_list/AssetList.py @@ -949,9 +949,19 @@ class AssetList: if self.phase: # We filter on just the properties that have had an inspection - self.standardised_asset_list = self.standardised_asset_list[ - ~self.standardised_asset_list['Surveyors Name'].isin(["YET TO BE SURVEYED"]) - ] + if self.new_format_non_insturives_present_v2: + self.standardised_asset_list = self.standardised_asset_list[ + ~self.standardised_asset_list['NAME OF SURVEYOR'].isin( + ["YET TO BE SURVEYED", "", None] + ) + ] + self.standardised_asset_list = self.standardised_asset_list[ + ~pd.isnull(self.standardised_asset_list["NAME OF SURVEYOR"]) + ] + else: + self.standardised_asset_list = self.standardised_asset_list[ + ~self.standardised_asset_list['Surveyors Name'].isin(["YET TO BE SURVEYED"]) + ] if not self.variable_mappings and not override_empty_mappings: raise ValueError("Please run init_standardise first") diff --git a/asset_list/app.py b/asset_list/app.py index f817dc7f..8af23f38 100644 --- a/asset_list/app.py +++ b/asset_list/app.py @@ -58,6 +58,39 @@ def app(): EPC recommendations Property UPRN """ + # Freebridge + data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Freebridge" + data_filename = "Domna - FCH property data May 25 copy.xlsx" + sheet_name = "EPC Data" + postcode_column = 'Post Code' + address1_column = "Address 1" + address1_method = None + fulladdress_column = None + address_cols_to_concat = ["Address 1", "Address 4"] + missing_postcodes_method = None + landlord_year_built = "Build Date" + landlord_os_uprn = None + landlord_property_type = "Property Type" + landlord_built_form = None + landlord_wall_construction = "Walls Description" + landlord_heating_system = "Heating Type" + landlord_existing_pv = None + landlord_property_id = "Place Ref" + landlord_roof_construction = "Roof Description" + landlord_sap = "Current SAP" + outcomes_filename = [] + outcomes_sheetname = [] + outcomes_postcode = [] + outcomes_houseno = [] + outcomes_address = [] + outcomes_id = [] + master_filepaths = [] + master_to_asset_list_filepath = None + asset_list_header = 0 + landlord_block_reference = None + master_id_colnames = [] + phase = True # Inspections not complete, produce a partial view + ecosurv_landlords = None data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Broadlands" data_filename = "Broadlands Asset List.xlsx" diff --git a/asset_list/mappings/heating_systems.py b/asset_list/mappings/heating_systems.py index b55f13c8..424b9b46 100644 --- a/asset_list/mappings/heating_systems.py +++ b/asset_list/mappings/heating_systems.py @@ -431,6 +431,47 @@ HEATING_MAPPINGS = { 'Mains Electric': 'electric fuel', 'Unvented cylinder': 'other', 'MVHR & Heat Recovery': 'other', - 'Solar': 'other' + 'Solar': 'other', + 'Electric storage heaters, Electric storage heaters': 'electric storage heaters', + 'Room heaters, electric': 'room heaters', + 'Room heaters, mains gas, Room heaters, electric': 'room heaters', + 'Air source heat pump, underfloor, electric': 'air source heat pump', + 'Air source heat pump, radiators, electric': 'air source heat pump', + 'Air source heat pump, Systems with radiators, electric': 'air source heat pump', + 'Electric storage heaters': 'electric storage heaters', + 'Air source heat pump, Underfloor heating and radiators, pipes in screed above insulation, electric': 'air source ' + 'heat pump', + 'Room heaters, coal': 'room heaters', + 'Room heaters, electric, Electric storage heaters': 'electric storage heaters', + 'Air source heat pump, fan coil units, electric': 'air source heat pump', + 'Boiler and radiators, mains gas': 'gas boiler, radiators', + 'Boiler and radiators, mains gas, Electric storage heaters': 'condensing boiler, radiators', + 'Room heaters, mains gas': 'room heaters', + 'Air source heat pump, radiators, electric, Air source heat pump, fan coil units, electric': 'air source heat pump', + 'Air source heat pump, warm air, electric': 'air source heat pump', + 'Electric ceiling heating, electric': 'electric ceiling', + 'Electric storage heaters, Room heaters, electric': 'electric storage heaters', + 'Room heaters, dual fuel (mineral and wood)': 'room heaters', + 'Water source heat pump, radiators, electric': 'other', + 'Warm air, electric': 'warm air heating', + 'Boiler and radiators, wood logs': 'solid fuel', + 'Boiler and radiators, dual fuel (mineral and wood)': 'solid fuel', + 'Boiler & underfloor, mains gas': 'gas boiler, radiators', + 'Boiler and underfloor heating, mains gas': 'gas boiler, radiators', + 'Community scheme': 'communal heating', + 'Warm air, Electricaire': 'warm air heating', + 'Boiler and radiators, smokeless fuel': 'solid fuel', + 'Warm air, mains gas': 'warm air heating', + 'Warm air , electric': 'warm air heating', + 'Boiler and radiators, LPG': 'boiler - other fuel', + 'Boiler & underfloor, oil': 'oil boiler', + 'Boiler and radiators, bottled LPG': 'boiler - other fuel', + 'Boiler and underfloor heating, oil': 'oil boiler', + 'SAP05:Main-Heating': 'unknown', + 'Boiler and radiators, coal': 'solid fuel', + 'Boiler and radiators, oil': 'oil boiler', + 'Boiler and radiators, electric': 'electric boiler', + 'No system present: electric heaters assumed': 'electric radiators', + 'Boiler and radiators, anthracite': 'solid fuel' } diff --git a/asset_list/mappings/roof.py b/asset_list/mappings/roof.py index 66860bec..60f0473c 100644 --- a/asset_list/mappings/roof.py +++ b/asset_list/mappings/roof.py @@ -10,8 +10,12 @@ STANDARD_ROOF_CONSTRUCTIONS = { "another dwelling above", "flat unknown insulation", "flat insulated", + "flat uninsulated", "unknown insulated", "unknown", + "room roof insulated", + "room roof uninsulated", + "average thermal transmittance", } ROOF_CONSTRUCTION_MAPPINGS = { @@ -173,6 +177,73 @@ ROOF_CONSTRUCTION_MAPPINGS = { 'PitchedNormalNoLoftAccess: Unknown': 'pitched no access to loft', 'PitchedNormalLoftAccess: Unknown': 'pitched unknown insulation', - 'AnotherDwellingAbove: Unknown': 'another dwelling above' + 'AnotherDwellingAbove: Unknown': 'another dwelling above', + + 'Flat, insulated': 'flat insulated', + 'Pitched, insulated (assumed)': 'pitched insulated', + 'Flat, insulated (assumed)': 'flat insulated', + '(another dwelling above)': 'another dwelling above', + 'Pitched, insulated at rafters': 'pitched insulated', + '(other premises above)': 'another dwelling above', + 'Average thermal transmittance 0.15 W/m-¦K': 'average thermal transmittance', + 'Pitched, 25 mm loft insulation': 'pitched less than 100mm insulation', + 'Roof room(s), insulated (assumed)': 'room roof insulated', + 'Pitched, limited insulation (assumed)': 'pitched less than 100mm insulation', + 'Pitched, 270 mm loft insulation': 'pitched insulated', + 'Pitched, 250 mm loft insulation': 'pitched insulated', + 'Pitched, 200mm loft insulation': 'pitched insulated', + 'Flat, no insulation': 'flat uninsulated', + + 'Pitched, 75 mm loft insulation': 'pitched less than 100mm insulation', + 'Average thermal transmittance 0.09 W/m-¦K': 'average thermal transmittance', + + 'SAP05:Roof': 'unknown', + 'Pitched, 400 mm loft insulation': 'pitched insulated', + 'Pitched, 150mm loft insulation': 'pitched insulated', + 'Average thermal transmittance 0.11 W/m-¦K': 'unknown', + 'Pitched, 100 mm loft insulation': 'pitched less than 100mm insulation', + 'Pitched, 300 mm loft insulation': 'pitched insulated', + 'Pitched, 75mm loft insulation': 'pitched less than 100mm insulation', + 'Pitched, 300+mm loft insulation': 'pitched insulated', + 'Pitched, 300+ mm loft insulation': 'pitched insulated', + 'Average thermal transmittance 0.11 W/m?K': 'average thermal transmittance', + 'Average thermal transmittance 0.10 W/m?K': 'average thermal transmittance', + 'Pitched, 250mm loft insulation': 'pitched insulated', + 'Pitched, 300+ mm loft insulation': 'pitched insulated', + 'Average thermal transmittance 0.1 W/m-¦K': 'average thermal transmittance', + 'Pitched, *** INVALID INPUT Code : 57 *** loft insulation': 'unknown', + 'Pitched, 100mm loft insulation': 'pitched less than 100mm insulation', + 'Pitched, loft insulation': 'pitched less than 100mm insulation', + 'Average thermal transmittance 0.20 W/m?K': 'average thermal transmittance', + 'Average thermal transmittance 0.1 W/m?K': 'average thermal transmittance', + 'Average thermal transmittance 0.16 W/m-¦K': 'average thermal transmittance', + 'Average thermal transmittance 0.14 W/m?K': 'average thermal transmittance', + 'Pitched, 50 mm loft insulation': 'pitched less than 100mm insulation', + 'Flat, limited insulation': 'flat uninsulated', + 'Average thermal transmittance 0.12 W/m?K': 'average thermal transmittance', + 'Roof room(s), ceiling insulated': 'room roof insulated', + 'Average thermal transmittance 0.18 W/m?K': 'average thermal transmittance', + 'Average thermal transmittance 0.10 W/m-¦K': 'average thermal transmittance', + 'Pitched, 400+ mm loft insulation': 'pitched insulated', + 'Average thermal transmittance 0.14 W/m²K': 'average thermal transmittance', + 'Pitched, no insulation (assumed)': 'pitched less than 100mm insulation', + 'Average thermal transmittance 0.16 W/m?K': 'average thermal transmittance', + 'Average thermal transmittance 0.21 W/m?K': 'average thermal transmittance', + 'Flat, no insulation (assumed)': 'flat uninsulated', + 'Pitched, no insulation': 'pitched less than 100mm insulation', + + 'Average thermal transmittance 0.12 W/m-¦K': 'average thermal transmittance', + 'Pitched, 12 mm loft insulation': 'pitched less than 100mm insulation', + 'Average thermal transmittance 0.07 W/m-¦K': 'average thermal transmittance', + 'Roof room(s), no insulation (assumed)': 'room roof uninsulated', + 'Pitched, no insulation(assumed)': 'pitched less than 100mm insulation', + 'Average thermal transmittance 0.13 W/m-¦K': 'average thermal transmittance', + 'Average thermal transmittance 0.08 W/m-¦K': 'average thermal transmittance', + 'Average thermal transmittance 0.14 W/m-¦K': 'average thermal transmittance', + 'Pitched, 350 mm loft insulation': 'pitched insulated', + 'Average thermal transmittance 0 W/m-¦K': 'average thermal transmittance', + 'Pitched, 200 mm loft insulation': 'pitched insulated', + 'Pitched, 150 mm loft insulation': 'pitched insulated', + 'Flat, limited insulation (assumed)': 'flat uninsulated', } diff --git a/asset_list/mappings/walls.py b/asset_list/mappings/walls.py index 245b7f88..14e4565c 100644 --- a/asset_list/mappings/walls.py +++ b/asset_list/mappings/walls.py @@ -334,4 +334,13 @@ WALL_CONSTRUCTION_MAPPINGS = { 'Cavity: FilledCavity, TimberFrame: AsBuilt': 'filled cavity', 'Cavity: FilledCavity, SolidBrick: AsBuilt, SolidBrick: Internal': 'filled cavity', 'Cavity: Internal, SolidBrick: AsBuilt': 'filled cavity', + + 'Timber frame, filled cavity': 'filled cavity', + 'Cob, as built': 'cob', + 'Cavity wall, filled cavity and internal insulation': 'filled cavity', + 'SAP05:Walls': 'other', + 'Solid brick, as built, partial insulation (assumed)': 'insulated solid brick', + 'Sandstone, as built, no insulation (assumed)': 'uninsulated sandstone or limestone', + 'System built, as built, partial insulation (assumed)': 'system built unknown insulation', + 'Timber frame, with external insulation': 'insulated timber frame' } diff --git a/backend/Funding.py b/backend/Funding.py index 74d43e3e..cab11899 100644 --- a/backend/Funding.py +++ b/backend/Funding.py @@ -96,8 +96,9 @@ class Funding: """ measure_types = [m["type"] for m in measures] innovation_flags = [m.get("is_innovation", False) for m in measures] + uplifts = [m["uplift"] for m in measures] innovation_measures = [m["type"] for m in measures if m.get("is_innovation", False)] - return measure_types, innovation_flags, innovation_measures + return measure_types, uplifts, innovation_flags, innovation_measures @staticmethod def _meets_upgrade_target(starting_sap: int, ending_sap: int) -> bool: @@ -325,9 +326,104 @@ class Funding: return starting_str, ending_uvalue + @staticmethod + def _map_to_pre_main_heating(mainheating, main_fuel, mainheat_energy_eff): + # We check most likely primary heating system. Because mixed systems are hard to break up, we + # check the larger, more prominent heating systems first and then the smaller ones. We aim + # to cover the case where properties have heating systems like + # "boiler radiators, mains gas, electric storage heaters" so mixed systems + if mainheating["has_air_source_heat_pump"]: + return 'Air to Water ASHP' + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "biomass"): + return 'Biomass Boiler' + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "lpg"): + return 'Bottled LPG Boiler' + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "mains gas") and ( + mainheat_energy_eff in ["Good", "Very Good"] + ): + # Assume higher efficiency condensing boiler + return 'Condensing Gas Boiler' + + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "mains gas") and ( + mainheat_energy_eff in ["Average", "Poor"] + ): + return 'Non Condensing Gas Boiler' + + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "mains gas") and ( + mainheat_energy_eff in ["Very Poor"] + ) and mainheating["has_radiators"]: + return 'Gas Back Boiler to Radiators' + + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "mains gas") and ( + mainheat_energy_eff in ["Very Poor"] + ) and not mainheating["has_radiators"]: + # Doesnt have radiators + return 'Gas Fire with Back Boiler' + + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "oil") and ( + mainheat_energy_eff in ["Good", "Very Good"] + ): + return 'Condensing Oil Boiler' + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "oil") and ( + mainheat_energy_eff in ["Average", "Very Poor", "Poor"] + ): + return 'Non Condensing Oil Boiler' + + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "lpg") and ( + mainheat_energy_eff in ["Good", "Very Good"] + ): + return 'Condensing LPG Boiler' + + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "lpg") and ( + mainheat_energy_eff in ["Average", "Very Poor", "Poor"] + ): + return 'Non Condensing LPG Boiler' + + if mainheating["has_boiler"] and ( + main_fuel["fuel_type"] in ["dual fuel appliance mineral and wood", "manufactured smokeless fuel"] + ) and (mainheat_energy_eff in ["Average", "Very Poor", "Poor"]): + return 'Solid Fossil Boiler' + + if mainheating["has_ground_source_heat_pump"]: + return 'GSHP' + + if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "electric"): + return 'Electric Boiler' + + if mainheating["has_community_scheme"] and mainheat_energy_eff in ["Good", "Very Good"]: + return 'DHS CHP' + if mainheating["has_community_scheme"] and mainheat_energy_eff in ["Average", "Very Poor", "Poor"]: + return 'DHS non-CHP' + + if mainheating["has_electric_storage_heaters"] and mainheat_energy_eff == "Very Poor": + return 'Electric Storage Heaters Responsiveness <=0.2' + if mainheating["has_electric_storage_heaters"] and mainheat_energy_eff in [ + "Poor", "Average", "Good", "Very Good", + ]: + return 'Electric Storage Heaters Responsiveness >0.2' + + if mainheating["has_room_heaters"] and main_fuel["fuel_tye"] == "lpg": + return 'Bottled LPG Room Heaters' + + if mainheating["has_room_heaters"] and main_fuel["fuel_tye"] == "electricity": + return 'Electric Room Heaters' + + if mainheating["has_room_heaters"] and main_fuel["fuel_tye"] == "mains gas": + return 'Gas Room Heaters' + + if mainheating["has_room_heaters"] and main_fuel["fuel_tye"] in [ + "dual fuel appliance mineral and wood", "manufactured smokeless fuel" + ]: + return 'Solid Fossil Room Heaters' + + raise ValueError("Invalid pre heating system") + def calculate_partial_project_abs( self, measure_type: str, + mainheating: dict, + main_fuel: dict, + mainheat_energy_eff: str, current_wall_uvalue: float = None, is_partial: bool = False, existing_li_thickness: float = None, @@ -411,6 +507,23 @@ class Funding: raise ValueError("Invalid SFI category") return pps.squeeze()["Cost Savings"] + if measure_type == "solar_pv": + pre_heating_system = self._map_to_pre_main_heating(mainheating, main_fuel, mainheat_energy_eff) + solar_pps_df = df[ + (df["Measure_Type"] == "Solar_PV") & (df["Pre_Main_Heating_Source"] == pre_heating_system) + ] + return solar_pps_df.squeeze()["Cost Savings"] + + if measure_type == "air_source_heat_pump": + pre_heating_system = self._map_to_pre_main_heating(mainheating, main_fuel, mainheat_energy_eff) + pps = df[ + (df["Pre_Main_Heating_Source"] == pre_heating_system) & + (df["Post_Main_Heating_Source"] == "Air to Water ASHP") + ] + if pps.shape[0] != 1: + raise ValueError("something went wrong, more than one pps for ashp") + return pps.squeeze()["Cost Savings"] + raise ValueError(f"Invalid measure type for partial project ABS calculation: {measure_type}") # ----------------------- @@ -588,6 +701,9 @@ class Funding: current_wall_uvalue: float, is_partial: False, existing_li_thickness: float, + mainheating: dict, + main_fuel: dict, + mainheat_energy_eff: str, council_tax_band: str = None, has_wall_insulation_recommendation: bool = False, has_roof_insulation_recommendation: bool = False, @@ -602,7 +718,7 @@ class Funding: """ # Normalize measures - measure_types, innovation_flags, innovation_measures = self._split_measures(measures) + measure_types, uplifts, innovation_flags, innovation_measures = self._split_measures(measures) # If we have a heating measure, we check if we meet the pre conditions has_ftch = "first_time_central_heating" in measure_types @@ -666,6 +782,22 @@ class Funding: if self.eco4_eligible: # Calculate the full project ABS for ECO4 self.full_project_abs = self.calculate_full_project_abs() + + # We calculate uplift innovation, where required + project_uplifts = [] + for i, measure in enumerate(measure_types): + pps = self.calculate_partial_project_abs( + measure_type=measure, + mainheating=mainheating, + main_fuel=main_fuel, + mainheat_energy_eff=mainheat_energy_eff, + current_wall_uvalue=current_wall_uvalue, + is_partial=is_partial, + existing_li_thickness=existing_li_thickness, + ) + project_uplifts.append(pps * uplifts[i]) + total_uplift = sum(project_uplifts) + self.full_project_abs += total_uplift self.eco4_funding = self.full_project_abs * ( self.social_cavity_abs_rate if is_cavity else self.social_solid_abs_rate ) @@ -673,7 +805,13 @@ class Funding: if self.gbis_eligible: # Calculate the partial project score - this is dependent on the measure self.partial_project_abs = self.calculate_partial_project_abs( - measure_types[0], current_wall_uvalue, is_partial, existing_li_thickness, + measure_type=measure_types[0], + mainheating=mainheating, + main_fuel=main_fuel, + mainheat_energy_eff=mainheat_energy_eff, + current_wall_uvalue=current_wall_uvalue, + is_partial=is_partial, + existing_li_thickness=existing_li_thickness, ) diff --git a/backend/tests/test_funding.py b/backend/tests/test_funding.py index be59771d..45e26dc8 100644 --- a/backend/tests/test_funding.py +++ b/backend/tests/test_funding.py @@ -907,3 +907,84 @@ def test_custom_eco4_scenarios( assert caveat in funding.eco4_eligibility_caveats, f"Missing caveat in: {scenario['description']}" for caveat in funding.eco4_eligibility_caveats: assert caveat in scenario.get("expected_caveats", []), f"Unexpected caveat in: {scenario['description']}" + + +### ------------------------- +### Innovation uplift scenarios +### ------------------------- + +def test_uplift( + mock_project_scores_matrix, + mock_partial_scores_matrix, + mock_whlg_postcodes +): + funding = Funding( + project_scores_matrix=mock_project_scores_matrix, + partial_project_scores_matrix=mock_partial_scores_matrix, + whlg_eligible_postcodes=mock_whlg_postcodes, + social_cavity_abs_rate=13.5, + social_solid_abs_rate=17, + private_cavity_abs_rate=13.5, + private_solid_abs_rate=17, + tenure="Social" + ) + + # # TODO: Add a scenario with multiple measures, where some are innovation, some are not and we have + # TODO: Make sure private works too + measures = [ + {"type": "solar_pv", "is_innovation": True, "uplift": 0.45}, + {"type": "internal_wall_insulation", "is_innovation": False, "uplift": 0}, + {"type": "loft_insulation", "is_innovation": False, "uplift": 0}, + {"type": "air_source_heat_pump", "is_innovation": False, "uplift": 0}, + {"type": "cavity_wall_insulation", "is_innovation": False, "uplift": 0.25}, + ] + + mainheating = { + 'original_description': 'Electric storage heaters', 'has_radiators': False, + 'has_fan_coil_units': False, + 'has_pipes_in_screed_above_insulation': False, + 'has_pipes_in_insulated_timber_floor': False, + 'has_pipes_in_concrete_slab': False, 'has_boiler': False, + 'has_air_source_heat_pump': False, + 'has_room_heaters': False, 'has_electric_storage_heaters': True, 'has_warm_air': False, + 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False, + 'has_community_scheme': False, + 'has_ground_source_heat_pump': False, 'has_no_system_present': False, + 'has_portable_electric_heaters': False, + 'has_water_source_heat_pump': False, 'has_electric': True, 'has_mains_gas': False, + 'has_wood_logs': False, + 'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, 'has_anthracite': + False, + 'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, + 'has_assumed': False, + 'has_electricaire': False, 'has_assumed_for_most_rooms': False, + 'has_underfloor_heating': False, + "has_electric_heat_pumps": False, + "has_micro-cogeneration": False + } + main_fuel = { + 'original_description': 'Electricity: electricity, unspecified tariff', 'fuel_type': + 'electricity', + 'tariff_type': 'unspecified tariff', 'is_community': False, + 'no_individual_heating_or_community_network': False, + 'complex_fuel_type': None + } + mainheat_energy_eff = "Good" + + funding.check_funding( + measures=measures, + starting_sap=33, + ending_sap=69, + floor_area=71, + mainheat_description="Electic storage heaters", + heating_control_description="Manual charge control", + is_cavity=True, + current_wall_uvalue=2, + is_partial=False, + existing_li_thickness=0, + has_wall_insulation_recommendation=True, + has_roof_insulation_recommendation=True, + mainheating=mainheating, + main_fuel=main_fuel, + mainheat_energy_eff=mainheat_energy_eff, + )