from recommendations.recommendation_utils import convert_thickness_to_numeric from etl.epc_clean.epc_attributes.RoofAttributes import RoofAttributes from etl.epc_clean.epc_attributes.WallAttributes import WallAttributes from etl.epc_clean.epc_attributes.FloorAttributes import FloorAttributes class Eligibility: """ Given the epc data about a property, this class holds the logic for determining if the home is eligible for a specific retrofit measure. For example, this could be whether the loft has insulation below a standardised threshold, or if it has an empty cavity Further to this, this class is responsible for determining if the property is suitable for specific funding schemes """ loft = None cavity = None solid_wall = None room_roof = None flat_roof = None suspended_floor = None solid_floor = None # schemes based on Warmfront now gbis_warmfront = None eco4_warmfront = None # Schemes based on full eligibility gbis = None eco4 = None # If the loft has less than 100mm of insulation, we classify the home has needing loft insulation LOFT_INSULATION_THRESHOLD = 100 HIGH_LOFT_INSULATION_THRESHOLD = 269 # Because EPCS have different values for tenure, we need to remap them to a common set of values tenure_remap = { 'NO DATA!': "unknown", 'Not defined - use in the case of a new dwelling for which the intended tenure in not known. It is no': "unknown", 'Owner-occupied': 'Owner-occupied', 'Rented (private)': 'Rented (private)', 'Rented (social)': 'Rented (social)', 'owner-occupied': 'Owner-occupied', 'rental (private)': 'Rented (private)', 'rental (social)': 'Rented (social)', 'unknown': "unknown", } def __init__(self, epc, cleaned): self.epc = epc self.cleaned = cleaned self.walls = self.parse_fabric("walls-description") self.roof = self.parse_fabric("roof-description") self.floor = self.parse_fabric("floor-description") self.tenure = self.tenure_remap.get(self.epc["tenure"], None) def parse_fabric(self, key): # Get the cleaned version of the description remapped = [ data for data in self.cleaned[key] if data["original_description"] == self.epc[key] ] if remapped: return remapped[0] if "SAP05:" in self.epc[key]: # This is a placeholder method for handling this but this will occur in the case of a very old # EPC and therefore we just skip self.epc[key] = "(assumed)" if key == "walls-description": cleaner_cls = WallAttributes(self.epc[key]) elif key == "roof-description": cleaner_cls = RoofAttributes(self.epc[key]) elif key == "floor-description": cleaner_cls = FloorAttributes(self.epc[key]) else: raise ValueError("Invalid key") output = cleaner_cls.process() output["clean_description"] = cleaner_cls.description.replace("(assumed)", "").rstrip().capitalize() return output def loft_insulation(self, loft_thickness_threshold: int = None): """ Given the description of roof, this function determines whether or not the property is suitable for loft insulation. A loft existing insulation with a thickness below loft_thickness_threshold, is deemed to be suitable for loft insulation :param loft_thickness_threshold: Integer, Optional. If provided, any loft found with insulation lower than this thickness is deemed to be suitable for loft insulation. If this parameter is not provided, this method will default to the variable specified in LOFT_INSULATION_THRESHOLD """ loft_thickness_threshold = ( self.LOFT_INSULATION_THRESHOLD if loft_thickness_threshold is None else loft_thickness_threshold ) high_loft_thickness_threshold = self.HIGH_LOFT_INSULATION_THRESHOLD # We firstly check if the roof is a loft is_loft = self.roof["is_pitched"] and (not self.roof["is_roof_room"]) if not is_loft: self.loft = { "suitability": False, "thickness": None, "reason": "roof not loft", "thickness_classification": None } return # If it is a loft, we'll convert the textual thickenss to a numerical value we can easily use insulation_thickness = convert_thickness_to_numeric( string_thickness=self.roof["insulation_thickness"], is_pitched=self.roof["is_pitched"], is_flat=self.roof["is_flat"] ) if insulation_thickness <= 100: thickness_classification = "0-100mm" elif insulation_thickness <= high_loft_thickness_threshold: thickness_classification = "100-270mm" else: thickness_classification = "270mm+" if insulation_thickness <= loft_thickness_threshold: # We produce a thiclkness classification for the loft # 0 - 100mm insulation # 100 - 270mm insulation # 270mm+ insulation self.loft = { "suitability": True, "thickness": insulation_thickness, "reason": None, "thickness_classification": thickness_classification } return # Insulation is already thick enough self.loft = { "suitability": False, "thickness": insulation_thickness, "reason": "existing insulation", "thickness_classification": thickness_classification } return def cavity_insulation(self): """ Given the description of the walls, this function determines if the property is suitable for cavity wall insulation :return: """ is_cavity = self.walls["is_cavity_wall"] is_empty = (not self.walls["is_filled_cavity"]) is_as_built = ( self.walls["is_as_built"] and self.walls["insulation_thickness"] not in ["average", "above average"] and self.walls["is_assumed"] ) is_partial_filled = "partial" in self.walls["clean_description"].lower() # We look for potentially under performing cavities - anything that is assumed, as built and insulated is_underperforming = ( self.walls["is_as_built"] and self.walls["insulation_thickness"] in ["average"] and self.walls["is_assumed"] ) is_unfilled_cavity = is_cavity and (is_empty and not is_partial_filled) is_partial_filled_cavity = is_cavity and is_partial_filled is_assumed_filled_cavity = is_cavity and is_as_built is_underperforming_cavity = is_cavity and is_underperforming # Check if it has internal or external wall insulation has_internal_wall_insulation = self.walls["internal_insulation"] has_external_wall_insulation = self.walls["external_insulation"] if has_internal_wall_insulation or has_external_wall_insulation: self.cavity = { "suitability": False, "type": "internal or external wall insulation" } return if is_unfilled_cavity: self.cavity = { "suitability": True, "type": "empty", } return if is_assumed_filled_cavity: self.cavity = { "suitability": True, "type": "as built assumed", } return if is_partial_filled_cavity: self.cavity = { "suitability": True, "type": "partial" } return if is_underperforming_cavity: self.cavity = { "suitability": True, "type": "underperforming" } return self.cavity = { "suitability": False, "type": "full" } def solid_wall_insulation(self): """ Given the description of the walls, this function determines if the property is suitable for solid wall insulation :return: """ is_solid = self.walls["is_solid_brick"] is_insulated = self.walls["insulation_thickness"] in ["average", "above average"] if is_solid and is_insulated: self.solid_wall = { "suitability": True, } return self.solid_wall = { "suitability": False, } def room_roof_insulation(self): is_room_roof = self.roof["is_roof_room"] if not is_room_roof: self.room_roof = { "suitability": False, "thickness": None } return insulation_thickness = convert_thickness_to_numeric( self.roof["insulation_thickness"], self.roof["is_pitched"], self.roof["is_flat"] ) self.room_roof = { "suitability": is_room_roof and insulation_thickness == 0, "thickness": insulation_thickness } def flat_roof_insulation(self): is_flat = self.roof["is_flat"] if not is_flat: self.flat_roof = { "suitability": False, "thickness": None } return insulation_thickness = convert_thickness_to_numeric( self.roof["insulation_thickness"], self.roof["is_pitched"], self.roof["is_flat"] ) self.flat_roof = { "suitability": is_flat and insulation_thickness <= 100, "thickness": insulation_thickness } def suspended_floor_insulation(self): if "no_data" in self.floor.keys(): if self.floor["no_data"]: self.suspended_floor = { "suitability": False, } return is_suspended = self.floor["is_suspended"] is_insulated = self.floor["insulation_thickness"] in ["average", "above average"] self.suspended_floor = { "suitability": is_suspended and (not is_insulated), } return def solid_floor_insulation(self): if "no_data" in self.floor.keys(): if self.floor["no_data"]: self.solid_floor = { "suitability": False, } return is_solid = self.floor["is_solid"] is_insulated = self.floor["insulation_thickness"] in ["average", "above average"] self.solid_floor = { "suitability": is_solid and (not is_insulated), } return def check_gbis_warmfront(self): """ The Eligibility criteria for the Great British Insulation Scheme (GBIS) can be found here: https://www.ofgem.gov.uk/environmental-and-social-schemes/great-british-insulation-scheme/homeowners-and-tenants At a high level, the criteria is the following: - The home must be within council tax bands A-D in England, A-E in Scotland, A-E in Wales - It must have an EPC rating of D or below For the moment, we won't check whether a property is in the correct council tax band. There is likely to be public data for this since there is a govenment website which allows you to search for properties: https://www.gov.uk/council-tax-bands This data is possibly contained on the council tax valuation list but it remains to be see (seems unlikely) whether or not the data is openly accessible https://www.gov.uk/government/statistics/quality-assurance-of-administrative-data-in-the-uk-house-price-index /valuation-office-agency-council-tax-valuation-lists Currently, we tailor this module to the Warmfront Team and their delivery capabilities (both practically and commercially). Therefore, we will check: 1) Whether the property is an EPC D or below 2) Whether the property is suitible for cavity wall insulation However, GBIS applies to many insulation measures, which can be seen in the ofgem document GBIS does not have any minimum upgrade requirement so we don't need to simulate the post retrofit sap score using the machine learning model """ # Check if the property is suitable for cavity wall self.cavity_insulation() current_sap = int(self.epc["current-energy-efficiency"]) # We have a strict suitability check and a non-strict check # Perfect strictness if (self.cavity["type"] == "empty") and (current_sap < 69): self.gbis_warmfront = { "eligible": True, "strict": True, "message": "Perfect suitability", } return # Near perfect if self.cavity["suitability"] and (current_sap < 69): self.gbis_warmfront = { "eligible": True, "strict": True, "message": "Near perfect suitability", } return self.gbis_warmfront = { "eligible": False, "strict": False, "message": "All conditions fail", } def check_eco4_warmfront(self): """ This funciton will check if the property is eligible for funding under the ECO4 scheme For the moment, this function will consider just measures that can be implemented by the Warmfront team, therefore we will only check if a property has an uninsulated loft AND uninsulated cavity We use Ofgem's V1.1 ECO 4 guidance document for the conditions under which a property is elligible This document can be found here: https://www.ofgem.gov.uk/sites/default/files/2023-02/ECO4%20Delivery%20Guidance%20v1.1%20%281%29.pdf The conditions (to be reviewed) to be eligible for retrofit, under ECO4, are the following: 1) The property is a social home (This is assumed prior to this function as this code will often be run on property lists provided by a HA 2) The property is an EPC E or below 3) The property has an unfilled cavity and uninsulated loft 4) After retrofit, the property will hit an EPC C Note: This criteria will likely be adjusted depending on the properties that can be served right now If the post_retrofit_sap is provided, then is this value is 69 or higher, the property will be deemed to be eligible for ECO4 funding. If the post_retrofit_sap is not provided, the property will be deemed to be eligible, conditional to the post_retrofit_sap score check :param post_retrofit_sap: :return: """ current_sap = int(self.epc["current-energy-efficiency"]) self.cavity_insulation() self.loft_insulation() # We put in a placeholder when the roof is not a loft if self.loft["reason"] == "roof not loft": self.loft["thickness"] = 999 # Case 1: No conditions meet if not self.cavity["suitability"] and (self.loft["thickness"] > 100) and current_sap >= 55: self.eco4_warmfront = { "eligible": False, "strict": False, "message": "All conditions fail", "cavity_type": self.cavity["type"], "loft_type": self.loft["thickness_classification"] } return # Case 2 - perfect match if (self.cavity["type"] == "empty") and (self.loft["thickness"] <= 100) and (current_sap < 55): self.eco4_warmfront = { "eligible": True, "strict": True, "message": "Perfect suitability", "cavity_type": self.cavity["type"], "loft_type": self.loft["thickness_classification"] } return # Case 2.5 - near perfect match - but we would not recommend this using the model if self.cavity["suitability"] and (self.loft["thickness"] <= 100) and (current_sap < 55): self.eco4_warmfront = { "eligible": True, "strict": True, "message": "Near perfect suitability", "cavity_type": self.cavity["type"], "loft_type": self.loft["thickness_classification"] } return # Case 3 - cavity is suitable, loft is within 150mm, sap is good if self.cavity["suitability"] and (self.loft["thickness"] <= 150) and (current_sap < 55): self.eco4_warmfront = { "eligible": True, "strict": False, "message": "Meets cavity, loft borderline, meets sap", "cavity_type": self.cavity["type"], "loft_type": self.loft["thickness_classification"] } return # Case 3 - cavity is suitable, loft is not, sap is good if self.cavity["suitability"] and (self.loft["thickness"] > 150) and (current_sap < 55): self.eco4_warmfront = { "eligible": True, "strict": False, "message": "Meets cavity and sap", "cavity_type": self.cavity["type"], "loft_type": self.loft["thickness_classification"] } return # Case 4 - cavity is not suitable, loft is, sap is not - we say this is not elifible if not self.cavity["suitability"] and (self.loft["thickness"] <= 100) and (current_sap < 55): self.eco4_warmfront = { "eligible": False, "strict": False, "message": "failed fabric check", "cavity_type": self.cavity["type"], "loft_type": self.loft["thickness_classification"] } return # Case 5 - cavity and loft suitable, sap too high if self.cavity["suitability"] and (self.loft["thickness"] <= 150) and (current_sap >= 55): self.eco4_warmfront = { "eligible": True, "strict": False, "message": "Meets fabric, fails SAP check", "cavity_type": self.cavity["type"], "loft_type": self.loft["thickness_classification"] } return # Case 6 - meets just cavity if self.cavity["suitability"] and (self.loft["thickness"] > 100) and (current_sap >= 55): self.eco4_warmfront = { "eligible": True, "strict": False, "message": "Meets just cavity", "cavity_type": self.cavity["type"], "loft_type": self.loft["thickness_classification"] } return # Case 7 - fails cavity, loft but meets sap if not self.cavity["suitability"] and (self.loft["thickness"] > 100) and (current_sap < 55): self.eco4_warmfront = { "eligible": False, "strict": False, "message": "Fails cavity and loft, meets SAP", "cavity_type": self.cavity["type"], "loft_type": self.loft["thickness_classification"] } return # Case 8 - fails cavity, meets loft, fails sap if not self.cavity["suitability"] and (self.loft["thickness"] <= 100) and (current_sap >= 55): self.eco4_warmfront = { "eligible": False, "strict": False, "message": "Fails cavity, meets loft, fails SAP", "cavity_type": self.cavity["type"], "loft_type": self.loft["thickness_classification"] } return raise ValueError("Implement me") def check_gbis(self): """ The Eligibility criteria for the Great British Insulation Scheme (GBIS) can be found here: https://www.ofgem.gov.uk/environmental-and-social-schemes/great-british-insulation-scheme/homeowners-and-tenants Full delivery guidance and be downloaded here: https://www.ofgem.gov.uk/sites/default/files/2023-08/Great%20British%20Insulation%20Scheme%20Delivery %20Guidance%20V101693416860968.pdf For social housing, the criteria is the following: If the property is currently an EPC D: - It's valid for innovation measures only but not a heating control measure - The property must be rented at below the market rate. All eligible social housing is treated based on the low income group, therefore the tennant must be in receipt of one the eligible benefits If the property is currently an EPC E or below: - It's valid for all eligible insulation measures - The property must be rented at below the market rate. All eligible social housing is treated based on the low income group, therefore the tennant must be in receipt of one the eligible benefits From GBIS guidance document: Determining whether the premises are let below market rate 3.101 Social housing under this provision will only be eligible where the housing is let below the market rate. The supplier must produce a declaration signed by a social landlord providing confirmation that the social housing premises are let below the market rate, or where the premises are currently void, have previously and will be let below the market rate. The declaration to be signed by a social landlord is included within the Eligibility and Pre-Retrofit Declaration form. This declaration form must be retained by suppliers and be available on request for audit purposes. 3.102 Where social housing is let at or above the market rate, the property can be treated as a private domestic premises, where the occupant meets the eligibility requirements. See section on PRS from paragraph 1.13 for more information. This method searches ALL of the possible measures that can be implemented under GBIS. This includes: - cavity wall (including party wall) - loft - solid wall - pitched roof - flat roof - under-floor - solid floor - park home - room-in-roof :return: """ self.cavity_insulation() self.loft_insulation() self.solid_wall_insulation() self.room_roof_insulation() self.flat_roof_insulation() self.suspended_floor_insulation() self.solid_floor_insulation() current_sap = int(self.epc["current-energy-efficiency"]) is_below_e = current_sap <= 54 is_below_c = current_sap <= 68 needs_measure = ( self.cavity["suitability"] or self.loft["suitability"] or self.solid_wall["suitability"] or self.room_roof["suitability"] or self.flat_roof["suitability"] or self.suspended_floor["suitability"] or self.solid_floor["suitability"] ) if self.tenure == "Rented (social)": if is_below_c and (not is_below_e): # this is a placeholder methodology self.gbis = { "eligible": int(self.epc["potential-energy-efficiency"]) > 68, "message": "contingent on innovation measure delivery" } return elif is_below_e: self.gbis = { "eligible": needs_measure, "message": "eligible under fabric measure" } return else: self.gbis = { "eligible": False, "message": "not eligible" } return elif self.tenure == "Rented (private)": self.gbis = { "eligible": is_below_c and needs_measure, "message": "eligible under fabric measure" } return elif self.tenure == "Owner-occupied": self.gbis = { "eligible": False, "message": "Out-of-scope" } return elif (self.tenure is None) or self.tenure == "unknown": self.gbis = { "eligible": needs_measure, "message": "unknown tenure" } return else: raise ValueError("Implement me other tenure types") def check_eco4(self): """ Because ECO4 supports nearly all measures. If we have commercial agreements in place then a large number of homes would be eligible for eco funding, if identified. These are the eligibility criteria we consider for this process: Privately rented, Help to heat group - Sap E-G - Must receive one of solid wall insulation, first time central heating or district heating control - The property must already have cavity walls and roof insulated Social Housing, SAP D - Innovation measures and insulation measures to meet the minimum insulation requirement - Improvement to at least band C - Fabric measures - If receiving any heating measures, must have at least one insulation measure first Social Housing, SAP E-G - Insulation measures, first time central heating, renewable heating, district heating connection, innovation measures - Improvement to D (F & G properties) or C (E properties) - If receiving any heating measure, must already have cavity and roof insulation Privately rented, ECO4 Flex route 1, 2, 3, 4 - Must have SAP E-G - Most measures eligible, but must receive one of solid wall insulation, first time central heating, renewable heating and district heating control - Improvement to D (F & G properties) or C (E properties) - All homes receiving heating measures must first have insulated cavity/roof The flex routes are given here: https://so-eco.co.uk/what-is-eco4-flex/#:~:text=One%20way%20to%20gain%20ECO4, including%20elderly%20residents%20and%20lodgers. :return: """ self.cavity_insulation() self.loft_insulation() self.solid_wall_insulation() self.room_roof_insulation() self.flat_roof_insulation() self.suspended_floor_insulation() self.solid_floor_insulation() current_sap = int(self.epc["current-energy-efficiency"]) is_below_e = current_sap <= 54 is_below_c = current_sap <= 68 sap_potential = int(self.epc["potential-energy-efficiency"]) first_time_central_heating = "boiler" not in self.epc["mainheat-description"].lower() needs_fabric_measure = ( self.cavity["suitability"] or self.loft["suitability"] or self.solid_wall["suitability"] or self.room_roof["suitability"] or self.flat_roof["suitability"] or self.suspended_floor["suitability"] or self.solid_floor["suitability"] ) if current_sap <= 38 and sap_potential >= 55: # sap needs to get to at least a D expected_to_meet_upgrades = True elif current_sap <= 68 and sap_potential >= 69: # sap needs to get to at least a C expected_to_meet_upgrades = True else: expected_to_meet_upgrades = False if self.tenure == "Rented (social)": if is_below_c and (not is_below_e) and expected_to_meet_upgrades: # If the property is a D, then it's eligible under innovation measures but requires improvement to a # band C self.eco4 = { "eligible": True, "message": "eligible under innovation measure and improvement to band C" } elif is_below_e and expected_to_meet_upgrades: # If the property is an E or below, then it's eligible under fabric measures or heating/innovation # measures message = "eligible under fabric measures, with sufficient post retrofit sap improvement" if ( needs_fabric_measure) else ( "eligible under heating and innovation measures, with sufficient post retrofit sap improvement" ) self.eco4 = {"eligible": True, "message": message} else: if (current_sap <= 68) and expected_to_meet_upgrades: raise ValueError("something is wrong") self.eco4 = { "eligible": False, "message": "not eligible, above EPC C" } return if self.tenure == 'Rented (private)': # For private homes, the property needs to be an E or below # For private homes, the cavity must be filled and the roof insulated cavity_filled = not self.cavity["suitability"] roof_insulated = (not self.loft["suitability"]) and (not self.room_roof["suitability"]) and ( not self.flat_roof["suitability"]) if is_below_e and cavity_filled and roof_insulated and expected_to_meet_upgrades: if self.solid_wall["suitability"]: self.eco4 = { "eligible": True, "message": "eligible under solid wall insulation, conditional on post retrofit sap and help " "to heat/ECO flex route" } elif first_time_central_heating: self.eco4 = { "eligible": True, "message": "eligible under first time central heating, conditional on post retrofit sap and " "help to heat/ECO flex route" } else: self.eco4 = { "eligible": False, "message": "not eligible at this time" } return else: self.eco4 = { "eligible": False, "message": "not eligible at this time, EPC too high" } self.eco4 = { "eligible": False, "message": "Out of scope" }