diff --git a/README.md b/README.md index c5831350e..eb6211c21 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` 2.5 s, bounded by the deadline, `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` 1 s on the joint path, `CONTINUITY_SPLIT_TIME_LIMIT` 2.5 s on a split, both 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 b10d9d928..7bb16a8da 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -160,12 +160,16 @@ 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 -# 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 +# solve time above which the continuity stage is not tried at all, and the clock the candidate +# gets on the joint path, see _continuity_clock. The joint path is 97 % of the traffic and a tenth +# of a second there is replicas: one cap for every path at 2.5 s lifted its mean solve from 0.57 +# to 0.67 s within minutes, for a split improvement rate of 6.4 instead of 4.9 %. +CONTINUITY_TIME_LIMIT = 1.0 +# clock the candidate gets on a split. That request has paid for a probe that proved nothing and +# ends around 5 to 6 s of the 10 s limit, so 2.5 s fits inside what is left, and 1 s was too short: +# every split that ran the stage at 1 s ended at the cap, where the stored #170 request needs 1.1 s. +# Both bounded by the request deadline. +CONTINUITY_SPLIT_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 @@ -1056,7 +1060,8 @@ def _continuity_clock(self, deadline: float | None) -> float | None: if spent > CONTINUITY_TIME_LIMIT: self.continuity_stage = f'skipped, solve took {spent:.1f} s' return None - remaining = CONTINUITY_TIME_LIMIT if deadline is None else min(CONTINUITY_TIME_LIMIT, deadline - time.monotonic()) + cap = CONTINUITY_TIME_LIMIT if self.solve_path == 'joint' else CONTINUITY_SPLIT_TIME_LIMIT + remaining = cap if deadline is None else min(cap, deadline - time.monotonic()) if remaining <= 0: self.continuity_stage = 'no time' return None diff --git a/tests/test_continuity.py b/tests/test_continuity.py index c3d4b6b7d..8f46b6c07 100644 --- a/tests/test_continuity.py +++ b/tests/test_continuity.py @@ -6,7 +6,7 @@ import pulp import pytest -from optimizer.optimizer import CONTINUITY_TIME_LIMIT, BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData +from optimizer.optimizer import CONTINUITY_SPLIT_TIME_LIMIT, CONTINUITY_TIME_LIMIT, BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData def build(strategy: str = 'none') -> Optimizer: @@ -85,6 +85,18 @@ def timed(tmpdir: str, deadline: float | None) -> None: assert ('continuity' in model.stage_seconds) == expected_stage.startswith('improved') +@pytest.mark.parametrize('path, cap', [('joint', CONTINUITY_TIME_LIMIT), ('split', CONTINUITY_SPLIT_TIME_LIMIT)]) +def test_split_gets_the_longer_continuity_clock(path: str, cap: float): + # the joint path is most of the traffic, the split has paid for a probe that proved nothing + # and has clock left; both stay inside the deadline + model = build() + model.solve_path = path + model.stage_seconds = {'probe': 0.5, 'cost': 0.5} + + assert model._continuity_clock(None) == cap + assert model._continuity_clock(time.monotonic() + 0.5) == pytest.approx(0.5, abs=0.05) + + @pytest.mark.parametrize('schedule', [(0, 500, 0, 500, 0, 500), (0, 500, 500, 500, 0, 0)]) def test_running_session_is_not_interrupted(monkeypatch: pytest.MonkeyPatch, schedule: tuple[float, ...]): model = build()