From 5305643991e4986fd077c47d125cdb120ce3ff61 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 23 Feb 2026 12:44:42 +0000 Subject: [PATCH 1/5] pass needs ventilation to optimiser functon' --- backend/engine/engine.py | 10 +++++++--- .../optimiser/optimiser_functions.py | 17 ++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 80d6d078..6c6b0c70 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -1053,7 +1053,9 @@ 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 + 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 @@ -1177,8 +1179,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..e916f0fd 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"), From 84aef797355146a2b5901b59adcfa6be3688fa95 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 23 Feb 2026 13:35:31 +0000 Subject: [PATCH 2/5] Added tests for checking ventilation --- backend/engine/engine.py | 7 ++- .../optimiser/optimiser_functions.py | 23 +++++++ .../tests/test_optimiser_functions.py | 60 +++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/backend/engine/engine.py b/backend/engine/engine.py index 6c6b0c70..dd0aebe4 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -1060,9 +1060,10 @@ async def model_engine(body: PlanTriggerRequest): # 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 diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index e916f0fd..c17cdf1e 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -398,3 +398,26 @@ 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], + has_ventilation: bool, + ventilation_included: 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 has_ventilation: Whether the property currently has ventilation + :param ventilation_included: 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 + """ + return any( + x in property_measure_types for x in measures_needing_ventilation + ) and not has_ventilation and ventilation_included diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py index f0ca6dac..8f898970 100644 --- a/recommendations/tests/test_optimiser_functions.py +++ b/recommendations/tests/test_optimiser_functions.py @@ -510,3 +510,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 From 73928c67c587ce841ea7eb684a9767eed46b3b41 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 23 Feb 2026 13:37:17 +0000 Subject: [PATCH 3/5] added future todo for measure types --- backend/engine/engine.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/engine/engine.py b/backend/engine/engine.py index dd0aebe4..101f6ada 100644 --- a/backend/engine/engine.py +++ b/backend/engine/engine.py @@ -1053,6 +1053,7 @@ 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] + # TODO - formalise property measure types into an enum ventilation_included = ( "ventilation" in property_measure_types or "mechanical_ventilation" in property_measure_types ) From 694717bd34a5e9aedaeca942abbf9905fcb81e2d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 23 Feb 2026 14:14:07 +0000 Subject: [PATCH 4/5] addressing Dan's feedback --- recommendations/optimiser/optimiser_functions.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index c17cdf1e..ab98113c 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -403,21 +403,25 @@ def flatten_recommendations_with_defaults(property_id, recommendations, selected def check_needs_ventilation( property_measure_types: Set[str], measures_needing_ventilation: List[str], - has_ventilation: bool, - ventilation_included: bool + 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 has_ventilation: Whether the property currently has ventilation - :param ventilation_included: Whether ventilation is already included in the recommended measures + :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 """ - return any( + + needs_ventilation = any( x in property_measure_types for x in measures_needing_ventilation - ) and not has_ventilation and ventilation_included + ) + + return needs_ventilation and not has_ventilation and ventilation_included From 9dda6fb434c4c13306924f7be16a17d82bdd0ddb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 23 Feb 2026 15:33:32 +0000 Subject: [PATCH 5/5] fixed test and variable renames in function --- recommendations/optimiser/optimiser_functions.py | 2 +- recommendations/tests/test_optimiser_functions.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/recommendations/optimiser/optimiser_functions.py b/recommendations/optimiser/optimiser_functions.py index ab98113c..4b0d4b94 100644 --- a/recommendations/optimiser/optimiser_functions.py +++ b/recommendations/optimiser/optimiser_functions.py @@ -424,4 +424,4 @@ def check_needs_ventilation( x in property_measure_types for x in measures_needing_ventilation ) - return needs_ventilation and not has_ventilation and ventilation_included + 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 8f898970..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)