diff --git a/backend/Funding.py b/backend/Funding.py index 016db276..020cc20a 100644 --- a/backend/Funding.py +++ b/backend/Funding.py @@ -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 E–G + 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) diff --git a/backend/tests/test_funding.py b/backend/tests/test_funding.py index 7a18fa55..7e9bc3b4 100644 --- a/backend/tests/test_funding.py +++ b/backend/tests/test_funding.py @@ -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