mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
572 lines
30 KiB
Python
572 lines
30 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
|
|
from recommendations.optimiser.StrategicOptimiser import StrategicOptimiser, Strategies
|
|
|
|
|
|
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_returns_zero_for_already_installed_getting_to_target(self):
|
|
body = SimpleNamespace(goal="Increasing EPC", goal_value="C")
|
|
p = SimpleNamespace(data={"current-energy-efficiency": "67"}, id=1)
|
|
fixed_gain = 0
|
|
eco_packages = {1: (None, None, None, [])}
|
|
already_installed_sap = 2
|
|
gain = optimiser_functions.calculate_gain(
|
|
body=body,
|
|
p=p,
|
|
fixed_gain=fixed_gain,
|
|
eco_packages=eco_packages,
|
|
already_installed_gain=already_installed_sap
|
|
)
|
|
|
|
assert gain == 0
|
|
|
|
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 == 17.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 == 17.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 == {'7_phase=6', '5_phase=4', '10_phase=7'}
|
|
assert float(optimiser.solution_gain) == 17.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': '10_phase=7', 'cost': 5826.491999999999, 'gain': np.float64(12.0), 'type': 'solar_pv'}
|
|
]
|
|
|
|
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)
|
|
|
|
# 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"
|
|
|
|
|
|
class TestStrategicOptimiser:
|
|
|
|
@pytest.fixture
|
|
def components(self):
|
|
components = [
|
|
[
|
|
{'id': '0_phase=0', 'cost': 819.0, 'gain': 5.6, 'type': 'loft_insulation', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 819.0, 'raw_cost': 819.0, 'partial_project_funding': 0,
|
|
'partial_project_score': 0, 'uplift_project_score': 0, 'already_installed': False,
|
|
'has_battery': False, 'array_size': 0},
|
|
{'id': '1_phase=0', 'cost': 702.0, 'gain': 5.6, 'type': 'loft_insulation', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 702.0, 'raw_cost': 702.0, 'partial_project_funding': 0,
|
|
'partial_project_score': 0, 'uplift_project_score': 0, 'already_installed': False,
|
|
'has_battery': False, 'array_size': 0},
|
|
{'id': '2_phase=0', 'cost': 585.0, 'gain': 5.6, 'type': 'loft_insulation', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 585.0, 'raw_cost': 585.0, 'partial_project_funding': 0,
|
|
'partial_project_score': 0, 'uplift_project_score': 0, 'already_installed': False,
|
|
'has_battery': False, 'array_size': 0}],
|
|
[{'id': '4_phase=2', 'cost': 3656.25, 'gain': 2.0, 'type': 'suspended_floor_insulation',
|
|
'innovation_uplift': 0, 'cost_minus_uplift': 3656.25, 'raw_cost': 3656.25, 'partial_project_funding': 0,
|
|
'partial_project_score': 0, 'uplift_project_score': 0, 'already_installed': False, 'has_battery': False,
|
|
'array_size': 0}],
|
|
[{'id': '5_phase=3', 'cost': 17.5, 'gain': 1.0, 'type': 'low_energy_lighting', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 17.5, 'raw_cost': 17.5, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 0}],
|
|
[{'id': '6_phase=4', 'cost': 140, 'gain': 3.4, 'type': 'roomstat_programmer_trvs', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 140, 'raw_cost': 140, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 0},
|
|
{'id': '7_phase=4', 'cost': 874.5680000000001, 'gain': 4.2, 'type': 'time_temperature_zone_control',
|
|
'innovation_uplift': 0, 'cost_minus_uplift': 874.5680000000001, 'raw_cost': 874.5680000000001,
|
|
'partial_project_funding': 0, 'partial_project_score': 0, 'uplift_project_score': 0,
|
|
'already_installed': False, 'has_battery': False, 'array_size': 0}],
|
|
[{'id': '9_phase=6', 'cost': 5420.0, 'gain': 13.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5420.0, 'raw_cost': 5420.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.6},
|
|
{'id': '10_phase=6', 'cost': 6210.0, 'gain': 16.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6210.0, 'raw_cost': 6210.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.6,
|
|
'battery_gain': 3},
|
|
{'id': '11_phase=6', 'cost': 6820.0, 'gain': 16.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6820.0, 'raw_cost': 6820.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.6,
|
|
'battery_gain': 3},
|
|
{'id': '12_phase=6', 'cost': 7202.0, 'gain': 14.5, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 7202.0, 'raw_cost': 7202.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.915},
|
|
{'id': '13_phase=6', 'cost': 6495.0, 'gain': 14.5, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6495.0, 'raw_cost': 6495.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.92},
|
|
{'id': '14_phase=6', 'cost': 7285.0, 'gain': 17.5, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 7285.0, 'raw_cost': 7285.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.92,
|
|
'battery_gain': 3},
|
|
{'id': '15_phase=6', 'cost': 7895.0, 'gain': 17.5, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 7895.0, 'raw_cost': 7895.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.92,
|
|
'battery_gain': 3},
|
|
{'id': '16_phase=6', 'cost': 5520.0, 'gain': 15.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5520.0, 'raw_cost': 5520.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 4.0},
|
|
{'id': '17_phase=6', 'cost': 6310.0, 'gain': 18.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6310.0, 'raw_cost': 6310.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 4.0,
|
|
'battery_gain': 3},
|
|
{'id': '18_phase=6', 'cost': 6920.0, 'gain': 18.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6920.0, 'raw_cost': 6920.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 4.0,
|
|
'battery_gain': 3},
|
|
{'id': '19_phase=6', 'cost': 5320.0, 'gain': 12.1, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5320.0, 'raw_cost': 5320.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.2},
|
|
{'id': '20_phase=6', 'cost': 6110.0, 'gain': 14.1, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6110.0, 'raw_cost': 6110.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.2,
|
|
'battery_gain': 2},
|
|
{'id': '21_phase=6', 'cost': 6720.0, 'gain': 14.1, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6720.0, 'raw_cost': 6720.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.2,
|
|
'battery_gain': 2},
|
|
{'id': '22_phase=6', 'cost': 6932.0, 'gain': 13.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6932.0, 'raw_cost': 6932.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.48},
|
|
{'id': '23_phase=6', 'cost': 6295.0, 'gain': 13.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6295.0, 'raw_cost': 6295.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.48},
|
|
{'id': '24_phase=6', 'cost': 7085.0, 'gain': 16.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 7085.0, 'raw_cost': 7085.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.48,
|
|
'battery_gain': 3},
|
|
{'id': '25_phase=6', 'cost': 7695.0, 'gain': 16.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 7695.0, 'raw_cost': 7695.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': True, 'array_size': 3.48,
|
|
'battery_gain': 3},
|
|
{'id': '26_phase=6', 'cost': 5220.0, 'gain': 10.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5220.0, 'raw_cost': 5220.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.8},
|
|
{'id': '27_phase=6', 'cost': 6662.0, 'gain': 12.3, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6662.0, 'raw_cost': 6662.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.045},
|
|
{'id': '28_phase=6', 'cost': 6095.0, 'gain': 12.3, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6095.0, 'raw_cost': 6095.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 3.05},
|
|
{'id': '29_phase=6', 'cost': 5160.0, 'gain': 9.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5160.0, 'raw_cost': 5160.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.4},
|
|
{'id': '30_phase=6', 'cost': 6392.0, 'gain': 10.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6392.0, 'raw_cost': 6392.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.61},
|
|
{'id': '31_phase=6', 'cost': 5910.0, 'gain': 10.2, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5910.0, 'raw_cost': 5910.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.61},
|
|
{'id': '32_phase=6', 'cost': 5100.0, 'gain': 8.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5100.0, 'raw_cost': 5100.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.0},
|
|
{'id': '33_phase=6', 'cost': 6098.0, 'gain': 8.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 6098.0, 'raw_cost': 6098.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.175},
|
|
{'id': '34_phase=6', 'cost': 5725.0, 'gain': 8.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5725.0, 'raw_cost': 5725.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 2.18},
|
|
{'id': '35_phase=6', 'cost': 5040.0, 'gain': 6.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5040.0, 'raw_cost': 5040.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 1.6},
|
|
{'id': '36_phase=6', 'cost': 5828.0, 'gain': 7.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5828.0, 'raw_cost': 5828.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 1.74},
|
|
{'id': '37_phase=6', 'cost': 5540.0, 'gain': 7.0, 'type': 'solar_pv', 'innovation_uplift': 0,
|
|
'cost_minus_uplift': 5540.0, 'raw_cost': 5540.0, 'partial_project_funding': 0, 'partial_project_score': 0,
|
|
'uplift_project_score': 0, 'already_installed': False, 'has_battery': False, 'array_size': 1.74}
|
|
]
|
|
]
|
|
return components
|
|
|
|
def test_budget_and_target_gain_strategy_case_1_try_min_cost_with_constraints(self, components):
|
|
budget = 5000
|
|
target_gain = 11.5
|
|
|
|
opt = StrategicOptimiser(
|
|
components=components,
|
|
target_gain=target_gain,
|
|
budget=budget,
|
|
)
|
|
|
|
opt.solve()
|
|
|
|
# check strategy used
|
|
assert opt.strategy_used.value == "case_1_try_min_cost_with_constraints"
|
|
# Check the solution values
|
|
assert opt.solution_cost == 4398.75
|
|
assert opt.solution_gain == 12
|
|
|
|
def test_budget_and_target_gain_expecting_case_1_solve_max_gain_under_budget_strategy(self, components):
|
|
budget = 4000
|
|
target_gain = 11.5
|
|
|
|
opt = StrategicOptimiser(
|
|
components=components,
|
|
target_gain=target_gain,
|
|
budget=budget,
|
|
)
|
|
|
|
opt.solve()
|
|
|
|
# We expect to use case 1, but we won't be able to meet the target gain, so we should get the best solution
|
|
# possible within the budget. We end up with an infeasible solution when we try
|
|
# case_1_try_min_cost_with_constraints
|
|
assert opt.strategy_used.value == "case_1_solve_max_gain_under_budget"
|
|
assert opt.solution_cost == 1477.0680000000002
|
|
assert opt.solution_gain == 10.8
|
|
|
|
def test_just_gain_expecting_case_3_solve_min_cost_for_target_strategy(self, components):
|
|
budget = None
|
|
target_gain = 11.5
|
|
|
|
opt = StrategicOptimiser(
|
|
components=components,
|
|
target_gain=target_gain,
|
|
budget=budget,
|
|
)
|
|
|
|
opt.solve()
|
|
|
|
# Should be case 3 - minimise cost for target gain
|
|
assert opt.strategy_used.value == "case_3_solve_min_cost_for_target"
|
|
assert opt.solution_cost == 4398.75
|
|
assert opt.solution_gain == 12
|
|
|
|
def test_just_gain_of_20_expecting_case_3_solve_min_cost_for_target_strategy(self, components):
|
|
budget = None
|
|
target_gain = 20
|
|
|
|
opt = StrategicOptimiser(
|
|
components=components,
|
|
target_gain=target_gain,
|
|
budget=budget,
|
|
)
|
|
|
|
opt.solve()
|
|
|
|
# Should be case 3 - minimise cost for target gain
|
|
assert opt.strategy_used.value == "case_3_solve_min_cost_for_target"
|
|
assert opt.solution_cost == 5962.5
|
|
assert opt.solution_gain == 20.2
|
|
|
|
def test_just_budget_expecting_case_2_solve_max_gain_under_budget_strategy(self, components):
|
|
budget = 10000
|
|
target_gain = None
|
|
|
|
opt = StrategicOptimiser(
|
|
components=components,
|
|
target_gain=target_gain,
|
|
budget=budget,
|
|
)
|
|
|
|
opt.solve()
|
|
|
|
# Should be case 2 - minimise cost for target gain
|
|
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
|