diff --git a/backend/Funding.py b/backend/Funding.py index e52b1943..74d43e3e 100644 --- a/backend/Funding.py +++ b/backend/Funding.py @@ -1,16 +1,14 @@ from enum import Enum -import pandas as pd -import numpy as np from typing import List -from backend.app.plan.schemas import HousingType, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES +from backend.app.plan.schemas import HousingType, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, MEASURE_MAP 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" - NEEDS_INSULATION_TO_MINIMUM_STANDARDS = "needs_insulation_to_minimum_standards" + MINIMUM_INSULATION_PRECONDITIONS_NOT_MET = "minimum_insulation_preconditions_not_met" class Funding: @@ -97,9 +95,9 @@ class Funding: 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_flags = [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 + return measure_types, innovation_flags, innovation_measures @staticmethod def _meets_upgrade_target(starting_sap: int, ending_sap: int) -> bool: @@ -205,7 +203,8 @@ class Funding: ending_sap: int, has_innovation: bool, has_solar: bool, - solar_eligible: bool + solar_eligible: bool, + solar_meets_mir: bool, ): """ ECO4 Social Housing eligibility. @@ -215,6 +214,11 @@ class Funding: """ 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 @@ -234,6 +238,7 @@ class Funding: self.eco4_eligible = True self.eco4_eligibility_caveats = [] + return self.eco4_eligible = True self.eco4_eligibility_caveats = [] @@ -442,11 +447,14 @@ class Funding: 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) + 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 + 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( @@ -457,23 +465,116 @@ class Funding: # 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 + 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 + 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 + return True, False, False - return True, True + 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 check_funding( self, @@ -501,10 +602,30 @@ class Funding: """ # Normalize measures - measure_types, has_innovation, innovation_measures = self._split_measures(measures) + measure_types, 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 = self.check_solar_eligibility( + has_solar, solar_eligible, solar_meets_mir = self.check_solar_eligibility( measure_types, mainheat_description, heating_control_description, @@ -512,6 +633,10 @@ class Funding: 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) @@ -531,10 +656,12 @@ class Funding: elif self.tenure == "Social": # ECO4 Social - self.eco4_sh_eligibility(starting_sap, ending_sap, has_innovation, has_solar, solar_eligible) + 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, has_innovation) + self.gbis_sh_eligibility(starting_sap, measure_types, meets_innovation) if self.eco4_eligible: # Calculate the full project ABS for ECO4 diff --git a/recommendations/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv b/backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv similarity index 100% rename from recommendations/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv rename to backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv diff --git a/backend/tests/test_data/heating_scenarios.py b/backend/tests/test_data/heating_scenarios.py new file mode 100644 index 00000000..6144558f --- /dev/null +++ b/backend/tests/test_data/heating_scenarios.py @@ -0,0 +1,104 @@ +from backend.Funding import EligibilityCaveats + +heating_scenarios = [ + { + "description": "EPC D with ASHP and no insulation at all — fails precondition 1", + "measures": [{"type": "air_source_heat_pump"}], + "starting_sap": 60, + "mainheat_description": "air source heat pump", + "heating_control_description": "roomstat_programmer_trvs", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": False, + "expected_eligibility": False, + "expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET], + }, + { + "description": "EPC D with ASHP and no insulation at all — fails precondition 1", + "measures": [{"type": "air_source_heat_pump"}], + "starting_sap": 60, + "mainheat_description": "air source heat pump", + "heating_control_description": "roomstat_programmer_trvs", + "has_wall_insulation_recommendation": False, + "has_roof_insulation_recommendation": False, + "expected_eligibility": False, + "expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET], + }, + { + "description": "EPC D with ASHP and floor insulation — passes precondition 1", + "measures": [ + {"type": "air_source_heat_pump"}, + {"type": "suspended_floor_insulation"} + ], + "starting_sap": 60, + "has_wall_insulation_recommendation": False, + "has_roof_insulation_recommendation": False, + "mainheat_description": "air source heat pump", + "heating_control_description": "roomstat_programmer_trvs", + "expected_eligibility": True, + "expected_caveats": [], + }, + { + "description": "EPC E with ASHP and only floor insulation — fails precondition 2 due to missing wall/roof", + "measures": [ + {"type": "air_source_heat_pump"}, + {"type": "suspended_floor_insulation"} + ], + "starting_sap": 45, + "mainheat_description": "air source heat pump", + "heating_control_description": "roomstat_programmer_trvs", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": True, + "expected_eligibility": False, + "expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET], + }, + { + "description": "EPC E with ASHP and both wall and roof insulation — passes precondition 2", + "measures": [ + {"type": "air_source_heat_pump"}, + {"type": "external_wall_insulation"}, + {"type": "loft_insulation"} + ], + "starting_sap": 45, + "mainheat_description": "air source heat pump", + "heating_control_description": "roomstat_programmer_trvs", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": True, + "expected_eligibility": True, + "expected_caveats": [], + }, + { + "description": "EPC D with FTCH and no insulation — still passes (exempt from precondition 1)", + "measures": [{"type": "first_time_central_heating"}], + "starting_sap": 60, + "mainheat_description": "none", + "heating_control_description": "none", + "expected_eligibility": True, + "expected_caveats": [], + }, + { + "description": "EPC E with FTCH and no insulation — fails precondition 2", + "measures": [{"type": "first_time_central_heating"}], + "starting_sap": 45, + "mainheat_description": "none", + "heating_control_description": "none", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": True, + "expected_eligibility": False, + "expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET], + }, + { + "description": "EPC E with FTCH and wall/roof insulation — passes precondition 2", + "measures": [ + {"type": "first_time_central_heating"}, + {"type": "external_wall_insulation"}, + {"type": "loft_insulation"}, + ], + "starting_sap": 45, + "mainheat_description": "none", + "heating_control_description": "none", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": True, + "expected_eligibility": True, + "expected_caveats": [], + }, +] diff --git a/backend/tests/test_data/innovation_measure_fixtures.py b/backend/tests/test_data/innovation_measure_fixtures.py new file mode 100644 index 00000000..db1ed4ed --- /dev/null +++ b/backend/tests/test_data/innovation_measure_fixtures.py @@ -0,0 +1,170 @@ +from backend.Funding import Funding, EligibilityCaveats + +innovation_scenarios = [ + # 1) Innovation PV, non-eligible heating system in place, EPC D - not eligible + { + "description": "Innovation PV, non-eligible heating system in place, EPC D", + "measures": [{"type": "solar_pv", "is_innovation": True}], + "starting_sap": 60, + "mainheat_description": "Electric storage heaters", + "heating_control_description": "Manual charge control", + "has_wall_insulation_recommendation": False, + "has_roof_insulation_recommendation": False, + "expected_eligibility": False, + "expected_caveats": [EligibilityCaveats.SOLAR_NEEDS_HEATING], + }, + # 2) Innovation PV, eligible heating system in place, EPC D - eligible + { + "description": "Innovation PV, eligible heating system in place, EPC D", + "measures": [{"type": "solar_pv", "is_innovation": True}], + "starting_sap": 60, + "mainheat_description": "Air source heat pump, radiators", + "heating_control_description": "Programmer, room thermostat and TRVs", + "has_wall_insulation_recommendation": False, + "has_roof_insulation_recommendation": False, + "expected_eligibility": True, + "expected_caveats": [], + }, + # 3) Innovation PV, non-eligible heating system, heating upgrade to HHRSH, EPC E - eligible + { + "description": "Innovation PV + HHRSH upgrade, EPC E", + "measures": [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "high_heat_retention_storage_heater", "is_innovation": True} + ], + "starting_sap": 50, + "mainheat_description": "Electric storage heaters", + "heating_control_description": "Manual charge control", + "has_wall_insulation_recommendation": False, + "has_roof_insulation_recommendation": False, + "expected_eligibility": True, + "expected_caveats": [], + }, + # 4) Innovation PV + HHRSH upgrade + { + "description": "Innovation PV + HHRSH upgrade, EPC E", + "measures": [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "high_heat_retention_storage_heater", "is_innovation": True} + ], + "starting_sap": 50, + "mainheat_description": "Electric storage heaters", + "heating_control_description": "Manual charge control", + "has_wall_insulation_recommendation": False, + "has_roof_insulation_recommendation": False, + "expected_eligibility": True, + "expected_caveats": [], + }, + # 5) Innovation PV, needs wall insulation, no wall insulation measure - not eligible + { + "description": "Innovation PV, wall insulation recommended, but not installed", + "measures": [{"type": "solar_pv", "is_innovation": True}], + "starting_sap": 60, + "mainheat_description": "Air source heat pump, radiators", + "heating_control_description": "Programmer, room thermostat and TRVs", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": False, + "expected_eligibility": False, + "expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET], + }, + # 6) Innovation PV, wall insulation recommended and installed - eligible + { + "description": "Innovation PV, wall insulation recommended and installed", + "measures": [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "internal_wall_insulation", "is_innovation": False} + ], + "starting_sap": 60, + "mainheat_description": "Air source heat pump, radiators", + "heating_control_description": "Programmer, room thermostat and TRVs", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": False, + "expected_eligibility": True, + "expected_caveats": [], + }, + # 7) Innovation PV, needs roof insulation, no roof insulation measure - not eligible + { + "description": "Innovation PV, roof insulation recommended, not installed", + "measures": [{"type": "solar_pv", "is_innovation": True}], + "starting_sap": 60, + "mainheat_description": "Air source heat pump, radiators", + "heating_control_description": "Programmer, room thermostat and TRVs", + "has_wall_insulation_recommendation": False, + "has_roof_insulation_recommendation": True, + "expected_eligibility": False, + "expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET], + }, + # 8) Innovation PV, roof insulation recommended and installed - eligible + { + "description": "Innovation PV, roof insulation recommended and installed", + "measures": [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "loft_insulation", "is_innovation": False} + ], + "starting_sap": 60, + "mainheat_description": "Air source heat pump, radiators", + "heating_control_description": "Programmer, room thermostat and TRVs", + "has_wall_insulation_recommendation": False, + "has_roof_insulation_recommendation": True, + "expected_eligibility": True, + "expected_caveats": [], + }, + # 9) Innovation PV, needs both roof + wall insulation, no insulation - not eligible + { + "description": "Innovation PV, both insulations recommended, none installed", + "measures": [{"type": "solar_pv", "is_innovation": True}], + "starting_sap": 60, + "mainheat_description": "Air source heat pump, radiators", + "heating_control_description": "Programmer, room thermostat and TRVs", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": True, + "expected_eligibility": False, + "expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET], + }, + # 10) Innovation PV, both recommended, only wall insulation installed - not eligible + { + "description": "Innovation PV, both insulations recommended, only wall done", + "measures": [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "internal_wall_insulation", "is_innovation": False} + ], + "starting_sap": 60, + "mainheat_description": "Air source heat pump, radiators", + "heating_control_description": "Programmer, room thermostat and TRVs", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": True, + "expected_eligibility": False, + "expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET], + }, + # 11) Innovation PV, both recommended, only roof insulation installed - not eligible + { + "description": "Innovation PV, both insulations recommended, only roof done", + "measures": [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "loft_insulation", "is_innovation": False} + ], + "starting_sap": 60, + "mainheat_description": "Air source heat pump, radiators", + "heating_control_description": "Programmer, room thermostat and TRVs", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": True, + "expected_eligibility": False, + "expected_caveats": [EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET], + }, + # 12) Innovation PV, both recommended, both installed - eligible + { + "description": "Innovation PV, both insulations recommended and installed", + "measures": [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "internal_wall_insulation", "is_innovation": False}, + {"type": "loft_insulation", "is_innovation": False} + ], + "starting_sap": 60, + "mainheat_description": "Air source heat pump, radiators", + "heating_control_description": "Programmer, room thermostat and TRVs", + "has_wall_insulation_recommendation": True, + "has_roof_insulation_recommendation": True, + "expected_eligibility": True, + "expected_caveats": [], + }, +] diff --git a/backend/tests/test_funding.py b/backend/tests/test_funding.py index 8df90f23..be59771d 100644 --- a/backend/tests/test_funding.py +++ b/backend/tests/test_funding.py @@ -1,6 +1,7 @@ import pytest import pandas as pd from backend.Funding import Funding, EligibilityCaveats +from backend.tests.test_data.innovation_measure_fixtures import innovation_scenarios @pytest.fixture @@ -30,7 +31,7 @@ def mock_project_scores_matrix(): @pytest.fixture def mock_partial_scores_matrix(): - df = pd.read_csv("recommendations/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv") + df = pd.read_csv("backend/tests/test_data/ECO4_Partial_Project_Scores_Matrix_v6.csv") df.columns = ['Measure category', 'Measure_Type', 'Pre_Main_Heating_Source', 'Post_Main_Heating_Source', 'Total Floor Area Band', 'Starting Band', 'Average Treatable Factor', 'Cost Savings', 'SAP Savings'] @@ -358,8 +359,8 @@ def test_eco4_sh_epc_d_requires_innovation(mock_project_scores_matrix, mock_part existing_li_thickness=0, ) - assert funding5.eco4_eligible - assert not funding5.eco4_eligibility_caveats + assert not funding5.eco4_eligible + assert EligibilityCaveats.INNOVATION_REQUIRED in funding5.eco4_eligibility_caveats # Test with innovation solar, an eligible heating system but a package that excludes the required # fabric upgrades @@ -393,7 +394,39 @@ def test_eco4_sh_epc_d_requires_innovation(mock_project_scores_matrix, mock_part ) assert not funding6.eco4_eligible - assert not funding6.eco4_eligibility_caveats + assert EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET in funding6.eco4_eligibility_caveats + + # Test with innovation solar, an eligible heating system but a package that includes the required + # fabric upgrades + funding7 = 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="Social", + ) + measures7 = [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "cavity_wall_insulation", "is_innovation": False}, + {"type": "loft_insulation", "is_innovation": False} + ] + funding7.check_funding( + measures=measures7, + starting_sap=60, # EPC D + ending_sap=69, + floor_area=80, + mainheat_description="Air source heat pump, radiators", + heating_control_description="Programmer, room thermostat and TRVs", + is_cavity=True, + current_wall_uvalue=2, + is_partial=False, + existing_li_thickness=0, + ) + assert funding7.eco4_eligible + assert not funding7.eco4_eligibility_caveats def test_eco4_sh_solar_pv_requires_heating(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes): @@ -458,8 +491,8 @@ def test_eco4_sh_solar_pv_with_heating_is_ok(mock_project_scores_matrix, mock_pa existing_li_thickness=0, ) - assert funding.eco4_eligible - assert EligibilityCaveats.SOLAR_NEEDS_HEATING not in funding.eco4_eligibility_caveats + assert not funding.eco4_eligible + assert EligibilityCaveats.INNOVATION_REQUIRED in funding.eco4_eligibility_caveats def test_eco4_upgrade_requirement_e_to_c_pass(mock_project_scores_matrix, mock_partial_scores_matrix, @@ -596,3 +629,281 @@ def test_eco4_upgrade_requirement_f_to_e_fail(mock_project_scores_matrix, mock_p ) assert not funding.eco4_eligible + + +### ------------------------- +### INNOVATION PRODUCTS +### ------------------------- +def test_epc_d_social_no_innovation_no_heating(mock_project_scores_matrix, mock_partial_scores_matrix, + mock_whlg_postcodes): + 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="Social" + ) + + measures = [ + {"type": "solar_pv", "is_innovation": True} + ] + + funding.check_funding( + measures=measures, + starting_sap=61, + ending_sap=69, + floor_area=80, + mainheat_description="Electric storage heaters", + heating_control_description="Manual charge control", + is_cavity=True, + has_wall_insulation_recommendation=False, + has_roof_insulation_recommendation=False, + current_wall_uvalue=2, + is_partial=False, + existing_li_thickness=0 + ) + + assert not funding.eco4_eligible + assert EligibilityCaveats.SOLAR_NEEDS_HEATING in funding.eco4_eligibility_caveats + + +def test_epc_d_social_with_heating_and_insulation(mock_project_scores_matrix, mock_partial_scores_matrix, + mock_whlg_postcodes): + 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="Social" + ) + + # Should NOT be eligible as the ASHP is not an innovation measure + measures = [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "internal_wall_insulation", "is_innovation": False}, + {"type": "loft_insulation", "is_innovation": False}, + {"type": "air_source_heat_pump", "is_innovation": False} + ] + + funding.check_funding( + measures=measures, + starting_sap=61, + ending_sap=69, + floor_area=80, + mainheat_description="Boiler and radiators, mains gas", + heating_control_description="Programmer, room thermostat and TRVs", + is_cavity=True, + has_wall_insulation_recommendation=True, + has_roof_insulation_recommendation=True, + current_wall_uvalue=2, + is_partial=False, + existing_li_thickness=0 + ) + + assert not funding.eco4_eligible + assert EligibilityCaveats.INNOVATION_REQUIRED in funding.eco4_eligibility_caveats + + +def test_epc_d_social_solar_with_only_minimum_insulation_should_fail( + mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes +): + 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="Social" + ) + + # Solar PV innovation with insulation, but no heating system upgrade => not eligible + measures = [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "internal_wall_insulation", "is_innovation": False}, + {"type": "loft_insulation", "is_innovation": False} + ] + + funding.check_funding( + measures=measures, + starting_sap=61, + ending_sap=69, + floor_area=80, + mainheat_description="Electric storage heaters", + heating_control_description="Manual charge control", + is_cavity=True, + has_wall_insulation_recommendation=True, + has_roof_insulation_recommendation=True, + current_wall_uvalue=2, + is_partial=False, + existing_li_thickness=0 + ) + + assert not funding.eco4_eligible + assert EligibilityCaveats.SOLAR_NEEDS_HEATING in funding.eco4_eligibility_caveats + + +def test_epc_d_social_solar_with_ashp_and_no_insulation_should_fail( + mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes +): + 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="Social" + ) + + # Solar PV innovation with heating, but no insulation when insulation is recommended => not eligible + measures = [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "air_source_heat_pump", "is_innovation": False} + ] + + funding.check_funding( + measures=measures, + starting_sap=61, + ending_sap=69, + floor_area=80, + mainheat_description="Electric storage heaters", + heating_control_description="Manual charge control", + is_cavity=True, + has_wall_insulation_recommendation=True, + has_roof_insulation_recommendation=True, + current_wall_uvalue=2, + is_partial=False, + existing_li_thickness=0 + ) + + assert not funding.eco4_eligible + assert EligibilityCaveats.MINIMUM_INSULATION_PRECONDITIONS_NOT_MET in funding.eco4_eligibility_caveats + + +def test_epc_d_social_solar_with_heating_and_minimum_insulation_should_pass( + mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes +): + 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="Social" + ) + + # Innovation solar + insulation measures + eligible heating upgrade = not valid because the heat pump isn;t + # an innovation measure + measures = [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "internal_wall_insulation", "is_innovation": False}, + {"type": "loft_insulation", "is_innovation": False}, + {"type": "air_source_heat_pump", "is_innovation": False} + ] + + funding.check_funding( + measures=measures, + starting_sap=61, + ending_sap=69, + floor_area=80, + mainheat_description="Electric storage heaters", + heating_control_description="Manual charge control", + is_cavity=True, + has_wall_insulation_recommendation=True, + has_roof_insulation_recommendation=True, + current_wall_uvalue=2, + is_partial=False, + existing_li_thickness=0 + ) + + assert not funding.eco4_eligible + assert EligibilityCaveats.INNOVATION_REQUIRED in funding.eco4_eligibility_caveats + + funding2 = 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="Social" + ) + + # Innovation solar + insulation measures + eligible heating upgrade = should be valid because the + # heat pump is an innovation measure + measures2 = [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "internal_wall_insulation", "is_innovation": False}, + {"type": "loft_insulation", "is_innovation": False}, + {"type": "air_source_heat_pump", "is_innovation": True} + ] + + funding2.check_funding( + measures=measures2, + starting_sap=61, + ending_sap=69, + floor_area=80, + mainheat_description="Electric storage heaters", + heating_control_description="Manual charge control", + is_cavity=True, + has_wall_insulation_recommendation=True, + has_roof_insulation_recommendation=True, + current_wall_uvalue=2, + is_partial=False, + existing_li_thickness=0 + ) + + assert funding2.eco4_eligible + assert not funding2.eco4_eligibility_caveats + + +@pytest.mark.parametrize("scenario", innovation_scenarios) +def test_custom_eco4_scenarios( + scenario, + mock_project_scores_matrix, + mock_partial_scores_matrix, + mock_whlg_postcodes +): + 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="Social" + ) + + funding.check_funding( + measures=scenario["measures"], + starting_sap=scenario["starting_sap"], + ending_sap=69, + floor_area=80, + mainheat_description=scenario["mainheat_description"], + heating_control_description=scenario["heating_control_description"], + is_cavity=True, + current_wall_uvalue=2, + is_partial=False, + existing_li_thickness=0, + has_wall_insulation_recommendation=scenario.get("has_wall_insulation_recommendation", False), + has_roof_insulation_recommendation=scenario.get("has_roof_insulation_recommendation", False) + ) + + assert funding.eco4_eligible == scenario["expected_eligibility"], f"Failed: {scenario['description']}" + for caveat in scenario.get("expected_caveats", []): + assert caveat in funding.eco4_eligibility_caveats, f"Missing caveat in: {scenario['description']}" + for caveat in funding.eco4_eligibility_caveats: + assert caveat in scenario.get("expected_caveats", []), f"Unexpected caveat in: {scenario['description']}"