added insulation precondition unit tests

This commit is contained in:
Khalim Conn-Kowlessar 2025-08-06 20:04:38 +01:00
parent 1aa6371ff5
commit e2e2e8c71c
5 changed files with 735 additions and 23 deletions

View file

@ -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

View file

@ -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": [],
},
]

View file

@ -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": [],
},
]

View file

@ -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']}"