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
Open
Stop the cost stage starving the tie break, and give it a floor that always fits#140andig wants to merge 4 commits into
andig wants to merge 4 commits into
Conversation
…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>
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
# Conflicts: # tests/test_app.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_peaksrequest: 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:
time_limit * PROBE_SHARE— a fixed slicedeadline - now— everything leftmin(leftovers, time_limit * PREFERENCE_TIME_SHARE)— a ceiling on leftoversThe 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:
PREFERENCE_TIME_SHAREgoes0.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.preference_stagenow 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:
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.5was measured too and buys nothing over0.4.Production runs
OPTIMIZER_TIME_LIMIT=10on 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 nevermax()ed across them — amax()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_moneyandtest_a_preference_stage_without_an_integer_solution_is_not_keptupdated 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
_probe_then_splitfalls 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:Feasiblecounts are unchanged or better at every limit.OPTIMIZER_TIME_LIMITby up toLP_PREFERENCE_TIME_LIMIT(1 s). Measured worst is 0.165 s, and gunicorn's--timeoutis 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