Skip to content

Level the grid profile against a free level instead of its peak and ramp - #130

Open
andig wants to merge 2 commits into
mainfrom
feat/level-grid-profile-deviation
Open

Level the grid profile against a free level instead of its peak and ramp#130
andig wants to merge 2 commits into
mainfrom
feat/level-grid-profile-deviation

Conversation

@andig

@andig andig commented Aug 1, 2026

Copy link
Copy Markdown
Member

What this is

Follow-up to the question whether the peak shaving strategies carry the right incentive, i.e. minimum mean square deviation from constant grid import or export. They are close, but they miss one thing, and it is not a corner case.

attenuate_*_peaks priced two quantities: the horizon maximum, and the step to step ramp. Neither prices one time step against the rest.

  • the maximum is a single value. Once it is pinned by a load the schedule cannot touch, the term has nothing left to win below it
  • the ramp is a sum over transitions. Every path that rises monotonically between the same two levels carries the same sum, so it cannot tell a straight climb from a plateau

Where the maximum is pinned, the cheapest schedule is then to charge flat out against it until the goal is met and stop - the opposite of what the strategy promises. A 6 kW spike in step 2, an EV that needs 12 kWh:

main    [6000, 6000, 6000, 1044, 1044, 1044, 500, 500]   sd 2514 W
branch  [3026, 3026, 6000, 3026, 3026, 3026, 500, 500]   sd 1623 W

Same peak, same energy, same bill.

The change

Two commits. The first adds a third term per levelled side, the second removes the ramp, which measurement showed the new term makes redundant on every case the strategies are aimed at.

Deviation from a free level

p_dev[t] >= |p_grid[t] - p_lvl|, with p_lvl placed freely by the optimizer. That is the linear stand-in for the mean square deviation the strategies are meant to minimize - a square cannot go into a MILP, and a mean absolute deviation around a free level is the closest thing that can.

  • weight penalty_base * 1e-4, a decade below peak levelling, so lowering the peak still wins a direct trade
  • weighted by step length over the horizon, so the same day carries the same weight whether it arrives hourly or in quarter hours
  • level and distance bounded by the power the side can physically carry. Unbounded they relax into big-M range and cost the largest stored case a factor of ten with the objective handed over unscaled (the path test_objective_scaling exercises). The bound falls back to the big M where a limit on the opposite side can feed this one past its natural cap, mirroring the rule the flow direction constraints already use

Dropping the ramp

Carrying both put two absolute value systems on the same p_grid. That overlap, not the extra rows, is what cost the solve time. On 018-high-soc-initial, with attenuate_grid_peaks forced:

018 rows simplex iterations nodes CBC s
main (peak + ramp) 895 419 10 0.16
peak + ramp + deviation 1011 1299 3 0.33
peak + deviation 899 464 5 0.16

Iterations triple only when both are present - 419 with one, 464 with the other, 1299 together. Note the node count falls when the deviation term is added: it breaks objective ties, so the branch and bound search gets easier. The cost was never the search, and it was never the Python side either - 98 % of the added time was inside CBC, 2 % in pulp building the model.

Benchmarks

uv run python tools/bench_peak_leveling.py [--rev main] [--strategy ...] [--sweep] runs the stored cases, or the pinned peak sweep, through the working tree and a reference revision.

All numbers: Apple M4, native CBC 2.10.13, best of 3 solves per case. Repeat runs vary by a few percent on the totals; the outlier runs are not reported here but the ordering was stable across all of them.

Solve time

forced attenuate_grid_peaks, 20 cases total
main (peak + ramp) 3.911 s
peak + ramp + deviation 5.456 s (+40 %)
this branch (peak + deviation) 3.769 s (-4 % vs main, -31 % vs the ramp version)

Stored corpus with the strategies the cases ship with: 2.555 s -> 2.546 s. Only 5 of 20 cases level anything; the other 15 build an identical model.

Horizon scaling, synthetic day, PV bell, day/night tariff, EV goal at the end, both sides levelled:

steps ramp version this branch
48 0.063 s 0.055 s
96 0.130 s 0.094 s
192 0.252 s 0.201 s

Model size

Per levelled side, against main: +2 rows and +2 columns, total, not per step. The deviation term covers T steps where the ramp covered T - 1, so the two nearly cancel. 026 goes 117 -> 121 rows and 89 -> 93 columns; the synthetic 192 step horizon 2877 -> 2881 and 2113 -> 2117.

Schedules

Stored corpus: no schedule changes except the case added here.

486 pinned peak cases, now shipped as --sweep so the numbers are reproducible (spike size x position x goal x charge power x base load = 3 x 6 x 3 x 3 x 3):

vs main
profile changed 432 of 486 (89 %)
deviation where changed 1512 W -> 1236 W, mean 18 % lower
worst single case 1659 W -> 627 W (-62 %)
cases where deviation got worse 0
peak raised 0 of 486
largest cost delta 1.5e-7 currency

Against the ramp version: 0 of 486 profiles differ. On the case class the strategies exist for, the ramp was contributing nothing.

