diff --git a/backend/Property.py b/backend/Property.py index 840d503d..efde7c18 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -343,7 +343,7 @@ class Property: else: output["glazed_type_ending"] = "double glazing installed during or after 2002" - if recommendation["type"] in ["heating", "heating_control"]: + if recommendation["type"] == "heating": # We update the data, as defined in the recommendaton simulation_config = recommendation["simulation_config"] @@ -363,7 +363,7 @@ class Property: "internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", "loft_insulation", "room_roof_insulation", "flat_roof_insulation", "solid_floor_insulation", "suspended_floor_insulation", "exposed_floor_insulation", - "windows_glazing", "solar_pv", "heating", "heating_control", + "windows_glazing", "solar_pv", "heating", ]: raise NotImplementedError("Implement me") @@ -623,6 +623,7 @@ class Property: floor_height=self.floor_height, perimeter=self.perimeter, built_form=self.data["built-form"], + property_type=self.data["property-type"], ) self.insulation_floor_area = self.floor_area / self.number_of_floors diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index f737c2ee..a3f975be 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -146,9 +146,6 @@ async def trigger_plan(body: PlanTriggerRequest): recommender = Recommendations(property_instance=p, materials=materials) property_recommendations, property_representative_recommendations = recommender.recommend() - # TODO: Re-run property_dimensions - # Do the simulation in adjust_difference_record_with_recommendations, to update the values - if not property_recommendations: continue @@ -184,6 +181,8 @@ async def trigger_plan(body: PlanTriggerRequest): ) # Insert the predictions into the recommendations and run the optimiser + # TODO: If a recommendation has a negative impact on SAP, we should remove it - this seems to have become a + # possibility with heating system logger.info("Optimising recommendations") for property_id in recommendations.keys(): @@ -203,21 +202,22 @@ async def trigger_plan(body: PlanTriggerRequest): expected_adjusted_energy=expected_adjusted_energy ) - # TODO: For the private customer, we should probably NOT allow floor insulation, because it often requires - # decanting the tenant - input_measures = prepare_input_measures(recommendations_with_impact, body.goal) + input_measures = prepare_input_measures(recommendations_with_impact, body.goal, body.housing_type) + + current_sap_points = int(property_instance.data["current-energy-efficiency"]) + target_sap_points = epc_to_sap_lower_bound(body.goal_value) + sap_gain = CostOptimiser.calculate_sap_gain_with_slack(target_sap_points - current_sap_points) if body.budget: - optimiser = GainOptimiser(input_measures, max_cost=body.budget) + optimiser = GainOptimiser( + input_measures, max_cost=body.budget, max_gain=sap_gain if sap_gain > 0 else 0 + ) else: # The minimum gain is the minimum number of SAP points required to get to the target SAP band - current_sap_points = int(property_instance.data["current-energy-efficiency"]) - target_sap_points = epc_to_sap_lower_bound(body.goal_value) - # If the gain is negative, the optimiser will return an empty solution optimiser = CostOptimiser( input_measures, - min_gain=CostOptimiser.calculate_sap_gain_with_slack(target_sap_points - current_sap_points) + min_gain=sap_gain ) optimiser.setup() diff --git a/etl/customers/urban_splash.py b/etl/customers/urban_splash.py index 48116864..85097ef7 100644 --- a/etl/customers/urban_splash.py +++ b/etl/customers/urban_splash.py @@ -76,20 +76,20 @@ def app(): to_append = { **row.to_dict(), "uprn": newest_epc["uprn"], - "address": newest_epc["address1"], - "postcode": newest_epc["postcode"], + "postcode to check": newest_epc["postcode"], # "walls-description": newest_epc["walls-description"], # "roof-description": newest_epc["roof-description"], # "floor-description": newest_epc["floor-description"], # "total-floor-area": newest_epc["total-floor-area"], "full-address": newest_epc["address"], - } processed_asset_list.append(to_append) epc_data.append(newest_epc) processed_asset_list_df = pd.DataFrame(processed_asset_list) + processed_asset_list_df.to_excel("urban_splash_processed_asset_list.xlsx") + epc_data_df = pd.DataFrame(epc_data) example = epc_data_df.iloc[11, :] diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 505b4a32..1ae194a9 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -961,3 +961,22 @@ class Costs: "labour_hours": labour_hours, "labour_days": labour_days, } + + def celect_type_controls(self): + """ + Calculate the cost of installing Celect type controls + """ + + # The £50 cost is a rough estimate based on internet research + total_cost = 50 + subtotal_before_vat = total_cost / (1 + self.VAT_RATE) + vat = total_cost - subtotal_before_vat + + # We estimate the labour hours to be 4 + return { + "total": total_cost, + "subtotal": subtotal_before_vat, + "vat": vat, + "labour_hours": 4, + "labour_days": 1, + } diff --git a/recommendations/HeatingControlRecommender.py b/recommendations/HeatingControlRecommender.py index 4ee28d7e..3e9b9dbc 100644 --- a/recommendations/HeatingControlRecommender.py +++ b/recommendations/HeatingControlRecommender.py @@ -19,10 +19,16 @@ class HeatingControlRecommender: # This first iteration of the recommender will provide very basic recommendation # We recommend heating controls based on the main heating system - if heating_description in ["Room heaters, electric", "Electric storage heaters, radiators"]: + if heating_description in [ + "Room heaters, electric", "Electric storage heaters" + ]: self.recommend_room_heaters_electric_controls() return + if heating_description in ["Electric storage heaters, radiators"]: + self.recommend_celect_type_controls() + return + def recommend_room_heaters_electric_controls(self): """ If the home has Room heaters, electric, we start by identifying potential heating controls that could @@ -63,7 +69,6 @@ class HeatingControlRecommender: self.recommendation.append( { - "type": "heating_control", "description": "upgrade heating controls to Programmer and Appliance or Smart Thermostats", **self.costs.programmer_and_appliance_thermostat(has_programmer=has_programmer), "simulation_config": simulation_config @@ -72,3 +77,33 @@ class HeatingControlRecommender: # We don't implement any other recommendations right now return + + def recommend_celect_type_controls(self): + """ + If the home has Electric storage heaters, radiators, we start by identifying potential heating controls that + could + be upgraded, that would provide a practical impact. This will be the least invasive improvement. + + We can then consider the heating system itself + :return: + """ + + # We recommend upgrading to Celect type controls + ending_config = MainheatControlAttributes("Celect-type controls").process() + # We look at what has changed in the ending config, and compare it to the current config + simulation_config = check_simulation_difference( + new_config=ending_config, old_config=self.property.main_heating_controls + ) + # This upgrade will only take the heating system to average energy efficiency + simulation_config["mainheatc_energy_eff_ending"] = "Good" + + self.recommendation.append( + { + "description": "upgrade heating controls to Celect type controls", + **self.costs.celect_type_controls(), + "simulation_config": simulation_config + } + ) + + # We don't implement any other recommendations right now + return diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py index ab7c76eb..68fed382 100644 --- a/recommendations/HeatingRecommender.py +++ b/recommendations/HeatingRecommender.py @@ -18,8 +18,12 @@ class HeatingRecommender: # This first iteration of the recommender will provide very basic recommendation # We recommend heating controls based on the main heating system if self.property.main_heating["clean_description"] == "Room heaters, electric": - self.recommend_room_heaters_electric(phase=phase, heating_controls_only=True) - self.recommend_electric_storage_heaters(phase=phase, heating_controls_only=False) + self.recommend_room_heaters_electric(phase=phase, system_change=False, heating_controls_only=True) + self.recommend_electric_storage_heaters(phase=phase, system_change=True, heating_controls_only=False) + return + + if self.property.main_heating["clean_description"] == "Electric storage heaters, radiators": + self.recommend_electric_storage_heaters(phase=phase, system_change=False, heating_controls_only=True) return @staticmethod @@ -37,7 +41,8 @@ class HeatingRecommender: @staticmethod def combine_heating_and_controls( - controls_recommendations, heating_simulation_config, costs, description, phase, heating_controls_only + controls_recommendations, heating_simulation_config, costs, description, phase, heating_controls_only, + system_change ): """ Given a recommendation for heating controls, and a recommendation for the heating system, we combine the two @@ -48,6 +53,9 @@ class HeatingRecommender: :param description: The description of the recommendation :param phase: The phase of the recommendation :param heating_controls_only: If True, we will also add a recommendation for heating controls only + :param system_change: Indicates if we are recommending a different type of heating system, compared to the + current system. If we have a system change and we have a heat control recommendation, we only recommend + both heating and controls together :return: """ @@ -57,6 +65,9 @@ class HeatingRecommender: if not heating_simulation_config: heating_controls_switch = [] + if system_change and len(controls_recommendations): + heating_controls_switch = [True] + output = [] for controls_switch in heating_controls_switch: total_costs = costs.copy() @@ -102,6 +113,10 @@ class HeatingRecommender: output.append( { "phase": phase, + "parts": [ + # TODO + ], + "type": "heating", "starting_u_value": None, "new_u_value": None, "sap_points": None, @@ -111,16 +126,36 @@ class HeatingRecommender: return output - def recommend_electric_storage_heaters(self, phase, heating_controls_only): + def recommend_electric_storage_heaters(self, phase, system_change, heating_controls_only): """ We recommend electric storage heaters as an upgrade to the heating system. + :param phase: The phase of the recommendation + :param system_change: Indicates if we are recommending a different type of heating system, compared to the + current system + :param heating_controls_only: Indicates if we should include a recommendation for just heating controls :return: """ controls_recommender = HeatingControlRecommender(self.property) - controls_recommender.recommend(heating_description="Room heaters, electric") + # The heating controls we're recommending for are based on the recommended heating system - if self.property.data["mainheat-energy-eff"] not in ["Poor", "Very Poor"]: + # We only recommend Celect-type controls if the current heating system is not Celect-type controls + if self.property.main_heating_controls["clean_description"] != "Celect-type controls": + controls_recommender.recommend(heating_description="Electric storage heaters, radiators") + + # Conditions for not needing this recommendation + efficient_room_heaters = self.property.main_heating["clean_description"] == "Room heaters, electric" and ( + self.property.data["mainheat-energy-eff"] not in ["Poor", "Very Poor", "Average"] + ) + + efficient_storage_heaters = ( + (self.property.main_heating["clean_description"] in [ + "Electric storage heaters, radiators", "Electric storage heaters" + ]) and self.property.data["mainheat-energy-eff"] not in ["Poor", "Very Poor", "Average"] + ) + + # Conditions for not recommending electric storage heaters + if efficient_room_heaters or efficient_storage_heaters: # We do just heating controls self.recommendations.extend( self.combine_heating_and_controls( @@ -140,7 +175,6 @@ class HeatingRecommender: new_config=heating_ending_config, old_config=self.property.main_heating ) # This upgrade will only take the heating system to average energy efficiency - heating_simulation_config["mainheatc_energy_eff_ending"] = "Good" heating_simulation_config["mainheat_energy_eff_ending"] = "Average" # Upgrade to electric storage heaters @@ -155,22 +189,28 @@ class HeatingRecommender: costs=costs, description=description, phase=phase, - heating_controls_only=heating_controls_only + heating_controls_only=heating_controls_only, + system_change=system_change ) self.recommendations.extend(recommendations) - def recommend_room_heaters_electric(self, phase, heating_controls_only): + def recommend_room_heaters_electric(self, phase, system_change, heating_controls_only): """ If the home has Room heaters, electric, we start by identifying potential heating controls that could be upgraded, that would provide a practical impact. This will be the least invasive improvement. We can then consider the heating system itself + :param phase: The phase of the recommendation + :param system_change: Indicates if we are recommending a different type of heating system, compared to the + current system + :param heating_controls_only: Indicates if we should include a recommendation for just heating controls :return: """ controls_recommender = HeatingControlRecommender(self.property) - controls_recommender.recommend(heating_description="Room heaters, electric") + if self.property.main_heating_controls["clean_description"] != "Programmer and appliance thermostats": + controls_recommender.recommend(heating_description='Room heaters, electric') if self.property.data["mainheat-energy-eff"] not in ["Poor", "Very Poor"]: # We do just heating controls @@ -189,7 +229,7 @@ class HeatingRecommender: costs = self.costs.electric_room_heaters( number_heated_rooms=self.property.data["number-heated-rooms"] ) - description = "Upgrade electric room heaters to more efficient electric radiators" + description = "Upgrade electric room heaters to efficient electric radiators" heating_simulation_config = {"mainheat_energy_eff_ending": "Average"} recommendations = self.combine_heating_and_controls( @@ -198,7 +238,8 @@ class HeatingRecommender: costs=costs, description=description, phase=phase, - heating_controls_only=heating_controls_only + heating_controls_only=heating_controls_only, + system_change=system_change ) self.recommendations.extend(recommendations) diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index 44be78f6..e5391626 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -57,6 +57,7 @@ class Recommendations: phase = 0 # Building Fabric + self.wall_recomender.recommend(phase=phase) if self.wall_recomender.recommendations: property_recommendations.append(self.wall_recomender.recommendations) phase += 1 diff --git a/recommendations/optimiser/GainOptimiser.py b/recommendations/optimiser/GainOptimiser.py index d5c8a5af..0db6b4ea 100644 --- a/recommendations/optimiser/GainOptimiser.py +++ b/recommendations/optimiser/GainOptimiser.py @@ -9,10 +9,20 @@ class GainOptimiser: This class is used to maximise gain, given a constrained cost """ - def __init__(self, components, max_cost): + def __init__(self, components, max_cost, max_gain=None): + """ + This function will try and maximise the gain, given a constrained cost. If we specific a max_gain, then the + optimisation routine is constained to try not to exceed a maximum increase + + :param components: List of components, where each component is a dictionary with keys "id", "cost" and "gain" + :param max_cost: Maximum cost constraint + :param max_gain: Maximum gain constraint + """ self.components = components self.max_cost = max_cost + self.max_gain = max_gain self.cost_constraint = None + self.max_gain_constraint = None self.m = None self.variables = [] self.solution = [] @@ -50,6 +60,15 @@ class GainOptimiser: self.cost_constraint = self.m.add_constr(cost_expression) + # Add an optional max gain constraint if max_gain is not None + if self.max_gain is not None: + max_gain_expression = xsum( + component['gain'] * var for group, group_vars in zip(self.components, self.variables) for component, var + in zip(group, group_vars) + ) <= self.max_gain + + self.max_gain_constraint = self.m.add_constr(max_gain_expression) + # This constraint ensures that at most one item from each group is selected # This is expressed by summing up the decision variables for each group and ensuring that the sum is <= 1 for group_vars in self.variables: diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index 03aa38bd..27838d6e 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -1,13 +1,17 @@ -def prepare_input_measures(property_recommendations, goal): +def prepare_input_measures(property_recommendations, goal, housing_type): """ Basic function to convert recommendations_to_upload to a format that is suitable for the optimiser - large :param property_recommendations: object containing the recommendations, created in the plan trigger api :param goal: goal to be optimised for, should be one of the keys in gain_map. E.g. if the gain is SAP points, the goal should reflect that desired gain + :param housing_type: type of housing the recommendations are for - should be one of "Social" or "Private" :return: Nested list of input measures """ + if housing_type not in ["Social", "Private"]: + raise ValueError("Invalid housing type - investigate me") + goal_map = { "Increase EPC": "sap_points" } @@ -16,6 +20,10 @@ def prepare_input_measures(property_recommendations, goal): if not goal_key: raise NotImplementedError("Not implemented this gain type - investigate me") + # We don't include suspended and solid floor insulation as possible measures in private housing, because + # of the need to decant the tenant + ignored_measures = ["suspended_floor_insulation", "solid_floor_insulation"] if housing_type == "Private" else [] + input_measures = [] for recs in property_recommendations: input_measures.append( @@ -26,7 +34,7 @@ def prepare_input_measures(property_recommendations, goal): "gain": rec[goal_key], "type": rec["type"] } - for rec in recs + for rec in recs if rec["type"] not in ignored_measures ] ) diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index 0d5f9743..21f704f8 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -544,7 +544,7 @@ def get_wall_type( return None -def estimate_external_wall_area(num_floors, floor_height, perimeter, built_form): +def estimate_external_wall_area(num_floors, floor_height, perimeter, built_form, property_type): """ This method estimates the external wall area based on fundamental assumptions about the home @@ -553,6 +553,7 @@ def estimate_external_wall_area(num_floors, floor_height, perimeter, built_form) :param floor_height: Height of one floor in meters. :param perimeter: Total perimeter of the building on one floor in meters. :param built_form: The built form of the property. This is used to determine the number of exposed walls. + :param property_type: The type of the property. This is used to determine the number of exposed walls. :return: """ wall_area_one_floor = perimeter * floor_height @@ -565,8 +566,11 @@ def estimate_external_wall_area(num_floors, floor_height, perimeter, built_form) 'Semi-Detached': 3, 'Detached': 4, } - - exposed_wall_area = total_wall_area * (number_exposed_walls.get(built_form, 3) / 4) + if built_form == "Detached" and property_type == "Flat": + # We don't have 4 exposed walls for a flat + exposed_wall_area = total_wall_area * (3 / 4) + else: + exposed_wall_area = total_wall_area * (number_exposed_walls.get(built_form, 3) / 4) return exposed_wall_area