From 0ad3f099026854c4cc28d3040d3fcee3bee68ef8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 26 Jan 2026 12:25:51 +0000 Subject: [PATCH] refactoring roof recommendations logic --- asset_list/app.py | 10 +- etl/find_my_epc/RetrieveFindMyEpc.py | 2 +- recommendations/Costs.py | 31 +++- recommendations/RoofRecommendations.py | 154 +++++++++++++++--- .../tests/test_roof_recommendations.py | 40 +++++ 5 files changed, 199 insertions(+), 38 deletions(-) diff --git a/asset_list/app.py b/asset_list/app.py index 21a06a07..01906c5f 100644 --- a/asset_list/app.py +++ b/asset_list/app.py @@ -60,7 +60,7 @@ def app(): """ data_folder = "/Users/khalimconn-kowlessar/Documents/hestia/Customers/Hackney" - data_filename = "Domna SHF Wave 3.xlsx" + data_filename = "Domna SHF Wave 3 (3).xlsx" sheet_name = "Domna Wave 3" postcode_column = 'Postcode' address1_column = "Address 1" @@ -68,11 +68,11 @@ def app(): fulladdress_column = None address_cols_to_concat = ["Address 1"] missing_postcodes_method = None - landlord_year_built = None + landlord_year_built = "Construction Years" landlord_os_uprn = "UPRN" - landlord_property_type = None - landlord_built_form = None - landlord_wall_construction = None + landlord_property_type = "Type" + landlord_built_form = "Attachment" + landlord_wall_construction = "Wall type" landlord_roof_construction = None landlord_heating_system = None landlord_existing_pv = None diff --git a/etl/find_my_epc/RetrieveFindMyEpc.py b/etl/find_my_epc/RetrieveFindMyEpc.py index cf6659f9..82215443 100644 --- a/etl/find_my_epc/RetrieveFindMyEpc.py +++ b/etl/find_my_epc/RetrieveFindMyEpc.py @@ -665,7 +665,7 @@ class RetrieveFindMyEpc: ], "Change heating to gas condensing boiler": ["boiler_upgrade"], "Fan assisted storage heaters and dual immersion cylinder": ["high_heat_retention_storage_heaters"], - "Flat roof or sloping ceiling insulation": ["flat_roof_insulation"], + "Flat roof or sloping ceiling insulation": ["flat_roof_insulation", "sloping_ceiling_insulation"], "Heating controls (room thermostat)": [ "roomstat_programmer_trvs", "time_temperature_zone_control" ], diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 60b1d8a2..3a65312e 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -160,6 +160,13 @@ class Costs: "low_energy_lighting": 0.26, "high_heat_retention_storage_heaters": 0.1, "windows_glazing": 0.15, + "boiler_upgrade": 0.26, + "time_and_temperature_zone_control": 0.1, + "roomstat_programmer_trvs": 0.1, + "room_roof_insulation": 0.26, + "heater_removal": 0.1, + "sealing_open_fireplace": 0.1, + "mechanical_ventilation": 0.26 } # Preliminaries are a percentage of the total cost of the work and covers the cost of site-specific costs @@ -664,10 +671,12 @@ class Costs: subtotal_before_vat = total_cost / (1 + self.VAT_RATE) vat = total_cost - subtotal_before_vat + contingency_rate = self.CONTINGENCIES["roomstat_programmer_trvs"] + return { "total": total_cost, - "contingency": total_cost * self.CONTINGENCY, - "contingency_rate": self.CONTINGENCY, + "contingency": total_cost * contingency_rate, + "contingency_rate": contingency_rate, "subtotal": subtotal_before_vat, "vat": vat, "labour_hours": labour_hours, @@ -698,10 +707,12 @@ class Costs: labour_days = np.ceil(labour_hours / 8) + contingency_rate = self.CONTINGENCIES["time_and_temperature_zone_control"] + return { "total": total_cost, - "contingency": total_cost * self.CONTINGENCY, - "contingency_rate": self.CONTINGENCY, + "contingency": total_cost * contingency_rate, + "contingency_rate": contingency_rate, "subtotal": subtotal_before_vat, "vat": vat, "labour_hours": labour_hours, @@ -752,10 +763,12 @@ class Costs: subtotal_before_vat = removal_cost total_cost = subtotal_before_vat + vat + contingency_rate = self.CONTINGENCIES["heater_removal"] + return { "total": total_cost, - "contingency": total_cost * self.CONTINGENCY, - "contingency_rate": self.CONTINGENCY, + "contingency": total_cost * contingency_rate, + "contingency_rate": contingency_rate, "subtotal": subtotal_before_vat, "vat": vat, "labour_hours": removal_labour_hours, @@ -858,10 +871,12 @@ class Costs: subtotal_before_vat += system_change_cost_before_vat vat += system_change_vat + contingency_rate = self.CONTINGENCIES["boiler_upgrade"] + return { "total": total_cost, - "contingency": total_cost * self.CONTINGENCY, - "contingency_rate": self.CONTINGENCY, + "contingency": total_cost * contingency_rate, + "contingency_rate": contingency_rate, "subtotal": subtotal_before_vat, "vat": vat, "labour_hours": labour_hours, diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py index 7f7c334e..1d6fe06c 100644 --- a/recommendations/RoofRecommendations.py +++ b/recommendations/RoofRecommendations.py @@ -137,41 +137,127 @@ class RoofRecommendations: """ pass - def recommend(self, phase, measures=None, default_u_values=False): + @staticmethod + def is_sloping_ceiling_appropriate( + is_pitched: bool, is_loft: bool, is_assumed: bool, has_sloping_ceiling_recommendation: bool, + primary_roof_is_sloped: bool + ) -> bool: + """ + :param is_pitched: Boolean - indicates whether or not the roof is pitched + :param is_loft: Boolean - indicates whether or not the roof is described as a loft + :param is_assumed: Boolean - indiates if the assessment of the roof is assumed or actually confirmed + :param has_sloping_ceiling_recommendation: Boolean - indicates if the property has a sloping ceiling + recommendation + :param primary_roof_is_sloped: Boolean - indicates if the primary room is described a sloped (as opposed to + an extension) + :return: + """ + # We need to check: + # 1) If the property has a pitched roof + # 2) Does it have a recommendation for sloping ceiling + # 3) Is the insulation status NOT assumed + # 4) Is there a sloping ceiling recommendation (this may relate to the primary or secondary roof) + + # If we have a loft primary roof and sloping cei + + # The property is pitched, not a loft, not assumed and has a sloping ceiling rec + if (is_pitched and not is_loft and not is_assumed and has_sloping_ceiling_recommendation and + primary_roof_is_sloped): + return True + + return False + + @staticmethod + def is_loft_insulation_appropriate( + non_invasive_recommendations, measures, is_pitched, is_at_rafters, rir_over_loft + ) -> bool: + """ + Determine if loft insulation is appropriate + :param non_invasive_recommendations: List - list of non-invasive recommendations + :param measures: List - list of measures + :param is_pitched: Boolean - indicates whether or not the roof is pitched + :param is_at_rafters: Boolean - indicates whether or not the loft insulation is at rafters + :param rir_over_loft: Boolean - indicates whether or not there we should be doing RIR insulation + :return: + """ + + has_li_in_measures = "loft_insulation" in measures + has_li_non_invasive_recommendation = any( + x["type"] == "loft_insulation" for x in non_invasive_recommendations + ) + + return has_li_non_invasive_recommendation or ( + is_pitched and has_li_in_measures and not is_at_rafters + ) and not rir_over_loft + + @staticmethod + def is_flat_roof_insulation_appropriate( + is_flat: bool, measures: List, non_invasive_recommendations: List + ) -> bool: + """ + Determine if flat roof insulation is appropriate + :param is_flat: Boolean - indicates whether or not the roof is flat + :param measures: List - list of measures + :param non_invasive_recommendations: List - list of non-invasive recommendations + :return: + """ + + flat_roof_in_measures = "flat_roof_insulation" in measures + flat_roof_non_invasive_rec = has_li_non_invasive_recommendation = any( + x["type"] == "flat_roof_insulation" for x in non_invasive_recommendations + ) + + return (is_flat and flat_roof_in_measures) or flat_roof_non_invasive_rec + + def _does_roof_need_recommendation(self, measures: List | None = None, u_value: float | None = None): + """ + Utility function to recommend which contains the logic to determine whether the roof needs a recommendation + :return: + """ + # If there is a property above, nothing can be done if self.property.roof["has_dwelling_above"]: - return + return False - measures = MEASURE_MAP["roof_insulation"] if measures is None else measures - - u_value = self.property.roof["thermal_transmittance"] - - # If we have a flat roof but we don't have flat roof as a measure, we exit + # If we have a flat roof but not flat roof insulation recommendation if self.property.roof["is_flat"] and "flat_roof_insulation" not in measures: - return + return False - # We check if the roof is already insulated and if so, we exit - - # Building regulations part L recommend installing at least 270mm of insulation, however generally we - # experience diminishing returns in terms of SAP once we go beyond around 150mm of insulation - # This only holds true for pitched roofs. + # Logic to check if we have an already insulated loft if self.is_loft_already_insulated(measures): - return + return False + # Logic to check if we have an insulated flat roof if (self.insulation_thickness >= self.MINIMUM_FLAT_ROOF_ISULATION_MM) and self.property.roof["is_flat"]: - return + return False + # Logic to check if we have an already insulated room in roof if self.is_room_roof_insulated_or_unsuitable(measures): - return + return False if self.property.roof["is_thatched"]: - return + return False - # If we have a u-value and we don't have a non-invasive recommendation, we can't recommend anything if (u_value is not None) and not any( x in MEASURE_MAP["roof_insulation"] for x in [r["type"] for r in self.property.non_invasive_recommendations] ): - # We don't have enough information to provide a recommendation + return False + + def recommend(self, phase: int, measures: List | None = None, default_u_values: bool = False): + """ + Main method to recommend roof insulation measures + :param phase: Integer - phase of the recommendation, determines the order in which recommendations are + applied to the property + :param measures: List - list of measures to consider for recommendation + :param default_u_values: Boolean - whether or not to use default u-values for the recommendations + :return: + """ + + measures = MEASURE_MAP["roof_insulation"] if measures is None else measures + u_value = self.property.roof["thermal_transmittance"] + property_needs_roof_recommendation = self._does_roof_need_recommendation(measures, u_value) + + if not property_needs_roof_recommendation: return u_value = get_roof_u_value( @@ -200,17 +286,37 @@ class RoofRecommendations: # 1) We have an uninsulated loft (assumed) # 2) We have a non-intrusive recommendation for room in roof insulation + is_pitched = self.property.roof["is_pitched"] + is_loft = self.property.roof["is_loft"] + is_assumed = self.property.roof["is_assumed"] + is_at_rafters = self.property.roof["is_at_rafters"] + has_sloping_ceiling_recommendation = any( + x["type"] == "sloping_ceiling_insulation" for x in non_invasive_recommendations + ) + primary_roof_is_sloped = False # TODO + rir_over_loft = ( - self.property.roof["is_pitched"] and + is_pitched and self.property.roof["insulation_thickness"] == "none" and "room_in_roof_insulation" in [x["type"] for x in non_invasive_recommendations] ) + needs_sloping_ceiling = self.is_sloping_ceiling_appropriate( + is_pitched=is_pitched, is_loft=is_loft, is_assumed=is_assumed, + has_sloping_ceiling_recommendation=has_sloping_ceiling_recommendation, + primary_roof_is_sloped=primary_roof_is_sloped + ) + + needs_loft_insulation = self.is_loft_insulation_appropriate( + non_invasive_recommendations=non_invasive_recommendations, measures=measures, + is_pitched=is_pitched, is_at_rafters=is_at_rafters, rir_over_loft=rir_over_loft + ) + + ################################################## + # ~~~~~ Loft Insulation Recommendation Logic ~~~~~ + ################################################## # 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 and - not self.property.roof["is_at_rafters"] - ) and not rir_over_loft: + if needs_loft_insulation: self.recommend_roof_insulation( u_value=u_value, insulation_thickness=self.insulation_thickness, diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py index 2241aeb7..b8cea10b 100644 --- a/recommendations/tests/test_roof_recommendations.py +++ b/recommendations/tests/test_roof_recommendations.py @@ -2,6 +2,7 @@ from backend.Property import Property from recommendations.RoofRecommendations import RoofRecommendations from recommendations.tests.test_data.materials import materials from etl.epc.Record import EPCRecord +import pytest class TestRoofRecommendations: @@ -402,3 +403,42 @@ class TestRoofRecommendations: roof_recommender14.recommend(phase=0) assert not roof_recommender14.recommendations + + # ~~~~~~~~~~~~ Sloping Ceiling Insulation ~~~~~~~~~~~~ + @pytest.mark.parameterize("roof", + [ + ( + # For this example, the roof is pitched, without insulation and the description + # isn't assumed + {'original_description': 'Pitched, no insulation', 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': False, 'is_flat': False, + 'is_thatched': False, + 'is_at_rafters': False, 'is_assumed': False, 'has_dwelling_above': False, + 'is_valid': True, + 'insulation_thickness': 'none'} + ) + ] + ) + def test_sloping_ceiling_valid(self, roof): + # All conditions are met and therefore we should produce a sloping ceiling recommendation + assert RoofRecommendations.is_sloping_ceiling_appropriate( + is_pitched=True, is_loft=False, is_assumed=False, has_sloping_ceiling_recommendation=True + ) + + # One condition not met - we cannot verify + assert not RoofRecommendations.is_sloping_ceiling_appropriate( + is_pitched=True, is_loft=True, is_assumed=False, has_sloping_ceiling_recommendation=True + ) + assert not RoofRecommendations.is_sloping_ceiling_appropriate( + is_pitched=False, is_loft=False, is_assumed=False, has_sloping_ceiling_recommendation=True, + primary_roof_is_sloped=True + ) + assert not RoofRecommendations.is_sloping_ceiling_appropriate( + is_pitched=True, is_loft=False, is_assumed=True, has_sloping_ceiling_recommendation=True, + primary_roof_is_sloped=True + ) + assert not RoofRecommendations.is_sloping_ceiling_appropriate( + is_pitched=True, is_loft=False, is_assumed=True, has_sloping_ceiling_recommendation=True, + primary_roof_is_sloped=True + )