diff --git a/backend/Property.py b/backend/Property.py
index bb545248..41696c37 100644
--- a/backend/Property.py
+++ b/backend/Property.py
@@ -14,7 +14,7 @@ from epc_api.client import EpcClient
from BaseUtility import Definitions
from recommendations.rdsap_tables import england_wales_age_band_lookup, FLOOR_LEVEL_MAP
from recommendations.recommendation_utils import (
- estimate_perimeter, get_wall_type, estimate_external_wall_area, esimtate_pitched_roof_area
+ estimate_perimeter, get_wall_type, estimate_external_wall_area, esimtate_pitched_roof_area, estimate_windows
)
ENVIRONMENT = os.environ.get('ENVIRONMENT', 'dev')
@@ -87,6 +87,7 @@ class Property(Definitions):
self.insulation_floor_area = None
self.number_lighting_outlets = None
self.floor_level = None
+ self.number_of_windows = None
self.current_adjusted_energy = None
self.expected_adjusted_energy = None
@@ -332,6 +333,7 @@ class Property(Definitions):
self.set_wall_type()
self.set_floor_type()
self.set_floor_level()
+ self.set_windows_count()
def set_age_band(self):
"""
@@ -850,3 +852,18 @@ class Property(Definitions):
"""
self.current_adjusted_energy = current_adjusted_energy
self.expected_adjusted_energy = expected_adjusted_energy
+
+ def set_windows_count(self):
+ """
+ Using the estimate_windows function, this method will set the number of windows in the property
+ :return:
+ """
+
+ self.number_of_windows = estimate_windows(
+ property_type=self.data["property-type"],
+ built_form=self.data["built-form"],
+ construction_age_band=self.construction_age_band,
+ floor_area=self.floor_area,
+ number_habitable_rooms=self.number_of_rooms,
+ extension_count=float(self.data["extension-count"]),
+ )
diff --git a/backend/app/db/models/materials.py b/backend/app/db/models/materials.py
index f887fc25..97085d7a 100644
--- a/backend/app/db/models/materials.py
+++ b/backend/app/db/models/materials.py
@@ -18,6 +18,7 @@ class MaterialType(enum.Enum):
exposed_floor_insulation = "exposed_floor_insulation"
flat_roof_insulation = "flat_roof_insulation"
room_roof_insulation = "room_roof_insulation"
+ windows_glazing = "windows_glazing"
iwi_wall_demolition = "iwi_wall_demolition"
iwi_vapour_barrier = "iwi_vapour_barrier"
diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py
index 7a6df2da..a284e50b 100644
--- a/backend/app/plan/router.py
+++ b/backend/app/plan/router.py
@@ -61,6 +61,7 @@ async def trigger_plan(body: PlanTriggerRequest):
logger.info("Getting the inputs")
epc_client = EpcClient(auth_token=get_settings().EPC_AUTH_TOKEN)
plan_input = read_csv_from_s3(bucket_name=get_settings().PLAN_TRIGGER_BUCKET, filepath=body.trigger_file_path)
+
uprn_filenames = read_dataframe_from_s3_parquet(
bucket_name=get_settings().DATA_BUCKET, file_key="spatial/filename_meta.parquet"
)
@@ -117,9 +118,6 @@ async def trigger_plan(body: PlanTriggerRequest):
logger.info("Getting components and epc recommendations")
- # 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
-
recommendations = {}
recommendations_scoring_data = []
property_scoring_data = {}
@@ -546,9 +544,7 @@ async def trigger_plan(body: PlanTriggerRequest):
valuations = PropertyValuation.estimate(property_instance=p, target_epc=new_epc)
- property_valuation_increases.append(
- valuations["average_increased_value"] - valuations["current_value"]
- )
+ property_valuation_increases.append(valuations["average_increase"])
# Commit the session after each batch
session.commit()
diff --git a/backend/app/plan/utils.py b/backend/app/plan/utils.py
index 7aba99c9..b82be297 100644
--- a/backend/app/plan/utils.py
+++ b/backend/app/plan/utils.py
@@ -175,11 +175,31 @@ def create_recommendation_scoring_data(
scoring_dict["LOW_ENERGY_LIGHTING_ENDING"] = 100
scoring_dict["LIGHTING_ENERGY_EFF_STARTING"] = "Very Good"
+ if recommendation["type"] == "windows_glazing":
+ scoring_dict["MULTI_GLAZE_PROPORTION_ENDING"] = 100
+ scoring_dict["WINDOWS_ENERGY_EFF_ENDING"] = "Average"
+
+ is_secondary_glazing = recommendation["is_secondary_glazing"]
+
+ if scoring_dict["glazing_type_ENDING"] == "multiple":
+ pass
+ elif scoring_dict["glazing_type_ENDING"] == "single":
+ scoring_dict["glazing_type_ENDING"] = "secondary" if is_secondary_glazing else "double"
+ elif scoring_dict["glazing_type_ENDING"] == "double":
+ scoring_dict["glazing_type_ENDING"] = "multiple" if is_secondary_glazing else "double"
+ elif scoring_dict["glazing_type_ENDING"] == "secondary":
+ scoring_dict["glazing_type_ENDING"] = "secondary" if is_secondary_glazing else "multiple"
+ elif scoring_dict["glazing_type_ENDING"] in ["triple", "high performance"]:
+ scoring_dict["glazing_type_ENDING"] = "multiple"
+ else:
+ raise ValueError("Invalid glazing type - 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"
+ "solid_floor_insulation", "suspended_floor_insulation", "exposed_floor_insulation",
+ "windows_glazing"
]:
raise NotImplementedError("Implement me")
diff --git a/backend/ml_models/Valuation.py b/backend/ml_models/Valuation.py
index f5a7e2bb..cdbbe698 100644
--- a/backend/ml_models/Valuation.py
+++ b/backend/ml_models/Valuation.py
@@ -93,7 +93,13 @@ class PropertyValuation:
value = cls.UPRN_VALUE_LOOKUP.get(property_instance.uprn)
if not value:
- raise ValueError("Have not implemented valuation for this property")
+ return {
+ "current_value": None,
+ "lower_bound_increased_value": None,
+ "upper_bound_increased_value": None,
+ "average_increased_value": None,
+ "average_increase": None
+ }
current_epc = property_instance.data["current-energy-rating"]
# We get the spectrum of ratings between the current and target EPC
@@ -119,4 +125,5 @@ class PropertyValuation:
"lower_bound_increased_value": value * (1 + min_increase),
"upper_bound_increased_value": value * (1 + max_increase),
"average_increased_value": value * (1 + avg_increase),
+ "average_increase": value * (1 + avg_increase) - value
}
diff --git a/backend/tests/test_property.py b/backend/tests/test_property.py
index 871c9291..0113d690 100644
--- a/backend/tests/test_property.py
+++ b/backend/tests/test_property.py
@@ -37,7 +37,8 @@ mock_epc_response = {
"floor-height": 2.5,
"total-floor-area": 100,
"construction-age-band": "England and Wales: 1967-1975",
- "floor-description": "Floor Description"
+ "floor-description": "Floor Description",
+ "floor-level": "Ground"
},
{
"lmk-key": 2,
@@ -68,7 +69,8 @@ mock_epc_response = {
"floor-height": 2.5,
"total-floor-area": 100,
"construction-age-band": "England and Wales: 1967-1975",
- "floor-description": "Floor Description"
+ "floor-description": "Floor Description",
+ "floor-level": "Ground"
}
]
}
@@ -100,7 +102,8 @@ mock_epc_response_dupe = {
"floor-height": 2.5,
"total-floor-area": 100,
"construction-age-band": "England and Wales: 1967-1975",
- "floor-description": "Floor Description"
+ "floor-description": "Floor Description",
+ "floor-level": "Ground"
},
{
"lmk-key": 2,
@@ -128,7 +131,8 @@ mock_epc_response_dupe = {
"floor-height": 2.5,
"total-floor-area": 100,
"construction-age-band": "England and Wales: 1967-1975",
- "floor-description": "Floor Description"
+ "floor-description": "Floor Description",
+ "floor-level": "Ground"
},
{
"lmk-key": 3,
@@ -156,7 +160,8 @@ mock_epc_response_dupe = {
"floor-height": 2.5,
"total-floor-area": 100,
"construction-age-band": "England and Wales: 1967-1975",
- "floor-description": "Floor Description"
+ "floor-description": "Floor Description",
+ "floor-level": "Ground"
}
]
}
diff --git a/etl/costs/app.py b/etl/costs/app.py
index 4d53ce28..30eff735 100644
--- a/etl/costs/app.py
+++ b/etl/costs/app.py
@@ -75,6 +75,7 @@ def app():
ewi_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="external_wall_insulation", header=0)
lel_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="low_energy_lighting", header=0)
flat_roof_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="flat_roof_insulation", header=0)
+ window_costs = pd.read_excel(DATA_DIRECTORY, sheet_name="window_glazing", header=0)
# Form a single table to be uploaded
costs = pd.concat(
diff --git a/etl/epc/property_change_app.py b/etl/epc/property_change_app.py
index 4f49f6da..f8c293a1 100644
--- a/etl/epc/property_change_app.py
+++ b/etl/epc/property_change_app.py
@@ -631,6 +631,19 @@ def app():
file_key="sap_change_model/all_equal_rows.parquet",
)
+ from utils.s3 import read_dataframe_from_s3_parquet
+ dataset = read_dataframe_from_s3_parquet(
+ bucket_name="retrofit-data-dev",
+ file_key="sap_change_model/dataset_test.parquet",
+ )
+
+ z = dataset[dataset["CONSTITUENCY"].isin(["E14000707", "E14000909"])]
+ z["CONSTITUENCY"].value_counts()
+
+ z[z["CONSTITUENCY"] == "E14000909"]["UPRN"].sample(1)
+
+ self.data[self.data["UPRN"] == "100030549358"]
+
if __name__ == "__main__":
app()
diff --git a/etl/epc_clean/epc_attributes/WindowAttributes.py b/etl/epc_clean/epc_attributes/WindowAttributes.py
index e962cd31..ce0b156a 100644
--- a/etl/epc_clean/epc_attributes/WindowAttributes.py
+++ b/etl/epc_clean/epc_attributes/WindowAttributes.py
@@ -52,7 +52,7 @@ class WindowAttributes(Definitions):
raise ValueError('Invalid description')
def process(self) -> Dict[str, Union[str, bool]]:
- result: Dict[str, Union[str, bool]] = {
+ result: Dict[str, Union[str, bool, None]] = {
"has_glazing": False,
"glazing_coverage": None,
"glazing_type": None,
@@ -80,7 +80,11 @@ class WindowAttributes(Definitions):
break
# If we didn't find any coverage or type, we assume full coverage
- if not result["glazing_coverage"]:
+ if (not result["glazing_coverage"]) & (result["glazing_type"] != "single"):
result["glazing_coverage"] = "full"
+ # We reset some values if the glazing is single
+ if result["glazing_type"] == "single":
+ result["has_glazing"] = False
+
return result
diff --git a/etl/epc_clean/tests/test_data/test_window_attributes_cases.py b/etl/epc_clean/tests/test_data/test_window_attributes_cases.py
index 1eeeee21..f01ccba9 100644
--- a/etl/epc_clean/tests/test_data/test_window_attributes_cases.py
+++ b/etl/epc_clean/tests/test_data/test_window_attributes_cases.py
@@ -30,7 +30,8 @@ windows_cases = [
'glazing_type': 'triple', 'no_data': False},
{'original_description': 'Gwydrau triphlyg rhannol', 'has_glazing': True, 'glazing_coverage': 'partial',
'glazing_type': 'triple', 'no_data': False},
- {'original_description': 'Single glazed', 'has_glazing': True, 'glazing_coverage': 'full', 'glazing_type': 'single',
+ {'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': None,
+ 'glazing_type': 'single',
'no_data': False},
{'original_description': 'Some double glazing', 'has_glazing': True, 'glazing_coverage': 'partial',
'glazing_type': 'double', 'no_data': False},
@@ -46,7 +47,8 @@ windows_cases = [
'glazing_type': 'double', 'no_data': False},
{'original_description': 'Gwydrau dwbl gan mwyaf', 'has_glazing': True, 'glazing_coverage': 'most',
'glazing_type': 'double', 'no_data': False},
- {'original_description': 'Gwydrau sengl', 'has_glazing': True, 'glazing_coverage': 'full', 'glazing_type': 'single',
+ {'original_description': 'Gwydrau sengl', 'has_glazing': False, 'glazing_coverage': None,
+ 'glazing_type': 'single',
'no_data': False},
{'original_description': 'Ffenestri perfformiad uchel', 'has_glazing': True, 'glazing_coverage': 'full',
'glazing_type': 'high performance', 'no_data': False},
diff --git a/etl/epc_clean/tests/test_roof_attributes.py b/etl/epc_clean/tests/test_roof_attributes.py
index b0663a3e..481beedc 100644
--- a/etl/epc_clean/tests/test_roof_attributes.py
+++ b/etl/epc_clean/tests/test_roof_attributes.py
@@ -3,12 +3,13 @@ from pathlib import Path
from etl.epc_clean.tests.test_data.test_roof_attributes_cases import clean_roof_test_cases
from etl.epc_clean.epc_attributes.RoofAttributes import RoofAttributes
+
# For local testing
-if __file__ == "":
- input_data_path = Path("./model_data/tests/test_data/EpcClean_inputs.obj")
-else:
- current_file_path = Path(__file__)
- input_data_path = current_file_path.parent / 'test_data' / 'EpcClean_inputs.obj'
+# if __file__ == "":
+# input_data_path = Path("./model_data/tests/test_data/EpcClean_inputs.obj")
+# else:
+# current_file_path = Path(__file__)
+# input_data_path = current_file_path.parent / 'test_data' / 'EpcClean_inputs.obj'
class TestRoofAttributes:
@@ -88,7 +89,12 @@ class TestRoofAttributes:
def test_clean_roof_no_description(self):
roof = RoofAttributes('').process()
- assert roof == {}
+ assert roof == {
+ 'thermal_transmittance': False, 'thermal_transmittance_unit': False, 'is_pitched': False,
+ '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': False,
+ 'insulation_thickness': False
+ }
def test_clean_roof_edge_cases(self):
# Insulation thickness edge case
diff --git a/etl/testing_data/windows_portfolio.py b/etl/testing_data/windows_portfolio.py
new file mode 100644
index 00000000..356d107e
--- /dev/null
+++ b/etl/testing_data/windows_portfolio.py
@@ -0,0 +1,43 @@
+"""
+This script will create an input csv for the recommendation engine and upload it to S3, which can be used for
+testing
+"""
+import pandas as pd
+from utils.s3 import save_csv_to_s3
+
+USER_ID = 8
+PORTFOLIO_ID = 56
+
+
+def app():
+ """
+ This portfolio is for testing windows recommendations
+ :return:
+ """
+
+ test_file = pd.DataFrame(
+ [
+ {"address": "3 Church Terrace", "postcode": "LE13 0PW", "Notes": None},
+ {"address": "3, Main Street, Redmile", "postcode": "NG13 0GA", "Notes": None},
+ {"address": "Manor House, Kennel Lane, Reepham", "postcode": "LN3 4DZ", "Notes": None},
+ {"address": "13 Main Street", "postcode": "LE14 2JU", "Notes": None},
+ {"address": "8 The Crescent, Coston Road, Buckminster", "postcode": "NG33 5SF", "Notes": None},
+ ]
+ )
+
+ # Store the data in s3
+ filename = f"{USER_ID}/{PORTFOLIO_ID}/windows_portfolio_inputs.csv"
+ save_csv_to_s3(
+ dataframe=test_file,
+ bucket_name="retrofit-plan-inputs-dev",
+ file_name=filename
+ )
+
+ body = {
+ "portfolio_id": str(PORTFOLIO_ID),
+ "housing_type": "Social",
+ "goal": "Increase EPC",
+ "goal_value": "A",
+ "trigger_file_path": filename
+ }
+ print(body)
diff --git a/recommendations/Costs.py b/recommendations/Costs.py
index 0d9031b2..24ea0584 100644
--- a/recommendations/Costs.py
+++ b/recommendations/Costs.py
@@ -64,6 +64,16 @@ class Costs:
VAT_RATE = 0.2
PROFIT_MARGIN = 0.2
+ # Based on this greenmatch article, on average, a Sash window is around 50% more expensive than a casement window.
+ # Therefore, for a conservative cost estimate, and allowance for a more premium window type, we inflate the material
+ # cost of the windows to allow for a sash window type
+ # 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.
@@ -719,3 +729,85 @@ class Costs:
"labour_days": labour_days,
"labour_cost": labour_costs
}
+
+ 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
+ condition of the window frame and opening. Precise measurements are taken to ensure the new double glazed
+ windows fit perfectly.
+
+ 2) Remove the Existing Window: This involves carefully dismantling and removing the old single glazed window. It
+ requires skill to avoid damaging the surrounding wall and the window frame (if it's to be reused).
+
+ 3) Dispose of the Existing Window: The old window, especially if it's a single glazed unit, needs to be
+ disposed of responsibly. Glass and other materials should be recycled where possible.
+
+ 4) Surface Preparation: The window opening might need some preparation, especially if there's damage or if
+ adjustments are needed to accommodate the new window. This can include repairing or replacing parts of the
+ window frame, sealing gaps, and ensuring the opening is level and square.
+
+ 5) Install the Window Frame (if new frames are used): In many cases, double glazed windows come with their
+ frames. These need to be installed securely into the window opening. This process involves aligning, leveling,
+ and fixing the frame in place.
+
+ 6) Install the Window Sill: If a new window sill is required, it is installed at this stage. It needs to be
+ correctly aligned with the frame and securely attached.
+
+ 7) Install the Double Glazed Glass Units: The glass units are carefully inserted into the frame. This step
+ requires precision to ensure a snug fit without causing stress on the glass, which could lead to cracking or
+ breaking.
+
+ 8) Sealing and Weatherproofing: After the glass units are in place, it's crucial to seal around the frame and
+ between the glass and frame to ensure there are no drafts and that the installation is weather-tight. This
+ typically involves applying silicone sealant or other appropriate sealing materials.
+
+ 9) Finishing Touches: This includes any cosmetic work, such as trimming, painting, or staining the frame and
+ sill to match the rest of the property. It might also involve cleaning up any mess created during the
+ installation.
+
+ 10) Inspection and Testing: Finally, the new windows should be inspected to ensure they open, close, and lock
+ correctly. This is also a good time to check for any gaps or issues with the sealing.
+
+ For this cost estimation process, we factor in initial assement into the preliminaries
+
+ """
+
+ material_cost = material["material_cost"] * number_of_windows
+
+ 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
+ profit_cost = subtotal * self.PROFIT_MARGIN
+
+ subtotal_before_vat = subtotal + contingency_cost + preliminaries_cost + profit_cost
+
+ vat_cost = subtotal_before_vat * self.VAT_RATE
+
+ 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
+
+ return {
+ "total": total_cost,
+ "subtotal": subtotal_before_vat,
+ "vat": vat_cost,
+ "contingency": contingency_cost,
+ "preliminaries": preliminaries_cost,
+ "material": material_cost,
+ "profit": profit_cost,
+ "labour_hours": labour_hours,
+ "labour_cost": labour_cost,
+ "labour_days": labour_days
+ }
diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py
index 60cdb696..4b54cb52 100644
--- a/recommendations/Recommendations.py
+++ b/recommendations/Recommendations.py
@@ -6,6 +6,7 @@ from recommendations.RoofRecommendations import RoofRecommendations
from recommendations.VentilationRecommendations import VentilationRecommendations
from recommendations.FireplaceRecommendations import FireplaceRecommendations
from recommendations.LightingRecommendations import LightingRecommendations
+from recommendations.WindowsRecommendations import WindowsRecommendations
from backend.ml_models.AnnualBillSavings import AnnualBillSavings
@@ -35,6 +36,7 @@ class Recommendations:
)
self.fireplace_recommender = FireplaceRecommendations(property_instance=property_instance)
self.lighting_recommender = LightingRecommendations(property_instance=property_instance, materials=materials)
+ self.windows_recommender = WindowsRecommendations(property_instance=property_instance, materials=materials)
def recommend(self):
@@ -77,6 +79,11 @@ class Recommendations:
if self.lighting_recommender.recommendation:
property_recommendations.append(self.lighting_recommender.recommendation)
+ # Windows recommendations
+ self.windows_recommender.recommend()
+ if self.windows_recommender.recommendation:
+ property_recommendations.append(self.windows_recommender.recommendation)
+
# We insert temporary ids into the recommendations which is important for the optimiser later
property_recommendations = self.insert_temp_recommendation_id(property_recommendations)
diff --git a/recommendations/WindowsRecommendations.py b/recommendations/WindowsRecommendations.py
new file mode 100644
index 00000000..b6ecd099
--- /dev/null
+++ b/recommendations/WindowsRecommendations.py
@@ -0,0 +1,97 @@
+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
+ self.costs = Costs(self.property)
+
+ self.recommendation = []
+
+ self.glazing_material = [
+ material for material in materials if material["type"] == "windows_glazing"
+ ]
+
+ if len(self.glazing_material) != 1:
+ raise ValueError("There should only be one window glazing material")
+ self.glazing_material = self.glazing_material[0]
+
+ def recommend(self):
+ """
+ This method will recommend the best possible glazing options for a property.
+
+ In order to do this, we need to estimate the number of windows that the home has. This information will be
+ stored in the property object, under property.number_of_windows
+ :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")
+
+ 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 - (int(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
+ )
+
+ 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 herigate building status"
+ elif self.property.in_conservation_area:
+ description += ". Secondary glazing recommended due to conservation area status"
+
+ self.recommendation = [
+ {
+ "parts": [],
+ "type": "windows_glazing",
+ "description": description,
+ "starting_u_value": None,
+ "new_u_value": None,
+ "sap_points": None,
+ **cost_result,
+ "is_secondary_glazing": is_secondary_glazing
+ }
+ ]
diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py
index 100ecb15..7cfe023e 100644
--- a/recommendations/recommendation_utils.py
+++ b/recommendations/recommendation_utils.py
@@ -652,3 +652,64 @@ def esimtate_pitched_roof_area(floor_area: float, floor_height: float) -> float:
area = 2 * (slope * wall_width)
return area
+
+
+def estimate_windows(
+ property_type, built_form, construction_age_band, floor_area, number_habitable_rooms, extension_count
+):
+ # Base window count based on habitable rooms
+ window_count = number_habitable_rooms
+
+ # Additional windows for non-habitable rooms (e.g., kitchen, bathroom)
+ # Assuming most houses will have at least one kitchen and one bathroom
+ # Scale non-habitable windows with the number of habitable rooms
+ non_habitable_base = 2 # Base for kitchen and bathroom
+ extra_non_habitable = max(0, (number_habitable_rooms - 3) // 2) # Extra for large houses
+ window_count += non_habitable_base + extra_non_habitable
+
+ # Adjustments based on built form and property type
+ if property_type in ["House", "Bungalow"] and built_form in ["Semi-Detached", "Detached"]:
+ built_form_lookup = {
+ "Semi-Detached": 3,
+ "Detached": 4,
+ }
+ else:
+ # For Flats and Maisonettes, adjustments might be less
+ built_form_lookup = {
+ "Mid-Terrace": 0,
+ "End-Terrace": 1,
+ "Semi-Detached": 1,
+ "Detached": 2,
+ }
+ window_count += built_form_lookup.get(built_form, 0)
+
+ # Adjust for floor area (larger floor area might indicate more rooms/windows)
+ if floor_area < 85: # Small to medium properties
+ # Standard window count likely sufficient
+ pass
+ elif 85 <= floor_area <= 120: # Medium to large properties
+ # More rooms or larger rooms likely, potentially more windows
+ window_count += 1
+ elif floor_area > 120: # Very large properties
+ # Likely to have significantly more or larger rooms
+ window_count += 2
+
+ # Adjust for construction age band
+ if construction_age_band in ["England and Wales: before 1900", "England and Wales: 1900-1929"]:
+ # Older houses with smaller, more numerous windows
+ window_count += 1
+
+ # Adjust for extensions (each extension might add windows)
+ window_count += extension_count
+
+ # Adjustments for specific property types
+ if property_type in ["Flat", "Maisontte"]:
+ # Flats might have fewer windows due to shared walls
+ # Maisonettes might follow a similar pattern to flats or small houses
+ window_count -= 1
+
+ # Ensure window count is not negative
+ if window_count < 0:
+ raise ValueError("Window count cannot be negative.")
+
+ return window_count
diff --git a/recommendations/tests/test_data/materials.py b/recommendations/tests/test_data/materials.py
index d7241be5..187d1401 100644
--- a/recommendations/tests/test_data/materials.py
+++ b/recommendations/tests/test_data/materials.py
@@ -942,8 +942,24 @@ materials = [
'https://www.hamuch.com/cost/led-spot-light#:~:text=It%20costs%20an%20average%20of,'
'will%20drive%20up%20the%20cost.',
'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907), 'is_active': True, 'prime_material_cost': None,
- 'material_cost': 20.0, 'labour_cost': 46.0, 'labour_hours_per_unit': 0.8, 'plant_cost': 0.0, 'total_cost': 66.0,
+ 'material_cost': 20.0, 'labour_cost': 15.0, 'labour_hours_per_unit': 0.8, 'plant_cost': 0.0, 'total_cost': 66.0,
'notes': 'We estimate the unit economics from the checkatrade article. We assume that the average job consists '
'of installing 6 lights based on the hamuch article. We use the median value of 400 for a job of 6 '
- 'lights'}
+ 'lights'},
+ {'id': 1235, 'type': 'windows_glazing',
+ 'description': 'uPVC windows; Profile 22 or other equal and approved; reinforced where appropriate with '
+ 'aluminium alloy; in refurbishment work, including standard ironmongery; sills and factory glazed '
+ 'with low-e 24 mm double glazing; removing existing windows and fixing new in position; including '
+ 'lugs plugged and screwed to brickwork or blockwork; Casement/fixed light; including vents; '
+ 'e.p.d.m. glazing gaskets and weather seals; 1770 mm × 1200 mm; ref P312WW',
+ 'depth': 0.0, 'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': None,
+ 'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None, 'thermal_conductivity_unit': None,
+ 'link': 'SPONs',
+ 'created_at': datetime.datetime(2023, 11, 28, 22, 49, 12, 244907),
+ 'is_active': True, 'prime_material_cost': 176.55,
+ 'material_cost': 182.25, 'labour_cost': 163.36, 'labour_hours_per_unit': 6.5, 'plant_cost': 0.0,
+ 'total_cost': 345.61,
+ 'notes': 'This is the cost of removal of existing windows and installation of new windows. This is a casement '
+ 'style window, which is the most common but also the cheapest style. In the cost estimation framework, '
+ 'we can inflate prices for different finishes, to be conservative on price.'}
]
diff --git a/recommendations/tests/test_floor_recommendations.py b/recommendations/tests/test_floor_recommendations.py
index 43e98d60..700d33d3 100644
--- a/recommendations/tests/test_floor_recommendations.py
+++ b/recommendations/tests/test_floor_recommendations.py
@@ -68,6 +68,7 @@ class TestFloorRecommendations:
input_properties[2].wall_type = "solid brick"
input_properties[2].floor_type = "suspended"
input_properties[2].number_of_floors = 1
+ input_properties[2].floor_level = 0
recommender = FloorRecommendations(property_instance=input_properties[2], materials=materials)
assert recommender.estimated_u_value is None
@@ -93,6 +94,8 @@ class TestFloorRecommendations:
input_properties[3].insulation_floor_area = 100
input_properties[3].insulation_wall_area = 100
input_properties[3].number_of_floors = 1
+ input_properties[3].floor_level = 0
+
recommender = FloorRecommendations(property_instance=input_properties[3], materials=materials)
assert recommender.estimated_u_value is None
recommender.recommend()
@@ -114,6 +117,7 @@ class TestFloorRecommendations:
input_properties[4].wall_type = "solid brick"
input_properties[4].floor_type = "solid"
input_properties[4].number_of_floors = 1
+ input_properties[4].floor_level = 0
# In this case, we have no county, so in this case, it should yse the local-authority-label if possible
input_properties[4].data["county"] = ""
diff --git a/recommendations/tests/test_lighting_recommendations.py b/recommendations/tests/test_lighting_recommendations.py
index 06d1163f..5a4545eb 100644
--- a/recommendations/tests/test_lighting_recommendations.py
+++ b/recommendations/tests/test_lighting_recommendations.py
@@ -40,8 +40,7 @@ class TestLightingRecommendations:
assert lr.recommendation == [
{'parts': [], 'type': 'low_energy_lighting', 'description': 'Install low energy lighting in 4 outlets',
- 'starting_u_value': None, 'new_u_value': None, 'sap_points': 0.4, 'total': 458.976, 'subtotal': 382.48,
- 'vat': 76.49600000000001, 'contingency': 27.320000000000007, 'preliminaries': 27.320000000000007,
- 'material': 80.0, 'profit': 54.640000000000015, 'labour_hours': 3.2, 'labour_days': 0.4,
- 'labour_cost': 193.20000000000002}
+ 'starting_u_value': None, 'new_u_value': None, 'sap_points': 0.4, 'total': 240.24,
+ 'subtotal': 200.20000000000002, 'vat': 40.040000000000006, 'contingency': 14.3, 'preliminaries': 14.3,
+ 'material': 80.0, 'profit': 28.6, 'labour_hours': 3.2, 'labour_days': 0.4, 'labour_cost': 63.0}
]
diff --git a/recommendations/tests/test_recommendation_utils.py b/recommendations/tests/test_recommendation_utils.py
index aefc70b0..559a51b2 100644
--- a/recommendations/tests/test_recommendation_utils.py
+++ b/recommendations/tests/test_recommendation_utils.py
@@ -427,3 +427,106 @@ def test_external_wall_area():
for num_floors, floor_height, perimeter, built_form, expected in test_cases:
result = recommendation_utils.estimate_external_wall_area(num_floors, floor_height, perimeter, built_form)
assert result == expected, f"Test failed for {built_form}: Expected {expected}, got {result}"
+
+
+def test_estimate_windows():
+ # Based on data from an EPR that has 4 windows
+ windows_case_1 = recommendation_utils.estimate_windows(
+ property_type="Flat",
+ built_form="Semi-Detached",
+ construction_age_band="England and Wales: 1976-1982",
+ floor_area=37,
+ number_habitable_rooms=2,
+ extension_count=0,
+ )
+
+ assert windows_case_1 == 4, f"Expected 4 windows, got {windows_case_1}"
+
+ # Based on data from an EPR that has 7 winows, however two of the windows were very small, having areas of
+ # 0.21m^2 and 0.3m^2 respectively. We see 6 as a reasonable estimate for the number of windows
+ windows_case_2 = recommendation_utils.estimate_windows(
+ property_type="House",
+ built_form="Mid-Terrace",
+ construction_age_band="England and Wales: 1950-1966",
+ floor_area=69,
+ number_habitable_rooms=4,
+ extension_count=0,
+ )
+
+ assert windows_case_2 == 6, f"Expected 6 windows, got {windows_case_2}"
+
+ # Based on data from an EPR on a bungalow, that has 6 windows. Two of the windows are small, both have a 0.4m^2 area
+ # and so 5 windows is an acceptable estimate
+ windows_case_3 = recommendation_utils.estimate_windows(
+ property_type="Bungalow",
+ built_form="Mid-Terrace",
+ construction_age_band="England and Wales: 1967-1975",
+ floor_area=56,
+ number_habitable_rooms=3,
+ extension_count=0,
+ )
+
+ assert windows_case_3 == 5, f"Expected 5 windows, got {windows_case_3}"
+
+ # Based on data from an EPR on a end terrace house that has 8 windows. One of the windows is very small, with an
+ # area of 0.25 m^2 and so 7 windows is an acceptable estimate
+ windows_case_4 = recommendation_utils.estimate_windows(
+ property_type="House",
+ built_form="End-Terrace",
+ construction_age_band="England and Wales: 1967-1975",
+ floor_area=77.28,
+ number_habitable_rooms=4,
+ extension_count=0,
+ )
+
+ assert windows_case_4 == 7, f"Expected 7 windows, got {windows_case_4}"
+
+ # Based on data from an EPR on a Semi-detatched house that has 11 windows based on the associated condition report
+ # Right now, we estimate 12 windows for this property
+ windows_case_5 = recommendation_utils.estimate_windows(
+ property_type="House",
+ built_form="Semi-Detached",
+ construction_age_band="England and Wales: 1950-1966",
+ floor_area=88.4,
+ number_habitable_rooms=5,
+ extension_count=0,
+ )
+
+ assert windows_case_5 == 12, f"Expected 12 windows, got {windows_case_5}"
+
+ # Based on Khalim's flat which has 3 windows. There is no construction age band on the EPC. The windows are large
+ # so an estimate of 5 windows is a reasonable estimate
+ windows_case_6 = recommendation_utils.estimate_windows(
+ property_type="Flat",
+ built_form="",
+ construction_age_band="",
+ floor_area=100,
+ number_habitable_rooms=3,
+ extension_count=0,
+ )
+
+ assert windows_case_6 == 5, f"Expected 5 windows, got {windows_case_6}"
+
+ # Based on an EPR semi detatched house though we don't have the exact number of windows. We estimate 10
+ windows_case_7 = recommendation_utils.estimate_windows(
+ property_type="House",
+ built_form="Semi-Detached",
+ construction_age_band="England and Wales: 1967-1975",
+ floor_area=85,
+ number_habitable_rooms=4,
+ extension_count=0,
+ )
+
+ assert windows_case_7 == 10, f"Expected 10 windows, got {windows_case_7}"
+
+ # Base on Khalim's parents flat
+ windows_case_8 = recommendation_utils.estimate_windows(
+ property_type="Flat",
+ built_form="End-Terrace",
+ construction_age_band="",
+ floor_area=50,
+ number_habitable_rooms=3,
+ extension_count=0,
+ )
+
+ assert windows_case_8 == 5, f"Expected 5 windows, got {windows_case_8}"
diff --git a/recommendations/tests/test_window_recommendations.py b/recommendations/tests/test_window_recommendations.py
new file mode 100644
index 00000000..359dd962
--- /dev/null
+++ b/recommendations/tests/test_window_recommendations.py
@@ -0,0 +1,253 @@
+from recommendations.WindowsRecommendations import WindowsRecommendations
+from backend.Property import Property
+from unittest.mock import Mock
+from recommendations.tests.test_data.materials import materials
+
+
+class TestWindowRecommendations:
+
+ def test_fully_single_glazed(self):
+ """
+ For this property, we expect all windows to be single glazed and should recommend full double glazing
+ :return:
+ """
+
+ property_1 = Property(
+ id=1,
+ postcode='1',
+ address1='1',
+ epc_client=Mock(),
+ data={
+ "county": "Wychavon",
+ "multi-glaze-proportion": 0
+ }
+ )
+ property_1.windows = {
+ 'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': 'full',
+ 'glazing_type': 'single',
+ 'no_data': False
+ }
+ property_1.number_of_windows = 7
+
+ recommender = WindowsRecommendations(property_instance=property_1, materials=materials)
+
+ assert not recommender.recommendation
+
+ recommender.recommend()
+
+ assert recommender.recommendation == [
+ {'parts': [], 'type': 'windows_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': 'windows_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):
+ property_5 = Property(
+ id=1,
+ postcode='1',
+ address1='1',
+ epc_client=Mock(),
+ data={
+ "county": "Wychavon",
+ "multi-glaze-proportion": 50
+ }
+ )
+ property_5.windows = {'original_description': 'Partial secondary glazing', 'has_glazing': True,
+ 'glazing_coverage': 'partial',
+ 'glazing_type': 'secondary', 'no_data': False}
+ property_5.number_of_windows = 7
+
+ recommender5 = WindowsRecommendations(property_instance=property_5, materials=materials)
+
+ assert not recommender5.recommendation
+
+ recommender5.recommend()
+
+ assert recommender5.recommendation == [
+ {'parts': [], 'type': 'windows_glazing',
+ 'description': 'Install secondary glazing to the remaining windows',
+ 'starting_u_value': None, 'new_u_value': None, 'sap_points': None, 'total': 1089.893952,
+ 'subtotal': 908.24496, 'vat': 181.64899200000002, 'contingency': 64.87464, 'preliminaries': 64.87464,
+ 'material': 729.0, 'profit': 129.74928, 'labour_hours': 13.0, 'labour_cost': 568.4928,
+ 'labour_days': 0.8125}]
+
+ def test_single_glazed_restricted_measures(self):
+ property_6 = Property(
+ id=1,
+ postcode='1',
+ address1='1',
+ epc_client=Mock(),
+ data={
+ "county": "Wychavon",
+ "multi-glaze-proportion": 0
+ }
+ )
+ property_6.windows = {'original_description': 'Single glazed', 'has_glazing': False, 'glazing_coverage': None,
+ 'glazing_type': 'single',
+ 'no_data': False}
+ property_6.number_of_windows = 7
+ property_6.restricted_measures = True
+ property_6.is_heritage = True
+
+ recommender6 = WindowsRecommendations(property_instance=property_6, materials=materials)
+
+ assert not recommender6.recommendation
+
+ recommender6.recommend()
+
+ assert recommender6.recommendation == [
+ {'parts': [], 'type': 'windows_glazing',
+ 'description': 'Install secondary glazing to all windows. Secondary '
+ 'glazing recommended due to herigate building status',
+ 'starting_u_value': None, 'new_u_value': None, 'sap_points': None,
+ 'total': 1907.314416, 'subtotal': 1589.42868, 'vat': 317.885736,
+ 'contingency': 113.53062, 'preliminaries': 113.53062,
+ 'material': 1275.75, 'profit': 227.06124, 'labour_hours': 22.75,
+ 'labour_cost': 994.8624, 'labour_days': 1.421875}
+ ]
+
+ def test_full_triple_glazed(self):
+ property_7 = Property(
+ id=1,
+ postcode='1',
+ address1='1',
+ epc_client=Mock(),
+ data={
+ "county": "Wychavon",
+ "multi-glaze-proportion": 100
+ }
+ )
+ property_7.windows = {'original_description': 'Fully triple glazed', 'has_glazing': True,
+ 'glazing_coverage': 'full',
+ 'glazing_type': 'triple', 'no_data': False}
+ property_7.number_of_windows = 7
+
+ recommender7 = WindowsRecommendations(property_instance=property_7, materials=materials)
+
+ assert not recommender7.recommendation
+
+ recommender7.recommend()
+
+ assert not recommender7.recommendation
+
+ def test_partial_triple_glazed(self):
+ """
+ We should just recommend double glazing to the remaining windows, since it's a cheaper option
+ """
+
+ property_8 = Property(
+ id=1,
+ postcode='1',
+ address1='1',
+ epc_client=Mock(),
+ data={
+ "county": "Wychavon",
+ "multi-glaze-proportion": 80
+ }
+ )
+ property_8.windows = {'original_description': 'Mostly triple glazing', 'has_glazing': True,
+ 'glazing_coverage': 'most',
+ 'glazing_type': 'triple', 'no_data': False}
+ property_8.number_of_windows = 7
+
+ recommender8 = WindowsRecommendations(property_instance=property_8, materials=materials)
+
+ assert not recommender8.recommendation
+
+ recommender8.recommend()
+
+ assert recommender8.recommendation == [
+ {'parts': [], 'type': 'windows_glazing', 'description': 'Install double glazing to the remaining windows',
+ 'starting_u_value': None, 'new_u_value': None, 'sap_points': None, 'total': 1634.840928,
+ 'subtotal': 1362.36744, 'vat': 272.47348800000003, 'contingency': 97.31196, 'preliminaries': 97.31196,
+ 'material': 364.5, 'profit': 194.62392, 'labour_hours': 13.0, 'labour_cost': 284.2464,
+ 'labour_days': 0.8125}]