|
| 1 | +import pulp |
| 2 | +import pytest |
| 3 | +from test_objective_split import build |
| 4 | + |
| 5 | +from optimizer.optimizer import INTEGRALITY_TOLERANCE |
| 6 | + |
| 7 | +# small enough to solve in well under a second, and it has binaries to go fractional: the c_min |
| 8 | +# gate of the early charging case is exactly the rule a relaxed binary stops enforcing |
| 9 | +CASE = '012-early-charging-not-perfect' |
| 10 | + |
| 11 | + |
| 12 | +def binaries(optimizer): |
| 13 | + # pulp stores a binary as an integer bounded to 0 and 1, LpBinary never survives on a variable |
| 14 | + return [var for var in optimizer.problem.variables() if var.cat == pulp.LpInteger] |
| 15 | + |
| 16 | + |
| 17 | +def relax(optimizer, value=0.3): |
| 18 | + """Scribble a relaxation over the binaries, the way a stage that found no integer solution |
| 19 | + leaves them behind.""" |
| 20 | + for var in binaries(optimizer): |
| 21 | + var.varValue = value |
| 22 | + |
| 23 | + |
| 24 | +def test_a_preference_stage_without_an_integer_solution_is_not_kept(monkeypatch): |
| 25 | + # the second stage decides whether to keep its result by reading the variables, and a solver |
| 26 | + # that ran out of clock before it found an integer solution leaves the relaxation in them. That |
| 27 | + # point scores better on the preferences than any real schedule, because it is one the model |
| 28 | + # forbids, so it has to be refused on the status rather than on its score. |
| 29 | + optimizer = build(CASE) |
| 30 | + optimizer.settings.probe_seconds = 0 |
| 31 | + optimizer.create_model() |
| 32 | + |
| 33 | + real_solve = optimizer.problem.solve |
| 34 | + calls = [] |
| 35 | + |
| 36 | + def solve(*args, **kwargs): |
| 37 | + calls.append(1) |
| 38 | + if len(calls) == 1: # the cost stage, left alone |
| 39 | + return real_solve(*args, **kwargs) |
| 40 | + relax(optimizer) # the preference stage, out of time and empty handed |
| 41 | + optimizer.problem.status = pulp.LpStatusNotSolved |
| 42 | + optimizer.problem.sol_status = pulp.LpSolutionNoSolutionFound |
| 43 | + return optimizer.problem.status |
| 44 | + |
| 45 | + monkeypatch.setattr(optimizer.problem, 'solve', solve) |
| 46 | + optimizer.solve() |
| 47 | + |
| 48 | + assert len(calls) == 2, f'the preference stage did not run, {len(calls)} solves' |
| 49 | + assert optimizer.preference_stage.endswith('kept the first stage'), \ |
| 50 | + f'preference stage ended as {optimizer.preference_stage}' |
| 51 | + for var in binaries(optimizer): |
| 52 | + assert min(abs(var.varValue), abs(var.varValue - 1)) <= INTEGRALITY_TOLERANCE, \ |
| 53 | + f'{var.name} came back at {var.varValue}' |
| 54 | + |
| 55 | + |
| 56 | +def test_a_fractional_solution_is_reported_as_no_schedule(monkeypatch): |
| 57 | + # the last line of defence, standing in for every stage above deciding correctly. A schedule |
| 58 | + # that breaks the model is worse than no schedule: it looks like an answer and the caller |
| 59 | + # charges a battery by it. Here the solve is faked wholesale, a solver claiming a solution it |
| 60 | + # does not have. |
| 61 | + optimizer = build(CASE) |
| 62 | + optimizer.create_model() |
| 63 | + |
| 64 | + def probe_then_split(tmpdir, deadline): |
| 65 | + relax(optimizer) |
| 66 | + optimizer.problem.status = pulp.LpStatusOptimal |
| 67 | + optimizer.problem.sol_status = pulp.LpSolutionIntegerFeasible |
| 68 | + |
| 69 | + monkeypatch.setattr(optimizer, '_probe_then_split', probe_then_split) |
| 70 | + result = optimizer.solve() |
| 71 | + |
| 72 | + assert result['status'] == 'Not Solved', f"reported {result['status']}" |
| 73 | + assert result['objective_value'] is None |
| 74 | + assert result['batteries'] == [] |
| 75 | + |
| 76 | + |
| 77 | +@pytest.mark.parametrize('value', [0.0, 1.0, INTEGRALITY_TOLERANCE / 2, 1 - INTEGRALITY_TOLERANCE / 2]) |
| 78 | +def test_a_solution_on_the_integers_passes(value): |
| 79 | + # the guard must not fire on CBC's own rounding, which it reports within its integer tolerance |
| 80 | + optimizer = build(CASE) |
| 81 | + optimizer.create_model() |
| 82 | + relax(optimizer, value) |
| 83 | + assert optimizer._is_integral() |
0 commit comments