mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
944 lines
31 KiB
Python
944 lines
31 KiB
Python
import math
|
|
from datetime import datetime
|
|
from copy import deepcopy
|
|
from typing import Union
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
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,
|
|
table_s12 as s12,
|
|
)
|
|
from recommendations.config import (
|
|
PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION,
|
|
PARTIAL_CAVITY_DESCRIPTIONS,
|
|
)
|
|
|
|
|
|
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, cost_result, quantity, quantity_unit):
|
|
"""
|
|
Utility function to return a recommended part with the selected depth.
|
|
:param part: part to be recommended
|
|
:param cost_result: Total cost of the selected part, as returned by the Cost class
|
|
:param quantity: Quantity of the selected part
|
|
:param quantity_unit: Unit of the quantity
|
|
:return:
|
|
"""
|
|
recommended_part = deepcopy(part)
|
|
recommended_part["quantity"] = quantity
|
|
recommended_part["quantity_unit"] = quantity_unit
|
|
|
|
recommended_part.update(cost_result)
|
|
|
|
return recommended_part
|
|
|
|
|
|
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:
|
|
"""
|
|
|
|
if clean_description in PARTIAL_CAVITY_DESCRIPTIONS:
|
|
# If we have a partial cavity fill, we linearly interpolate the u-value. This isn't necessarily the perfect
|
|
# method and how we do this should be explored, however we want to distinguish between the old
|
|
filled_uvalue = float(
|
|
wall_uvalues_df[wall_uvalues_df["Wall_type"] == "Filled cavity"][
|
|
age_band
|
|
].values[0]
|
|
)
|
|
unfilled_uvalue = float(
|
|
wall_uvalues_df[wall_uvalues_df["Wall_type"] == "Cavity as built"][
|
|
age_band
|
|
].values[0]
|
|
)
|
|
|
|
mapped_value = str(
|
|
unfilled_uvalue
|
|
- (
|
|
PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION
|
|
* (unfilled_uvalue - filled_uvalue)
|
|
)
|
|
)
|
|
else:
|
|
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 pd.isnull(mapped_value) and "Park home" in mapped_description:
|
|
# We don't know enough in this case so we default to 0
|
|
return 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":
|
|
# We don't know enough in this case so we default to 0
|
|
return 0
|
|
|
|
return float(mapped_value)
|
|
|
|
|
|
def extract_thickness(thickness, is_roof_room, is_at_rafters, is_loft, is_flat):
|
|
if is_roof_room or is_at_rafters:
|
|
# TODO: We get None instead of a string none, this should be fixed
|
|
if thickness is None:
|
|
thickness = "none"
|
|
# We re-map the thickness
|
|
thickness_map = {
|
|
"below average": "50",
|
|
"average": "100",
|
|
"above average": "270",
|
|
"none": "0",
|
|
}
|
|
thickness = thickness_map[thickness]
|
|
|
|
if is_flat:
|
|
try:
|
|
thickness = int(thickness)
|
|
return thickness
|
|
except ValueError:
|
|
# If thickness is not a valid number (could be a string or None), return None
|
|
return None
|
|
|
|
if thickness in ["below average", "average", "above average", "none", None] or (
|
|
not is_loft and not is_roof_room and not is_at_rafters
|
|
):
|
|
return None
|
|
elif thickness.endswith("+"):
|
|
thickness = int(thickness[:-1])
|
|
return thickness
|
|
else:
|
|
try:
|
|
thickness = int(thickness)
|
|
return thickness
|
|
except ValueError:
|
|
# If thickness is not a valid number (could be a string or None), return None
|
|
return None
|
|
|
|
|
|
def get_u_value_from_s9(
|
|
thickness, s9, is_loft, is_roof_room, is_thatched, is_at_rafters
|
|
):
|
|
"""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 and not is_at_rafters
|
|
):
|
|
return None
|
|
|
|
if thickness in [0, "0"] and is_loft:
|
|
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"
|
|
)
|
|
|
|
if thickness in [0, "0"] and is_roof_room:
|
|
return s9[pd.isnull(s9["Insulation_thickness_mm"])][column].iloc[0]
|
|
else:
|
|
# 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
|
|
|
|
thickness = extract_thickness(
|
|
thickness=insulation_thickness,
|
|
is_roof_room=is_roof_room,
|
|
is_at_rafters=is_at_rafters,
|
|
is_loft=is_loft,
|
|
is_flat=is_flat
|
|
)
|
|
|
|
# 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=thickness,
|
|
s9=s9,
|
|
is_loft=is_loft,
|
|
is_roof_room=is_roof_room,
|
|
is_thatched=is_thatched,
|
|
is_at_rafters=is_at_rafters,
|
|
)
|
|
|
|
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
|
|
if is_flat and thickness is not None:
|
|
u_value = s10.loc[
|
|
(s10["Insulation_Thickness"] == thickness) |
|
|
s10["Age_band"].str.contains(age_band),
|
|
column
|
|
].values.min()
|
|
else:
|
|
u_value = s10.loc[s10["Age_band"].str.contains(age_band), column].values[0]
|
|
|
|
u_value = float(u_value)
|
|
|
|
# As per the documentation here: https://bregroup.com/documents/d/bre-group/rdsap_2012_9-94-20-09-2019
|
|
# Table s.10
|
|
# "The value from the table applies for unknown and as built. If the roof is known to have more insulation than
|
|
# would normally be expected for the age band, either observed or on the basis of documentary evidence, use the
|
|
# lower of the value in the table and:
|
|
# 50 mm insulation 0.68
|
|
# 100 mm insulation: 0.40
|
|
# 150 mm or more insulation: 0.30"
|
|
if thickness is not None:
|
|
if thickness == 50:
|
|
u_value = min(u_value, 0.68)
|
|
if thickness == 100:
|
|
u_value = min(u_value, 0.40)
|
|
if thickness >= 150:
|
|
u_value = min(u_value, 0.30)
|
|
|
|
return u_value
|
|
|
|
|
|
def estimate_number_of_floors(property_type):
|
|
"""
|
|
Using the property type, we estimate the number of floors in the property
|
|
"""
|
|
|
|
if property_type == "House":
|
|
number_of_floors = 2
|
|
elif property_type in ["Flat", "Bungalow"]:
|
|
number_of_floors = 1
|
|
elif property_type == "Maisonette":
|
|
number_of_floors = 2
|
|
else:
|
|
raise NotImplementedError("Implement me")
|
|
|
|
return number_of_floors
|
|
|
|
|
|
def estimate_perimeter(floor_area, num_rooms):
|
|
"""
|
|
Uses a basic methodology to attempt to estimate perimeter. Works better for
|
|
:param floor_area: floor area of the home
|
|
:param num_rooms: number of rooms in the home
|
|
:return: estimated perimeter
|
|
"""
|
|
if floor_area < 0:
|
|
raise ValueError("Floor area cannot be negative.")
|
|
if num_rooms <= 0:
|
|
raise ValueError("Number of rooms must be greater than zero.")
|
|
# 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
|
|
|
|
# Estimate the length and width of the property assuming it is rectangular
|
|
length = total_side_length / 2
|
|
width = floor_area / length
|
|
|
|
# Compute the perimeter of the property
|
|
perimeter = 2 * (length + width)
|
|
|
|
return perimeter
|
|
|
|
|
|
def get_exposed_floor_uvalue(insulation_thickness_str, age_band):
|
|
"""
|
|
We implement the methodology as defined in section 5.6 and table S12 of the RdSAP document
|
|
:param insulation_thickness_str:
|
|
:return:
|
|
"""
|
|
|
|
unknown_insulation_age_bands = ["A", "B", "C", "D", "E", "F", "G", "H", "I"]
|
|
# As directed by the documentation, if the insulation thickness is not known, we assume it's
|
|
# 50mm for these age bands
|
|
if insulation_thickness_str in ["below average", "average", "above average"] and (
|
|
age_band in unknown_insulation_age_bands
|
|
):
|
|
insulation_thickness = 50
|
|
elif insulation_thickness_str in ["none", None]:
|
|
insulation_thickness = 0
|
|
elif insulation_thickness_str == "below average":
|
|
insulation_thickness = 50
|
|
elif insulation_thickness_str == "average":
|
|
insulation_thickness = 100
|
|
elif insulation_thickness_str == "above average":
|
|
insulation_thickness = 150
|
|
else:
|
|
insulation_thickness = int(insulation_thickness_str.replace("mm", ""))
|
|
|
|
return s12[s12["age_band"] == age_band][
|
|
f"insulation_{insulation_thickness}"
|
|
].values[0]
|
|
|
|
|
|
def get_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
|
|
"""
|
|
|
|
if floor_type == "exposed_floor":
|
|
# In this case, we extract the u-value from table s12
|
|
# See section 5.6 of the RdSAP document for more details
|
|
# https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf
|
|
return get_exposed_floor_uvalue(insulation_thickness, age_band)
|
|
|
|
# Cleans our regularly inputted insulation thickness for usage in this function
|
|
insulation_thickness = extract_insulation_thickness(insulation_thickness)
|
|
|
|
# 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]
|
|
if wall_thickness is None and wall_type == "park home":
|
|
# We don't know enough and likely won't make recommendations
|
|
return 0
|
|
wall_thickness = wall_thickness / 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
|
|
|
|
|
|
def extract_insulation_thickness(insulation_thickness_str):
|
|
"""
|
|
Converts insulation thickness to a float
|
|
:param insulation_thickness_str:
|
|
:return:
|
|
"""
|
|
if insulation_thickness_str in [
|
|
"none",
|
|
"average",
|
|
"below average",
|
|
"above average",
|
|
None,
|
|
]:
|
|
return None
|
|
|
|
if isinstance(insulation_thickness_str, (float, int)):
|
|
return insulation_thickness_str
|
|
|
|
return int(insulation_thickness_str.replace("mm", ""))
|
|
|
|
|
|
def get_wall_type(
|
|
is_cavity_wall,
|
|
is_solid_brick,
|
|
is_granite_or_whinstone,
|
|
is_sandstone_or_limestone,
|
|
is_timber_frame,
|
|
is_cob,
|
|
is_system_built,
|
|
is_park_home,
|
|
**kwargs,
|
|
) -> Union[str, None]:
|
|
"""
|
|
Converts booleans to a string wall type, for querying the wall thickness table
|
|
:return:
|
|
"""
|
|
if is_cavity_wall:
|
|
return "cavity"
|
|
|
|
if is_solid_brick:
|
|
return "solid brick"
|
|
|
|
if is_granite_or_whinstone or is_sandstone_or_limestone:
|
|
return "stone"
|
|
|
|
if is_timber_frame:
|
|
return "timber frame"
|
|
|
|
if is_cob:
|
|
return "cob"
|
|
|
|
if is_system_built:
|
|
return "system build"
|
|
|
|
if is_park_home:
|
|
return "park home"
|
|
|
|
return None
|
|
|
|
|
|
def estimate_external_wall_area(num_floors, floor_height, perimeter, built_form):
|
|
"""
|
|
This method estimates the external wall area based on fundamental assumptions about the home
|
|
|
|
|
|
:param num_floors: Number of floors in the building.
|
|
:param floor_height: Height of one floor in meters.
|
|
:param perimeter: Total perimeter of the building on one floor in meters.
|
|
:param built_form: The built form of the property. This is used to determine the number of exposed walls.
|
|
:return:
|
|
"""
|
|
wall_area_one_floor = perimeter * floor_height
|
|
|
|
total_wall_area = wall_area_one_floor * num_floors
|
|
|
|
number_exposed_walls = {
|
|
"End-Terrace": 3,
|
|
"Mid-Terrace": 2,
|
|
"Semi-Detached": 3,
|
|
"Detached": 4,
|
|
}
|
|
|
|
exposed_wall_area = total_wall_area * (number_exposed_walls.get(built_form, 3) / 4)
|
|
|
|
return exposed_wall_area
|
|
|
|
|
|
def calculate_r_value_per_mm(thickness_mm, thermal_conductivity_w_mK):
|
|
"""
|
|
# Calculate R-value (thermal resistance) using the formula: R = thickness / thermal_conductivity
|
|
# Note: The thickness should be converted to meters for the units to be consistent.
|
|
:param thickness_mm:
|
|
:param thermal_conductivity_w_mK:
|
|
:return:
|
|
"""
|
|
|
|
if thermal_conductivity_w_mK is None:
|
|
return None
|
|
|
|
r_value_m2k_w = (thickness_mm / 1000) / thermal_conductivity_w_mK
|
|
|
|
# Calculate R-value per mm
|
|
r_value_per_mm = r_value_m2k_w / thickness_mm
|
|
|
|
return r_value_per_mm
|
|
|
|
|
|
def convert_thickness_to_numeric(string_thickness, is_pitched, is_flat):
|
|
"""
|
|
Roof insulation thickness could be a string like "None", "300mm+" or a numeric string.
|
|
This function will convert these strings to a number for easy usage
|
|
|
|
we handle loft insulation differently to flat roof or room in roof insulation, since for loft insulation,
|
|
we are presented with an insulation thickness, whereas for the other forms of roof, we are just told whether or not
|
|
the roof is insulated or not.
|
|
|
|
:param string_thickness: string measure of insulation thickness
|
|
:param is_pitched: boolean indicating if the roof is a pitched roof
|
|
:return: integer measure of insulation thickness
|
|
"""
|
|
|
|
if string_thickness is None:
|
|
return 0
|
|
|
|
if is_pitched:
|
|
lookup = {"none": 0, "below average": 50, "average": 100, "above average": 270}
|
|
elif is_flat:
|
|
# For a flat roof, if it's below average, we assume it's 0 and requires a re-roof
|
|
lookup = {"none": 0, "below average": 0, "average": 100, "above average": 150}
|
|
else:
|
|
lookup = {"none": 0, "below average": 100, "average": 270, "above average": 270}
|
|
|
|
mapped = lookup.get(string_thickness)
|
|
|
|
if mapped is not None:
|
|
return mapped
|
|
|
|
if "+" in string_thickness:
|
|
return int(string_thickness.replace("+", ""))
|
|
|
|
return int(string_thickness)
|
|
|
|
|
|
def estimate_pitched_roof_area(floor_area: float) -> float:
|
|
"""
|
|
This function mimics the methodology for calculating floor area in Elmhurst, so that we can simulate the outcomes
|
|
in a way that is consistent with the Elmhurst methodology.
|
|
|
|
:param floor_area: area of the home's floor
|
|
:return: Numerical estimate of the surface area of the top of the pitched roof
|
|
"""
|
|
|
|
scalar = 1.0571283428862048
|
|
return scalar * (floor_area / np.cos(np.radians(30)))
|
|
|
|
|
|
def estimate_windows(
|
|
property_type, built_form, construction_age_band, floor_area, number_habitable_rooms
|
|
):
|
|
# If there is an extension, that will boost the number of habitable rooms
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
|
|
def calculate_cavity_age(newest_epc, older_epcs, cleaned):
|
|
all_epcs = [newest_epc] + older_epcs
|
|
|
|
df = []
|
|
for x in all_epcs:
|
|
# Get the cleaned mapping
|
|
mapped = [
|
|
y
|
|
for y in cleaned["walls-description"]
|
|
if y["original_description"] == x["walls-description"]
|
|
]
|
|
if not mapped:
|
|
continue
|
|
df.append(
|
|
{
|
|
**mapped[0],
|
|
"inspection-date": x["lodgement-date"],
|
|
}
|
|
)
|
|
|
|
df = pd.DataFrame(df)
|
|
df = df[df["is_cavity_wall"] & df["is_filled_cavity"]]
|
|
|
|
cavity_age = (datetime.now() - pd.to_datetime(df["inspection-date"].max())).days
|
|
return cavity_age
|
|
|
|
|
|
def check_simulation_difference(
|
|
old_config, new_config, prefix="", keys_with_prefix=None
|
|
):
|
|
"""
|
|
Given two dictionaries, that describe the heating control configurations, this method will compare the two
|
|
and pick out the differences. These differences will be things that have been added and things that have been
|
|
removed. This will be used to determine how we should be updating the configuration in the simulation
|
|
:return:
|
|
"""
|
|
|
|
keys_with_prefix = (
|
|
["is_assumed", "thermal_transmittance", "insulation_thickness"]
|
|
if keys_with_prefix is None
|
|
else keys_with_prefix
|
|
)
|
|
|
|
differences = {}
|
|
for key in new_config:
|
|
if old_config[key] != new_config[key]:
|
|
new_key = (
|
|
prefix + key + "_ending" if key in keys_with_prefix else key + "_ending"
|
|
)
|
|
differences[new_key] = new_config[key]
|
|
|
|
return differences
|
|
|
|
|
|
def override_costs(costs):
|
|
"""
|
|
If the method is overridden, we want to make sure that the costs are zero. This function sets the costs to zero
|
|
:param costs: Dictionary of costing, as returned by the Costs class
|
|
:return:
|
|
"""
|
|
for k in costs:
|
|
costs[k] = 0
|
|
|
|
return costs
|
|
|
|
|
|
def combine_recommendation_configs(recommendation_config1, recommendation_config2):
|
|
"""
|
|
Given two simulation configs, this function will combine them into one
|
|
:param recommendation_config1:
|
|
:param recommendation_config2:
|
|
:return:
|
|
"""
|
|
# Efficiency values - keys which contain _energy_eff_ending
|
|
eff_1 = {
|
|
k: v
|
|
for k, v in recommendation_config1.items()
|
|
if ("_energy_eff_ending" in k) or ("-energy-eff" in k)
|
|
}
|
|
eff_2 = {
|
|
k: v
|
|
for k, v in recommendation_config2.items()
|
|
if ("_energy_eff_ending" in k) or ("-energy-eff" in k)
|
|
}
|
|
|
|
# We combine the simulation configs
|
|
combined = {**recommendation_config1, **recommendation_config2}
|
|
|
|
# Find overlapping keys
|
|
overlapping_keys = set(eff_1.keys()).intersection(set(eff_2.keys()))
|
|
if overlapping_keys:
|
|
# We make sure we take the best value - map efficiency values to numbers
|
|
numerical_embedding = {
|
|
"Very poor": 1,
|
|
"Poor": 2,
|
|
"Average": 3,
|
|
"Good": 4,
|
|
"Very good": 5,
|
|
}
|
|
for key in overlapping_keys:
|
|
if numerical_embedding[eff_1[key]] >= numerical_embedding[eff_2[key]]:
|
|
combined[key] = eff_1[key]
|
|
else:
|
|
combined[key] = eff_2[key]
|
|
|
|
return combined
|