diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 80d6d078..101f6ada 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -1053,14 +1053,18 @@ async def model_engine(body: PlanTriggerRequest): property_required_measures = [m for m in recommendations[p.id] if m[0]["type"] in body.required_measures] measures_to_optimise = [m for m in recommendations[p.id] if m[0]["type"] not in body.required_measures] - ventilation_included = "ventilation" in property_measure_types + # TODO - formalise property measure types into an enum + ventilation_included = ( + "ventilation" in property_measure_types or "mechanical_ventilation" in property_measure_types + ) # If a measure requiring ventilation is selected, and the property does not have ventilation, we enfore # its inclusion - needs_ventilation = any( - x in property_measure_types for x in assumptions.measures_needing_ventilation - ) and not p.has_ventilation and ventilation_included + needs_ventilation = optimiser_functions.check_needs_ventilation( + property_measure_types, assumptions.measures_needing_ventilation, p.has_ventilation, + ventilation_included + ) if not measures_to_optimise: # Nothing to do, we just reshape the recommendations @@ -1177,8 +1181,10 @@ async def model_engine(body: PlanTriggerRequest): recommendations=recommendations, selected=selected, ) - # Add best practice measures (ventilation/trickle vents) - selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) + # Add best practice measures (ventilation/trickle vents) - pass needs_ventilation flag + selected = optimiser_functions.add_best_practice_measures( + p.id, solution, recommendations, selected, needs_ventilation + ) # Final flattening - we pass what the battery SAP score would be, regardless if the battery was selected recommendations[p.id] = optimiser_functions.flatten_recommendations_with_defaults( p.id, recommendations, selected, battery_sap_score diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index d704b3fb..4b0d4b94 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -1,4 +1,5 @@ import pandas as pd +from typing import List, Dict, Any, Set import backend.app.assumptions as assumptions from backend.Property import Property from backend.app.plan.schemas import PlanTriggerRequest @@ -300,7 +301,13 @@ def add_required_measures(property_id, property_required_measures, recommendatio ] -def add_best_practice_measures(property_id, solution, recommendations, selected): +def add_best_practice_measures( + property_id: int, + solution: List[Dict[str, Any]], + recommendations: Dict[int, List[List[Dict[str, Any]]]], + selected: Set[str], + needs_ventilation: bool +): """ Ensures best-practice measures like ventilation and trickle vents are included in the selected recommendations when appropriate. @@ -320,6 +327,8 @@ def add_best_practice_measures(property_id, solution, recommendations, selected) All recommendations for all properties, keyed by property id. selected : set Set of already selected recommendation IDs. + needs_ventilation : bool + Whether the property requires mechanical ventilation to accompany certain measures. Returns ------- @@ -329,12 +338,6 @@ def add_best_practice_measures(property_id, solution, recommendations, selected) # Check if any selected measure requires ventilation ventilation_selected = [r for r in solution if "+mechanical_ventilation" in r["type"]] - # If ventilation has been selected, or one of the measures needs ventilation, we need to ensure ventilation is - # included - needs_ventilation = any( - x in [r["type"] for r in solution] for x in assumptions.measures_needing_ventilation - ) or len(ventilation_selected) > 0 - if needs_ventilation: ventilation_rec = next( (r[0] for r in recommendations[property_id] if r[0]["type"] == "mechanical_ventilation"), @@ -395,3 +398,30 @@ def flatten_recommendations_with_defaults(property_id, recommendations, selected # Flatten the nested list of lists into a single list return [rec for recommendations_by_type in final_recommendations for rec in recommendations_by_type] + + +def check_needs_ventilation( + property_measure_types: Set[str], + measures_needing_ventilation: List[str], + property_already_has_ventilation: bool, + ventilation_in_included_measures: bool +) -> bool: + """ + Function to check if we need to include ventilation based on the measures selected and the property + features + :param property_measure_types: The set of measure types recommended for the property + :param measures_needing_ventilation: The set of measure types that require ventilation + :param property_already_has_ventilation: Whether the property currently has ventilation + :param ventilation_in_included_measures: Whether ventilation is already included in the recommended + measures + :return: Boolean indicating whether ventilation needs to be included in the recommendations + + # TODO - none of the inputs of this function are well structured and so this is quite brittle - we should + consider refactoring to make this more robust + """ + + needs_ventilation = any( + x in property_measure_types for x in measures_needing_ventilation + ) + + return needs_ventilation and not property_already_has_ventilation and ventilation_in_included_measures diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py index f0ca6dac..debd2d88 100644 --- a/recommendations/tests/test_optimiser_functions.py +++ b/recommendations/tests/test_optimiser_functions.py @@ -143,7 +143,9 @@ class TestAddBestPracticeMeasures: ] } selected = set() - updated = optimiser_functions.add_best_practice_measures(property_id, solution, recommendations, selected) + updated = optimiser_functions.add_best_practice_measures( + property_id, solution, recommendations, selected, True + ) assert "vent1" in updated assert "trickle1" in updated @@ -273,7 +275,7 @@ class TestIncreasingEpcE2e: total_optimised_gain = sum(m["gain"] for m in solution) assert total_optimised_gain == 17.6, "Total gain of optimised measures should meet or exceed target gain" - selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected) + selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected, False) # Flatten recommendations for output flattened = optimiser_functions.flatten_recommendations_with_defaults(p.id, recommendations, selected) @@ -510,3 +512,63 @@ class TestStrategicOptimiser: assert opt.strategy_used.value == "case_2_solve_max_gain_under_budget" assert opt.solution_cost == 7787.068 assert opt.solution_gain == 28.8 + + +class TestCheckNeedsVentilation: + + def measure_types_includes_ventilation_no_existing_ventilation(self): + property_measure_types = {'mechanical_ventilation', 'cavity_wall_insulation', 'suspended_floor_insulation', + 'secondary_heating', 'loft_insulation', 'heating', 'low_energy_lighting'} + + measures_needing_ventilation = ['internal_wall_insulation', 'external_wall_insulation', + 'cavity_wall_insulation'] + + has_ventilation = False + + ventilation_included = True + + result = optimiser_functions.check_needs_ventilation( + property_measure_types, measures_needing_ventilation, has_ventilation, + ventilation_included + ) + + assert result == True + + def measure_types_includes_ventilation_existing_ventilation(self): + property_measure_types = {'mechanical_ventilation', 'cavity_wall_insulation', 'suspended_floor_insulation', + 'secondary_heating', 'loft_insulation', 'heating', 'low_energy_lighting'} + + measures_needing_ventilation = ['internal_wall_insulation', 'external_wall_insulation', + 'cavity_wall_insulation'] + + has_ventilation = True + + ventilation_included = True + + result = optimiser_functions.check_needs_ventilation( + property_measure_types, measures_needing_ventilation, has_ventilation, + ventilation_included + ) + + assert result == False + + def measure_types_includes_ventilation_existing_ventilation(self): + property_measure_types_without_ventilation = { + 'cavity_wall_insulation', 'suspended_floor_insulation', + 'secondary_heating', 'loft_insulation', 'heating', + 'low_energy_lighting' + } + + measures_needing_ventilation = ['internal_wall_insulation', 'external_wall_insulation', + 'cavity_wall_insulation'] + + has_ventilation = False + + ventilation_included = True + + result = optimiser_functions.check_needs_ventilation( + property_measure_types_without_ventilation, measures_needing_ventilation, has_ventilation, + ventilation_included + ) + + assert result == False