diff --git a/recommendations/tests/test_optimiser_functions.py b/recommendations/tests/test_optimiser_functions.py new file mode 100644 index 00000000..197b3263 --- /dev/null +++ b/recommendations/tests/test_optimiser_functions.py @@ -0,0 +1,135 @@ +import pytest +from types import SimpleNamespace +from recommendations.optimiser import optimiser_functions + + +class TestPrepareInputMeasures: + def test_returns_expected_structure_without_ventilation(self): + recs = [ + [ # loft insulation measure + {"recommendation_id": "loft1", "type": "loft_insulation", "total": 100, "kwh_savings": 200, + "energy_cost_savings": 10, "has_battery": False}, + ], + ] + measures = optimiser_functions.prepare_input_measures(recs, goal="Energy Savings", needs_ventilation=False) + assert isinstance(measures, list) + assert measures[0][0]["id"] == "loft1" + assert measures[0][0]["cost"] == 100 + assert measures[0][0]["gain"] == 200 + + def test_bundles_ventilation_when_needed(self, monkeypatch): + # patch measures_needing_ventilation so that "wall_insulation" needs ventilation + monkeypatch.setattr(optimiser_functions.assumptions, "measures_needing_ventilation", ["wall_insulation"]) + recs = [ + [{"recommendation_id": "wall1", "type": "internal_wall_insulation", "total": 500, "kwh_savings": 300, + "energy_cost_savings": 5, "has_battery": False}], + [{"recommendation_id": "vent1", "type": "mechanical_ventilation", "total": 50, "kwh_savings": 30, + "energy_cost_savings": 5, "has_battery": False}] + ] + measures = optimiser_functions.prepare_input_measures(recs, goal="Energy Savings", needs_ventilation=True) + wall_option = measures[0][0] + assert wall_option["cost"] == 550 + assert wall_option["gain"] == 330 + assert "+mechanical_ventilation" in wall_option["type"] + + def test_filters_out_negative_cost_savings(self): + recs = [ + [{"recommendation_id": "bad1", "type": "loft_insulation", "total": 200, "kwh_savings": 100, + "energy_cost_savings": -5, "has_battery": False}], + ] + measures = optimiser_functions.prepare_input_measures(recs, goal="Energy Savings", needs_ventilation=False) + assert measures == [] # should skip negative cost saving recs + + +class TestCalculateFixedGain: + def test_no_required_measures_returns_zero(self): + fixed_gain = optimiser_functions.calculate_fixed_gain( + [], {}, SimpleNamespace(id="P1"), needs_ventilation=False + ) + assert fixed_gain == 0 + + def test_sums_max_sap_points_per_type(self, monkeypatch): + monkeypatch.setattr(optimiser_functions.assumptions, "measures_needing_ventilation", ["wall_insulation"]) + required_measures = [ + [{"type": "internal_wall_insulation", "sap_points": 5}, + {"type": "internal_wall_insulation", "sap_points": 10}], + [{"type": "loft_insulation", "sap_points": 3}] + ] + recommendations = {"P1": [[{"type": "mechanical_ventilation", "sap_points": 2}]]} + prop = SimpleNamespace(id="P1") + gain = optimiser_functions.calculate_fixed_gain( + required_measures, recommendations, prop, needs_ventilation=True + ) + # Should take max of wall (10) + loft (3) + ventilation (2) + assert gain == 15 + + +class TestCalculateGain: + def test_returns_none_for_energy_savings_goal(self): + body = SimpleNamespace(goal="Energy Savings") + prop = SimpleNamespace(data={"current-energy-efficiency": "50"}) + gain = optimiser_functions.calculate_gain(body, prop, fixed_gain=0) + assert gain is None + + def test_calculates_gain_for_epc(self, monkeypatch): + # patch cost optimiser calculation + monkeypatch.setattr(optimiser_functions.CostOptimiser, "calculate_sap_gain_with_slack", lambda x: x + 1) + monkeypatch.setattr(optimiser_functions, "epc_to_sap_lower_bound", lambda goal_value: 69) + + body = SimpleNamespace(goal="Increasing EPC", goal_value="C", simulate_sap_10=False) + prop = SimpleNamespace(data={"current-energy-efficiency": "50"}) + gain = optimiser_functions.calculate_gain(body, prop, fixed_gain=2) + # epc_to_sap_lower_bound (69) - current (50) = 10 + slack (1) = 11 - fixed_gain (2) = 9 + assert gain == 18.5 + + +class TestAddRequiredMeasures: + def test_adds_cheapest_required_measure(self): + property_id = "P1" + required_measures = [ + [{"recommendation_id": "a", "total": 100, "sap_points": 5, "type": "loft_insulation"}, + {"recommendation_id": "b", "total": 80, "sap_points": 6, "type": "loft_insulation"}] + ] + recommendations = { + "P1": [[{"recommendation_id": "a", "total": 100, "sap_points": 5, "type": "loft_insulation"}, + {"recommendation_id": "b", "total": 80, "sap_points": 6, "type": "loft_insulation"}]] + } + selected = set() + result = optimiser_functions.add_required_measures(property_id, required_measures, recommendations, selected) + # cheapest should be b + assert "b" in selected + assert any(rec["id"] == "b" for rec in result) + + +class TestAddBestPracticeMeasures: + def test_adds_ventilation_and_trickle_vents(self, monkeypatch): + monkeypatch.setattr(optimiser_functions.assumptions, "measures_needing_ventilation", ["wall_insulation"]) + property_id = "P1" + solution = [{"type": "internal_wall_insulation", "id": "w1", "gain": 10, "cost": 100}] + recommendations = { + "P1": [ + [{"type": "mechanical_ventilation", "recommendation_id": "vent1"}], + [{"type": "trickle_vents", "recommendation_id": "trickle1"}] + ] + } + selected = set() + updated = optimiser_functions.add_best_practice_measures(property_id, solution, recommendations, selected) + assert "vent1" in updated + assert "trickle1" in updated + + +class TestFlattenRecommendationsWithDefaults: + def test_marks_selected_and_flattens(self): + property_id = "P1" + recommendations = { + "P1": [ + [{"recommendation_id": "a", "foo": 1}, {"recommendation_id": "b", "foo": 2}], + [{"recommendation_id": "c", "foo": 3}] + ] + } + selected = {"b", "c"} + result = optimiser_functions.flatten_recommendations_with_defaults(property_id, recommendations, selected) + # All recs should now have a default key + assert all("default" in rec for rec in result) + assert next(r for r in result if r["recommendation_id"] == "b")["default"] is True + assert next(r for r in result if r["recommendation_id"] == "a")["default"] is False