Model/etl/eligibility/Eligibility.py
2024-02-28 12:31:48 +00:00

787 lines
30 KiB
Python

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"
}