diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 6171ddbf..59e6cc32 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -121,7 +121,7 @@ async def trigger_plan(body: PlanTriggerRequest): # TODO: Move this to a class. We probably want a Recommender class which takes the injects the optimisers # in as a dependency and then the optimisers can take the input measures in as part of the setup() method - + # import pickle # with open("input_properties.pickle", "rb") as f: # input_properties = pickle.load(f) diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py index 06eab302..d364dea9 100644 --- a/recommendations/RoofRecommendations.py +++ b/recommendations/RoofRecommendations.py @@ -18,6 +18,9 @@ class RoofRecommendations: DIMINISHING_RETURNS_U_VALUE = 0.14 + # It is recommended that lofts should have at least 270mm of insulation + MINIMUM_LOFT_ISULATION_MM = 270 + def __init__( self, property_instance: Property, @@ -41,7 +44,7 @@ class RoofRecommendations: # 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 - if insulation_thickness < 270: + if insulation_thickness >= self.MINIMUM_LOFT_ISULATION_MM: return # If we have a u-value already, need to implement this @@ -52,7 +55,7 @@ class RoofRecommendations: if self.property.roof["is_pitched"]: # We recommend loft insulation - self.recommend_loft_insulation(u_value) + self.recommend_loft_insulation(u_value, insulation_thickness) return raise NotImplementedError("Implement me") @@ -61,10 +64,12 @@ class RoofRecommendations: def make_loft_insulation_description(material, depth): return f"Install {depth}{material['depth_unit']} of {material['description']}" - def recommend_loft_insulation(self, u_value): + def recommend_loft_insulation(self, u_value, insulation_thickness): """ This method will recommend which insulation materials to use + :param u_value: U-value of the roof before any retrofit measures have been installed + :param insulation_thickness: Existing Insulation thickness of the loft :return: """ @@ -79,6 +84,10 @@ class RoofRecommendations: for material in loft_insulation_materials: for depth, cost_per_unit in zip(material["depths"], material["cost"]): + # We make sure we hit a depth of 270mm. We should factor in any existing insulation if the + # loft is already partially insulated + if (depth + insulation_thickness) < self.MINIMUM_LOFT_ISULATION_MM: + continue part_u_value = r_value_per_mm_to_u_value(depth, material["r_value_per_mm"]) diff --git a/recommendations/tests/test_roof_recommendations.py b/recommendations/tests/test_roof_recommendations.py index b5507421..8243a35b 100644 --- a/recommendations/tests/test_roof_recommendations.py +++ b/recommendations/tests/test_roof_recommendations.py @@ -13,6 +13,28 @@ loft_insulation_materials = [ } ] +loft_insulation_materials_50mm_existing = [ + { + 'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation', + 'depths': [220, 210], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter', + 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/', + 'is_active': True + } +] + +loft_insulation_materials_150mm_existing = [ + { + 'id': 18, 'type': 'loft_insulation', 'description': 'Iso Spacesaver Mineral Wool insulation', + 'depths': [130, 119], 'depth_unit': 'mm', 'cost': [9, 10], 'cost_unit': 'gbp_sq_meter', + 'r_value_per_mm': 0.022727273, 'r_value_unit': 'square_meter_kelvin_per_watt', + 'thermal_conductivity': 0.044, 'thermal_conductivity_unit': 'watt_per_meter_kelvin', + 'link': 'https://flooringwarehousedirect.co.uk/product/isover-spacesaver-roll-100mm-x-1160mm-x-12-18m-14-13m2/', + 'is_active': True + } +] + class TestRoofRecommendations: @@ -37,3 +59,136 @@ class TestRoofRecommendations: roof_recommender.recommend() assert len(roof_recommender.recommendations) + + def test_loft_insulation_recommendation_50mm_insulation(self): + property_instance2 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) + property_instance2.age_band = "F" + property_instance2.floor_area = 100 + property_instance2.roof = { + 'original_description': 'Pitched, 50mm loft insulation (assumed)', + 'clean_description': 'Pitched, 50mm loft insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, + 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, + 'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' + } + + roof_recommender2 = RoofRecommendations( + property_instance=property_instance2, materials=loft_insulation_materials + ) + + assert not roof_recommender2.recommendations + + roof_recommender2.recommend() + + assert len(roof_recommender2.recommendations) == 1 + + assert roof_recommender2.recommendations[0]["cost"] == 900 + assert roof_recommender2.recommendations[0]["new_u_value"] == 0.14 + assert roof_recommender2.recommendations[0]["starting_u_value"] == 0.68 + + property_instance3 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) + property_instance3.age_band = "F" + property_instance3.floor_area = 100 + property_instance3.roof = { + 'original_description': 'Pitched, 50mm loft insulation (assumed)', + 'clean_description': 'Pitched, 50mm loft insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, + 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, + 'insulation_thickness': '50', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' + } + + roof_recommender3 = RoofRecommendations( + property_instance=property_instance3, materials=loft_insulation_materials_50mm_existing + ) + + assert not roof_recommender3.recommendations + + roof_recommender3.recommend() + + # The 220mm insulation should be selected, not the 210 + assert roof_recommender3.recommendations + assert len(roof_recommender3.recommendations) == 1 + assert roof_recommender3.recommendations[0]["parts"][0]["depths"] == [220] + + def test_loft_insulation_recommendation_150mm_insulation(self): + property_instance4 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) + property_instance4.age_band = "F" + property_instance4.floor_area = 100 + property_instance4.roof = { + 'original_description': 'Pitched, 150mm loft insulation (assumed)', + 'clean_description': 'Pitched, 150mm loft insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, + 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, + 'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' + } + + roof_recommender4 = RoofRecommendations( + property_instance=property_instance4, materials=loft_insulation_materials + ) + + assert not roof_recommender4.recommendations + + roof_recommender4.recommend() + + assert len(roof_recommender4.recommendations) == 1 + + assert roof_recommender4.recommendations[0]["cost"] == 900 + assert roof_recommender4.recommendations[0]["new_u_value"] == 0.11 + assert roof_recommender4.recommendations[0]["starting_u_value"] == 0.3 + + property_instance5 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) + property_instance5.age_band = "F" + property_instance5.floor_area = 100 + property_instance5.roof = { + 'original_description': 'Pitched, 150mm loft insulation (assumed)', + 'clean_description': 'Pitched, 150mm loft insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, + 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, + 'insulation_thickness': '150', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' + } + + roof_recommender5 = RoofRecommendations( + property_instance=property_instance5, materials=loft_insulation_materials_150mm_existing + ) + + assert not roof_recommender5.recommendations + + roof_recommender5.recommend() + + # The 130mm insulation should be selected, not the 110 + assert roof_recommender5.recommendations + assert len(roof_recommender5.recommendations) == 1 + assert roof_recommender5.recommendations[0]["parts"][0]["depths"] == [130] + + def test_loft_insulation_recommendation_270mm_insulation(self): + # We shouldn't recommend anything in this case + property_instance6 = Property(id=0, address1="fake", postcode="fake", epc_client=Mock()) + property_instance6.age_band = "F" + property_instance6.floor_area = 100 + property_instance6.roof = { + 'original_description': 'Pitched, 270mm loft insulation (assumed)', + 'clean_description': 'Pitched, 270mm loft insulation', + 'thermal_transmittance': None, + 'thermal_transmittance_unit': None, + 'is_pitched': True, 'is_roof_room': False, 'is_loft': True, 'is_flat': False, 'is_thatched': False, + 'is_at_rafters': False, 'is_assumed': True, 'has_dwelling_above': False, 'is_valid': True, + 'insulation_thickness': '270', 'roof_thermal_transmittance': None, 'roof_insulation_thickness': 'none' + } + + roof_recommender6 = RoofRecommendations( + property_instance=property_instance6, materials=loft_insulation_materials + ) + + assert not roof_recommender6.recommendations + + roof_recommender6.recommend() + + assert len(roof_recommender6.recommendations) == 0