Skip to content

Commit ae2e77a

Browse files
committed
Merge remote-tracking branch 'origin/perf/two-stage-solve' into rollup/two-stage-plus-130
2 parents edd1759 + 8f75a4e commit ae2e77a

2 files changed

Lines changed: 117 additions & 1 deletion

File tree

src/optimizer/optimizer.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ def objective_scale(objective) -> float:
8787
# peak. A hundredth of a cent, three orders below the gap the cost stage may already leave.
8888
COST_BOUND_TOLERANCE = 1e-4
8989

90+
# how far off 0 or 1 a binary may land and still count as integral. CBC's own integer tolerance
91+
# defaults to 1e-6 and it rounds within that before reporting, so anything above this came back
92+
# from a relaxation rather than from a rounding difference.
93+
INTEGRALITY_TOLERANCE = 1e-5
94+
9095
# share of OPTIMIZER_TIME_LIMIT the probe gets before the solve falls back to the split. Kept well
9196
# under half: the probe is pure loss on a request that ends up splitting anyway, so it should be
9297
# long enough to catch the ordinary ones and no longer. Measured over the captured slow requests,
@@ -827,7 +832,17 @@ def _solve_preferences(self, tmpdir, deadline) -> None:
827832
# rather than read off the status, so a solver that reports the wrong one cannot spend
828833
# money.
829834
self.preference_stage = pulp.LpStatus[self.problem.status]
830-
improved = (pulp.value(self.preference_objective) > undecided
835+
# a stage that ran out of clock before it found an integer solution leaves the relaxation
836+
# in the variables, and pulp reads that back like any other result. It looks like a large
837+
# improvement precisely because it is one the model forbids: the binaries land between 0
838+
# and 1, and every rule they gate stops holding, c_min among them. Checked here beside the
839+
# other two conditions, for the same reason they are checked here rather than read off the
840+
# status: a solver that reports the wrong one must not be able to spend money, and it must
841+
# not be able to hand back a schedule the model does not allow either.
842+
integral = self.problem.sol_status in (pulp.LpSolutionOptimal,
843+
pulp.LpSolutionIntegerFeasible)
844+
improved = (integral
845+
and pulp.value(self.preference_objective) > undecided
831846
and pulp.value(self.cost_objective) >= cost - budget - COST_BOUND_TOLERANCE)
832847
if not improved:
833848
self.preference_stage += ', kept the first stage'
@@ -891,6 +906,16 @@ def _probe_then_split(self, tmpdir, deadline) -> None:
891906
var.varValue = value
892907
self.problem.status = pulp.LpStatusOptimal
893908

909+
def _is_integral(self) -> bool:
910+
"""Whether every integer variable of the current solution came back on a whole number.
911+
912+
pulp stores a binary as an integer bounded to 0 and 1, so LpBinary never appears on a
913+
variable and every gate in this model is covered by the integer category alone.
914+
"""
915+
return all(abs(var.varValue - round(var.varValue)) <= INTEGRALITY_TOLERANCE
916+
for var in self.problem.variables()
917+
if var.cat == pulp.LpInteger and var.varValue is not None)
918+
894919
def solve(self) -> Dict:
895920
"""
896921
Creates the MILP model if none exists and solves the optimization problem.
@@ -921,6 +946,14 @@ def solve(self) -> Dict:
921946
if status == 'Optimal' and self.problem.sol_status != pulp.LpSolutionOptimal:
922947
status = 'Feasible'
923948

949+
# last line of defence. Every stage above decides for itself whether to keep what came
950+
# back, so nothing should reach this point off the integers, but a schedule that breaks
951+
# the model is worse than no schedule: it looks like an answer, and the caller charges a
952+
# battery by it. One pass over the binaries against a solve measured in seconds.
953+
if status in ('Optimal', 'Feasible') and not self._is_integral():
954+
print("solver returned a fractional solution, reporting no schedule")
955+
status = 'Not Solved'
956+
924957
# grid import and export if no demand rate is active
925958
# if a limit is set and exceeded, this is the part that is actually imported / exported.
926959
# the exceeding portion is captured in 'e_imp_lim_exc' and / or 'e_exp_lim_exc'

tests/test_fractional_solution.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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

Comments
 (0)