diff --git a/.idea/Model.iml b/.idea/Model.iml index b0f9c00d..4413bb06 100644 --- a/.idea/Model.iml +++ b/.idea/Model.iml @@ -7,7 +7,7 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index 1122b380..6f308057 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/backend/Property.py b/backend/Property.py index d3dd8395..a8ed9129 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -1,4 +1,5 @@ import os +import ast from itertools import groupby import pandas as pd @@ -55,7 +56,13 @@ class Property: DATA_ANOMALY_MATCHES = DATA_ANOMALY_MATCHES - def __init__(self, id, postcode, address, epc_record): + # Surplus information, that can be provided as optional inputs, by a customer + n_bathrooms = None + n_bedrooms = None + + def __init__( + self, id, postcode, address, epc_record, already_installed=None, **kwargs + ): self.epc_record = epc_record @@ -68,6 +75,11 @@ class Property: } self.old_data = epc_record.get("old_data") self.property_dimensions = None + # This is a list of measures that have already been installed in the property, typically found as a result + # of the non-invasive surveys. We reflect that this has been installed in the recommendations, but remove the + # cost and instead, provide a message that the measure has already been installed + + self.already_installed = ast.literal_eval(already_installed['already_installed']) if already_installed else [] self.uprn = epc_record.get("uprn") self.full_sap_epc = epc_record.get("full_sap_epc") @@ -133,6 +145,35 @@ class Property: self.recommendations_scoring_data = [] + self.parse_kwargs(kwargs) + + @classmethod + def extract_kwargs(cls, kwargs): + """ + This method is to be used in the router, to extract the kwargs from the request and prevent any errors such as + non-integer values, or inputs that clash with the __init__ method of this class + :param kwargs: + :return: + """ + n_bathrooms = kwargs.get("n_bathrooms", None) + if n_bathrooms is not None: + # We add on a small value to ensure that the number of bathrooms is rounded up, in case the value is 0.5 + n_bathrooms = int(round(float(n_bathrooms) + 1e-5)) + + n_bedrooms = kwargs.get("n_bedrooms", None) + if n_bedrooms is not None: + n_bedrooms = int(round(float(n_bedrooms) + 1e-5)) + + return { + "n_bathrooms": n_bathrooms, + "n_bedrooms": n_bedrooms, + } + + def parse_kwargs(self, kwargs): + # We extract the elements from kwargs that we recognise. Anything additional is ignored + self.n_bathrooms = kwargs.get("n_bathrooms", None) + self.n_bedrooms = kwargs.get("n_bedrooms", None) + def create_base_difference_epc_record(self, cleaned_lookup: dict): """ Creates a EPCDifferenceRecord object, which is used to store the difference between the current and @@ -421,7 +462,9 @@ class Property: "double glazing installed during or after 2002" ) - if recommendation["type"] in ["heating", "hot_water_tank_insulation", "heating_control"]: + if recommendation["type"] in [ + "heating", "hot_water_tank_insulation", "heating_control", "secondary_heating" + ]: # We update the data, as defined in the recommendaton simulation_config = recommendation["simulation_config"] @@ -442,7 +485,7 @@ class Property: "loft_insulation", "room_roof_insulation", "flat_roof_insulation", "solid_floor_insulation", "suspended_floor_insulation", "exposed_floor_insulation", "windows_glazing", "solar_pv", "heating", "hot_water_tank_insulation", - "heating_control", + "heating_control", "secondary_heating" ]: raise NotImplementedError( "Implement me, given type %s" % recommendation["type"] diff --git a/backend/app/db/functions/non_intrusive_surveys.py b/backend/app/db/functions/non_intrusive_surveys.py new file mode 100644 index 00000000..93348121 --- /dev/null +++ b/backend/app/db/functions/non_intrusive_surveys.py @@ -0,0 +1,50 @@ +from sqlalchemy.orm import Session +from backend.app.db.models.non_intrusive_surveys import NonIntrusiveSurvey, NonIntrusiveSurveyNotes + + +def upload_non_intrusive_survey_notes(session: Session, non_invasive_notes, batch_size=500): + """ + Uploads a list of non-intrusive survey notes into the database in batches. Each dictionary in the list represents + one survey and its associated notes. + + :param session: SQLAlchemy Session object through which all database transactions are handled. + :param non_invasive_notes: List of dictionaries where each dictionary contains survey details including 'uprn', + 'survey_date', 'surveyor', and other notes as key-value pairs. + :param batch_size: The size of each batch to be processed (default is 500). + :return: None + """ + + # Helper function to process each batch + def process_batch(batch): + surveys = [] + notes = [] + + for note in batch: + survey = NonIntrusiveSurvey( + uprn=note['uprn'], + survey_date=note['survey_date'], + surveyor=note['surveyor'] + ) + surveys.append(survey) + + session.add_all(surveys) + session.flush() # Get IDs for surveys + + for note, survey in zip(batch, surveys): + for key, value in note.items(): + if key not in ['uprn', 'survey_date', 'surveyor']: + notes.append(NonIntrusiveSurveyNotes( + survey_id=survey.id, + title=key, + note=value + )) + + session.bulk_save_objects(notes) + session.commit() + + # Split the data into batches and process each batch + total = len(non_invasive_notes) + for start in range(0, total, batch_size): + end = min(start + batch_size, total) + batch = non_invasive_notes[start:end] + process_batch(batch) diff --git a/backend/app/db/functions/recommendations_functions.py b/backend/app/db/functions/recommendations_functions.py index 1426e339..b22ce92f 100644 --- a/backend/app/db/functions/recommendations_functions.py +++ b/backend/app/db/functions/recommendations_functions.py @@ -85,7 +85,8 @@ def upload_recommendations(session: Session, recommendations_to_upload, property "co2_equivalent_savings": rec["co2_equivalent_savings"], "total_work_hours": rec["labour_hours"], "energy_cost_savings": rec["energy_cost_savings"], - "labour_days": rec["labour_days"] + "labour_days": rec["labour_days"], + "already_installed": rec["already_installed"], } for rec in recommendations_to_upload ] diff --git a/backend/app/db/models/non_intrusive_surveys.py b/backend/app/db/models/non_intrusive_surveys.py new file mode 100644 index 00000000..bc2d8adc --- /dev/null +++ b/backend/app/db/models/non_intrusive_surveys.py @@ -0,0 +1,22 @@ +from sqlalchemy import Column, BigInteger, String, TIMESTAMP, ForeignKey, Integer +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + + +class NonIntrusiveSurvey(Base): + __tablename__ = 'non_intrusive_survey' + + id = Column(BigInteger, primary_key=True, autoincrement=True) + uprn = Column(Integer, nullable=False) + survey_date = Column(TIMESTAMP, nullable=False) + surveyor = Column(String, nullable=False) + + +class NonIntrusiveSurveyNotes(Base): + __tablename__ = 'non_intrusive_survey_notes' + + id = Column(BigInteger, primary_key=True, autoincrement=True) + survey_id = Column(BigInteger, ForeignKey('non_intrusive_survey.id'), nullable=False) + title = Column(String, nullable=False) + note = Column(String, nullable=False) diff --git a/backend/app/db/models/recommendations.py b/backend/app/db/models/recommendations.py index a492f2f2..186f87a8 100644 --- a/backend/app/db/models/recommendations.py +++ b/backend/app/db/models/recommendations.py @@ -30,6 +30,7 @@ class Recommendation(Base): rental_yield_increase = Column(Float) total_work_hours = Column(Float) labour_days = Column(Float) + already_installed = Column(Boolean, nullable=False, default=False) class RecommendationMaterials(Base): diff --git a/backend/app/plan/router.py b/backend/app/plan/router.py index 50b8a837..49e14872 100644 --- a/backend/app/plan/router.py +++ b/backend/app/plan/router.py @@ -44,20 +44,15 @@ BATCH_SIZE = 5 SCORING_BATCH_SIZE = 400 -def patch_epc(config, epc_records): +def patch_epc(patch, epc_records): """ This utility function is useful to patch the epc data if we have data from the customer :return: """ - number_habitable_rooms = config.get("number-habitable-rooms", None) - number_heated_rooms = config.get("number-heated-rooms", None) - - if number_habitable_rooms is not None: - epc_records["original_epc"]["number-habitable-rooms"] = int(number_habitable_rooms) - - if number_heated_rooms is not None: - epc_records["original_epc"]["number-heated-rooms"] = int(number_heated_rooms) + for patch_variable, patch_value in patch.items(): + if patch_variable in epc_records["original_epc"]: + epc_records["original_epc"][patch_variable] = patch_value return epc_records @@ -79,12 +74,23 @@ async def trigger_plan(body: PlanTriggerRequest): # TODO: We should store the trigger file path in the database with the plan so we can track the file that # triggered the plan - # TODO: Create the ability to congigure/switch off certain measures + # TODO: if the measure is already installed, it should actually be the very first phase try: session.begin() logger.info("Getting the inputs") plan_input = read_csv_from_s3(bucket_name=get_settings().PLAN_TRIGGER_BUCKET, filepath=body.trigger_file_path) + # If we have patches or overrides, we should read them in here + patches = [] + if body.patches_file_path: + patches = read_csv_from_s3(bucket_name=get_settings().PLAN_TRIGGER_BUCKET, filepath=body.patches_file_path) + + already_installed = [] + if body.already_installed_file_path: + already_installed = read_csv_from_s3( + bucket_name=get_settings().PLAN_TRIGGER_BUCKET, filepath=body.already_installed_file_path + ) + cleaning_data = read_dataframe_from_s3_parquet( bucket_name=get_settings().DATA_BUCKET, file_key="sap_change_model/cleaning_dataset.parquet", ) @@ -108,7 +114,6 @@ async def trigger_plan(body: PlanTriggerRequest): property_id, is_new = create_property( session, body.portfolio_id, epc_searcher.address_clean, epc_searcher.postcode_clean, epc_searcher.uprn ) - # if a new record was not created, we don't produduce recommendations if not is_new: continue @@ -125,7 +130,11 @@ async def trigger_plan(body: PlanTriggerRequest): 'full_sap_epc': epc_searcher.full_sap_epc.copy(), 'old_data': epc_searcher.older_epcs.copy(), } - epc_records = patch_epc(config, epc_records) + + patch = next(( + x for x in patches if (x["address"] == config["address"]) and (x["postcode"] == config["postcode"]) + ), {}) + epc_records = patch_epc(patch, epc_records) prepared_epc = EPCRecord( epc_records=epc_records, @@ -133,12 +142,18 @@ async def trigger_plan(body: PlanTriggerRequest): cleaning_data=cleaning_data ) + property_already_installed = next(( + x for x in already_installed if + (x["address"] == config["address"]) and (x["postcode"] == config["postcode"]) + ), {}) input_properties.append( Property( id=property_id, address=epc_searcher.address_clean, postcode=epc_searcher.postcode_clean, epc_record=prepared_epc, + already_installed=property_already_installed, + **Property.extract_kwargs(config) ) ) @@ -242,7 +257,7 @@ async def trigger_plan(body: PlanTriggerRequest): expected_adjusted_energy=expected_adjusted_energy ) - input_measures = prepare_input_measures(recommendations_with_impact, body.goal, body.housing_type) + input_measures = prepare_input_measures(recommendations_with_impact, body.goal) current_sap_points = int(property_instance.data["current-energy-efficiency"]) target_sap_points = epc_to_sap_lower_bound(body.goal_value) @@ -279,9 +294,6 @@ async def trigger_plan(body: PlanTriggerRequest): if ventilation_rec: selected_recommendations.add(ventilation_rec["recommendation_id"]) - # We check if the selected recommendation is wall ventilation and if so, we make sure - # mechanical ventilation is selected - # We'll use the set of selected recommendations to filter the recommendations to upload final_recommendations = [ [ diff --git a/backend/app/plan/schemas.py b/backend/app/plan/schemas.py index b8a99704..76eb49d2 100644 --- a/backend/app/plan/schemas.py +++ b/backend/app/plan/schemas.py @@ -9,6 +9,8 @@ class PlanTriggerRequest(BaseModel): goal_value: str portfolio_id: int trigger_file_path: str + already_installed_file_path: Optional[str] = None + patches_file_path: Optional[str] = None exclusions: Optional[conlist(str, min_items=1)] = None # Pre-defined list of possibilities for exclusions diff --git a/backend/ml_models/Valuation.py b/backend/ml_models/Valuation.py index 2bb7de32..251c016a 100644 --- a/backend/ml_models/Valuation.py +++ b/backend/ml_models/Valuation.py @@ -52,6 +52,17 @@ class PropertyValuation: 10070056829: 76_000, 10070056920: 76_000, 10023345463: 76_000, + # IMMO Dudley Pilot - search by going to https://www.zoopla.co.uk/property/uprn/{uprn}/ + 90070461: 172_000, # Based on Zoopla + 90022227: 181_000, # Based on Zoopla + 90106884: 180_000, # Based on Zoopla + 90051858: 201_000, # Based on Zoopla + 90060989: 172_000, # Based on Zoopla + 90048026: 196_000, # Based on Zoopla + 90077535: 192_000, # Based on Zoopla + 90093693: 279_000, # Based on Zoopla + 90055152: 149_000, # Based on Zoopla + 90028499: 238_000, # Based on Zoopla } # We base our valuation uplifts on a number of sources diff --git a/etl/air_source_heat_pump/AirSourceHeatPumpEfficiency.py b/etl/air_source_heat_pump/AirSourceHeatPumpEfficiency.py new file mode 100644 index 00000000..2ba82e77 --- /dev/null +++ b/etl/air_source_heat_pump/AirSourceHeatPumpEfficiency.py @@ -0,0 +1,78 @@ +import pandas as pd +from tqdm import tqdm +from utils.s3 import save_dataframe_to_s3_parquet, read_dataframe_from_s3_parquet +from utils.logger import setup_logger +from etl.epc.settings import EARLIEST_EPC_DATE + +logger = setup_logger() + + +class AirSourceHeatPumpEfficiency: + + def __init__(self, file_directories, cleaned_lookup): + """ + :param file_directories: A list of directories where files are stored. + :param cleaned_lookup: A dictionary containing cleaned lookup data. + """ + self.file_directories = file_directories + self.cleaned_lookup = cleaned_lookup + + self.results = [] + + def create_dataset(self): + logger.info("Creating solar photo supply dataset") + for dir in tqdm(self.file_directories): + filepath = dir / "certificates.csv" + df = pd.read_csv(filepath, low_memory=False) + df = df[~pd.isnull(df["UPRN"])] + df["UPRN"] = df["UPRN"].astype(int).astype(str) + # Take entries after SAP12 + df["LODGEMENT_DATE"] = pd.to_datetime(df["LODGEMENT_DATE"]) + df = df[df["LODGEMENT_DATE"] > EARLIEST_EPC_DATE] + + df = df[ + ~df["TENURE"].isin( + [ + "unknown", + "Not defined - use in the case of a new dwelling for which the intended tenure in not known. " + "It is not to be used for an existing dwelling" + ] + ) + ] + + # Take entries that contain an air source heat pump + df = df[ + df["MAINHEAT_DESCRIPTION"].str.contains("air source heat pump", case=False, na=False) + ] + # Get the columns we're interested in + df = df[ + [ + "MAINHEAT_DESCRIPTION", + "MAINHEAT_ENERGY_EFF", + "MAINHEATCONT_DESCRIPTION", + "MAINHEATC_ENERGY_EFF", + "MAIN_FUEL", + "HOTWATER_DESCRIPTION", + "HOT_WATER_ENERGY_EFF", + "MAINS_GAS_FLAG" + ] + ] + + counts = df.groupby( + [ + "MAINHEAT_DESCRIPTION", + "MAINHEAT_ENERGY_EFF", + "MAINHEATCONT_DESCRIPTION", + "MAINHEATC_ENERGY_EFF", + "MAIN_FUEL", + "HOTWATER_DESCRIPTION", + "HOT_WATER_ENERGY_EFF", + "MAINS_GAS_FLAG" + ] + ).size().reset_index(name="count") + + # Drop rows that have a missing PROPERTY_TYPE, BUILT_FORM, CONSTRUCTION_AGE_BAND, TOTAL_FLOOR_AREA + for col in ["PROPERTY_TYPE", "BUILT_FORM", "CONSTRUCTION_AGE_BAND", "TOTAL_FLOOR_AREA"]: + df = df[~pd.isnull(df[col])] + # Take newest LODGEMENT_DATE per UPRN + df = df.sort_values(by="LODGEMENT_DATE", ascending=False).drop_duplicates(subset=["UPRN"]) diff --git a/etl/air_source_heat_pump/app.py b/etl/air_source_heat_pump/app.py new file mode 100644 index 00000000..ac87b34b --- /dev/null +++ b/etl/air_source_heat_pump/app.py @@ -0,0 +1,24 @@ +from pathlib import Path +from backend.app.plan.utils import get_cleaned +from etl.air_source_heat_pump.AirSourceHeatPumpEfficiency import AirSourceHeatPumpEfficiency + +DATA_DIRECTORY = Path(__file__).parent / "local_data" / "all-domestic-certificates" + + +def app(): + """ + This code reads in the EPC dataset and looks at the efficiency values for heating systems that inclue air source + heat pumps. This dataset is then used to inform the recommendations for the air source heat pump, so we know + how to set the simulation + :return: + """ + + directories = [entry for entry in DATA_DIRECTORY.iterdir() if entry.is_dir()] + cleaned_lookup = get_cleaned() + + ashp_data_client = AirSourceHeatPumpEfficiency( + file_directories=directories, + cleaned_lookup=cleaned_lookup + ) + + ashp_data_client.create_dataset() diff --git a/etl/customers/immo/pilot/asset_list.py b/etl/customers/immo/pilot/asset_list.py new file mode 100644 index 00000000..e587cc25 --- /dev/null +++ b/etl/customers/immo/pilot/asset_list.py @@ -0,0 +1,129 @@ +import pandas as pd +from utils.s3 import read_excel_from_s3 +from utils.s3 import save_csv_to_s3 + +USER_ID = 8 +PORTFOLIO_ID = 70 + +council_tax_bands = [ + {'address': '8 Corporation Road', 'postcode': 'DY2 7PX', 'band': 'A'}, + {'address': '21 Wells Road', 'postcode': 'DY5 3TB', 'band': 'A'}, + {'address': '27 Milton Road', 'postcode': 'WV14 8HZ', 'band': 'A'}, + {'address': '195 Ashenhurst Road', 'postcode': 'DY1 2JB', 'band': 'A'}, + {'address': '53 Bromley', 'postcode': 'DY5 4PJ', 'band': 'A'}, + {'address': '91 Osprey Drive', 'postcode': 'DY1 2JS', 'band': 'B'}, + {'address': '47 Fairfield Road', 'postcode': 'DY8 5UJ', 'band': 'B'}, + {'address': '150 Huntingtree Road', 'postcode': 'B63 4HP', 'band': 'C'}, + {'address': '6 Beech Road', 'postcode': 'DY1 4BP', 'band': 'A'}, + {'address': '5 Oaklands', 'postcode': 'B62 0JA', 'band': 'A'}, +] +council_tax_bands = pd.DataFrame(council_tax_bands) + +# This is information we need to override on the EPC itself, for instance if a new survey has been conducted and +# that has not reached the API +patches = [ + { + 'address': '6 Beech Road', 'postcode': 'DY1 4BP', + 'walls-description': 'Cavity wall, filled cavity', + 'walls-energy-eff': 'Good', + 'roof-description': 'Pitched, 12 mm loft insulation', + 'roof-energy-eff': 'Very Poor', + 'windows-description': 'Fully double glazed', + 'windows-energy-eff': 'Good', + 'mainheat-description': 'Room heaters, electric', + 'mainheat-energy-eff': 'Very Poor', + 'mainheatcont-description': 'Appliance thermostats', + 'mainheatc-energy-eff': 'Good', + 'lighting-description': 'Low energy lighting in 25% of fixed outlets', + 'lighting-energy-eff': 'Good', + 'floor-description': 'Solid, no insulation (assumed)', + 'secondheat-description': 'None', + 'current-energy-efficiency': '32', + 'energy-consumption-current': '491', + 'co2-emissions-current': '5.0', + 'potential-energy-efficiency': '87' + } +] + +# This is information that is found as a result of the non-invasives, that mean that certain measures +# have been installed already. To reflect this in the front end, it is included in the recommendation, however +# the cost is removed and instead, a message is presented saying that the measure is already installed. +already_installed = [ + { + 'address': '5 Oaklands', + 'postcode': 'B62 0JA', + "already_installed": ["windows_glazing"] + } +] + + +def app(): + raw_asset_list = read_excel_from_s3( + bucket_name="retrofit-datalake-dev", + file_key="customers/Immo/IMMO Sample Assets_Dudley.xlsx", + header_row=0 + ) + raw_asset_list = raw_asset_list.drop(columns=["Unnamed: 0"]) + # Extract address and postcode + raw_asset_list["address"] = raw_asset_list["Full Address"].str.split(",").str[0] + raw_asset_list["postcode"] = raw_asset_list["Full Address"].str.split(",").str[-1].str.strip() + + asset_list = raw_asset_list.merge(council_tax_bands, how="left", on=["address", "postcode"]) + + # We're provided with number of bathrooms and number of bedrooms. + asset_list = asset_list.rename( + columns={ + "No. of Beds": "n_bedrooms", + "No. of WC's": "n_bathrooms" + } + ) + + # Store the asset list in s3 + filename = f"{USER_ID}/{PORTFOLIO_ID}/pilot.csv" + save_csv_to_s3( + dataframe=asset_list, + bucket_name="retrofit-plan-inputs-dev", + file_name=filename + ) + + # Store overrides in s3 + already_installed_filename = f"{USER_ID}/{PORTFOLIO_ID}/already_installed.json" + save_csv_to_s3( + dataframe=pd.DataFrame(already_installed), + bucket_name="retrofit-plan-inputs-dev", + file_name=already_installed_filename + ) + + # Store patches in s3 + patches_filename = f"{USER_ID}/{PORTFOLIO_ID}/patches.json" + save_csv_to_s3( + dataframe=pd.DataFrame(patches), + bucket_name="retrofit-plan-inputs-dev", + file_name=patches_filename + ) + + # EPC C portoflio + body = { + "portfolio_id": str(PORTFOLIO_ID), + "housing_type": "Private", + "goal": "Increase EPC", + "goal_value": "C", + "trigger_file_path": filename, + "already_installed_file_path": already_installed_filename, + "patches_file_path": patches_filename, + "budget": None, + } + print(body) + + # EPC B portoflio + body = { + "portfolio_id": str(PORTFOLIO_ID + 1), + "housing_type": "Private", + "goal": "Increase EPC", + "goal_value": "B", + "trigger_file_path": filename, + "already_installed_file_path": already_installed_filename, + "patches_file_path": patches_filename, + "budget": None, + } + print(body) diff --git a/etl/customers/immo/pilot/non_invasive.py b/etl/customers/immo/pilot/non_invasive.py new file mode 100644 index 00000000..6dc22c62 --- /dev/null +++ b/etl/customers/immo/pilot/non_invasive.py @@ -0,0 +1,210 @@ +# import extract_msg +from datetime import datetime +from sqlalchemy.orm import sessionmaker +from backend.app.db.connection import db_engine +from backend.app.db.functions.non_intrusive_surveys import upload_non_intrusive_survey_notes + + +def parse_msg_body(text): + # Split the text into lines + lines = text.split('\r\n') + + # Dictionary to hold the parsed data + data = {} + + # Process each line + for line in lines: + # Remove all asterisks and extra whitespace + clean_line = line.replace('*', '').strip() + + if clean_line: # Ensure the line is not empty after cleaning + # Attempt to split clean '=' if present + if '=' in clean_line: + clean_line = clean_line.replace(' = ', ': ') + + # Use line content as a key with a default value indicating presence + # Generate a unique key for lines without '=' + data[f"Info{len(data) + 1}"] = clean_line + + return data + + +def app(): + """ + This code retrieves the results of the non-invasive surveys, to be stored in S3 + :return: + """ + + # filepath = ("/Users/khalimconn-kowlessar/Downloads/IMMO - Dudley Pilot - non-invasive raw data/5 Oaklands B62 " + # "0JA/Immo - 5 Oaklands Halesowen B62 0JA.msg") + # filepath = ("/Users/khalimconn-kowlessar/Downloads/IMMO - Dudley Pilot - non-invasive raw data/6 Beech Rd DY1 " + # "4BP/IMMO - 6 Beech Road Dudley DY1 4BP.msg") + # filepath = ( + # "/Users/khalimconn-kowlessar/Downloads/IMMO - Dudley Pilot - non-invasive raw data/8 Corporation Rd DY2 " + # "7PX/IMMO - 8 Corporation Road Dudley DY2 7PX.msg" + # ) + # filepath = ( + # "/Users/khalimconn-kowlessar/Downloads/IMMO - Dudley Pilot - non-invasive raw data/21 Wells Rd DY5 3TB/" + # "IMMO - 21 Wells Road Brierley Hill DY5 3TB.msg" + # ) + # filepath = ( + # "/Users/khalimconn-kowlessar/Downloads/IMMO - Dudley Pilot - non-invasive raw data/47 Fairfield Rd DY8 " + # "5UJ/IMMO - 47 Fairfield Road Wordsley Stourbridge DY8 5UJ.msg" + # ) + # filepath = ( + # "/Users/khalimconn-kowlessar/Downloads/IMMO - Dudley Pilot - non-invasive raw data/91 Osprey Drive DY1 " + # "2JS/IMMO - 91 Osprey Drive Dudley DY1 2JS.msg" + # ) + # filepath = ( + # "/Users/khalimconn-kowlessar/Downloads/IMMO - Dudley Pilot - non-invasive raw data/195 Ashenhurst Rd DY1 " + # "2JB/IMMO - 195 Ashenhurst Road Dudley DY1 2JB.msg" + # ) + # filepath = ( + # "/Users/khalimconn-kowlessar/Downloads/IMMO - Dudley Pilot - non-invasive raw data/27 Milton Rd DY1 2JB/IMMO " + # "- 27 Milton Road Coseley Bilston WV14 8HZ.msg" + # ) + # + # with extract_msg.Message(filepath) as msg: + # body = msg.body + # + # from pprint import pprint + # pprint(parse_msg_body(body)) + + # We manually create the non-invasive notes for the pilot + non_invasive_notes = [ + { + 'uprn': 90028499, + # 'address': '5 Oaklands', + # 'postcode': 'B62 0JA', + 'surveyor': 'Carl Fitzgerald - The Warmfront Team', + 'survey_date': datetime.strptime('2024-04-11', '%Y-%m-%d'), + 'Wall Insulation': 'Cavity wall, retro drilled, containing loose fibre insulation. Consider getting a ' + 'CIGA check and extracting the cavity, replacing with bead insulation. ' + 'There is a shared alleyway with the neighbour, that is a solid brick wall.', + 'Wall Render': 'Partial render between top of ground floor window and bottom of 1st floor window', + 'Existing solar PV': 'No existing solar', + 'Orientation': 'Front house direction: North East, Back house direction: South West', + 'Access to mains?': 'Property has access to the mains', + }, + { + 'uprn': 90055152, + # 'address': '6 Beech Road', + # 'postcode': 'DY1 4BP', + 'surveyor': 'Carl Fitzgerald - The Warmfront Team', + 'survey_date': datetime.strptime('2024-04-11', '%Y-%m-%d'), + 'Wall Insulation': '1st floor is solid brick with external wall insulation. 2nd floor is cavity, ' + 'retro drilled, containing loose fibre insulation. Consider getting a ' + 'CIGA check and extracting the cavity, replacing with bead insulation.', + 'Wall Render': None, + 'Existing solar PV': 'No existing solar', + 'Orientation': 'Side house direction: North East', + 'Access to mains?': 'Property has access to the mains', + }, + { + 'uprn': 90070461, + # 'address': '8 Corporation Road', + # 'postcode': 'DY2 7PX', + 'surveyor': 'Carl Fitzgerald - The Warmfront Team', + 'survey_date': datetime.strptime('2024-04-11', '%Y-%m-%d'), + 'Wall Insulation': "External wall insulation", + 'Wall Render': "Render finish throughout", + 'Existing solar PV': 'No existing solar', + 'Orientation': 'Front house direction: North East, Back house direction: South West', + 'Access to mains?': None, + }, + { + 'uprn': 90022227, + # 'address': '21 Wells Road', + # 'postcode': 'DY5 3TB', + 'surveyor': 'Carl Fitzgerald - The Warmfront Team', + 'survey_date': datetime.strptime('2024-04-11', '%Y-%m-%d'), + 'Wall Insulation': 'Cavity wall, retro drilled, containing loose fibre insulation. Consider getting a ' + 'CIGA check and extracting the cavity, replacing with bead insulation.', + 'Wall Render': None, + 'Existing solar PV': 'No existing solar', + 'Orientation': 'Front house direction: East, Back house direction: West', + 'Access to mains?': 'Property has access to the mains', + }, + { + 'uprn': 90077535, + # 'address': '47 Fairfield Road', + # 'postcode': 'DY8 5UJ', + 'surveyor': 'Carl Fitzgerald - The Warmfront Team', + 'survey_date': datetime.strptime('2024-04-11', '%Y-%m-%d'), + 'Wall Insulation': 'Cavity wall, retro drilled, containing loose fibre insulation. Consider getting a ' + 'CIGA check and extracting the cavity, replacing with bead insulation.', + 'Wall Render': None, + 'Existing solar PV': 'No existing solar', + 'Orientation': 'Front house direction: East, Back house direction: West', + 'Access to mains?': 'Property has access to the mains', + }, + { + 'uprn': 90060989, + # 'address': '53 Bromley', + # 'postcode': 'DY5 4PJ', + 'surveyor': 'Carl Fitzgerald - The Warmfront Team', + 'survey_date': datetime.strptime('2024-04-11', '%Y-%m-%d'), + 'Wall Insulation': "Filled at build, partially filled - celotex/king board, 50mm cavity remaining - " + "recommends a cavity wall fill", + "Roof": "Hipped roof", + 'Existing solar PV': 'No existing solar', + 'Orientation': "Front house direction: North, Back house direction: South, Side house direction: West", + 'Access to mains?': 'Property has access to the mains', + }, + { + 'uprn': 90048026, + # 'address': '91 Osprey Drive', + # 'postcode': 'DY1 2JS', + 'surveyor': 'Carl Fitzgerald - The Warmfront Team', + 'survey_date': datetime.strptime('2024-04-11', '%Y-%m-%d'), + 'Wall Insulation': 'Cavity wall, retro drilled, containing loose fibre insulation. Consider getting a ' + 'CIGA check and extracting the cavity, replacing with bead insulation.', + 'Wall Render': 'Tile hung front and rear of property', + 'Existing solar PV': 'No existing solar', + 'Orientation': 'Side house direction: East', + 'Access to mains?': 'Property has access to the mains', + }, + { + 'uprn': 90093693, + # 'address': '150 Huntingtree Road', + # 'postcode': 'B63 4HP', + 'surveyor': 'Carl Fitzgerald - The Warmfront Team', + 'survey_date': datetime.strptime('2024-04-11', '%Y-%m-%d'), + 'Heating': 'Electric (storage heaters)', + 'Wall Insulation': 'Cavity wall, retro drilled, containing loose fibre insulation. Consider getting a ' + 'CIGA check and extracting the cavity, replacing with bead insulation.', + "Roof": "Hipped roof", + 'Existing solar PV': 'No existing solar', + 'Orientation': "Front house direction: North West, Back house direction: South East, Side house direction: " + "North East", + }, + { + 'uprn': 90051858, + # 'address': '195 Ashenhurst Road', + # 'postcode': 'DY1 2JB', + 'surveyor': 'Carl Fitzgerald - The Warmfront Team', + 'survey_date': datetime.strptime('2024-04-11', '%Y-%m-%d'), + 'Wall Insulation': 'Cavity wall, retro drilled, containing loose fibre insulation. Consider getting a ' + 'CIGA check and extracting the cavity, replacing with bead insulation.', + 'Wall Render': "Solid render front and rear of property", + 'Existing solar PV': 'No existing solar', + 'Orientation': 'Front house direction: South, Back house direction: North', + 'Access to mains?': 'Property has access to the mains', + }, + { + 'uprn': 90106884, + # 'address': '27 Milton Road', + # 'postcode': 'WV14 8HZ', + 'surveyor': 'Carl Fitzgerald - The Warmfront Team', + 'survey_date': datetime.strptime('2024-04-11', '%Y-%m-%d'), + 'Wall Insulation': 'Cavity wall, retro drilled, containing loose fibre insulation. Consider getting a ' + 'CIGA check and extracting the cavity, replacing with bead insulation.', + 'Wall Render': "Solid render front and rear of property", + 'Existing solar PV': 'No existing solar', + 'Orientation': 'Front house direction: South East, Back house direction: North West', + 'Access to mains?': 'Property has access to the mains', + }, + ] + + session = sessionmaker(bind=db_engine)() + upload_non_intrusive_survey_notes(session=session, non_invasive_notes=non_invasive_notes, batch_size=500) diff --git a/etl/customers/immo/pilot/requirements.txt b/etl/customers/immo/pilot/requirements.txt new file mode 100644 index 00000000..4673ab35 --- /dev/null +++ b/etl/customers/immo/pilot/requirements.txt @@ -0,0 +1 @@ +extract-msg diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf index d545cdf8..fde25487 100644 --- a/infrastructure/terraform/main.tf +++ b/infrastructure/terraform/main.tf @@ -181,4 +181,16 @@ module "lambda_carbon_prediction_ecr" { module "lambda_heat_prediction_ecr" { ecr_name = "lambda-heat-prediction-${var.stage}" source = "./modules/ecr" +} + +############################################## +# CDN - Cloudfront +############################################## +module "cloudfront_distribution" { + source = "./modules/cloudfront" + bucket_name = module.s3.bucket_name + bucket_id = module.s3.bucket_id + bucket_arn = module.s3.bucket_arn + bucket_domain_name = module.s3.bucket_domain_name + stage = var.stage } \ No newline at end of file diff --git a/infrastructure/terraform/modules/cloudfront/main.tf b/infrastructure/terraform/modules/cloudfront/main.tf new file mode 100644 index 00000000..281ff09f --- /dev/null +++ b/infrastructure/terraform/modules/cloudfront/main.tf @@ -0,0 +1,65 @@ +resource "aws_cloudfront_distribution" "s3_distribution" { + origin { + domain_name = var.bucket_domain_name + origin_id = "S3-${var.bucket_name}" + + s3_origin_config { + origin_access_identity = aws_cloudfront_origin_access_identity.oai.cloudfront_access_identity_path + } + } + + enabled = true + + default_cache_behavior { + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "S3-${var.bucket_name}" + viewer_protocol_policy = "redirect-to-https" + compress = true + + forwarded_values { + query_string = false + cookies { + forward = "none" + } + } + + min_ttl = 0 + default_ttl = 86400 + max_ttl = 31536000 + } + + price_class = "PriceClass_All" + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + cloudfront_default_certificate = true + } +} + +resource "aws_cloudfront_origin_access_identity" "oai" { + comment = "OAI for ${var.bucket_name}" +} + +resource "aws_s3_bucket_policy" "bucket_policy" { + bucket = var.bucket_id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${aws_cloudfront_origin_access_identity.oai.id}" + } + Action = "s3:GetObject" + Resource = "${var.bucket_arn}/*" + }, + ] + }) +} diff --git a/infrastructure/terraform/modules/cloudfront/variables.tf b/infrastructure/terraform/modules/cloudfront/variables.tf new file mode 100644 index 00000000..88f770a8 --- /dev/null +++ b/infrastructure/terraform/modules/cloudfront/variables.tf @@ -0,0 +1,24 @@ +variable "bucket_name" { + description = "The name of the bucket" + type = string +} + +variable "stage" { + description = "The deployment stage" + type = string +} + +variable "bucket_id" { + description = "The ID of the S3 bucket" + type = string +} + +variable "bucket_arn" { + description = "The ARN of the S3 bucket" + type = string +} + +variable "bucket_domain_name" { + description = "The regional domain name of the S3 bucket" + type = string +} \ No newline at end of file diff --git a/infrastructure/terraform/modules/s3/outputs.tf b/infrastructure/terraform/modules/s3/outputs.tf index a5e7ddb4..7668dbc4 100644 --- a/infrastructure/terraform/modules/s3/outputs.tf +++ b/infrastructure/terraform/modules/s3/outputs.tf @@ -2,3 +2,15 @@ output "bucket_name" { description = "The name of the S3 bucket" value = aws_s3_bucket.bucket.bucket } + +output "bucket_id" { + value = aws_s3_bucket.bucket.id +} + +output "bucket_arn" { + value = aws_s3_bucket.bucket.arn +} + +output "bucket_domain_name" { + value = aws_s3_bucket.bucket.bucket_regional_domain_name +} \ No newline at end of file diff --git a/recommendations/Costs.py b/recommendations/Costs.py index e5ceb0c0..0e67b352 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -79,6 +79,18 @@ CONVENTIONAL_BOILER_COSTS = { "40kw": 1776 } +# Assumes 3 hours to remove each heater (including re-decorating) +ROOM_HEATER_REMOVAL_COST = 120 +ROOM_HEATER_REMOVAL_LABOUR_HOURS = 3 + +# This is a cost quoted by Jim for a system flush - existig system will run more efficiently +SYSTEM_FLUSH_COST = 250 + +SINGLE_RADIATOR_COST = 150 +DOUBLE_RADIATOR_COST = 300 +FLUE_COST = 600 +PIPEWORK_COST = 750 # Min cost is £500 + class Costs: """ @@ -1100,9 +1112,67 @@ class Costs: "labour_days": labour_days, } - def low_carbon_boiler(self, is_combi, size): + def heater_removal(self, n_rooms): + """ + Estimates the costs of removal of heaters, including the redecoration costs of the space behind the heater + :return: + """ + + removal_cost = ROOM_HEATER_REMOVAL_COST * n_rooms + removal_labour_hours = ROOM_HEATER_REMOVAL_LABOUR_HOURS * n_rooms + + vat = removal_cost * self.VAT_RATE + + subtotal_before_vat = removal_cost + total_cost = subtotal_before_vat + vat + + return { + "total": total_cost, + "subtotal": subtotal_before_vat, + "vat": vat, + "labour_hours": removal_labour_hours, + "labour_days": np.ceil(removal_labour_hours / 8), + } + + @staticmethod + def _estimate_n_radiators(number_habitable_rooms, total_floor_area, property_type, built_form): + # Base number of radiators: one per habitable room + base_radiators = number_habitable_rooms + + # Additional radiators for non-habitable essential areas (e.g., kitchens, hallways) + additional_radiators = 3 # Initial assumption + + # Adjust additional radiators based on property type + if property_type == 'Flat': + additional_radiators -= 1 # Flats may need fewer radiators due to less exposure + elif property_type in ['House', 'Bungalow', 'Maisonette']: + # Multiple floors in Maisonette may require additional heating points + additional_radiators += 2 # Houses and bungalows might need more due to greater exposure + else: + raise Exception("Invalid property type") + + # Adjust total radiator needs based on built form + form_factor = { + 'Mid-Terrace': 0.95, + 'Semi-Detached': 1.05, + 'Detached': 1.25, + 'End-Terrace': 1.05 + } + + # Calculate total heating power needed and number of radiators based on standard output + total_heating_power_required = total_floor_area * 80 # Watts per square meter + radiator_output = 1000 # Average wattage per radiator + total_radiators_based_on_power = (total_heating_power_required / radiator_output) * form_factor[built_form] + + # Final estimation taking the higher of calculated needs or base room count + estimated_radiators = max(total_radiators_based_on_power, base_radiators + additional_radiators) + return round(estimated_radiators) + + def boiler(self, is_combi, size, exising_room_heaters, system_change, n_heated_rooms, n_rooms): """ Based on a basic estimate of median value £2600 to install a low carbon combi boiler + First time central heating vosts can als be found here: + https://www.checkatrade.com/blog/cost-guides/central-heating-installation-cost/ :return: """ @@ -1110,23 +1180,58 @@ class Costs: # The unit cost is the cost without VAT # We now need to estimate the cost of the works labour_days = 2 - labour_rate = 500 + labour_hours = labour_days * 8 + labour_rate = 300 # Average cost of installation is 1 (maybe 2days) at £300 per day # https://www.checkatrade.com/blog/cost-guides/new-boiler-cost/ - # To be pessimistic, assume 2 days work and £500 day rate + # To be pessimistic, assume 2 days work labour_cost = labour_rate * self.labour_adjustment_factor * labour_days # Add contingency and preliminaries labour_cost = labour_cost * (1 + self.CONTINGENCY + self.PRELIMINARIES) + + # labour_days = labour_days + (removal_labour_hours / 8) + vat = labour_cost * self.VAT_RATE subtotal_before_vat = unit_cost + labour_cost total_cost = subtotal_before_vat + vat + # if there are existing room heaters, we need to add the cost of removing them + if exising_room_heaters: + removal_costing = self.heater_removal(n_rooms=n_heated_rooms) + # Add the totals to the existing totals + total_cost += removal_costing["total"] + subtotal_before_vat += removal_costing["subtotal"] + labour_hours += removal_costing["labour_hours"] + labour_days += removal_costing["labour_days"] + vat += removal_costing["vat"] + + if system_change: + # We need the cost of radiators + n_radiators = self._estimate_n_radiators( + number_habitable_rooms=n_rooms, + total_floor_area=self.property.floor_area, + property_type=self.property.data["property-type"], + built_form=self.property.data["built-form"] + ) + + additionals_labour_cost = labour_rate * self.labour_adjustment_factor + radiator_cost = DOUBLE_RADIATOR_COST * n_radiators + system_change_cost = radiator_cost + FLUE_COST + PIPEWORK_COST + additionals_labour_cost + system_change_cost_before_vat = system_change_cost / (1 + self.VAT_RATE) + system_change_vat = system_change_cost - system_change_cost_before_vat + # We add an extra labour day for the system change + labour_days += 1 + labour_hours += 8 + total_cost += system_change_cost + subtotal_before_vat += system_change_cost_before_vat + vat += system_change_vat + return { "total": total_cost, "subtotal": subtotal_before_vat, "vat": vat, - "labour_hours": labour_days * 8, + "labour_hours": labour_hours, "labour_days": labour_days, } diff --git a/recommendations/FireplaceRecommendations.py b/recommendations/FireplaceRecommendations.py index 5d620d49..601a8eb0 100644 --- a/recommendations/FireplaceRecommendations.py +++ b/recommendations/FireplaceRecommendations.py @@ -32,7 +32,8 @@ class FireplaceRecommendations(Definitions): if number_open_fireplaces == 0: return - estimated_cost = number_open_fireplaces * self.COST_OF_WORK + already_installed = "sealing_open_fireplace" in self.property.already_installed + estimated_cost = number_open_fireplaces * self.COST_OF_WORK if not already_installed else 0 # We recommend installing two mechanical ventilation systems self.recommendation = [ @@ -44,6 +45,7 @@ class FireplaceRecommendations(Definitions): "starting_u_value": None, "new_u_value": None, "sap_points": None, + "already_installed": already_installed, "total": estimated_cost, # Take a very basic estimate of 6 hours, multipled by the number of open fireplaces to seal "labour_hours": 6 * number_open_fireplaces, diff --git a/recommendations/FloorRecommendations.py b/recommendations/FloorRecommendations.py index 713d5f92..3f764d83 100644 --- a/recommendations/FloorRecommendations.py +++ b/recommendations/FloorRecommendations.py @@ -8,7 +8,7 @@ from datatypes.enums import QuantityUnits from backend.Property import Property from recommendations.recommendation_utils import ( r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns, update_lowest_selected_u_value, - get_recommended_part, get_floor_u_value + get_recommended_part, get_floor_u_value, override_costs ) from recommendations.Costs import Costs @@ -192,12 +192,21 @@ class FloorRecommendations(Definitions): material=material.to_dict(), non_insulation_materials=non_insulation_materials ) + + already_installed = "suspended_floor_insulation" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) + elif material["type"] == "solid_floor_insulation": cost_result = self.costs.solid_floor_insulation( insulation_floor_area=self.property.insulation_floor_area, material=material.to_dict(), non_insulation_materials=non_insulation_materials ) + + already_installed = "solid_floor_insulation" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) else: raise NotImplementedError("Implement me!") @@ -217,6 +226,7 @@ class FloorRecommendations(Definitions): "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": None, + "already_installed": already_installed, **cost_result } ) diff --git a/recommendations/HeatingControlRecommender.py b/recommendations/HeatingControlRecommender.py index 95b5e3b1..d24ad811 100644 --- a/recommendations/HeatingControlRecommender.py +++ b/recommendations/HeatingControlRecommender.py @@ -1,5 +1,5 @@ from recommendations.Costs import Costs -from recommendations.recommendation_utils import check_simulation_difference +from recommendations.recommendation_utils import check_simulation_difference, override_costs from backend.Property import Property from etl.epc_clean.epc_attributes.MainheatControlAttributes import MainheatControlAttributes @@ -159,20 +159,30 @@ class HeatingControlRecommender: has_room_thermostat = not needs_room_thermostat has_trvs = not needs_trvs + cost_result = self.costs.roomstat_programmer_trvs( + number_heated_rooms=int(self.property.data["number-heated-rooms"]), + has_programmer=has_programmer, + has_room_thermostat=has_room_thermostat, + has_trvs=has_trvs + ) + + description = "upgrade heating controls to Room thermostat, programmer and TRVs" + + already_installed = "heating_control" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) + description = "Heating controls have already been upgraded, no further action needed." + self.recommendation.append( { "type": "heating_control", "parts": [], - "description": "upgrade heating controls to Room thermostat, programmer and TRVs", - **self.costs.roomstat_programmer_trvs( - number_heated_rooms=int(self.property.data["number-heated-rooms"]), - has_programmer=has_programmer, - has_room_thermostat=has_room_thermostat, - has_trvs=has_trvs - ), + "description": description, + **cost_result, "starting_u_value": None, "new_u_value": None, "sap_points": None, + "already_installed": already_installed, "simulation_config": simulation_config } ) @@ -211,17 +221,28 @@ class HeatingControlRecommender: if self.property.data["mainheatc-energy-eff"] in ["Poor", "Very Poor", "Average", "Good"]: simulation_config["mainheatc_energy_eff_ending"] = "Very Good" + cost_result = self.costs.time_and_temperature_zone_control( + number_heated_rooms=int(self.property.data["number-heated-rooms"]) + ) + + description = ("Upgrade heating controls to Smart Thermostats, room sensors and smart radiator valves (time & " + "temperature zone control)") + + already_installed = "heating_control" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) + description = "Heating controls have already been upgraded, no further action needed." + self.recommendation.append( { "type": "heating_control", "parts": [], - "description": "Upgrade heating controls to Smart Thermostats, room sensors and smart radiator valves", - **self.costs.time_and_temperature_zone_control( - number_heated_rooms=int(self.property.data["number-heated-rooms"]) - ), + "description": description, + **cost_result, "starting_u_value": None, "new_u_value": None, "sap_points": None, + "already_installed": already_installed, "simulation_config": simulation_config } ) diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py index aec1f419..432dc6a6 100644 --- a/recommendations/HeatingRecommender.py +++ b/recommendations/HeatingRecommender.py @@ -1,7 +1,7 @@ import pandas as pd from recommendations.Costs import Costs -from recommendations.recommendation_utils import check_simulation_difference +from recommendations.recommendation_utils import check_simulation_difference, override_costs from backend.Property import Property from etl.epc_clean.epc_attributes.MainheatAttributes import MainHeatAttributes from etl.epc_clean.epc_attributes.HotWaterAttributes import HotWaterAttributes @@ -18,6 +18,11 @@ class HeatingRecommender: self.recommendations = [] def recommend(self, phase=0): + + # TODO: We could have a system flush recommendation for an existing boiler, where there is no need to replace + # the boiler, but instead flushing the system will make it run more efficiently. There is a cost for this + # in the Costs class, stored as SYSTEM_FLUSH_COST + self.recommendations = [] # This first iteration of the recommender will provide very basic recommendation # We recommend heating controls based on the main heating system @@ -33,8 +38,7 @@ class HeatingRecommender: if has_electric_heating_description or no_heating_no_mains: # Recommend high heat retention storage heaters - self.recommend_electric_storage_heaters(phase=phase, system_change=True, heating_controls_only=False) - return + self.recommend_hhr_storage_heaters(phase=phase, system_change=True, heating_controls_only=False) # if the property has mains heating with boiler and radiators, we recommend optimal heating controls has_boiler = self.property.main_heating["clean_description"] in ["Boiler and radiators, mains gas"] @@ -44,9 +48,38 @@ class HeatingRecommender: 'No system present, electric heaters assumed' ] and self.property.data["mains-gas-flag"] - if has_boiler or no_heating_has_mains: - self.recommend_boiler_upgrades(phase=phase, no_heating_has_mains=no_heating_has_mains) - return + has_gas_heaters = ( + self.property.main_heating["clean_description"] in ["Room heaters, mains gas"] and + self.property.data["mains-gas-flag"] + ) + + # We also check if the property has electric heating, but it has access to the mains gas + electic_heating_has_mains = has_electric_heating_description and self.property.data["mains-gas-flag"] + + portable_heaters_has_mains = ( + self.property.main_heating["clean_description"] in ["Portable electric heaters assumed for most rooms"] and + self.property.data["mains-gas-flag"] + ) + + if ( + has_boiler or + no_heating_has_mains or + electic_heating_has_mains or + has_gas_heaters or + portable_heaters_has_mains + ): + # This indicates that the home previously did not have a boiler in place and so would require + # an overhaul to the system - right now, this is all reasons, apart from if there is an existing boiler + system_change = not has_boiler + exising_room_heaters = self.property.main_heating["clean_description"] in [ + "Room heaters, electric", "Room heaters, mains gas" + ] + + self.recommend_boiler_upgrades( + phase=phase, system_change=system_change, exising_room_heaters=exising_room_heaters + ) + + return @staticmethod def check_simulation_difference(old_config, new_config): @@ -61,9 +94,8 @@ class HeatingRecommender: return differences - @staticmethod def combine_heating_and_controls( - controls_recommendations, heating_simulation_config, costs, description, phase, heating_controls_only, + self, controls_recommendations, heating_simulation_config, costs, description, phase, heating_controls_only, system_change ): """ @@ -112,6 +144,11 @@ class HeatingRecommender: recommendation_description = f"{description} and {controls_description}" + already_installed = "cavity_wall_insulation" in self.property.already_installed + if already_installed: + total_costs = override_costs(total_costs) + recommendation_description = "Heating system has already been upgraded, no further action needed." + recommendation = { "phase": phase, "parts": [ @@ -122,6 +159,7 @@ class HeatingRecommender: "starting_u_value": None, "new_u_value": None, "sap_points": None, + "already_installed": already_installed, **total_costs, "simulation_config": recommendation_simulation_config } @@ -153,9 +191,8 @@ class HeatingRecommender: return output - def recommend_electric_storage_heaters(self, phase, system_change, heating_controls_only): + def recommend_hhr_storage_heaters(self, phase, system_change, heating_controls_only): """ - We recommend electric storage heaters as an upgrade to the heating system. We will recommend upgrading to a high heat retention storage system, if the current system is not already high heat retention storage @@ -256,12 +293,16 @@ class HeatingRecommender: return closest_size - def recommend_boiler_upgrades(self, phase, no_heating_has_mains): + def recommend_boiler_upgrades(self, phase, system_change, exising_room_heaters): """ This boiler recommendation will only recommend a like-for-like upgrade, since changing the system is generally more expensive :param phase: - :param no_heating_has_mains: indicaes if the property has no heating system, but has access to the mains gas + :param system_change: Indicates if the property would be undergoing a heating system change. This could be true + if the home didn't have a heating system in place, or if the home had electric heating + previously + :param exising_room_heaters: Indicates if the property had room heaters previously - if so, a boiler + recommendation will need to be accompanied by removal of the room heaters :return: """ @@ -270,6 +311,7 @@ class HeatingRecommender: # We now recommend boiler upgrades, if applicable simulation_config = {} boiler_costs = {} + boiler_recommendation = {} if self.property.data["mainheat-energy-eff"] in ["Very Poor", "Poor", "Average"]: boiler_size = self.estimate_boiler_size( property_type=self.property.data["property-type"], @@ -279,17 +321,23 @@ class HeatingRecommender: num_heated_rooms=self.property.data["number-heated-rooms"], ) - # If heating and hot water come from the mains, we need a combi boiler, otherwise we need a regular boiler - hotwater_from_mains = self.property.hotwater["clean_description"] in ["From main system"] - - is_combi = hotwater_from_mains or no_heating_has_mains + # We recommend a combi boiler under the following conditions + # 1) If there are 4 or fewer rooms (we don't use heqted rooms because none of the rooms could be + # heated if there is no existing heating system). + # 2) There 1 or fewer bathrooms + # Otherwise, we recommend a gas condensing boiler, which will server a larger property, that has multiple + # bathrooms + is_combi = ( + (self.property.number_of_rooms <= 4) and + (self.property.n_bathrooms in [None, 0, 1]) + ) if is_combi: description = "Upgrade to a new combi boiler" else: - description = "Upgrade to a new boiler" + description = "Upgrade to a new gas condensing boiler" simulation_config = {"mainheat_energy_eff_ending": "Good"} - if no_heating_has_mains: + if system_change: # Installation of a boiler improves the hot water system so we need to reflect this in # the outcome of the recommendation heating_ending_config = MainHeatAttributes("Boiler and radiators, mains gas").process() @@ -314,24 +362,35 @@ class HeatingRecommender: "hot_water_energy_eff_ending": "Good" } - boiler_costs = self.costs.low_carbon_boiler(is_combi=is_combi, size=f"{boiler_size}kw") - - self.recommendations.append( - { - "phase": recommendation_phase, - "parts": [ - # TODO - ], - "type": "heating", - "description": description, - "starting_u_value": None, - "new_u_value": None, - "sap_points": None, - "simulation_config": simulation_config, - **boiler_costs - } + boiler_costs = self.costs.boiler( + is_combi=is_combi, + size=f"{boiler_size}kw", + exising_room_heaters=exising_room_heaters, + system_change=system_change, + n_heated_rooms=self.property.data["number-heated-rooms"], + n_rooms=self.property.number_of_rooms ) + already_installed = "heating" in self.property.already_installed + if already_installed: + boiler_costs = override_costs(boiler_costs) + description = "Heating system has already been upgraded, no further action needed." + + boiler_recommendation = { + "phase": recommendation_phase, + "parts": [ + # TODO + ], + "type": "heating", + "description": description, + "starting_u_value": None, + "new_u_value": None, + "sap_points": None, + "already_installed": already_installed, + "simulation_config": simulation_config, + **boiler_costs + } + # We recommend the heating controls # If the property did not previously have a boiler, we combine controls_recommender = HeatingControlRecommender(self.property) @@ -341,9 +400,8 @@ class HeatingRecommender: if not controls_recommender.recommendation: return - if no_heating_has_mains: - # We combine the heating and controls recommendations - boiler_recommendation = self.recommendations[0].copy() + if system_change: + # We combine the heating and controls recommendations, in the case of a system change combined_recommendations = [] for controls_recommendation in controls_recommender.recommendation: combined_recommendation = self.combine_heating_and_controls( @@ -358,10 +416,15 @@ class HeatingRecommender: combined_recommendations.extend(combined_recommendation) # Overwrite the existing boiler recommendation - self.recommendations = combined_recommendations + self.recommendations.extend(combined_recommendations) else: # We increment the recommendation phase, since the heating controls are separate from the boiler upgrade - recommendation_phase += 1 + # but we'll only upgrade if we have a heating recommendation + has_heating_recommendation = any( + recommendation["type"] == "heating" for recommendation in self.recommendations + ) + if has_heating_recommendation: + recommendation_phase += 1 # The heating controls recommendation is distrinct from the boiler upgrade recommendation # We insert phase into the recommendations for heating controls for recommendation in controls_recommender.recommendation: diff --git a/recommendations/HotwaterRecommendations.py b/recommendations/HotwaterRecommendations.py index 7f77597f..9c5c7045 100644 --- a/recommendations/HotwaterRecommendations.py +++ b/recommendations/HotwaterRecommendations.py @@ -1,5 +1,6 @@ from backend.Property import Property from recommendations.Costs import Costs +from recommendations.recommendation_utils import override_costs class HotwaterRecommendations: @@ -41,6 +42,13 @@ class HotwaterRecommendations: recommendation_cost = self.costs.hot_water_tank_insulation() + already_installed = "hot_water_tank_insulation" in self.property.already_installed + if already_installed: + recommendation_cost = override_costs(recommendation_cost) + description = "Insulation tank has already been insulated, no further action required" + else: + description = "Insulate hot water tank" + self.recommendations.append( { "phase": phase, @@ -48,10 +56,11 @@ class HotwaterRecommendations: # TODO ], "type": "hot_water_tank_insulation", - "description": "Insulate the hot water tank with an insulation jacket", + "description": description, "starting_u_value": None, "new_u_value": None, "sap_points": None, + "already_installed": already_installed, **recommendation_cost, "simulation_config": {"hot_water_energy_eff_ending": "Average"} } diff --git a/recommendations/LightingRecommendations.py b/recommendations/LightingRecommendations.py index 352c4d8a..31720579 100644 --- a/recommendations/LightingRecommendations.py +++ b/recommendations/LightingRecommendations.py @@ -1,6 +1,7 @@ from backend.Property import Property from typing import List from recommendations.Costs import Costs +from recommendations.recommendation_utils import override_costs class LightingRecommendations: @@ -91,6 +92,11 @@ class LightingRecommendations: heat_demand_change, carbon_change = self.estimate_lighting_impact(number_non_lel_outlets) + already_installed = "low_energy_lighting" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) + description = "Low energy lighting has already been installed, no further action required" + self.recommendation = [ { "phase": phase, @@ -99,6 +105,7 @@ class LightingRecommendations: "description": description, "starting_u_value": None, "new_u_value": None, + "already_installed": already_installed, # For SAP points, we use the fact that lighting is usually worth 2 points and we scale this to # the proportion of lights that will be set to low energy "sap_points": round(2 * (number_non_lel_outlets / number_lighting_outlets), 2), diff --git a/recommendations/Recommendations.py b/recommendations/Recommendations.py index 902023dc..68fead16 100644 --- a/recommendations/Recommendations.py +++ b/recommendations/Recommendations.py @@ -11,6 +11,7 @@ from recommendations.SolarPvRecommendations import SolarPvRecommendations from recommendations.WindowsRecommendations import WindowsRecommendations from recommendations.HeatingRecommender import HeatingRecommender from recommendations.HotwaterRecommendations import HotwaterRecommendations +from recommendations.SecondaryHeating import SecondaryHeating from backend.ml_models.AnnualBillSavings import AnnualBillSavings @@ -46,6 +47,7 @@ class Recommendations: self.solar_recommender = SolarPvRecommendations(property_instance=property_instance) self.heating_recommender = HeatingRecommender(property_instance=property_instance) self.hotwater_recommender = HotwaterRecommendations(property_instance=property_instance) + self.secondary_heating_recommender = SecondaryHeating(property_instance=property_instance) def recommend(self): @@ -130,6 +132,12 @@ class Recommendations: property_recommendations.append(self.lighting_recommender.recommendation) phase += 1 + if "secondary_heating" not in self.exclusions: + self.secondary_heating_recommender.recommend(phase=phase) + if self.secondary_heating_recommender.recommendation: + property_recommendations.append(self.secondary_heating_recommender.recommendation) + phase += 1 + # Renewables if "solar_pv" not in self.exclusions: self.solar_recommender.recommend(phase=phase) diff --git a/recommendations/RoofRecommendations.py b/recommendations/RoofRecommendations.py index eb1c6c4f..dc5ee7db 100644 --- a/recommendations/RoofRecommendations.py +++ b/recommendations/RoofRecommendations.py @@ -5,7 +5,7 @@ from typing import List from datatypes.enums import QuantityUnits from recommendations.recommendation_utils import ( get_roof_u_value, r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns, - update_lowest_selected_u_value, get_recommended_part, convert_thickness_to_numeric + update_lowest_selected_u_value, get_recommended_part, convert_thickness_to_numeric, override_costs ) from recommendations.Costs import Costs @@ -20,8 +20,9 @@ class RoofRecommendations: DIMINISHING_RETURNS_U_VALUE = 0.14 - # It is recommended that lofts should have at least 270mm of insulation - MINIMUM_LOFT_ISULATION_MM = 270 + # It is recommended that lofts should have at least 270mm of insulation. If the property has more than 200mm of + # loft insulation in place already, we do not recommend anything for the moment + MINIMUM_LOFT_ISULATION_MM = 200 # Flat roof should have at least 100mm of insulation MINIMUM_FLAT_ROOF_ISULATION_MM = 100 @@ -71,7 +72,7 @@ class RoofRecommendations: # Building regulations part L recommend installing at least 270mm of insulation, however generally we # experience diminishing returns in terms of SAP once we go beyond around 150mm of insulation # This only holds true for pitched roofs. - if (insulation_thickness >= self.MINIMUM_LOFT_ISULATION_MM) and self.property.roof["is_pitched"]: + if (insulation_thickness > self.MINIMUM_LOFT_ISULATION_MM) and self.property.roof["is_pitched"]: return if (insulation_thickness >= self.MINIMUM_FLAT_ROOF_ISULATION_MM) and self.property.roof["is_flat"]: @@ -206,12 +207,18 @@ class RoofRecommendations: floor_area=self.property.insulation_floor_area, material=material ) + already_installed = "loft_insulation" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) elif material["type"] == "flat_roof_insulation": cost_result = self.costs.flat_roof_insulation( floor_area=self.property.insulation_floor_area, material=material, non_insulation_materials=non_insulation_materials ) + already_installed = "flat_roof_insulation" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) else: raise ValueError("Invalid material type") @@ -231,6 +238,7 @@ class RoofRecommendations: "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": None, + "already_installed": already_installed, **cost_result } ) diff --git a/recommendations/SecondaryHeating.py b/recommendations/SecondaryHeating.py new file mode 100644 index 00000000..5d763510 --- /dev/null +++ b/recommendations/SecondaryHeating.py @@ -0,0 +1,65 @@ +from recommendations.Costs import Costs +from recommendations.recommendation_utils import override_costs +from backend.Property import Property + + +class SecondaryHeating: + """ + This class recommends the removal of the secondary heating system for properties that have a primary heating + system. + """ + + # The list of existing heating systems that are accepted + ACCEPTED_MAINHEAT_DESCRIPTIONS = ["Boiler and radiators, mains gas"] + ACCEPTED_SECONDHEAT_DESCRIPTIONS = ["Room heaters, electric"] + # These are the heaters where works are required to remove them + FIXED_HEATER_DESCRIPTIONS = ["Room heaters, electric"] + + def __init__(self, property_instance: Property): + self.property = property_instance + self.costs = Costs(self.property) + + self.recommendation = [] + + def recommend(self, phase: int): + # Reset + self.recommendation = [] + + if self.property.main_heating["clean_description"] not in self.ACCEPTED_MAINHEAT_DESCRIPTIONS: + return + + # TODO: We need to clean secondary data + if self.property.data['secondheat-description'] not in self.ACCEPTED_SECONDHEAT_DESCRIPTIONS: + return + + if self.property.data['secondheat-description'] in self.FIXED_HEATER_DESCRIPTIONS: + # We have an associated cost otherwise, there is no cost + n_rooms = self.property.data['number-heated-rooms'] + else: + n_rooms = 0 + + costs = self.costs.heater_removal(n_rooms=n_rooms) + + already_installed = "secondary_heating" in self.property.already_installed + if already_installed: + costs = override_costs(costs) + description = "Secondary heating system has already been removed, no further action required" + else: + description = "Remove the secondary heating system" + + self.recommendation.append( + { + "phase": phase, + "parts": [], + "type": "secondary_heating", + "description": description, + "starting_u_value": None, + "new_u_value": None, + "sap_points": None, + "already_installed": already_installed, + **costs, + "simulation_config": { + "secondheat_description_ending": "None" + } + } + ) diff --git a/recommendations/SolarPvRecommendations.py b/recommendations/SolarPvRecommendations.py index 4cf1c1fc..58cf9735 100644 --- a/recommendations/SolarPvRecommendations.py +++ b/recommendations/SolarPvRecommendations.py @@ -1,5 +1,6 @@ import numpy as np from recommendations.Costs import Costs +from recommendations.recommendation_utils import override_costs class SolarPvRecommendations: @@ -110,6 +111,10 @@ class SolarPvRecommendations: description = (f"Install a {kw} kilowatt-peak (kWp) solar photovoltaic (PV) p" f"anel system on {round(roof_coverage_percent)}% the roof.") + already_installed = "solar_pv" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) + self.recommendation.append( { "phase": phase, @@ -119,9 +124,11 @@ class SolarPvRecommendations: "starting_u_value": None, "new_u_value": None, "sap_points": None, + "already_installed": already_installed, **cost_result, # This is required for simulating the SAP impact. solar_pv_percentage is between 0 & 1 so we scale # back up here - "photo_supply": 100 * roof_coverage + "photo_supply": 100 * roof_coverage, + "has_battery": has_battery } ) diff --git a/recommendations/VentilationRecommendations.py b/recommendations/VentilationRecommendations.py index 1657b759..5b36bd9c 100644 --- a/recommendations/VentilationRecommendations.py +++ b/recommendations/VentilationRecommendations.py @@ -50,7 +50,11 @@ class VentilationRecommendations(Definitions): part = self.materials.copy() - estimated_cost = n_units * part[0]["cost"] + already_installed = "cavity_wall_insulation" in self.property.already_installed + + estimated_cost = n_units * part[0]["cost"] if not already_installed else 0 + labour_hours = 4 * n_units if not already_installed else 0 + labour_days = 4 * n_units / 8.0 if not already_installed else 0 part[0]["total"] = estimated_cost part[0]["quantity"] = n_units @@ -65,6 +69,7 @@ class VentilationRecommendations(Definitions): "description": f"Install {n_units} {part[0]['description']} units", "starting_u_value": None, "new_u_value": None, + "already_installed": already_installed, "sap_points": 0, "heat_demand": 0, "adjusted_heat_demand": 0, @@ -72,7 +77,7 @@ class VentilationRecommendations(Definitions): "energy_cost_savings": 0, "total": estimated_cost, # We use a very simple and rough estimate of 4 hours per unit - "labour_hours": 4 * n_units, - "labour_days": 4 * n_units / 8.0 # Assume 8 hour day + "labour_hours": labour_hours, + "labour_days": labour_days # Assume 8 hour day } ] diff --git a/recommendations/WallRecommendations.py b/recommendations/WallRecommendations.py index 6b59c148..feb2620b 100644 --- a/recommendations/WallRecommendations.py +++ b/recommendations/WallRecommendations.py @@ -8,7 +8,7 @@ from backend.Property import Property from BaseUtility import Definitions from recommendations.recommendation_utils import ( r_value_per_mm_to_u_value, calculate_u_value_uplift, is_diminishing_returns, update_lowest_selected_u_value, - get_recommended_part, get_wall_u_value + get_recommended_part, get_wall_u_value, override_costs ) from recommendations.config import PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION from recommendations.Costs import Costs @@ -221,6 +221,10 @@ class WallRecommendations(Definitions): material=material.to_dict(), ) + already_installed = "cavity_wall_insulation" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) + recommendations.append( { "phase": phase, @@ -237,6 +241,7 @@ class WallRecommendations(Definitions): "starting_u_value": u_value, "new_u_value": new_u_value, "sap_points": None, + "already_installed": already_installed, **cost_result } ) @@ -277,12 +282,19 @@ class WallRecommendations(Definitions): material=material.to_dict(), non_insulation_materials=non_insulation_materials ) + already_installed = "internal_wall_insulation" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) + elif material["type"] == "external_wall_insulation": cost_result = self.costs.external_wall_insulation( wall_area=self.property.insulation_wall_area, material=material.to_dict(), non_insulation_materials=non_insulation_materials ) + already_installed = "external_wall_insulation" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) else: raise ValueError("Invalid material type") @@ -301,6 +313,7 @@ class WallRecommendations(Definitions): "description": self._make_description(material), "starting_u_value": u_value, "new_u_value": new_u_value, + "already_installed": already_installed, "sap_points": None, **cost_result } diff --git a/recommendations/WindowsRecommendations.py b/recommendations/WindowsRecommendations.py index d7404e3b..b7c2823a 100644 --- a/recommendations/WindowsRecommendations.py +++ b/recommendations/WindowsRecommendations.py @@ -4,6 +4,7 @@ import numpy as np from backend.Property import Property from recommendations.Costs import Costs +from recommendation_utils import override_costs class WindowsRecommendations: @@ -70,18 +71,23 @@ class WindowsRecommendations: is_secondary_glazing=is_secondary_glazing ) - glazing_type = "secondary glazing" if is_secondary_glazing else "double glazing" - if self.property.windows["glazing_coverage"] in ["partial", "most"]: - description = f"Install {glazing_type} to the remaining windows" + already_installed = "windows_glazing" in self.property.already_installed + if already_installed: + cost_result = override_costs(cost_result) + description = "The property already has double glazing installed. No further action is required." else: - description = f"Install {glazing_type} to all windows" + glazing_type = "secondary glazing" if is_secondary_glazing else "double glazing" + if self.property.windows["glazing_coverage"] in ["partial", "most"]: + description = f"Install {glazing_type} to the remaining windows" + else: + description = f"Install {glazing_type} to all windows" - if self.property.is_listed: - description += ". Secondary glazing recommended due to listed building status" - elif self.property.is_heritage: - description += ". Secondary glazing recommended due to herigate building status" - elif self.property.in_conservation_area: - description += ". Secondary glazing recommended due to conservation area status" + if self.property.is_listed: + description += ". Secondary glazing recommended due to listed building status" + elif self.property.is_heritage: + description += ". Secondary glazing recommended due to herigate building status" + elif self.property.in_conservation_area: + description += ". Secondary glazing recommended due to conservation area status" self.recommendation = [ { @@ -92,6 +98,7 @@ class WindowsRecommendations: "starting_u_value": None, "new_u_value": None, "sap_points": None, + "already_installed": already_installed, **cost_result, "is_secondary_glazing": is_secondary_glazing } diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index 27838d6e..d6353eea 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -1,17 +1,13 @@ -def prepare_input_measures(property_recommendations, goal, housing_type): +def prepare_input_measures(property_recommendations, goal): """ Basic function to convert recommendations_to_upload to a format that is suitable for the optimiser - large :param property_recommendations: object containing the recommendations, created in the plan trigger api :param goal: goal to be optimised for, should be one of the keys in gain_map. E.g. if the gain is SAP points, the goal should reflect that desired gain - :param housing_type: type of housing the recommendations are for - should be one of "Social" or "Private" :return: Nested list of input measures """ - if housing_type not in ["Social", "Private"]: - raise ValueError("Invalid housing type - investigate me") - goal_map = { "Increase EPC": "sap_points" } @@ -20,12 +16,14 @@ def prepare_input_measures(property_recommendations, goal, housing_type): if not goal_key: raise NotImplementedError("Not implemented this gain type - investigate me") - # We don't include suspended and solid floor insulation as possible measures in private housing, because - # of the need to decant the tenant - ignored_measures = ["suspended_floor_insulation", "solid_floor_insulation"] if housing_type == "Private" else [] - input_measures = [] for recs in property_recommendations: + if recs[0]["type"] == "solar_pv": + # if the recommendation is a solar recommendation without a battery, we exclude it from the optimisation. + # That will ensure that the optimiser only considers solar recommendations with batteries, so we don't + # under-report the potential cost + recs = [r for r in recs if r["has_battery"]] + input_measures.append( [ { @@ -34,7 +32,7 @@ def prepare_input_measures(property_recommendations, goal, housing_type): "gain": rec[goal_key], "type": rec["type"] } - for rec in recs if rec["type"] not in ignored_measures + for rec in recs ] ) diff --git a/recommendations/recommendation_utils.py b/recommendations/recommendation_utils.py index 0d5f9743..a3043c31 100644 --- a/recommendations/recommendation_utils.py +++ b/recommendations/recommendation_utils.py @@ -767,3 +767,15 @@ def check_simulation_difference(old_config, new_config): differences = {key + "_ending": new_config[key] for key in new_config if old_config[key] != new_config[key]} return differences + + +def override_costs(costs): + """ + If the method is overridden, we want to make sure that the costs are zero. This function sets the costs to zero + :param costs: Dictionary of costing, as returned by the Costs class + :return: + """ + for k in costs: + costs[k] = 0 + + return costs