added initial test for recommendation impact calculation with adjustment

This commit is contained in:
Khalim Conn-Kowlessar 2026-01-20 10:54:22 +00:00
parent 3ebb8667c8
commit 2b071e6afd
3 changed files with 960 additions and 28 deletions

View file

@ -65,7 +65,7 @@ data["Wall Insulation"].value_counts()
data["Wall Construction"].value_counts()
as_built_map = {
"Cavity": {"insulated_age_bands":[], "partial_insulated_age_bands": []},
"Cavity": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
"Solid Brick": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
"System": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
"Timber Frame": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
@ -74,6 +74,7 @@ as_built_map = {
"Cob": {"insulated_age_bands": [], "partial_insulated_age_bands": []},
}
def map_wall_construction(wall_constuction, wall_insulation, construction_age_band):
if wall_insulation == "AsBuilt":
# Deduce based on wall construction and age band
@ -83,13 +84,10 @@ def map_wall_construction(wall_constuction, wall_insulation, construction_age_ba
# We check if the age band is in insulated or partial insulated, and if neither, we assume uninsulated
# Variables we want to map
'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type',
'Attachment', 'Construction Years', 'Wall Construction',
'Wall Insulation', 'Roof Construction', 'Roof Insulation',
'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating',
'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN',
'Total Floor Area (m2)'
# 'Org Ref', 'Address 1', 'Address 2', 'Address 3', 'Postcode', 'Type',
# 'Attachment', 'Construction Years', 'Wall Construction',
# 'Wall Insulation', 'Roof Construction', 'Roof Insulation',
# 'Floor Construction', 'Floor Insulation', 'Glazing', 'Heating',
# 'Boiler Efficiency', 'Main Fuel', 'Controls Adequacy', 'UPRN',
# 'Total Floor Area (m2)'

View file

@ -486,6 +486,34 @@ class Recommendations:
return predicted_appliances_cost_reduction, predicted_appliances_kwh_reduction
@staticmethod
def _check_veniltation_out_of_bounds(sap_impact, ventilation_sap_limit):
return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0)
@staticmethod
def _adjust_ventilation_sap(sap_impact, ventilation_sap_limit):
if sap_impact >= 0:
return -1
if sap_impact < ventilation_sap_limit:
return ventilation_sap_limit
@staticmethod
def _filter_phase_adjustment(phase_adjustments):
"""
Utility function to select the entry from the dictionary, by phase, with the largest
phase adjustment
:param phase_adjustments: List of phase adjustments, in the form
[{"recommendation_id": str, "phase": int, "adjustment_amount": float}]
:return:
"""
filtered_adjustments = []
phase_adjustments = sorted(phase_adjustments, key=lambda x: x["phase"])
for phase, adjustments in groupby(phase_adjustments, key=lambda x: x["phase"]):
adjustments = list(adjustments)
adjustments.sort(key=lambda x: x["sap_adjustment"], reverse=True)
filtered_adjustments.append(adjustments[0])
return filtered_adjustments
@classmethod
def calculate_recommendation_impact(
cls,
@ -493,6 +521,7 @@ class Recommendations:
all_predictions,
recommendations,
representative_recommendations,
debug=False
):
"""
@ -507,6 +536,9 @@ class Recommendations:
:param all_predictions: dictionary of predictions from the model apis
:param recommendations: dictionary of recommendations for the property
:param representative_recommendations: dictionary of representative recommendations for the property
:param debug: boolean, indicating if the function is running in debug mode. The only difference is that
adjustments are returned for testing
:return:
"""
@ -533,7 +565,9 @@ class Recommendations:
rec["phase"] for recs in property_recommendations for rec in recs
)
impact_summary = []
# We keep a history of adjustments we have made, so that we ensure that we adjust future
# phases for SAP
impact_summary, adjustments = [], []
for recommendations_by_type in property_recommendations:
for rec in recommendations_by_type:
if rec["type"] in ["trickle_vents", "draught_proofing", "extension_cavity_wall_insulation"]:
@ -610,6 +644,16 @@ class Recommendations:
current_phase_sap = rec["sap_points"] + previous_phase_values["sap"]
else:
current_phase_sap = phase_energy_efficiency_metrics["sap_change"]
# If we have an adjustment, we apply it here. We de-dupe, taking the
# largest adjustment by phase - though, they should all be the same
phase_adjustments = [a for a in adjustments if a["phase"] < rec["phase"]]
if phase_adjustments:
phase_adjustments = cls._filter_phase_adjustment(phase_adjustments)
total_adjustment = sum(
a["sap_adjustment"] for a in phase_adjustments
)
# Take the max, by phase, subtract from the current phase sap
current_phase_sap -= total_adjustment
current_phase_values = {
"sap": current_phase_sap,
@ -687,26 +731,46 @@ class Recommendations:
if rec["type"] == "mechanical_ventilation":
# ventilation is capped by having no greater and a -4 impact
ventilation_sap_limit = -4
def _check_veniltation_out_of_bounds(sap_impact):
return (sap_impact < ventilation_sap_limit) or (sap_impact >= 0)
def _adjust_ventilation_sap(sap_impact):
if sap_impact >= 0:
return -1
if sap_impact < ventilation_sap_limit:
return ventilation_sap_limit
ventilation_out_of_bounds = _check_veniltation_out_of_bounds(property_phase_impact["sap"])
ventilation_out_of_bounds = cls._check_veniltation_out_of_bounds(
property_phase_impact["sap"], ventilation_sap_limit
)
if ventilation_out_of_bounds:
previous_modelled_sap = previous_phase_values.get("sap_prediction", 0)
proposed_sap_impact = current_phase_sap - previous_modelled_sap
proposal_out_of_bounds = _check_veniltation_out_of_bounds(proposed_sap_impact)
proposal_out_of_bounds = cls._check_veniltation_out_of_bounds(
proposed_sap_impact, ventilation_sap_limit
)
if proposal_out_of_bounds:
property_phase_impact["sap"] = _adjust_ventilation_sap(proposed_sap_impact)
else:
property_phase_impact["sap"] = proposed_sap_impact
proposed_sap_impact = cls._adjust_ventilation_sap(
proposed_sap_impact, ventilation_sap_limit
)
# We keep track of the adjustment
# In this case, if the SAP impact has increased, then the adustment should be negative
# otherwise it should be positive
# When we add the total adjustment, it's an addition
# Example
# Before: 60, impact -2 => 58
# After: 60, impact -1 (So the impact is bigger) => 59
# So in this case, we need to make sure we add 1 to all future predictions so
# the adjustment should be positive
# Before: 60, impact 1 => 61
# After: 60, impact -1 => 59
# So in this case, we need to make sure we subtract 1 to all future predictions so
# the adjustment should be negative
# Both cases are reflected in sap adjustment
sap_adjustment = proposed_sap_impact - float(property_phase_impact["sap"])
adjustments.append(
{
"recommendation_id": rec["recommendation_id"],
"phase": rec["phase"],
"sap_adjustment": sap_adjustment,
}
)
property_phase_impact["sap"] = proposed_sap_impact
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
@ -720,16 +784,40 @@ class Recommendations:
property_instance.data["roof-energy-eff"], property_instance.roof["insulation_thickness"]
)
if li_sap_limit is not None:
property_phase_impact["sap"] = min(property_phase_impact["sap"], li_sap_limit)
new_value = min(property_phase_impact["sap"], li_sap_limit)
# If we've made an adjustment, keep track of it
if new_value != property_phase_impact["sap"]:
adjustments.append(
{
"recommendation_id": rec["recommendation_id"],
"phase": rec["phase"],
# If we've made an adjustment, it will be negative
"sap_adjustment": property_phase_impact["sap"] - new_value,
}
)
property_phase_impact["sap"] = new_value
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
if rec["type"] == "solar_pv":
# We use the SAP points in the recommendation as a minimum
property_phase_impact["sap"] = (
proposed_impact = (
rec["sap_points"] if property_phase_impact["sap"] < rec["sap_points"] else
property_phase_impact["sap"]
)
# SAP adjustments should be negative
if proposed_impact != property_phase_impact["sap"]:
adjustments.append(
{
"recommendation_id": rec["recommendation_id"],
"phase": rec["phase"],
# If we've made an adjustment, it will be positive
"sap_adjustment": proposed_impact - property_phase_impact["sap"],
}
)
property_phase_impact["sap"] = proposed_impact
# Update the current phase values
current_phase_values["sap"] = previous_phase_values["sap"] + property_phase_impact["sap"]
@ -757,6 +845,9 @@ class Recommendations:
}
)
if debug:
return property_recommendations, impact_summary, adjustments
return property_recommendations, impact_summary
@staticmethod

View file

@ -0,0 +1,843 @@
import datetime
import pandas as pd
from pandas import Timestamp
import numpy as np
from numpy import nan
from unittest.mock import Mock
from recommendations.Recommendations import Recommendations
def test__filter_phase_adjustment():
eg1 = [
{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1.7},
{'recommendation_id': '1_phase=0', 'phase': 0, 'sap_adjustment': 1.7},
{'recommendation_id': '2_phase=0', 'phase': 0, 'sap_adjustment': 1.7}
]
res1 = Recommendations._filter_phase_adjustment(eg1)
assert res1 == [{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1.7}]
eg2 = [
{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1},
{'recommendation_id': '1_phase=0', 'phase': 1, 'sap_adjustment': 2},
{'recommendation_id': '2_phase=0', 'phase': 2, 'sap_adjustment': 3}
]
res2 = Recommendations._filter_phase_adjustment(eg2)
assert res2 == [
{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': 1},
{'recommendation_id': '1_phase=0', 'phase': 1, 'sap_adjustment': 2},
{'recommendation_id': '2_phase=0', 'phase': 2, 'sap_adjustment': 3}
]
eg3 = [
{'recommendation_id': 'third', 'phase': 3, 'sap_adjustment': 1},
{'recommendation_id': 'first', 'phase': 1, 'sap_adjustment': 2},
{'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3}
]
res3 = Recommendations._filter_phase_adjustment(eg3)
assert res3 == [
{'recommendation_id': 'first', 'phase': 1, 'sap_adjustment': 2},
{'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3},
{'recommendation_id': 'third', 'phase': 3, 'sap_adjustment': 1},
]
eg4 = [
{'recommendation_id': 'third_0', 'phase': 3, 'sap_adjustment': 1},
{'recommendation_id': 'third_1', 'phase': 3, 'sap_adjustment': 2},
{'recommendation_id': 'first_0', 'phase': 1, 'sap_adjustment': 2},
{'recommendation_id': 'first_1', 'phase': 1, 'sap_adjustment': 2},
{'recommendation_id': 'first_2', 'phase': 1, 'sap_adjustment': 100},
{'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3}
]
res4 = Recommendations._filter_phase_adjustment(eg4)
assert res4 == [
{'recommendation_id': 'first_2', 'phase': 1, 'sap_adjustment': 100},
{'recommendation_id': 'second', 'phase': 2, 'sap_adjustment': 3},
{'recommendation_id': 'third_1', 'phase': 3, 'sap_adjustment': 2},
]
def test_calculate_recommendation_impact():
all_predictions = {
"sap_change_predictions": pd.DataFrame(
[
{'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626',
'recommendation_id': '0_phase=0',
'phase': 0},
{'id': '614626+1_phase=0', 'predictions': 66.7, 'property_id': '614626',
'recommendation_id': '1_phase=0',
'phase': 0},
{'id': '614626+2_phase=0', 'predictions': 66.7, 'property_id': '614626',
'recommendation_id': '2_phase=0',
'phase': 0},
{'id': '614626+3_phase=1', 'predictions': 65.3, 'property_id': '614626',
'recommendation_id': '3_phase=1',
'phase': 1},
{'id': '614626+4_phase=2', 'predictions': 66.3, 'property_id': '614626',
'recommendation_id': '4_phase=2',
'phase': 2},
{'id': '614626+5_phase=3', 'predictions': 67.3, 'property_id': '614626',
'recommendation_id': '5_phase=3',
'phase': 3},
{'id': '614626+6_phase=3', 'predictions': 68.1, 'property_id': '614626',
'recommendation_id': '6_phase=3',
'phase': 3},
{'id': '614626+7_phase=3', 'predictions': 70.1, 'property_id': '614626',
'recommendation_id': '7_phase=3',
'phase': 3},
{'id': '614626+8_phase=4', 'predictions': 67.3, 'property_id': '614626',
'recommendation_id': '8_phase=4',
'phase': 4},
{'id': '614626+9_phase=5', 'predictions': 85.3, 'property_id': '614626',
'recommendation_id': '9_phase=5',
'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 85.3, 'property_id': '614626',
'recommendation_id': '10_phase=5', 'phase': 5},
{'id': '614626+11_phase=5', 'predictions': 85.3, 'property_id': '614626',
'recommendation_id': '11_phase=5', 'phase': 5},
{'id': '614626+12_phase=5', 'predictions': 85.5, 'property_id': '614626',
'recommendation_id': '12_phase=5', 'phase': 5},
{'id': '614626+13_phase=5', 'predictions': 85.5, 'property_id': '614626',
'recommendation_id': '13_phase=5', 'phase': 5},
{'id': '614626+14_phase=5', 'predictions': 85.5, 'property_id': '614626',
'recommendation_id': '14_phase=5', 'phase': 5},
{'id': '614626+15_phase=5', 'predictions': 85.5, 'property_id': '614626',
'recommendation_id': '15_phase=5', 'phase': 5},
{'id': '614626+16_phase=5', 'predictions': 85.5, 'property_id': '614626',
'recommendation_id': '16_phase=5', 'phase': 5},
{'id': '614626+17_phase=5', 'predictions': 85.5, 'property_id': '614626',
'recommendation_id': '17_phase=5', 'phase': 5},
{'id': '614626+18_phase=5', 'predictions': 85.5, 'property_id': '614626',
'recommendation_id': '18_phase=5', 'phase': 5},
{'id': '614626+19_phase=5', 'predictions': 86.4, 'property_id': '614626',
'recommendation_id': '19_phase=5', 'phase': 5},
{'id': '614626+20_phase=5', 'predictions': 86.4, 'property_id': '614626',
'recommendation_id': '20_phase=5', 'phase': 5},
{'id': '614626+21_phase=5', 'predictions': 86.4, 'property_id': '614626',
'recommendation_id': '21_phase=5', 'phase': 5},
{'id': '614626+22_phase=5', 'predictions': 86.4, 'property_id': '614626',
'recommendation_id': '22_phase=5', 'phase': 5},
{'id': '614626+23_phase=5', 'predictions': 86.4, 'property_id': '614626',
'recommendation_id': '23_phase=5', 'phase': 5},
{'id': '614626+24_phase=5', 'predictions': 86.4, 'property_id': '614626',
'recommendation_id': '24_phase=5', 'phase': 5},
{'id': '614626+25_phase=5', 'predictions': 86.7, 'property_id': '614626',
'recommendation_id': '25_phase=5', 'phase': 5},
{'id': '614626+26_phase=5', 'predictions': 86.7, 'property_id': '614626',
'recommendation_id': '26_phase=5', 'phase': 5},
{'id': '614626+27_phase=5', 'predictions': 86.7, 'property_id': '614626',
'recommendation_id': '27_phase=5', 'phase': 5},
{'id': '614626+28_phase=5', 'predictions': 86.7, 'property_id': '614626',
'recommendation_id': '28_phase=5', 'phase': 5},
{'id': '614626+29_phase=5', 'predictions': 83.8, 'property_id': '614626',
'recommendation_id': '29_phase=5', 'phase': 5},
{'id': '614626+30_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '30_phase=5', 'phase': 5},
{'id': '614626+31_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '31_phase=5', 'phase': 5},
{'id': '614626+32_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '32_phase=5', 'phase': 5},
{'id': '614626+33_phase=5', 'predictions': 86.4, 'property_id': '614626',
'recommendation_id': '33_phase=5', 'phase': 5},
{'id': '614626+34_phase=5', 'predictions': 86.4, 'property_id': '614626',
'recommendation_id': '34_phase=5', 'phase': 5},
{'id': '614626+35_phase=5', 'predictions': 86.4, 'property_id': '614626',
'recommendation_id': '35_phase=5', 'phase': 5},
{'id': '614626+36_phase=5', 'predictions': 86.4, 'property_id': '614626',
'recommendation_id': '36_phase=5', 'phase': 5},
{'id': '614626+37_phase=5', 'predictions': 81.2, 'property_id': '614626',
'recommendation_id': '37_phase=5', 'phase': 5},
{'id': '614626+38_phase=5', 'predictions': 81.2, 'property_id': '614626',
'recommendation_id': '38_phase=5', 'phase': 5},
{'id': '614626+39_phase=5', 'predictions': 81.2, 'property_id': '614626',
'recommendation_id': '39_phase=5', 'phase': 5},
{'id': '614626+40_phase=5', 'predictions': 83.4, 'property_id': '614626',
'recommendation_id': '40_phase=5', 'phase': 5},
{'id': '614626+41_phase=5', 'predictions': 83.4, 'property_id': '614626',
'recommendation_id': '41_phase=5', 'phase': 5},
{'id': '614626+42_phase=5', 'predictions': 83.4, 'property_id': '614626',
'recommendation_id': '42_phase=5', 'phase': 5},
{'id': '614626+43_phase=5', 'predictions': 83.4, 'property_id': '614626',
'recommendation_id': '43_phase=5', 'phase': 5},
{'id': '614626+44_phase=5', 'predictions': 85.5, 'property_id': '614626',
'recommendation_id': '44_phase=5', 'phase': 5},
{'id': '614626+45_phase=5', 'predictions': 85.5, 'property_id': '614626',
'recommendation_id': '45_phase=5', 'phase': 5},
{'id': '614626+46_phase=5', 'predictions': 85.5, 'property_id': '614626',
'recommendation_id': '46_phase=5', 'phase': 5},
{'id': '614626+47_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '47_phase=5', 'phase': 5},
{'id': '614626+48_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '48_phase=5', 'phase': 5},
{'id': '614626+49_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '49_phase=5', 'phase': 5},
{'id': '614626+50_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '50_phase=5', 'phase': 5},
{'id': '614626+51_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '51_phase=5', 'phase': 5},
{'id': '614626+52_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '52_phase=5', 'phase': 5},
{'id': '614626+53_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '53_phase=5', 'phase': 5},
{'id': '614626+54_phase=5', 'predictions': 85.4, 'property_id': '614626',
'recommendation_id': '54_phase=5', 'phase': 5},
{'id': '614626+55_phase=5', 'predictions': 79.4, 'property_id': '614626',
'recommendation_id': '55_phase=5', 'phase': 5},
{'id': '614626+56_phase=5', 'predictions': 81.2, 'property_id': '614626',
'recommendation_id': '56_phase=5', 'phase': 5},
{'id': '614626+57_phase=5', 'predictions': 81.2, 'property_id': '614626',
'recommendation_id': '57_phase=5', 'phase': 5}]
),
"heat_demand_predictions": pd.DataFrame(
[
{'id': '614626+0_phase=0', 'predictions': 256.6, 'property_id': '614626',
'recommendation_id': '0_phase=0',
'phase': 0},
{'id': '614626+1_phase=0', 'predictions': 256.6, 'property_id': '614626',
'recommendation_id': '1_phase=0',
'phase': 0},
{'id': '614626+2_phase=0', 'predictions': 256.6, 'property_id': '614626',
'recommendation_id': '2_phase=0',
'phase': 0},
{'id': '614626+3_phase=1', 'predictions': 263.1, 'property_id': '614626',
'recommendation_id': '3_phase=1',
'phase': 1},
{'id': '614626+4_phase=2', 'predictions': 259.0, 'property_id': '614626',
'recommendation_id': '4_phase=2',
'phase': 2},
{'id': '614626+5_phase=3', 'predictions': 250.5, 'property_id': '614626',
'recommendation_id': '5_phase=3',
'phase': 3},
{'id': '614626+6_phase=3', 'predictions': 245.7, 'property_id': '614626',
'recommendation_id': '6_phase=3',
'phase': 3},
{'id': '614626+7_phase=3', 'predictions': 199.7, 'property_id': '614626',
'recommendation_id': '7_phase=3',
'phase': 3},
{'id': '614626+8_phase=4', 'predictions': 250.5, 'property_id': '614626',
'recommendation_id': '8_phase=4',
'phase': 4},
{'id': '614626+9_phase=5', 'predictions': 139.5, 'property_id': '614626',
'recommendation_id': '9_phase=5',
'phase': 5}, {'id': '614626+10_phase=5', 'predictions': 139.5, 'property_id': '614626',
'recommendation_id': '10_phase=5', 'phase': 5},
{'id': '614626+11_phase=5', 'predictions': 139.5, 'property_id': '614626',
'recommendation_id': '11_phase=5', 'phase': 5},
{'id': '614626+12_phase=5', 'predictions': 133.6, 'property_id': '614626',
'recommendation_id': '12_phase=5', 'phase': 5},
{'id': '614626+13_phase=5', 'predictions': 133.6, 'property_id': '614626',
'recommendation_id': '13_phase=5', 'phase': 5},
{'id': '614626+14_phase=5', 'predictions': 133.6, 'property_id': '614626',
'recommendation_id': '14_phase=5', 'phase': 5},
{'id': '614626+15_phase=5', 'predictions': 133.6, 'property_id': '614626',
'recommendation_id': '15_phase=5', 'phase': 5},
{'id': '614626+16_phase=5', 'predictions': 133.6, 'property_id': '614626',
'recommendation_id': '16_phase=5', 'phase': 5},
{'id': '614626+17_phase=5', 'predictions': 133.6, 'property_id': '614626',
'recommendation_id': '17_phase=5', 'phase': 5},
{'id': '614626+18_phase=5', 'predictions': 133.6, 'property_id': '614626',
'recommendation_id': '18_phase=5', 'phase': 5},
{'id': '614626+19_phase=5', 'predictions': 114.3, 'property_id': '614626',
'recommendation_id': '19_phase=5', 'phase': 5},
{'id': '614626+20_phase=5', 'predictions': 114.3, 'property_id': '614626',
'recommendation_id': '20_phase=5', 'phase': 5},
{'id': '614626+21_phase=5', 'predictions': 114.3, 'property_id': '614626',
'recommendation_id': '21_phase=5', 'phase': 5},
{'id': '614626+22_phase=5', 'predictions': 114.3, 'property_id': '614626',
'recommendation_id': '22_phase=5', 'phase': 5},
{'id': '614626+23_phase=5', 'predictions': 114.3, 'property_id': '614626',
'recommendation_id': '23_phase=5', 'phase': 5},
{'id': '614626+24_phase=5', 'predictions': 114.3, 'property_id': '614626',
'recommendation_id': '24_phase=5', 'phase': 5},
{'id': '614626+25_phase=5', 'predictions': 102.5, 'property_id': '614626',
'recommendation_id': '25_phase=5', 'phase': 5},
{'id': '614626+26_phase=5', 'predictions': 102.5, 'property_id': '614626',
'recommendation_id': '26_phase=5', 'phase': 5},
{'id': '614626+27_phase=5', 'predictions': 102.5, 'property_id': '614626',
'recommendation_id': '27_phase=5', 'phase': 5},
{'id': '614626+28_phase=5', 'predictions': 102.5, 'property_id': '614626',
'recommendation_id': '28_phase=5', 'phase': 5},
{'id': '614626+29_phase=5', 'predictions': 82.5, 'property_id': '614626',
'recommendation_id': '29_phase=5', 'phase': 5},
{'id': '614626+30_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '30_phase=5', 'phase': 5},
{'id': '614626+31_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '31_phase=5', 'phase': 5},
{'id': '614626+32_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '32_phase=5', 'phase': 5},
{'id': '614626+33_phase=5', 'predictions': 114.3, 'property_id': '614626',
'recommendation_id': '33_phase=5', 'phase': 5},
{'id': '614626+34_phase=5', 'predictions': 114.3, 'property_id': '614626',
'recommendation_id': '34_phase=5', 'phase': 5},
{'id': '614626+35_phase=5', 'predictions': 114.3, 'property_id': '614626',
'recommendation_id': '35_phase=5', 'phase': 5},
{'id': '614626+36_phase=5', 'predictions': 114.3, 'property_id': '614626',
'recommendation_id': '36_phase=5', 'phase': 5},
{'id': '614626+37_phase=5', 'predictions': 169.2, 'property_id': '614626',
'recommendation_id': '37_phase=5', 'phase': 5},
{'id': '614626+38_phase=5', 'predictions': 169.2, 'property_id': '614626',
'recommendation_id': '38_phase=5', 'phase': 5},
{'id': '614626+39_phase=5', 'predictions': 169.2, 'property_id': '614626',
'recommendation_id': '39_phase=5', 'phase': 5},
{'id': '614626+40_phase=5', 'predictions': 155.1, 'property_id': '614626',
'recommendation_id': '40_phase=5', 'phase': 5},
{'id': '614626+41_phase=5', 'predictions': 155.1, 'property_id': '614626',
'recommendation_id': '41_phase=5', 'phase': 5},
{'id': '614626+42_phase=5', 'predictions': 155.1, 'property_id': '614626',
'recommendation_id': '42_phase=5', 'phase': 5},
{'id': '614626+43_phase=5', 'predictions': 155.1, 'property_id': '614626',
'recommendation_id': '43_phase=5', 'phase': 5},
{'id': '614626+44_phase=5', 'predictions': 133.6, 'property_id': '614626',
'recommendation_id': '44_phase=5', 'phase': 5},
{'id': '614626+45_phase=5', 'predictions': 133.6, 'property_id': '614626',
'recommendation_id': '45_phase=5', 'phase': 5},
{'id': '614626+46_phase=5', 'predictions': 133.6, 'property_id': '614626',
'recommendation_id': '46_phase=5', 'phase': 5},
{'id': '614626+47_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '47_phase=5', 'phase': 5},
{'id': '614626+48_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '48_phase=5', 'phase': 5},
{'id': '614626+49_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '49_phase=5', 'phase': 5},
{'id': '614626+50_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '50_phase=5', 'phase': 5},
{'id': '614626+51_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '51_phase=5', 'phase': 5},
{'id': '614626+52_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '52_phase=5', 'phase': 5},
{'id': '614626+53_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '53_phase=5', 'phase': 5},
{'id': '614626+54_phase=5', 'predictions': 130.0, 'property_id': '614626',
'recommendation_id': '54_phase=5', 'phase': 5},
{'id': '614626+55_phase=5', 'predictions': 182.6, 'property_id': '614626',
'recommendation_id': '55_phase=5', 'phase': 5},
{'id': '614626+56_phase=5', 'predictions': 169.2, 'property_id': '614626',
'recommendation_id': '56_phase=5', 'phase': 5},
{'id': '614626+57_phase=5', 'predictions': 169.2, 'property_id': '614626',
'recommendation_id': '57_phase=5', 'phase': 5}
]
),
"carbon_change_predictions": pd.DataFrame(
[
{'id': '614626+0_phase=0', 'predictions': 2.2, 'property_id': '614626',
'recommendation_id': '0_phase=0',
'phase': 0},
{'id': '614626+1_phase=0', 'predictions': 2.2, 'property_id': '614626',
'recommendation_id': '1_phase=0',
'phase': 0},
{'id': '614626+2_phase=0', 'predictions': 2.2, 'property_id': '614626',
'recommendation_id': '2_phase=0',
'phase': 0},
{'id': '614626+3_phase=1', 'predictions': 2.2, 'property_id': '614626',
'recommendation_id': '3_phase=1',
'phase': 1},
{'id': '614626+4_phase=2', 'predictions': 2.2, 'property_id': '614626',
'recommendation_id': '4_phase=2',
'phase': 2},
{'id': '614626+5_phase=3', 'predictions': 2.1, 'property_id': '614626',
'recommendation_id': '5_phase=3',
'phase': 3},
{'id': '614626+6_phase=3', 'predictions': 2.1, 'property_id': '614626',
'recommendation_id': '6_phase=3',
'phase': 3},
{'id': '614626+7_phase=3', 'predictions': 1.4, 'property_id': '614626',
'recommendation_id': '7_phase=3',
'phase': 3},
{'id': '614626+8_phase=4', 'predictions': 2.1, 'property_id': '614626',
'recommendation_id': '8_phase=4',
'phase': 4},
{'id': '614626+9_phase=5', 'predictions': 1.3, 'property_id': '614626',
'recommendation_id': '9_phase=5',
'phase': 5},
{'id': '614626+10_phase=5', 'predictions': 1.3, 'property_id': '614626',
'recommendation_id': '10_phase=5',
'phase': 5},
{'id': '614626+11_phase=5', 'predictions': 1.3, 'property_id': '614626',
'recommendation_id': '11_phase=5',
'phase': 5},
{'id': '614626+12_phase=5', 'predictions': 1.2, 'property_id': '614626',
'recommendation_id': '12_phase=5',
'phase': 5},
{'id': '614626+13_phase=5', 'predictions': 1.2, 'property_id': '614626',
'recommendation_id': '13_phase=5',
'phase': 5},
{'id': '614626+14_phase=5', 'predictions': 1.2, 'property_id': '614626',
'recommendation_id': '14_phase=5',
'phase': 5},
{'id': '614626+15_phase=5', 'predictions': 1.2, 'property_id': '614626',
'recommendation_id': '15_phase=5',
'phase': 5},
{'id': '614626+16_phase=5', 'predictions': 1.2, 'property_id': '614626',
'recommendation_id': '16_phase=5',
'phase': 5},
{'id': '614626+17_phase=5', 'predictions': 1.2, 'property_id': '614626',
'recommendation_id': '17_phase=5',
'phase': 5},
{'id': '614626+18_phase=5', 'predictions': 1.2, 'property_id': '614626',
'recommendation_id': '18_phase=5',
'phase': 5},
{'id': '614626+19_phase=5', 'predictions': 1.0, 'property_id': '614626',
'recommendation_id': '19_phase=5',
'phase': 5},
{'id': '614626+20_phase=5', 'predictions': 1.0, 'property_id': '614626',
'recommendation_id': '20_phase=5',
'phase': 5},
{'id': '614626+21_phase=5', 'predictions': 1.0, 'property_id': '614626',
'recommendation_id': '21_phase=5',
'phase': 5},
{'id': '614626+22_phase=5', 'predictions': 1.0, 'property_id': '614626',
'recommendation_id': '22_phase=5',
'phase': 5},
{'id': '614626+23_phase=5', 'predictions': 1.0, 'property_id': '614626',
'recommendation_id': '23_phase=5',
'phase': 5},
{'id': '614626+24_phase=5', 'predictions': 1.0, 'property_id': '614626',
'recommendation_id': '24_phase=5',
'phase': 5},
{'id': '614626+25_phase=5', 'predictions': 0.9, 'property_id': '614626',
'recommendation_id': '25_phase=5',
'phase': 5},
{'id': '614626+26_phase=5', 'predictions': 0.9, 'property_id': '614626',
'recommendation_id': '26_phase=5',
'phase': 5},
{'id': '614626+27_phase=5', 'predictions': 0.9, 'property_id': '614626',
'recommendation_id': '27_phase=5',
'phase': 5},
{'id': '614626+28_phase=5', 'predictions': 0.9, 'property_id': '614626',
'recommendation_id': '28_phase=5',
'phase': 5},
{'id': '614626+29_phase=5', 'predictions': 0.8, 'property_id': '614626',
'recommendation_id': '29_phase=5',
'phase': 5},
{'id': '614626+30_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '30_phase=5',
'phase': 5},
{'id': '614626+31_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '31_phase=5',
'phase': 5},
{'id': '614626+32_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '32_phase=5',
'phase': 5},
{'id': '614626+33_phase=5', 'predictions': 1.0, 'property_id': '614626',
'recommendation_id': '33_phase=5',
'phase': 5},
{'id': '614626+34_phase=5', 'predictions': 1.0, 'property_id': '614626',
'recommendation_id': '34_phase=5',
'phase': 5},
{'id': '614626+35_phase=5', 'predictions': 1.0, 'property_id': '614626',
'recommendation_id': '35_phase=5',
'phase': 5},
{'id': '614626+36_phase=5', 'predictions': 1.0, 'property_id': '614626',
'recommendation_id': '36_phase=5',
'phase': 5},
{'id': '614626+37_phase=5', 'predictions': 1.5, 'property_id': '614626',
'recommendation_id': '37_phase=5',
'phase': 5},
{'id': '614626+38_phase=5', 'predictions': 1.5, 'property_id': '614626',
'recommendation_id': '38_phase=5',
'phase': 5},
{'id': '614626+39_phase=5', 'predictions': 1.5, 'property_id': '614626',
'recommendation_id': '39_phase=5',
'phase': 5},
{'id': '614626+40_phase=5', 'predictions': 1.4, 'property_id': '614626',
'recommendation_id': '40_phase=5',
'phase': 5},
{'id': '614626+41_phase=5', 'predictions': 1.4, 'property_id': '614626',
'recommendation_id': '41_phase=5',
'phase': 5},
{'id': '614626+42_phase=5', 'predictions': 1.4, 'property_id': '614626',
'recommendation_id': '42_phase=5',
'phase': 5},
{'id': '614626+43_phase=5', 'predictions': 1.4, 'property_id': '614626',
'recommendation_id': '43_phase=5',
'phase': 5},
{'id': '614626+44_phase=5', 'predictions': 1.2, 'property_id': '614626',
'recommendation_id': '44_phase=5',
'phase': 5},
{'id': '614626+45_phase=5', 'predictions': 1.2, 'property_id': '614626',
'recommendation_id': '45_phase=5',
'phase': 5},
{'id': '614626+46_phase=5', 'predictions': 1.2, 'property_id': '614626',
'recommendation_id': '46_phase=5',
'phase': 5},
{'id': '614626+47_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '47_phase=5',
'phase': 5},
{'id': '614626+48_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '48_phase=5',
'phase': 5},
{'id': '614626+49_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '49_phase=5',
'phase': 5},
{'id': '614626+50_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '50_phase=5',
'phase': 5},
{'id': '614626+51_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '51_phase=5',
'phase': 5},
{'id': '614626+52_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '52_phase=5',
'phase': 5},
{'id': '614626+53_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '53_phase=5',
'phase': 5},
{'id': '614626+54_phase=5', 'predictions': 1.1, 'property_id': '614626',
'recommendation_id': '54_phase=5',
'phase': 5},
{'id': '614626+55_phase=5', 'predictions': 1.6, 'property_id': '614626',
'recommendation_id': '55_phase=5',
'phase': 5},
{'id': '614626+56_phase=5', 'predictions': 1.5, 'property_id': '614626',
'recommendation_id': '56_phase=5',
'phase': 5},
{'id': '614626+57_phase=5', 'predictions': 1.5, 'property_id': '614626',
'recommendation_id': '57_phase=5',
'phase': 5}
]
),
"hotwater_kwh_predictions": pd.DataFrame([]),
"heating_kwh_predictions": pd.DataFrame([]),
}
# Mock the property - we need id and some of the data
p = Mock(
id=614626,
data={
"current-energy-efficiency": 65,
"co2-emissions-current": 2.4,
"energy-consumption-current": 284,
"roof-energy-eff": "Good",
"lighting-energy-eff": "Good"
},
roof={
'original_description': 'Pitched, 250 mm loft insulation',
'clean_description': 'Pitched, 250 mm loft insulation', 'thermal_transmittance': None,
'thermal_transmittance_unit': None, 'is_pitched': True, 'is_roof_room': False, 'is_loft': True,
'is_flat': False, 'is_thatched': False, 'is_at_rafters': False, 'is_assumed': False,
'has_dwelling_above': False, 'is_valid': True, 'insulation_thickness': '250'
},
lighting={
'original_description': 'Low energy lighting in 50% of fixed outlets',
'clean_description': 'Low energy lighting in 50% of fixed outlets', 'low_energy_proportion': 0.5
}
)
recommendations = {
614626: [
[
{
'phase': 0, 'parts': [
{'id': 3362, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', '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': 'Warm Front',
'created_at': Timestamp('2025-08-15 16:31:52.995292'), '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': 21.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0,
'size': None,
'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None,
'quantity': 54.125488565924286, 'quantity_unit': 'm2', 'total': 1029.0, 'contingency': 102.9,
'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1}], 'type': 'loft_insulation',
'measure_type': 'loft_insulation',
'description': 'Install 300mm of Fibre loft insulation in your loft',
'starting_u_value': np.float64(0.17), 'new_u_value': np.float64(0.14), 'sap_points': 0,
'already_installed': False, 'simulation_config': {'roof_insulation_thickness_ending': '300',
'roof_thermal_transmittance_ending': np.float64(
0.14),
'roof_energy_eff_ending': 'Very Good'},
'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation',
'roof-energy-eff': 'Very Good'}, 'total': 1029.0, 'contingency': 102.9,
'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False,
'innovation_rate': 0.0,
'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587),
'co2_equivalent_savings': np.float64(0.19999999999999973),
'heat_demand': np.float64(27.399999999999977)},
],
[
{
'phase': 1, 'parts': [{'id': 3337, 'type': 'mechanical_ventilation',
'description': 'Decentralised 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': 'CRG',
'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292),
'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': 280.0, 'notes': None, 'is_installer_quote': True,
'innovation_rate': 0.0, 'size': None, 'size_unit': None,
'includes_scaffolding': False, 'includes_battery': False,
'battery_size': None,
'total': 560.0, 'quantity': 2, 'quantity_unit': 'part'}],
'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation',
'description': 'Install 2 Decentralised mechanical extract ventilation units',
'starting_u_value': None,
'new_u_value': None, 'already_installed': False, 'sap_points': np.float64(-1.4000000000000057),
'heat_demand': np.float64(-6.5), 'kwh_savings': 0, 'co2_equivalent_savings': np.float64(0.0),
'energy_cost_savings': 0, 'total': 560.0, 'labour_hours': 8, 'labour_days': 1.0,
'simulation_config': {'mechanical_ventilation_ending': 'mechanical, extract only'},
'description_simulation': {'mechanical-ventilation': 'mechanical, extract only'},
'innovation_rate': 0.0,
'recommendation_id': '3_phase=1', 'efficiency': 0}
],
[
{'phase': 2, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting',
'description': 'Install low energy lighting in 3 outlets', 'starting_u_value': None,
'new_u_value': None,
'already_installed': False, 'sap_points': 1, 'kwh_savings': 164.25,
'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0),
'description_simulation': {'lighting-energy-eff': 'Very Good',
'lighting-description': 'Low energy lighting in all fixed outlets',
'low-energy-lighting': 100}, 'total': 10.5, 'contingency': 2.73,
'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
'innovation_rate': 0.0,
'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)}
],
[
{'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, 'parts': [],
'description': 'Upgrade heating controls to Room thermostat, programmer and TRVs', 'total': 70,
'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, 'vat': 11.666666666666664,
'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None, 'new_u_value': None,
'sap_points': np.float64(1.0), 'already_installed': False,
'simulation_config': {'trvs_ending': 'trvs', 'mainheatc_energy_eff_ending': 'Good'},
'description_simulation': {'mainheatcont-description': 'Programmer, room thermostat and TRVS',
'mainheatc-energy-eff': 'Good'}, 'innovation_rate': 0.0,
'recommendation_id': '5_phase=3', 'efficiency': 70,
'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)},
{'type': 'heating', 'phase': 3, 'measure_type': 'time_temperature_zone_control', 'parts': [],
'description': 'Upgrade heating controls to Smart Thermostats, room sensors and smart radiator '
'valves (time & temperature zone control)',
'total': 604.5840000000001, 'contingency': 60.45840000000001, 'contingency_rate': 0.1,
'subtotal': 571.32,
'vat': 33.264, 'labour_hours': 3.08, 'labour_days': np.float64(1.0), 'starting_u_value': None,
'new_u_value': None, 'sap_points': np.float64(1.8), 'already_installed': False,
'simulation_config': {'thermostatic_control_ending': 'time and temperature zone control',
'switch_system_ending': None, 'mainheatc_energy_eff_ending': 'Very Good'},
'description_simulation': {'mainheatcont-description': 'Time and temperature zone control',
'mainheatc-energy-eff': 'Very Good'}, 'innovation_rate': 0.0,
'recommendation_id': '6_phase=3', 'efficiency': 604.5840000000001,
'co2_equivalent_savings': np.float64(0.10000000000000009),
'heat_demand': np.float64(13.300000000000011)},
{'phase': 3, 'parts': [], 'type': 'heating', 'measure_type': 'air_source_heat_pump',
'description': 'Install a 5KW air source heat pump, and upgrade heating controls to Smart '
'Thermostats, room sensors and smart radiator valves (time & temperature zone '
'control). Ensure you have a single tariff',
'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8),
'already_installed': False,
'simulation_config': {'mainheat_energy_eff_ending': 'Good', 'hot_water_energy_eff_ending': 'Average',
'has_boiler_ending': False, 'has_air_source_heat_pump_ending': True,
'has_electric_ending': True, 'has_mains_gas_ending': False,
'fuel_type_ending': 'electricity',
'thermostatic_control_ending': 'time and temperature zone control',
'switch_system_ending': None, 'mainheatc_energy_eff_ending': 'Very Good'},
'description_simulation': {'mainheat-description': 'Air source heat pump, radiators, electric',
'mainheat-energy-eff': 'Good', 'hot-water-energy-eff': 'Average',
'hotwater-description': 'From main system',
'main-fuel': 'electricity (not community)',
'mainheatcont-description': 'Time and temperature zone control',
'mainheatc-energy-eff': 'Very Good'}, 'total': 17144.924,
'contingency': 4195.5434000000005, 'contingency_rate': 0.35, 'vat': 33.264, 'labour_hours': 83.08,
'labour_days': np.float64(11.0), 'innovation_rate': 0, 'recommendation_id': '7_phase=3',
'efficiency': 17144.924, 'co2_equivalent_savings': np.float64(0.8000000000000003),
'heat_demand': np.float64(59.30000000000001)}
],
[
{'phase': 4, 'parts': [], 'type': 'secondary_heating', 'measure_type': 'secondary_heating',
'description': 'Remove the secondary heating system', 'starting_u_value': None, 'new_u_value': None,
'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0,
'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0,
'labour_days': np.float64(1.0), 'simulation_config': {'secondheat_description_ending': 'None'},
'description_simulation': {'secondheat-description': 'None'}, 'innovation_rate': 0.0,
'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0),
'heat_demand': np.float64(0.0)}
],
[
{
'phase': 5, 'parts': [
{'id': 3516, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': nan,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None,
'thermal_conductivity_unit': None, 'link': 'Coactivation',
'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), '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': 5892.21, 'notes': '445W panels', 'is_installer_quote': True,
'innovation_rate': 0.0, 'size': 5.34, 'size_unit': 'kWp', 'includes_scaffolding': True,
'includes_battery': False, 'battery_size': None, 'panel_size': 445}], 'type': 'solar_pv',
'measure_type': 'solar_pv',
'description': 'Trina Vertex S3 445W solar panels - 5.34 kWp '
'system',
'starting_u_value': None, 'new_u_value': None,
'sap_points': np.float64(16.0), 'already_installed': False,
'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315,
'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48,
'labour_days': 2, 'has_battery': False,
'simulation_config': {'photo_supply_ending': np.float64(80.0)},
'initial_ac_kwh_per_year': np.float64(4844.465553999999),
'description_simulation': {'photo-supply': np.float64(80.0)},
'innovation_rate': 0.0, 'recommendation_id': '29_phase=5',
'efficiency': np.float64(368.263125)
}
]
]
}
representative_recommendations = {
614626: [
{
'phase': 0, 'parts': [
{'id': 3362, 'type': 'loft_insulation', 'description': 'Fibre loft insulation', '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': 'Warm Front',
'created_at': Timestamp('2025-08-15 16:31:52.995292'), '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': 21.0, 'notes': None, 'is_installer_quote': True, 'innovation_rate': 0.0,
'size': None,
'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False, 'battery_size': None,
'quantity': 54.125488565924286, 'quantity_unit': 'm2', 'total': 1029.0, 'contingency': 102.9,
'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1}], 'type': 'loft_insulation',
'measure_type': 'loft_insulation',
'description': 'Install 300mm of Fibre loft insulation in your loft',
'starting_u_value': np.float64(0.17), 'new_u_value': np.float64(0.14), 'sap_points': 0,
'already_installed': False, 'simulation_config': {'roof_insulation_thickness_ending': '300',
'roof_thermal_transmittance_ending': np.float64(
0.14),
'roof_energy_eff_ending': 'Very Good'},
'description_simulation': {'roof-description': 'Pitched, 300mm loft insulation',
'roof-energy-eff': 'Very Good'}, 'total': 1029.0, 'contingency': 102.9,
'contingency_rate': 0.1, 'labour_hours': 8, 'labour_days': 1, 'survey': False,
'innovation_rate': 0.0,
'recommendation_id': '0_phase=0', 'efficiency': np.float64(6052.801176470587),
'co2_equivalent_savings': np.float64(0.19999999999999973),
'heat_demand': np.float64(27.399999999999977)
},
{
'phase': 1, 'parts': [
{'id': 3337, 'type': 'mechanical_ventilation',
'description': 'Decentralised 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': 'CRG',
'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), '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': 280.0, 'notes': None, 'is_installer_quote': True,
'innovation_rate': 0.0,
'size': None, 'size_unit': None, 'includes_scaffolding': False, 'includes_battery': False,
'battery_size': None, 'total': 560.0, 'quantity': 2, 'quantity_unit': 'part'}],
'type': 'mechanical_ventilation',
'measure_type': 'mechanical_ventilation',
'description': 'Install 2 Decentralised mechanical '
'extract ventilation units',
'starting_u_value': None, 'new_u_value': None,
'already_installed': False,
'sap_points': np.float64(-1.4000000000000057),
'heat_demand': np.float64(-6.5), 'kwh_savings': 0,
'co2_equivalent_savings': np.float64(0.0),
'energy_cost_savings': 0, 'total': 560.0,
'labour_hours': 8, 'labour_days': 1.0,
'simulation_config': {
'mechanical_ventilation_ending': 'mechanical, '
'extract only'},
'description_simulation': {
'mechanical-ventilation': 'mechanical, '
'extract only'},
'innovation_rate': 0.0,
'recommendation_id': '3_phase=1', 'efficiency': 0},
{
'phase': 2, 'parts': [], 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting',
'description': 'Install low energy lighting in 3 outlets', 'starting_u_value': None,
'new_u_value': None, 'already_installed': False, 'sap_points': 1, 'kwh_savings': 164.25,
'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0),
'description_simulation': {'lighting-energy-eff': 'Very Good',
'lighting-description': 'Low energy lighting in all fixed outlets',
'low-energy-lighting': 100}, 'total': 10.5, 'contingency': 2.73,
'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True,
'innovation_rate': 0.0, 'recommendation_id': '4_phase=2', 'efficiency': 10.5,
'heat_demand': np.float64(4.100000000000023)
},
{
'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, 'parts': [],
'description': 'Upgrade heating controls to Room thermostat, programmer and TRVs', 'total': 70,
'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336,
'vat': 11.666666666666664, 'labour_hours': 0.5, 'labour_days': 1, 'starting_u_value': None,
'new_u_value': None, 'sap_points': np.float64(1.0), 'already_installed': False,
'simulation_config': {'trvs_ending': 'trvs', 'mainheatc_energy_eff_ending': 'Good'},
'description_simulation': {'mainheatcont-description': 'Programmer, room thermostat and TRVS',
'mainheatc-energy-eff': 'Good'}, 'innovation_rate': 0.0,
'recommendation_id': '5_phase=3', 'efficiency': 70,
'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)
},
{
'phase': 4, 'parts': [], 'type': 'secondary_heating', 'measure_type': 'secondary_heating',
'description': 'Remove the secondary heating system', 'starting_u_value': None, 'new_u_value': None,
'sap_points': np.float64(0.0), 'already_installed': False, 'total': 60.0, 'contingency': 6.0,
'contingency_rate': 0.1, 'subtotal': 50.0, 'vat': 10.0, 'labour_hours': 6.0,
'labour_days': np.float64(1.0), 'simulation_config': {'secondheat_description_ending': 'None'},
'description_simulation': {'secondheat-description': 'None'}, 'innovation_rate': 0.0,
'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0),
'heat_demand': np.float64(0.0)},
{
'phase': 5, 'parts': [
{'id': 3516, 'type': 'solar_pv', 'description': 'Trina Vertex S3 445W solar panels', 'depth': 0.0,
'depth_unit': None, 'cost': None, 'cost_unit': None, 'r_value_per_mm': nan,
'r_value_unit': 'square_meter_kelvin_per_watt', 'thermal_conductivity': None,
'thermal_conductivity_unit': None, 'link': 'Coactivation',
'created_at': datetime.datetime(2025, 8, 15, 16, 31, 52, 995292), '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': 5892.21, 'notes': '445W panels', 'is_installer_quote': True,
'innovation_rate': 0.0, 'size': 5.34, 'size_unit': 'kWp', 'includes_scaffolding': True,
'includes_battery': False, 'battery_size': None, 'panel_size': 445}], 'type': 'solar_pv',
'measure_type': 'solar_pv',
'description': 'Trina Vertex S3 445W solar panels - 5.34 kWp '
'system',
'starting_u_value': None, 'new_u_value': None,
'sap_points': np.float64(16.0), 'already_installed': False,
'total': 5892.21, 'subtotal': 5892.21, 'contingency': 883.8315,
'contingency_rate': 0.15, 'vat': 0, 'labour_hours': 48,
'labour_days': 2, 'has_battery': False,
'simulation_config': {'photo_supply_ending': np.float64(80.0)},
'initial_ac_kwh_per_year': np.float64(4844.465553999999),
'description_simulation': {'photo-supply': np.float64(80.0)},
'innovation_rate': 0.0, 'recommendation_id': '29_phase=5',
'efficiency': np.float64(368.263125)
}
]
}
recommendations_with_impact, impact_summary, adjustments = (
Recommendations.calculate_recommendation_impact(
property_instance=p,
all_predictions=all_predictions,
recommendations=recommendations,
representative_recommendations=representative_recommendations,
debug=True
)
)
# We expect an adjustment to be made for loft insulation, reducing the impact by
# 1.7
assert adjustments == [{'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)}]
# We expect that adjustment to flow through to the final recommendation so that the solar recommendation has
# a 1.7 sap point reduction in impact
assert float(impact_summary[-1]["sap"]) == 82.1
assert float(impact_summary[-1]["sap_prediction"]) == 83.8
assert impact_summary[-1] == {
'phase': 5, 'representative': True, 'recommendation_id': '29_phase=5', 'measure_type': 'solar_pv',
'sap': np.float64(82.1), 'carbon': np.float64(0.8), 'heat_demand': np.float64(82.5),
'sap_prediction': np.float64(83.8)
}