Skip to content

fix: level the grid profile below the peak, not only the absolute peak#102

Merged
andig merged 2 commits into
mainfrom
fix/level-grid-profile-below-peak
Jul 24, 2026
Merged

fix: level the grid profile below the peak, not only the absolute peak#102
andig merged 2 commits into
mainfrom
fix/level-grid-profile-below-peak

Conversation

@andig

@andig andig commented Jul 24, 2026

Copy link
Copy Markdown
Member

Problem

The attenuate_demand_peaks / attenuate_feedin_peaks / attenuate_grid_peaks strategies penalize only p_{side}_peak, the horizon maximum of the grid power. That caps the absolute peak but says nothing about the profile underneath it.

Once the maximum is pinned by a time step the batteries cannot shave — a battery already at s_max, or a surplus far beyond c_max — the objective becomes completely flat over every profile below the cap, so the solver returns an arbitrary vertex. On the reported 192-step case the feed-in tail came out as

172, 0, 429, 525, 0, 0, 0, 943, 0, 0, 1179, 0, 1252, 0, 1415, 0

Peak capped, curve not leveled.

Change

Penalize the step to step ramp of the grid power in addition to the peak:

p_{side}_ramp[t] >= |p_grid[t] - p_grid[t-1]|

with prc_p_ramp = penalty_base * 1e-5, a hundred times below prc_p_peak = penalty_base * 1e-3, so lowering the peak still wins wherever the two disagree. The peak constraint is rewritten in power terms (p_grid[t] <= p_{side}_peak) so both constraints share the same expression, including the portion beyond p_max_imp / p_max_exp.

Effect on the reported case

peak total variation economics
before 1853 W 20666 6.686112
after 1806 W 8899 6.686112

Cost neutral: identical import cost, export revenue and stored value. The tail is flat 402, 402, 402, … instead of the sawtooth, and the morning ramp climbs monotonically to the cap.

Solving also gets ~20x faster on this case (12.7 s → 0.5 s): the ramp term removes the degeneracy CBC was branching through.

Two small dips remain (t=21, t=105) at ~5e-6 objective difference, i.e. below CBC's termination tolerance rather than a formulation gap.

Tests

Coverage lives in the data driven harness on #101: test_cases/026-attenuate-feedin-peaks-below-cap.json. It pins the export peak in step 0 beyond reach of c_max, so the remaining charge energy can be placed anywhere in the following steps without touching the peak, and the strict schedule comparison pins the leveled result.

  • without this change: grid_export [950, 50, 50, 50, 80]
  • with it: grid_export [950, 57.5, 57.5, 57.5, 57.5]

That case therefore fails until this PR lands. uv run pytest on this branch → 15 passed, ruff check clean; with #101 merged in as well → 19 passed.

🤖 Generated with Claude Code

The attenuate_* charging strategies penalized only p_{side}_peak, the horizon
maximum of the grid power. Once that maximum is pinned by a time step the
batteries cannot shave - because a battery is already full, or because the
surplus is far beyond c_max - the objective is completely flat over every
profile below the cap, so the solver returns an arbitrary vertex. The result
was a capped but jagged feed-in curve, e.g. 172, 0, 429, 525, 0, 0, 0, 943, 0.

Penalize the step to step ramp of the grid power as well:

    p_{side}_ramp[t] >= |p_grid[t] - p_grid[t-1]|

at prc_p_ramp = penalty_base * 1e-5, a hundred times below the peak weight, so
lowering the peak still wins wherever the two disagree. The peak constraint is
rewritten in power terms (p_grid[t] <= p_{side}_peak) so both share the same
expression.

On the reported case the schedule economics are unchanged (6.686112), the peak
drops from 1853 W to 1806 W and the total variation of the feed-in curve from
20666 W to 8899 W. Solving also gets ~20x faster (12.7s -> 0.5s) because the
ramp term removes the degeneracy the solver was branching through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
andig added a commit that referenced this pull request Jul 24, 2026
The three strategy cases pin the absolute peak, but not the shape of the
profile underneath it. Cover that separately: the surplus of the first step is
far beyond c_max, so the export peak is pinned there and the remaining 170 Wh
can be charged anywhere in the following steps without touching it. A
schedule that only caps the peak therefore has a free choice, and the strict
comparison pins the leveled one.

Requires #102: without the ramp
penalty the optimizer returns grid_export [950, 50, 50, 50, 80] instead of
[950, 57.5, 57.5, 57.5, 57.5].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The coverage moves to test_cases/026-attenuate-feedin-peaks-below-cap.json on
#101, where the peak shaping cases and
the strict schedule comparison live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@andig
andig merged commit d51f464 into main Jul 24, 2026
1 check passed
@andig
andig deleted the fix/level-grid-profile-below-peak branch July 24, 2026 11:14
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