fixed heating recommendation tests

This commit is contained in:
Khalim Conn-Kowlessar 2026-01-22 08:49:57 +00:00
parent fe5e781491
commit 201e8dd829
9 changed files with 94 additions and 730 deletions

View file

@ -236,10 +236,13 @@ def calculate_gain(
if body.goal == "Increasing EPC": if body.goal == "Increasing EPC":
current_sap = int(p.data["current-energy-efficiency"]) + already_installed_gain current_sap = int(p.data["current-energy-efficiency"]) + already_installed_gain
target_sap = ( if eco_packages is None:
eco_packages.get(p.id)[1] if eco_packages.get(p.id)[1] is not None target_sap = epc_to_sap_lower_bound(body.goal_value)
else epc_to_sap_lower_bound(body.goal_value) else:
) target_sap = (
eco_packages.get(p.id)[1] if eco_packages.get(p.id)[1] is not None
else epc_to_sap_lower_bound(body.goal_value)
)
if target_sap <= current_sap: if target_sap <= current_sap:
# We've already met or exceeded the target EPC # We've already met or exceeded the target EPC

View file

@ -488,10 +488,11 @@ def estimate_perimeter(floor_area, num_rooms):
return perimeter return perimeter
def get_exposed_floor_uvalue(insulation_thickness_str, age_band): def get_exposed_floor_uvalue(insulation_thickness_str: None | str, age_band: str) -> float:
""" """
We implement the methodology as defined in section 5.6 and table S12 of the RdSAP document We implement the methodology as defined in section 5.6 and table S12 of the RdSAP document
:param insulation_thickness_str: :param insulation_thickness_str: Insulation thickness as defined in the EPC data
:param age_band: Age band of the property
:return: :return:
""" """
@ -513,9 +514,15 @@ def get_exposed_floor_uvalue(insulation_thickness_str, age_band):
else: else:
insulation_thickness = int(insulation_thickness_str.replace("mm", "")) insulation_thickness = int(insulation_thickness_str.replace("mm", ""))
return s12[s12["age_band"] == age_band][ filtered = s12[s12["age_band"] == age_band][
f"insulation_{insulation_thickness}" f"insulation_{insulation_thickness}"
].values[0] ]
if filtered.empty:
# We don't have data so we use the median value
return float(s12[f"insulation_{insulation_thickness}"].median())
return float(filtered.values[0])
def get_floor_u_value( def get_floor_u_value(

View file

@ -223,15 +223,16 @@ testing_examples = [
'local-authority-label': 'Lewisham', 'constituency-label': 'Lewisham, Deptford', 'posttown': 'LONDON', 'local-authority-label': 'Lewisham', 'constituency-label': 'Lewisham, Deptford', 'posttown': 'LONDON',
'construction-age-band': 'England and Wales: before 1900', 'lodgement-datetime': '2014-06-26 11:40:50', 'construction-age-band': 'England and Wales: before 1900', 'lodgement-datetime': '2014-06-26 11:40:50',
'tenure': 'owner-occupied', 'fixed-lighting-outlets-count': 9.0, 'low-energy-fixed-light-count': 5.0, 'tenure': 'owner-occupied', 'fixed-lighting-outlets-count': 9.0, 'low-energy-fixed-light-count': 5.0,
'uprn': 100021936225.0, 'uprn-source': 'Address Matched', 'uprn': 100021936225, 'uprn-source': 'Address Matched',
}, },
"heating_measure_types": [ "heating_measure_types": [
"air_source_heat_pump",
'roomstat_programmer_trvs', 'roomstat_programmer_trvs',
'time_temperature_zone_control' 'time_temperature_zone_control'
], ],
"notes": "Because this property already has a boiler, we don't recommend HHR. We don't recommend an ashp " "notes": "Because this property already has a boiler, we don't recommend HHR. "
"because the home is mid-terraced. Because the heating controls are " "Because the heating controls are Programmer, no room thermostat, "
"Programmer, no room thermostat, we have a programmer, room thermostat and trvs recommendation" "we have a programmer, room thermostat and trvs recommendation"
"for heating controls and for TTZC." "for heating controls and for TTZC."
}, },
{ {
@ -369,12 +370,13 @@ testing_examples = [
'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None
}, },
"heating_measure_types": [ "heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade', 'boiler_upgrade',
'high_heat_retention_storage_heaters', 'high_heat_retention_storage_heaters',
'boiler_upgrade' 'boiler_upgrade'
], ],
"notes": "This property has assumed electric heating and is mid-terrace house. It has a mains gas connection." "notes": "This property has assumed electric heating and is mid-terrace house. It has a mains gas connection."
"We can recommend a boiler upgrade and high heat retention storage heaters" "We can recommend a boiler upgrade, high heat retention storage heaters, and an ASHP"
}, },
{ {
"epc": { "epc": {
@ -510,12 +512,12 @@ testing_examples = [
}, },
"heating_measure_types": [ "heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade', 'boiler_upgrade',
'boiler_upgrade', 'boiler_upgrade',
'high_heat_retention_storage_heaters',
], ],
"notes": "This property has assumed electric heaters. Boiler upgrade, HHR are recommended. We don't recommend" "notes": "This property has assumed electric heaters. Boiler upgrade, ASHP are recommended. We don't recommend"
"an ASHP off of the bat because it's mid-terrace." "HHRSH since there is potential community heating"
}, },
{ {
"epc": { "epc": {
@ -556,6 +558,7 @@ testing_examples = [
'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None 'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None
}, },
"heating_measure_types": [ "heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade', 'boiler_upgrade',
'high_heat_retention_storage_heaters', 'high_heat_retention_storage_heaters',
'boiler_upgrade' 'boiler_upgrade'
@ -603,12 +606,12 @@ testing_examples = [
'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None
}, },
"heating_measure_types": [ "heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade', 'boiler_upgrade',
'boiler_upgrade', 'boiler_upgrade',
'high_heat_retention_storage_heaters', 'high_heat_retention_storage_heaters',
], ],
"notes": "This property already has storage heaters with manual charge control. The home is mid terrace so" "notes": "This property already has storage heaters with manual charge control"
"the ashp is not suitable"
}, },
{ {
"epc": { "epc": {
@ -1149,6 +1152,7 @@ testing_examples = [
'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None 'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None
}, },
"heating_measure_types": [ "heating_measure_types": [
"air_source_heat_pump",
'boiler_upgrade', 'boiler_upgrade',
'boiler_upgrade', 'boiler_upgrade',
'high_heat_retention_storage_heaters' 'high_heat_retention_storage_heaters'
@ -1193,10 +1197,9 @@ testing_examples = [
'uprn': 100070685908, 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'uprn': 100070685908, 'uprn-source': 'Address Matched', 'sheating-energy-eff': None,
'sheating-env-eff': None 'sheating-env-eff': None
}, },
"heating_measure_types": [], "heating_measure_types": ["high_heat_retention_storage_heaters"],
"notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating" "notes": "This property is a flat, without mains gas connection. Currently has underfloor electric heating. "
"don't recommend anything. HHRSH isn't recommended as with underfloor heating, it's quite" "In this case we just recommend hhrsh as an additional heating system, which would become the primary"
"disruptive"
}, },
{ {
"epc": { "epc": {

View file

@ -214,7 +214,7 @@ measures_to_optimise = [
'heat_demand': np.float64(15.400000000000006), 'heat_demand': np.float64(15.400000000000006),
'kwh_savings': np.float64(202.30000000000018), 'kwh_savings': np.float64(202.30000000000018),
'energy_cost_savings': np.float64(15.065400000000011)}], [ 'energy_cost_savings': np.float64(15.065400000000011)}], [
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.', 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0, 'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0,
@ -226,7 +226,7 @@ measures_to_optimise = [
'heat_demand': np.float64(88.69999999999999), 'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2040.8566307499998), 'kwh_savings': np.float64(2040.8566307499998),
'energy_cost_savings': np.float64(525.1124110919749)}, 'energy_cost_savings': np.float64(525.1124110919749)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.', 'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0, 'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0,
@ -238,7 +238,7 @@ measures_to_optimise = [
'heat_demand': np.float64(88.69999999999999), 'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2857.1992830499994), 'kwh_savings': np.float64(2857.1992830499994),
'energy_cost_savings': np.float64(735.1573755287648)}, 'energy_cost_savings': np.float64(735.1573755287648)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.6}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.', 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0, 'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0,
@ -249,7 +249,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.42834948104), 'co2_equivalent_savings': np.float64(0.42834948104),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397), 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397),
'energy_cost_savings': np.float64(475.0617304809999)}, 'energy_cost_savings': np.float64(475.0617304809999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.6}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.', 'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0, 'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0,
@ -260,7 +260,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.599689273456), 'co2_equivalent_savings': np.float64(0.599689273456),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558), 'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558),
'energy_cost_savings': np.float64(665.0864226734)}, 'energy_cost_savings': np.float64(665.0864226734)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.', 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0, 'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0,
@ -271,7 +271,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3), 'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3),
'kwh_savings': np.float64(1650.2708274), 'kwh_savings': np.float64(1650.2708274),
'energy_cost_savings': np.float64(424.61468389001993)}, 'energy_cost_savings': np.float64(424.61468389001993)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 3.2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.', 'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0, 'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0,
@ -282,7 +282,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.53600796473952), 'heat_demand': np.float64(78.3), 'co2_equivalent_savings': np.float64(0.53600796473952), 'heat_demand': np.float64(78.3),
'kwh_savings': np.float64(2310.3791583599996), 'kwh_savings': np.float64(2310.3791583599996),
'energy_cost_savings': np.float64(594.4605574460278)}, 'energy_cost_savings': np.float64(594.4605574460278)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.8}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.', 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0, 'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0,
@ -293,7 +293,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0), 'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0),
'kwh_savings': np.float64(1453.5933906), 'kwh_savings': np.float64(1453.5933906),
'energy_cost_savings': np.float64(374.00957940138)}, 'energy_cost_savings': np.float64(374.00957940138)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.8}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.', 'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0, 'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0,
@ -304,7 +304,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.47212713326688), 'heat_demand': np.float64(64.0), 'co2_equivalent_savings': np.float64(0.47212713326688), 'heat_demand': np.float64(64.0),
'kwh_savings': np.float64(2035.03074684), 'kwh_savings': np.float64(2035.03074684),
'energy_cost_savings': np.float64(523.6134111619319)}, 'energy_cost_savings': np.float64(523.6134111619319)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.', 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0, 'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0,
@ -315,7 +315,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3), 'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3),
'kwh_savings': np.float64(1255.12594), 'kwh_savings': np.float64(1255.12594),
'energy_cost_savings': np.float64(322.94390436199996)}, 'energy_cost_savings': np.float64(322.94390436199996)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2.4}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.', 'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0, 'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0,
@ -326,7 +326,7 @@ measures_to_optimise = [
'co2_equivalent_savings': np.float64(0.40766490531199995), 'heat_demand': np.float64(54.3), 'co2_equivalent_savings': np.float64(0.40766490531199995), 'heat_demand': np.float64(54.3),
'kwh_savings': np.float64(1757.1763159999998), 'kwh_savings': np.float64(1757.1763159999998),
'energy_cost_savings': np.float64(452.1214661067999)}, 'energy_cost_savings': np.float64(452.1214661067999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.', 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0, 'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0,
@ -336,7 +336,7 @@ measures_to_optimise = [
'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856), 'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856),
'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5), 'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1048.341318), 'energy_cost_savings': np.float64(269.7382211214)}, 'kwh_savings': np.float64(1048.341318), 'energy_cost_savings': np.float64(269.7382211214)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv', {'phase': 7, 'parts': [{"type": "solar_pv", "size": 2}], 'type': 'solar_pv', 'measure_type': 'solar_pv',
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.', 'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0), 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0, 'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0,

