Merge pull request #264 from Hestia-Homes/windows-recommendations

Windows recommendations
This commit is contained in:
KhalimCK 2023-12-21 15:59:08 +00:00 committed by GitHub
commit 75bb414471
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 777 additions and 30 deletions

View file

@ -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"]),
)

View file

@ -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"

View file

@ -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()

View file

@ -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")

View file

@ -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
}

View file

@ -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"
}
]
}

View file

@ -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(

View file

@ -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()

View file

@ -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

View file

@ -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},

View file

@ -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>":
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>":
# 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

View file

@ -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)

View file

@ -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
}

View file

@ -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)

View file

@ -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
}
]

View file

@ -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

View file

@ -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.'}
]

View file

@ -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"] = ""

View file

@ -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}
]

View file

@ -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}"

View file

@ -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}]