fix: level the grid profile below the peak, not only the absolute peak#102
Merged
Conversation
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>
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.
Problem
The
attenuate_demand_peaks/attenuate_feedin_peaks/attenuate_grid_peaksstrategies penalize onlyp_{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 beyondc_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 asPeak capped, curve not leveled.
Change
Penalize the step to step ramp of the grid power in addition to the peak:
with
prc_p_ramp = penalty_base * 1e-5, a hundred times belowprc_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 beyondp_max_imp/p_max_exp.Effect on the reported case
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 ofc_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.grid_export [950, 50, 50, 50, 80]grid_export [950, 57.5, 57.5, 57.5, 57.5]That case therefore fails until this PR lands.
uv run pyteston this branch → 15 passed,ruff checkclean; with #101 merged in as well → 19 passed.🤖 Generated with Claude Code