View file

@ -7,6 +7,7 @@ from etl.epc.Record import EPCRecord
from etl.bill_savings.KwhData import KwhData from etl.bill_savings.KwhData import KwhData
from recommendations.HeatingRecommender import HeatingRecommender from recommendations.HeatingRecommender import HeatingRecommender
from recommendations.tests.test_data.heating_recommendations_data import testing_examples from recommendations.tests.test_data.heating_recommendations_data import testing_examples
from recommendations.tests.test_data.materials import materials
class TestHeatingRecommendations: class TestHeatingRecommendations:
@ -56,6 +57,7 @@ class TestHeatingRecommendations:
x["has_hot-water-only"] = False x["has_hot-water-only"] = False
x["has_mineral_and_wood"] = False x["has_mineral_and_wood"] = False
x["has_dual_fuel_appliance"] = False x["has_dual_fuel_appliance"] = False
x["has_wood_chips"] = False
epc_records = {"original_epc": test_case["epc"].copy(), "full_sap_epc": {}, "old_data": []} epc_records = {"original_epc": test_case["epc"].copy(), "full_sap_epc": {}, "old_data": []}
@ -75,6 +77,7 @@ class TestHeatingRecommendations:
"energy_assessment_is_newer": False "energy_assessment_is_newer": False
} }
) )
p.already_installed = []
# For these tests, this can be fixed # For these tests, this can be fixed
kwh_predictions = { kwh_predictions = {
@ -92,7 +95,7 @@ class TestHeatingRecommendations:
p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=kwh_predictions) p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=kwh_predictions)
recommender = HeatingRecommender(property_instance=p) recommender = HeatingRecommender(property_instance=p, materials=materials)
# Check they're empty # Check they're empty
assert not recommender.heating_recommendations assert not recommender.heating_recommendations
@ -194,9 +197,9 @@ def test_pick_model_boundaries():
""" """
assert HeatingRecommender.pick_model((2.0, 4.9), models_kw=(3, 5, 6, 8.5)) == 5 assert HeatingRecommender.pick_model((2.0, 4.9), models_kw=(3, 5, 6, 8.5)) == 5
assert HeatingRecommender.pick_model((5.0, 5.0), models_kw=(3, 5, 6, 8.5)) == 5 assert HeatingRecommender.pick_model((5.0, 5.0), models_kw=(3, 5, 6, 8.5)) == 5
assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 6 assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 8.5
assert HeatingRecommender.pick_model((8.6, 9.0), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2 assert HeatingRecommender.pick_model((8.6, 9.0), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2
assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) is None assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2 # largest model
def test_parameter_validation_and_defaults(): def test_parameter_validation_and_defaults():

View file

@ -13,6 +13,7 @@ class TestLightingRecommendations:
epc_record.prepared_epc = {"county": "Greater London Authority"} epc_record.prepared_epc = {"county": "Greater London Authority"}
input_property0 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) input_property0 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property0.lighting = {"low_energy_proportion": 0} input_property0.lighting = {"low_energy_proportion": 0}
input_property0.already_installed = []
# Test for invalid materials # Test for invalid materials
with pytest.raises(ValueError): with pytest.raises(ValueError):
LightingRecommendations(input_property0, []) LightingRecommendations(input_property0, [])
@ -23,6 +24,7 @@ class TestLightingRecommendations:
epc_record.prepared_epc = {"county": "Greater London Authority"} epc_record.prepared_epc = {"county": "Greater London Authority"}
input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property1.lighting = {"low_energy_proportion": 100} input_property1.lighting = {"low_energy_proportion": 100}
input_property1.already_installed = []
lr = LightingRecommendations(input_property1, materials) lr = LightingRecommendations(input_property1, materials)
lr.recommend() lr.recommend()
@ -35,19 +37,16 @@ class TestLightingRecommendations:
input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record) input_property1 = Property(id=1, postcode="F4k3 6", address="623 fake street", epc_record=epc_record)
input_property1.lighting = {"low_energy_proportion": 0.80} input_property1.lighting = {"low_energy_proportion": 0.80}
input_property1.number_lighting_outlets = 20 input_property1.number_lighting_outlets = 20
input_property1.already_installed = []
lr = LightingRecommendations(input_property1, materials) lr = LightingRecommendations(input_property1, materials)
lr.recommend() lr.recommend()
assert len(lr.recommendation) == 1 assert len(lr.recommendation) == 1
# Note - this test may be dependent on the ofgem price caps # Note - this test may be dependent on the ofgem price caps
assert lr.recommendation == [ assert lr.recommendation[0]["description_simulation"] == {'lighting-energy-eff': 'Very Good',
{'phase': 0, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', 'lighting-description': 'Low energy lighting in all '
'description': 'Install low energy lighting in 4 outlets', 'starting_u_value': None, 'new_u_value': None, 'fixed outlets',
'already_installed': False, 'sap_points': 0.4, 'kwh_savings': 219.0, 'low-energy-lighting': 100}
'energy_cost_savings': 56.348699999999994, 'co2_equivalent_savings': 0.035478, assert lr.recommendation[0]["description"] == 'Install low energy lighting in 4 outlets'
'description_simulation': {'lighting-energy-eff': 'Very Good', assert lr.recommendation[0]["total"] == 14
'lighting-description': 'Low energy lighting in all fixed outlets',
'low-energy-lighting': 100}, 'total': 188.76000000000002, 'subtotal': 157.3,
'vat': 31.460000000000004, 'contingency': 14.3, 'material': 80.0, 'labour_hours': 3.2, 'labour_days': 0.4,
'labour_cost': 63.0, 'survey': False}]

