Skip to content

Stop the cost stage starving the tie break, and give it a floor that always fits - #140

Open
andig wants to merge 4 commits into
mainfrom
fix/reserve-the-tie-break-slice
Open

Stop the cost stage starving the tie break, and give it a floor that always fits#140
andig wants to merge 4 commits into
mainfrom
fix/reserve-the-tie-break-slice

Conversation

@andig

@andig andig commented Aug 21, 2026

Copy link
Copy Markdown
Member

Peak levelling lives entirely in the tie break stage, so when that stage does not run the strategy silently does nothing. The schedule comes back cost optimal, the status says Feasible, and only the shape of the profile gives it away.

Found on a captured 245 step attenuate_grid_peaks request: it returned a 1591 W import peak and a 3609 W export peak, against the 200 W / 1606 W the same model reaches at the same money with enough clock.

What was wrong

The shared clock was budgeted three different ways, and only the probe got a real share:

stage budget
probe time_limit * PROBE_SHARE — a fixed slice
cost deadline - noweverything left
tie break min(leftovers, time_limit * PREFERENCE_TIME_SHARE) — a ceiling on leftovers

The constant's own comment already stated the intent — "the cost stage keeps the rest", meaning the rest after the tie break's share — but that subtraction was never written. The cost stage is anytime branch and bound, so on a request it cannot close it spends every second it is offered, which is exactly the shape of request the tie break matters on. It reached 'no time'.

On the captured request the cost stage also stopped 4 cents short of the optimum, four times gap_abs, confirming it stopped on the clock rather than on its gap.

What changed

Two changes doing different jobs:

  • The reserve. The cost stage now has the tie break's slice taken off its budget up front, and PREFERENCE_TIME_SHARE goes 0.25 -> 0.4. A reserve too small to seat the stage is worse than none — it is idle time the cost stage could have used — and the MILP tie break needs 1.6 s on a model this size.
  • The floor. The tie break runs a cheap solve first: pin the binaries the cost stage already chose, leaving only the continuous variables free. That is a linear program, 0.03 s mean and 0.165 s worst over the stored cases, so it runs whatever the clock says. The MILP then gets to beat it on the reserved slice, and whichever is ahead is kept.

preference_stage now names both solves, so a starved stage is legible in the logs instead of having to be inferred from the profile.

Measurements

20 cases × 3 time limits × 4 configurations. Money is unchanged everywhere — worst delta 0.000000 — and p95 latency stays at 0.52 s.

Captured request, peak per levelled side [W], optimum is 200 / 1606:

time limit main reserve only reserve + floor
3 s 1417 / 3609 1286 / 1606 1286 / 1606
5 s 1417 / 3609 1286 / 1606 200 / 1606
10 s 200 / 1606 200 / 1606 200 / 1606

The floor is what recovers the export side at every limit; the reserve is what seats the MILP and recovers the import side once there is clock for it. 0.5 was measured too and buys nothing over 0.4.

Production runs OPTIMIZER_TIME_LIMIT=10 on a fractional core, so the 3–5 s local rows are the regime that matters, not the 10 s one.

Tests

Every stored case is proved outright by the probe, so none of them ever reached the tie break's own budget — all 19 return an identical profile at every time limit and every clock split tried. They gave no signal on this bug at all, so the captured request is added as 028-attenuate-grid-peaks-long-horizon.

It is not marked strict: the default comparison covers status and objective value, and the tie break is cost neutral by contract, so the objective is identical whether the profile came back levelled or not. Pinning 245 steps of schedule would only make the case brittle across CBC builds without testing the thing that matters. Two tests assert the peaks instead, per levelled side and never max()ed across them — a max() lets a regression on one side hide behind the larger of the two, which is how the import peak went unnoticed at six times its optimum.

  • test_the_long_horizon_case_levels_both_sides — pins the stored answer on the joint path.
  • test_the_long_horizon_case_still_levels_once_the_solve_splits — forces the split and asserts the profile is levelled at all. Thresholds sit between the levelled and unlevelled values rather than on a schedule, so the test says which of the two came back and stays true on a machine too slow to finish the MILP stage.
  • test_the_cost_stage_leaves_the_tie_break_its_slice — the starvation itself, asserted on the budget offered rather than on a slow model.
  • test_the_lp_floor_decides_the_tie_with_no_clock_left — with the deadline already gone, the floor still improves the preferences.
  • test_preferences_are_not_paid_for_with_money and test_a_preference_stage_without_an_integer_solution_is_not_kept updated for the two solve stage. The second now covers the floor refusing a relaxation as well.

134 passed. Verified the five new tests fail against main — a regression test that passes either way is worth nothing.

