[Cadded upgrade logic to funding engine

This commit is contained in:
Khalim Conn-Kowlessar 2025-08-01 18:57:47 +01:00
parent 9a558c5bb5
commit 30e1eca74b
2 changed files with 165 additions and 19 deletions

View file

@ -83,7 +83,8 @@ class Funding:
return "98-199"
return "200"
def _split_measures(self, measures: List[dict]):
@staticmethod
def _split_measures(measures: List[dict]):
"""
Extracts measure types and flags innovation.
measures: list of dicts like {"type": "solar_pv", "is_innovation": True}
@ -93,11 +94,24 @@ class Funding:
innovation_measures = [m["type"] for m in measures if m.get("is_innovation", False)]
return measure_types, has_innovation, 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, measure_types: List, mainheat_description: str,
def eco4_prs_eligibility(self, starting_sap: int, ending_sap: int, measure_types: List, mainheat_description: str,
heating_control_description: str):
"""
ECO4 PRS eligibility:
@ -106,24 +120,27 @@ class Funding:
- Tenant must be on benefits (flagged)
"""
meets_epc = starting_sap <= 54 # EPC EG
meets_upgrade_target = self._meets_upgrade_target(starting_sap, ending_sap)
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"
]) or (
("electric storage heaters" in mainheat_description)
and (
heating_control_description.lower() == "controls for high heat "
"retention storage heaters")
)
has_eligible_electric_heating = (
any(x in mainheat_description 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")
)
)
solar_counts_as_renewable = has_eligible_electric_heating and "solar_pv" in measure_types
if meets_epc and (has_swi or has_renewable or has_ftch or has_dhc or solar_counts_as_renewable):
if meets_upgrade_target and meets_epc and (
has_swi or has_renewable or has_ftch or has_dhc or solar_counts_as_renewable
):
self.eco4_eligible = True
self.eco4_eligibility_caveats.append(EligibilityCaveats.TENANT_ON_BENEFITS_OR_LOW_INCOME)
@ -163,7 +180,7 @@ class Funding:
# Social Housing
# -----------------------
def eco4_sh_eligibility(self, starting_sap: int, measure_types: List, has_innovation: bool,
def eco4_sh_eligibility(self, starting_sap: int, ending_sap: int, measure_types: List, has_innovation: bool,
innovation_measures: List):
"""
ECO4 Social Housing eligibility.
@ -172,7 +189,9 @@ class Funding:
If solar PV is the innovation measure, must also install ASHP or HHRSH.
"""
meets_epc = starting_sap <= 69
if not meets_epc:
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
@ -262,7 +281,8 @@ class Funding:
if self.tenure == "Private":
# ECO4 PRS
self.eco4_prs_eligibility(starting_sap, measure_types, mainheat_description, heating_control_description)
self.eco4_prs_eligibility(starting_sap, ending_sap, measure_types, mainheat_description,
heating_control_description)
# GBIS PRS
self.gbis_prs_eligibility(starting_sap, council_tax_band or "", measure_types)
@ -273,7 +293,7 @@ class Funding:
elif self.tenure == "Social":
# ECO4 Social
self.eco4_sh_eligibility(starting_sap, measure_types, has_innovation, innovation_measures)
self.eco4_sh_eligibility(starting_sap, ending_sap, measure_types, has_innovation, innovation_measures)
# GBIS Social
self.gbis_sh_eligibility(starting_sap, measure_types, has_innovation, innovation_measures)

View file

@ -7,13 +7,15 @@ from backend.Funding import Funding, EligibilityCaveats
def mock_project_scores_matrix():
data = []
floor_segments = ["0-72", "73-97", "98-199", "200"]
starting_bands = ["Low_G", "High_G", "Low_F", "High_F", "Low_E", "High_E", "Low_D", "High_D", "Low_C", "High_C"]
finishing_bands = ["Low_C", "High_C", "Low_B"] # covers likely improvement targets
bands = [
"Low_G", "High_G", "Low_F", "High_F", "Low_E", "High_E", "Low_D", "High_D", "Low_C", "High_C", "Low_B",
"High_B", "Low_A", "High_A"
]
cost = 50.0
for floor in floor_segments:
for start in starting_bands:
for finish in finishing_bands:
for start in bands:
for finish in bands:
if start != finish: # skip identical start/finish (no SAP movement)
data.append({
"Floor Area Segment": floor,
@ -276,3 +278,127 @@ def test_eco4_sh_solar_pv_with_heating_is_ok(mock_project_scores_matrix, mock_pa
assert funding.eco4_eligible
assert EligibilityCaveats.SOLAR_NEEDS_HEATING not in funding.eco4_eligibility_caveats
def test_eco4_upgrade_requirement_e_to_c_pass(mock_project_scores_matrix, mock_partial_scores_matrix,
mock_whlg_postcodes):
"""EPC E upgraded to C should pass ECO4 upgrade rule."""
funding = Funding(
project_scores_matrix=mock_project_scores_matrix,
partial_project_scores_matrix=mock_partial_scores_matrix,
whlg_eligible_postcodes=mock_whlg_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 = [{"type": "internal_wall_insulation", "is_innovation": False}]
# E (SAP 50) → C (SAP 70) meets upgrade rule
funding.check_funding(
measures=measures,
starting_sap=50,
ending_sap=70,
floor_area=80,
mainheat_description="Boiler and radiators, mains gas",
heating_control_description="Programmer, room thermostat and TRVs",
is_cavity=True,
council_tax_band="B"
)
assert funding.eco4_eligible
def test_eco4_upgrade_requirement_e_to_d_fail(mock_project_scores_matrix, mock_partial_scores_matrix,
mock_whlg_postcodes):
"""EPC E upgraded to D should FAIL ECO4 upgrade rule (needs to hit C)."""
funding = Funding(
project_scores_matrix=mock_project_scores_matrix,
partial_project_scores_matrix=mock_partial_scores_matrix,
whlg_eligible_postcodes=mock_whlg_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 = [{"type": "internal_wall_insulation", "is_innovation": False}]
# E (SAP 50) → D (SAP 65) does NOT meet ECO4 upgrade rule
funding.check_funding(
measures=measures,
starting_sap=50,
ending_sap=65,
floor_area=80,
mainheat_description="Boiler and radiators, mains gas",
heating_control_description="Programmer, room thermostat and TRVs",
is_cavity=True,
council_tax_band="B"
)
assert not funding.eco4_eligible
def test_eco4_upgrade_requirement_f_to_d_pass(mock_project_scores_matrix, mock_partial_scores_matrix,
mock_whlg_postcodes):
"""EPC F upgraded to D should pass ECO4 upgrade rule."""
funding = Funding(
project_scores_matrix=mock_project_scores_matrix,
partial_project_scores_matrix=mock_partial_scores_matrix,
whlg_eligible_postcodes=mock_whlg_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 = [{"type": "internal_wall_insulation", "is_innovation": False}]
# F (SAP 35) → D (SAP 60) is OK for ECO4
funding.check_funding(
measures=measures,
starting_sap=35,
ending_sap=60,
floor_area=80,
mainheat_description="Boiler and radiators, mains gas",
heating_control_description="Programmer, room thermostat and TRVs",
is_cavity=True,
council_tax_band="B"
)
assert funding.eco4_eligible
def test_eco4_upgrade_requirement_f_to_e_fail(mock_project_scores_matrix, mock_partial_scores_matrix,
mock_whlg_postcodes):
"""EPC F upgraded only to E should FAIL ECO4 upgrade rule (needs to hit at least D)."""
funding = Funding(
project_scores_matrix=mock_project_scores_matrix,
partial_project_scores_matrix=mock_partial_scores_matrix,
whlg_eligible_postcodes=mock_whlg_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 = [{"type": "internal_wall_insulation", "is_innovation": False}]
# F (SAP 35) → E (SAP 50) does NOT meet ECO4 rule
funding.check_funding(
measures=measures,
starting_sap=35,
ending_sap=50,
floor_area=80,
mainheat_description="Boiler and radiators, mains gas",
heating_control_description="Programmer, room thermostat and TRVs",
is_cavity=True,
council_tax_band="B"
)
assert not funding.eco4_eligible