View file

@ -108,7 +108,7 @@ class TestCalculateGain:
body = SimpleNamespace(goal="Increasing EPC", goal_value="C", simulate_sap_10=False) body = SimpleNamespace(goal="Increasing EPC", goal_value="C", simulate_sap_10=False)
prop = SimpleNamespace(data={"current-energy-efficiency": "50"}) prop = SimpleNamespace(data={"current-energy-efficiency": "50"})
gain = optimiser_functions.calculate_gain(body, prop, fixed_gain=2) gain = optimiser_functions.calculate_gain(body, prop, fixed_gain=2)
assert gain == 18.5 assert gain == 17.5
class TestAddRequiredMeasures: class TestAddRequiredMeasures:
@ -235,7 +235,7 @@ class TestIncreasingEpcE2e:
gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain) gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain)
assert gain == 18.5, "Expected gain to be calculated correctly based on fixed gain and SAP target" assert gain == 17.5, "Expected gain to be calculated correctly based on fixed gain and SAP target"
optimiser = ( optimiser = (
GainOptimiser( GainOptimiser(
@ -254,7 +254,8 @@ class TestIncreasingEpcE2e:
# Collect selected measure IDs # Collect selected measure IDs
selected = {r["id"] for r in solution} selected = {r["id"] for r in solution}
assert selected == {'8_phase=7', '5_phase=4', '7_phase=6'} assert selected == {'7_phase=6', '5_phase=4', '10_phase=7'}
assert float(optimiser.solution_gain) == 17.6
# Add required measures (none here) # Add required measures (none here)
solution = optimiser_functions.add_required_measures( solution = optimiser_functions.add_required_measures(
@ -265,11 +266,11 @@ class TestIncreasingEpcE2e:
assert solution == [ assert solution == [
{'id': '5_phase=4', 'cost': 58.8, 'gain': 2, 'type': 'low_energy_lighting'}, {'id': '5_phase=4', 'cost': 58.8, 'gain': 2, 'type': 'low_energy_lighting'},
{'id': '7_phase=6', 'cost': 30.0, 'gain': np.float64(3.6), 'type': 'secondary_heating'}, {'id': '7_phase=6', 'cost': 30.0, 'gain': np.float64(3.6), 'type': 'secondary_heating'},
{'id': '8_phase=7', 'cost': 6013.139999999999, 'gain': np.float64(13.0), 'type': 'solar_pv'} {'id': '10_phase=7', 'cost': 5826.491999999999, 'gain': np.float64(12.0), 'type': 'solar_pv'}
] ]
total_optimised_gain = sum(m["gain"] for m in solution) total_optimised_gain = sum(m["gain"] for m in solution)
assert total_optimised_gain == 18.6, "Total gain of optimised measures should meet or exceed target gain" assert total_optimised_gain == 17.6, "Total gain of optimised measures should meet or exceed target gain"
selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected)

View file

@ -1,52 +1,6 @@
from pandas import Timestamp
from numpy import nan
import datetime
import numpy as np
import pandas as pd
import pytest import pytest
from copy import deepcopy
from recommendations.optimiser import optimiser_functions from recommendations.optimiser.funding_optimiser import build_heat_pump_paths
from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths, build_heat_pump_paths
from backend.Funding import Funding
from backend.app.plan.schemas import WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES, ECO4_ELIGIBILE_FABRIC_MEASURES
ALLOWED_FABRIC_TYPES = set(WALL_INSULATION_MEASURES + ROOF_INSULATION_MEASURES + ECO4_ELIGIBILE_FABRIC_MEASURES)
@pytest.fixture
def mock_project_scores_matrix():
data = []
floor_segments = ["0-72", "73-97", "98-199", "200"]
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 bands:
for finish in bands:
if start != finish: # skip identical start/finish (no SAP movement)
data.append({
"Floor Area Segment": floor,
"Starting Band": start,
"Finishing Band": finish,
"Cost Savings": cost
})
cost += 5.0 # increment to create variety
return pd.DataFrame(data)
@pytest.fixture
def mock_partial_scores_matrix():
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']
return df
class DummyProp: class DummyProp:
@ -105,619 +59,6 @@ def p():
return DummyProp() return DummyProp()
@pytest.fixture
def funding(monkeypatch, mock_partial_scores_matrix, mock_project_scores_matrix):
"""Simple Funding that returns zero uplift so costs stay as provided."""
# Build the Funding with tiny in-memory frames (avoid test I/O)
f = Funding(
project_scores_matrix=mock_project_scores_matrix,
partial_project_scores_matrix=mock_partial_scores_matrix,
whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]),
eco4_social_cavity_abs_rate=13.5, eco4_social_solid_abs_rate=17,
eco4_private_cavity_abs_rate=13.5, eco4_private_solid_abs_rate=17,
gbis_social_cavity_abs_rate=21, gbis_social_solid_abs_rate=25,
gbis_private_cavity_abs_rate=22, gbis_private_solid_abs_rate=28,
tenure="Social"
)
# Keep innovation_uplift simple for the first test
# monkeypatch.setattr(f, "get_innovation_uplift", lambda *args, **kwargs: 0.0)
# If your solar precondition matters, you can force True/False here:
# monkeypatch.setattr(
# __import__("backend").Funding, "check_solar_eligible_heating_system",
# staticmethod(lambda mainheat_description, heating_control_description: False)
# )
return f
@pytest.fixture
def property_recommendations():
"""Short sample; replace with your full block if you want."""
recs = [
[{'phase': 0, 'parts': [{'id': 2466, 'type': 'external_wall_insulation',
'description': 'EWI Pro EPS external wall insulation system with '
'Brick Slip finish',
'depth': 150.0, 'depth_unit': 'mm', 'cost': None,
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.02631579,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.038,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 0.0, 'plant_cost': 0.0,
'total_cost': 298.35,
'notes': 'This is the quoted value from SCIS',
'is_installer_quote': True, 'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 19090.810139104888,
'labour_hours': 0.0, 'labour_days': 0.0}],
'type': 'external_wall_insulation', 'measure_type': 'external_wall_insulation',
"innovation_rate": 0,
'description': 'Install 150mm EWI Pro EPS external wall insulation system with Brick '
'Slip finish on external walls',
'starting_u_value': 1.7, 'new_u_value': 0.32, 'already_installed': False,
'sap_points': np.float64(9.6),
'simulation_config': {'is_as_built_ending': False, 'walls_is_assumed_ending': False,
'walls_insulation_thickness_ending': 'average',
'external_insulation_ending': True,
'walls_energy_eff_ending': 'Good',
'walls_thermal_transmittance_ending': 0.23},
'description_simulation': {'walls-description': 'Solid brick, with external insulation',
'walls-energy-eff': 'Good'}, 'total': 19090.810139104888,
'labour_hours': 0.0, 'labour_days': 0.0, 'survey': False,
'recommendation_id': '0_phase=0', 'efficiency': 11229.568317120522,
'co2_equivalent_savings': np.float64(0.5), 'heat_demand': np.float64(37.099999999999994),
'kwh_savings': np.float64(1827.8999999999996),
'energy_cost_savings': np.float64(136.1247882352941)}, {'phase': 0, 'parts': [
{'id': 2373, 'type': 'internal_wall_insulation', 'description': 'SWIP EcoBatt & Plastered finish',
'depth': 95.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.03125,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': 0.032,
'thermal_conductivity_unit': None,
'link': 'SCIS', 'created_at': Timestamp('2025-03-16 15:26:22.379496'), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 2.1,
'plant_cost': 0.0, 'total_cost': 89.0, 'notes': None, 'is_installer_quote': True,
'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 5694.929118083911, 'labour_hours': 134.37473199973275,
'labour_days': 4.199210374991648}], 'type': 'internal_wall_insulation',
'measure_type': 'internal_wall_insulation',
"innovation_rate": 0,
'description': 'Install 95mm '
'SWIP EcoBatt & '
'Plastered '
'finish on '
'internal walls',
'starting_u_value': 1.7,
'new_u_value': 0.32,
'already_installed': False,
'sap_points': 6,
'simulation_config': {
'is_as_built_ending': False,
'walls_is_assumed_ending':
False,
'walls_insulation_thickness_ending': 'average',
'internal_insulation_ending': True,
'walls_energy_eff_ending':
'Good',
'walls_thermal_transmittance_ending': 0.29},
'description_simulation': {
'walls-description': 'Solid '
'brick, with internal '
'insulation',
'walls-energy-eff': 'Good'},
'total': 5694.929118083911,
'labour_hours': 134.37473199973275,
'labour_days': 4.199210374991648,
'survey': True,
'recommendation_id': '1_phase=0',
'efficiency': 3349.6383047552417,
'co2_equivalent_savings': np.float64(
0.5),
'heat_demand': np.float64(
35.30000000000001),
'kwh_savings': np.float64(
1432.3999999999996),
'energy_cost_savings': np.float64(
106.67167058823532)}], [
{'phase': 1, 'parts': [{'id': 2351, 'type': 'loft_insulation',
'description': 'Knauf Loft Roll 44 glass fibre roll',
'depth': 300.0, 'depth_unit': 'mm', 'cost': None,
'cost_unit': 'gbp_per_m2', 'r_value_per_mm': 0.022727273,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.044,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 0.11, 'plant_cost': 0.0,
'total_cost': 15.0,
'notes': 'This is the cost if there is less than 100mm '
'existing insulation',
'is_installer_quote': True, 'quantity': 63.98796761892035,
'quantity_unit': 'm2', 'total': 645.0, 'labour_hours': 8,
'labour_days': 1}], 'type': 'loft_insulation',
'measure_type': 'loft_insulation',
"innovation_rate": 0,
'description': 'Install 300mm of Knauf Loft Roll 44 glass fibre roll in your loft',
'starting_u_value': 2.3, 'new_u_value': 2.3, 'sap_points': np.float64(2.4),
'already_installed': False,
'simulation_config': {'is_loft_ending': True, 'roof_is_assumed_ending': False,
'roof_insulation_thickness_ending': '300',
'roof_thermal_transmittance_ending': 2.3,
'roof_energy_eff_ending': 'Very Good'},
'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation',
'roof-energy-eff': 'Very Good'}, 'total': 645.0,
'labour_hours': 8, 'labour_days': 1, 'survey': False, 'recommendation_id': '2_phase=1',
'efficiency': 278.1347826086957,
'co2_equivalent_savings': np.float64(0.10000000000000009),
'heat_demand': np.float64(1.5), 'kwh_savings': np.float64(566.1499999999996),
'energy_cost_savings': np.float64(42.16152352941185)}], [{'phase': 2, 'parts': [
{'id': 2329, 'type': 'mechanical_ventilation', 'description': 'Mechanical Extract Ventilation',
'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': 'gbp_per_unit', 'r_value_per_mm': nan,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None,
'thermal_conductivity_unit': None,
'link': 'SCIS', 'created_at': datetime.datetime(2025, 3, 16, 15, 26, 22, 379496), 'is_active': True,
'prime_material_cost': None, 'material_cost': 0.0, 'labour_cost': 0.0, 'labour_hours_per_unit': 0.0,
'plant_cost': 0.0, 'total_cost': 350.0, 'notes': None, 'is_installer_quote': True, 'total': 700.0,
'quantity': 2,
'quantity_unit': 'part'}], 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation',
"innovation_rate": 0,
'description': 'Install 2 '
'Mechanical '
'Extract '
'Ventilation units',
'starting_u_value': None,
'new_u_value': None,
'already_installed': False,
'sap_points': np.float64(
-0.10000000000000142),
'heat_demand': np.float64(
-3.3999999999999773),
'kwh_savings': np.float64(
-53.80000000000018),
'co2_equivalent_savings': np.float64(
0.0),
'energy_cost_savings': np.float64(
-4.0065176470588995),
'total': 700.0,
'labour_hours': 8,
'labour_days': 1.0,
'simulation_config': {
'mechanical_ventilation_ending':
'mechanical, '
'extract '
'only'},
'description_simulation': {
'mechanical-ventilation': 'mechanical, '
'extract only'},
'recommendation_id': '3_phase=2',
'efficiency': 0}], [
{'phase': 3, 'parts': [{'id': 2409, 'type': 'suspended_floor_insulation',
'description': 'Q-bot underfloor insulation', 'depth': 75.0,
'depth_unit': 'mm', 'cost': None, 'cost_unit': 'gbp_per_m2',
'r_value_per_mm': 0.045454547,
'r_value_unit': 'square_meter_kelvin_per_watt',
'thermal_conductivity': 0.022,
'thermal_conductivity_unit': 'watt_per_meter_kelvin',
'link': 'SCIS',
'created_at': Timestamp('2025-03-16 15:26:22.379496'),
'is_active': True, 'prime_material_cost': None,
'material_cost': 0.0, 'labour_cost': 0.0,
'labour_hours_per_unit': 1.63, 'plant_cost': 0.0,
'total_cost': 93.75,
'notes': 'Linearly interpolated based on Qbot costs',
'is_installer_quote': True, 'quantity': 43.0,
'quantity_unit': 'm2', 'total': 4031.25,
'labour_hours': 70.08999999999999,
'labour_days': 2.920416666666666}],
'type': 'suspended_floor_insulation', 'measure_type': 'suspended_floor_insulation',
"innovation_rate": 0,
'description': 'Install 75mm Q-bot underfloor insulation insulation in suspended '
'floor',
'starting_u_value': 0.83, 'new_u_value': 0.22, 'sap_points': 2, 'survey': True,
'already_installed': False, 'simulation_config': {'floor_is_assumed_ending': False,
'floor_insulation_thickness_ending': 'average',
'floor_thermal_transmittance_ending': 0.685593},
'description_simulation': {'floor-description': 'Suspended, insulated'},
'total': 4031.25, 'labour_hours': 70.08999999999999, 'labour_days': 2.920416666666666,
'recommendation_id': '4_phase=3', 'efficiency': 4856.707710843373,
'co2_equivalent_savings': np.float64(0.20000000000000018),
'heat_demand': np.float64(33.5), 'kwh_savings': np.float64(1021.1999999999998),
'energy_cost_savings': np.float64(76.04936470588231)}], [
{'phase': 4, 'parts': [], 'type': 'low_energy_lighting',
'measure_type': 'low_energy_lighting',
"innovation_rate": 0,
'description': 'Install low energy lighting in -886 outlets', 'starting_u_value': None,
'new_u_value': None, 'already_installed': False, 'sap_points': 2,
'kwh_savings': -48508.5, 'energy_cost_savings': -12481.237049999998,
'co2_equivalent_savings': -7.858377,
'description_simulation': {'lighting-energy-eff': 'Very Good',
'lighting-description': 'Low energy lighting in all fixed'
' outlets',
'low-energy-lighting': 100}, 'total': -3411.1000000000004,
'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
'recommendation_id': '5_phase=4', 'efficiency': -1705.5500000000002,
'heat_demand': np.float64(5.099999999999994)}], [
{'type': 'heating', 'phase': 5, 'measure_type': 'time_temperature_zone_control',
"innovation_rate": 0,
'parts': [],
'description': 'Upgrade heating controls to Smart Thermostats, room sensors and '
'smart radiator valves (time & temperature zone control)',
'total': 739.576, 'subtotal': 700.48, 'vat': 39.096000000000004,
'labour_hours': 3.6199999999999997, 'labour_days': np.float64(1.0),
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(2.9),
'already_installed': False, 'simulation_config': {
'thermostatic_control_ending': 'time and temperature zone control',
'switch_system_ending': None, 'trvs_ending': None,
'mainheatc_energy_eff_ending': 'Very Good'}, 'description_simulation': {
'mainheatcont-description': 'Time and temperature zone control',
'mainheatc-energy-eff': 'Very Good'}, 'recommendation_id': '6_phase=5',
'efficiency': 739.576, 'co2_equivalent_savings': np.float64(0.30000000000000027),
'heat_demand': np.float64(6.599999999999994),
'kwh_savings': np.float64(876.8000000000002),
'energy_cost_savings': np.float64(65.29581176470589)}], [
{'phase': 6, 'parts': [], 'type': 'secondary_heating',
'measure_type': 'secondary_heating',
"innovation_rate": 0,
'description': 'Remove the secondary heating system', 'starting_u_value': None,
'new_u_value': None, 'sap_points': np.float64(3.6), 'already_installed': False,
'total': 30.0, 'subtotal': 25.0, 'vat': 5.0, 'labour_hours': 3.0,
'labour_days': np.float64(1.0),
'simulation_config': {'secondheat_description_ending': 'None'},
'description_simulation': {'secondheat-description': 'None'},
'recommendation_id': '7_phase=6', 'efficiency': 30.0,
'co2_equivalent_savings': np.float64(0.10000000000000009),
'heat_demand': np.float64(15.400000000000006),
'kwh_savings': np.float64(196.29999999999927),
'energy_cost_savings': np.float64(14.61857647058821)}], [
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 6013.139999999999, 'subtotal': 5010.95, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996),
'description_simulation': {'photo-supply': np.float64(65.0)},
'recommendation_id': '8_phase=7', 'efficiency': np.float64(462.54923076923075),
'co2_equivalent_savings': np.float64(0.47347873833399995),
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2040.8566307499998),
'energy_cost_savings': np.float64(525.1124110919749)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 4.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(13.0),
'already_installed': False, 'total': 10537.008, 'subtotal': 8780.84, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(65.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(4081.7132614999996),
'description_simulation': {'photo-supply': np.float64(65.0)},
'recommendation_id': '9_phase=7', 'efficiency': np.float64(810.5390769230769),
'co2_equivalent_savings': np.float64(0.6628702336675999),
'heat_demand': np.float64(88.69999999999999),
'kwh_savings': np.float64(2857.1992830499994),
'energy_cost_savings': np.float64(735.1573755287648)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 5826.491999999999, 'subtotal': 4855.41, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3692.66794),
'description_simulation': {'photo-supply': np.float64(60.0)},
'recommendation_id': '10_phase=7', 'efficiency': np.float64(485.54099999999994),
'co2_equivalent_savings': np.float64(0.42834948104),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(1846.33397),
'energy_cost_savings': np.float64(475.0617304809999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.6 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(12.0),
'already_installed': False, 'total': 10350.359999999999, 'subtotal': 8625.3, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(60.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3692.66794),
'description_simulation': {'photo-supply': np.float64(60.0)},
'recommendation_id': '11_phase=7', 'efficiency': np.float64(862.5299999999999),
'co2_equivalent_savings': np.float64(0.599689273456),
'heat_demand': np.float64(83.69999999999999), 'kwh_savings': np.float64(2584.867558),
'energy_cost_savings': np.float64(665.0864226734)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 5642.604, 'subtotal': 4702.17, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(3300.5416548),
'description_simulation': {'photo-supply': np.float64(55.0)},
'recommendation_id': '12_phase=7', 'efficiency': np.float64(512.964),
'co2_equivalent_savings': np.float64(0.3828628319568), 'heat_demand': np.float64(78.3),
'kwh_savings': np.float64(1650.2708274),
'energy_cost_savings': np.float64(424.61468389001993)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 3.2 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(11.0),
'already_installed': False, 'total': 10166.472, 'subtotal': 8472.06, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(55.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(3300.5416548),
'description_simulation': {'photo-supply': np.float64(55.0)},
'recommendation_id': '13_phase=7', 'efficiency': np.float64(924.2247272727273),
'co2_equivalent_savings': np.float64(0.53600796473952),
'heat_demand': np.float64(78.3), 'kwh_savings': np.float64(2310.3791583599996),
'energy_cost_savings': np.float64(594.4605574460278)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 5458.727999999999, 'subtotal': 4548.94, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2907.1867812),
'description_simulation': {'photo-supply': np.float64(45.0)},
'recommendation_id': '14_phase=7', 'efficiency': np.float64(606.5253333333333),
'co2_equivalent_savings': np.float64(0.3372336666192), 'heat_demand': np.float64(64.0),
'kwh_savings': np.float64(1453.5933906),
'energy_cost_savings': np.float64(374.00957940138)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.8 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(9.0),
'already_installed': False, 'total': 9982.596, 'subtotal': 8318.83, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(45.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2907.1867812),
'description_simulation': {'photo-supply': np.float64(45.0)},
'recommendation_id': '15_phase=7', 'efficiency': np.float64(1109.1773333333333),
'co2_equivalent_savings': np.float64(0.47212713326688),
'heat_demand': np.float64(64.0), 'kwh_savings': np.float64(2035.03074684),
'energy_cost_savings': np.float64(523.6134111619319)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 5274.852, 'subtotal': 4395.71, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2510.25188),
'description_simulation': {'photo-supply': np.float64(40.0)},
'recommendation_id': '16_phase=7', 'efficiency': np.float64(659.3565),
'co2_equivalent_savings': np.float64(0.29118921808), 'heat_demand': np.float64(54.3),
'kwh_savings': np.float64(1255.12594),
'energy_cost_savings': np.float64(322.94390436199996)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.4 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(8.0),
'already_installed': False, 'total': 9798.72, 'subtotal': 8165.6, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(40.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2510.25188),
'description_simulation': {'photo-supply': np.float64(40.0)},
'recommendation_id': '17_phase=7', 'efficiency': np.float64(1224.84),
'co2_equivalent_savings': np.float64(0.40766490531199995),
'heat_demand': np.float64(54.3), 'kwh_savings': np.float64(1757.1763159999998),
'energy_cost_savings': np.float64(452.1214661067999)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 5090.976, 'subtotal': 4242.48, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0),
'has_battery': False, 'initial_ac_kwh_per_year': np.float64(2096.682636),
'description_simulation': {'photo-supply': np.float64(35.0)},
'recommendation_id': '18_phase=7', 'efficiency': np.float64(727.2822857142856),
'co2_equivalent_savings': np.float64(0.243215185776), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1048.341318),
'energy_cost_savings': np.float64(269.7382211214)},
{'phase': 7, 'parts': [], 'type': 'solar_pv', 'measure_type': 'solar_pv',
"innovation_rate": 0,
'description': 'Install a 2.0 kilowatt-peak (kWp) solar panel system, with a battery.',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(7.0),
'already_installed': False, 'total': 9614.844, 'subtotal': 8012.369999999999, 'vat': 0,
'labour_hours': 48, 'labour_days': 2, 'photo_supply': np.float64(35.0),
'has_battery': True, 'initial_ac_kwh_per_year': np.float64(2096.682636),
'description_simulation': {'photo-supply': np.float64(35.0)},
'recommendation_id': '19_phase=7', 'efficiency': np.float64(1373.5491428571427),
'co2_equivalent_savings': np.float64(0.3405012600864), 'heat_demand': np.float64(48.5),
'kwh_savings': np.float64(1467.6778451999999),
'energy_cost_savings': np.float64(377.6335095699599)}]
]
return recs
def _attach_costs_and_uplifts(recs, funding, p):
"""Mimic what your script did: add cost fields & innovation uplift."""
out = deepcopy(recs)
for group in out:
for r in group:
if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating"]:
(
r["partial_project_score"],
r["partial_project_funding"],
r["innovation_uplift"],
r["uplift_project_score"],
) = (
0, 0, 0, 0
)
continue
(
r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"],
r["uplift_project_score"]
) = funding.get_innovation_uplift(
measure=r,
starting_sap=55,
floor_area=70.0,
is_cavity=False,
current_wall_uvalue=1.7,
is_partial=False,
existing_li_thickness=150,
mainheating=p.main_heating,
main_fuel=p.main_fuel,
mainheat_energy_eff="Very Good",
)
# the optimiser_functions.prepare_input_measures will translate these to input format; but
# for safety add explicit cost fields some downstream code expects:
r["total"] = float(r["total"])
return out
def _to_input_measures(recs, p):
"""Use your own helper so we test the full pipeline."""
property_measure_types = {rec["type"] for grp in recs for rec in grp}
needs_ventilation = any(
x in property_measure_types for x in optimiser_functions.assumptions.measures_needing_ventilation
) and not getattr(p, "has_ventilation", False)
# goal="Increasing EPC", add_uplift=True for Social path
return optimiser_functions.prepare_input_measures(
recs, goal="Increasing EPC", needs_ventilation=needs_ventilation, funding=True
)
def _types_of(picked_items):
return {item["type"] for item in picked_items}
def test_social_fabric_only_returns_only_fabric_types(p, funding, property_recommendations, monkeypatch):
# 1) prepare data like your script
recs = _attach_costs_and_uplifts(property_recommendations, funding, p)
input_measures = _to_input_measures(recs, p)
# 2) run optimiser wrapper (budget and target_gain can be modest for the test)
budget = 30000.0
target_gain = 8.0
solutions = optimise_with_funding_paths(
p=p,
input_measures=input_measures,
housing_type="Social",
budget=budget,
target_gain=target_gain,
funding=funding
)
# 3) basic shape assertions
assert isinstance(solutions, pd.DataFrame)
assert not solutions.empty
# 4) find the fabric-only ECO4 row
fabric_rows = solutions[
solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "fabric-only:eco4")]
assert not fabric_rows.empty, "Expected a fabric-only:eco4 solution for Social tenure"
# 5) ensure only fabric measure types are present in that solution
picked_types = _types_of(fabric_rows.iloc[0]["items"])
assert picked_types == {'internal_wall_insulation+mechanical_ventilation',
'suspended_floor_insulation'}, "incorrect types selected"
# 6) respect budget
assert fabric_rows.iloc[0]["total_cost"] <= budget + 1e-9
# (optional) ensure unfunded baseline also appears
unfunded_rows = solutions[
solutions["path"].apply(lambda x: isinstance(x, dict) and x.get("reference") == "unfunded:all")]
assert not unfunded_rows.empty
def test_private_solid_wall_no_innovation_epc_d(p, funding, mock_project_scores_matrix, mock_partial_scores_matrix):
"""
We have a specific test for this case which was implemented incorrectly originally.
This is an EPC D property and so shouldn't be eligible for ECO4. Instead, only GBIS should be considered.
"""
# Overwrite the data - copied from real example
p2 = deepcopy(p)
p2.data = {
"current-energy-rating": "D",
"current-energy-efficiency": 68,
"mainheat-energy-eff": "Good",
}
p2.walls = {'original_description': 'Sandstone or limestone, as built, no insulation (assumed)',
'clean_description': 'Sandstone or limestone, as built, no insulation', 'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False,
'is_solid_brick': False, 'is_system_built': False, 'is_timber_frame': False,
'is_granite_or_whinstone': False, 'is_as_built': True, 'is_cob': False, 'is_assumed': True,
'is_sandstone_or_limestone': True, 'is_park_home': False, 'insulation_thickness': 'none',
'external_insulation': False, 'internal_insulation': False}
funding2 = Funding(
tenure="Private",
project_scores_matrix=mock_project_scores_matrix,
partial_project_scores_matrix=mock_partial_scores_matrix,
whlg_eligible_postcodes=pd.DataFrame([{"Postcode": "ab12cd"}]),
eco4_social_cavity_abs_rate=12.5,
eco4_social_solid_abs_rate=17,
eco4_private_cavity_abs_rate=12.5,
eco4_private_solid_abs_rate=17,
gbis_social_cavity_abs_rate=21,
gbis_social_solid_abs_rate=25,
gbis_private_cavity_abs_rate=21,
gbis_private_solid_abs_rate=28,
)
input_measures = [
[{'id': '0_phase=0', 'cost': np.float64(4441.202499013676), 'gain': np.float64(3.4000000000000057),
'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0),
'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756,
'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3),
'uplift_project_score': np.float64(0.0)}], [
{'id': '2_phase=2', 'cost': np.float64(2280.0), 'gain': np.float64(0.4), 'type': 'secondary_glazing',
'innovation_uplift': np.float64(0.0), 'cost_minus_uplift': np.float64(2280.0),
'raw_cost': np.float64(2280.0), 'partial_project_funding': np.float64(1421.1999999999998),
'partial_project_score': np.float64(83.6), 'uplift_project_score': np.float64(0.0)}], [
{'id': '3_phase=3', 'cost': np.float64(604.5840000000001), 'gain': np.float64(1.2),
'type': 'time_temperature_zone_control', 'innovation_uplift': np.float64(0.0),
'cost_minus_uplift': np.float64(604.5840000000001), 'raw_cost': 604.5840000000001,
'partial_project_funding': np.float64(702.0999999999999), 'partial_project_score': np.float64(41.3),
'uplift_project_score': np.float64(0.0)}], [
{'id': '4_phase=4', 'cost': 60.0, 'gain': np.float64(0.0), 'type': 'secondary_heating',
'innovation_uplift': 0, 'cost_minus_uplift': 60.0, 'raw_cost': 60.0, 'partial_project_funding': 0,
'partial_project_score': 0, 'uplift_project_score': 0}]
]
solutions = optimise_with_funding_paths(
p=p2,
input_measures=input_measures,
housing_type="Private",
budget=None,
target_gain=1.5,
funding=funding2
)
# 3) basic shape assertions
assert isinstance(solutions, pd.DataFrame)
assert not solutions.empty
# We should have 2 rows
assert solutions.shape[0] == 2
# We should only have None or GBIS
assert set(solutions["scheme"].unique()) == {"none", "gbis"}
meets_upgrade_gbis = solutions[solutions["meets_upgrade_target"] & solutions["is_eligible"]]
assert meets_upgrade_gbis.shape[0] == 1
# Check exact result
assert meets_upgrade_gbis.squeeze().to_dict() == {
'fixed_ids': ['0_phase=0'], 'items': [
{'id': '0_phase=0', 'cost': 3881.2024990136756, 'gain': np.float64(3.4000000000000057),
'type': 'internal_wall_insulation+mechanical_ventilation', 'innovation_uplift': np.float64(0.0),
'cost_minus_uplift': np.float64(4441.202499013676), 'raw_cost': 3881.2024990136756,
'partial_project_funding': np.float64(2300.1000000000004), 'partial_project_score': np.float64(135.3),
'uplift_project_score': np.float64(0.0)}], 'total_cost': 3881.2024990136756,
'total_gain': 3.4000000000000057, 'path': [{'AND': ['internal_wall_insulation+mechanical_ventilation'],
'reference':
'internal_wall_insulation+mechanical_ventilation:gbis'}],
'scheme': 'gbis', 'is_eligible': True, 'unfunded_items': [], 'meets_upgrade_target': True, 'starting_sap': 68,
'floor_area': 70.0, 'ending_sap': 71.4, 'starting_band': 'High_D', 'ending_band': 'Low_C',
'floor_area_band': '0-72', 'project_score': 540.0, 'full_project_funding': 0.0,
'partial_project_funding': 2300.1000000000004, 'partial_project_score': 135.3, 'total_uplift': 0.0,
'total_uplift_score': 0.0
}
def test_build_heat_pump_paths(): def test_build_heat_pump_paths():
eg1 = build_heat_pump_paths([], ["loft_insulation"]) eg1 = build_heat_pump_paths([], ["loft_insulation"])

