Model/model_data/recommendations/WallRecommendations.py
Khalim Conn-Kowlessar 9f36efb2a1 Fixed tests
2023-06-23 16:03:40 +01:00

565 lines
23 KiB
Python

import re
import itertools
import math
from model_data.Property import Property
from model_data.ConservationAreaClient import ConservationAreaClient
from model_data.analysis.UvalueEstimations import UvalueEstimations
from model_data.BaseUtility import BaseUtility
import pandas as pd
from copy import deepcopy
external_wall_insulation_parts = [
{
# Example product
# https://insulationgo.co.uk/100mm-rockwool-external-wall-insulation-dual-density-slabs-a1-non-combustible
# -slab-ewi-render-fire/
"type": "external_wall_insulation",
"description": "Mineral Wool External Wall Insulation",
"depths": [30, 50, 70, 80, 90, 100, 150, 200],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": 0.0278,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.036,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.insulationking.co.uk/products/polystyrene-eps70?variant=44156186558759
"type": "external_wall_insulation",
"description": "Expanded Polystyrene External Wall Insulation",
"depths": [25, 50, 100, 125],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": 0.02703,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.037,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.insulationshop.co/20mm_kooltherm_k5_external_wall_kingspan.html
"type": "external_wall_insulation",
"description": "Phenolic Foam External Wall Insulation",
"depths": [20, 50, 100],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": 0.043478260869565216,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.023,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
"type": "external_wall_insulation",
"description": "Polyisocyanurate/Polyurethane Foam External Wall Insulation",
"depths": [],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": None,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": None,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.mikewye.co.uk/product/steico-duo-dry/
"type": "external_wall_insulation",
"description": "Wood Fiber External Wall Insulation",
"depths": [40, 60],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": 0.023255813953488375,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.043,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.thermablok.co.uk/site/wp-content/uploads/2022/09/Thermablok-Aerogel-Insulation-Blanket-TDS-AIS
# -and-Steel-Related-Details.pdf
"type": "external_wall_insulation",
"description": "Aerogel External Wall Insulation",
"depths": [10, 20, 30, 40, 50, 60, 70],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": 0.06666666666666667,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.015,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
"type": "external_wall_insulation",
"description": "Vacuum Insulation Panels External Wall Insulation",
"depths": [45, 60],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": 0.16666666666666666,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.006,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
}
]
internal_wall_insulation_parts = [
{
# Example product
# https://www.insulationshop.co/25mm_polystyrene_insulation_eps_70jablite.html
"type": "internal_wall_insulation",
"description": "Rigid Insulation Boards Internal Wall Insulation",
"depths": [25, 40, 50, 75, 100],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": 0.026315789473684213,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.038,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.rockwool.com/siteassets/rw-uk/downloads/datasheets/flexi.pdf
"type": "internal_wall_insulation",
"description": "Mineral Wool Internal Wall Insulation",
"depths": [140],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": 0.02857142857142857,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.035,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.kingspan.com/gb/en/products/insulation-boards/wall-insulation-boards/kooltherm-k118-insulated
# -plasterboard/
"type": "internal_wall_insulation",
"description": "Insulated Plasterboard Internal Wall Insulation",
"depths": [25, 80],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": 0.02857142857142857,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.019,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
"type": "internal_wall_insulation",
"description": "Reflective Internal Wall Insulation",
"depths": [],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": None,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": None,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
{
# Example product
# https://www.insulationsuperstore.co.uk/product/vacutherm-vacupor-nt-b2-vacuum-insulated-panel-1m-x-600mm-x
# -30mm.html
"type": "internal_wall_insulation",
"description": "Vacuum Insulation Panels Wall Insulation",
"depths": [20, 30],
"depth_unit": "mm",
"cost": None,
"cost_unit": None,
"r_value_per_mm": 0.125,
"r_value_unit": "square_meter_kelvin_per_watt",
"thermal_conductivity": 0.008,
"thermal_conductivity_unit": "watt_per_meter_kelvin"
},
]
wall_parts = external_wall_insulation_parts + internal_wall_insulation_parts
class WallRecommendations(BaseUtility):
YEAR_WALLS_BUILT_WITH_INSULATION = 1990
# After 1930, Solid brick walls became less populate and instead, cavity walls became a
# more popular choice
YEARS_CAVITY_WALLS_BEGAN = 1930
U_VALUE_UNIT = 'w/m-¦k'
# part L building regulations indicate that any rennovations on an existing property's walls should
# achieve a U-value of no higher than 0.3
BUILDING_REGULATIONS_PART_L_MAX_U_VALUE = 0.3
# We don't recommend measures that are too low because it becomes expensive, therefore we aim to avoid
# diminishing returns. This value should be verified with Osmosis (TODO)
DIMINISHING_RETURNS_U_VALUE = 0.25
# Part L regulations indicate that any new build should have walls that achieve a u-value of no higher
# than 0.18.
BUILDING_REGULATIONS_PART_L_NEW_BUILD_MAX_U_VALUE = 0.18
# 0.15 is an often cited diminishing returns value for new builds
NEW_BUILD_DIMINISHING_RETURNS_U_VALUE = 0.15
# Add some error so that if, for example, a new part we recommend provides a u-value of 0.19,
# we still consider it as an option
U_VALUE_ERROR = 0.01
DEFAULT_U_VALUES = {
"solid_brick": 2,
}
def __init__(self, property_instance: Property, uvalue_estimates: UvalueEstimations):
self.property = property_instance
self.uvalue_estimates = uvalue_estimates
# For audit purposes, when estimating u values we'll store it
self.estimated_u_value = None
# Will contains a list of recommended measures
self.recommendations = []
@property
def ewi_valid(self):
"""
This method check available data, to determine if a property is suitable for external wall insulation
"""
# Current logic: If the property is in a conservation area or a flat, it is not suitable for EWI
if (self.property.in_conservation_area in ["in_conversation_area"]) or \
(self.property.data["property-type"].lower() == "flat"):
return False
return True
def recommend(self):
# if building built after 1990 + we're able to identify U-value +
# U-value less than 0.18 and if in or close to a conversation area,
# recommend internal wall insulation as a possible measure
u_value = self.property.walls["thermal_transmittance"]
is_cavity_wall = self.property.walls["is_cavity_wall"]
is_solid_brick = self.property.walls["is_solid_brick"]
insulation_thickness = self.property.walls["insulation_thickness"]
if u_value:
if self.property.walls["thermal_transmittance_unit"] != self.U_VALUE_UNIT:
raise NotImplementedError("Haven't handled the case of other u value units yet")
# TODO: It's worth thinking about this logic because depending on when properties were built,
# they're likely to be of a certain standard. E.g. properties built within a certain time
# period are likely to have cavity walls
# We can't detect it's a cavity wall, but it was built after 1990 so likely built with insulation already
# + it already has a U-value WORSE than the building regulations, so we recommend either internal or
# external wall insulation
if (not is_cavity_wall) and (self.property.year_built >= self.YEAR_WALLS_BUILT_WITH_INSULATION) and (
u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE
):
# Recommend insulation
self.find_insulation(u_value)
return
# We can't detect it's a cavity wall, but it was built after 1990 so likely built with insulation already
# + it already has a U-value better than the building regulations, so we don't need to recommend anything
if (not is_cavity_wall) and (self.property.year_built >= self.YEAR_WALLS_BUILT_WITH_INSULATION) and (
u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE
):
# Recommend nothing
return
raise NotImplementedError("Not implemented yet")
if is_solid_brick:
if insulation_thickness == "none":
# This is an estimated figure based on industry standards
u_value = self.DEFAULT_U_VALUES["solid_brick"]
else:
u_value = self._get_walls_uvalue_estimate()
self.estimated_u_value = u_value
if u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
self.find_insulation(u_value)
return
raise NotImplementedError("Not implemented yet")
def _is_diminishing_returns(self, new_u_value, lowest_selected_u_value):
"""
What are defines diminishing returns?
1) The new u value is lower than the lowest selected u value
2) The new u value is below the diminishing returns threshold
3) We already have some recommendations so there is no need to
insert another recommendation in
"""
# if we don't have anything selected, lowest_selected_u_value will be missing
if lowest_selected_u_value is None:
if self.recommendations:
raise ValueError("Recommendations should be empty - investigate")
# This means that nothing has been selected yet
# the new u value is less than the threshold, however this MIGHT be the only
# solution and so we consider it
return False
# We should already have recommendations
if not self.recommendations:
raise ValueError("Recommendations should not be empty - investigate")
# We already have a solution that is suitable so we want to make sure that
# any new solutin actually has a higher u-value as it will either be
# 1) cheaper
# 2) thinner with a more efficient material
is_diminishing = (new_u_value < self.DIMINISHING_RETURNS_U_VALUE) and (
new_u_value < lowest_selected_u_value
)
return is_diminishing
def find_insulation(self, u_value):
"""
This function contains the logic for finding potential insulation measures for a property, depending
on the parts available and whether the property can have external wall insulation installed
:return:
"""
ewi_parts = [
part for part in wall_parts if part["type"] == "external_wall_insulation"
] if self.ewi_valid else []
iwi_parts = [part for part in wall_parts if part["type"] == "internal_wall_insulation"]
# Recommend external and internal wall insulation separately
lowest_selected_u_value = None
for part in ewi_parts + iwi_parts:
for depth in part["depths"]:
part_u_value = self.r_value_per_mm_to_u_value(depth, part["r_value_per_mm"])
_, new_u_value = self.calculate_u_value_uplift(u_value, part_u_value)
new_u_value = math.ceil(new_u_value * 100.0) / 100.0
# If I have a lowest U value and my new u value is higher than that but lower than the
# diminishing returns threshold, it can be considered
# If I have a lowest U value and my new u value is lower than the lowest value, it's
# further into the diminishing returns threshold and can shouldn't be
if self._is_diminishing_returns(new_u_value, lowest_selected_u_value):
continue
# We allow a small tolerance for error so we don't discount the recommendation entirely
if new_u_value <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
if lowest_selected_u_value is None:
lowest_selected_u_value = new_u_value
if new_u_value <= lowest_selected_u_value:
lowest_selected_u_value = new_u_value
self.recommendations.append(
self._get_recommended_part(part, depth, new_u_value)
)
# We also can recommend both internal and external wall insulation together
# By looping through ewi first, if there is nothing there, that ensures not combinations are tested
for ewi_part in ewi_parts:
for iwi_part in iwi_parts:
for ewi_depth, iwi_depth in itertools.product(ewi_part["depths"], iwi_part["depths"]):
ewi_part_u_value = self.r_value_per_mm_to_u_value(ewi_depth, ewi_part["r_value_per_mm"])
iwi_part_u_value = self.r_value_per_mm_to_u_value(iwi_depth, iwi_part["r_value_per_mm"])
# First calculate the new U-value after applying external wall insulation
_, ewi_new_u_value = self.calculate_u_value_uplift(u_value, ewi_part_u_value)
# Then calculate the new U-value after applying internal wall insulation
_, combined_new_u_value = self.calculate_u_value_uplift(ewi_new_u_value, iwi_part_u_value)
combined_new_u_value = round(combined_new_u_value, 2)
if combined_new_u_value < self.DIMINISHING_RETURNS_U_VALUE:
# We don't recommend an overkill solution
continue
# Check if the combined new U-value meets the requirement
if combined_new_u_value - self.U_VALUE_ERROR <= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
# Here you might want to define a way to add both recommendations together.
# For now, I'm adding them as separate items in the list
recommendation = [
self._get_recommended_part(ewi_part, ewi_depth, combined_new_u_value),
self._get_recommended_part(iwi_part, iwi_depth, combined_new_u_value)
]
self.recommendations.append(recommendation)
self.prune_diminishing_recommendations()
def prune_diminishing_recommendations(self):
# For any recommendations, if we have at least 1 reommendation that does not exhibit diminishing returns
# we trim all others that are beyond the diminishing returns threshold
# We first check if we have any recommendations that are not diminishing returns
not_diminishing_return = [
rec for rec in self.recommendations if rec["new_u_value"] >= self.DIMINISHING_RETURNS_U_VALUE
]
if not_diminishing_return:
self.recommendations = [
rec for rec in self.recommendations if rec["new_u_value"] >= self.DIMINISHING_RETURNS_U_VALUE
]
def _get_walls_uvalue_estimate(self):
"""
Wrapper function which contains the methodology to extract a property's walls u-value estimate
when we don't have a true value and if we can't base our assumption off of the material
:return:
"""
total_floor_area_group_decile = self.uvalue_estimates.classify_decile_newvalues(
decile_boundaries=self.uvalue_estimates.walls_decile_data["decile_boundaries"],
decile_labels=self.uvalue_estimates.walls_decile_data["decile_labels"],
new_values=[float(self.property.data["total-floor-area"])],
)[0]
u_value_estimate = self.uvalue_estimates.walls[
(self.uvalue_estimates.walls["local-authority"] == self.property.data["local-authority"]) &
(self.uvalue_estimates.walls["property-type"] == self.property.data["property-type"]) &
(self.uvalue_estimates.walls["built-form"] == self.property.data["built-form"]) &
(self.uvalue_estimates.walls["walls-energy-eff"] == self.property.data["walls-energy-eff"]) &
(self.uvalue_estimates.walls["walls-env-eff"] == self.property.data["walls-env-eff"]) &
(self.uvalue_estimates.walls["total-floor-area_group"] == total_floor_area_group_decile)
]
if u_value_estimate.empty:
raise ValueError("No U-value estimate found for the given property")
# Because of how spuriously populated the data is for number-habitable-rooms and number-heated-rooms,
# we will try and filter on these to see if we get a result
habitable_rooms_filter = (
self.uvalue_estimates.walls["number-habitable-rooms"] == self.property.data["number-habitable-rooms"]
)
if any(habitable_rooms_filter):
u_value_estimate = u_value_estimate[habitable_rooms_filter]
heated_rooms_filter = (
self.uvalue_estimates.walls["number-heated-rooms"] == self.property.data["number-heated-rooms"]
)
if any(heated_rooms_filter):
u_value_estimate = u_value_estimate[heated_rooms_filter]
# It's possible for us to have multiple rows if we didn't do a habitable/heated rooms filter so we
# average
return u_value_estimate["median_thermal_transmittance"].mean()
@staticmethod
def _get_recommended_part(part, selected_depth, new_u_value):
"""
Utility function to return a recommended part with the selected depth.
:param part:
:param selected_depth:
:param new_u_value:
:return:
"""
recommended_part = deepcopy(part)
recommended_part["depths"] = [selected_depth]
return {
**recommended_part, "new_u_value": new_u_value,
}
@staticmethod
def calculate_u_value_uplift(u_value, insulation_u_value):
"""
Calculates the U-value uplift (improvement) when applying internal wall insulation to a wall.
:param u_value: Float, Starting U-value of the wall (without insulation) in W/m²K.
:param insulation_u_value: Float, U-value of the internal wall insulation in W/m²K.
Returns:
float: U-value uplift (improvement) achieved by applying internal wall insulation in W/m²K.
Raises:
ZeroDivisionError: If either u_value or iwi_u_value is zero.
Notes:
This function assumes 100% coverage of the internal wall insulation and does not account for other factors
such as thermal bridging or the specific configuration of the wall.
"""
inverse_u_value = 1 / u_value
inverse_insulation_u_value = 1 / insulation_u_value
inverse_u_total = inverse_u_value + inverse_insulation_u_value
new_u_value = 1 / inverse_u_total
u_value_uplift = u_value - new_u_value
return u_value_uplift, new_u_value
@staticmethod
def rvalue_per_mm(total_r_value: float, thickness_mm: float) -> float:
"""Return R-value per mm.
Parameters
----------
total_r_value : float
Total R-value (in m2K/W).
thickness_mm : float
Thickness of the material in mm.
Returns
-------
float
R-value per mm.
"""
return total_r_value / thickness_mm
@staticmethod
def r_value_per_mm_to_u_value(depth_mm: int, r_value_per_mm: float):
"""
Converts R-value per mm to U-value in W/m²K.
Parameters
----------
depth_mm : int
Depth of the material in mm.
r_value_per_mm : float
R-value per mm.
Returns
-------
float
U-value in W/m²K.
"""
return 1 / (depth_mm * r_value_per_mm)
@staticmethod
def thermal_conductivity_to_r_value_per_mm(thermal_conductivity: float) -> float:
"""Convert thermal conductivity to R-value per mm.
Parameters
----------
thermal_conductivity : float
Thermal conductivity (in W/mK).
Returns
-------
float
R-value per mm.
"""
# Calculate R-value in m²K/W for 1 meter of the material
r_value_per_meter = 1 / thermal_conductivity
# Convert R-value to R-value per mm
r_value_per_mm = r_value_per_meter / 1000
return r_value_per_mm