diff --git a/README.md b/README.md index 5d12369a4..c5831350e 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 3a88ed489..b10d9d928 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -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 @@ -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). diff --git a/tests/test_continuity.py b/tests/test_continuity.py index 4202c0825..c3d4b6b7d 100644 --- a/tests/test_continuity.py +++ b/tests/test_continuity.py @@ -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)])