diff --git a/backend/Property.py b/backend/Property.py index e57d8326..109f70a9 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -150,158 +150,195 @@ class Property: # self.base_difference_record.df - def adjust_difference_record_with_recommendations(self, property_recommendations): + def adjust_difference_record_with_recommendations( + self, property_recommendations, + property_representative_recommendations + ): """ This method will adjust the difference record, based on the recommendations made for the property + + In order to score the measures, we need to consider the phase of the retrofit. + :param property_recommendations: dictionary of recommendations for the property + :param property_representative_recommendations: dictionary of representative recommendations for the property """ self.recommendations_scoring_data = [] + phases = sorted([r[0]["phase"] for r in property_recommendations if r[0]["phase"] is not None]) + + for phase in phases: + property_recommendations_by_phase = [r for r in property_recommendations if r[0]["phase"] == phase][0] + previous_phases = [p for p in phases if p < phase] + previous_phase_representatives = [ + r for r in property_representative_recommendations if r["phase"] in previous_phases + ] + recommendation_record = self.base_difference_record.df.to_dict("records")[0].copy() + + for rec in property_recommendations_by_phase: + # We simulate the impact of the recommendation at this current phase, and all of the prior phases - for recommendations_by_type in property_recommendations: - for i, rec in enumerate(recommendations_by_type): - recommendation_record = self.base_difference_record.df.to_dict("records")[0].copy() scoring_dict = self.create_recommendation_scoring_data( - property_id=self.id, recommendation_record=recommendation_record, recommendation=rec, + property_id=self.id, + recommendation_record=recommendation_record, + recommendations=previous_phase_representatives + [rec], + primary_recommendation_id=rec["recommendation_id"] ) - self.recommendations_scoring_data.append(scoring_dict) @staticmethod - def create_recommendation_scoring_data(property_id, recommendation_record, recommendation: dict): + def create_recommendation_scoring_data( + property_id, recommendation_record, recommendations: list, primary_recommendation_id: int + ): + """ + This function will iterate through a list of recommendations and apply a simulation for each recommendation + This allows us to later multiple measures and see the impact of the measures on the property + :param property_id: The id of the property + :param recommendation_record: The record of the property, which will be updated + :param recommendations: The list of recommendations to apply + :param primary_recommendation_id: The id of the primary recommendation, which is used to identify the record + :return: The updated recommendation record + """ + + output = recommendation_record.copy() for col in [ "walls_insulation_thickness", "floor_insulation_thickness", "roof_insulation_thickness" ]: - if recommendation_record[col] is None: - recommendation_record[col] = "none" + if output[col] is None: + output[col] = "none" - # We update the description to indicate it's insulated - if recommendation["type"] in ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation"]: - # The upgrade made here is to the u-value of the walls and the description of the - # insulation thickness - recommendation_record["walls_thermal_transmittance_ending"] = recommendation["new_u_value"] - recommendation_record["walls_insulation_thickness_ending"] = "above average" - recommendation_record["walls_energy_eff_ending"] = "Good" + for recommendation in recommendations: + # For the list of recommendations we have, we iteratively update the output - # Note: often when the wall is insulatied, the internal/external insulation is not noted so we should - # test the impact of using these booleans - if recommendation["type"] == "external_wall_insulation": - recommendation_record["external_insulation"] = True - recommendation_record["internal_insulation"] = False + # We update the description to indicate it's insulated + if recommendation["type"] in [ + "internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation" + ]: + # The upgrade made here is to the u-value of the walls and the description of the + # insulation thickness + output["walls_thermal_transmittance_ending"] = recommendation["new_u_value"] + output["walls_insulation_thickness_ending"] = "above average" + output["walls_energy_eff_ending"] = "Good" - if recommendation["type"] == "internal_wall_insulation": - recommendation_record["external_insulation"] = False - recommendation_record["internal_insulation"] = True + # Note: often when the wall is insulatied, the internal/external insulation is not noted so we should + # test the impact of using these booleans + if recommendation["type"] == "external_wall_insulation": + output["external_insulation"] = True + output["internal_insulation"] = False - else: - if recommendation_record["walls_thermal_transmittance_ending"] is None: - raise ValueError("We should not have a None value for the u value") + if recommendation["type"] == "internal_wall_insulation": + output["external_insulation"] = False + output["internal_insulation"] = True - if recommendation_record["walls_insulation_thickness_ending"] is None: - recommendation_record["walls_insulation_thickness_ending"] = "none" + # When making a recommendation for the wall, we will also update the ventilation + if output["mechanical_ventilation_ending"] == 'natural': + output["mechanical_ventilation_ending"] = 'mechanical, extract only' - # Update description to indicate it's insulate - if recommendation["type"] in [ - "solid_floor_insulation", "suspended_floor_insulation", "exposed_floor_insulation" - ]: - if len(recommendation["parts"]) > 1: - raise NotImplementedError("Have more than 1 floor insulation part - handle this case") + else: + if output["walls_thermal_transmittance_ending"] is None: + raise ValueError("We should not have a None value for the u value") - # recommendation_record["floor_thermal_transmittance_ending"] = recommendation["new_u_value"] - # We don't really see above average for this in the training data - recommendation_record["floor_insulation_thickness_ending"] = "average" - # This is rarely ever populated in the training data - # recommendation_record["floor_energy_eff_ending"] = "Good" - else: - if recommendation_record["floor_thermal_transmittance_ending"] is None: - raise ValueError("We should not have a None value for the u value") + if output["walls_insulation_thickness_ending"] is None: + output["walls_insulation_thickness_ending"] = "none" - if recommendation_record["floor_insulation_thickness_ending"] is None: - recommendation_record["floor_insulation_thickness_ending"] = "none" + # Update description to indicate it's insulate + if recommendation["type"] in [ + "solid_floor_insulation", "suspended_floor_insulation", "exposed_floor_insulation" + ]: + if len(recommendation["parts"]) > 1: + raise NotImplementedError("Have more than 1 floor insulation part - handle this case") - if recommendation["type"] in ["loft_insulation", "room_roof_insulation", "flat_roof_insulation"]: - recommendation_record["roof_thermal_transmittance_ending"] = recommendation["new_u_value"] + # output["floor_thermal_transmittance_ending"] = recommendation["new_u_value"] + # We don't really see above average for this in the training data + output["floor_insulation_thickness_ending"] = "average" + # This is rarely ever populated in the training data + # output["floor_energy_eff_ending"] = "Good" + else: + if output["floor_thermal_transmittance_ending"] is None: + raise ValueError("We should not have a None value for the u value") - parts = recommendation["parts"] - if len(parts) != 1: - raise ValueError("More than one part for roof insulation - investiage me") + if output["floor_insulation_thickness_ending"] is None: + output["floor_insulation_thickness_ending"] = "none" - # This is based on the values we have in the training data - valid_numeric_values = [ - 12, 25, 50, 75, 100, 150, 200, 250, 270, 300, 350, 400 - ] + if recommendation["type"] in ["loft_insulation", "room_roof_insulation", "flat_roof_insulation"]: + output["roof_thermal_transmittance_ending"] = recommendation["new_u_value"] - proposed_depth = int(parts[0]["depth"]) - if proposed_depth not in valid_numeric_values: - # Take the nearest value for scoring - proposed_depth = min(valid_numeric_values, key=lambda x: abs(x - proposed_depth)) + parts = recommendation["parts"] + if len(parts) != 1: + raise ValueError("More than one part for roof insulation - investiage me") - recommendation_record["roof_insulation_thickness_ending"] = str(proposed_depth) - if recommendation["type"] == "loft_insulation": - if proposed_depth >= 270: - recommendation_record["roof_energy_eff_ending"] = "Very Good" + # This is based on the values we have in the training data + valid_numeric_values = [ + 12, 25, 50, 75, 100, 150, 200, 250, 270, 300, 350, 400 + ] + + proposed_depth = int(parts[0]["depth"]) + if proposed_depth not in valid_numeric_values: + # Take the nearest value for scoring + proposed_depth = min(valid_numeric_values, key=lambda x: abs(x - proposed_depth)) + + output["roof_insulation_thickness_ending"] = str(proposed_depth) + if recommendation["type"] == "loft_insulation": + if proposed_depth >= 270: + output["roof_energy_eff_ending"] = "Very Good" + else: + output["roof_energy_eff_ending"] = "Good" else: - recommendation_record["roof_energy_eff_ending"] = "Good" + output["roof_energy_eff_ending"] = "Very Good" else: - recommendation_record["roof_energy_eff_ending"] = "Very Good" - else: - # Fill missing roof u-values - this fill is not based on recommended upgrades - if recommendation_record["roof_thermal_transmittance_ending"] is None: - raise ValueError("We should not have a None value for the u value") + # Fill missing roof u-values - this fill is not based on recommended upgrades + if output["roof_thermal_transmittance_ending"] is None: + raise ValueError("We should not have a None value for the u value") - if recommendation_record["roof_insulation_thickness_ending"] is None: - recommendation_record["roof_insulation_thickness_ending"] = "none" + if output["roof_insulation_thickness_ending"] is None: + output["roof_insulation_thickness_ending"] = "none" - if recommendation["type"] == "mechanical_ventilation": - recommendation_record["mechanical_ventilation_ending"] = 'mechanical, extract only' + if recommendation["type"] == "sealing_open_fireplace": + output["number_open_fireplaces_ending"] = 0 - if recommendation["type"] == "sealing_open_fireplace": - recommendation_record["number_open_fireplaces_ending"] = 0 + if recommendation["type"] == "low_energy_lighting": + output["low_energy_lighting_ending"] = 100 + output["lighting_energy_eff_starting"] = "Very Good" - if recommendation["type"] == "low_energy_lighting": - recommendation_record["low_energy_lighting_ending"] = 100 - recommendation_record["lighting_energy_eff_starting"] = "Very Good" + if recommendation["type"] == "windows_glazing": + output["multi_glaze_proportion_ending"] = 100 + output["windows_energy_eff_ending"] = "Average" - if recommendation["type"] == "windows_glazing": - recommendation_record["multi_glaze_proportion_ending"] = 100 - recommendation_record["windows_energy_eff_ending"] = "Average" + is_secondary_glazing = recommendation["is_secondary_glazing"] - is_secondary_glazing = recommendation["is_secondary_glazing"] + if output["glazing_type_ending"] == "multiple": + pass + elif output["glazing_type_ending"] == "single": + output["glazing_type_ending"] = "secondary" if is_secondary_glazing else "double" + elif output["glazing_type_ending"] == "double": + output["glazing_type_ending"] = "multiple" if is_secondary_glazing else "double" + elif output["glazing_type_ending"] == "secondary": + output["glazing_type_ending"] = "secondary" if is_secondary_glazing else "multiple" + elif output["glazing_type_ending"] in ["triple", "high performance"]: + output["glazing_type_ending"] = "multiple" + else: + raise ValueError("Invalid glazing type - implement me") - if recommendation_record["glazing_type_ending"] == "multiple": - pass - elif recommendation_record["glazing_type_ending"] == "single": - recommendation_record["glazing_type_ending"] = "secondary" if is_secondary_glazing else "double" - elif recommendation_record["glazing_type_ending"] == "double": - recommendation_record["glazing_type_ending"] = "multiple" if is_secondary_glazing else "double" - elif recommendation_record["glazing_type_ending"] == "secondary": - recommendation_record["glazing_type_ending"] = "secondary" if is_secondary_glazing else "multiple" - elif recommendation_record["glazing_type_ending"] in ["triple", "high performance"]: - recommendation_record["glazing_type_ending"] = "multiple" - else: - raise ValueError("Invalid glazing type - implement me") + if is_secondary_glazing: + output["glazed_type_ending"] = "secondary glazing" + else: + output["glazed_type_ending"] = "double glazing installed during or after 2002 " - if is_secondary_glazing: - recommendation_record["glazed_type_ending"] = "secondary glazing" - else: - recommendation_record["glazed_type_ending"] = "double glazing installed during or after 2002 " + if recommendation["type"] == "solar_pv": + output["photo_supply_ending"] = recommendation["photo_supply"] - if recommendation["type"] == "solar_pv": - recommendation_record["photo_supply_ending"] = recommendation["photo_supply"] + if recommendation["type"] not in [ + "mechanical_ventilation", "sealing_open_fireplace", "low_energy_lighting", + "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" + ]: + raise NotImplementedError("Implement me") - if recommendation["type"] not in [ - "mechanical_ventilation", "sealing_open_fireplace", "low_energy_lighting", - "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" - ]: - raise NotImplementedError("Implement me") + output['id'] = "+".join([str(property_id), str(primary_recommendation_id)]) - recommendation_record['id'] = "+".join([str(property_id), str(recommendation["recommendation_id"])]) - - return recommendation_record + return output def get_components(self, cleaned, photo_supply_lookup, floor_area_decile_thresholds): """ diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 811c3c09..08d3f048 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -136,22 +136,32 @@ async def trigger_plan(body: PlanTriggerRequest): recommendations = {} recommendations_scoring_data = [] - + representive_recommendations = {} for p in input_properties: # Property recommendations p.get_components(cleaned, photo_supply_lookup, floor_area_decile_thresholds) + # TODO: For the private customer, we should probably NOT allow floor insulation, because it often requires + # decanting the tenant recommender = Recommendations(property_instance=p, materials=materials) - property_recommendations = recommender.recommend() + property_recommendations, property_representative_recommendations = recommender.recommend() if not property_recommendations: continue recommendations[p.id] = property_recommendations + representive_recommendations[p.id] = property_representative_recommendations p.create_base_difference_epc_record(cleaned_lookup=cleaned) - p.adjust_difference_record_with_recommendations(property_recommendations) + p.adjust_difference_record_with_recommendations( + property_recommendations, property_representative_recommendations + ) + + p.recommendations_scoring_data[0]["id"] + p.recommendations_scoring_data[0]["walls_thermal_transmittance"] + p.recommendations_scoring_data[0]["walls_thermal_transmittance_ending"] + p.recommendations_scoring_data[0]["walls_thermal_transmittance_ending"] recommendations_scoring_data.extend(p.recommendations_scoring_data) diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py index a19ee025..40d9fb10 100644 --- a/recommendations/FloorRecommendations.py +++ b/recommendations/FloorRecommendations.py @@ -118,6 +118,7 @@ class FloorRecommendations(Definitions): if self.property.floor["is_suspended"]: # Given the U-value, we recommend underfloor insulation self.recommend_floor_insulation( + phase=phase, u_value=u_value, insulation_materials=self.suspended_floor_insulation_materials, non_insulation_materials=self.suspended_floor_non_insulation_materials diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index b4b764c2..f0cf7806 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -107,7 +107,52 @@ class Recommendations: # We insert temporary ids into the recommendations which is important for the optimiser later property_recommendations = self.insert_temp_recommendation_id(property_recommendations) - return property_recommendations + # We also need to create the representative recommendations for each recommendation type + property_representative_recommendations = self.create_representative_recommendations(property_recommendations) + + return property_recommendations, property_representative_recommendations + + @staticmethod + def create_representative_recommendations(property_recommendations): + """ + This method will create a representative recommendation for each recommendation type + In order to create a representative recommendation, we choose the recommendation that has: + 1) Where a U-value is available, has the best U-value to cost ratio + 2) Where SAP points are available, has the best SAP points to cost ratio + + We don't include mechanical ventilation in the representative recommendations, since we don't attribute a + SAP impact to this recommendation + :return: + """ + property_representative_recommendations = [] + + for recommendations_by_type in property_recommendations: + + if recommendations_by_type[0].get("type") == "mechanical_ventilation": + continue + + has_u_value = recommendations_by_type[0].get("new_u_value") is not None + has_sap_points = recommendations_by_type[0].get("sap_points") is not None + + if has_u_value: + # We sort by the cost per U-value improvement - the lower the better + recommendations_by_type.sort( + key=lambda x: x["total"] / x["starting_u_value"] - x["new_u_value"] + ) + elif not has_u_value and has_sap_points: + # Sort the options by the cost per SAP point improvement - the lower the better + recommendations_by_type.sort( + key=lambda x: x["total"] / x["sap_points"] + ) + else: + # Sort the options by cost - the lower the better + recommendations_by_type.sort( + key=lambda x: x["total"] + ) + + property_representative_recommendations.append(recommendations_by_type[0]) + + return property_representative_recommendations @staticmethod def insert_temp_recommendation_id(property_recommendations):