mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
fleshing out prs eligibility, adding social
This commit is contained in:
parent
6c6a44abfe
commit
c970cc81ca
2 changed files with 205 additions and 522 deletions
|
|
@ -6,431 +6,26 @@ from typing import List
|
|||
from backend.app.plan.schemas import HousingType
|
||||
|
||||
|
||||
class FundingOld:
|
||||
"""
|
||||
Given a property, this class identifies if the home is possibly eligible for funding under
|
||||
the various funding schemes. It will also calculate the expected amount of funding available
|
||||
and flag any tenant specific requirements that need to be considered to the funding to be attained
|
||||
"""
|
||||
|
||||
SCHEMES = ["eco4", "gbis", "whlg"]
|
||||
|
||||
ECO_SAP_SCORE_THREHOLDS = [
|
||||
{'Band': 'High_A', 'From': 96.0, 'Up to': 100.0, 'Mid-point': 98.0},
|
||||
{'Band': 'Low_A', 'From': 92.0, 'Up to': 96.0, 'Mid-point': 94.0},
|
||||
{'Band': 'High_B', 'From': 86.0, 'Up to': 91.0, 'Mid-point': 88.5},
|
||||
{'Band': 'Low_B', 'From': 81.0, 'Up to': 86.0, 'Mid-point': 83.5},
|
||||
{'Band': 'High_C', 'From': 74.5, 'Up to': 80.0, 'Mid-point': 77.25},
|
||||
{'Band': 'Low_C', 'From': 69.0, 'Up to': 74.5, 'Mid-point': 71.75},
|
||||
{'Band': 'High_D', 'From': 61.5, 'Up to': 68.0, 'Mid-point': 64.75},
|
||||
{'Band': 'Low_D', 'From': 55.0, 'Up to': 61.5, 'Mid-point': 58.25},
|
||||
{'Band': 'High_E', 'From': 46.5, 'Up to': 54.0, 'Mid-point': 50.25},
|
||||
{'Band': 'Low_E', 'From': 39.0, 'Up to': 46.5, 'Mid-point': 42.75},
|
||||
{'Band': 'High_F', 'From': 29.5, 'Up to': 38.0, 'Mid-point': 33.75},
|
||||
{'Band': 'Low_F', 'From': 21.0, 'Up to': 29.5, 'Mid-point': 25.25},
|
||||
{'Band': 'High_G', 'From': 10.5, 'Up to': 20.0, 'Mid-point': 15.25},
|
||||
{'Band': 'Low_G', 'From': 1.0, 'Up to': 10.5, 'Mid-point': 5.75}
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tenure: HousingType,
|
||||
starting_epc,
|
||||
starting_sap,
|
||||
postcode,
|
||||
floor_area,
|
||||
council_tax_band,
|
||||
property_recommendations,
|
||||
project_scores_matrix,
|
||||
whlg_eligible_postcodes,
|
||||
gbis_abs_rate: int,
|
||||
eco4_abs_rate: int,
|
||||
):
|
||||
"""
|
||||
Use Pydantic to validate the parameter types
|
||||
:param tenure: Indicates if the property is a social or private home
|
||||
:param starting_epc: The current EPC rating of the property
|
||||
:param starting_sap: The current SAP score for the property
|
||||
:param floor_area: The total floor area of the property
|
||||
:param council_tax_band: The council tax band of the property
|
||||
:param property_recommendations: The recommendations for the property
|
||||
:param project_scores_matrix: The matrix of project scores for ECO4
|
||||
:param whlg_eligible_postcodes: The postcodes eligible for WHLG
|
||||
:param gbis_abs_rate: The assumed £/abs achieved by the installer for GBIS
|
||||
:param eco4_abs_rate: The assumed £/abs achieved by the installer for ECO4
|
||||
"""
|
||||
|
||||
# TODO: Things we need to include:
|
||||
# 1) Amount of funding
|
||||
# 2) Fundable measures, as a subset of measures may be fundable, not all
|
||||
|
||||
self.tenure = tenure
|
||||
self.starting_epc = starting_epc
|
||||
self.starting_sap = starting_sap
|
||||
self.postcode = postcode
|
||||
self.starting_eco_band = self.sap_to_eco_band(self.starting_sap)
|
||||
self.floor_area_segment = self.classify_floor_area(floor_area)
|
||||
self.gbis_abs_rate = gbis_abs_rate
|
||||
self.eco4_abs_rate = eco4_abs_rate
|
||||
self.council_tax_band = council_tax_band
|
||||
|
||||
self.recommendations = property_recommendations
|
||||
|
||||
self.measure_types = list({r["measure_type"] for r in property_recommendations if r["default"]})
|
||||
|
||||
# Load in the eco4 project scores matrix
|
||||
# Filter the matrix on scores relevant to this property
|
||||
self.project_scores_matrix = project_scores_matrix[
|
||||
(project_scores_matrix["Floor Area Segment"] == self.floor_area_segment) &
|
||||
(project_scores_matrix["Starting Band"] == self.starting_eco_band)
|
||||
]
|
||||
|
||||
# The postcode column is already lower case
|
||||
self.whlg_eligible_postcodes = whlg_eligible_postcodes[
|
||||
whlg_eligible_postcodes["Postcode"] == self.postcode.lower()
|
||||
]
|
||||
|
||||
# Store the final outputs
|
||||
self.gbis_eligibiltiy = {}
|
||||
self.eco4_eligibility = {}
|
||||
self.whlg_eligibility = {}
|
||||
|
||||
def output(
|
||||
self,
|
||||
scheme: str,
|
||||
eligible: bool,
|
||||
types: List[str],
|
||||
measure_types: List[str],
|
||||
project_score: float,
|
||||
estimated_funding: float,
|
||||
notify_tenant_benefits_requirements: bool,
|
||||
notify_council_tax_band_requirements: bool,
|
||||
notify_tenant_low_income_requirements: bool,
|
||||
innovation_required: bool,
|
||||
):
|
||||
""""
|
||||
"""
|
||||
|
||||
if scheme not in self.SCHEMES:
|
||||
raise ValueError("Scheme not recognised")
|
||||
|
||||
return {
|
||||
"scheme": scheme,
|
||||
"eligible": eligible,
|
||||
"type": types,
|
||||
"measure_types": measure_types,
|
||||
"project_score": project_score,
|
||||
"estimated_funding": estimated_funding,
|
||||
"requires_benefits": notify_tenant_benefits_requirements,
|
||||
"requires_council_tax_band": notify_council_tax_band_requirements,
|
||||
"requires_low_income": notify_tenant_low_income_requirements,
|
||||
"innovation_required": innovation_required,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def classify_floor_area(floor_area):
|
||||
if floor_area <= 72:
|
||||
return "0-72"
|
||||
|
||||
if floor_area <= 97:
|
||||
return "73-97"
|
||||
|
||||
if floor_area <= 199:
|
||||
return "98-199"
|
||||
|
||||
return "200"
|
||||
|
||||
def eco4(self):
|
||||
"""
|
||||
Checks if a property is eligible for ECO4
|
||||
:return:
|
||||
"""
|
||||
pass
|
||||
|
||||
def find_gbis_measures(self, measures):
|
||||
"""
|
||||
The best measure is one that:
|
||||
1) Creates some SAP movement, therefore enables eligiblity
|
||||
2) Generates the most funding
|
||||
3) Has a reasonable ROI
|
||||
:return:
|
||||
"""
|
||||
measure_table = pd.DataFrame([
|
||||
m for m in self.recommendations if
|
||||
(m["type"] in measures) or (m["measure_type"] in measures) and m["default"]
|
||||
])
|
||||
|
||||
measure_table["post_install_sap"] = measure_table["sap_points"] + self.starting_sap
|
||||
# We classify the movement
|
||||
measure_table["Finishing Band"] = np.floor(measure_table["post_install_sap"]).apply(
|
||||
lambda points: self.sap_to_eco_band(points)
|
||||
)
|
||||
# Remove any measures that generate zero SAP movement
|
||||
measure_table = measure_table[measure_table["Finishing Band"] != self.starting_eco_band]
|
||||
|
||||
if measure_table.empty:
|
||||
raise NotImplementedError("No measures available, handle me!")
|
||||
|
||||
# We merge on the project matrix, on post install band
|
||||
measure_table = measure_table.merge(
|
||||
self.project_scores_matrix, how="left", on="Finishing Band"
|
||||
)
|
||||
# Cost Savings is the abs
|
||||
measure_table["estimated_funding"] = measure_table["Cost Savings"] * self.gbis_abs_rate
|
||||
# We cap any estimated funding at the install cost
|
||||
measure_table["estimated_funding"] = np.where(
|
||||
measure_table["estimated_funding"] >= measure_table["total"],
|
||||
measure_table["total"],
|
||||
measure_table["estimated_funding"]
|
||||
)
|
||||
|
||||
# Sort by the measure that will cost the client the least, per sap point
|
||||
measure_table["cost_minus_funding"] = measure_table["total"] - measure_table["estimated_funding"]
|
||||
measure_table["cost_minus_funding_per_sap"] = measure_table["cost_minus_funding"] / measure_table["sap_points"]
|
||||
measure_table = measure_table.sort_values(["cost_minus_funding_per_sap", "total"], ascending=[True, False])
|
||||
|
||||
return measure_table[
|
||||
["type", "measure_type", "Cost Savings", "estimated_funding"]
|
||||
].rename(columns={"Cost Savings": "project_score"}).to_dict("records")
|
||||
|
||||
def sap_to_eco_band(self, sap_points):
|
||||
"""
|
||||
Giuven a sap point score, this function will classify the points into the SAP half-band
|
||||
:param sap_points:
|
||||
:return:
|
||||
"""
|
||||
|
||||
if sap_points > 100:
|
||||
return "High_A"
|
||||
|
||||
classification = [
|
||||
x for x in self.ECO_SAP_SCORE_THREHOLDS if (x["From"] <= sap_points) and (sap_points <= x["Up to"])
|
||||
]
|
||||
|
||||
if len(classification) != 1:
|
||||
raise Exception("We should have a single classifcation for SAP points to half band")
|
||||
|
||||
return classification[0]['Band']
|
||||
|
||||
def gbis_prs(self):
|
||||
"""
|
||||
Checks if a private rental is eligible for GBIS. There are the following possible options
|
||||
1) General Eligibilty, contigent on EPC D-G and council tax band A-D. Excludes CWI, LI and heating
|
||||
controls
|
||||
2) Low income group - contigent on EPC D-G and tenant must receive benefits. Excludes heating controls
|
||||
3) GBIS Flex route 1, 3 - Great British Insulation Scheme Routes 1 and 3 are for pre-installation
|
||||
SAP bands D-G for owner-occupied households, D-E for private rented sector households
|
||||
(Including F & G if exempt from MEES). If houseold is low income. Excludes heating controls
|
||||
4) GBIS Flex route 2 - EPC E - G and low income household. Excludes heating controls
|
||||
|
||||
Eligible measures:
|
||||
• Solid wall
|
||||
• pitched roof
|
||||
• flat roof
|
||||
• under floor
|
||||
• solid floor park home and
|
||||
• room in-roof insulation
|
||||
|
||||
:return:
|
||||
"""
|
||||
|
||||
valid_measures = [
|
||||
"internal_wall_insulation",
|
||||
"external_wall_insulation",
|
||||
"flat_roof_insulation",
|
||||
"suspended_floor_insulation",
|
||||
"room_roof_insulation",
|
||||
# Not available for every eligiblity type
|
||||
"cavity_wall_insulation",
|
||||
"loft_insulation",
|
||||
]
|
||||
|
||||
# General Eligibility
|
||||
if (
|
||||
(self.starting_epc in ["G", "D", "E", "F"]) and
|
||||
any(
|
||||
[measure in valid_measures for measure in self.measure_types
|
||||
if measure not in ["cavity_wall_insulation", "loft_insulation"]]
|
||||
) and
|
||||
(self.council_tax_band in [None, "A", "B", "C", "D"])
|
||||
):
|
||||
# This function pulls out the various measures that can provide funding under GBIS
|
||||
recommended_measures = self.find_gbis_measures(
|
||||
measures=[m for m in valid_measures if m not in ["cavity_wall_insulation", "loft_insulation"]]
|
||||
)
|
||||
# If the council tax band is missing, we nofify the customer that this is a requirement that
|
||||
# should be checked
|
||||
return [
|
||||
self.output(
|
||||
scheme="gbis",
|
||||
eligible=True,
|
||||
types=[m["type"]], # This is single measure so we only have one type
|
||||
measure_types=[m["measure_type"]],
|
||||
project_score=m["project_score"],
|
||||
estimated_funding=m["estimated_funding"],
|
||||
notify_tenant_benefits_requirements=False,
|
||||
notify_council_tax_band_requirements=self.council_tax_band is None,
|
||||
notify_tenant_low_income_requirements=False,
|
||||
innovation_required=False
|
||||
) for m in recommended_measures
|
||||
]
|
||||
|
||||
# Low income/flex
|
||||
if (
|
||||
(self.starting_sap in ["G", "D", "E", "F"]) and
|
||||
any([measure in valid_measures for measure in self.measure_types])
|
||||
):
|
||||
# Find the best measure, and can also include CWI/LI but requires the tenant to be
|
||||
# low inome or on benefits
|
||||
# We find the best measure for GBIS
|
||||
recommended_measures = self.find_gbis_measures(measures=valid_measures)
|
||||
return [
|
||||
self.output(
|
||||
scheme="gbis",
|
||||
eligible=True,
|
||||
types=[m["type"]], # This is single measure so we only have one type
|
||||
measure_types=[m["measure_type"]],
|
||||
project_score=m["project_score"],
|
||||
estimated_funding=m["estimated_funding"],
|
||||
notify_tenant_benefits_requirements=True,
|
||||
notify_council_tax_band_requirements=False,
|
||||
notify_tenant_low_income_requirements=True,
|
||||
innovation_required=False
|
||||
) for m in recommended_measures
|
||||
]
|
||||
|
||||
# Otherwise, no funding availability
|
||||
return []
|
||||
|
||||
def gbis_social(self):
|
||||
"""
|
||||
Because this is social housing, we have two typical means for eligibility
|
||||
1) EPC D, where an innovation measure is required
|
||||
2) EPC G-E, where an innovation measure isn't required
|
||||
:return:
|
||||
"""
|
||||
valid_measures = [
|
||||
"internal_wall_insulation",
|
||||
"external_wall_insulation",
|
||||
"flat_roof_insulation",
|
||||
"suspended_floor_insulation",
|
||||
"room_roof_insulation",
|
||||
# Not available for every eligiblity type
|
||||
"cavity_wall_insulation",
|
||||
"loft_insulation",
|
||||
"heating_control"
|
||||
]
|
||||
|
||||
recommended_measures = self.find_gbis_measures(
|
||||
measures=valid_measures
|
||||
)
|
||||
|
||||
# All measures are available
|
||||
if self.starting_sap == "D":
|
||||
return [
|
||||
self.output(
|
||||
scheme="gbis",
|
||||
eligible=True,
|
||||
types=[m["type"]], # This is single measure so we only have one type
|
||||
measure_types=[m["measure_type"]],
|
||||
project_score=m["project_score"],
|
||||
estimated_funding=m["estimated_funding"],
|
||||
notify_tenant_benefits_requirements=False,
|
||||
notify_council_tax_band_requirements=False,
|
||||
notify_tenant_low_income_requirements=False,
|
||||
innovation_required=True
|
||||
) for m in recommended_measures
|
||||
]
|
||||
|
||||
if self.starting_sap in ["G", "F", "E"]:
|
||||
return [
|
||||
self.output(
|
||||
scheme="gbis",
|
||||
eligible=True,
|
||||
types=[m["type"]], # This is single measure so we only have one type
|
||||
measure_types=[m["measure_type"]],
|
||||
project_score=m["project_score"],
|
||||
estimated_funding=m["estimated_funding"],
|
||||
notify_tenant_benefits_requirements=False,
|
||||
notify_council_tax_band_requirements=False,
|
||||
notify_tenant_low_income_requirements=False,
|
||||
innovation_required=False
|
||||
) for m in recommended_measures
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
def gbis(self):
|
||||
"""
|
||||
Check if a property is eligible for GBIS
|
||||
:return:
|
||||
"""
|
||||
|
||||
if self.tenure == "Private":
|
||||
self.gbis_eligibiltiy = self.gbis_prs()
|
||||
return
|
||||
|
||||
if self.tenure == "Social":
|
||||
self.gbis_eligibiltiy = self.gbis_social()
|
||||
|
||||
raise NotImplementedError("Implement social/oo")
|
||||
|
||||
def whlg(self):
|
||||
if self.tenure == "Social":
|
||||
# We can't do anything for social housing
|
||||
self.whlg_eligibility = []
|
||||
return
|
||||
|
||||
if not self.whlg_eligible_postcodes.empty:
|
||||
raise Exception("Implement me")
|
||||
# self.whlg_eligibility = [
|
||||
# self.output(
|
||||
# scheme,
|
||||
# eligible,
|
||||
# types,
|
||||
# measure_types,
|
||||
# project_score: float,
|
||||
# estimated_funding: float,
|
||||
# notify_tenant_benefits_requirements: bool,
|
||||
# notify_council_tax_band_requirements: bool,
|
||||
# notify_tenant_low_income_requirements: bool,
|
||||
# innovation_required: bool,
|
||||
# )
|
||||
# ]
|
||||
|
||||
def eco4(self):
|
||||
if self.tenure == "Private":
|
||||
self.eco4_eligibiltiy = self.eco4_prs()
|
||||
return
|
||||
|
||||
def check_eligibiltiy(self):
|
||||
"""
|
||||
This function instigates the checking process
|
||||
:return:
|
||||
"""
|
||||
|
||||
self.gbis()
|
||||
# self.eco4()
|
||||
self.whlg()
|
||||
|
||||
|
||||
class EligibilityCaveats(Enum):
|
||||
TENANT_ON_BENEFITS_OR_LOW_INCOME = "tenant_on_benefits_or_low_income"
|
||||
INNOVATION_REQUIRED = "innovation_required"
|
||||
SOLAR_NEEDS_HEATING = "solar_needs_heating"
|
||||
|
||||
|
||||
class Funding:
|
||||
"""
|
||||
New class to handle funding calculation
|
||||
Handles eligibility and funding calculations for ECO4 & GBIS (PRS + Social Housing).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tenure: HousingType,
|
||||
tenure: str, # 'Private' or 'Social'
|
||||
social_cavity_abs_rate: float,
|
||||
social_solid_abs_rate: float,
|
||||
private_cavity_abs_rate: float,
|
||||
private_solid_abs_rate: float,
|
||||
project_scores_matrix,
|
||||
partial_project_scores_matrix,
|
||||
whlg_eligible_postcodes
|
||||
):
|
||||
self.tenure = tenure
|
||||
|
|
@ -443,10 +38,17 @@ class Funding:
|
|||
self.ending_sap_band = None
|
||||
self.floor_area_band = None
|
||||
self.project_scores_matrix = project_scores_matrix
|
||||
self.partial_project_scores_matrix = partial_project_scores_matrix
|
||||
self.whlg_eligible_postcodes = whlg_eligible_postcodes
|
||||
|
||||
self.eco4_eligible = False
|
||||
self.eligbility_caveat = None
|
||||
self.eco4_eligibility_caveats = []
|
||||
self.gbis_eligible = False
|
||||
self.gbis_eligibility_caveats = []
|
||||
|
||||
# -----------------------
|
||||
# Utility Helpers
|
||||
# -----------------------
|
||||
|
||||
@staticmethod
|
||||
def get_sap_band(sap_score_number):
|
||||
|
|
@ -466,151 +68,219 @@ class Funding:
|
|||
("High_G", 10.5, 21),
|
||||
("Low_G", 1, 10.5),
|
||||
]
|
||||
|
||||
for band, lower, upper in bands:
|
||||
if lower <= sap_score_number < upper:
|
||||
return band
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_floor_area_band(floor_area):
|
||||
if floor_area <= 72:
|
||||
return "0-72"
|
||||
|
||||
if floor_area <= 97:
|
||||
return "73-97"
|
||||
|
||||
if floor_area <= 199:
|
||||
return "98-199"
|
||||
|
||||
return "200"
|
||||
|
||||
def eco4_prs_eligibility(
|
||||
self, starting_sap: int, measures: List, mainheat_description: str, heating_control_description: str
|
||||
):
|
||||
def _split_measures(self, measures: List[dict]):
|
||||
"""
|
||||
Handles the eligibility criteria for private rental properties under eco
|
||||
:return:
|
||||
Extracts measure types and flags innovation.
|
||||
measures: list of dicts like {"type": "solar_pv", "is_innovation": True}
|
||||
"""
|
||||
measure_types = [m["type"] for m in measures]
|
||||
has_innovation = any(m.get("is_innovation", False) for m in measures)
|
||||
innovation_measures = [m["type"] for m in measures if m.get("is_innovation", False)]
|
||||
return measure_types, has_innovation, innovation_measures
|
||||
|
||||
# Help to heat group
|
||||
# 1) EPC E - G
|
||||
# 2) Must receive one of SWI, FTCH, renewable heating or DHC
|
||||
# 3) Tenant must be on benefits
|
||||
# -----------------------
|
||||
# Private Rented Sector
|
||||
# -----------------------
|
||||
|
||||
# We don't consider the tenant being on benefits - we just notify the end user that this is a requirement
|
||||
def eco4_prs_eligibility(self, starting_sap: int, measure_types: List, mainheat_description: str,
|
||||
heating_control_description: str):
|
||||
"""
|
||||
ECO4 PRS eligibility:
|
||||
- EPC E–G
|
||||
- Must include SWI, FTCH, renewable heating, or DHC
|
||||
- Tenant must be on benefits (flagged)
|
||||
"""
|
||||
meets_epc = starting_sap <= 54 # EPC E–G
|
||||
|
||||
meets_epc = starting_sap <= 54
|
||||
has_solid_wall = "internal_wall_insulation" in measures or "external_wall_insulation" in measures
|
||||
# We check if the property has a heating system that means solar pv counts as a renewable heating system
|
||||
has_swi = "internal_wall_insulation" in measure_types or "external_wall_insulation" in measure_types
|
||||
has_renewable = "air_source_heat_pump" in measure_types or "ground_source_heat_pump" in measure_types
|
||||
has_ftch = "first_time_central_heating" in measure_types
|
||||
has_dhc = "district_heating_connection" in measure_types
|
||||
|
||||
has_eligible_electric_heating = any(x in mainheat_description for x in [
|
||||
"air source heat pump", "ground source heat pump", "boiler and radiators, electric"
|
||||
]) | (("electric storage heaters" in mainheat_description) and
|
||||
(heating_control_description.lower() == "controls for high heat retention storage heaters")
|
||||
)
|
||||
]) or (
|
||||
("electric storage heaters" in mainheat_description)
|
||||
and (
|
||||
heating_control_description.lower() == "controls for high heat "
|
||||
"retention storage heaters")
|
||||
)
|
||||
|
||||
# Counts as renewable heating
|
||||
solar_renweable_heating = has_eligible_electric_heating & ("solar_pv" in measures)
|
||||
# Is a renewable heating
|
||||
ashp = "air_source_heat_pump" in measures
|
||||
solar_counts_as_renewable = has_eligible_electric_heating and "solar_pv" in measure_types
|
||||
|
||||
# Meets the EPC criteria, has the measure requirement and tenant must be on benefits
|
||||
if meets_epc & (solar_renweable_heating or ashp or has_solid_wall):
|
||||
if meets_epc and (has_swi or has_renewable or has_ftch or has_dhc or solar_counts_as_renewable):
|
||||
self.eco4_eligible = True
|
||||
self.eligbility_caveat = EligibilityCaveats.TENANT_ON_BENEFITS_OR_LOW_INCOME
|
||||
self.eco4_eligibility_caveats.append(EligibilityCaveats.TENANT_ON_BENEFITS_OR_LOW_INCOME)
|
||||
|
||||
def gbis_prs_eligibility(self, starting_sap: int, council_tax_band: str, measure_types: List):
|
||||
"""
|
||||
GBIS PRS eligibility:
|
||||
- General route: Council Tax Band & EPC D–G
|
||||
- Low-income route: tenant on benefits (flagged)
|
||||
"""
|
||||
gbis_measures = {
|
||||
"general": [
|
||||
"internal_wall_insulation", "external_wall_insulation",
|
||||
"flat_roof_insulation", "suspended_floor_insulation",
|
||||
"room_roof_insulation", "solid_floor_insulation", "park_home_insulation"
|
||||
],
|
||||
"low_income": [
|
||||
"internal_wall_insulation", "external_wall_insulation",
|
||||
"flat_roof_insulation", "suspended_floor_insulation",
|
||||
"room_roof_insulation", "solid_floor_insulation",
|
||||
"cavity_wall_insulation", "loft_insulation", "park_home_insulation"
|
||||
]
|
||||
}
|
||||
|
||||
meets_epc = starting_sap <= 69 # EPC D–G
|
||||
|
||||
# General route
|
||||
if meets_epc and council_tax_band in ["A", "B", "C", "D", "E"]:
|
||||
if any(m in gbis_measures["general"] for m in measure_types):
|
||||
self.gbis_eligible = True
|
||||
|
||||
# Low-income route
|
||||
if meets_epc and any(m in gbis_measures["low_income"] for m in measure_types):
|
||||
self.gbis_eligible = True
|
||||
self.gbis_eligibility_caveats.append(EligibilityCaveats.TENANT_ON_BENEFITS_OR_LOW_INCOME)
|
||||
|
||||
# -----------------------
|
||||
# Social Housing
|
||||
# -----------------------
|
||||
|
||||
def eco4_sh_eligibility(self, starting_sap: int, measure_types: List, has_innovation: bool,
|
||||
innovation_measures: List):
|
||||
"""
|
||||
ECO4 Social Housing eligibility.
|
||||
- EPC E–G: eligible, no income check.
|
||||
- EPC D: innovation measure required.
|
||||
If solar PV is the innovation measure, must also install ASHP or HHRSH.
|
||||
"""
|
||||
meets_epc = starting_sap <= 69
|
||||
if not meets_epc:
|
||||
return
|
||||
|
||||
return False
|
||||
# EPC D innovation rule
|
||||
if 55 <= starting_sap <= 68: # EPC D
|
||||
if not has_innovation:
|
||||
self.eco4_eligible = False
|
||||
self.eco4_eligibility_caveats.append(EligibilityCaveats.INNOVATION_REQUIRED)
|
||||
return
|
||||
|
||||
def gbis_prs_eligibiltiy(self):
|
||||
if "solar_pv" in innovation_measures and not any(
|
||||
m in measure_types for m in ["air_source_heat_pump", "high_heat_retention_storage_heater"]
|
||||
):
|
||||
self.eco4_eligible = False
|
||||
self.eco4_eligibility_caveats.append(EligibilityCaveats.SOLAR_NEEDS_HEATING)
|
||||
return
|
||||
|
||||
self.eco4_eligible = True
|
||||
|
||||
def gbis_sh_eligibility(self, starting_sap: int, measure_types: List, has_innovation: bool,
|
||||
innovation_measures: List):
|
||||
"""
|
||||
Determines if a project is eligible for GBIS funding for private rental properties
|
||||
GBIS Social Housing eligibility.
|
||||
- EPC E–G: insulation measures OK.
|
||||
- EPC D: innovation measure required (same solar PV + heating rule).
|
||||
"""
|
||||
meets_epc = starting_sap <= 69
|
||||
if not meets_epc:
|
||||
return
|
||||
|
||||
if 55 <= starting_sap <= 68: # EPC D
|
||||
if not has_innovation:
|
||||
self.gbis_eligible = False
|
||||
self.gbis_eligibility_caveats.append(EligibilityCaveats.INNOVATION_REQUIRED)
|
||||
return
|
||||
|
||||
if "solar_pv" in innovation_measures and not any(
|
||||
m in measure_types for m in ["air_source_heat_pump", "high_heat_retention_storage_heater"]
|
||||
):
|
||||
self.gbis_eligible = False
|
||||
self.gbis_eligibility_caveats.append(EligibilityCaveats.SOLAR_NEEDS_HEATING)
|
||||
return
|
||||
|
||||
self.gbis_eligible = True
|
||||
|
||||
# -----------------------
|
||||
# Score Lookup
|
||||
# -----------------------
|
||||
|
||||
def calculate_full_project_abs(self):
|
||||
|
||||
# Filter the project scores matrix
|
||||
"""Look up ABS score for full projects."""
|
||||
data = self.project_scores_matrix[
|
||||
(self.project_scores_matrix["Floor Area Segment"] == self.floor_area_band) &
|
||||
(self.project_scores_matrix["Starting Band"] == self.starting_sap_band) &
|
||||
(self.project_scores_matrix["Finishing Band"] == self.ending_sap_band)
|
||||
]
|
||||
|
||||
if data.emtpy:
|
||||
raise ValueError("Missing abs rate, check the project scores matrix")
|
||||
if data.empty:
|
||||
raise ValueError("Missing ABS rate, check the project scores matrix")
|
||||
|
||||
return data["Cost Savings"].values[0]
|
||||
|
||||
# -----------------------
|
||||
# Main Entry Point
|
||||
# -----------------------
|
||||
|
||||
def check_funding(
|
||||
self, measures: List,
|
||||
self, measures: List[dict],
|
||||
starting_sap: int,
|
||||
ending_sap: int,
|
||||
floor_area: float,
|
||||
mainheat_description: str,
|
||||
heating_control_description: str,
|
||||
is_cavity: bool
|
||||
is_cavity: bool,
|
||||
council_tax_band: str = None
|
||||
):
|
||||
"""
|
||||
Given a list of measures, this function will check if the package of measures is fundable
|
||||
:param measures:
|
||||
:param starting_sap:
|
||||
:param ending_sap:
|
||||
:param floor_area:
|
||||
:param mainheat_description:
|
||||
:param heating_control_description:
|
||||
:param is_cavity: Indicates if the property has cavity wall insulation
|
||||
:return:
|
||||
Given a list of measures, check ECO4/GBIS eligibility.
|
||||
"""
|
||||
|
||||
# If it's an E or D, should get to an EPC C
|
||||
if starting_sap >= 55 and ending_sap < 69:
|
||||
raise NotImplementedError("This property doesn't have sufficient SAP movement")
|
||||
|
||||
if starting_sap <= 38 & ending_sap <= 55:
|
||||
# F or G should get to D
|
||||
raise NotImplementedError("Implement F or G to D eligibility")
|
||||
# Normalize measures
|
||||
measure_types, has_innovation, innovation_measures = self._split_measures(measures)
|
||||
|
||||
# Track EPC bands and floor area
|
||||
self.starting_sap_band = self.get_sap_band(starting_sap)
|
||||
self.ending_sap_band = self.get_sap_band(ending_sap)
|
||||
self.floor_area_band = self.get_floor_area_band(floor_area)
|
||||
|
||||
########################
|
||||
# Private
|
||||
########################
|
||||
# 1) ECO4
|
||||
# 2) GBIS
|
||||
|
||||
if self.tenure == "Private":
|
||||
self.eco4_prs_eligibility(
|
||||
starting_sap=starting_sap,
|
||||
measures=measures,
|
||||
mainheat_description=mainheat_description,
|
||||
heating_control_description=heating_control_description
|
||||
)
|
||||
# ECO4 PRS
|
||||
self.eco4_prs_eligibility(starting_sap, measure_types, mainheat_description, heating_control_description)
|
||||
# GBIS PRS
|
||||
self.gbis_prs_eligibility(starting_sap, council_tax_band or "", measure_types)
|
||||
|
||||
# Need to implement
|
||||
# 1) Package has to include an insulation measure
|
||||
# 2) We should use the funding for the measure that has the largest partial project score
|
||||
# TODO: check the rules around GBIS eligibility and heating controls
|
||||
self.gbis_prs_eligibiltiy()
|
||||
if self.eco4_eligible:
|
||||
eco4_abs = self.calculate_full_project_abs()
|
||||
eco4_funding = eco4_abs * (self.private_cavity_abs_rate if is_cavity else self.private_solid_abs_rate)
|
||||
return {"eco4_funding": eco4_funding}
|
||||
|
||||
if not is_eco4_eligible:
|
||||
return
|
||||
eco4_abs = self.calculate_full_project_abs()
|
||||
# We estimate rates now
|
||||
eco4_funding = (
|
||||
eco4_abs * self.private_cavity_abs_rate if is_cavity else eco4_abs & self.private_solid_abs_rate
|
||||
)
|
||||
elif self.tenure == "Social":
|
||||
# ECO4 Social
|
||||
self.eco4_sh_eligibility(starting_sap, measure_types, has_innovation, innovation_measures)
|
||||
# GBIS Social
|
||||
self.gbis_sh_eligibility(starting_sap, measure_types, has_innovation, innovation_measures)
|
||||
|
||||
########################
|
||||
# Social
|
||||
########################
|
||||
# 1) ECO4
|
||||
# 2) GBIS
|
||||
if self.eco4_eligible:
|
||||
eco4_abs = self.calculate_full_project_abs()
|
||||
eco4_funding = eco4_abs * (self.social_cavity_abs_rate if is_cavity else self.social_solid_abs_rate)
|
||||
return {"eco4_funding": eco4_funding}
|
||||
|
||||
if self.tenure == "Social":
|
||||
pass
|
||||
|
||||
raise NotImplementedError("Only implemented for Private or Social housing")
|
||||
else:
|
||||
raise NotImplementedError("Only 'Private' and 'Social' tenures are supported.")
|
||||
|
|
|
|||
|
|
@ -17,35 +17,48 @@ def get_funding_data():
|
|||
project_scores_matrix.columns = ['Floor Area Segment', 'Starting Band', 'Finishing Band', 'Cost Savings']
|
||||
project_scores_matrix["Cost Savings"] = project_scores_matrix["Cost Savings"].astype(float)
|
||||
|
||||
partial_project_scores_matrix = read_csv_from_s3(
|
||||
bucket_name="retrofit-data-dev",
|
||||
filepath="funding/ECO4_Partial_Project_Scores_Matrix_v6.csv",
|
||||
)
|
||||
partial_project_scores_matrix = pd.DataFrame(partial_project_scores_matrix)
|
||||
partial_project_scores_matrix["Cost Savings"] = partial_project_scores_matrix["Cost Savings"].astype(float)
|
||||
|
||||
whlg_eligible_postcodes = read_csv_from_s3(
|
||||
bucket_name="retrofit-data-dev",
|
||||
filepath="funding/whlg eligible postcodes.csv",
|
||||
)
|
||||
whlg_eligible_postcodes = pd.DataFrame(whlg_eligible_postcodes)
|
||||
|
||||
return project_scores_matrix, whlg_eligible_postcodes
|
||||
return project_scores_matrix, partial_project_scores_matrix, whlg_eligible_postcodes
|
||||
|
||||
# class TestFunding:
|
||||
#
|
||||
# def test_prs(self):
|
||||
# eco_project_scores_matrix, whlg_eligible_postcodes = get_funding_data()
|
||||
# funding = Funding(
|
||||
# project_scores_matrix=eco_project_scores_matrix,
|
||||
# whlg_eligible_postcodes=whlg_eligible_postcodes,
|
||||
# social_cavity_abs_rate=13.5,
|
||||
# social_solid_abs_rate=17,
|
||||
# private_cavity_abs_rate=13.5,
|
||||
# private_solid_abs_rate=17,
|
||||
# tenure="Private",
|
||||
# )
|
||||
#
|
||||
# measures_1 = ["internal_wall_insulation", "solar_pv"]
|
||||
# funding.check_funding(
|
||||
# measures=measures_1,
|
||||
# starting_sap=54,
|
||||
# ending_sap=69,
|
||||
# floor_area=73,
|
||||
# mainheat_description="Boiler and radiators, mains gas",
|
||||
# heating_control_description="Programmer, room thermostat and TRVs",
|
||||
# is_cavity=True
|
||||
# )
|
||||
|
||||
class TestFunding:
|
||||
|
||||
def test_prs(self):
|
||||
fps_matrix, pps_matrix, whlg_eligible_postcodes = get_funding_data()
|
||||
funding = Funding(
|
||||
project_scores_matrix=fps_matrix,
|
||||
partial_project_scores_matrix=pps_matrix,
|
||||
whlg_eligible_postcodes=whlg_eligible_postcodes,
|
||||
social_cavity_abs_rate=13.5,
|
||||
social_solid_abs_rate=17,
|
||||
private_cavity_abs_rate=13.5,
|
||||
private_solid_abs_rate=17,
|
||||
tenure="Private",
|
||||
)
|
||||
|
||||
measures_1 = [
|
||||
{"type": "internal_wall_insulation", "is_innovation": False},
|
||||
{"type": "solar_pv", "is_innovation": True},
|
||||
]
|
||||
|
||||
funding.check_funding(
|
||||
measures=measures_1,
|
||||
starting_sap=54,
|
||||
ending_sap=69,
|
||||
floor_area=73,
|
||||
mainheat_description="Boiler and radiators, mains gas",
|
||||
heating_control_description="Programmer, room thermostat and TRVs",
|
||||
is_cavity=True
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue