diff --git a/backend/onboarders/parity.py b/backend/onboarders/parity.py index f41ebeaf..27244777 100644 --- a/backend/onboarders/parity.py +++ b/backend/onboarders/parity.py @@ -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)' \ No newline at end of file +# '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)' diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index ab13134d..d3467919 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -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 diff --git a/recommendations/tests/test_recommendations.py b/recommendations/tests/test_recommendations.py new file mode 100644 index 00000000..c933ad25 --- /dev/null +++ b/recommendations/tests/test_recommendations.py @@ -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) + }