diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py index cdc27abd..0abca0e8 100644 --- a/backend/tests/test_integration.py +++ b/backend/tests/test_integration.py @@ -1,574 +1,330 @@ -# import ast -# import json -from copy import deepcopy -# from dataclasses import replace -# from datetime import datetime - -import random -from tqdm import tqdm -# import pandas as pd -import numpy as np -from etl.epc.Record import EPCRecord -# from backend.SearchEpc import SearchEpc -# from sqlalchemy.exc import IntegrityError, OperationalError -# from sqlalchemy.orm import sessionmaker -# from starlette.responses import Response - -# from backend.app.config import get_settings, get_prediction_buckets -# from backend.app.db.connection import db_engine -# from backend.app.db.functions.materials_functions import get_materials -# from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations -# from backend.app.db.functions.property_functions import ( -# create_property, create_property_details_epc, create_property_targets, update_property_data, -# update_or_create_property_spatial_details -# ) -# from backend.app.db.functions.recommendations_functions import ( -# create_plan, upload_recommendations, create_scenario -# ) -# from backend.app.db.functions.funding_functions import upload_funding -# from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn -# from backend.app.db.models.portfolio import rating_lookup -from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES -# from backend.app.plan.utils import get_cleaned -# from backend.app.utils import sap_to_epc -import backend.app.assumptions as assumptions - -from backend.ml_models.api import ModelApi -from backend.Property import Property -from backend.apis.GoogleSolarApi import GoogleSolarApi - -from recommendations.optimiser.CostOptimiser import CostOptimiser -from recommendations.optimiser.GainOptimiser import GainOptimiser -import recommendations.optimiser.optimiser_functions as optimiser_functions -from recommendations.Recommendations import Recommendations -# from utils.logger import setup_logger -# from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3 -# from backend.ml_models.Valuation import PropertyValuation +# # import ast +# # import json +# from copy import deepcopy +# # from dataclasses import replace +# # from datetime import datetime # -# from etl.bill_savings.KwhData import KwhData -# from etl.spatial.OpenUprnClient import OpenUprnClient -# from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc - -from backend.Funding import Funding -from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths -from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value - -# Input data (temp) -import pickle - -import pandas as pd - -with open("local_data_for_deletion.pkl", 'rb') as f: - local_data = pickle.load(f) - -cleaning_data = local_data["cleaning_data"] -materials = local_data["materials"] -cleaned = local_data["cleaned"] -project_scores_matrix = local_data["project_scores_matrix"] -partial_project_scores_matrix = local_data["partial_project_scores_matrix"] -whlg_eligible_postcodes = local_data["whlg_eligible_postcodes"] - -with open("kwh_client_for_deletion.pkl", "rb") as f: - kwh_client = pickle.load(f) - -epc_data = pd.read_csv( - "/Users/khalimconn-kowlessar/Downloads/domestic-E06000002-Middlesbrough/certificates.csv", - low_memory=False -) - -# TODO: Store this for cleaning -costs_by_floor_area = epc_data[ - pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2024-01-01" - ][["TOTAL_FLOOR_AREA", "CURRENT_ENERGY_EFFICIENCY", "LIGHTING_COST_CURRENT", "HEATING_COST_CURRENT", - "HOT_WATER_COST_CURRENT"]].copy() - -epc_data = epc_data[ - (epc_data["MAINHEAT_DESCRIPTION"].str.contains("SAP05:") == False) & - (~epc_data["LIGHTING_COST_CURRENT"].isin([None, ""])) & - (~pd.isnull(epc_data["LIGHTING_COST_CURRENT"])) - ] - -costs_by_floor_area.columns = [c.lower().replace("_", "-") for c in costs_by_floor_area.columns] -for c in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: - costs_by_floor_area[c + "_scaled"] = costs_by_floor_area[c] / costs_by_floor_area["total-floor-area"] - -costs_by_floor_area = costs_by_floor_area.groupby("current-energy-efficiency")[ - ["lighting-cost-current_scaled", "heating-cost-current_scaled", "hot-water-cost-current_scaled"] -].mean().reset_index() - -epc_data = epc_data[~pd.isnull(epc_data["UPRN"])] - -sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2008-01-01"].drop_duplicates("UPRN").sample( - 50000).reset_index(drop=True) - -# TODO: In Property find_energy_sources, sort out biomass community heating - what fuel type -# TODO: We might be able to remove find_energy_sources entirely and remove estimate_electrical_consumption. It's used -# in the google solar api but is it really needed? I don't think it's super accurate. It might be better to -# just use an average energy consumption by floor area for UK households? -# Load the input properties -input_properties = [] -for row_id, config in tqdm(sample_epc_data.iterrows(), total=len(sample_epc_data)): - epc = { - k.lower().replace("_", "-"): v if not pd.isnull(v) else None for k, v in config.items() - } - # Avoid the data load inside of EPCRecord - something we should pull out - for x in ["number-habitable-rooms", "floor-height", "number-heated-rooms"]: - if pd.isnull(epc[x]): - if x == "floor-height": - epc[x] = 2.4 - if x == "number-habitable-rooms": - epc[x] = 3 - if x == "number-heated-rooms": - epc[x] = 3 - - epc_records = {'original_epc': epc, 'full_sap_epc': {}, 'old_data': []} - - prepared_epc = EPCRecord( - epc_records=epc_records, - run_mode="newdata", - cleaning_data=cleaning_data, - ) - - input_properties.append( - Property( - id=row_id, - is_new=True, - address=epc["address"], - postcode=epc["postcode"], - epc_record=prepared_epc, - already_installed={}, - property_valuation={}, - non_invasive_recommendations=[], - energy_assessment=None, - **Property.extract_kwargs(config), # TODO: Depraecate this - ) - ) - -# For each property, insert the default solar configuration -for p in tqdm(input_properties): - solar_api = GoogleSolarApi( - api_key=None, solar_materials=[m for m in materials if m["type"] == "solar_pv"], max_retries=5 - ) - panel_performance = solar_api.default_panel_performance(property_instance=p) - p.set_solar_panel_configuration( - solar_panel_configuration={ - "insights_data": None, "panel_performance": panel_performance, "unit_share_of_energy": 1 - }, - ) - -# We mock kwh preds -mocked_kwh_predictions = {"heating_kwh_predictions": [], "hotwater_kwh_predictions": []} -for p in tqdm(input_properties): - mocked_kwh_predictions["heating_kwh_predictions"].append({ - "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] - }) - mocked_kwh_predictions["hotwater_kwh_predictions"].append({ - "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] - }) -mocked_kwh_predictions["heating_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["heating_kwh_predictions"]) -mocked_kwh_predictions["hotwater_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["hotwater_kwh_predictions"]) - -# TODO: We might want to implement this generally, via an ETL process -for x in cleaned["mainheat-description"]: - x["has_wood_chips"] = False -for p in input_properties: - for col in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: - if pd.isnull(p.data[col]): - min_diff = abs( - (costs_by_floor_area["current-energy-efficiency"] - p.data["current-energy-efficiency"]) - ).min() - df = costs_by_floor_area[ - abs((costs_by_floor_area["current-energy-efficiency"] - p.data[ - "current-energy-efficiency"])) == min_diff - ] - if df.shape[0] > 1: - df = df.head(1) - p.data[col] = (df[col + "_scaled"] * p.data["total-floor-area"]).values[0] - -[ - p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) for p in - input_properties -] +# import random +# from tqdm import tqdm +# # import pandas as pd +# import numpy as np +# from etl.epc.Record import EPCRecord +# # from backend.SearchEpc import SearchEpc +# # from sqlalchemy.exc import IntegrityError, OperationalError +# # from sqlalchemy.orm import sessionmaker +# # from starlette.responses import Response +# +# # from backend.app.config import get_settings, get_prediction_buckets +# # from backend.app.db.connection import db_engine +# # from backend.app.db.functions.materials_functions import get_materials +# # from backend.app.db.functions.portfolio_functions import aggregate_portfolio_recommendations +# # from backend.app.db.functions.property_functions import ( +# # create_property, create_property_details_epc, create_property_targets, update_property_data, +# # update_or_create_property_spatial_details +# # ) +# # from backend.app.db.functions.recommendations_functions import ( +# # create_plan, upload_recommendations, create_scenario +# # ) +# # from backend.app.db.functions.funding_functions import upload_funding +# # from backend.app.db.functions.energy_assessment_functions import get_latest_assessment_by_uprn +# # from backend.app.db.models.portfolio import rating_lookup +# from backend.app.plan.schemas import PlanTriggerRequest, WALL_INSULATION_MEASURES, ROOF_INSULATION_MEASURES +# # from backend.app.plan.utils import get_cleaned +# # from backend.app.utils import sap_to_epc +# import backend.app.assumptions as assumptions +# +# from backend.ml_models.api import ModelApi +# from backend.Property import Property +# from backend.apis.GoogleSolarApi import GoogleSolarApi +# +# from recommendations.optimiser.CostOptimiser import CostOptimiser +# from recommendations.optimiser.GainOptimiser import GainOptimiser +# import recommendations.optimiser.optimiser_functions as optimiser_functions +# from recommendations.Recommendations import Recommendations +# # from utils.logger import setup_logger +# # from utils.s3 import read_dataframe_from_s3_parquet, read_csv_from_s3, read_excel_from_s3 +# # from backend.ml_models.Valuation import PropertyValuation +# # +# # from etl.bill_savings.KwhData import KwhData +# # from etl.spatial.OpenUprnClient import OpenUprnClient +# # from etl.find_my_epc.RetrieveFindMyEpc import RetrieveFindMyEpc +# +# from backend.Funding import Funding +# from recommendations.optimiser.funding_optimiser import optimise_with_funding_paths +# from recommendations.recommendation_utils import convert_thickness_to_numeric, get_wall_u_value +# +# # Input data (temp) +# import pickle +# +# import pandas as pd +# +# with open("local_data_for_deletion.pkl", 'rb') as f: +# local_data = pickle.load(f) +# +# cleaning_data = local_data["cleaning_data"] +# materials = local_data["materials"] +# cleaned = local_data["cleaned"] +# project_scores_matrix = local_data["project_scores_matrix"] +# partial_project_scores_matrix = local_data["partial_project_scores_matrix"] +# whlg_eligible_postcodes = local_data["whlg_eligible_postcodes"] +# +# with open("kwh_client_for_deletion.pkl", "rb") as f: +# kwh_client = pickle.load(f) +# +# epc_data = pd.read_csv( +# "/Users/khalimconn-kowlessar/Downloads/domestic-E06000002-Middlesbrough/certificates.csv", +# low_memory=False +# ) +# +# # TODO: Store this for cleaning +# costs_by_floor_area = epc_data[ +# pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2024-01-01" +# ][["TOTAL_FLOOR_AREA", "CURRENT_ENERGY_EFFICIENCY", "LIGHTING_COST_CURRENT", "HEATING_COST_CURRENT", +# "HOT_WATER_COST_CURRENT"]].copy() +# +# epc_data = epc_data[ +# (epc_data["MAINHEAT_DESCRIPTION"].str.contains("SAP05:") == False) & +# (~epc_data["LIGHTING_COST_CURRENT"].isin([None, ""])) & +# (~pd.isnull(epc_data["LIGHTING_COST_CURRENT"])) +# ] +# +# costs_by_floor_area.columns = [c.lower().replace("_", "-") for c in costs_by_floor_area.columns] +# for c in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: +# costs_by_floor_area[c + "_scaled"] = costs_by_floor_area[c] / costs_by_floor_area["total-floor-area"] +# +# costs_by_floor_area = costs_by_floor_area.groupby("current-energy-efficiency")[ +# ["lighting-cost-current_scaled", "heating-cost-current_scaled", "hot-water-cost-current_scaled"] +# ].mean().reset_index() +# +# epc_data = epc_data[~pd.isnull(epc_data["UPRN"])] +# +# sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2008-01-01"].drop_duplicates("UPRN").sample( +# 50000).reset_index(drop=True) +# +# # TODO: In Property find_energy_sources, sort out biomass community heating - what fuel type +# # TODO: We might be able to remove find_energy_sources entirely and remove estimate_electrical_consumption. It's used +# # in the google solar api but is it really needed? I don't think it's super accurate. It might be better to +# # just use an average energy consumption by floor area for UK households? +# # Load the input properties +# input_properties = [] +# for row_id, config in tqdm(sample_epc_data.iterrows(), total=len(sample_epc_data)): +# epc = { +# k.lower().replace("_", "-"): v if not pd.isnull(v) else None for k, v in config.items() +# } +# # Avoid the data load inside of EPCRecord - something we should pull out +# for x in ["number-habitable-rooms", "floor-height", "number-heated-rooms"]: +# if pd.isnull(epc[x]): +# if x == "floor-height": +# epc[x] = 2.4 +# if x == "number-habitable-rooms": +# epc[x] = 3 +# if x == "number-heated-rooms": +# epc[x] = 3 +# +# epc_records = {'original_epc': epc, 'full_sap_epc': {}, 'old_data': []} +# +# prepared_epc = EPCRecord( +# epc_records=epc_records, +# run_mode="newdata", +# cleaning_data=cleaning_data, +# ) +# +# input_properties.append( +# Property( +# id=row_id, +# is_new=True, +# address=epc["address"], +# postcode=epc["postcode"], +# epc_record=prepared_epc, +# already_installed={}, +# property_valuation={}, +# non_invasive_recommendations=[], +# energy_assessment=None, +# **Property.extract_kwargs(config), # TODO: Depraecate this +# ) +# ) +# +# # For each property, insert the default solar configuration +# for p in tqdm(input_properties): +# solar_api = GoogleSolarApi( +# api_key=None, solar_materials=[m for m in materials if m["type"] == "solar_pv"], max_retries=5 +# ) +# panel_performance = solar_api.default_panel_performance(property_instance=p) +# p.set_solar_panel_configuration( +# solar_panel_configuration={ +# "insights_data": None, "panel_performance": panel_performance, "unit_share_of_energy": 1 +# }, +# ) +# +# # We mock kwh preds +# mocked_kwh_predictions = {"heating_kwh_predictions": [], "hotwater_kwh_predictions": []} +# for p in tqdm(input_properties): +# mocked_kwh_predictions["heating_kwh_predictions"].append({ +# "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] +# }) +# mocked_kwh_predictions["hotwater_kwh_predictions"].append({ +# "id": p.uprn, "predictions": random.sample(range(100, 3000), 1)[0] +# }) +# mocked_kwh_predictions["heating_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["heating_kwh_predictions"]) +# mocked_kwh_predictions["hotwater_kwh_predictions"] = pd.DataFrame(mocked_kwh_predictions["hotwater_kwh_predictions"]) +# +# # TODO: We might want to implement this generally, via an ETL process +# for x in cleaned["mainheat-description"]: +# x["has_wood_chips"] = False # for p in input_properties: -# p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) - -# Run the recommendations -recommendations = {} -recommendations_scoring_data = [] -representative_recommendations = {} -for p in tqdm(input_properties): - if p.data["property-type"] == "House" and pd.isnull(p.data["built-form"]): - p.data["built-form"] = "Semi-Detached" - recommender = Recommendations( - property_instance=p, - materials=materials, - exclusions=[], - inclusions=[], - default_u_values=True - ) - property_recommendations, property_representative_recommendations = recommender.recommend() - - if not property_recommendations: - continue - - recommendations[p.id] = property_recommendations - representative_recommendations[p.id] = property_representative_recommendations - - p.create_base_difference_epc_record(cleaned_lookup=cleaned) - p.adjust_difference_record_with_recommendations( - property_recommendations, property_representative_recommendations - ) - - recommendations_scoring_data.extend(p.recommendations_scoring_data) - -recommendations_scoring_data = pd.DataFrame(recommendations_scoring_data) -recommendations_scoring_data = recommendations_scoring_data.drop( - columns=[ - "rdsap_change", "heat_demand_change", "carbon_change", "sap_ending", "heat_demand_ending", - "carbon_ending" - ] -) - -model_predictions_mocked = { - "sap_change_predictions": None, - "heat_demand_predictions": None, - "carbon_change_predictions": None, - "heating_kwh_predictions": None, - "hotwater_kwh_predictions": None, -} - -for k in model_predictions_mocked.keys(): - model_predictions_mocked[k] = recommendations_scoring_data[["id"]].copy() - model_predictions_mocked[k][['property_id', 'recommendation_id']] = ( - model_predictions_mocked[k]['id'].str.split('+', expand=True) - ) - model_predictions_mocked[k]['phase'] = model_predictions_mocked[k]['recommendation_id'].apply( - ModelApi.extract_phase) - - if k in ["heating_kwh_predictions", "hotwater_kwh_predictions"]: - model_predictions_mocked[k]["predictions"] = random.choices(range(100, 3000), - k=len(recommendations_scoring_data)) - continue - - model_predictions_mocked[k] = model_predictions_mocked[k].sort_values(["property_id", "phase"], ascending=True) - preds = [] - for p_id in model_predictions_mocked[k]["property_id"].unique(): - # We add some amount each time - p = [p for p in input_properties if str(p.id) == p_id][0] - if k == "sap_change_predictions": - start = p.data["current-energy-efficiency"] - elif k == "heat_demand_predictions": - start = p.data["energy-consumption-current"] - else: - start = p.data["co2-emissions-current"] - df = model_predictions_mocked[k][model_predictions_mocked[k]["property_id"] == p_id].copy() - # Add some amount each time - to_add = random.choices(range(0, 15), k=len(df)) - to_add = np.cumsum(to_add) - df["predictions"] = start + to_add - preds.append(df) - preds = pd.concat(preds) - model_predictions_mocked[k] = preds - -for property_id in tqdm(recommendations.keys(), total=len(recommendations)): - property_instance = [p for p in input_properties if p.id == property_id][0] - - recommendations_with_impact, impact_summary = ( - Recommendations.calculate_recommendation_impact( - property_instance=property_instance, - all_predictions=model_predictions_mocked, - recommendations=recommendations, - representative_recommendations=representative_recommendations - ) - ) - - # We use the impact_summary to update the simulation_epcs with the new SAP, heat demand, carbon, cost etc - # at each phase - property_instance.update_simulation_epcs(impact_summary) - recommendations[property_id] = recommendations_with_impact - -for property_id in tqdm([p.id for p in input_properties]): - property_recommendations = recommendations.get(property_id, []) - property_instance = [p for p in input_properties if p.id == property_id][0] - - property_current_energy_bill = ( - Recommendations.calculate_recommendation_tenant_savings( - property_instance=property_instance, - kwh_simulation_predictions=model_predictions_mocked, - property_recommendations=property_recommendations, - ashp_cop=2.8 - ) - ) - property_instance.current_energy_bill = property_current_energy_bill - -body = PlanTriggerRequest( - **{'budget': None, 'goal': 'Increasing EPC', 'housing_type': 'Social', 'goal_value': 'B', 'portfolio_id': 0, - 'trigger_file_path': '', 'already_installed_file_path': '', - 'patches_file_path': None, 'non_invasive_recommendations_file_path': None, - 'valuation_file_path': '', - 'required_measures': [], 'scenario_name': 'EPC B', 'scenario_id': None, - 'multi_plan': True, 'optimise': True, 'default_u_values': True, 'ashp_cop': 2.8, - 'event_type': 'remote_assessment', 'simulate_sap_10': False, 'file_type': None, 'file_format': None, - 'sheet_name': None, 'sheet_count': None, 'index_start': None, 'index_end': None} -) - -eco_packages = {} -# For testing -for p in input_properties: - eco_packages[p.id] = (None, None, None) - -for p in tqdm(input_properties): - if not recommendations.get(p.id): - continue - - # Temp allow to skip - if not isinstance(recommendations.get(p.id)[0], list): - continue - - # we need to double unlist because we have a list of lists - property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs} - property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] - measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures] - - # If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore - # its inclusion - needs_ventilation = any( - x in property_measure_types for x in assumptions.measures_needing_ventilation - ) and not p.has_ventilation - - if not measures_to_optimise: - # Nothing to do, we just reshape the recommendations - recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( - p.id, recommendations, set() - ) - continue - - fixed_gain = optimiser_functions.calculate_fixed_gain( - property_required_measures, recommendations, p, needs_ventilation - ) - gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain, eco_packages=eco_packages) - - # funding = Funding( - # tenure=body.housing_type, - # project_scores_matrix=project_scores_matrix, - # partial_project_scores_matrix=partial_project_scores_matrix, - # whlg_eligible_postcodes=whlg_eligible_postcodes, - # eco4_social_cavity_abs_rate=13, - # eco4_social_solid_abs_rate=17, - # eco4_private_cavity_abs_rate=13, - # eco4_private_solid_abs_rate=17, - # gbis_social_cavity_abs_rate=21, - # gbis_social_solid_abs_rate=25, - # gbis_private_cavity_abs_rate=21, - # gbis_private_solid_abs_rate=28, - # ) - # - # li_thickness = convert_thickness_to_numeric( - # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] - # ) - # current_wall_u_value = p.walls["thermal_transmittance"] - # if current_wall_u_value is None: - # current_wall_u_value = get_wall_u_value( - # clean_description=p.walls["clean_description"], - # age_band=p.age_band, - # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], - # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], - # ) - - # We insert the innovation uplift - measures_to_optimise_with_uplift = deepcopy(measures_to_optimise) - - # TODO: Turn this into a function and store the innovaiton uplift - for group in measures_to_optimise_with_uplift: - for r in group: - (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - r["uplift_project_score"]) = ( - 0, 0, 0, 0 - ) - - # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", - # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: - # ( - # r["partial_project_score"], - # r["partial_project_funding"], - # r["innovation_uplift"], - # r["uplift_project_score"], - # ) = ( - # 0, 0, 0, 0 - # ) - # continue - # - # ( - # r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - # r["uplift_project_score"] - # ) = funding.get_innovation_uplift( - # measure=r, - # starting_sap=int(p.data["current-energy-efficiency"]), - # floor_area=p.floor_area, - # is_cavity=p.walls["is_cavity_wall"], - # current_wall_uvalue=current_wall_u_value, - # is_partial="partial" in p.walls["clean_description"].lower(), - # existing_li_thickness=li_thickness, - # mainheating=p.main_heating, - # main_fuel=p.main_fuel, - # mainheat_energy_eff=p.data["mainheat-energy-eff"], - # ) - - if r["already_installed"]: - # if already installed, we zero out the uplift and funding - (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], - r["uplift_project_score"]) = ( - 0, 0, 0, 0 - ) - - input_measures = optimiser_functions.prepare_input_measures( - measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True, - property_eco_packages=eco_packages.get(p.id) - ) - - # When the goal is Increasing EPC, we can run the funding optimiser - if body.goal == "Switch off": - - solutions = optimise_with_funding_paths( - p=p, - input_measures=input_measures, - housing_type=body.housing_type, - budget=body.budget, - target_gain=gain, - funding=funding, - work_package=eco_packages[p.id][2] - ) - - # If the solution isn't eligible, we can't really consider it - solutions = solutions[ - (solutions["is_eligible"] & (solutions["scheme"] != "none")) | (solutions["scheme"] == "none") - ] - - if solutions["meets_upgrade_target"].any(): - # If we have a solution that meets the upgrade target, we select that one - optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0] - else: - # Pick the cheapest - optimal_solution = solutions.iloc[0] - - # This is the list of measures that we will recommend - scheme = optimal_solution["scheme"] - - # We create this full list of selected measures, which is used in the next section for setting - # default measures - solution = deepcopy(optimal_solution["items"]) + deepcopy(optimal_solution["unfunded_items"]) - funded_measures = deepcopy(optimal_solution["items"]) if scheme != "none" else [] - - # This is the total amount of funding that the project will produce (EXCLUDING uplifts) (£) - project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \ - optimal_solution["partial_project_funding"] - # This is the total amount of funding associated to the uplift (£) - total_uplift = optimal_solution["total_uplift"] - # This is the funding scheme selected - # This is the full project ABS - full_project_score = optimal_solution["project_score"] - # This is the partial project ABS - partial_project_score = optimal_solution["partial_project_score"] - # This is the uplift score ABS - uplift_project_score = optimal_solution["total_uplift_score"] - else: - # We optimise and then we determine eligibility for funding, based on the measures selected - optimiser = ( - GainOptimiser( - input_measures, max_cost=body.budget, max_gain=gain, allow_slack=False - ) if body.budget else CostOptimiser(input_measures, min_gain=gain) - ) - optimiser.setup() - optimiser.solve() - solution = optimiser.solution - - recommendation_types = [] - for measures in input_measures: - for measure in measures: - recommendation_types.append(measure["type"]) - recommendation_types = set(recommendation_types) - - has_wall_insulation_recommendation = any( - (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in - WALL_INSULATION_MEASURES - ) - has_roof_insulation_recommendation = any( - (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in - ROOF_INSULATION_MEASURES - ) - - # funding.check_funding( - # measures=solution, - # starting_sap=int(p.data["current-energy-efficiency"]), - # ending_sap=int(p.data["current-energy-efficiency"]) + sum([x["gain"] for x in solution]), - # floor_area=p.floor_area, - # mainheat_description=p.main_heating["clean_description"], - # heating_control_description=p.main_heating_controls["clean_description"], - # is_cavity=p.walls["is_cavity_wall"], - # current_wall_uvalue=current_wall_u_value, - # is_partial="partial" in p.walls["clean_description"].lower(), - # existing_li_thickness=li_thickness, - # mainheating=p.main_heating, - # main_fuel=p.main_fuel, - # mainheat_energy_eff=p.data["mainheat-energy-eff"], - # has_wall_insulation_recommendation=has_wall_insulation_recommendation, - # has_roof_insulation_recommendation=has_roof_insulation_recommendation, - # ) - - # Determine the scheme - scheme = "none" - # if funding.eco4_eligible: - # scheme = "eco4" - # if scheme == "none" and funding.gbis_eligible: - # scheme = "gbis" - - funded_measures = [] - # funded_measures = solution if scheme in ["gbis", "eco4"] else [] - # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs - project_funding = 0 - # total_uplift = funding.eco4_uplift - total_uplift = 0 - # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs - full_project_score = 0 - # partial_project_score = funding.partial_project_abs - partial_project_score = 0 - # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift - uplift_project_score = 0 - - selected = {r["id"] for r in solution} - - if property_required_measures: - solution = optimiser_functions.add_required_measures( - property_id=p.id, property_required_measures=property_required_measures, - recommendations=recommendations, selected=selected, - ) - - # Add best practice measures (ventilation/trickle vents) - selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) - # Final flattening - recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( - p.id, recommendations, selected - ) - - # TODO: functionise - for measure in funded_measures: - if "+mechanical_ventilation" in measure["type"]: - measure["type"] = measure["type"].split("+mechanical_ventilation")[0] - - p.insert_funding( - scheme=scheme, - funded_measures=funded_measures, - project_funding=project_funding, - total_uplift=total_uplift, - full_project_score=full_project_score, - partial_project_score=partial_project_score, - uplift_project_score=uplift_project_score - ) - +# for col in ["lighting-cost-current", "heating-cost-current", "hot-water-cost-current"]: +# if pd.isnull(p.data[col]): +# min_diff = abs( +# (costs_by_floor_area["current-energy-efficiency"] - p.data["current-energy-efficiency"]) +# ).min() +# df = costs_by_floor_area[ +# abs((costs_by_floor_area["current-energy-efficiency"] - p.data[ +# "current-energy-efficiency"])) == min_diff +# ] +# if df.shape[0] > 1: +# df = df.head(1) +# p.data[col] = (df[col + "_scaled"] * p.data["total-floor-area"]).values[0] +# +# [ +# p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) for p in +# input_properties +# ] +# # for p in input_properties: +# # p.set_features(cleaned=cleaned, kwh_client=kwh_client, kwh_predictions=mocked_kwh_predictions) +# +# # Run the recommendations +# recommendations = {} +# recommendations_scoring_data = [] +# representative_recommendations = {} +# for p in tqdm(input_properties): +# if p.data["property-type"] == "House" and pd.isnull(p.data["built-form"]): +# p.data["built-form"] = "Semi-Detached" +# recommender = Recommendations( +# property_instance=p, +# materials=materials, +# exclusions=[], +# inclusions=[], +# default_u_values=True +# ) +# property_recommendations, property_representative_recommendations = recommender.recommend() +# +# if not property_recommendations: +# continue +# +# recommendations[p.id] = property_recommendations +# representative_recommendations[p.id] = property_representative_recommendations +# +# p.create_base_difference_epc_record(cleaned_lookup=cleaned) +# p.adjust_difference_record_with_recommendations( +# property_recommendations, property_representative_recommendations +# ) +# +# recommendations_scoring_data.extend(p.recommendations_scoring_data) +# +# recommendations_scoring_data = pd.DataFrame(recommendations_scoring_data) +# recommendations_scoring_data = recommendations_scoring_data.drop( +# columns=[ +# "rdsap_change", "heat_demand_change", "carbon_change", "sap_ending", "heat_demand_ending", +# "carbon_ending" +# ] +# ) +# +# model_predictions_mocked = { +# "sap_change_predictions": None, +# "heat_demand_predictions": None, +# "carbon_change_predictions": None, +# "heating_kwh_predictions": None, +# "hotwater_kwh_predictions": None, +# } +# +# for k in model_predictions_mocked.keys(): +# model_predictions_mocked[k] = recommendations_scoring_data[["id"]].copy() +# model_predictions_mocked[k][['property_id', 'recommendation_id']] = ( +# model_predictions_mocked[k]['id'].str.split('+', expand=True) +# ) +# model_predictions_mocked[k]['phase'] = model_predictions_mocked[k]['recommendation_id'].apply( +# ModelApi.extract_phase) +# +# if k in ["heating_kwh_predictions", "hotwater_kwh_predictions"]: +# model_predictions_mocked[k]["predictions"] = random.choices(range(100, 3000), +# k=len(recommendations_scoring_data)) +# continue +# +# model_predictions_mocked[k] = model_predictions_mocked[k].sort_values(["property_id", "phase"], ascending=True) +# preds = [] +# for p_id in model_predictions_mocked[k]["property_id"].unique(): +# # We add some amount each time +# p = [p for p in input_properties if str(p.id) == p_id][0] +# if k == "sap_change_predictions": +# start = p.data["current-energy-efficiency"] +# elif k == "heat_demand_predictions": +# start = p.data["energy-consumption-current"] +# else: +# start = p.data["co2-emissions-current"] +# df = model_predictions_mocked[k][model_predictions_mocked[k]["property_id"] == p_id].copy() +# # Add some amount each time +# to_add = random.choices(range(0, 15), k=len(df)) +# to_add = np.cumsum(to_add) +# df["predictions"] = start + to_add +# preds.append(df) +# preds = pd.concat(preds) +# model_predictions_mocked[k] = preds +# +# for property_id in tqdm(recommendations.keys(), total=len(recommendations)): +# property_instance = [p for p in input_properties if p.id == property_id][0] +# +# recommendations_with_impact, impact_summary = ( +# Recommendations.calculate_recommendation_impact( +# property_instance=property_instance, +# all_predictions=model_predictions_mocked, +# recommendations=recommendations, +# representative_recommendations=representative_recommendations +# ) +# ) +# +# # We use the impact_summary to update the simulation_epcs with the new SAP, heat demand, carbon, cost etc +# # at each phase +# property_instance.update_simulation_epcs(impact_summary) +# recommendations[property_id] = recommendations_with_impact +# +# for property_id in tqdm([p.id for p in input_properties]): +# property_recommendations = recommendations.get(property_id, []) +# property_instance = [p for p in input_properties if p.id == property_id][0] +# +# property_current_energy_bill = ( +# Recommendations.calculate_recommendation_tenant_savings( +# property_instance=property_instance, +# kwh_simulation_predictions=model_predictions_mocked, +# property_recommendations=property_recommendations, +# ashp_cop=2.8 +# ) +# ) +# property_instance.current_energy_bill = property_current_energy_bill +# +# body = PlanTriggerRequest( +# **{'budget': None, 'goal': 'Increasing EPC', 'housing_type': 'Social', 'goal_value': 'B', 'portfolio_id': 0, +# 'trigger_file_path': '', 'already_installed_file_path': '', +# 'patches_file_path': None, 'non_invasive_recommendations_file_path': None, +# 'valuation_file_path': '', +# 'required_measures': [], 'scenario_name': 'EPC B', 'scenario_id': None, +# 'multi_plan': True, 'optimise': True, 'default_u_values': True, 'ashp_cop': 2.8, +# 'event_type': 'remote_assessment', 'simulate_sap_10': False, 'file_type': None, 'file_format': None, +# 'sheet_name': None, 'sheet_count': None, 'index_start': None, 'index_end': None} +# ) +# +# eco_packages = {} +# # For testing +# for p in input_properties: +# eco_packages[p.id] = (None, None, None) +# # for p in tqdm(input_properties): # if not recommendations.get(p.id): # continue # +# # Temp allow to skip +# if not isinstance(recommendations.get(p.id)[0], list): +# continue +# # # we need to double unlist because we have a list of lists # property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs} # property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] @@ -590,34 +346,34 @@ for p in tqdm(input_properties): # fixed_gain = optimiser_functions.calculate_fixed_gain( # property_required_measures, recommendations, p, needs_ventilation # ) -# gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain) +# gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain, eco_packages=eco_packages) # -# funding = Funding( -# tenure="Social", -# project_scores_matrix=project_scores_matrix, -# partial_project_scores_matrix=partial_project_scores_matrix, -# whlg_eligible_postcodes=whlg_eligible_postcodes, -# eco4_social_cavity_abs_rate=12.5, -# eco4_social_solid_abs_rate=17, -# eco4_private_cavity_abs_rate=12.5, -# eco4_private_solid_abs_rate=17, -# gbis_social_cavity_abs_rate=21, -# gbis_social_solid_abs_rate=25, -# gbis_private_cavity_abs_rate=21, -# gbis_private_solid_abs_rate=28, -# ) -# -# li_thickness = convert_thickness_to_numeric( -# p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] -# ) -# current_wall_u_value = p.walls["thermal_transmittance"] -# if current_wall_u_value is None: -# current_wall_u_value = get_wall_u_value( -# clean_description=p.walls["clean_description"], -# age_band=p.age_band, -# is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], -# is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], -# ) +# # funding = Funding( +# # tenure=body.housing_type, +# # project_scores_matrix=project_scores_matrix, +# # partial_project_scores_matrix=partial_project_scores_matrix, +# # whlg_eligible_postcodes=whlg_eligible_postcodes, +# # eco4_social_cavity_abs_rate=13, +# # eco4_social_solid_abs_rate=17, +# # eco4_private_cavity_abs_rate=13, +# # eco4_private_solid_abs_rate=17, +# # gbis_social_cavity_abs_rate=21, +# # gbis_social_solid_abs_rate=25, +# # gbis_private_cavity_abs_rate=21, +# # gbis_private_solid_abs_rate=28, +# # ) +# # +# # li_thickness = convert_thickness_to_numeric( +# # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] +# # ) +# # current_wall_u_value = p.walls["thermal_transmittance"] +# # if current_wall_u_value is None: +# # current_wall_u_value = get_wall_u_value( +# # clean_description=p.walls["clean_description"], +# # age_band=p.age_band, +# # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], +# # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], +# # ) # # # We insert the innovation uplift # measures_to_optimise_with_uplift = deepcopy(measures_to_optimise) @@ -625,41 +381,53 @@ for p in tqdm(input_properties): # # TODO: Turn this into a function and store the innovaiton uplift # for group in measures_to_optimise_with_uplift: # for r in group: -# -# if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", -# "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: -# ( -# r["partial_project_score"], -# r["partial_project_funding"], -# r["innovation_uplift"], -# r["uplift_project_score"], -# ) = ( -# 0, 0, 0, 0 -# ) -# continue -# -# ( -# r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], -# r["uplift_project_score"] -# ) = funding.get_innovation_uplift( -# measure=r, -# starting_sap=p.data["current-energy-efficiency"], -# floor_area=p.floor_area, -# is_cavity=p.walls["is_cavity_wall"], -# current_wall_uvalue=current_wall_u_value, -# is_partial="partial" in p.walls["clean_description"].lower(), -# existing_li_thickness=li_thickness, -# mainheating=p.main_heating, -# main_fuel=p.main_fuel, -# mainheat_energy_eff=p.data["mainheat-energy-eff"], +# (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# r["uplift_project_score"]) = ( +# 0, 0, 0, 0 # ) # +# # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", +# # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: +# # ( +# # r["partial_project_score"], +# # r["partial_project_funding"], +# # r["innovation_uplift"], +# # r["uplift_project_score"], +# # ) = ( +# # 0, 0, 0, 0 +# # ) +# # continue +# # +# # ( +# # r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# # r["uplift_project_score"] +# # ) = funding.get_innovation_uplift( +# # measure=r, +# # starting_sap=int(p.data["current-energy-efficiency"]), +# # floor_area=p.floor_area, +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # ) +# +# if r["already_installed"]: +# # if already installed, we zero out the uplift and funding +# (r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# r["uplift_project_score"]) = ( +# 0, 0, 0, 0 +# ) +# # input_measures = optimiser_functions.prepare_input_measures( -# measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True +# measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True, +# property_eco_packages=eco_packages.get(p.id) # ) # # # When the goal is Increasing EPC, we can run the funding optimiser -# if body.goal == "Increasing EPC": +# if body.goal == "Switch off": # # solutions = optimise_with_funding_paths( # p=p, @@ -667,20 +435,14 @@ for p in tqdm(input_properties): # housing_type=body.housing_type, # budget=body.budget, # target_gain=gain, -# funding=funding +# funding=funding, +# work_package=eco_packages[p.id][2] # ) # -# # Given the solutions we select the optimal one -# solutions["cost_less_full_project_funding"] = np.where( -# solutions["scheme"] == "eco4", -# solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"], -# solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"] -# ) -# -# solutions["cost_less_full_project_funding"] = ( -# solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"] -# ) -# solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True) +# # If the solution isn't eligible, we can't really consider it +# solutions = solutions[ +# (solutions["is_eligible"] & (solutions["scheme"] != "none")) | (solutions["scheme"] == "none") +# ] # # if solutions["meets_upgrade_target"].any(): # # If we have a solution that meets the upgrade target, we select that one @@ -691,9 +453,13 @@ for p in tqdm(input_properties): # # # This is the list of measures that we will recommend # scheme = optimal_solution["scheme"] -# funded_measures = optimal_solution["items"] if scheme != "none" else [] -# solution = optimal_solution["items"] + optimal_solution["unfunded_items"] -# # This is the total amount of funding that the project will produce (including uplifts) (£) +# +# # We create this full list of selected measures, which is used in the next section for setting +# # default measures +# solution = deepcopy(optimal_solution["items"]) + deepcopy(optimal_solution["unfunded_items"]) +# funded_measures = deepcopy(optimal_solution["items"]) if scheme != "none" else [] +# +# # This is the total amount of funding that the project will produce (EXCLUDING uplifts) (£) # project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \ # optimal_solution["partial_project_funding"] # # This is the total amount of funding associated to the uplift (£) @@ -731,37 +497,43 @@ for p in tqdm(input_properties): # ROOF_INSULATION_MEASURES # ) # -# funding.check_funding( -# measures=solution, -# starting_sap=p.data["current-energy-efficiency"], -# ending_sap=p.data["current-energy-efficiency"] + sum([x["gain"] for x in solution]), -# floor_area=p.floor_area, -# mainheat_description=p.main_heating["clean_description"], -# heating_control_description=p.main_heating_controls["clean_description"], -# is_cavity=p.walls["is_cavity_wall"], -# current_wall_uvalue=current_wall_u_value, -# is_partial="partial" in p.walls["clean_description"].lower(), -# existing_li_thickness=li_thickness, -# mainheating=p.main_heating, -# main_fuel=p.main_fuel, -# mainheat_energy_eff=p.data["mainheat-energy-eff"], -# has_wall_insulation_recommendation=has_wall_insulation_recommendation, -# has_roof_insulation_recommendation=has_roof_insulation_recommendation, -# ) +# # funding.check_funding( +# # measures=solution, +# # starting_sap=int(p.data["current-energy-efficiency"]), +# # ending_sap=int(p.data["current-energy-efficiency"]) + sum([x["gain"] for x in solution]), +# # floor_area=p.floor_area, +# # mainheat_description=p.main_heating["clean_description"], +# # heating_control_description=p.main_heating_controls["clean_description"], +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # has_wall_insulation_recommendation=has_wall_insulation_recommendation, +# # has_roof_insulation_recommendation=has_roof_insulation_recommendation, +# # ) # # # Determine the scheme # scheme = "none" -# if funding.eco4_eligible: -# scheme = "eco4" -# if scheme == "none" and funding.gbis_eligible: -# scheme = "gbis" +# # if funding.eco4_eligible: +# # scheme = "eco4" +# # if scheme == "none" and funding.gbis_eligible: +# # scheme = "gbis" # -# funded_measures = solution if scheme in ["gbis", "eco4"] else [] -# project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs -# total_uplift = funding.eco4_uplift -# full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs -# partial_project_score = funding.partial_project_abs -# uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift +# funded_measures = [] +# # funded_measures = solution if scheme in ["gbis", "eco4"] else [] +# # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs +# project_funding = 0 +# # total_uplift = funding.eco4_uplift +# total_uplift = 0 +# # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs +# full_project_score = 0 +# # partial_project_score = funding.partial_project_abs +# partial_project_score = 0 +# # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift +# uplift_project_score = 0 # # selected = {r["id"] for r in solution} # @@ -773,10 +545,10 @@ for p in tqdm(input_properties): # # # Add best practice measures (ventilation/trickle vents) # selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) -# # Final flattening - Don't do this! -# # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( -# # p.id, recommendations, selected -# # ) +# # Final flattening +# recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( +# p.id, recommendations, selected +# ) # # # TODO: functionise # for measure in funded_measures: @@ -792,3 +564,231 @@ for p in tqdm(input_properties): # partial_project_score=partial_project_score, # uplift_project_score=uplift_project_score # ) +# +# # for p in tqdm(input_properties): +# # if not recommendations.get(p.id): +# # continue +# # +# # # we need to double unlist because we have a list of lists +# # property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs} +# # property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] +# # measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures] +# # +# # # If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore +# # # its inclusion +# # needs_ventilation = any( +# # x in property_measure_types for x in assumptions.measures_needing_ventilation +# # ) and not p.has_ventilation +# # +# # if not measures_to_optimise: +# # # Nothing to do, we just reshape the recommendations +# # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( +# # p.id, recommendations, set() +# # ) +# # continue +# # +# # fixed_gain = optimiser_functions.calculate_fixed_gain( +# # property_required_measures, recommendations, p, needs_ventilation +# # ) +# # gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain) +# # +# # funding = Funding( +# # tenure="Social", +# # project_scores_matrix=project_scores_matrix, +# # partial_project_scores_matrix=partial_project_scores_matrix, +# # whlg_eligible_postcodes=whlg_eligible_postcodes, +# # eco4_social_cavity_abs_rate=12.5, +# # eco4_social_solid_abs_rate=17, +# # eco4_private_cavity_abs_rate=12.5, +# # eco4_private_solid_abs_rate=17, +# # gbis_social_cavity_abs_rate=21, +# # gbis_social_solid_abs_rate=25, +# # gbis_private_cavity_abs_rate=21, +# # gbis_private_solid_abs_rate=28, +# # ) +# # +# # li_thickness = convert_thickness_to_numeric( +# # p.roof["insulation_thickness"], p.roof["is_pitched"], p.roof["is_flat"] +# # ) +# # current_wall_u_value = p.walls["thermal_transmittance"] +# # if current_wall_u_value is None: +# # current_wall_u_value = get_wall_u_value( +# # clean_description=p.walls["clean_description"], +# # age_band=p.age_band, +# # is_granite_or_whinstone=p.walls["is_granite_or_whinstone"], +# # is_sandstone_or_limestone=p.walls["is_sandstone_or_limestone"], +# # ) +# # +# # # We insert the innovation uplift +# # measures_to_optimise_with_uplift = deepcopy(measures_to_optimise) +# # +# # # TODO: Turn this into a function and store the innovaiton uplift +# # for group in measures_to_optimise_with_uplift: +# # for r in group: +# # +# # if r["type"] in ["mechanical_ventilation", "low_energy_lighting", "secondary_heating", +# # "extension_cavity_wall_insulation", "draught_proofing", "sealing_open_fireplace"]: +# # ( +# # r["partial_project_score"], +# # r["partial_project_funding"], +# # r["innovation_uplift"], +# # r["uplift_project_score"], +# # ) = ( +# # 0, 0, 0, 0 +# # ) +# # continue +# # +# # ( +# # r["partial_project_score"], r["partial_project_funding"], r["innovation_uplift"], +# # r["uplift_project_score"] +# # ) = funding.get_innovation_uplift( +# # measure=r, +# # starting_sap=p.data["current-energy-efficiency"], +# # floor_area=p.floor_area, +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # ) +# # +# # input_measures = optimiser_functions.prepare_input_measures( +# # measures_to_optimise_with_uplift, body.goal, needs_ventilation, funding=True +# # ) +# # +# # # When the goal is Increasing EPC, we can run the funding optimiser +# # if body.goal == "Increasing EPC": +# # +# # solutions = optimise_with_funding_paths( +# # p=p, +# # input_measures=input_measures, +# # housing_type=body.housing_type, +# # budget=body.budget, +# # target_gain=gain, +# # funding=funding +# # ) +# # +# # # Given the solutions we select the optimal one +# # solutions["cost_less_full_project_funding"] = np.where( +# # solutions["scheme"] == "eco4", +# # solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"], +# # solutions["total_cost"] - solutions["partial_project_funding"] - solutions["total_uplift"] +# # ) +# # +# # solutions["cost_less_full_project_funding"] = ( +# # solutions["total_cost"] - solutions["full_project_funding"] - solutions["total_uplift"] +# # ) +# # solutions = solutions.sort_values("cost_less_full_project_funding", ascending=True) +# # +# # if solutions["meets_upgrade_target"].any(): +# # # If we have a solution that meets the upgrade target, we select that one +# # optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0] +# # else: +# # # Pick the cheapest +# # optimal_solution = solutions.iloc[0] +# # +# # # This is the list of measures that we will recommend +# # scheme = optimal_solution["scheme"] +# # funded_measures = optimal_solution["items"] if scheme != "none" else [] +# # solution = optimal_solution["items"] + optimal_solution["unfunded_items"] +# # # This is the total amount of funding that the project will produce (including uplifts) (£) +# # project_funding = optimal_solution["full_project_funding"] if scheme == "eco4" else \ +# # optimal_solution["partial_project_funding"] +# # # This is the total amount of funding associated to the uplift (£) +# # total_uplift = optimal_solution["total_uplift"] +# # # This is the funding scheme selected +# # # This is the full project ABS +# # full_project_score = optimal_solution["project_score"] +# # # This is the partial project ABS +# # partial_project_score = optimal_solution["partial_project_score"] +# # # This is the uplift score ABS +# # uplift_project_score = optimal_solution["total_uplift_score"] +# # else: +# # # We optimise and then we determine eligibility for funding, based on the measures selected +# # optimiser = ( +# # GainOptimiser( +# # input_measures, max_cost=body.budget, max_gain=gain, allow_slack=False +# # ) if body.budget else CostOptimiser(input_measures, min_gain=gain) +# # ) +# # optimiser.setup() +# # optimiser.solve() +# # solution = optimiser.solution +# # +# # recommendation_types = [] +# # for measures in input_measures: +# # for measure in measures: +# # recommendation_types.append(measure["type"]) +# # recommendation_types = set(recommendation_types) +# # +# # has_wall_insulation_recommendation = any( +# # (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in +# # WALL_INSULATION_MEASURES +# # ) +# # has_roof_insulation_recommendation = any( +# # (m in recommendation_types or "+".join([m, "mechanical_ventilation"])) for m in +# # ROOF_INSULATION_MEASURES +# # ) +# # +# # funding.check_funding( +# # measures=solution, +# # starting_sap=p.data["current-energy-efficiency"], +# # ending_sap=p.data["current-energy-efficiency"] + sum([x["gain"] for x in solution]), +# # floor_area=p.floor_area, +# # mainheat_description=p.main_heating["clean_description"], +# # heating_control_description=p.main_heating_controls["clean_description"], +# # is_cavity=p.walls["is_cavity_wall"], +# # current_wall_uvalue=current_wall_u_value, +# # is_partial="partial" in p.walls["clean_description"].lower(), +# # existing_li_thickness=li_thickness, +# # mainheating=p.main_heating, +# # main_fuel=p.main_fuel, +# # mainheat_energy_eff=p.data["mainheat-energy-eff"], +# # has_wall_insulation_recommendation=has_wall_insulation_recommendation, +# # has_roof_insulation_recommendation=has_roof_insulation_recommendation, +# # ) +# # +# # # Determine the scheme +# # scheme = "none" +# # if funding.eco4_eligible: +# # scheme = "eco4" +# # if scheme == "none" and funding.gbis_eligible: +# # scheme = "gbis" +# # +# # funded_measures = solution if scheme in ["gbis", "eco4"] else [] +# # project_funding = 0 if funding.full_project_abs is not None else funding.full_project_abs +# # total_uplift = funding.eco4_uplift +# # full_project_score = 0 if funding.full_project_abs is not None else funding.full_project_abs +# # partial_project_score = funding.partial_project_abs +# # uplift_project_score = funding.eco4_uplift if scheme == "eco4" else funding.gbis_uplift +# # +# # selected = {r["id"] for r in solution} +# # +# # if property_required_measures: +# # solution = optimiser_functions.add_required_measures( +# # property_id=p.id, property_required_measures=property_required_measures, +# # recommendations=recommendations, selected=selected, +# # ) +# # +# # # Add best practice measures (ventilation/trickle vents) +# # selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) +# # # Final flattening - Don't do this! +# # # recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( +# # # p.id, recommendations, selected +# # # ) +# # +# # # TODO: functionise +# # for measure in funded_measures: +# # if "+mechanical_ventilation" in measure["type"]: +# # measure["type"] = measure["type"].split("+mechanical_ventilation")[0] +# # +# # p.insert_funding( +# # scheme=scheme, +# # funded_measures=funded_measures, +# # project_funding=project_funding, +# # total_uplift=total_uplift, +# # full_project_score=full_project_score, +# # partial_project_score=partial_project_score, +# # uplift_project_score=uplift_project_score +# # ) diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index d3467919..2795d7ff 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -1,7 +1,7 @@ import pandas as pd import numpy as np from backend.Property import Property -from typing import List +from typing import List, Mapping from itertools import groupby from recommendations.FloorRecommendations import FloorRecommendations from recommendations.WallRecommendations import WallRecommendations @@ -31,6 +31,18 @@ class Recommendations: High level recommendations class, which sits above the measure specific recommendation classes """ + # Used in calculation of recommendation impact - increasing variables are features where + # a higher value indicates an improvement. Decreasing is the opposite + INCREASING_VARIABLES = ["sap"] + DECREASING_VARIABLES = ["carbon", "heat_demand"] + + # If the recommendation is mechanical ventilation, we don't apply the rule that the new value should be higher + MV_INCREASING_VARIABLES = ["carbon", "heat_demand"] + MV_DECREASING_VARIABLES = ["sap"] + + # List of models we expect predictions for, when calculation recommendation impact + PREDICTION_PREFIXES = ["sap_change", "heat_demand", "carbon_change"] + def __init__( self, property_instance: Property, @@ -514,15 +526,50 @@ class Recommendations: filtered_adjustments.append(adjustments[0]) return filtered_adjustments + @classmethod + def _filter_predictions_for_property( + cls, + all_predictions: Mapping[str, pd.DataFrame], + property_id: str, + ) -> dict: + """ + Utility function to filter predictions for a specific property + :param all_predictions: Dictionary of all predictions from the model apis + :param property_id: The property id to filter for + :return: + """ + + return { + f"{prefix}_predictions": ( + all_predictions[f"{prefix}_predictions"] + .loc[ + all_predictions[f"{prefix}_predictions"]["property_id"] == property_id + ] + .copy() + ) + for prefix in cls.PREDICTION_PREFIXES + } + + @classmethod + def get_monotonic_variables(cls, rec_type: str) -> tuple[List[str], List[str]]: + """ + Utility function to get the monotonic variables for a specific recommendation type + :param rec_type: The recommendation type + :return: + """ + if rec_type == "mechanical_ventilation": + return cls.MV_INCREASING_VARIABLES, cls.MV_DECREASING_VARIABLES + return cls.INCREASING_VARIABLES, cls.DECREASING_VARIABLES + @classmethod def calculate_recommendation_impact( cls, - property_instance, - all_predictions, - recommendations, - representative_recommendations, - debug=False - ): + property_instance: Property, + all_predictions: Mapping[str, any], + recommendations: Mapping[int, List], + representative_recommendations: Mapping[int, List], + debug: bool = False + ) -> (Mapping[int, List], List[Mapping[str, any]]): """ Given predictions from the model apis, with method will update the recommendations with the predicted @@ -539,31 +586,20 @@ class Recommendations: :param debug: boolean, indicating if the function is running in debug mode. The only difference is that adjustments are returned for testing - :return: + :return: Updated recommendations with predicted impact, and a list of impacts by phase """ + property_predictions = cls._filter_predictions_for_property( + all_predictions, str(property_instance.id) + ) - property_predictions = { - prefix + "_predictions": all_predictions[prefix + "_predictions"][ - all_predictions[prefix + "_predictions"]["property_id"] == str(property_instance.id) - ].copy() for prefix in ["sap_change", "heat_demand", "carbon_change"] - } - + # shallow copy intentional - we're going to modify the internals property_recommendations = recommendations[property_instance.id].copy() representative_recs = representative_recommendations[property_instance.id].copy() representative_ids = [r["recommendation_id"] for r in representative_recs] - increasing_variables = ["sap"] - decreasing_variables = ["carbon", "heat_demand"] - - # If the recommendation is mechanical ventilation, we don't apply the rule that the new value should be higher - mv_increasing_variables = ["carbon", "heat_demand"] - mv_decreasing_variables = ["sap"] - # We allow for negative phase - starting_phase = min( - rec["phase"] for recs in property_recommendations for rec in recs - ) + starting_phase = min(rec["phase"] for recs in property_recommendations for rec in recs) # We keep a history of adjustments we have made, so that we ensure that we adjust future # phases for SAP @@ -602,7 +638,7 @@ class Recommendations: prefix: property_predictions[prefix + "_predictions"][ property_predictions[prefix + "_predictions"]["recommendation_id"] == str( rec["recommendation_id"] - )]["predictions"].values[0] for prefix in ["sap_change", "heat_demand", "carbon_change"] + )]["predictions"].values[0] for prefix in cls.PREDICTION_PREFIXES } # We structure this so that depending on the phase, we capture the previous phase impacts and @@ -669,12 +705,7 @@ class Recommendations: # However, if the recommendation is mechanical ventilation, this can have a negative SAP impact so # we don't apply this rule - if rec["type"] == "mechanical_ventilation": - phase_increasing_variables = mv_increasing_variables - phase_decreasing_variables = mv_decreasing_variables - else: - phase_increasing_variables = increasing_variables - phase_decreasing_variables = decreasing_variables + phase_increasing_variables, phase_decreasing_variables = cls.get_monotonic_variables(rec["type"]) for v in phase_increasing_variables: current_phase_values[v] = ( @@ -718,7 +749,21 @@ class Recommendations: property_instance.lighting["low_energy_proportion"] ) - property_phase_impact["sap"] = min(property_phase_impact["sap"], lighting_sap_limit) + # add an adjustment + proposed_sap_impact = min(property_phase_impact["sap"], lighting_sap_limit) + if proposed_sap_impact != property_phase_impact["sap"]: + # Store the sap adjustment. The proposed sap impact will always be less + # than the current sap impact, so the adjustment is always positive + # as we subtract it from the future phases + adjustments.append( + { + "recommendation_id": rec["recommendation_id"], + "phase": rec["phase"], + "sap_adjustment": property_phase_impact["sap"] - proposed_sap_impact, + } + ) + + property_phase_impact["sap"] = proposed_sap_impact property_phase_impact["carbon"] = min( property_phase_impact["carbon"], rec["co2_equivalent_savings"] ) @@ -812,8 +857,9 @@ class Recommendations: { "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"], + # If we've made an adjustment, we will be increasing the number of SAP + # points. Since, we subtract adjustments, this number should be negative + "sap_adjustment": property_phase_impact["sap"] - proposed_impact, } ) property_phase_impact["sap"] = proposed_impact diff --git a/recommendations/tests/test_data/__init__.py b/recommendations/tests/test_data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/recommendations/tests/test_recommendations.py b/recommendations/tests/test_recommendations.py index c933ad25..7a7930bf 100644 --- a/recommendations/tests/test_recommendations.py +++ b/recommendations/tests/test_recommendations.py @@ -1,72 +1,385 @@ +import pytest import datetime import pandas as pd -from pandas import Timestamp import numpy as np +from pandas import Timestamp 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}, - ] +@pytest.fixture +def heat_demand_predictions(): + return 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} + ] + ) -def test_calculate_recommendation_impact(): - all_predictions = { +@pytest.fixture +def carbon_predictions(): + return 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} + ] + ) + + +@pytest.fixture +def property_instance(): + return 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={ + "is_loft": True, + "insulation_thickness": "250", + "is_valid": True, + }, + lighting={ + "low_energy_proportion": 0.5 + } + ) + + +@pytest.mark.parametrize( + "input_data, expected", + [ + ( + [ + {"recommendation_id": "a", "phase": 0, "sap_adjustment": 1.7}, + {"recommendation_id": "b", "phase": 0, "sap_adjustment": 1.7}, + ], + [{"recommendation_id": "a", "phase": 0, "sap_adjustment": 1.7}], + ), + ( + [ + {"recommendation_id": "a", "phase": 1, "sap_adjustment": 2}, + {"recommendation_id": "b", "phase": 2, "sap_adjustment": 3}, + ], + [ + {"recommendation_id": "a", "phase": 1, "sap_adjustment": 2}, + {"recommendation_id": "b", "phase": 2, "sap_adjustment": 3}, + ], + ), + ], +) +def test_filter_phase_adjustment(input_data, expected): + assert Recommendations._filter_phase_adjustment(input_data) == expected + + +def test_calculate_recommendation_impact(property_instance, heat_demand_predictions, carbon_predictions): + ####### + # Case 3 + ####### + # Here, the solar impact falls below our threshold and so we expect a solar adjustment to increase the impact + # above the minimum threshold + + all_predictions3 = { "sap_change_predictions": pd.DataFrame( [ {'id': '614626+0_phase=0', 'predictions': 66.7, 'property_id': '614626', @@ -195,367 +508,363 @@ def test_calculate_recommendation_impact(): {'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} - ] - ), + "heat_demand_predictions": heat_demand_predictions, + "carbon_change_predictions": carbon_predictions, "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 - } + recommendations3 = { + 614626: [ + [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'survey': False, + 'recommendation_id': '0_phase=0', + 'co2_equivalent_savings': np.float64(0.19999999999999973), + 'heat_demand': np.float64(27.399999999999977)}, + ], + [ + { + 'phase': 1, + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + '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, + 'innovation_rate': 0.0, + 'recommendation_id': '3_phase=1', 'efficiency': 0} + ], + [ + { + 'phase': 2, + 'type': 'low_energy_lighting', + 'measure_type': 'low_energy_lighting', + 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + 'total': 10.5, 'contingency': 2.73, + 'contingency_rate': 0.26, 'labour_hours': 1, 'labour_days': 0.125, 'survey': True, + 'recommendation_id': '4_phase=2', 'efficiency': 10.5, 'heat_demand': np.float64(4.100000000000023)} + ], + [ + { + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + 'total': 70, + 'contingency': 7.0, 'contingency_rate': 0.1, 'subtotal': 58.333333333333336, + 'vat': 11.666666666666664, + 'labour_hours': 0.5, 'labour_days': 1, + 'sap_points': np.float64(1.0), 'already_installed': False, + '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', + '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), + 'sap_points': np.float64(1.8), 'already_installed': False, + 'recommendation_id': '6_phase=3', 'efficiency': 604.5840000000001, + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(13.300000000000011) + }, + { + 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump', + 'sap_points': np.float64(3.8), + 'already_installed': False, + '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, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + '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), '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, + 'type': 'solar_pv', + 'measure_type': 'solar_pv', + '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, + 'initial_ac_kwh_per_year': np.float64(4844.465553999999), + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + ] + } + + representative_recommendations3 = { + 614626: [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'already_installed': False, + '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, + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + '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, + 'innovation_rate': 0.0, + 'recommendation_id': '3_phase=1', 'efficiency': 0}, + { + 'phase': 2, + 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + '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, + '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, + '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, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + '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), + 'recommendation_id': '8_phase=4', 'efficiency': 60.0, 'co2_equivalent_savings': np.float64(0.0), + 'heat_demand': np.float64(0.0)}, + { + 'phase': 5, + 'type': 'solar_pv', + 'measure_type': 'solar_pv', + + '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, + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + } + + recommendations_with_impact3, impact_summary3, adjustments3 = ( + Recommendations.calculate_recommendation_impact( + property_instance=property_instance, + all_predictions=all_predictions3, + recommendations=recommendations3, + representative_recommendations=representative_recommendations3, + debug=True + ) ) + # We expect adjustments for loft insulation, lighting and solar + + assert adjustments3 == [ + {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)}, + {'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)}, + {'recommendation_id': '29_phase=5', 'phase': 5, 'sap_adjustment': np.float64(-2.5)} + ] + + # Check the impact has slowed through to solar - the final on the impact summary. The 5 + # point prediction isn't associated to the prediction from the model so the adjustment + # should be + + df = all_predictions3["sap_change_predictions"] + raw_prediction = 83.8 + # We expect 1.7 decrease from loft, 4 decrease from lighting, and 2.5 increase from solar + # for a total of a 3.2 decrease + expected_adjusted_prediction = raw_prediction - 3.2 + + assert impact_summary3[-1]["sap"] == expected_adjusted_prediction + + +def test_loft_adjustment_flows_to_solar(property_instance, heat_demand_predictions, carbon_predictions): + ######################## + # Case 1 + ######################## + # Just an adjustment to loft insulation + + 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}] + ) + + all_predictions = { + "sap_change_predictions": sap_change_predictions, + "heat_demand_predictions": heat_demand_predictions, + "carbon_change_predictions": carbon_predictions, + "hotwater_kwh_predictions": pd.DataFrame([]), + "heating_kwh_predictions": pd.DataFrame([]), + } + 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', + 'phase': 0, '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, + 'sap_points': 0, + 'already_installed': False, '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), @@ -564,126 +873,75 @@ def test_calculate_recommendation_impact(): ], [ { - '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'}], + 'phase': 1, '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), + '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, + {'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', '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)} + { + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + '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, + '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', + '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, + 'recommendation_id': '6_phase=3', + 'co2_equivalent_savings': np.float64(0.10000000000000009), + 'heat_demand': np.float64(13.300000000000011) + }, + { + 'phase': 3, 'type': 'heating', 'measure_type': 'air_source_heat_pump', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8), + 'already_installed': False, + '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, + {'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + '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, + 'labour_days': np.float64(1.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', + 'phase': 5, '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) } @@ -694,28 +952,10 @@ def test_calculate_recommendation_impact(): 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', + 'phase': 0, '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, + 'already_installed': False, '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), @@ -723,22 +963,9 @@ def test_calculate_recommendation_impact(): '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'}], + 'phase': 1, '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), @@ -746,79 +973,48 @@ def test_calculate_recommendation_impact(): '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, + 'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + '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, + 'type': 'heating', 'measure_type': 'roomstat_programmer_trvs', 'phase': 3, + '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, + 'phase': 4, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + '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', + 'phase': 5, '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) + 'recommendation_id': '29_phase=5', } ] } recommendations_with_impact, impact_summary, adjustments = ( Recommendations.calculate_recommendation_impact( - property_instance=p, + property_instance=property_instance, all_predictions=all_predictions, recommendations=recommendations, representative_recommendations=representative_recommendations, @@ -833,11 +1029,308 @@ def test_calculate_recommendation_impact(): # 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 + final_impact_summary = impact_summary[-1] + assert float(final_impact_summary["sap"]) == 82.1 + assert float(final_impact_summary["sap_prediction"]) == 83.8 + assert final_impact_summary["measure_type"] == "solar_pv" + assert recommendations_with_impact[0][0]["sap_points"] == 0 - 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) + +def test_lighting_and_loft_adjustment_combined(property_instance, heat_demand_predictions, carbon_predictions): + ######################## + # Case 2 + ######################## + # Example case with both a loft insulation and lighting adjustment + # lighting now has a SAP point impact of 5 - the affected recommendation is + # recommendation_id=4_phase=2 + all_predictions2 = { + "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': 71.3, 'property_id': '614626', + 'recommendation_id': '4_phase=2', + 'phase': 2}, + {'id': '614626+5_phase=3', 'predictions': 72.3, 'property_id': '614626', + 'recommendation_id': '5_phase=3', + 'phase': 3}, + {'id': '614626+6_phase=3', 'predictions': 73.1, 'property_id': '614626', + 'recommendation_id': '6_phase=3', + 'phase': 3}, + {'id': '614626+7_phase=3', 'predictions': 75.1, 'property_id': '614626', + 'recommendation_id': '7_phase=3', + 'phase': 3}, + {'id': '614626+8_phase=4', 'predictions': 72.3, 'property_id': '614626', + 'recommendation_id': '8_phase=4', + 'phase': 4}, + {'id': '614626+9_phase=5', 'predictions': 90.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': 90.3, 'property_id': '614626', + 'recommendation_id': '11_phase=5', 'phase': 5}, + {'id': '614626+12_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '12_phase=5', 'phase': 5}, + {'id': '614626+13_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '13_phase=5', 'phase': 5}, + {'id': '614626+14_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '14_phase=5', 'phase': 5}, + {'id': '614626+15_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '15_phase=5', 'phase': 5}, + {'id': '614626+16_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '16_phase=5', 'phase': 5}, + {'id': '614626+17_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '17_phase=5', 'phase': 5}, + {'id': '614626+18_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '18_phase=5', 'phase': 5}, + {'id': '614626+19_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '19_phase=5', 'phase': 5}, + {'id': '614626+20_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '20_phase=5', 'phase': 5}, + {'id': '614626+21_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '21_phase=5', 'phase': 5}, + {'id': '614626+22_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '22_phase=5', 'phase': 5}, + {'id': '614626+23_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '23_phase=5', 'phase': 5}, + {'id': '614626+24_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '24_phase=5', 'phase': 5}, + {'id': '614626+25_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '25_phase=5', 'phase': 5}, + {'id': '614626+26_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '26_phase=5', 'phase': 5}, + {'id': '614626+27_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '27_phase=5', 'phase': 5}, + {'id': '614626+28_phase=5', 'predictions': 91.7, 'property_id': '614626', + 'recommendation_id': '28_phase=5', 'phase': 5}, + {'id': '614626+29_phase=5', 'predictions': 88.8, 'property_id': '614626', + 'recommendation_id': '29_phase=5', 'phase': 5}, + {'id': '614626+30_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '30_phase=5', 'phase': 5}, + {'id': '614626+31_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '31_phase=5', 'phase': 5}, + {'id': '614626+32_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '32_phase=5', 'phase': 5}, + {'id': '614626+33_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '33_phase=5', 'phase': 5}, + {'id': '614626+34_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '34_phase=5', 'phase': 5}, + {'id': '614626+35_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '35_phase=5', 'phase': 5}, + {'id': '614626+36_phase=5', 'predictions': 91.4, 'property_id': '614626', + 'recommendation_id': '36_phase=5', 'phase': 5}, + {'id': '614626+37_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '37_phase=5', 'phase': 5}, + {'id': '614626+38_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '38_phase=5', 'phase': 5}, + {'id': '614626+39_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '39_phase=5', 'phase': 5}, + {'id': '614626+40_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '40_phase=5', 'phase': 5}, + {'id': '614626+41_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '41_phase=5', 'phase': 5}, + {'id': '614626+42_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '42_phase=5', 'phase': 5}, + {'id': '614626+43_phase=5', 'predictions': 88.4, 'property_id': '614626', + 'recommendation_id': '43_phase=5', 'phase': 5}, + {'id': '614626+44_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '44_phase=5', 'phase': 5}, + {'id': '614626+45_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '45_phase=5', 'phase': 5}, + {'id': '614626+46_phase=5', 'predictions': 90.5, 'property_id': '614626', + 'recommendation_id': '46_phase=5', 'phase': 5}, + {'id': '614626+47_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '47_phase=5', 'phase': 5}, + {'id': '614626+48_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '48_phase=5', 'phase': 5}, + {'id': '614626+49_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '49_phase=5', 'phase': 5}, + {'id': '614626+50_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '50_phase=5', 'phase': 5}, + {'id': '614626+51_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '51_phase=5', 'phase': 5}, + {'id': '614626+52_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '52_phase=5', 'phase': 5}, + {'id': '614626+53_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '53_phase=5', 'phase': 5}, + {'id': '614626+54_phase=5', 'predictions': 90.4, 'property_id': '614626', + 'recommendation_id': '54_phase=5', 'phase': 5}, + {'id': '614626+55_phase=5', 'predictions': 84.4, 'property_id': '614626', + 'recommendation_id': '55_phase=5', 'phase': 5}, + {'id': '614626+56_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '56_phase=5', 'phase': 5}, + {'id': '614626+57_phase=5', 'predictions': 86.2, 'property_id': '614626', + 'recommendation_id': '57_phase=5', 'phase': 5}] + ), + "heat_demand_predictions": heat_demand_predictions, + "carbon_change_predictions": carbon_predictions, + "hotwater_kwh_predictions": pd.DataFrame([]), + "heating_kwh_predictions": pd.DataFrame([]), } + + recommendations2 = { + 614626: [ + [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + '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, + 'type': 'mechanical_ventilation', 'measure_type': 'mechanical_ventilation', + '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, + 'recommendation_id': '3_phase=1', + } + ], + [ + { + 'phase': 2, 'type': 'low_energy_lighting', 'measure_type': 'low_energy_lighting', + 'new_u_value': None, + 'already_installed': False, 'sap_points': 5, 'kwh_savings': 164.25, + 'energy_cost_savings': 45.480824999999996, 'co2_equivalent_savings': np.float64(0.0), + '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, + '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, + 'recommendation_id': '5_phase=3', + 'co2_equivalent_savings': np.float64(0.10000000000000009), 'heat_demand': np.float64(8.5)}, + { + 'type': 'heating', 'phase': 3, 'measure_type': '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, + '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, 'type': 'heating', 'measure_type': 'air_source_heat_pump', + 'starting_u_value': None, 'new_u_value': None, 'sap_points': np.float64(3.8), + 'already_installed': False, + '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, 'type': 'secondary_heating', 'measure_type': 'secondary_heating', + '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), '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, 'type': 'solar_pv', + 'measure_type': 'solar_pv', + '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, + 'innovation_rate': 0.0, 'recommendation_id': '29_phase=5', + 'efficiency': np.float64(368.263125) + } + ] + ] + } + + representative_recommendations2 = { + 614626: [ + { + 'phase': 0, + 'type': 'loft_insulation', + 'measure_type': 'loft_insulation', + 'sap_points': 0, + 'survey': False, + 'recommendation_id': '0_phase=0', + }, + { + 'phase': 1, + 'type': 'mechanical_ventilation', + 'measure_type': 'mechanical_ventilation', + 'sap_points': np.float64(-1.4000000000000057), + 'recommendation_id': '3_phase=1' + }, + { + 'phase': 2, + 'type': 'low_energy_lighting', + 'measure_type': 'low_energy_lighting', + 'sap_points': 5, + 'survey': True, + 'recommendation_id': '4_phase=2', + }, + { + 'type': 'heating', + 'measure_type': 'roomstat_programmer_trvs', + 'phase': 3, + 'sap_points': np.float64(1.0), + 'recommendation_id': '5_phase=3', + }, + { + 'phase': 4, + 'type': 'secondary_heating', + 'measure_type': 'secondary_heating', + 'sap_points': np.float64(0.0), + 'recommendation_id': '8_phase=4', + }, + { + 'phase': 5, + 'type': 'solar_pv', + 'measure_type': 'solar_pv', + 'sap_points': np.float64(16.0), + 'recommendation_id': '29_phase=5', + } + ] + } + + recommendations_with_impact2, impact_summary2, adjustments2 = ( + Recommendations.calculate_recommendation_impact( + property_instance=property_instance, + all_predictions=all_predictions2, + recommendations=recommendations2, + representative_recommendations=representative_recommendations2, + debug=True + ) + ) + + assert adjustments2 == [ + {'recommendation_id': '0_phase=0', 'phase': 0, 'sap_adjustment': np.float64(1.7)}, + {'recommendation_id': '4_phase=2', 'phase': 2, 'sap_adjustment': np.float64(4.0)} + ] diff --git a/tox.ini b/tox.ini index 19a4ad9a..e330f564 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ passenv = EPC_AUTH_TOKEN description = Install dependencies and run tests deps = -rbackend/engine/requirements.txt + -rbackend/app/requirements/requirements.txt -rtest.requirements.txt commands = pytest