diff --git a/recommendations/optimiser/GainOptimiser.py b/recommendations/optimiser/GainOptimiser.py index 94e022da..9c291313 100644 --- a/recommendations/optimiser/GainOptimiser.py +++ b/recommendations/optimiser/GainOptimiser.py @@ -14,7 +14,7 @@ class GainOptimiser: self, components: list[list[Mapping[str, int | float | str]]], max_cost: float | int, - max_gain: float | int, + max_gain: float | int | None, allow_slack: bool = True, verbose: bool = False ): diff --git a/recommendations/tests/test_optimisers.py b/recommendations/tests/test_optimisers.py index 0c794119..5a4df160 100644 --- a/recommendations/tests/test_optimisers.py +++ b/recommendations/tests/test_optimisers.py @@ -1,76 +1,34 @@ import pytest -from recommendations.optimiser.funding_optimiser import build_heat_pump_paths -from recommendations.optimiser.funding_optimiser import run_optimizer +from recommendations.optimiser.funding_optimiser import ( + build_heat_pump_paths, + run_optimizer, +) -class DummyProp: - """Minimal property stub exposing just what your code reads.""" - - def __init__(self): - self.data = { - "current-energy-rating": "E", # or "D" for the special Social+D path - "current-energy-efficiency": 55, # numeric SAP points used in eligibility calc - "mainheat-energy-eff": "Very Good", - } - self.has_ventilation = False - self.floor_area = 70.0 - self.main_heating_controls = {"clean_description": "time and temperature zone control"} - self.walls = {'original_description': 'Solid brick, as built, no insulation (assumed)', - 'thermal_transmittance': None, - 'thermal_transmittance_unit': None, 'is_cavity_wall': False, 'is_filled_cavity': False, - 'is_solid_brick': True, - 'is_system_built': False, 'is_timber_frame': False, 'is_granite_or_whinstone': False, - 'is_as_built': True, - 'is_cob': False, 'is_assumed': True, 'is_sandstone_or_limestone': False, - 'insulation_thickness': 'none', - 'external_insulation': False, 'internal_insulation': False} - - self.main_heating = { - 'original_description': 'Boiler and radiators, mains gas', - 'clean_description': 'Boiler and radiators, mains gas', - 'has_radiators': True, 'has_fan_coil_units': False, 'has_pipes_in_screed_above_insulation': False, - 'has_pipes_in_insulated_timber_floor': False, 'has_pipes_in_concrete_slab': False, 'has_boiler': True, - 'has_air_source_heat_pump': False, 'has_room_heaters': False, 'has_electric_storage_heaters': False, - 'has_warm_air': False, 'has_electric_underfloor_heating': False, 'has_electric_ceiling_heating': False, - 'has_community_scheme': False, 'has_ground_source_heat_pump': False, 'has_no_system_present': False, - 'has_portable_electric_heaters': False, 'has_water_source_heat_pump': False, 'has_electric_heat_pump': - False, - 'has_micro-cogeneration': False, 'has_solar_assisted_heat_pump': False, 'has_exhaust_source_heat_pump': - False, - 'has_community_heat_pump': False, 'has_hot-water-only': False, 'has_electric': False, 'has_mains_gas': - True, - 'has_wood_logs': False, 'has_coal': False, 'has_oil': False, 'has_wood_pellets': False, - 'has_anthracite': False, - 'has_dual_fuel_mineral_and_wood': False, 'has_smokeless_fuel': False, 'has_lpg': False, 'has_b30k': False, - 'has_mineral_and_wood': False, 'has_dual_fuel_appliance': False, 'has_assumed': False, - 'has_electricaire': False, - 'has_assumed_for_most_rooms': False, 'has_underfloor_heating': False - } - - self.main_fuel = { - 'original_description': 'mains gas (not community)', 'clean_description': 'Mains gas not community', - 'fuel_type': 'mains gas', 'tariff_type': None, 'is_community': False, - 'no_individual_heating_or_community_network': False, 'complex_fuel_type': None - } - - -@pytest.fixture -def p(): - return DummyProp() - +# --------------------------------------------------------------------- +# Heat pump path tests (unchanged – these are fine) +# --------------------------------------------------------------------- def test_build_heat_pump_paths(): eg1 = build_heat_pump_paths([], ["loft_insulation"]) - assert eg1 == [{'AND': ['loft_insulation', 'air_source_heat_pump']}] - eg2 = build_heat_pump_paths(["internal_wall_insulation", "external_wall_insulation"], ["loft_insulation"]) + eg2 = build_heat_pump_paths( + ["internal_wall_insulation", "external_wall_insulation"], + ["loft_insulation"], + ) - assert eg2 == [{'AND': ['internal_wall_insulation', 'loft_insulation', 'air_source_heat_pump']}, - {'AND': ['external_wall_insulation', 'loft_insulation', 'air_source_heat_pump']}] + assert eg2 == [ + {'AND': ['internal_wall_insulation', 'loft_insulation', 'air_source_heat_pump']}, + {'AND': ['external_wall_insulation', 'loft_insulation', 'air_source_heat_pump']}, + ] +# --------------------------------------------------------------------- +# run_optimizer tests +# --------------------------------------------------------------------- + def test_run_optimizer_empty_input(): solution, cost, gain = run_optimizer([]) assert solution is None @@ -78,134 +36,158 @@ def test_run_optimizer_empty_input(): assert gain == 0.0 -def test_uses_gain_optimiser_when_budget_provided(monkeypatch): - captured_args = {} +# --------------------------------------------------------------------- +# StrategicOptimiser mocking boundary +# --------------------------------------------------------------------- - class FakeGainOptimiser: - def __init__(self, measures, max_cost, max_gain, allow_slack): - captured_args["measures"] = measures - captured_args["max_cost"] = max_cost - captured_args["max_gain"] = max_gain - captured_args["allow_slack"] = allow_slack - self.solution = [{"cost": 100}] +def test_budget_and_target_are_passed_correctly(monkeypatch): + captured = {} + + class FakeStrategicOptimiser: + def __init__( + self, + components, + budget=None, + target_gain=None, + allow_slack=False, + verbose=False, + ): + captured["components"] = components + captured["budget"] = budget + captured["target_gain"] = target_gain + captured["allow_slack"] = allow_slack + + self.solution = [{"cost": 100, "gain": 5}] + self.solution_cost = 100 self.solution_gain = 5 - def setup(self): - pass - def solve(self): pass monkeypatch.setattr( - "recommendations.optimiser.funding_optimiser.GainOptimiser", - FakeGainOptimiser + "recommendations.optimiser.funding_optimiser.StrategicOptimiser", + FakeStrategicOptimiser, ) - measures = [[{"cost": 100, "gain": 5}]] - solution, cost, gain = run_optimizer( - measures, + [[{"cost": 100, "gain": 5}]], budget=500, sub_target_gain=10, - allow_slack=True + allow_slack=True, ) - assert captured_args["max_cost"] == 500 - assert captured_args["max_gain"] == 10 - assert captured_args["allow_slack"] is True + assert captured["budget"] == 500 + assert captured["target_gain"] == 10 + assert captured["allow_slack"] is True + assert cost == 100 assert gain == 5 + assert solution == [{"cost": 100, "gain": 5}] -def test_sub_target_gain_zero_sets_max_gain_zero(monkeypatch): - captured_args = {} +def test_sub_target_gain_zero_is_passed_as_zero(monkeypatch): + captured = {} - class FakeGainOptimiser: - def __init__(self, measures, max_cost, max_gain, allow_slack): - captured_args["max_gain"] = max_gain + class FakeStrategicOptimiser: + def __init__( + self, + components, + budget=None, + target_gain=None, + allow_slack=False, + verbose=False, + ): + captured["target_gain"] = target_gain self.solution = [] - self.solution_gain = 0 - - def setup(self): - pass + self.solution_cost = 0.0 + self.solution_gain = 0.0 def solve(self): pass monkeypatch.setattr( - "recommendations.optimiser.funding_optimiser.GainOptimiser", - FakeGainOptimiser + "recommendations.optimiser.funding_optimiser.StrategicOptimiser", + FakeStrategicOptimiser, ) - measures = [[{"cost": 100, "gain": 5}]] - run_optimizer( - measures, + [[{"cost": 100, "gain": 5}]], budget=500, - sub_target_gain=0 + sub_target_gain=0, ) - assert captured_args["max_gain"] == 0 + assert captured["target_gain"] == 0 -def test_sub_target_gain_none_sets_max_gain_infinity(monkeypatch): - captured_args = {} +def test_sub_target_gain_none_becomes_infinity(monkeypatch): + captured = {} - class FakeGainOptimiser: - def __init__(self, measures, max_cost, max_gain, allow_slack): - captured_args["max_gain"] = max_gain + class FakeStrategicOptimiser: + def __init__( + self, + components, + budget=None, + target_gain=None, + allow_slack=False, + verbose=False, + ): + captured["target_gain"] = target_gain self.solution = [] - self.solution_gain = 0 - - def setup(self): - pass + self.solution_cost = 0.0 + self.solution_gain = 0.0 def solve(self): pass monkeypatch.setattr( - "recommendations.optimiser.funding_optimiser.GainOptimiser", - FakeGainOptimiser + "recommendations.optimiser.funding_optimiser.StrategicOptimiser", + FakeStrategicOptimiser, ) - measures = [[{"cost": 100, "gain": 5}]] - run_optimizer( - measures, + [[{"cost": 100, "gain": 5}]], budget=500, - sub_target_gain=None + sub_target_gain=None, ) - assert captured_args["max_gain"] == float("inf") + assert captured["target_gain"] == None -def test_uses_cost_optimiser_when_no_budget(monkeypatch): - captured_args = {} +def test_target_only_case(monkeypatch): + captured = {} - class FakeCostOptimiser: - def __init__(self, measures, min_gain): - captured_args["min_gain"] = min_gain - self.solution = [{"cost": 50}] + class FakeStrategicOptimiser: + def __init__( + self, + components, + budget=None, + target_gain=None, + allow_slack=False, + verbose=False, + ): + captured["budget"] = budget + captured["target_gain"] = target_gain + + self.solution = [{"cost": 50, "gain": 10}] + self.solution_cost = 50 self.solution_gain = 10 - def setup(self): - pass - def solve(self): pass monkeypatch.setattr( - "recommendations.optimiser.funding_optimiser.CostOptimiser", - FakeCostOptimiser + "recommendations.optimiser.funding_optimiser.StrategicOptimiser", + FakeStrategicOptimiser, ) - measures = [[{"cost": 50, "gain": 10}]] - solution, cost, gain = run_optimizer( - measures, - sub_target_gain=10 + [[{"cost": 50, "gain": 10}]], + sub_target_gain=10, ) - assert captured_args["min_gain"] == 10 + assert captured["budget"] is None + assert captured["target_gain"] == 10 + assert cost == 50 assert gain == 10 + assert solution == [{"cost": 50, "gain": 10}]