What dropping the ramp gives up

Stated plainly, because it is a real regression and the corpus above does not show it.

The level is free, and on a side that mostly rests at zero it settles at zero. With p_grid >= 0 the term is then sum p_grid[t] * dt[t] - the energy through that side, which the energy balance already fixes. So it scores these identically, at 750 each:

profile, same energy deviation @ free level ramp sd
plateau 1500 1500 1500 1500 0 0 0 0 750 1500 750
jagged 3000 0 3000 0 0 0 0 0 750 9000 1299
spike 6000 0 0 0 0 0 0 0 750 6000 1984

Below the peak, nothing orders them any more. The ramp did. The level sits at zero in 28 of 40 side-cases in the stored corpus, so this is the common case, not a corner.

Measured cost of that:

  • stored corpus, forced strategy: deviation worsens in 4 side-cases by at most 8 W, improves by 48 W on 023, net better
  • synthetic horizons, import side: 325 -> 344 W at 48 steps (+6 %), 920 -> 987 W at 96, 732 -> 876 W at 192 (+20 %)
  • peaks: unmoved on all 486 sweep cases. On the forced corpus 4 of 20 move, two of them exact ties in objective where CBC picks another vertex (+5 and +6 W), two real trades (+6 W, and +695 W on 013 export, +2.8 %)

The 192 step figure is the honest headline against it. It was judged worth 31 % of the solve time; if that reads differently to you, reverting the second commit restores it and the first commit stands on its own.

Also here

  • test_cases/028-attenuate-peaks-below-a-pinned-peak.json, strict, the profile above
  • tests/test_peak_leveling.py: weight order, sampling independence, the bounds and their big-M fallback, the pinned peak regression, a guard against the ramp being reintroduced unnoticed, and the blind spot above pinned as an explicit test so a future change to the term has to confront it
  • --sweep added to the benchmark tool
  • README and the objective term table updated for the removed term and the blind spot

Verification

uv run pytest green, 111 tests. uv run ruff check clean. Local runs used a native CBC 2.10.13 (the CBC that ships with pulp is x86-64 and does not run on Apple silicon); the container pins 2.10.10, so CI is the authority on the stored objective values.

One merge order note: the reported objective_value of the new case carries the get_clean_objective_value off-by-one that subtracts s[0] instead of s_initial. When that fix lands, this expected value needs regenerating - the schedule itself is unaffected.

🤖 Generated with Claude Code

andig and others added 2 commits August 1, 2026 19:37
The peak attenuation strategies priced the horizon maximum and the step to step ramp. Neither
prices a single time step against the rest: the maximum is one value, and every path that rises
monotonically between two levels carries the same ramp sum. Where the maximum is pinned by a load
the schedule cannot touch, the cheapest schedule is therefore to charge flat out against it until
the goal is met and then stop, which is the opposite of what the strategies promise.

Penalize the distance of every step from a level the optimizer places freely, the linear stand in
for the mean square deviation from constant grid power the strategies are meant to minimize. The
weight sits a decade below peak leveling and a decade above ramp leveling, so lowering the peak
still wins a direct trade, and the term is weighted by step length over the horizon so the same
day carries the same weight however finely it is sampled.

Level and distance are bounded by the power the side can physically carry, which keeps the new
rows out of big M territory - unbounded they cost the largest stored case a factor of ten with the
objective handed over unscaled. The bound falls back to the big M where a limit on the opposite
side can feed this one past its natural cap, the same case the flow direction constraints exclude.

Measured over 486 pinned peak cases: the profile changes in 35 % of them, mean deviation 11 %
lower and up to 22 % on the worst, the peak never moves and the bill stays put to 6e-8 currency.
Every stored schedule is unchanged except the case added here. Solve time is flat on the corpus as
stored and up to 45 % higher when every case is forced onto a peak strategy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ramp term priced the transitions of the grid profile, the deviation
term added in the previous commit prices every step against a free level.
Carrying both put two absolute value systems on the same grid power, and
that overlap, not the extra rows, is what cost the solve time: on
018-high-soc-initial the simplex iterations go 419 with either term alone
to 1299 with both, while the node count falls from 10 to 3.

Removing it takes the forced attenuate_grid_peaks corpus from 5.456 s to
3.769 s (-31 %), back to parity with main at 3.911 s, and the model back
to main's size - the deviation term covers T steps where the ramp covered
T - 1, so the whole change is +2 rows and +2 columns per leveled side.

The pinned peak sweep the term was written for is untouched: all 486
cases solve to the identical schedule with and without the ramp, and
against main the deviation is 18 % lower with the peak never raised.

What it gives up is shape ordering where the free level settles at zero.
There the term is the energy through the side, which the energy balance
already fixes, so a plateau, a jagged profile and a single spike of the
same energy score alike. On synthetic horizons the import side deviation
rises 6 % at 48 steps and 20 % at 192. Both halves are pinned by tests
and the blind spot is documented where the constraints are built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andig andig changed the title Level the grid profile against a free level, not only its peak Level the grid profile against a free level instead of its peak and ramp Aug 1, 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