diff --git a/backend/app/db/functions/energy_assessment_functions.py b/backend/app/db/functions/energy_assessment_functions.py index ca2f721c..bbdaaac7 100644 --- a/backend/app/db/functions/energy_assessment_functions.py +++ b/backend/app/db/functions/energy_assessment_functions.py @@ -71,6 +71,10 @@ def get_latest_assessment_by_uprn(session: Session, uprn: int) -> Optional[Energ :param uprn: The unique property reference number :return: The latest EnergyAssessment object or None if not found """ + + if not uprn: + return EnergyAssessment.empty_response() + try: # Query the EnergyAssessment model, filter by uprn, order by inspection_date in descending order latest_assessment = session.query(EnergyAssessment).filter_by(uprn=uprn).order_by( diff --git a/backend/app/db/functions/property_functions.py b/backend/app/db/functions/property_functions.py index 88b4e87d..b17d8e53 100644 --- a/backend/app/db/functions/property_functions.py +++ b/backend/app/db/functions/property_functions.py @@ -11,7 +11,8 @@ from backend.app.db.models.portfolio import ( from sqlalchemy.orm.exc import NoResultFound -def create_property(session: Session, portfolio_id: int, address: str, postcode: str, uprn: str) -> (int, bool): +def create_property(session: Session, portfolio_id: int, address: str, postcode: str, uprn: str, + energy_assessment: dict) -> (int, bool): """ This function will create a record for the property in the database if it does not exist. If it does exist, it will just update the updated_at field. @@ -39,13 +40,17 @@ def create_property(session: Session, portfolio_id: int, address: str, postcode: except NoResultFound: # Property doesn't exist, create a new one + + status = PortfolioStatus.ASSESSMENT.value if len(energy_assessment["epc"]) == 0 \ + else PortfolioStatus.SURVEY.value + new_property = PropertyModel( address=address, postcode=postcode, portfolio_id=portfolio_id, uprn=uprn, creation_status=PropertyCreationStatus.LOADING, - status=PortfolioStatus.ASSESSMENT.value, + status=status, has_pre_condition_report=False, has_recommendations=False ) diff --git a/backend/app/db/models/portfolio.py b/backend/app/db/models/portfolio.py index 7580a27d..e2b258f4 100644 --- a/backend/app/db/models/portfolio.py +++ b/backend/app/db/models/portfolio.py @@ -11,6 +11,7 @@ Base = declarative_base() class PortfolioStatus(enum.Enum): SCOPING = "scoping" ASSESSMENT = "assessment" + SURVEY = "survey" TENDERING = "tendering" PROJECT_UNDERWAY = "project underway" COMPLETION_ON_TRACK = "completion; status: on track" diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 929ce7fa..e925fe00 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -235,10 +235,14 @@ def create_epc_records(epc_searcher: SearchEpc, energy_assessment: dict): EnergyAssessment.empty_response() method """ + newest_epc = epc_searcher.newest_epc.copy() + if newest_epc["uprn"] == "" and epc_searcher.uprn: + newest_epc["uprn"] = epc_searcher.uprn + if not energy_assessment["epc"]: energy_assessment_is_newer = False return { - 'original_epc': epc_searcher.newest_epc.copy(), + 'original_epc': newest_epc, 'full_sap_epc': epc_searcher.full_sap_epc.copy(), 'old_data': epc_searcher.older_epcs.copy(), }, energy_assessment_is_newer @@ -249,22 +253,22 @@ def create_epc_records(epc_searcher: SearchEpc, energy_assessment: dict): # We insert county into the epc, since right now this isn't something that we pull out from the energy # assessment for col in ["county", "constituency", "constituency-label", "local-authority", "local-authority-label"]: - epc[col] = epc_searcher.newest_epc[col] + epc[col] = newest_epc[col] # We check if the energy assessment is newer than the newest EPC - if pd.to_datetime(energy_assessment_date) > pd.to_datetime(epc_searcher.newest_epc["inspection-date"]): + if pd.to_datetime(energy_assessment_date) > pd.to_datetime(newest_epc["inspection-date"]): # In this case, our energy assessment is newer than the EPCs available for this property energy_assessment_is_newer = True return { "original_epc": epc, "full_sap_epc": epc_searcher.full_sap_epc.copy(), - "old_data": epc_searcher.older_epcs.copy() + [epc_searcher.newest_epc.copy()] + "old_data": epc_searcher.older_epcs.copy() + [newest_epc] }, energy_assessment_is_newer # We check if the EPC we have produced is contained in the set of EPCs done for the property # We do this based on inspection-date and SAP epc_in_historicals = [ - x for x in epc_searcher.older_epcs + [epc_searcher.newest_epc] + x for x in epc_searcher.older_epcs + [newest_epc] if x["inspection-date"] == energy_assessment_date and x["current-energy-efficiency"] == epc["current-energy-efficiency"] ] @@ -273,7 +277,7 @@ def create_epc_records(epc_searcher: SearchEpc, energy_assessment: dict): if epc_in_historicals: # Then the EPC we have produced is already in the set of EPCs, and our EPC is older than the newest return { - "original_epc": epc_searcher.newest_epc.copy(), + "original_epc": newest_epc, "full_sap_epc": epc_searcher.full_sap_epc.copy(), "old_data": epc_searcher.older_epcs.copy() }, energy_assessment_is_newer @@ -281,7 +285,7 @@ def create_epc_records(epc_searcher: SearchEpc, energy_assessment: dict): # In this case, our EPC is older than the newest publically avaible one, but is not contained in # the historicals, so it can't have been lodged, so we include it in the old data return { - 'original_epc': epc_searcher.newest_epc.copy(), + 'original_epc': newest_epc, 'full_sap_epc': epc_searcher.full_sap_epc.copy(), 'old_data': epc_searcher.older_epcs.copy() + [epc], }, energy_assessment_is_newer @@ -412,7 +416,8 @@ async def trigger_plan(body: PlanTriggerRequest): # Create a record in db property_id, is_new = create_property( - session, body.portfolio_id, epc_searcher.address_clean, epc_searcher.postcode_clean, epc_searcher.uprn + session, body.portfolio_id, epc_searcher.address_clean, epc_searcher.postcode_clean, epc_searcher.uprn, + energy_assessment ) if not is_new and not body.multi_plan: continue @@ -799,6 +804,15 @@ async def trigger_plan(body: PlanTriggerRequest): if ventilation_rec: selected_recommendations.add(ventilation_rec["recommendation_id"]) + # If we have a trickle vents recommendation, we also switch it on. We don't just check the solution + trickle_vents_rec = next( + (r[0] for r in recommendations[p.id] if r[0]["type"] == "trickle_vents"), + None + ) + # If a matching recommendation was found, add its ID to the selected recommendations + if trickle_vents_rec: + selected_recommendations.add(trickle_vents_rec["recommendation_id"]) + # We'll use the set of selected recommendations to filter the recommendations to upload final_recommendations = [ [ diff --git a/etl/customers/vectis/outputs.py b/etl/customers/vectis/outputs.py index c6d0905f..333d2494 100644 --- a/etl/customers/vectis/outputs.py +++ b/etl/customers/vectis/outputs.py @@ -13,7 +13,7 @@ def app(): "surveyor": "JAFFERSONS ENERGY CONSULTANTS", "project_code": "VEC001", } - + # 5 Grove Mansions # These are the recommendations based on the on-site survey of the property. non_intrusive_recommendations = [ { @@ -22,17 +22,17 @@ def app(): "recommendations": [ { "type": "draught_proofing", - "cost": 123, + "cost": 100, "survey": True, "sap_points": 1 }, { - "type": "mixed_glazing", "cost": 12345, "survey": True, + "type": "mixed_glazing", "cost": 14632, "survey": True, "description": "Install double glazing to north facing windows and secondary glazing to the " "remaining windows at the front of the building", "sap_points": 3 }, - {"type": "trickle_vents", "cost": 500, "survey": True}, + {"type": "trickle_vents", "cost": 1000, "survey": True}, {"type": "suspended_floor_insulation", "cost": None, "survey": True, "sap_points": 2}, {"type": "internal_wall_insulation", "cost": None, "survey": True, "sap_points": 5}, ] @@ -41,14 +41,14 @@ def app(): # 8 Grove Mansions "uprn": 10024087855, "recommendations": [ - {"type": "draught_proofing", "cost": 123, "survey": True, "sap_points": 2}, + {"type": "draught_proofing", "cost": 100, "survey": True, "sap_points": 2}, { - "type": "mixed_glazing", "cost": 12345, "survey": True, + "type": "mixed_glazing", "cost": 7814, "survey": True, "description": "Install double glazing to north facing windows and secondary glazing to the " "remaining windows at the front of the building", "sap_points": 4 }, - {"type": "trickle_vents", "cost": 500, "survey": True}, + {"type": "trickle_vents", "cost": 700, "survey": True}, {"type": "low_energy_lighting", "cost": None, "survey": True, "sap_points": 0}, {"type": "internal_wall_insulation", "cost": None, "survey": True, 'sap_points': 5}, ] @@ -57,14 +57,14 @@ def app(): # 9 Grove Mansions "uprn": 121016128, "recommendations": [ - {"type": "draught_proofing", "cost": 123, "survey": True, "sap_points": 1}, + {"type": "draught_proofing", "cost": 100, "survey": True, "sap_points": 1}, { - "type": "mixed_glazing", "cost": 12345, "survey": True, + "type": "mixed_glazing", "cost": 9740, "survey": True, "description": "Install double glazing to north facing windows and secondary glazing to the " "remaining windows at the front of the building", "sap_points": 3 }, - {"type": "trickle_vents", "cost": 500, "survey": True}, + {"type": "trickle_vents", "cost": 1000, "survey": True}, {"type": "low_energy_lighting", "cost": None, "survey": True, "sap_points": 1}, {"type": "suspended_floor_insulation", "cost": None, "sap_points": 1}, {"type": "internal_wall_insulation", "cost": None, "survey": True, "sap_points": 6}, @@ -75,12 +75,12 @@ def app(): "uprn": 121016124, "recommendations": [ { - "type": "mixed_glazing", "cost": 12345, "survey": True, + "type": "mixed_glazing", "cost": 12662, "survey": True, "description": "Install double glazing to north facing windows and secondary glazing to the " "remaining windows at the front of the building", "sap_points": 5 }, - {"type": "trickle_vents", "cost": 500, "survey": True}, + {"type": "trickle_vents", "cost": 1300, "survey": True}, {"type": "low_energy_lighting", "cost": None, "survey": True, "sap_points": 2}, {"type": "internal_wall_insulation", "cost": None, "survey": True, "sap_points": 8}, ] @@ -89,14 +89,14 @@ def app(): # 14 Grove Mansions "uprn": 121016117, "recommendations": [ - {"type": "draught_proofing", "cost": 123, "survey": True, "sap_points": 1}, + {"type": "draught_proofing", "cost": 100, "survey": True, "sap_points": 1}, { - "type": "mixed_glazing", "cost": 12345, "survey": True, + "type": "mixed_glazing", "cost": 10736, "survey": True, "description": "Install double glazing to north facing windows and secondary glazing to the " "remaining windows at the front of the building", "sap_points": 4 }, - {"type": "trickle_vents", "cost": 500, "survey": True}, + {"type": "trickle_vents", "cost": 1000, "survey": True}, {"type": "low_energy_lighting", "cost": None, "survey": True, "sap_points": 1}, {"type": "internal_wall_insulation", "cost": None, "survey": True, "sap_points": 6}, ] @@ -113,6 +113,7 @@ def app(): ] asset_list = [ + # These are properties where we've done a survey { "uprn": 121016121, "address": "", "postcode": "" }, @@ -131,6 +132,63 @@ def app(): { "uprn": 10024087902, "address": "", "postcode": "" }, + # These properties we just model with default data + # Flat 1 + { + "uprn": 121016113, "address": "", "postcode": "" + }, + # Flat 10 + { + "uprn": 121016114, "address": "", "postcode": "" + }, + # Flat 11 + { + "uprn": 121016115, "address": "", "postcode": "" + }, + # Flat 12 + { + "uprn": 121016116, "address": "", "postcode": "" + }, + # Flat 15 + { + "uprn": 121016118, "address": "", "postcode": "" + }, + # Flat 16 + { + "uprn": 121016119, "address": "", "postcode": "" + }, + # Flat 17 + { + "address": "Flat 17 Grove Mansions", "postcode": "SW4 9SL" + }, + # Flat 18 + { + "uprn": 10024087901, "address": "", "postcode": "" + }, + # Flat 3 + { + "uprn": 121016122, "address": "", "postcode": "" + }, + # Flat 4 + { + "uprn": 121016123, "address": "", "postcode": "" + }, + # Flat 6 + { + "uprn": 121016125, "address": "", "postcode": "" + }, + # Flat 7 + { + "uprn": 10024087854, "address": "", "postcode": "" + }, + # Flat 7A + { + "uprn": 10024087840, "address": "", "postcode": "" + }, + # Flat 8A + { + "uprn": 10024087841, "address": "", "postcode": "" + }, ] asset_list = pd.DataFrame(asset_list) @@ -162,7 +220,7 @@ def app(): "patches_file_path": "", "non_invasive_recommendations_file_path": non_invasive_recommendations_filename, "inclusions": [ - "draught_proofing", "mixed_glazing", "trickle_vents", "low_energy_lighting", + "draught_proofing", "mixed_glazing", "trickle_vents", "low_energy_lighting", "windows" ], "budget": None, "scenario_name": "Quick wins - do now while tenanted", @@ -185,7 +243,9 @@ def app(): "trickle_vents", "low_energy_lighting", "suspended_floor_insulation", - "internal_wall_insulation" + "internal_wall_insulation", + "room_roof_insulation", + "windows" ], "budget": None, "scenario_name": "Do when void", diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 8deed75a..908a409a 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -168,9 +168,8 @@ class Costs: # https://www.greenmatch.co.uk/windows/double-glazing/cost SASH_WINDOW_INFLATION_FACTOR = 1.5 - # Typically, secondary glazing can be installed for 25% of the cost of double glazed windows - to be conservative, - # we scale the cost by half - SECONDARY_GLAZING_SCALING_FACTOR = 0.5 + # Based on relative costs from SCIS + SECONDARY_GLAZING_SCALING_FACTOR = 0.85 def __init__(self, property_instance): """ diff --git a/recommendations/LightingRecommendations.py b/recommendations/LightingRecommendations.py index b9456f8d..92394c11 100644 --- a/recommendations/LightingRecommendations.py +++ b/recommendations/LightingRecommendations.py @@ -9,6 +9,9 @@ class LightingRecommendations: # worth more than 2 points, but this is unlikely in the context of other upgrades that can be made to the property SAP_LIMIT = 2 + # If more than 50% of the lighting is LEDs already, the limit is 1 SAP point + SAP_LOWER_LIMIT = 1 + def __init__(self, property_instance: Property, materials: List): """ :param property_instance: Instance of the Property class, for the home associated to property_id @@ -128,6 +131,7 @@ class LightingRecommendations: "description_simulation": { "lighting-energy-eff": "Very Good", "lighting-description": "Low energy lighting in all fixed outlets", + "low-energy-lighting": 100, }, **cost_result, "survey": leds_recommendation_config.get("survey", False) diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index 45498a8a..d2c1db1b 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -102,6 +102,7 @@ class Recommendations: property_recommendations = [] phase = 0 measures = self.find_included_measures() + non_invasive_recommendation_types = [r["type"] for r in self.property_instance.non_invasive_recommendations] # Building Fabric self.wall_recomender.recommend(phase=phase, measures=measures) @@ -149,7 +150,8 @@ class Recommendations: property_recommendations.append(self.floor_recommender.recommendations) phase += 1 - if "windows" in measures: + if "windows" in measures and "mixed_glazing" not in non_invasive_recommendation_types: + # If we have a mixed glazing recommendation, we prioritise this over the windows recommendation self.windows_recommender.recommend(phase=phase) if self.windows_recommender.recommendation: property_recommendations.append(self.windows_recommender.recommendation) @@ -529,7 +531,12 @@ class Recommendations: # For the moment, we cap the number of SAP points that can be achieved by LEDs at 2 if rec["type"] == "low_energy_lighting": - property_phase_impact["sap"] = min(property_phase_impact["sap"], LightingRecommendations.SAP_LIMIT) + + if property_instance.data["low-energy-lighting"] < 50: + lighting_sap_limit = LightingRecommendations.SAP_LIMIT + else: + lighting_sap_limit = LightingRecommendations.SAP_LOWER_LIMIT + property_phase_impact["sap"] = min(property_phase_impact["sap"], lighting_sap_limit) property_phase_impact["carbon"] = min( property_phase_impact["carbon"], rec["co2_equivalent_savings"] ) diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py index fe027371..6635dd51 100644 --- a/recommendations/RoofRecommendations.py +++ b/recommendations/RoofRecommendations.py @@ -155,23 +155,44 @@ class RoofRecommendations: ) self.estimated_u_value = u_value - if (u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE) or ( - "loft_insulation" not in measures + if (u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE) or all( + m not in measures for m in MEASURE_MAP["roof_insulation"] ): # The Roof is already compliant return - if (self.property.roof["is_pitched"] and "loft_insulation" in measures) or ( - self.property.roof["is_flat"] and "flat_roof_insulation" in measures + non_invasive_recommendations = self.property.non_invasive_recommendations + + # We firstly handle non-intrusive recommendations, which may override the normal roof insulation recommendations + if ("loft_insulation" in [x["type"] for x in non_invasive_recommendations]) or ( + self.property.roof["is_pitched"] and "loft_insulation" in measures ): - insulation_thickness = 0 if "loft_insulation" not in measures else self.insulation_thickness - self.recommend_roof_insulation(u_value, insulation_thickness, self.property.roof, phase) + self.recommend_roof_insulation( + u_value=u_value, + insulation_thickness=self.insulation_thickness, + phase=phase, + is_flat=False, + is_pitched=True + ) + return + + if ( + (self.property.roof["is_flat"] and "flat_roof_insulation" in measures) or + "flat_roof_insulation" in [x["type"] for x in non_invasive_recommendations] + ): + self.recommend_roof_insulation( + u_value=u_value, + insulation_thickness=0, + phase=phase, + is_flat=True, + is_pitched=False + ) return # There are cases where the property might have a room roof as the second roof, but we have a recommendation for # it, so we allow this override if self.property.roof["is_roof_room"] and ("room_roof_insulation" in measures) or ( - "room_roof_insulation" in [x["type"] for x in self.property.non_invasive_recommendations] + "room_roof_insulation" in [x["type"] for x in non_invasive_recommendations] ): self.recommend_room_roof_insulation(u_value, phase) return @@ -195,7 +216,7 @@ class RoofRecommendations: raise ValueError("Invalid material type") def recommend_roof_insulation( - self, u_value, insulation_thickness, roof, phase + self, u_value, insulation_thickness, phase, is_pitched, is_flat ): """ @@ -218,7 +239,9 @@ class RoofRecommendations: :param u_value: U-value of the roof before any retrofit measures have been installed :param insulation_thickness: Existing Insulation thickness of the loft - :param roof: dictionary describing the make-up of the roof + :param phase: Phase of the recommendation + :param is_pitched: Is the roof pitched + :param is_flat: Is the roof flat :return: """ @@ -226,10 +249,10 @@ class RoofRecommendations: # Therefore the price is 100mm + whatever thickness is rolled on top, rolled at a 90 degree angle # from the base layer - if roof["is_pitched"]: + if is_pitched: insulation_materials = self.loft_insulation_materials non_insulation_materials = self.loft_non_insulation_materials - elif roof["is_flat"]: + elif is_flat: insulation_materials = self.flat_roof_insulation_materials non_insulation_materials = self.flat_roof_non_insulation_materials else: @@ -251,7 +274,7 @@ class RoofRecommendations: # Note: This requirement is only for loft insulation if ( (material["depth"] + insulation_thickness) < self.MINIMUM_RECOMMENDED_LOFT_INSULATION - ) and roof["is_pitched"]: + ) and is_pitched: continue part_u_value = r_value_per_mm_to_u_value(material["depth"], material["r_value_per_mm"])