Notes for review

  • The reserve is not free on one path. When the cost stage returns nothing at all, _probe_then_split falls back to the probe's schedule and never calls _solve_preferences, so the reserved 40% goes unused and that path now has less clock than before. It is not a lost answer — the probe's schedule is a joint solve result and carries preferences — but it is a real trade. Not observed on the corpus: Feasible counts are unchanged or better at every limit.
  • The warm start stays disabled. The comment blamed CBC 2.10.3, which is what PuLP bundles, but the image installs 2.10.10 and that binary has not been checked. Enabling it cut the MILP stage 1.61 s → 1.13 s on brew's 2.10.13 with an identical answer, so it is worth a look — separately, and against the binary we actually ship. Comment updated to say so.
  • The LP floor runs even once the deadline has passed, so worst case latency can exceed OPTIMIZER_TIME_LIMIT by up to LP_PREFERENCE_TIME_LIMIT (1 s). Measured worst is 0.165 s, and gunicorn's --timeout is 40 s, but it is a deliberate choice: without it a request that spent its whole clock on the money gets no strategy at all.

cc @ekkea

🤖 Generated with Claude Code

andig and others added 2 commits August 21, 2026 12:11
…that always fits

Peak levelling lives entirely in the tie break stage, so when that stage does not run the
strategy silently does nothing: the schedule comes back cost optimal, the status says
Feasible, and only the shape of the profile gives it away. On a captured 245 step
attenuate_grid_peaks request it returned a 1591 W import peak and a 3609 W export peak
against the 200 W / 1606 W the same model reaches with enough clock.

The clock was budgeted three different ways and only the probe got a real share.
PROBE_SHARE is a fixed slice off time_limit, the cost stage took `deadline - now`, all of
it, and the tie break got `min(leftovers, time_limit * PREFERENCE_TIME_SHARE)`. The
constant's own comment already said "the cost stage keeps the rest", meaning the rest
after the tie break's share, but that subtraction was never written. The cost stage is
anytime branch and bound, so on a request it cannot close it spends every second offered,
which is exactly the shape of request the tie break matters on. It reached 'no time'.

Two changes, doing different jobs:

  - the cost stage now has the tie break's slice taken off its budget up front, and
    PREFERENCE_TIME_SHARE goes 0.25 -> 0.4. A reserve too small to seat the stage is worse
    than none, it is idle time the cost stage could have used, and the MILP tie break needs
    1.6 s on a model this size.

  - the tie break runs a cheap floor first: pin the binaries the cost stage already chose,
    leaving only the continuous variables free, which is a linear program and lands in
    0.03 s mean / 0.165 s worst over the stored cases. It runs whatever the clock says. The
    MILP then gets to beat it on the reserved slice, and whichever is ahead is kept.

Measured over 20 cases at three time limits. Money is unchanged everywhere, worst delta
0.000000, and p95 latency stays at 0.52 s. On the captured request, per levelled side:

    time limit    main            reserve only     reserve + floor
    3 s           1417 / 3609     1286 / 1606      1286 / 1606
    5 s           1417 / 3609     1286 / 1606       200 / 1606
    10 s           200 / 1606      200 / 1606       200 / 1606

The floor is what recovers the export side at every limit; the reserve is what seats the
MILP and recovers the import side once there is clock for it. 0.5 was measured too and
buys nothing over 0.4.

preference_stage now names both solves, so a starved stage is legible in the logs rather
than having to be inferred from the profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every stored case is proved outright by the probe, so none of them ever reaches the tie
break's own budget, which is where the levelling was being lost. All 19 return an identical
profile at every time limit and every clock split tried - they gave no signal at all on the
bug this branch fixes.

028 is the captured request: 245 steps, 4450 variables, 1263 binaries, attenuate_grid_peaks
over both sides. Not marked strict. The default comparison covers status and objective
value, and the tie break is cost neutral by contract, so the objective is identical whether
the profile came back levelled or not - pinning 245 steps of schedule would only make the
case brittle across CBC builds without testing the thing that matters.

The peaks are what is worth asserting, so two tests in test_peak_leveling.py do it per
levelled side, never max()ed across them: a max() lets a regression on one side hide behind
the larger of the two, which is how the import peak went unnoticed at six times its optimum.

  - test_the_long_horizon_case_levels_both_sides pins the stored answer on the joint path.
  - test_the_long_horizon_case_still_levels_once_the_solve_splits forces the split and
    asserts the profile is levelled at all. The thresholds sit between the levelled and the
    unlevelled value rather than on a schedule, so the test says which of the two came back
    and stays true on a machine too slow to finish the MILP stage.

Verified against main: the split test and the four other new ones fail there and pass here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
andig and others added 2 commits August 23, 2026 11:18
evcc sends User-Agent evcc/<version> on every request. With it on the
solve line, a change in the solve mix or the latency can be read against
the release that sent the requests, and the version spread of the
installed base is one query away.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
andig added a commit that referenced this pull request Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant