Model/recommendations/recommendation_utils.py
2023-09-21 15:44:09 +01:00

460 lines
17 KiB
Python

import math
from copy import deepcopy
from backend.Property import Property
from statistics import mean
from recommendations.rdsap_tables import (
epc_wall_description_map, wall_uvalues_df, default_wall_thickness, table_s9 as s9, table_s10 as s10,
table_s11 as s11
)
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)
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
def is_diminishing_returns(recommendations, new_u_value, lowest_selected_u_value, diminishing_returns_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 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 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 < diminishing_returns_u_value) and (
new_u_value < lowest_selected_u_value
)
return is_diminishing
def update_lowest_selected_u_value(lowest_selected_u_value, new_u_value):
"""
Utility funciton which holds the logic for how we update the lowest selected u value
:param lowest_selected_u_value: current lowest selected u value, initialised as None
:param new_u_value: new u value to compare against
:return:
"""
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
return lowest_selected_u_value
def get_recommended_part(part, selected_depth, selected_total_cost, quantity, quantity_unit):
"""
Utility function to return a recommended part with the selected depth.
:param part: part to be recommended
:param selected_depth: depth of the selected part
:param selected_total_cost: Total cost of the selected part
:param quantity: Quantity of the selected part
:param quantity_unit: Unit of the quantity
:return:
"""
recommended_part = deepcopy(part)
recommended_part["depths"] = [selected_depth]
recommended_part["estimated_cost"] = selected_total_cost
recommended_part["quantity"] = quantity
recommended_part["quantity_unit"] = quantity_unit
return recommended_part
def get_uvalue_estimate(uvalue_estimates, property: Property, total_floor_area_group_decile):
"""
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:
"""
if not uvalue_estimates:
raise ValueError("No U-value estimate found for the given property - investigate")
# We try and filter on total_floor_area_group_decile
floor_area_filter = [
x for x in uvalue_estimates if
x["total-floor-area_group"] == total_floor_area_group_decile
]
if not floor_area_filter:
# Take a mean of all the u-value estimates
return mean(
[x["median_thermal_transmittance"] for x in uvalue_estimates if x["median_thermal_transmittance"]]
)
# 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_filer = [
x for x in floor_area_filter if
x["number-habitable-rooms"] == property.data["number-habitable-rooms"]
]
if not habitable_rooms_filer:
# Take a mean of all the u-value estimates
return mean(
[x["median_thermal_transmittance"] for x in floor_area_filter if x["median_thermal_transmittance"]]
)
# Try perform a filter on heated rooms
heated_rooms_filter = [
x for x in habitable_rooms_filer if
x["number-heated-rooms"] == property.data["number-heated-rooms"]
]
if not heated_rooms_filter:
# Take a mean of all the u-value estimates
return mean(
[x["median_thermal_transmittance"] for x in habitable_rooms_filer if x["median_thermal_transmittance"]]
)
return mean(
[x["median_thermal_transmittance"] for x in heated_rooms_filter if x["median_thermal_transmittance"]]
)
def apply_formula_s_5_1_1(is_granite_or_whinstone, is_sandstone_or_limestone, age_band):
"""
As the u-value table in https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf
on page 19, certain u-values as indicated by an "a", should be populated using a formula as defined in section
S.5.1.1
"""
stone_wall_thickness = [x for x in default_wall_thickness if x["type"] == "stone"][0]
thickness = stone_wall_thickness["J_K_L"] if age_band in ["J", "L", "L"] else stone_wall_thickness[age_band]
if is_granite_or_whinstone:
return 3.3 - 0.002 * thickness
if is_sandstone_or_limestone:
return 3 - 0.002 * thickness
raise ValueError("This should only be called when is_granite_or_whinstone or is_sandstone_or_limestone is True")
def get_wall_u_value(clean_description, age_band, is_granite_or_whinstone, is_sandstone_or_limestone):
"""
Given some features about a wall, this function will query the wall u-value table and return the u-value
:param clean_description: Cleaned up description of the wall from the EPC data
:param age_band: age band of the property from the EPC data
:param is_granite_or_whinstone: Boolean indicating if the wall is made of granite or whinstone
:param is_sandstone_or_limestone: Boolean indicating if the wall is made of sandstone or limestone
:return:
"""
mapped_description = epc_wall_description_map[clean_description]
mapped_value = wall_uvalues_df[wall_uvalues_df["Wall_type"] == mapped_description][age_band].values[0]
if mapped_value == "a":
# The rdSap documentation indicateswe should use a formula to calculate the u-value
return float(
apply_formula_s_5_1_1(
is_granite_or_whinstone=is_granite_or_whinstone,
is_sandstone_or_limestone=is_sandstone_or_limestone,
age_band=age_band
)
)
if "b" in mapped_value:
potential_uvalue = float(mapped_value.replace("b", ""))
formula_uvalue = float(apply_formula_s_5_1_1(
is_granite_or_whinstone=is_granite_or_whinstone,
is_sandstone_or_limestone=is_sandstone_or_limestone,
age_band=age_band
))
return min(potential_uvalue, formula_uvalue)
if mapped_value == "s1.1.2":
return None
return float(mapped_value)
def get_u_value_from_s9(thickness, s9, is_loft, is_roof_room, is_thatched):
"""Get the U-value from table S9 based on the insulation thickness."""
if thickness in ["below average", "average", "above average", "none", None] or (
not is_loft and not is_roof_room
):
return None
elif thickness.endswith("+"):
thickness = int(thickness[:-1])
else:
try:
thickness = int(thickness)
except ValueError:
# If thickness is not a valid number (could be a string or None), return None
return None
# Determine the column to refer based on the roof type
column = 'Thatched_roof_U_value_W_m2K' if is_thatched else 'Slates_or_tiles_U_value_W_m2K'
# Get the correct U-value based on the insulation thickness
return s9[s9['Insulation_thickness_mm'] >= thickness][column].iloc[0]
def get_roof_u_value(
insulation_thickness,
has_dwelling_above,
is_loft,
is_roof_room,
is_thatched,
age_band,
is_flat,
is_pitched,
is_at_rafters,
**kwargs
):
"""
Determine the U-value for a roof based on the description dictionary and age band.
We use table s9 is the insulation thickness was measured, otherwise we use table s10.
The methodology for this process can be found in page 23 of the BRE rdsap 2012 document found here:
https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf
Parameters:
insulation_thickness (str): contains description of the insulation thickness - may be missing
has_dwelling_above (bool): Indicates if there is a property above
is_loft (bool): Indicates if ther oof has a loft
is_roof_room (bool): Indicates if there is a room in roof
is_thatched (bool): Indicates if the roof is thatched
is_flat (bool): Indicates if the roof is flat
is_pitched (bool): Indicates if the roof is pitched
is_at_rafters (bool): Indicates if there is insulation at the rafters of the roof
age_band (str): The age band of the property.
s9 (pd.DataFrame): The DataFrame representing table S9.
s10 (pd.DataFrame): The DataFrame representing table S10.
Returns:
float: The determined U-value.
"""
# If there is a dwelling above, the U-value is 0
if has_dwelling_above:
return 0.0
# Step 1: Try to get the U-value from table S9 based on the insulation thickness
# The conditions for using table S9 are:
# - The insulation thickness is known
# - The roof is either a loft or a roof room
# The criteria for using this table is predominately defined by insulation around joists which is predominately
# a feature of lofts and roof rooms
u_value = get_u_value_from_s9(
thickness=insulation_thickness,
s9=s9,
is_loft=is_loft,
is_roof_room=is_roof_room,
is_thatched=is_thatched,
)
if u_value is not None:
return u_value
# Step 2: If the U-value could not be determined from table S9, use table S10
# Define the columns to be used based on the description details
if is_flat:
column = 'Flat_roof'
elif is_thatched:
if is_roof_room:
column = 'Thatched_roof_room_in_roof'
else:
column = 'Thatched_roof'
elif is_roof_room:
column = 'Room_in_roof_slates_or_tiles'
elif is_pitched:
if is_at_rafters:
column = 'Pitched_slates_or_tiles_insulation_at_rafters'
else:
column = 'Pitched_slates_or_tiles_insulation_between_joists_or_unknown'
else:
# Default to pitched roof with insulation between joists or unknown
column = 'Pitched_slates_or_tiles_insulation_between_joists_or_unknown'
# Get the U-value from table S10 based on the age band and the determined column
u_value = s10.loc[s10['Age_band'].str.contains(age_band), column].values[0]
return u_value
def estimate_perimeter(floor_area, num_rooms):
# Compute average room size based on total floor area and number of rooms
avg_room_size = floor_area / num_rooms
# Estimate the side length of a square room with the average room size
avg_room_side_length = math.sqrt(avg_room_size)
# Estimate total side length assuming rooms are lined up in a row
total_side_length = avg_room_side_length * num_rooms
# Assuming the house is a square (which is a very rough approximation), compute the perimeter
perimeter = math.sqrt(total_side_length) * 4
return perimeter
def estimate_perimeter_2_rooms(floor_area):
# Assuming a square layout for the entire floor area to get a first-order approximation of the perimeter
side_length = math.sqrt(floor_area)
# Calculating the perimeter of the square layout
perimeter = 4 * side_length
return perimeter
def calculate_floor_u_value(floor_type, area, perimeter, age_band, wall_type, insulation_thickness=None):
"""
Estimate the u-value of a suspended floor, based on RdSap methodology
Default U-value for UNINSULATED suspended floor, based on RdSAP methodology
https://files.bregroup.com/bre-co-uk-file-library-copy/filelibrary/SAP/2012/RdSAP-9.93/RdSAP_2012_9.93.pdf
w = wall thickness, where these estimates are based on the RD SAP methodology, as in table S3
A = floor area
Exposed perimeter = P
soil type clas thermal conductivity lambda_g = 1.5 W/mK
Rsi = 0.17m^2K/W
Rse = 0.04m^2K/W
Rf = 0.001 * d_ins / 0.035 where d_ins is the insulation thickness in mm
height above external ground h = 0.3m
average wind speed at 10m height v=5m/s
wind sheilding factor fw = 0.05
vantilation factor E = 0.003 m^2/m
U-value of walls to underfloor space Uw = 1.5 W/m^2K
# Calulations for suspended ground floors, example for 5 bedroom house with permiter estimated at
44.36214602563767
1) dg = w + lambda_g x (Rsi + Rse) = 0.5 + 1.5 * (0.17 + 0.04) = 0.615
2) B = 2 * A/P = 2 * 123.0 / 44.36214602563767 = 5.545268253204708
3) Ug = 2 * lambda_g * log(pi * B/dg + 1)/(pi * B + dg) =
2 * 1.5 * log(3.141592653589793 * 5.545268253204708/0.615 + 1) / (3.141592653589793 * 5.545268253204708
+ 0.615) = 0.5619604457160708
4) Ux = (2 * h * Uw /B) + (1450 * E * v * fw/B) = (2 * 0.3 * 1.5 / 5.545268253204708) + (1450 * 0.003 * 5 *
0.05/5.545268253204708) = 0.35841367978030436
5) U = 1/ (2 * Rsi + Rf + 1/(Ug + Ux)) = 1 / (2 * 0.17 + 0 + 1/(0.5619604457160708 + 0.35841367978030436)) =
0.701
"""
# Define constants
lambda_g = 1.5 # thermal conductivity of soil in W/m·K
Rsi = 0.17 # in m²K/W
Rse = 0.04 # in m²K/W
lambda_ins = 0.035 # thermal conductivity of floor insulation in W/m·K
wall_thickness = [x[age_band] for x in default_wall_thickness if x["type"] == wall_type][0] / 1000
if insulation_thickness is None:
insulation_lookup = s11[s11["Age_band"].str.contains(age_band) & s11["Floor_construction"] == floor_type]
if insulation_lookup.empty:
insulation_thickness = 0
else:
insulation_thickness = insulation_lookup["England_Wales"].values[0]
# Calculate Rf for insulated floors
Rf = 0.001 * insulation_thickness / lambda_ins
# Calculate B
B = 2 * area / perimeter
if floor_type == 'solid':
# Calculate dt
dt = wall_thickness + lambda_g * (Rsi + Rf + Rse)
# Calculate U value based on dt and B
if dt < B:
U = 2 * lambda_g * math.log(math.pi * B / dt + 1) / (math.pi * B + dt)
else:
U = lambda_g / (0.457 * B + dt)
elif floor_type == 'suspended':
# Define additional constants for suspended floors
h = 0.3 # height above external ground level in meters
v = 5 # average wind speed at 10 m height in m/s
fw = 0.05 # wind shielding factor
epsilon = 0.003 # ventilation openings per m exposed perimeter in m²/m
Uw = 1.5 # U-value of walls to underfloor space in W/m²K
# Calculate dg
dg = wall_thickness + lambda_g * (Rsi + Rse)
# Calculate Ug and Ux
Ug = 2 * lambda_g * math.log(math.pi * B / dg + 1) / (math.pi * B + dg)
Ux = (2 * h * Uw / B) + (1450 * epsilon * v * fw / B)
# Calculate final U value for suspended floors
if insulation_thickness > 0:
Rf += 0.2 # adding thermal resistance of floor deck
else:
Rf = 0.2 # thermal resistance of uninsulated floor deck
U = 1 / (2 * Rsi + Rf + 1 / (Ug + Ux))
else:
raise ValueError("Invalid floor type. Acceptable values are 'solid' or 'suspended'.")
return round(U, 2) # rounding U value to two decimal places