Model/backend/Funding.py
2025-08-11 18:53:20 +01:00

989 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from enum import Enum
from typing import List
import pandas as pd
from etl.epc_clean.epc_attributes.MainheatAttributes import MainHeatAttributes
from backend.app.plan.schemas import HousingType, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, MEASURE_MAP
class EligibilityCaveats(Enum):
EPC_RATING = "epc_rating" # EPC requirements not met
TENANT_ON_BENEFITS_OR_LOW_INCOME = "tenant_on_benefits_or_low_income"
INNOVATION_REQUIRED = "innovation_required"
SOLAR_NEEDS_HEATING = "solar_needs_heating"
MINIMUM_INSULATION_PRECONgiDITIONS_NOT_MET = "minimum_insulation_preconditions_not_met"
class Funding:
"""
Handles eligibility and funding calculations for ECO4 & GBIS (PRS + Social Housing).
"""
SOLID_FUELS = [
'wood logs', 'manufactured smokeless fuel', 'house coal', 'smokeless coal', 'oil', 'dual fuel mineral wood',
'anthracite', 'dual fuel appliance mineral and wood', "bulk wood pellets", "wood chips", "wood pellets"
]
def __init__(
self,
tenure: str, # 'Private' or 'Social'
eco4_social_cavity_abs_rate: float,
eco4_social_solid_abs_rate: float,
eco4_private_cavity_abs_rate: float,
eco4_private_solid_abs_rate: float,
gbis_social_cavity_abs_rate: float,
gbis_social_solid_abs_rate: float,
gbis_private_cavity_abs_rate: float,
gbis_private_solid_abs_rate: float,
project_scores_matrix,
partial_project_scores_matrix,
whlg_eligible_postcodes
):
if tenure not in [HousingType.PRIVATE, HousingType.SOCIAL]:
raise ValueError("Invalid tenure type. Must be 'Private' or 'Social'.")
self.tenure = tenure
self.eco4_social_cavity_abs_rate = eco4_social_cavity_abs_rate
self.eco4_social_solid_abs_rate = eco4_social_solid_abs_rate
self.eco4_private_cavity_abs_rate = eco4_private_cavity_abs_rate
self.eco4_private_solid_abs_rate = eco4_private_solid_abs_rate
self.gbis_social_cavity_abs_rate = gbis_social_cavity_abs_rate
self.gbis_social_solid_abs_rate = gbis_social_solid_abs_rate
self.gbis_private_cavity_abs_rate = gbis_private_cavity_abs_rate
self.gbis_private_solid_abs_rate = gbis_private_solid_abs_rate
self.starting_sap_band = None
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.eco4_eligibility_caveats = []
self.gbis_eligible = False
self.gbis_eligibility_caveats = []
# Funding calculation variables
self.full_project_abs = None
self.gbis_funding = None
self.eco4_funding = None
self.eco4_uplift = 0
self.partial_project_abs = None
# -----------------------
# Utility Helpers
# -----------------------
@staticmethod
def get_sap_band(sap_score_number):
bands = [
("High_A", 96, float("inf")),
("Low_A", 92, 96),
("High_B", 86, 92),
("Low_B", 81, 86),
("High_C", 74.5, 81),
("Low_C", 69, 74.5),
("High_D", 61.5, 69),
("Low_D", 55, 61.5),
("High_E", 46.5, 55),
("Low_E", 39, 46.5),
("High_F", 29.5, 39),
("Low_F", 21, 29.5),
("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"
@staticmethod
def _split_measures(measures: List[dict]):
"""
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]
innovation_flags = [m.get("is_innovation", False) for m in measures]
uplifts = [m["uplift"] for m in measures]
innovation_measures = [m["type"] for m in measures if m.get("is_innovation", False)]
return measure_types, uplifts, innovation_flags, innovation_measures
@staticmethod
def _meets_upgrade_target(starting_sap: int, ending_sap: int) -> bool:
"""
ECO4 Upgrade Requirement:
- EPC E/D (SAP ≥ 39): must upgrade to EPC C (SAP ≥ 69)
- EPC F/G (SAP < 39): must upgrade to EPC D (SAP ≥ 55)
"""
if starting_sap >= 39 and ending_sap >= 69:
return True
if starting_sap < 39 and ending_sap >= 55:
return True
return False
# -----------------------
# Private Rented Sector
# -----------------------
def eco4_prs_eligibility(
self, starting_sap: int, ending_sap: int, measure_types: List, has_solar: bool, solar_eligible: bool
):
"""
ECO4 PRS eligibility:
- EPC EG
- Must include SWI, FTCH, renewable heating, or DHC
- Tenant must be on benefits (flagged)
"""
meets_epc = starting_sap <= 54 # EPC EG
meets_upgrade_target = self._meets_upgrade_target(starting_sap, ending_sap)
if not meets_epc or not meets_upgrade_target:
self.eco4_eligible = False
self.eco4_eligibility_caveats.append(EligibilityCaveats.EPC_RATING)
return
if has_solar and not solar_eligible:
self.eco4_eligible = False
self.eco4_eligibility_caveats.append(EligibilityCaveats.SOLAR_NEEDS_HEATING)
return
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
if meets_upgrade_target and meets_epc and (
has_swi or has_renewable or has_ftch or has_dhc or solar_eligible
):
self.eco4_eligible = True
self.eco4_eligibility_caveats.append(EligibilityCaveats.TENANT_ON_BENEFITS_OR_LOW_INCOME)
return
self.eco4_eligible = False
self.eco4_eligibility_caveats = []
def gbis_prs_eligibility(self, starting_sap: int, council_tax_band: str, measure_types: List):
"""
GBIS PRS eligibility:
- General route: Council Tax Band & EPC DG
- Low-income route: tenant on benefits (flagged)
"""
gbis_measures = {
"general": [
# Cannot do CWI
"internal_wall_insulation", "external_wall_insulation",
"flat_roof_insulation", "suspended_floor_insulation",
"room_roof_insulation", "solid_floor_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"
]
}
meets_epc = starting_sap <= 69 # EPC DG
is_single_measure = len(measure_types) == 1
if not is_single_measure or not meets_epc:
self.gbis_eligible = False
self.gbis_eligibility_caveats = []
return
# 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,
ending_sap: int,
has_innovation: bool,
has_solar: bool,
solar_eligible: bool,
solar_meets_mir: bool,
):
"""
ECO4 Social Housing eligibility.
- EPC EG: eligible, no income check.
- EPC D: innovation measure required.
If solar PV is the innovation measure, must also install ASHP or HHRSH.
"""
if has_solar and not solar_eligible:
# The package contins solar PV but it doesn't meet the eligibility requirements
self.eco4_eligible = False
if not solar_meets_mir:
self.eco4_eligibility_caveats.append(EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET)
else:
self.eco4_eligibility_caveats.append(EligibilityCaveats.SOLAR_NEEDS_HEATING)
return
meets_epc = starting_sap <= 69
meets_upgrade_target = self._meets_upgrade_target(starting_sap, ending_sap)
if not meets_epc or not meets_upgrade_target:
return
# EPC D innovation rule
if 55 <= starting_sap <= 68: # EPC D
# If we don't meet the innovation requirements, we're not eligible
if not has_innovation:
self.eco4_eligible = False
self.eco4_eligibility_caveats.append(EligibilityCaveats.INNOVATION_REQUIRED)
return
self.eco4_eligible = True
self.eco4_eligibility_caveats = []
return
self.eco4_eligible = True
self.eco4_eligibility_caveats = []
def gbis_sh_eligibility(self, starting_sap: int, measure_types: List, has_innovation: bool):
"""
GBIS Social Housing eligibility.
- EPC EG: single insulation measure
- EPC D: single insulation, innovation measure
"""
meets_epc = starting_sap <= 69 # EPC DG
is_single_measure = len(measure_types) == 1
# Check if has a valid measure
insulation_measures = [
'internal_wall_insulation', 'external_wall_insulation', 'cavity_wall_insulation',
'loft_insulation', 'flat_roof_insulation', 'room_roof_insulation',
'suspended_floor_insulation', 'solid_floor_insulation',
]
has_valid_measures = any(m in measure_types for m in insulation_measures)
if not is_single_measure or not meets_epc or not has_valid_measures:
self.gbis_eligible = False
self.gbis_eligibility_caveats = []
return
if 55 <= starting_sap <= 68: # EPC D
# Since it's single measure if has_innovation is true, the single insulation measure
# must be the innovation measure
if not has_innovation:
self.gbis_eligible = False
self.gbis_eligibility_caveats.append(EligibilityCaveats.INNOVATION_REQUIRED)
return
# If we don't have an innovation measure, we're not eligible
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):
"""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.empty:
raise ValueError("Missing ABS rate, check the project scores matrix")
return data["Cost Savings"].values[0]
@staticmethod
def get_starting_ending_uvalues(current_uvalue: float) -> tuple[str, str]:
"""
Returns the closest starting U-value and appropriate ending U-value for solid wall insulation.
- If the closest starting U-value is 0.45, assume an improvement to 0.21.
- Otherwise, assume improvement to 0.30.
- Special formatting rules:
- If closest is 0.45 → return "2" (string)
- If closest is 2.00 → return "2.0"
- Else: format with 2 decimal places
"""
possible_starting_u_values = [2.00, 1.70, 1.00, 0.60, 0.45]
closest_starting = min(possible_starting_u_values, key=lambda x: abs(x - current_uvalue))
# Determine the ending U-value
ending_uvalue = "0.21" if closest_starting == 0.45 else "0.3"
# Format the starting U-value according to special rules
if closest_starting == 0.45:
starting_str = "2"
elif closest_starting == 2.00:
starting_str = "2.0"
else:
starting_str = f"{closest_starting:.2f}"
return starting_str, ending_uvalue
def _map_to_pre_main_heating(self, mainheating, main_fuel, mainheat_energy_eff):
# We check most likely primary heating system. Because mixed systems are hard to break up, we
# check the larger, more prominent heating systems first and then the smaller ones. We aim
# to cover the case where properties have heating systems like
# "boiler radiators, mains gas, electric storage heaters" so mixed systems
is_solid_fuel = (main_fuel["fuel_type"] in self.SOLID_FUELS) or (
mainheating["has_dual_fuel_mineral_and_wood"] or
mainheating["has_coal"] or
mainheating["has_anthracite"] or
mainheating["has_smokeless_fuel"] or
mainheating["has_mineral_and_wood"] or
mainheating["has_dual_fuel_appliance"]
)
if mainheating["has_air_source_heat_pump"]:
return 'Air to Water ASHP'
if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "biomass"):
return 'Biomass Boiler'
if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "lpg"):
return 'Bottled LPG Boiler'
if mainheating["has_boiler"] and (
(main_fuel["fuel_type"] in ["mains gas", "biogas"]) or (
(main_fuel["fuel_type"] == "unknown") and (mainheating["has_mains_gas"]))
):
if mainheat_energy_eff in ["Good", "Very Good"]:
# Assume higher efficiency condensing boiler
return 'Condensing Gas Boiler'
if mainheat_energy_eff in ["Average", "Poor"]:
return 'Non Condensing Gas Boiler'
return 'Gas Back Boiler to Radiators'
if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "mains gas") and (
mainheat_energy_eff in ["Very Poor"]
) and not mainheating["has_radiators"]:
# Doesnt have radiators
return 'Gas Fire with Back Boiler'
if mainheating["has_boiler"] and (main_fuel["fuel_type"] in ["oil", "b30k"]):
# b30k - kerosene
if mainheat_energy_eff in ["Good", "Very Good"]:
return 'Condensing Oil Boiler'
return 'Non Condensing Oil Boiler'
if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "lpg") and (
mainheat_energy_eff in ["Good", "Very Good"]
):
return 'Condensing LPG Boiler'
if mainheating["has_boiler"] and (main_fuel["fuel_type"] == "lpg") and (
mainheat_energy_eff in ["Average", "Very Poor", "Poor"]
):
return 'Non Condensing LPG Boiler'
if mainheating["has_boiler"] and is_solid_fuel:
return 'Solid Fossil Boiler'
if mainheating["has_ground_source_heat_pump"] or mainheating["has_water_source_heat_pump"]:
return 'GSHP'
if mainheating["has_boiler"] and (main_fuel["fuel_type"] in ["electric", "electricity"]):
return 'Electric Boiler'
if mainheating["has_community_scheme"] and mainheat_energy_eff in ["Good", "Very Good"]:
return 'DHS CHP'
if mainheating["has_community_scheme"] and (
mainheat_energy_eff in ["Average", "Very Poor", "Poor"] or pd.isnull(mainheat_energy_eff)
):
return 'DHS non-CHP'
if mainheating["has_electric_storage_heaters"] and (
(mainheat_energy_eff == "Very Poor") or pd.isnull(mainheat_energy_eff)
):
return 'Electric Storage Heaters Responsiveness <=0.2'
if mainheating["has_electric_storage_heaters"] and mainheat_energy_eff in [
"Poor", "Average", "Good", "Very Good",
]:
return 'Electric Storage Heaters Responsiveness >0.2'
if mainheating["has_room_heaters"] and main_fuel["fuel_type"] == "lpg":
return 'Bottled LPG Room Heaters'
if mainheating["has_room_heaters"] and (
(main_fuel["fuel_type"] == "electricity") or mainheating["has_electric"]
):
return 'Electric Room Heaters'
if mainheating["has_room_heaters"] and main_fuel["fuel_type"] == "mains gas":
return 'Gas Room Heaters'
if mainheating["has_room_heaters"] and is_solid_fuel:
return 'Solid Fossil Room Heaters'
# Handle the case of no heating system - electric heaters assumed
if mainheating["has_no_system_present"] or mainheating["has_portable_electric_heaters"] or (
mainheating["has_warm_air"] and mainheating["has_electric"] and not mainheating["has_electricaire"]
) or mainheating['has_hot-water-only']:
return 'Electric Room Heaters'
if not any(mainheating.values()):
# This means we have an unknown heating system like 'SAP05:Main-Heating'
return 'Electric Room Heaters'
if mainheating["has_warm_air"] and main_fuel["fuel_type"] == "mains gas":
if mainheat_energy_eff in ["Good", "Very Good"]:
return 'Condensing Gas Boiler'
if mainheat_energy_eff in ["Average", "Poor"]:
return 'Non Condensing Gas Boiler'
return 'Gas Back Boiler to Radiators'
if mainheating["has_electricaire"]:
# Based on current understanding, electricaire is an electric warm air storage heater, using
# off-peak electricity to heat a thermal store and then a fan blows the heat through ducts
# into rooms
if mainheat_energy_eff == "Very Poor":
return "Electric Storage Heaters Responsiveness <=0.2"
return "Electric Storage Heaters Responsiveness >0.2"
# direct-acting electric space heating (no storage)
if mainheating["has_electric_underfloor_heating"] or mainheating["has_electric_ceiling_heating"]:
return "Electric Room Heaters"
# Treat warm air lpg as a direct acting lpg oiler
if mainheating["has_warm_air"] and main_fuel["fuel_type"] == "lpg":
if mainheat_energy_eff in ["Good", "Very Good"]:
return 'Condensing LPG Boiler'
return 'Non Condensing LPG Boiler'
# Treat warm air oil as a direct acting oil boiler
if mainheating["has_warm_air"] and main_fuel["fuel_type"] == "oil":
if mainheat_energy_eff in ["Good", "Very Good"]:
return 'Condensing Oil Boiler'
return 'Non Condensing Oil Boiler'
fuels_identified = []
for fuel in MainHeatAttributes.FUEL_TYPES:
fuels_identified.append(mainheating[f"has_{fuel.replace(' ', '_')}"])
unknown_fuel = main_fuel["fuel_type"] == "unknown" and not any(fuels_identified)
if mainheating["has_boiler"] and unknown_fuel:
return 'Non Condensing Gas Boiler'
raise ValueError("Invalid pre heating system")
def calculate_partial_project_abs(
self,
measure_type: str,
mainheating: dict,
main_fuel: dict,
mainheat_energy_eff: str,
filtered_pps_matrix: pd.DataFrame,
pre_heating_system: str,
current_wall_uvalue: float = None,
is_partial: bool = False,
existing_li_thickness: float = None,
# is_roof_insulated: bool = False
):
"""
Calculate the partial project ABS score for a single measure.
"""
# Filter on the starting band and floor area so we only do this once
if measure_type == "internal_wall_insulation":
if current_wall_uvalue is None:
raise ValueError("current_wall_uvalue is required for IWI")
starting_str, ending_str = self.get_starting_ending_uvalues(current_wall_uvalue)
measure_code = f"IWI_solid_{starting_str}_{ending_str}"
pps = filtered_pps_matrix[filtered_pps_matrix["Measure_Type"] == measure_code]
if pps.shape[0] != 1:
raise ValueError(f"Invalid IWI category: {measure_code}")
return pps.squeeze()["Cost Savings"]
if measure_type == "external_wall_insulation":
if current_wall_uvalue is None:
raise ValueError("current_wall_uvalue is required for EWI")
starting_str, ending_str = self.get_starting_ending_uvalues(current_wall_uvalue)
measure_code = f"EWI_solid_{starting_str}_{ending_str}"
pps = filtered_pps_matrix[filtered_pps_matrix["Measure_Type"] == measure_code]
if pps.shape[0] != 1:
raise ValueError(f"Invalid EWI category: {measure_code}")
return pps.squeeze()["Cost Savings"]
if measure_type == "cavity_wall_insulation":
measure_code = "CWI_partial_fill" if is_partial else "CWI_0.033"
pps = filtered_pps_matrix[filtered_pps_matrix["Measure_Type"] == measure_code]
if pps.shape[0] != 1:
raise ValueError(f"Invalid CWI category: {measure_code}")
return pps.squeeze()["Cost Savings"]
if measure_type == "loft_insulation":
if existing_li_thickness is None:
raise ValueError("existing_li_thickness is required for LI")
measure_code = "LI_lessequal100" if existing_li_thickness <= 100 else "LI_greater100"
pps = filtered_pps_matrix[filtered_pps_matrix["Measure_Type"] == measure_code]
if pps.shape[0] != 1:
raise ValueError(f"Invalid LI category: {measure_code}")
return pps.squeeze()["Cost Savings"]
if measure_type == "flat_roof_insulation":
pps = filtered_pps_matrix[filtered_pps_matrix["Measure_Type"] == "FRI"]
if pps.shape[0] != 1:
raise ValueError("Invalid FRI category")
return pps.squeeze()["Cost Savings"]
if measure_type == "room_roof_insulation":
# Use the more conservative score (unin is usually lower)
# code = "RIRI_res_unin" if not is_roof_insulated else "RIRI_res_in"
code = "RIRI_res_unin"
pps = filtered_pps_matrix[filtered_pps_matrix["Measure_Type"] == code]
if pps.shape[0] != 1:
raise ValueError(f"Invalid RIRI category: {code}")
return pps.squeeze()["Cost Savings"]
if measure_type == "suspended_floor_insulation":
pps = filtered_pps_matrix[filtered_pps_matrix["Measure_Type"] == "UFI"]
if pps.shape[0] != 1:
raise ValueError("Invalid UFI category")
return pps.squeeze()["Cost Savings"]
if measure_type == "solid_floor_insulation":
pps = filtered_pps_matrix[filtered_pps_matrix["Measure_Type"] == "SFI"]
if pps.shape[0] != 1:
raise ValueError("Invalid SFI category")
return pps.squeeze()["Cost Savings"]
if measure_type == "solar_pv":
solar_pps_df = filtered_pps_matrix[
(filtered_pps_matrix["Measure_Type"] == "Solar_PV") &
(filtered_pps_matrix["Pre_Main_Heating_Source"] == pre_heating_system)
]
return solar_pps_df.squeeze()["Cost Savings"]
if measure_type == "air_source_heat_pump":
pps = filtered_pps_matrix[
(filtered_pps_matrix["Pre_Main_Heating_Source"] == pre_heating_system) &
(filtered_pps_matrix["Post_Main_Heating_Source"] == "Air to Water ASHP") &
(filtered_pps_matrix["Measure_Type"] == "B_Upgrade_nopreHCs")
# We assume we'll be making a heating system upgrade
]
if pps.shape[0] != 1:
raise ValueError("something went wrong, more than one pps for ashp")
return pps.squeeze()["Cost Savings"]
if measure_type == "high_heat_retention_storage_heater":
pps_data = filtered_pps_matrix[
filtered_pps_matrix["Post_Main_Heating_Source"] == "High Heat Retention Storage Heaters"
]
# Not every heating upgrade, that ends at HHRSH, will have a PPS. E.g. a gas boiler to HHRSH upgrade
# doesn't have a PPS
if pre_heating_system in pps_data["Pre_Main_Heating_Source"].values:
pps = pps_data[
pps_data["Pre_Main_Heating_Source"] == pre_heating_system
]
if pps.shape[0] != 1:
raise ValueError("something went wrong, more than one pps for HHRSH")
return pps.squeeze()["Cost Savings"]
return 0
raise ValueError(f"Invalid measure type for partial project ABS calculation: {measure_type}")
# -----------------------
# Main Entry Point
# -----------------------
@staticmethod
def check_solar_eligible_heating_system(mainheat_description, heating_control_description):
"""
Checks if the main heating system is eligible for solar PV funding.
:param mainheat_description: Describes the primary heating system
:param heating_control_description: Heating controls associated to the primary heating system
:return:
"""
return (
any(x in mainheat_description.lower() for x in [
"air source heat pump", "ground source heat pump", "boiler and radiators, electric"
]) or (
("electric storage heaters" in mainheat_description)
and (heating_control_description.lower() == "controls for high heat retention storage heaters")
)
)
def check_solar_eligibility(
self,
measure_types,
mainheat_description,
heating_control_description,
has_wall_insulation_recommendation: bool = False,
has_roof_insulation_recommendation: bool = False,
):
"""
Because of the various pre-requisites for solar, we have a self-contained function to check for
eligibility
Returns a tuple of booleans (has_solar, solar_eligible, meets_mir): corresponding to:
- If the package contains solar PV
- If the package is eligible for solar
- whether the package meets the minimum insulation requirements (MIR)
"""
if "solar_pv" not in measure_types:
return False, False, False
# 1) We check if there is an eligible heating system in place
has_eligibile_heating = self.check_solar_eligible_heating_system(
mainheat_description, heating_control_description
)
if not has_eligibile_heating:
# We check if there is a recommendation for an ASHP or HHRSH
if ("air_source_heat_pump" not in measure_types) and (
"high_heat_retention_storage_heater" not in measure_types):
return True, False, True
# 2) We check if there is a wall insulation measure for this property. If so, we make sure
# we have a wall insulation recommendation in this package
if has_wall_insulation_recommendation:
# Make sure we have a wall insulation recommendation
if not any(m in measure_types for m in WALL_INSULATION_MEASURES):
return True, False, False
# 3) We check if there is a roof insulation measure for this property. If so, we make sure
# we have a roof insulation recommendation in this package
if has_roof_insulation_recommendation:
# Make sure we have a roof insulation recommendation
if not any(m in measure_types for m in ROOF_INSULATION_MEASURES):
return True, False, False
return True, True, True
@staticmethod
def meets_innovation_requirement(
starting_sap: int,
measures: List[dict],
has_solar: bool,
solar_meets_mir: bool,
) -> bool:
"""
Determines if the innovation requirement is met for EPC D social housing.
- All measures must be innovation, unless:
- solar is present
- solar meets MIR (e.g. enough insulation)
- solar is innovation
- all other measures are insulation (can be non-innovation)
"""
if not (55 <= starting_sap <= 68):
return True # Only EPC D requires innovation check
# Case 1: solar + MIR met
if has_solar and solar_meets_mir:
for m in measures:
if m["type"] == "solar_pv":
if not m.get("is_innovation", False):
return False # solar must be innovation
elif m["type"] not in WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + [
"suspended_floor_insulation", "solid_floor_insulation"
]:
if not m.get("is_innovation", False):
return False # non-insulation, non-innovation = not eligible
return True
# Case 2: No solar or MIR not met — all measures must be innovation
return all(m.get("is_innovation", False) for m in measures)
@staticmethod
def has_heating_measure(measure_types: List[str]) -> bool:
"""
Heating measures include: ASHP, GSHP, FTCH, DHC, HHRSH, other storage heaters, heating controls, solar PV.
"""
heating_measures = MEASURE_MAP["heating"] + MEASURE_MAP["heating_controls"] + [
"first_time_central_heating", "district_heating_connection", "solar_pv"
]
return any(m in heating_measures for m in measure_types)
@staticmethod
def meets_minimum_insulation_preconditions(
starting_sap: int,
measure_types: List[str],
has_wall_insulation_recommendation: bool,
has_roof_insulation_recommendation: bool,
has_ftch: bool = False,
has_dhc: bool = False,
) -> bool:
"""
Applies ECO4 insulation guidance:
- **Precondition 1**:
- Applies to EPC D homes WITHOUT FTCH or DHC
- Must have at least one insulation measure IF any are recommended
- **Precondition 2**:
- Applies to EPC E/F/G or EPC D WITH FTCH or DHC
- Must include ALL *recommended* exterior wall and roof insulation (floor is exempt)
"""
# Normalize insulation types from MEASURE_MAP
wall_measures = MEASURE_MAP["wall_insulation"]
roof_measures = MEASURE_MAP["roof_insulation"]
floor_measures = MEASURE_MAP["floor_insulation"]
has_any_insulation_recommendation = (
has_wall_insulation_recommendation or has_roof_insulation_recommendation
# Floor is exempt, so we don't check for a recommendation here
)
# EPC D homes with no FTCH/DHC must include at least one insulation measure
if 55 <= starting_sap <= 68 and not has_ftch and not has_dhc:
if not has_any_insulation_recommendation:
return True
return any(m in measure_types for m in wall_measures + roof_measures + floor_measures)
# EPC EFG or D with FTCH/DHC: all recommended insulation types must be in place
if has_wall_insulation_recommendation and not any(m in measure_types for m in wall_measures):
return False
if has_roof_insulation_recommendation and not any(m in measure_types for m in roof_measures):
return False
# We treat floors are exempt due to payback periods
# if has_floor_insulation_recommendation and not any(m in measure_types for m in floor_measures):
# return False
return True
def calc_innovation_uplift(
self,
measure_types,
innovation_flags,
uplifts,
filtered_pps_matrix,
pre_heating_system,
mainheating,
main_fuel,
mainheat_energy_eff,
current_wall_uvalue,
is_partial,
existing_li_thickness,
):
"""Wrapper fundgion to calculate the innovation uplift for a project."""
project_uplifts = []
for i, measure in enumerate(measure_types):
if not innovation_flags[i]:
project_uplifts.append(0)
continue
pps = self.calculate_partial_project_abs(
measure_type=measure,
mainheating=mainheating,
main_fuel=main_fuel,
mainheat_energy_eff=mainheat_energy_eff,
current_wall_uvalue=current_wall_uvalue,
is_partial=is_partial,
existing_li_thickness=existing_li_thickness,
filtered_pps_matrix=filtered_pps_matrix,
pre_heating_system=pre_heating_system
)
project_uplifts.append(pps * uplifts[i])
return sum(project_uplifts)
def check_funding(
self,
measures: List[dict],
starting_sap: int,
ending_sap: int,
floor_area: float,
mainheat_description: str,
heating_control_description: str,
is_cavity: bool,
current_wall_uvalue: float,
is_partial: False,
existing_li_thickness: float,
mainheating: dict,
main_fuel: dict,
mainheat_energy_eff: str,
council_tax_band: str = None,
has_wall_insulation_recommendation: bool = False,
has_roof_insulation_recommendation: bool = False,
):
"""
Given a list of measures, check ECO4/GBIS eligibility.
Because measures like solar PV are subject to the minimum insulation requirements and we can get
exemptions on floor insulation recommendations, if has_wall_insulation_recommendation or
has_roof_insulation_recommendation are true, we check that the measures package contain a wall or roof
insulation measure otherwise solar PV isn't eligible
"""
# Normalize measures
measure_types, uplifts, innovation_flags, innovation_measures = self._split_measures(measures)
# If we have a heating measure, we check if we meet the pre conditions
has_ftch = "first_time_central_heating" in measure_types
has_dhc = "district_heating_connection" in measure_types
has_heating = self.has_heating_measure(measure_types)
if has_heating:
meets_mir = self.meets_minimum_insulation_preconditions(
starting_sap,
measure_types,
has_wall_insulation_recommendation,
has_roof_insulation_recommendation,
has_ftch=has_ftch,
has_dhc=has_dhc,
)
if not meets_mir:
self.eco4_eligible = False
self.eco4_eligibility_caveats.append(
EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET
)
return
# Determine if we have a solar eligible heating system
has_solar, solar_eligible, solar_meets_mir = self.check_solar_eligibility(
measure_types,
mainheat_description,
heating_control_description,
has_wall_insulation_recommendation,
has_roof_insulation_recommendation,
)
meets_innovation = self.meets_innovation_requirement(
starting_sap, measures, has_solar, solar_meets_mir
)
# 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)
filtered_pps_matrix = self.partial_project_scores_matrix[
(self.partial_project_scores_matrix["Total Floor Area Band"] == self.floor_area_band) &
(self.partial_project_scores_matrix["Starting Band"] == self.starting_sap_band)
].copy()
pre_heating_system = self._map_to_pre_main_heating(mainheating, main_fuel, mainheat_energy_eff)
if self.tenure == "Private":
# ECO4 PRS
self.eco4_prs_eligibility(starting_sap, ending_sap, measure_types, has_solar, solar_eligible)
# GBIS PRS
self.gbis_prs_eligibility(starting_sap, council_tax_band or "", measure_types)
if self.eco4_eligible:
# Calculate the full project ABS for ECO4
self.full_project_abs = self.calculate_full_project_abs()
self.eco4_uplift = self.calc_innovation_uplift(
measure_types=measure_types,
innovation_flags=innovation_flags,
uplifts=uplifts,
filtered_pps_matrix=filtered_pps_matrix,
pre_heating_system=pre_heating_system,
mainheating=mainheating,
main_fuel=main_fuel,
mainheat_energy_eff=mainheat_energy_eff,
current_wall_uvalue=current_wall_uvalue,
is_partial=is_partial,
existing_li_thickness=existing_li_thickness,
)
self.full_project_abs += self.eco4_uplift
self.eco4_funding = self.full_project_abs * (
self.eco4_social_cavity_abs_rate if is_cavity else self.eco4_social_solid_abs_rate
)
if self.gbis_eligible:
self.partial_project_abs = self.calculate_partial_project_abs(
measure_type=measure_types[0],
mainheating=mainheating,
main_fuel=main_fuel,
mainheat_energy_eff=mainheat_energy_eff,
current_wall_uvalue=current_wall_uvalue,
is_partial=is_partial,
existing_li_thickness=existing_li_thickness,
filtered_pps_matrix=filtered_pps_matrix,
pre_heating_system=pre_heating_system
)
self.gbis_funding = self.partial_project_abs * (
self.gbis_private_cavity_abs_rate if is_cavity else self.gbis_private_solid_abs_rate
)
elif self.tenure == "Social":
# ECO4 Social
self.eco4_sh_eligibility(
starting_sap, ending_sap, meets_innovation, has_solar, solar_eligible, solar_meets_mir
)
# GBIS Social
self.gbis_sh_eligibility(starting_sap, measure_types, meets_innovation)
if self.eco4_eligible:
# Calculate the full project ABS for ECO4
self.full_project_abs = self.calculate_full_project_abs()
self.eco4_uplift = self.calc_innovation_uplift(
measure_types=measure_types,
innovation_flags=innovation_flags,
uplifts=uplifts,
filtered_pps_matrix=filtered_pps_matrix,
pre_heating_system=pre_heating_system,
mainheating=mainheating,
main_fuel=main_fuel,
mainheat_energy_eff=mainheat_energy_eff,
current_wall_uvalue=current_wall_uvalue,
is_partial=is_partial,
existing_li_thickness=existing_li_thickness,
)
self.full_project_abs += self.eco4_uplift
self.eco4_funding = self.full_project_abs * (
self.eco4_social_cavity_abs_rate if is_cavity else self.eco4_social_solid_abs_rate
)
if self.gbis_eligible:
# Calculate the partial project score - this is dependent on the measure
self.partial_project_abs = self.calculate_partial_project_abs(
measure_type=measure_types[0],
mainheating=mainheating,
main_fuel=main_fuel,
mainheat_energy_eff=mainheat_energy_eff,
current_wall_uvalue=current_wall_uvalue,
is_partial=is_partial,
existing_li_thickness=existing_li_thickness,
filtered_pps_matrix=filtered_pps_matrix,
pre_heating_system=pre_heating_system
)
self.gbis_funding = self.partial_project_abs * (
self.gbis_social_cavity_abs_rate if is_cavity else self.gbis_social_solid_abs_rate
)
else:
raise NotImplementedError("Only 'Private' and 'Social' tenures are supported.")