From ebad3d7dd69a8e8cdf81f9bba8f99a8d9b8b7073 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 20 Dec 2023 15:55:34 +0000 Subject: [PATCH] Implementing recommendation logic and tests --- recommendations/Costs.py | 23 ++-- recommendations/WindowsRecommendations.py | 41 ++++++- .../tests/test_window_recommendations.py | 104 +++++++++++++++++- 3 files changed, 157 insertions(+), 11 deletions(-) diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 239dadd1..24ea0584 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -70,6 +70,10 @@ 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 + def __init__(self, property_instance): """ Initializes the Costs class with a property instance. @@ -726,7 +730,7 @@ class Costs: "labour_cost": labour_costs } - def window_glazing(self, number_of_windows, material): + def window_glazing(self, number_of_windows, material, is_secondary_glazing=False): """ We characterise the jobs to be done for window glazing as the following: 1) Initial Assessment and Measurements: Before removing the existing window, it's essential to assess the @@ -767,17 +771,17 @@ class Costs: For this cost estimation process, we factor in initial assement into the preliminaries - :param number_of_windows: - :return: """ - material_cost = material["material_cost"] * number_of_windows * self.SASH_WINDOW_INFLATION_FACTOR - labour_cost = ( - material["labour_cost"] * number_of_windows * self.SASH_WINDOW_INFLATION_FACTOR * - self.labour_adjustment_factor - ) + material_cost = material["material_cost"] * number_of_windows - subtotal = material_cost + labour_cost + labour_cost = ( + material["labour_cost"] * number_of_windows * self.labour_adjustment_factor + ) + multiplier = self.SECONDARY_GLAZING_SCALING_FACTOR if is_secondary_glazing else ( + self.SASH_WINDOW_INFLATION_FACTOR) + + subtotal = (material_cost + labour_cost) * multiplier contingency_cost = subtotal * self.CONTINGENCY preliminaries_cost = subtotal * self.PRELIMINARIES @@ -790,6 +794,7 @@ class Costs: total_cost = subtotal_before_vat + vat_cost labour_hours = material["labour_hours_per_unit"] * number_of_windows + labour_hours = labour_hours * self.SECONDARY_GLAZING_SCALING_FACTOR if is_secondary_glazing else labour_hours # Assume a team of 2 labour_days = (labour_hours / 8) / 2 diff --git a/recommendations/WindowsRecommendations.py b/recommendations/WindowsRecommendations.py index 474071d9..0d3fb0af 100644 --- a/recommendations/WindowsRecommendations.py +++ b/recommendations/WindowsRecommendations.py @@ -1,9 +1,20 @@ from typing import List + +import numpy as np + from backend.Property import Property from recommendations.Costs import Costs class WindowsRecommendations: + # If the property has existing glazing, we scale down the number of windows that need to be glazed + COVERAGE_MAP = { + # If most of the windows have already been glazed, we assume that 2/3 are glazed and 1/2 are remaining to be + # glazed + "most": 0.33, + # If glazing is partial, we assume 50/50 split between glazed and unglazed + "partial": 0.5 + } def __init__(self, property_instance: Property, materials: List): self.property = property_instance @@ -28,7 +39,14 @@ class WindowsRecommendations: :return: """ + # If the property is in a conservation area or is a listed building, it becomes more difficult to install + # double glazing. Therefore, we don't recommend it. It is still possible but is not practical as it + # requires planning permission and might require a more expensive window type, such as timber. + number_of_windows = self.property.number_of_windows + is_secondary_glazing = self.property.restricted_measures or ( + self.property.windows["glazing_type"] == "secondary" + ) if not number_of_windows: raise ValueError("Number of windows not specified") @@ -36,13 +54,34 @@ class WindowsRecommendations: if self.property.windows["has_glazing"] & (self.property.windows["glazing_coverage"] == "full"): return + # We scale the number of windows based on the proportion of existing glazing + if self.property.data["multi-glaze-proportion"] != "": + n_windows_scalar = 1 - (self.property.data["multi-glaze-proportion"] / 100) + else: + n_windows_scalar = self.COVERAGE_MAP.get(self.property.windows["glazing_coverage"], 1) + + number_of_windows *= n_windows_scalar + number_of_windows = np.ceil(number_of_windows) + # We then price the job based on the number of windows that there are cost_result = self.costs.window_glazing( number_of_windows=number_of_windows, material=self.glazing_material, + is_secondary_glazing=is_secondary_glazing ) - description = None + glazing_type = "secondary glazing" if is_secondary_glazing else "double glazing" + if self.property.windows["glazing_coverage"] in ["partial", "most"]: + description = f"Install {glazing_type} to the remaining windows" + else: + description = f"Install {glazing_type} to all windows" + + if self.property.is_listed: + description += ". Secondary glazing recommended due to listed building status" + elif self.property.is_heritage: + description += ". Secondary glazing recommended due to conservation area status" + elif self.property.in_conservation_area: + description += ". Secondary glazing recommended due to conservation area status" self.recommendation = [ { diff --git a/recommendations/tests/test_window_recommendations.py b/recommendations/tests/test_window_recommendations.py index 1a146b41..fbb67243 100644 --- a/recommendations/tests/test_window_recommendations.py +++ b/recommendations/tests/test_window_recommendations.py @@ -18,7 +18,8 @@ class TestWindowRecommendations: address1='1', epc_client=Mock(), data={ - "county": "Wychavon" + "county": "Wychavon", + "multi-glaze-proportion": 0 } ) property_1.windows = { @@ -33,3 +34,104 @@ class TestWindowRecommendations: assert not recommender.recommendation recommender.recommend() + + assert recommender.recommendation == [ + {'parts': [], 'type': 'window_glazing', 'description': 'Install double glazing to all windows', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': None, 'total': 5721.943248, + 'subtotal': 4768.28604, 'vat': 953.6572080000001, 'contingency': 340.59186, 'preliminaries': 340.59186, + 'material': 1275.75, 'profit': 681.18372, 'labour_hours': 45.5, 'labour_cost': 994.8624, + 'labour_days': 2.84375}] + + def test_partial_double_glazed(self): + """ + For this property, the double glazing is describes as partial, therefore we recommend completion of + double glazing + :return: + """ + + property_2 = Property( + id=1, + postcode='1', + address1='1', + epc_client=Mock(), + data={ + "county": "Wychavon", + "multi-glaze-proportion": 33 + } + ) + property_2.windows = {'original_description': 'Mostly double glazing', 'has_glazing': True, + 'glazing_coverage': 'most', + 'glazing_type': 'double', 'no_data': False} + property_2.number_of_windows = 7 + + recommender2 = WindowsRecommendations(property_instance=property_2, materials=materials) + + assert not recommender2.recommendation + + recommender2.recommend() + + assert recommender2.recommendation == [ + {'parts': [], 'type': 'window_glazing', 'description': 'Install double glazing to the remaining windows', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': None, 'total': 4087.10232, + 'subtotal': 3405.9186, 'vat': 681.18372, 'contingency': 243.2799, 'preliminaries': 243.2799, + 'material': 911.25, 'profit': 486.5598, 'labour_hours': 32.5, 'labour_cost': 710.6160000000001, + 'labour_days': 2.03125}] + + def test_fully_double_glazed(self): + """ + This property has full double glazing so we shouldn't recommend anything + :return: + """ + + property_3 = Property( + id=1, + postcode='1', + address1='1', + epc_client=Mock(), + data={ + "county": "Wychavon", + "multi-glaze-proportion": 80 + } + ) + property_3.windows = {'original_description': 'Fully double glazed', 'has_glazing': True, + 'glazing_coverage': 'full', + 'glazing_type': 'double', 'no_data': False} + property_3.number_of_windows = 7 + + recommender3 = WindowsRecommendations(property_instance=property_3, materials=materials) + + assert not recommender3.recommendation + + recommender3.recommend() + + assert not recommender3.recommendation + + def test_fully_secondary_glazed(self): + property_4 = Property( + id=1, + postcode='1', + address1='1', + epc_client=Mock(), + data={ + "county": "Wychavon", + "multi-glaze-proportion": 100 + } + ) + property_4.windows = {'original_description': 'Full secondary glazing', 'has_glazing': True, + 'glazing_coverage': 'full', + 'glazing_type': 'secondary', 'no_data': False} + property_4.number_of_windows = 7 + + recommender4 = WindowsRecommendations(property_instance=property_4, materials=materials) + + assert not recommender4.recommendation + + recommender4.recommend() + + assert not recommender4.recommendation + + def test_partial_secondary_glazing(self): + pass + + def test_single_glazed_restricted_measures(self): + pass