Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ One request is up to five solver runs under one wall clock, `OPTIMIZER_TIME_LIMI
| `cost` | Money only, stopped on an absolute gap. Holds back a slice of the clock for the tie break instead of taking whatever is left. | The probe did not prove its answer: path `split`. | `OPTIMIZER_GAP_ABS` 0.01 currency, `COST_TIME_LIMIT` 3 s, `PREFERENCE_TIME_SHARE` 0.25 of the limit reserved |
| `tie_break`, LP | Pin the binaries the cost stage chose and move only the continuous variables, under a bound that keeps the cost found. Milliseconds, so it runs whatever the clock says. If CBC calls the bound infeasible the slack is widened tenfold per retry. | Path `split` and a strategy is configured. | `OPTIMIZER_PREFERENCE_BUDGET` 0, `COST_BOUND_SLACK` 1e-5 up to `COST_BOUND_SLACK_CEILING` 1e-2, `COST_BOUND_TOLERANCE` 1e-4, `LP_PREFERENCE_TIME_LIMIT` 1 s |
| `tie_break`, MILP | Search the whole model under the same bound to beat the LP. Whichever is ahead is returned. | After the LP, clock permitting. | The reserved slice, capped at `MILP_PREFERENCE_TIME_LIMIT` 2.5 s; uncapped without a time limit |
| `continuity` | Fewest charge starts for batteries with `c_min > 0`, bounded by the cost, the preference value and each levelled grid peak already reached. A preference, not a guarantee: prices, charge demands and grid shaping still win, and power may vary within a session. | A battery has more than one charging session, and the solve so far took less than the stage may spend. | `CONTINUITY_TIME_LIMIT` 1 s, `CONTINUITY_TOLERANCE` 1e-5 |
| `continuity` | Fewest charge starts for batteries with `c_min > 0`, bounded by the cost, the preference value and each levelled grid peak already reached. A preference, not a guarantee: prices, charge demands and grid shaping still win, and power may vary within a session. | A battery has more than one charging session, and the solve so far took less than the stage may spend. | `CONTINUITY_TIME_LIMIT` 2.5 s, bounded by the deadline, `CONTINUITY_TOLERANCE` 1e-5 |

A schedule the solver stopped on at the limit is reported as `Feasible` rather than `Optimal`. A solve that comes back off the integers is refused and reported as `Not Solved`.

Expand Down
11 changes: 8 additions & 3 deletions src/optimizer/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,12 @@ def _complete_solution(problem: pulp.LpProblem) -> bool:
# spent its whole clock on the money gets no strategy at all.
LP_PREFERENCE_TIME_LIMIT = 1.0

CONTINUITY_TIME_LIMIT = 1.0
# clock the continuity candidate may spend, and the solve time above which it is not tried at all,
# see _continuity_clock. Always bounded by the request deadline. 1 s let the stage run on split
# requests whose cost stage finished under a second, 45 % of them in production, and every one
# of those ended at the cap with nothing kept, where the stored #170 request needs 1.1 s. The
# production split ends around 5 to 6 s of the 10 s limit, so 2.5 s fits inside what is left.
CONTINUITY_TIME_LIMIT = 2.5
CONTINUITY_TOLERANCE = 1e-5

# a cbc on PATH is preferred over the one pulp bundles, which is 2.10.3 built Dec 2019 and gets a
Expand Down Expand Up @@ -1039,10 +1044,10 @@ def _continuity_clock(self, deadline: float | None) -> float | None:
"""Seconds the continuity candidate may spend, or None with continuity_stage saying why not.

The candidate is this model plus a start per step, under a bound on the cost it just
optimized, so it is the harder problem: it does not finish inside a second where the
optimized, so it is the harder problem: it does not finish inside the cap where the
incumbent took longer than that. Measured over five days of production, 14 % of joint
solves ran this stage and half of those sat on the cap with nothing to show (#146). The
incumbent's own clock says which ones they are before a second is spent: the probe on the
incumbent's own clock says which ones they are before the cap is spent: the probe on the
joint path, the cost stage on the split path. Not the probe there: it spent its clock on
the joint objective and failed, and alone it is PROBE_SHARE of the time limit, so counting
it skipped the stage on every split request (#170).
Expand Down
37 changes: 12 additions & 25 deletions tests/test_continuity.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,45 +57,32 @@ def test_equal_prices_prefer_one_session(monkeypatch: pytest.MonkeyPatch, probe_
assert model.stage_seconds['continuity'] >= 0


def test_hard_solve_skips_continuity(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize('stage_seconds, expected_starts, expected_stage', [
# the candidate cannot beat the clock of the solve that produced the incumbent, so a solve
# that already took longer than the stage may spend gets no candidate at all
model = build()
seed_fragmented(model, monkeypatch)
seeded = model._probe_then_split

def slow(tmpdir: str, deadline: float | None) -> None:
seeded(tmpdir, deadline)
model.stage_seconds['probe'] = CONTINUITY_TIME_LIMIT + 1

monkeypatch.setattr(model, '_probe_then_split', slow)

result = model.solve()

assert starts(result['batteries'][0]['charging_power']) == 3
assert model.continuity_stage.startswith('skipped')
assert 'continuity' not in model.stage_seconds


def test_split_solve_is_judged_by_its_cost_stage(monkeypatch: pytest.MonkeyPatch):
({'probe': CONTINUITY_TIME_LIMIT + 1}, 3, 'skipped'),
# on the split path the probe spent its clock on the joint objective and failed, so it says
# nothing about the candidate; the cost stage it extends does. Counting the probe skipped the
# stage on every split request, the probe alone being PROBE_SHARE of the time limit (#170)
({'probe': CONTINUITY_TIME_LIMIT + 1, 'cost': 0.1}, 1, 'improved 3 to 1'),
])
def test_continuity_is_gated_by_the_clock_of_the_stage_it_extends(
monkeypatch: pytest.MonkeyPatch, stage_seconds: dict[str, float], expected_starts: int, expected_stage: str):
model = build()
seed_fragmented(model, monkeypatch)
seeded = model._probe_then_split

def split(tmpdir: str, deadline: float | None) -> None:
def timed(tmpdir: str, deadline: float | None) -> None:
seeded(tmpdir, deadline)
model.stage_seconds['probe'] = CONTINUITY_TIME_LIMIT + 1
model.stage_seconds['cost'] = 0.1
model.stage_seconds.update(stage_seconds)

monkeypatch.setattr(model, '_probe_then_split', split)
monkeypatch.setattr(model, '_probe_then_split', timed)

result = model.solve()

assert starts(result['batteries'][0]['charging_power']) == 1
assert model.continuity_stage == 'improved 3 to 1'
assert starts(result['batteries'][0]['charging_power']) == expected_starts
assert model.continuity_stage.startswith(expected_stage)
assert ('continuity' in model.stage_seconds) == expected_stage.startswith('improved')


@pytest.mark.parametrize('schedule', [(0, 500, 0, 500, 0, 500), (0, 500, 500, 500, 0, 0)])
Expand Down
Loading