diff --git a/backend/Funding.py b/backend/Funding.py index ece8e3cf..be3db0d4 100644 --- a/backend/Funding.py +++ b/backend/Funding.py @@ -809,7 +809,7 @@ class Funding: if not has_eligibile_heating: # We check if there is a recommendation for an ASHP or HHRSH if ("air_source_heat_pump" not in measure_types) and ( - "high_heat_retention_storage_heater" not in measure_types): + "high_heat_retention_storage_heaters" not in measure_types): return True, False, True # 2) We check if there is a wall insulation measure for this property. If so, we make sure diff --git a/backend/Property.py b/backend/Property.py index 8ea6749b..23e885d1 100644 --- a/backend/Property.py +++ b/backend/Property.py @@ -87,6 +87,7 @@ class Property: measures=None, energy_assessment=None, is_new=True, + inspections=None, **kwargs ): @@ -210,6 +211,9 @@ class Property: self.energy_assessment_condition_data = energy_assessment["condition"] self.energy_assessment_is_newer = energy_assessment["energy_assessment_is_newer"] + # Store inspections + self.inspections = inspections + # TODO: We keep this but only temporarily until we add bathrooms, bedrooms, building id to the condition data self.parse_kwargs(kwargs) @@ -1296,9 +1300,16 @@ class Property: self.roof["is_flat"] or self.roof["is_pitched"] or self.roof["is_roof_room"] ) # If there is no existing solar PV, the photo-supply field will be None or a missing value - has_no_existing_solar_pv = self.data["photo-supply"] in [ - None, 0, self.DATA_ANOMALY_MATCHES - ] + + # We use inspections data to tell us this + if self.inspections: + has_no_existing_solar_pv = self.inspections.roof_orientation.value not in [ + "already has solar pv", "roof too small", "no roof" + ] + else: + has_no_existing_solar_pv = self.data["photo-supply"] in [ + None, 0, self.DATA_ANOMALY_MATCHES + ] return is_valid_property_type and is_valid_roof_type and has_no_existing_solar_pv diff --git a/backend/app/plan/schemas.py b/backend/app/plan/schemas.py index 6fac54ad..858a0a35 100644 --- a/backend/app/plan/schemas.py +++ b/backend/app/plan/schemas.py @@ -17,7 +17,7 @@ ECO4_ELIGIBILE_FABRIC_MEASURES = [ "suspended_floor_insulation", "solid_floor_insulation", "double_glazing", "secondary_glazing" ] ECO4_ELIGIBLE_HEATING_MEASURES = [ - "boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump", "solar_pv" + "boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "solar_pv" ] SPECIFIC_MEASURES = ( @@ -48,7 +48,7 @@ MEASURE_MAP = { ], "roof_insulation": ["loft_insulation", "flat_roof_insulation", "room_roof_insulation"], "floor_insulation": ["suspended_floor_insulation", "solid_floor_insulation"], - "heating": ["boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump"], + "heating": ["boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump"], "windows": ["double_glazing", "secondary_glazing"], "heating_controls": ["roomstat_programmer_trvs", "time_temperature_zone_control"] } diff --git a/backend/app/plan/utils.py b/backend/app/plan/utils.py index c0ffad4a..67b7bce1 100644 --- a/backend/app/plan/utils.py +++ b/backend/app/plan/utils.py @@ -131,7 +131,9 @@ def parse_eco_packages(config: dict[str, Any], prepared_epc) -> tuple[list[str], landlord_heating_system = config["landlord_heating_system"] # This is the initial version of tackling "already installed" measures - already_installed = ["air_source_heat_pump"] if landlord_heating_system == "air source heat pump" else [] + already_installed = [] + if landlord_heating_system == "air source heat pump": + already_installed.append("air_source_heat_pump") # We map the categories to the desired measures and upgrade targets # We note that the categories are placeholder until we move the standardised asset list @@ -148,7 +150,8 @@ def parse_eco_packages(config: dict[str, Any], prepared_epc) -> tuple[list[str], "plan_type": "solar_eco4" }, "Solar Eligible, Needs Heating Upgrade": { - "measures": ["solar_pv", "loft_insulation", "high_heat_retention_storage_heater", "mechanical_ventilation"], + "measures": ["solar_pv", "loft_insulation", "high_heat_retention_storage_heaters", + "mechanical_ventilation"], "target_sap": 86, # High B "plan_type": "solar_hhrsh_eco4" }, @@ -193,9 +196,9 @@ def parse_eco_packages(config: dict[str, Any], prepared_epc) -> tuple[list[str], # If we have already installed an ASHP, we adjust the measures if "air_source_heat_pump" in already_installed: - if "high_heat_retention_storage_heater" in measures: + if "high_heat_retention_storage_heaters" in measures: # If we have a HHRSH already, we remove it - measures.remove("high_heat_retention_storage_heater") + measures.remove("high_heat_retention_storage_heaters") # Add in ASHP (replacing HHRSH if already had) measures.append("air_source_heat_pump") diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 285e6d5d..271effab 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -597,6 +597,11 @@ async def model_engine(body: PlanTriggerRequest): # If we have an ECO project, we parse the cavity/solar reasons eco_packages[property_id] = parse_eco_packages(config, prepared_epc) + # Final step - extract inspections data, if we have it - we inject into property for usage + property_inspections = extract_inspection_data(config) + if property_inspections: + inspections_map[property_id] = property_inspections + input_properties.append( Property( id=property_id, @@ -608,15 +613,11 @@ async def model_engine(body: PlanTriggerRequest): property_valuation=req_data.valuation, non_invasive_recommendations=property_non_invasive_recommendations, energy_assessment=energy_assessment, + inspections=inspections_map.get(property_id), **Property.extract_kwargs(config), # TODO: Depraecate this ) ) - # Final step - extract inspections data, if we have it - property_inspections = extract_inspection_data(config) - if property_inspections: - inspections_map[property_id] = property_inspections - if not input_properties: return Response(status_code=204) @@ -898,7 +899,8 @@ async def model_engine(body: PlanTriggerRequest): ) 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 @@ -929,6 +931,11 @@ async def model_engine(body: PlanTriggerRequest): 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 optimal_solution = solutions[solutions["meets_upgrade_target"]].iloc[0] @@ -940,7 +947,7 @@ async def model_engine(body: PlanTriggerRequest): 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) (£) + # 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 (£) diff --git a/backend/ml_models/api.py b/backend/ml_models/api.py index 84ba021f..7f3e5873 100644 --- a/backend/ml_models/api.py +++ b/backend/ml_models/api.py @@ -44,7 +44,7 @@ class ModelApi: self.timestamp = timestamp self.prediction_buckets = prediction_buckets self.max_retries = max_retries - self.semaphore = asyncio.Semaphore(2) + self.semaphore = asyncio.Semaphore(3) @staticmethod def get_aiohttp_session(): @@ -117,7 +117,7 @@ class ModelApi: } async with self.semaphore: - await asyncio.sleep(random.uniform(0.3, 1.2)) + # await asyncio.sleep(random.uniform(0.3, 1.2)) try: async with session.post(url, json=payload, headers=headers, timeout=120) as response: if response.status != 200: @@ -211,13 +211,14 @@ class ModelApi: response = await self.predict_async(f"s3://{bucket}/" + file_location, model_prefix, session=session) return model_prefix, response - results = [] - for coro in asyncio.as_completed([run_model(mp) for mp in model_prefixes]): - result = await coro - results.append(result) + # Run all model calls concurrently + results = await asyncio.gather( + *(run_model(mp) for mp in model_prefixes), + return_exceptions=True + ) for model_prefix, response in results: - if response: + if response and not isinstance(response, Exception): predictions_bucket = self.prediction_buckets[model_prefix] predictions_df = pd.DataFrame( read_dataframe_from_s3_parquet( diff --git a/backend/tests/test_data/innovation_measure_fixtures.py b/backend/tests/test_data/innovation_measure_fixtures.py index a66cc7ec..51f8e3ee 100644 --- a/backend/tests/test_data/innovation_measure_fixtures.py +++ b/backend/tests/test_data/innovation_measure_fixtures.py @@ -30,7 +30,7 @@ innovation_scenarios = [ "description": "Innovation PV + HHRSH upgrade, EPC E", "measures": [ {"type": "solar_pv", "is_innovation": True, "innovation_uplift": 0.45}, - {"type": "high_heat_retention_storage_heater", "is_innovation": False, "innovation_uplift": 0} + {"type": "high_heat_retention_storage_heaters", "is_innovation": False, "innovation_uplift": 0} ], "starting_sap": 50, "mainheat_description": "Electric storage heaters", @@ -45,7 +45,7 @@ innovation_scenarios = [ "description": "Innovation PV + HHRSH upgrade, EPC E", "measures": [ {"type": "solar_pv", "is_innovation": True, "innovation_uplift": 0.45}, - {"type": "high_heat_retention_storage_heater", "is_innovation": False, "innovation_uplift": 0} + {"type": "high_heat_retention_storage_heaters", "is_innovation": False, "innovation_uplift": 0} ], "starting_sap": 50, "mainheat_description": "Electric storage heaters", diff --git a/backend/tests/test_funding.py b/backend/tests/test_funding.py index 513c3271..8646ab27 100644 --- a/backend/tests/test_funding.py +++ b/backend/tests/test_funding.py @@ -477,7 +477,7 @@ def test_eco4_sh_epc_d_requires_innovation( measures5 = [ {"type": "solar_pv", "is_innovation": True, "innovation_uplift": 0.45}, - {"type": "high_heat_retention_storage_heater", "is_innovation": False, "innovation_uplift": 0} + {"type": "high_heat_retention_storage_heaters", "is_innovation": False, "innovation_uplift": 0} ] funding5.check_funding( measures=measures5, diff --git a/etl/customers/orbit/funding_example_portfolio.py b/etl/customers/orbit/funding_example_portfolio.py index cf0e151f..c1ade44d 100644 --- a/etl/customers/orbit/funding_example_portfolio.py +++ b/etl/customers/orbit/funding_example_portfolio.py @@ -114,7 +114,7 @@ def app(): "lighting", "secondary_heating", "boiler_upgrade", - "high_heat_retention_storage_heater", + "high_heat_retention_storage_heaters", ], "budget": None, } diff --git a/etl/find_my_epc/RetrieveFindMyEpc.py b/etl/find_my_epc/RetrieveFindMyEpc.py index 5bb5e39b..4a9cf24d 100644 --- a/etl/find_my_epc/RetrieveFindMyEpc.py +++ b/etl/find_my_epc/RetrieveFindMyEpc.py @@ -606,7 +606,7 @@ class RetrieveFindMyEpc: "roomstat_programmer_trvs", "time_temperature_zone_control" ], "Change heating to gas condensing boiler": ["boiler_upgrade"], - "Fan assisted storage heaters and dual immersion cylinder": ["high_heat_retention_storage_heater"], + "Fan assisted storage heaters and dual immersion cylinder": ["high_heat_retention_storage_heaters"], "Flat roof or sloping ceiling insulation": ["flat_roof_insulation"], "Heating controls (room thermostat)": [ "roomstat_programmer_trvs", "time_temperature_zone_control" @@ -634,7 +634,7 @@ class RetrieveFindMyEpc: "PV Cells recommendation": [], "Replacement glazing units": ["double_glazing"], "Heating controls (time and temperature zone control)": ["time_temperature_zone_control"], - "High heat retention storage heaters": ["high_heat_retention_storage_heater"], + "High heat retention storage heaters": ["high_heat_retention_storage_heaters"], "Gas condensing boiler": ["boiler_upgrade"], "Change room heaters to condensing boiler": ["boiler_upgrade"], "Cylinder thermostat": ["cylinder_thermostat"], @@ -677,10 +677,10 @@ class RetrieveFindMyEpc: ], "Internal wall insulation": ["internal_wall_insulation"], "High heat retention storage heaters and dual immersion cylinder and dual rate meter": [ - "high_heat_retention_storage_heater" + "high_heat_retention_storage_heaters" ], "High heat retention storage heaters and dual rate meter": [ - "high_heat_retention_storage_heater" + "high_heat_retention_storage_heaters" ], "Increase loft insulation to 250mm": ["loft_insulation"], "Solar photovoltaics panels, 25% of roof area": ["solar_pv"], diff --git a/recommendations/HeatingRecommender.py b/recommendations/HeatingRecommender.py index 41785104..b8a1b5a7 100644 --- a/recommendations/HeatingRecommender.py +++ b/recommendations/HeatingRecommender.py @@ -40,7 +40,7 @@ class HeatingRecommender: # type 1 "boiler_upgrade", # type 2 - "high_heat_retention_storage_heater", + "high_heat_retention_storage_heaters", ] } }, @@ -156,7 +156,7 @@ class HeatingRecommender: return ( hhr_suitable and (not ashp_only_heating_recommendation) and not self.has_ashp and not self.has_gshp and - ("high_heat_retention_storage_heater" in measures) + ("high_heat_retention_storage_heaters" in measures) ) def is_boiler_upgrade_suitable(self, measures, ashp_only_heating_recommendation): @@ -489,6 +489,55 @@ class HeatingRecommender: return heat_pump_size + @staticmethod + def estimate_peak_kw( + floor_area_m2: float, + epc_primary_kwh_per_m2_yr: float | None = None, + # Prefer these if available: + space_heat_kwh_per_m2_yr: float | None = None, # from EPC/SAP if you can + heat_loss_parameter_W_per_m2K: float | None = None, # HLP if available + primary_to_delivered_factor: float = 1.0, + space_heat_fraction_range=(0.5, 0.75), + hdd_base_dd: float = 2100.0, # set per location (base 15.5 °C typical UK) + t_indoor_C: float = 21.0, + t_design_ext_C: float = -3.0, + ): + ΔT = t_indoor_C - t_design_ext_C + + # 1) Best available path: HLP → direct peak + if heat_loss_parameter_W_per_m2K is not None: + peak_kw = heat_loss_parameter_W_per_m2K * floor_area_m2 * ΔT / 1000.0 + return (peak_kw, peak_kw) # no range needed + + # 2) Second-best: space-heating demand → HDD method + if space_heat_kwh_per_m2_yr is not None: + annual_space_kwh = space_heat_kwh_per_m2_yr * floor_area_m2 + Htot = annual_space_kwh * 1000.0 / (hdd_base_dd * 24.0) # W/K + peak_kw = Htot * ΔT / 1000.0 + return (peak_kw, peak_kw) + + # 3) Minimal inputs: primary energy + assumed fraction → range + assert epc_primary_kwh_per_m2_yr is not None + annual_primary = epc_primary_kwh_per_m2_yr * floor_area_m2 + annual_delivered = annual_primary / primary_to_delivered_factor + + def to_peak(space_fraction): + annual_space = annual_delivered * space_fraction + Htot = annual_space * 1000.0 / (hdd_base_dd * 24.0) + return Htot * ΔT / 1000.0 + + low = to_peak(space_heat_fraction_range[0]) + high = to_peak(space_heat_fraction_range[1]) + return (low, high) + + @staticmethod + def pick_model(peak_kw_range, models_kw=(5, 6, 8.5, 11.2, 14, 17, 20)): + target = peak_kw_range[1] # cover the upper end + for kw in models_kw: + if kw >= target: + return kw + return None + def recommend_air_source_heat_pump(self, phase, has_cavity_or_loft_recommendations, _return=False): """ This method will implement the recommendation for an air source heat pump @@ -504,7 +553,19 @@ class HeatingRecommender: controls_recommender = HeatingControlRecommender(self.property) controls_recommender.recommend(heating_description="Air source heat pump, radiators, electric", phase=phase) - ashp_size = self.size_heat_pump() + # ashp_size = self.size_heat_pump() + + # New functions to estimate size of ASHP + estimated_load = self.estimate_peak_kw( + floor_area_m2=self.property.floor_area, + epc_primary_kwh_per_m2_yr=self.property.data["energy-consumption-current"], + primary_to_delivered_factor=1.55, # use 1.13 if heating fuel is gas + space_heat_fraction_range=(0.35, 0.60), + hdd_base_dd=2000.0, # set from location + t_indoor_C=21.0, + t_design_ext_C=-1.0 # set from local CIBSE table + ) + ashp_size = self.pick_model(estimated_load) ashp_costs = self.costs.air_source_heat_pump(ashp_size) if non_intrusive_recommendation: @@ -884,7 +945,7 @@ class HeatingRecommender: # We check if there is a high heat retention non-intrusive recommendation non_intrusive_recommendation = next( (r for r in self.property.non_invasive_recommendations if - r["type"] == "high_heat_retention_storage_heater"), + r["type"] == "high_heat_retention_storage_heaters"), {} ) @@ -981,7 +1042,7 @@ class HeatingRecommender: phase=phase, heating_controls_only=heating_controls_only, system_change=system_change, - system_type="high_heat_retention_storage_heater", + system_type="high_heat_retention_storage_heaters", non_intrusive_recommendation=non_intrusive_recommendation, heating_product=hhrsh_product ) diff --git a/recommendations/optimiser/funding_optimiser.py b/recommendations/optimiser/funding_optimiser.py index bf0e1b68..6a0b1d0c 100644 --- a/recommendations/optimiser/funding_optimiser.py +++ b/recommendations/optimiser/funding_optimiser.py @@ -91,7 +91,7 @@ def violates_min_insulation(fixed, optimisation_input_measures): # heating (incl. PV) flags is_heating = has_any([ "air_source_heat_pump", - "high_heat_retention_storage_heater", + "high_heat_retention_storage_heaters", "boiler_upgrade", "electric_boiler", "time_temperature_zone_control", @@ -171,7 +171,7 @@ def _prs_solution_ok(items, p, funding): # renewable set: has_ashp = ("air_source_heat_pump" in types) # ASHP alone is renewable has_solar = ("solar_pv" in types) - has_hhrsh = ("high_heat_retention_storage_heater" in types) # only counts *with* solar + has_hhrsh = ("high_heat_retention_storage_heaters" in types) # only counts *with* solar # solar PV qualifies if paired with eligible existing heating solar_ok_existing = has_solar and funding.check_solar_eligible_heating_system( @@ -468,6 +468,7 @@ def optimise_with_funding_paths(p, input_measures, housing_type, funding: Fundin ) rate = funding.get_eco4_abs_rate(is_cavity=p.walls["is_cavity_wall"]) + # The full project funding, at this point, does NOT include any uplifts solutions["full_project_funding"] = solutions["project_score"] * rate # if the scheme is not ECO4, we set the funding to 0 with iloc solutions.loc[solutions["scheme"] != "eco4", "full_project_funding"] = 0.0 @@ -679,7 +680,7 @@ def parse_types(t): def includes_heating(opt_types): return any(x in opt_types for x in { "air_source_heat_pump", - "high_heat_retention_storage_heater", + "high_heat_retention_storage_heaters", "time_temperature_zone_control", # controls count as a heating measure in your pipeline "solar_pv" # you treat PV as heating for funding logic }) @@ -761,7 +762,7 @@ def _make_solar_heating_funding_paths( # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # We don't include electric boilers as they are not eligible for ECO4 funding solar_heating_combos = [ - ("high_heat_retention_storage_heater", "solar_pv+hhrsh:eco4"), + ("high_heat_retention_storage_heaters", "solar_pv+hhrsh:eco4"), ("air_source_heat_pump", "solar_pv+ashp:eco4"), ] if _find_measure(input_measures, "solar_pv"): @@ -790,11 +791,11 @@ def _make_solar_heating_funding_paths( single_heating_measures = ["air_source_heat_pump"] else: single_heating_measures = [ - "boiler_upgrade", "high_heat_retention_storage_heater", "air_source_heat_pump" + "boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump" ] measure_references = { "boiler_upgrade": "boiler_upgrade", - "high_heat_retention_storage_heater": "hhrsh", + "high_heat_retention_storage_heaters": "hhrsh", "air_source_heat_pump": "ashp" } for heating_upgrade in single_heating_measures: @@ -881,14 +882,16 @@ def make_funding_paths(p, input_measures, housing_type, funding: Funding): if housing_type == "Social" and p.data["current-energy-rating"] == "D": # If the property is currently EPC D, we can only include innovation measures or measures to meet the - # minimum insulation requirements + # minimum insulation requirements. We make an exception if we have a measure that is + # already installed, specifically a heat pump input_measures_innovation = [] input_gbis_measures_innovation = [] for measures in input_measures: group_of_innovation_measures = [] group_of_gbis_innovation_measures = [] for measure in measures: - if measure["innovation_uplift"] or measure["type"] in remaining_insulation_type: + if measure["innovation_uplift"] or measure["type"] in remaining_insulation_type or measure[ + "already_installed"]: group_of_innovation_measures.append(measure) if measure["innovation_uplift"] and measure["type"] in ( diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index 4812bc63..4a8c96da 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -6,7 +6,10 @@ from backend.app.utils import epc_to_sap_lower_bound from recommendations.optimiser.CostOptimiser import CostOptimiser -def prepare_input_measures(property_recommendations, goal, needs_ventilation, funding=False): +def prepare_input_measures( + property_recommendations, goal, needs_ventilation, funding=False, + property_eco_packages=None +): """ Prepares a nested list of measure options for optimisation. @@ -37,6 +40,9 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation, fu funding: bool, optional If true, the function will include the innovation uplift in the total cost calculation. If false, this is excluded, since innovation uplift cannot be claimed where funding is not available. + property_eco_packages: dict, optional + Eco package data for the property, if available. If a measure has been specified as part of an eco package + (e.g. HHRSH) this function will include that measure in the optimisation, even if it has negative cost savings. Returns ------- @@ -59,6 +65,8 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation, fu {} ) + eco_measures = property_eco_packages[0] if property_eco_packages else [] + input_measures = [] for recs in property_recommendations: @@ -71,7 +79,14 @@ def prepare_input_measures(property_recommendations, goal, needs_ventilation, fu recs = [r for r in recs if ~r["has_battery"]] # Only include measures with non-negative cost savings - recs_to_append = [rec for rec in recs if rec["energy_cost_savings"] >= 0] + if eco_measures: + recs_to_append = [ + rec for rec in recs if (rec["energy_cost_savings"] >= 0) or (rec["measure_type"] in eco_measures) + ] + else: + recs_to_append = [ + rec for rec in recs if (rec["energy_cost_savings"] >= 0) + ] if not recs_to_append: continue diff --git a/recommendations/tests/test_data/heating_recommendations_data.py b/recommendations/tests/test_data/heating_recommendations_data.py index f4b4c0a6..37c854c3 100644 --- a/recommendations/tests/test_data/heating_recommendations_data.py +++ b/recommendations/tests/test_data/heating_recommendations_data.py @@ -86,7 +86,7 @@ testing_examples = [ 'uprn-source': 'Address Matched', }, "heating_measure_types": [ - "high_heat_retention_storage_heater", + "high_heat_retention_storage_heaters", ], "notes": "This property has electric room heaters and is off gas so a boiler recommendation is not appropriate." "We would expect a high heat retention storage recommendation. The property is a flat and therefore" @@ -134,7 +134,7 @@ testing_examples = [ 'tenure': 'owner-occupied', 'fixed-lighting-outlets-count': 6.0, 'low-energy-fixed-light-count': 4.0, 'uprn': 100090311351.0, 'uprn-source': 'Address Matched', 'property-type_y': None, 'built-form_y': None, }, - "heating_measure_types": ['high_heat_retention_storage_heater', 'air_source_heat_pump'], + "heating_measure_types": ['high_heat_retention_storage_heaters', 'air_source_heat_pump'], "notes": "This test has electric storage heaters with automatic charge control - we recommend hhr storage" "heaters in this case, but because there are already electic storage heaters in place, we " "note, in the description of the recommendation, that this upgrade may be possible by retrofitting" @@ -275,7 +275,7 @@ testing_examples = [ 'uprn': 43088770.0, 'uprn-source': 'Address Matched', }, "heating_measure_types": [ - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', ], "notes": "This property is a flat so we don't have an ASHP recommendation. It also doesn't have access to the " "mains and so it can't have a gas boiler. We don't expect any controls recommendations" @@ -370,7 +370,7 @@ testing_examples = [ }, "heating_measure_types": [ 'boiler_upgrade', - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', 'boiler_upgrade' ], "notes": "This property has assumed electric heating and is mid-terrace house. It has a mains gas connection." @@ -416,7 +416,7 @@ testing_examples = [ }, "heating_measure_types": [ 'air_source_heat_pump', - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', ], "notes": "This property has an oil boiler and doesn't have a mains gas connection so we can only recommend" "an air source heat pump and HHR (since if the home has a non-gas boiler, we recommend HHR)" @@ -463,7 +463,7 @@ testing_examples = [ }, "heating_measure_types": [ 'boiler_upgrade', - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', 'air_source_heat_pump', 'boiler_upgrade' # TTZs ], @@ -512,7 +512,7 @@ testing_examples = [ "heating_measure_types": [ 'boiler_upgrade', 'boiler_upgrade', - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', ], "notes": "This property has assumed electric heaters. Boiler upgrade, HHR are recommended. We don't recommend" "an ASHP off of the bat because it's mid-terrace." @@ -557,7 +557,7 @@ testing_examples = [ }, "heating_measure_types": [ 'boiler_upgrade', - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', 'boiler_upgrade' ], "notes": "This has a form of assumed electric heating and has a mains connection so we recommend HHR, boiler" @@ -605,7 +605,7 @@ testing_examples = [ "heating_measure_types": [ 'boiler_upgrade', 'boiler_upgrade', - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', ], "notes": "This property already has storage heaters with manual charge control. The home is mid terrace so" "the ashp is not suitable" @@ -651,7 +651,7 @@ testing_examples = [ 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', 'air_source_heat_pump', ], "notes": "This property has an LFG boiler but it doesn't have a mains gas connection so we can only recommend" @@ -696,7 +696,7 @@ testing_examples = [ 'uprn-source': 'Energy Assessor', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', 'air_source_heat_pump', ], "notes": "This property has electric boilers in place, but does not have a mains connection so we don't " @@ -744,7 +744,7 @@ testing_examples = [ }, "heating_measure_types": [ 'air_source_heat_pump', - 'high_heat_retention_storage_heater' + 'high_heat_retention_storage_heaters' ], "notes": "This property has a dual fuel boiler and no mains gas connection. We recommend ASHP and HHR, but" "no gas condensing boiler" @@ -788,7 +788,7 @@ testing_examples = [ }, "heating_measure_types": [ 'air_source_heat_pump', - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', ], "notes": "This property has a coal boiler and no mains gas connection. We recommend ASHP and HHR, but" "no gas condensing boiler" @@ -835,7 +835,7 @@ testing_examples = [ }, "heating_measure_types": [ 'air_source_heat_pump', - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', ], "notes": "This property has a smokeless fuel boiler and no mains gas connection. We recommend ASHP and HHR, but" "no gas condensing boiler" @@ -880,7 +880,7 @@ testing_examples = [ }, "heating_measure_types": [ 'air_source_heat_pump', - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', ], "notes": "This property has a wood pellets boiler and no mains gas connection. We recommend ASHP and HHR, but" "no gas condensing boiler" @@ -925,7 +925,7 @@ testing_examples = [ 'uprn-source': 'Address Matched', 'sheating-energy-eff': None, 'sheating-env-eff': None }, "heating_measure_types": [ - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', 'air_source_heat_pump', ], "notes": "This is an end-terrace house, without mains gas connection, so we recommend is HHR & ASHP" @@ -1010,7 +1010,7 @@ testing_examples = [ }, "heating_measure_types": [ 'air_source_heat_pump', - 'high_heat_retention_storage_heater', + 'high_heat_retention_storage_heaters', 'time_temperature_zone_control', ], "notes": "This property has dual heating. A boiler and electric storage heaters. The heating is efficient so" @@ -1056,8 +1056,8 @@ testing_examples = [ "heating_measure_types": [ 'air_source_heat_pump', 'boiler_upgrade', - 'boiler_upgrade+high_heat_retention_storage_heater', - 'high_heat_retention_storage_heater', + 'boiler_upgrade+high_heat_retention_storage_heaters', + 'high_heat_retention_storage_heaters', 'time_temperature_zone_control' ], "notes": "This property is a modified version of the previous dual heating property, where we lower the" @@ -1104,7 +1104,7 @@ testing_examples = [ }, "heating_measure_types": [ 'air_source_heat_pump', - 'high_heat_retention_storage_heater' + 'high_heat_retention_storage_heaters' ], "notes": "This property has anthracite heating without mains. " "We recommend ASHP and HHR, but no gas condensing boiler" @@ -1151,7 +1151,7 @@ testing_examples = [ "heating_measure_types": [ 'boiler_upgrade', 'boiler_upgrade', - 'high_heat_retention_storage_heater' + 'high_heat_retention_storage_heaters' ], "notes": "This property has room heaters with two different fuel sources, so we recommend HHR, ASHP, and a " "boiler upgrade" @@ -1238,7 +1238,7 @@ testing_examples = [ }, "heating_measure_types": [ 'air_source_heat_pump', - 'high_heat_retention_storage_heater' + 'high_heat_retention_storage_heaters' ], "notes": "The property has warm air electricaire heating, so we recommend ASHP and HHR" }, diff --git a/recommendations/tests/test_heating_recommendations.py b/recommendations/tests/test_heating_recommendations.py index b18839aa..93acdefa 100644 --- a/recommendations/tests/test_heating_recommendations.py +++ b/recommendations/tests/test_heating_recommendations.py @@ -105,3 +105,108 @@ class TestHeatingRecommendations: {x["measure_type"] for x in recommender.heating_recommendations} == set(test_case["heating_measure_types"]) ) + + +@pytest.mark.parametrize( + "floor_area, epc_primary, expected_band, expected_model", + [ + # Case 1 – Typical pre-2000 house, gas heating + ( + 93.75, + 270.19, + (2.5, 4.6), # expected rough band (low, high) + 5, # chosen model + ), + # Case 2 – Efficient new-build (low EPC energy) + ( + 93.75, + 142.28, + (1.4, 2.4), + 3, # assume 3 or 5 kW model covers this + ), + ], +) +def test_estimate_peak_kw_basic(floor_area, epc_primary, expected_band, expected_model): + """ + Ensure the peak load estimate is within a sensible range and + that the model selection logic picks the correct bracket. + """ + + load_band = HeatingRecommender.estimate_peak_kw( + floor_area_m2=floor_area, + epc_primary_kwh_per_m2_yr=epc_primary, + primary_to_delivered_factor=1.55, # electricity + space_heat_fraction_range=(0.35, 0.60), + hdd_base_dd=2000.0, + t_indoor_C=21.0, + t_design_ext_C=-1.0, + ) + + # Assert range sanity + assert expected_band[0] * 0.8 <= load_band[0] <= expected_band[1] * 1.2 + assert expected_band[0] <= load_band[1] <= expected_band[1] * 1.2 + + # Pick model + model = HeatingRecommender.pick_model(load_band, models_kw=(3, 5, 6, 8.5, 11.2)) + assert model == expected_model + + +def test_estimate_peak_kw_with_hlp(): + """ + Test direct HLP input path (best-quality data). + """ + hlp = 1.5 # W/m²K typical for semi-detached + floor_area = 100 + load_band = HeatingRecommender.estimate_peak_kw( + floor_area_m2=floor_area, + heat_loss_parameter_W_per_m2K=hlp, + t_indoor_C=21, + t_design_ext_C=-2, + ) + # Should return identical low/high values since it's direct + assert isinstance(load_band, tuple) + assert abs(load_band[0] - load_band[1]) < 1e-6 + # Expected peak = 1.5 * 100 * 23 / 1000 = 3.45 kW + assert pytest.approx(load_band[0], rel=0.05) == 3.45 + + +def test_estimate_peak_kw_with_space_heat_demand(): + """ + Test the space-heating-demand path. + """ + floor_area = 120 + space_heat_kwh_m2 = 100 + load_band = HeatingRecommender.estimate_peak_kw( + floor_area_m2=floor_area, + space_heat_kwh_per_m2_yr=space_heat_kwh_m2, + hdd_base_dd=2100, + t_indoor_C=21, + t_design_ext_C=-3, + ) + # Rough expected peak ~ (100*120*1000)/(2100*24) * 24 /1000 = 5.4 kW + assert 4.5 < load_band[0] < 6.0 + assert abs(load_band[0] - load_band[1]) < 1e-6 + + +def test_pick_model_boundaries(): + """ + Ensure pick_model correctly selects the smallest model covering the upper band. + """ + assert HeatingRecommender.pick_model((2.0, 4.9), models_kw=(3, 5, 6, 8.5)) == 5 + assert HeatingRecommender.pick_model((5.0, 5.0), models_kw=(3, 5, 6, 8.5)) == 5 + assert HeatingRecommender.pick_model((5.0, 6.1), models_kw=(3, 5, 6, 8.5)) == 6 + assert HeatingRecommender.pick_model((8.6, 9.0), models_kw=(3, 5, 6, 8.5, 11.2)) == 11.2 + assert HeatingRecommender.pick_model((20, 25), models_kw=(3, 5, 6, 8.5, 11.2)) is None + + +def test_parameter_validation_and_defaults(): + """ + Validate that the function handles missing or minimal parameters properly. + """ + # Minimal path using primary energy only + load_band = HeatingRecommender.estimate_peak_kw( + floor_area_m2=80, + epc_primary_kwh_per_m2_yr=250, + ) + assert isinstance(load_band, tuple) + assert load_band[0] < load_band[1]