Model/recommendations/tests/test_optimiser_functions.py
2025-10-28 19:28:26 +00:00

271 lines
12 KiB
Python

import pytest
import numpy as np
from types import SimpleNamespace
from recommendations.tests.test_data.measures_to_optimise import measures_to_optimise
from recommendations.optimiser import optimiser_functions
from recommendations.optimiser.GainOptimiser import GainOptimiser
from recommendations.optimiser.CostOptimiser import CostOptimiser
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, "measure_type": "loft_insulation",
"partial_project_funding": 0, "partial_project_score": 0,
"uplift_project_score": 0,
},
],
]
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",
["internal_wall_insulation"])
recs = [
[{"recommendation_id": "wall1", "type": "internal_wall_insulation", "total": 500, "kwh_savings": 300,
"energy_cost_savings": 5, "has_battery": False, "measure_type": "internal_wall_insulation",
"partial_project_funding": 0, "partial_project_score": 0, "uplift_project_score": 0,
}],
[{"recommendation_id": "vent1", "type": "mechanical_ventilation", "total": 50, "kwh_savings": 30,
"energy_cost_savings": 5, "has_battery": False, "measure_type": "mechanical_ventilation",
"partial_project_funding": 0, "partial_project_score": 0, "uplift_project_score": 0, }],
]
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,
"partial_project_funding": 0, "partial_project_score": 0, "uplift_project_score": 0, }],
]
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",
["internal_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, "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)
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",
["internal_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
class TestIncreasingEpcE2e:
"""
Test out the classic increasing EPC optimisation flow end-to-end.
We have a goal (Increasing EPC), no budget, and we expect the optimiser to choose
the best set of measures and include best-practice ventilation.
"""
@pytest.fixture
def setup_case(self):
# Dummy property object
p = SimpleNamespace(
id="P1",
has_ventilation=False,
data={"current-energy-efficiency": "52"},
)
# Dummy request body
body = SimpleNamespace(
goal="Increasing EPC",
goal_value="C",
optimise=True,
budget=None,
simulate_sap_10=False,
required_measures=[]
)
recommendations = {"P1": measures_to_optimise}
return p, body, recommendations
def test_end_to_end_increasing_epc(self, setup_case):
p, body, recommendations = setup_case
# ---------------------
# RUN THE OPTIMISATION LOOP
# ---------------------
property_measure_types = {rec["type"] for recs in recommendations[p.id] for rec in recs}
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 flag
needs_ventilation = any(
x in property_measure_types for x in optimiser_functions.assumptions.measures_needing_ventilation
) and not p.has_ventilation
assert needs_ventilation
# Input the various things we need - set all to 0
for group in measures_to_optimise:
for r in group:
(
r["partial_project_score"],
r["partial_project_funding"],
r["innovation_uplift"],
r["uplift_project_score"],
) = (
0, 0, 0, 0
)
input_measures = optimiser_functions.prepare_input_measures(measures_to_optimise, body.goal, needs_ventilation)
assert input_measures, "Expected measures to optimise"
assert len(input_measures) == 7
fixed_gain = optimiser_functions.calculate_fixed_gain(
property_required_measures, recommendations, p, needs_ventilation
)
assert fixed_gain == 0, "No required measures should mean fixed gain is 0"
gain = optimiser_functions.calculate_gain(body=body, p=p, fixed_gain=fixed_gain)
assert gain == 18.5, "Expected gain to be calculated correctly based on fixed gain and SAP target"
optimiser = (
GainOptimiser(
input_measures, max_cost=body.budget, max_gain=gain,
allow_slack=body.goal == "Increasing EPC"
) if body.budget else CostOptimiser(input_measures, min_gain=gain)
)
optimiser.setup()
optimiser.solve()
solution = optimiser.solution
assert solution, "Optimiser should return a non-empty solution"
assert all("id" in m for m in solution)
assert any("solar_pv" in m["type"] for m in solution), "Expected solar PV to be included"
# Collect selected measure IDs
selected = {r["id"] for r in solution}
assert selected == {'8_phase=7', '5_phase=4', '7_phase=6'}
# Add required measures (none here)
solution = optimiser_functions.add_required_measures(
property_id=p.id, property_required_measures=property_required_measures,
recommendations=recommendations, selected=selected,
)
assert solution == [
{'id': '5_phase=4', 'cost': 58.8, 'gain': 2, 'type': 'low_energy_lighting'},
{'id': '7_phase=6', 'cost': 30.0, 'gain': np.float64(3.6), 'type': 'secondary_heating'},
{'id': '8_phase=7', 'cost': 6013.139999999999, 'gain': np.float64(13.0), 'type': 'solar_pv'}
]
total_optimised_gain = sum(m["gain"] for m in solution)
assert total_optimised_gain == 18.6, "Total gain of optimised measures should meet or exceed target gain"
selected = optimiser_functions.add_best_practice_measures(p.id, solution, recommendations, selected)
# Flatten recommendations for output
flattened = optimiser_functions.flatten_recommendations_with_defaults(p.id, recommendations, selected)
# ---------------------
# FINAL ASSERTIONS
# ---------------------
assert isinstance(flattened, list)
assert all("default" in rec for rec in flattened)
assert any(rec["default"] for rec in flattened), "Some measures should be marked as default"
# We don't add ventilation as major insulation work isn't done
ventilation_added = any(rec["recommendation_id"] == "3_phase=2" and rec["default"] for rec in flattened)
assert not ventilation_added, "Ventilation should not be added without major insulation work"