View file

@ -279,27 +279,34 @@ class TestRecommendationUtils:
# Test with wall_type not in default_wall_thickness # Test with wall_type not in default_wall_thickness
def test_wall_type_not_in_default_wall_thickness(self): def test_wall_type_not_in_default_wall_thickness(self):
with pytest.raises(IndexError): # THis previously raised an error but because it largely dicates the thickness, often defaulted to
recommendation_utils.get_floor_u_value( # 300, we just use the default instead of raising an error. We see cases of this in the wild, where we
floor_type="solid", # estimate EPCs and end up with unusual wall types, so we have fallbacks in place
area=100, assert recommendation_utils.get_floor_u_value(
perimeter=40, floor_type="solid",
age_band="A", area=100,
wall_type="InvalidWallType", perimeter=40,
insulation_thickness=None, age_band="A",
) wall_type="InvalidWallType",
insulation_thickness=None,
) == 0.6
# Test with age_band not in s11 # Test with age_band not in s11
def test_age_band_not_in_s11(self): def test_age_band_not_in_s11(self):
with pytest.raises(IndexError): # This previously raised an error but because it largely dicates the thickness, often defaulted to
recommendation_utils.get_floor_u_value( # 300, we just use the default instead of raising an error. We see cases of this in the wild, where we
floor_type="solid", # might estimate an EPC
area=100, recommendation_utils.get_floor_u_value(
perimeter=40, floor_type="solid",
age_band="Z", area=100,
wall_type="Cavity", perimeter=40,
insulation_thickness=None, age_band="Z",
) wall_type="Cavity",
insulation_thickness=None,
)
def test_age_band_not_in_s11_exposed_floor(self):
recommendation_utils.get_exposed_floor_uvalue(None, "BadValue")
def test_convert_thickness_to_numeric(self): def test_convert_thickness_to_numeric(self):