Model/recommendations/WallRecommendations.py
Khalim Conn-Kowlessar 5dd29ee1c6 removed stupid import
2026-01-10 21:43:15 +00:00

733 lines
32 KiB
Python

import math
from typing import List
import numpy as np
import pandas as pd
from datatypes.enums import QuantityUnits
from backend.Property import Property
from backend.app.plan.schemas import MEASURE_MAP
from BaseUtility import Definitions
from etl.epc_clean.epc_attributes.WallAttributes import WallAttributes
from recommendations.recommendation_utils import (
r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns, update_lowest_selected_u_value,
get_recommended_part, get_wall_u_value, override_costs, check_simulation_difference
)
from recommendations.config import PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION
from recommendations.Costs import Costs
from recommendations.wall_energy_efficiency_values import cavity_wall_energy_eff, iwi_energy_eff, ewi_energy_eff
from utils.logger import setup_logger
logger = setup_logger()
class WallRecommendations(Definitions):
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
# This can be seen in table 4.3 in building regulations part L:
# https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1133079
# /Approved_Document_L__Conservation_of_fuel_and_power__Volume_1_Dwellings__2021_edition_incorporating_2023_amendments.pdf
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
# Building regulations part L also indicates that cavity wall insulation should result in 0.55 u-value
BUILDING_REGULATIONS_PART_L_CAVITY_WALL_MAX_U_VALUE = 0.55
# 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
# Typically when the U-value is around 0.75 and below, and the home is a new build, this is a good indication
# that the home is already insulated with at least some partial insulation. We don't recommend insulation
# in this case. This estimate was verified with the Warmfront team and 0.75 has been used as a conservative
# threshold
NEW_BUILD_INSULATED = 0.75
# These are the ending descriptions we consider for walls with external insulation
# This maps the clean descriptions to the ending descriptions
EXTERNALLY_INSULATED_WALL_DESCRIPTIONS = {
"Cavity wall, as built, insulated": "Cavity wall, filled cavity and external insulation",
"Solid brick, as built, no insulation": "Solid brick, with external insulation",
"Solid brick, as built, insulated": "Solid brick, with external insulation",
"Solid brick, as built, partial insulation": "Solid brick, with external insulation",
"Cob, as built": "Cob, with external insulation",
"System built, as built, no insulation": "System built, with external insulation",
'System built, as built, partial insulation': "System built, with external insulation",
"Granite or whinstone, as built, no insulation": 'Granite or whinstone, with external insulation',
"Timber frame, as built, no insulation": "Timber frame, with external insulation",
'Timber frame, as built, partial insulation': 'Timber frame, with external insulation',
"Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with external insulation",
"Sandstone, as built, no insulation": "Sandstone, with external insulation",
"Sandstone, as built, partial insulation": "Sandstone, with external insulation",
}
# These are the ending descriptions we consider for walls with internal insulation
INTERNALLY_INSULATED_WALL_DESCRIPTIONS = {
"Cavity wall, as built, insulated": "Cavity wall, filled cavity and internal insulation",
"Solid brick, as built, no insulation": "Solid brick, with internal insulation",
"Solid brick, as built, insulated": "Solid brick, with internal insulation",
"Solid brick, as built, partial insulation": "Solid brick, with internal insulation",
"Cob, as built": "Cob, with internal insulation",
"System built, as built, no insulation": "System built, with internal insulation",
'System built, as built, partial insulation': "System built, with internal insulation",
"Granite or whinstone, as built, no insulation": 'Granite or whinstone, with internal insulation',
"Timber frame, as built, no insulation": "Timber frame, with internal insulation",
'Timber frame, as built, partial insulation': 'Timber frame, with internal insulation',
"Sandstone or limestone, as built, no insulation": "Sandstone or limestone, with internal insulation",
"Sandstone, as built, no insulation": "Sandstone, with internal insulation",
"Sandstone, as built, partial insulation": "Sandstone, with internal insulation",
}
def __init__(
self,
property_instance: Property,
materials: List
):
self.property = property_instance
self.costs = Costs(self.property)
# 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 = []
# Contains a list of extended recommendation measures, such as extension insulation
self.extended_recommendations = []
self.cavity_wall_insulation_materials = [
part for part in materials if part["type"] == "cavity_wall_insulation"
]
self.internal_wall_insulation_materials = [
part for part in materials if part["type"] == "internal_wall_insulation"
]
self.external_wall_insulation_materials = [
part for part in materials if part["type"] == "external_wall_insulation"
]
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/heritage building/listed building or a flat,
# it is not suitable for EWI
if self.property.restricted_measures or (
self.property.data["property-type"].lower() == "flat"
) or (
self.property.walls['is_cob'] or
self.property.walls['is_sandstone_or_limestone'] or
self.property.walls["is_cavity_wall"]
):
return False
return True
def is_suitable_for_solid_insulation(self):
"""
Checks if the wall is of a suitable type for internal/external wall insulation
"""
if self.property.walls["is_cavity_wall"] or self.property.walls["is_cob"]:
return False
return True
def recommend(self, phase=0, measures=None, default_u_values=False):
# 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
measures = MEASURE_MAP["wall_insulation"] if measures is None else measures
if not measures:
return
u_value = self.property.walls["thermal_transmittance"]
u_value = None if pd.isnull(u_value) else u_value
is_cavity_wall = self.property.walls["is_cavity_wall"]
insulation_thickness = self.property.walls["insulation_thickness"]
# We check if the wall is already insulated and if so, we exit
if (
(insulation_thickness in ["average", "above average"])
or self.property.walls["is_filled_cavity"]
or self.property.walls["clean_description"] is None
) and ("cavity_extract_and_refill" not in measures
):
return
if u_value is not None:
if self.property.walls["thermal_transmittance_unit"] != self.U_VALUE_UNIT:
raise NotImplementedError(
"Haven't handled the case of other u value units yet"
)
# If the property is a new build and the U-value is below 0.75, we don't recommend insulation because it's
# not practical
if (self.property.data["transaction-type"] == "new dwelling") and (
u_value <= self.NEW_BUILD_INSULATED
):
# Recommend nothing
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 WORSE than the building regulations, so we recommend either internal or
# external wall insulation
if (
(not is_cavity_wall)
and (u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE)
):
# Recommend insulation
self.find_insulation(u_value, phase, measures=measures, default_u_values=default_u_values)
return
# We have a sufficiently low U-value
return
u_value = get_wall_u_value(
clean_description=self.property.walls["clean_description"],
age_band=self.property.age_band,
is_granite_or_whinstone=self.property.walls["is_granite_or_whinstone"],
is_sandstone_or_limestone=self.property.walls["is_sandstone_or_limestone"],
)
self.estimated_u_value = u_value
if (is_cavity_wall and "cavity_wall_insulation" in measures) or "cavity_extract_and_refill" in measures:
if u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE:
# Test filling cavity
self.find_cavity_insulation(u_value, insulation_thickness, phase, measures, default_u_values)
return
# Remaining wall types are treated with IWI or EWI
if (u_value >= self.BUILDING_REGULATIONS_PART_L_MAX_U_VALUE) and self.is_suitable_for_solid_insulation():
self.find_insulation(u_value, phase, measures=measures, default_u_values=default_u_values)
return
# If the u-value is within regulations, we don't do anything
return
def recommend_extended(self, phase, measures):
"""
Where we have extended measures, such as extension insulation, which cannot typically be picked up
from the EPC api, we handle the recommendation of these here
:param measures:
:return:
"""
# These are the measures that are covered by this function
extended_measures = ["extension_cavity_wall_insulation"]
measures_to_recommend = [measure for measure in measures if measure in extended_measures]
if not measures_to_recommend:
return phase
# We reset this to be empty
self.extended_recommendations = []
recommendation_phase = phase
for measure in measures_to_recommend:
if measure == "extension_cavity_wall_insulation":
recommendation = self.recommend_extension_cavity_wall_insulation(phase=recommendation_phase)
else:
raise NotImplementedError(f"Measure {measure} is not implemented")
recommendation_phase += 1
self.extended_recommendations.append(recommendation)
return recommendation_phase
def recommend_extension_cavity_wall_insulation(self, phase):
"""
This function produces the recommendation for extension cavity wall insulation
:return:
"""
# TODO: We aren't provided with carbon, heat or bill savings figures for this measure
extension_cavity_insulation_recommendation = [
r for r in self.property.non_invasive_recommendations if r["type"] == "extension_cavity_wall_insulation"
][0]
# https://surreybuildingprojects.co.uk/how-much-does-a-24m2-extension-cost
average_extension_floor_area = 24
# https://assets.publishing.service.gov.uk/media/5f047a01d3bf7f2be8350262
# /Size_of_English_Homes_Fact_Sheet_EHS_2018.pdf
# This is rough
average_house_floor_area = 94
proposed_extension_floor_area = self.property.floor_area * (
average_extension_floor_area / average_house_floor_area
)
# assume 3 walls are external
proposed_extension_insulation_wall_area = (
np.sqrt(proposed_extension_floor_area) * self.property.floor_height * 3
)
cost_result = self.costs.cavity_wall_insulation(
wall_area=proposed_extension_insulation_wall_area,
material=self.cavity_wall_insulation_materials[0],
)
recommendation = {
"phase": phase,
"parts": [],
"type": "extension_cavity_wall_insulation",
"measure_type": "extension_cavity_wall_insulation",
"description": "Insulate the cavity walls of the extension",
"starting_u_value": None,
"new_u_value": None,
"sap_points": extension_cavity_insulation_recommendation["sap_points"],
"heat_demand": 0,
"kwh_savings": 0,
"energy_savings": 0,
"energy_cost_savings": 0,
"co2_equivalent_savings": 0,
"already_installed": False,
"simulation_config": {},
"description_simulation": {},
**cost_result,
"default": True,
}
return recommendation
def find_cavity_insulation(self, u_value, insulation_thickness, phase, measures, default_u_values):
"""
This method tests different materials to fill the cavity wall, determining which
material will give us the best U-value.
We check for diminishing returns, however this function does not check for meeting building
Part L regulations right now. This is because Part L is less stringent for cavity walls
Width of a cavity:
There are various sources online that suggest the width of a cavity wall is around 50mm. The retrofit course
indicates that most cavities are 50-75mm. Many sources online indicate that 50mm is the standard MINIMUM figure
therefore we'll use 75mm as the base assumption
This document:
https://www.buildingcentre.co.uk/media/_file/pdf/22220_pdf27.pdf
Indicates that they could be 50-85mm wide
:param u_value: u_value of the starting wall
:param insulation_thickness: describes the insulation level of the wall. If "below average", we have a partially
filled cavity wall
:param phase: The phase of the recommendation
:param measures: The measures we're considering
:param default_u_values: If we should use default u values
"""
insulation_materials = pd.DataFrame(self.cavity_wall_insulation_materials)
cavity_width = 75
if insulation_thickness == "below average":
cavity_width = cavity_width * (1 - PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION)
non_invasive_recommendations = next(
(r for r in self.property.non_invasive_recommendations if
r["type"] == insulation_materials["type"].values[0]), {}
)
# Test the different fill options
lowest_selected_u_value = None
recommendations = []
for _, material in insulation_materials.iterrows():
part_u_value = r_value_per_mm_to_u_value(
cavity_width, material["r_value_per_mm"]
)
_, new_u_value = calculate_u_value_uplift(u_value, part_u_value)
new_u_value = math.ceil(new_u_value * 100.0) / 100.0
if is_diminishing_returns(
recommendations,
new_u_value,
lowest_selected_u_value,
self.DIMINISHING_RETURNS_U_VALUE,
):
continue
if new_u_value <= self.BUILDING_REGULATIONS_PART_L_CAVITY_WALL_MAX_U_VALUE:
lowest_selected_u_value = update_lowest_selected_u_value(
lowest_selected_u_value, new_u_value
)
is_extraction_and_refill = (
"cavity_extract_and_refill"
in measures
)
cost_result = self.costs.cavity_wall_insulation(
wall_area=self.property.insulation_wall_area,
material=material.to_dict(),
is_extraction_and_refill=is_extraction_and_refill,
)
already_installed = (
"cavity_wall_insulation" in self.property.already_installed
)
if already_installed:
cost_result = override_costs(cost_result)
if is_extraction_and_refill:
description = f"Extract and refill cavity wall insulation with {material['description']}"
else:
description = self._make_description(material)
# updated the new u-value with the best possible our installers have
if default_u_values:
new_u_value = get_wall_u_value(
clean_description="Cavity wall, filled cavity",
age_band="G",
is_granite_or_whinstone=self.property.walls["is_granite_or_whinstone"],
is_sandstone_or_limestone=self.property.walls["is_sandstone_or_limestone"],
)
else:
new_u_value = max(0.31, new_u_value)
wall_ending_config = WallAttributes("Cavity wall, filled cavity").process()
walls_simulation_config = check_simulation_difference(
new_config=wall_ending_config, old_config=self.property.walls, prefix="walls_"
)
simulation_config = self.set_starting_simulation_config(
wall_ending_config=wall_ending_config
)
simulation_config = {
**simulation_config,
**walls_simulation_config,
"walls_thermal_transmittance_ending": new_u_value if not default_u_values else 0.7,
}
recommendations.append(
{
"phase": phase,
"parts": [
get_recommended_part(
part=material.to_dict(),
quantity=self.property.insulation_wall_area,
quantity_unit=QuantityUnits.m2.value,
cost_result=cost_result,
)
],
"type": "cavity_wall_insulation",
"measure_type": "cavity_wall_insulation",
"description": description,
"starting_u_value": u_value,
"new_u_value": new_u_value,
"sap_points": non_invasive_recommendations.get("sap_points", None),
"already_installed": already_installed,
"simulation_config": simulation_config,
"description_simulation": {
"walls-description": "Cavity wall, filled cavity",
"walls-energy-eff": "Good"
},
**cost_result,
"survey": non_invasive_recommendations.get("survey", False),
"innovation_rate": material.to_dict()["innovation_rate"]
}
)
self.recommendations = recommendations
def get_internal_external_wall_description(self, description_map, new_u_value):
if "Average thermal transmittance" in self.property.walls["clean_description"]:
if new_u_value is None:
raise ValueError("New u value is None")
return f'Average thermal transmittance {new_u_value} W/m-¦K'
return description_map[self.property.walls["clean_description"]]
def set_starting_simulation_config(self, wall_ending_config):
"""
Helper function to set the starting simulation config
"""
if wall_ending_config["is_cavity_wall"]:
efficiency_data = [
x for x in cavity_wall_energy_eff if
x["construction-age-band"] == self.property.construction_age_band
][0]
elif wall_ending_config["internal_insulation"]:
efficiency_data = [
x for x in iwi_energy_eff if
x["construction-age-band"] == self.property.construction_age_band
][0]
else:
efficiency_data = [
x for x in ewi_energy_eff if
x["construction-age-band"] == self.property.construction_age_band
][0]
if self.property.data["walls-energy-eff"] == "Good" and efficiency_data["walls-energy-eff"] not in [
"Good", "Very Good"
]:
simulation_config = {
"walls_energy_eff_ending": self.property.data["walls-energy-eff"]
}
elif self.property.data["walls-energy-eff"] == "Very Good":
simulation_config = {
"walls_energy_eff_ending": "Very Good"
}
else:
simulation_config = {
"walls_energy_eff_ending": efficiency_data["walls-energy-eff"]
}
# We check if we have double insulation in any instances
# TODO: We should pull the energy efficiency categories on double insulation instances, though it's quite rate
double_insulation = (
(wall_ending_config["is_filled_cavity"] and wall_ending_config["external_insulation"]) or
(wall_ending_config["is_filled_cavity"] and wall_ending_config["internal_insulation"]) or
(wall_ending_config["external_insulation"] and wall_ending_config["internal_insulation"])
)
if double_insulation:
simulation_config["walls_energy_eff_ending"] = "Very Good"
return simulation_config
def _find_insulation(self, u_value, insulation_materials, phase, default_u_values):
lowest_selected_u_value = None
recommendations = []
non_invasive_recommendations = next(
(r for r in self.property.non_invasive_recommendations if
r["type"] == insulation_materials["type"].values[0]), {}
)
for _, insulation_material_group in insulation_materials.groupby("description"):
for _, material in insulation_material_group.iterrows():
part_u_value = r_value_per_mm_to_u_value(
material["depth"], material["r_value_per_mm"]
)
_, new_u_value = 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 is_diminishing_returns(
recommendations,
new_u_value,
lowest_selected_u_value,
self.DIMINISHING_RETURNS_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:
lowest_selected_u_value = update_lowest_selected_u_value(
lowest_selected_u_value, new_u_value
)
cost_result = self.costs.solid_wall_insulation(
wall_area=self.property.insulation_wall_area,
material=material.to_dict(),
)
already_installed = material["type"] in self.property.already_installed
if already_installed:
cost_result = override_costs(cost_result)
if non_invasive_recommendations.get("cost") is not None:
raise NotImplementedError(
"Not handled passing costs from non-invasive recommendations for iwi"
)
if material["type"] == "internal_wall_insulation":
new_description = self.get_internal_external_wall_description(
self.INTERNALLY_INSULATED_WALL_DESCRIPTIONS, new_u_value
)
elif material["type"] == "external_wall_insulation":
new_description = self.get_internal_external_wall_description(
self.EXTERNALLY_INSULATED_WALL_DESCRIPTIONS, new_u_value
)
else:
raise ValueError("Invalid material type")
sap_points = non_invasive_recommendations.get("sap_points", None)
survey = non_invasive_recommendations.get("survey", False)
wall_ending_config = WallAttributes(new_description).process()
walls_simulation_config = check_simulation_difference(
new_config=wall_ending_config, old_config=self.property.walls, prefix="walls_"
)
simulation_config = self.set_starting_simulation_config(
wall_ending_config=wall_ending_config
)
simulation_config = {
**walls_simulation_config,
**simulation_config,
"walls_thermal_transmittance_ending": new_u_value
}
if default_u_values and "Average thermal transmittance" not in new_description:
# If we're using default U-values, we overwrite new_u_value
new_u_value = get_wall_u_value(
clean_description=new_description,
age_band=self.property.age_band,
is_granite_or_whinstone=self.property.walls["is_granite_or_whinstone"],
is_sandstone_or_limestone=self.property.walls["is_sandstone_or_limestone"],
)
recommendations.append(
{
"phase": phase,
"parts": [
get_recommended_part(
part=material.to_dict(),
quantity=self.property.insulation_wall_area,
quantity_unit=QuantityUnits.m2.value,
cost_result=cost_result,
)
],
"type": material["type"],
"measure_type": material["type"], # This is distinguished between EWI & IWI
"description": self._make_description(material),
"starting_u_value": u_value,
"new_u_value": new_u_value,
"already_installed": already_installed,
"sap_points": sap_points,
"simulation_config": simulation_config,
"description_simulation": {
"walls-description": new_description,
"walls-energy-eff": simulation_config["walls_energy_eff_ending"]
},
**cost_result,
"survey": survey,
"innovation_rate": material.to_dict()["innovation_rate"]
}
)
return recommendations
def find_insulation(self, u_value, phase, measures, default_u_values):
"""
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:
"""
# Recommend external and internal wall insulation separately
# Since external and internal wall insulation are sufficiently different,
# we separate the logic for for recommending them, therefore we don't
# consider diminishing returns between the two as they are considered to be separate measures
prop_already_installed = self.property.already_installed
# So, we'll end up with problems if e.g. an external wall insulation is already installed and we try and
# recommend internal wall insulation. To avoid this, we check if either measure is already installed
# and:
# 1) If EWI is installed, we don't recommend IWI
# 2) If IWI is installed, we don't recommend EWI
# We only produce the recommendation for the moment, for the purpose of re-baselining
ewi_recommendations = []
if self.ewi_valid() and "external_wall_insulation" in measures and (
"internal_wall_insulation" not in prop_already_installed
):
ewi_recommendations = self._find_insulation(
u_value=u_value,
insulation_materials=pd.DataFrame(
self.external_wall_insulation_materials
),
phase=phase,
default_u_values=default_u_values
)
iwi_recommendations = []
if "internal_wall_insulation" in measures and "external_wall_insulation" not in prop_already_installed:
iwi_recommendations = self._find_insulation(
u_value=u_value,
insulation_materials=pd.DataFrame(self.internal_wall_insulation_materials),
phase=phase,
default_u_values=default_u_values
)
self.recommendations += ewi_recommendations + iwi_recommendations
@staticmethod
def _make_description(material):
if material["type"] == "internal_wall_insulation":
return (
f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} on internal "
f"walls"
)
if material["type"] == "external_wall_insulation":
return (
f"Install {int(material['depth'])}{material['depth_unit']} {material['description']} on external "
f"walls"
)
if material["type"] == "cavity_wall_insulation":
return f"Fill cavity with {material['description']}"
raise ValueError("Invalid material type")
@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 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