From 005c6b844a32af75674cbe463fab8668cd2fd63d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 14 Nov 2025 22:20:03 +0000 Subject: [PATCH] refactored the handling of dual heating recommendations and fixing coverage of heating types in property class --- backend/Property.py | 3 +- backend/tests/test_integration.py | 66 ++++++------- recommendations/HeatingRecommender.py | 131 +++++++++++++++++++++----- 3 files changed, 140 insertions(+), 60 deletions(-) diff --git a/backend/Property.py b/backend/Property.py index e5639aa2..d0d85565 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -1221,7 +1221,8 @@ class Property: None: "Natural Gas (Community Scheme)", "mains gas": "Natural Gas (Community Scheme)", "biomass": "Smokeless Fuel", - "electricity": "Electricity" + "electricity": "Electricity", + "biogas": "Smokeless Fuel", } if self.main_fuel["fuel_type"] in fuel_map: # We assume when None as it's unknown self.heating_energy_source = fuel_map[self.main_fuel["fuel_type"]] diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py index eadd0788..e8dda31d 100644 --- a/backend/tests/test_integration.py +++ b/backend/tests/test_integration.py @@ -1,36 +1,36 @@ -import ast -import json +# import ast +# import json from copy import deepcopy -from dataclasses import replace -from datetime import datetime +# from dataclasses import replace +# from datetime import datetime import random from tqdm import tqdm -import pandas as pd +# 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.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.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 +# 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 @@ -41,13 +41,13 @@ 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 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 @@ -91,7 +91,7 @@ costs_by_floor_area = costs_by_floor_area.groupby("current-energy-efficiency")[ ].mean().reset_index() sample_epc_data = epc_data[pd.to_datetime(epc_data["LODGEMENT_DATE"]) >= "2015-01-01"].drop_duplicates("UPRN").sample( - 20000).reset_index(drop=True) + 3000).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 diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py index d84a47b5..87311306 100644 --- a/recommendations/HeatingRecommender.py +++ b/recommendations/HeatingRecommender.py @@ -10,6 +10,9 @@ from etl.epc_clean.epc_attributes.MainheatAttributes import MainHeatAttributes from etl.epc_clean.epc_attributes.HotWaterAttributes import HotWaterAttributes from etl.epc_clean.epc_attributes.MainFuelAttributes import MainFuelAttributes from recommendations.HeatingControlRecommender import HeatingControlRecommender +from utils.logger import setup_logger + +logger = setup_logger() class HeatingRecommender: @@ -44,6 +47,22 @@ class HeatingRecommender: ] } }, + "Boiler and radiators, mains gas, electric underfloor heating": { + "boiler": { + "mainheating_description": "Boiler and radiators, mains gas, electric underfloor heating", + "recommendation_description": "Upgrade the existing boiler to a new, more efficient condensing " + "boiler. ", + "controls_suffix": "Manual charge controls" + }, + # These are the heating types we need to produce a dual heating recommendation + "dual": { + "recommendation_description": "Upgrade the existing boiler to a new condensing boiler", + "types": [ + # type 1 + "boiler_upgrade", + ] + } + }, "Portable electric heaters assumed for most rooms, room heaters, electric": { "hhr": { "mainheating_description": "Electric storage heaters, radiators", @@ -127,7 +146,7 @@ class HeatingRecommender: n_trues += 1 if n_trues > 2 or n_trues == 0: - raise Exception("Implement me") + raise NotImplementedError("Implement me, zero or more than two heating systemss") if n_trues == 1: return False @@ -917,9 +936,11 @@ class HeatingRecommender: if self.property.main_heating_controls["clean_description"] != self.high_heat_retention_contols_desc: if self.dual_heating: - controls_prefix = self.DUAL_HEATING_DESCRIPTIONS[ - self.property.main_heating["clean_description"] - ]["hhr"]["controls_prefix"] + controls_prefix = self._map_dual_heating_description( + backup_map_to_description="current_controls", + output_type="controls_prefix", + recommendation_type="hhr" + ) if controls_prefix == "current_controls": description_prefix = self.property.main_heating_controls["clean_description"] @@ -951,9 +972,11 @@ class HeatingRecommender: # We check if the property has dual heating in place with a boiler and storage heaters if self.dual_heating: - new_heating_description = self.DUAL_HEATING_DESCRIPTIONS[ - self.property.main_heating["clean_description"] - ]["hhr"]["mainheating_description"] + new_heating_description = self._map_dual_heating_description( + backup_map_to_description="Electric storage heaters", + output_type="mainheating_description", + recommendation_type="hhr" + ) new_hot_water_description = self.property.hotwater["clean_description"] # We keep the hot water system else: new_heating_description = "Electric storage heaters" @@ -1010,10 +1033,12 @@ class HeatingRecommender: product=hhrsh_product ) if self.dual_heating: - description = self.DUAL_HEATING_DESCRIPTIONS[ - self.property.main_heating["clean_description"] - ]["hhr"]["recommendation_description"] - + description = self._map_dual_heating_description( + backup_map_to_description="Install high heat retention electric storage heaters with an appropriate " + "off-peak tariff.", + output_type="recommendation_description", + recommendation_type="hhr" + ) else: description = "Install high heat retention electric storage heaters with an appropriate off-peak tariff." @@ -1102,6 +1127,60 @@ class HeatingRecommender: return max(num_heated_rooms * 1.5, 6) + def _map_dual_heating_description( + self, backup_map_to_description, output_type, recommendation_type + ): + """ + Utility function to handle dual heating systems + :param backup_map_to_description: + :return: + """ + + if backup_map_to_description not in [ + # Recommendation descriptions - these are the textual descriptions shown in the front end + "Upgrade to a new condensing boiler.", + "Install high heat retention electric storage heaters with an appropriate off-peak tariff.", + # Simulation descriptions - this is the new EPC description we simulate with in the case + # of single heating + "Boiler and radiators, mains gas", + "Electric storage heaters", + # Suffixes allowed + "", + # Controls prefixes + "current_controls" + ]: + raise ValueError(f"Invalid backup_map_to_description, given {backup_map_to_description}") + + if output_type not in [ + "recommendation_description", + "mainheating_description", + "controls_suffix", + "controls_prefix", + ]: + raise ValueError(f"Invalid output_type, given {output_type}") + + if recommendation_type not in [ + "boiler", + ]: + raise ValueError(f"Given invalid recommendation type {recommendation_type}") + + # "Upgrade to a new condensing boiler." + if self.dual_heating: + + # We check if we have a mapped description + if self.property.main_heating["clean_description"] not in self.DUAL_HEATING_DESCRIPTIONS: + logger.warning( + f"We have a dual heating system that hasn't been mapped, defaulting to single " + f"{self.property.main_heating['clean_description']}" + ) + return backup_map_to_description + + return self.DUAL_HEATING_DESCRIPTIONS[ + self.property.main_heating["clean_description"] + ][recommendation_type][output_type] + + return backup_map_to_description + 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 @@ -1137,12 +1216,11 @@ class HeatingRecommender: if has_inefficient_space_heating or has_inefficient_water: - if self.dual_heating: - description = self.DUAL_HEATING_DESCRIPTIONS[ - self.property.main_heating["clean_description"] - ]["boiler"]["recommendation_description"] - else: - description = "Upgrade to a new condensing boiler." + description = self._map_dual_heating_description( + backup_map_to_description="Upgrade to a new condensing boiler.", + output_type="recommendation_description", + recommendation_type="boiler" + ) new_heating_eff = ( "Good" if self.property.data["mainheat-energy-eff"] in ["Very Poor", "Poor", "Average"] @@ -1167,13 +1245,12 @@ class HeatingRecommender: if system_change: # Installation of a boiler improves the hot water system so we need to reflect this in # the outcome of the recommendation - if self.dual_heating: - new_heating_description = self.DUAL_HEATING_DESCRIPTIONS[ - self.property.main_heating["clean_description"] - ]["boiler"]["mainheating_description"] - else: - new_heating_description = "Boiler and radiators, mains gas" + new_heating_description = self._map_dual_heating_description( + backup_map_to_description="Boiler and radiators, mains gas", + output_type="mainheating_description", + recommendation_type="boiler" + ) new_hotwater_description = "From main system" new_fuel_description = "mains gas (not community)" @@ -1239,9 +1316,11 @@ class HeatingRecommender: # If the property did not previously have a boiler, we combine controls_recommender = HeatingControlRecommender(self.property) if self.dual_heating: - description_suffix = self.DUAL_HEATING_DESCRIPTIONS[ - self.property.main_heating["clean_description"] - ]["boiler"]["controls_suffix"] + description_suffix = self._map_dual_heating_description( + backup_map_to_description="", + output_type="controls_suffix", + recommendation_type="boiler" + ) else: description_suffix = "" controls_recommender.recommend(