Skip to content

feat: split grid peak shaping into feed-in, demand and combined options#96

Open
andig wants to merge 2 commits into
mainfrom
fix/attenuate-grid-peaks-partial-power
Open

feat: split grid peak shaping into feed-in, demand and combined options#96
andig wants to merge 2 commits into
mainfrom
fix/attenuate-grid-peaks-partial-power

Conversation

@andig

@andig andig commented Jul 21, 2026

Copy link
Copy Markdown
Member

The attenuate_grid_peaks charging strategy never produced partial charge power. It rewarded charge power multiplied by the solar forecast, and a reward that grows monotonically with charge power always drives that variable to its bound, so the optimizer chose full power or nothing. The coefficient used gross solar forecast instead of surplus or actual grid draw, which made the term blind to household demand and able to create a grid peak rather than attenuate one. Because the coefficient scaled with forecast magnitude, on high yield days the term also outgrew the cost neutral range that strategy tie breakers are meant to stay in.

Peak shaping is now a penalty on the horizon maximum of the grid power, and the metered side it applies to is selectable, because the two sides serve different interests. A distribution operator capping feed-in cares about export, a demand rate tariff or a connection fuse cares about import, and a user may want either one alone.

charging_strategy levels
attenuate_demand_peaks grid import profile
attenuate_feedin_peaks grid export profile
attenuate_grid_peaks both
  • The optimum sits on the kink of the peak variable, so charging spreads over several time steps at partial power instead of running one step at full power.
  • The penalty scales from penalty_base rather than min_import_price, so negative market prices cannot flip it into a reward for peaks.
  • Each peak tracks the total grid power including the portion beyond p_max_imp / p_max_exp, so it stays correct in demand rate mode and when a limit is violated.
  • attenuate_grid_peaks keeps its name and now means "both sides", so existing callers get peak shaping on the side they already expected plus the feed-in side.

All charts below are actual optimizer output, twelve one hour steps with a flat price profile so every placement of the charge energy is cost neutral.

attenuate_demand_peaks

Overnight window, a household base load with an evening hump, and an EV that needs 20 kWh before the window ends. Bars are grid import, the line is EV charge power.

Before, the incentive term vanished at night because the solar forecast is zero, so the EV ran two hours at full power straight into the evening load hump.

xychart-beta
    title "Before: grid import peak 12.8 kW"
    x-axis "hour of the charging window" ["0h", "1h", "2h", "3h", "4h", "5h", "6h", "7h", "8h", "9h", "10h", "11h"]
    y-axis "power in kW" 0 --> 13
    bar [0.90, 0.70, 0.50, 0.40, 0.35, 0.30, 0.30, 0.35, 0.60, 12.80, 11.45, 0.90]
    line [0, 0, 0, 0, 0, 0, 0, 0, 0, 11.00, 10.05, 0]
Loading

After, the EV charges at partial power across the whole window and fills the valleys of the household profile. Grid import is flat and the peak drops from 12.8 kW to 2.5 kW for the same delivered energy.

xychart-beta
    title "After: grid import peak 2.5 kW"
    x-axis "hour of the charging window" ["0h", "1h", "2h", "3h", "4h", "5h", "6h", "7h", "8h", "9h", "10h", "11h"]
    y-axis "power in kW" 0 --> 13
    bar [2.46, 2.46, 2.46, 2.46, 2.46, 2.46, 2.46, 2.46, 2.46, 2.46, 2.46, 2.46]
    line [1.56, 1.76, 1.96, 2.06, 2.11, 2.16, 2.16, 2.11, 1.86, 0.66, 1.06, 1.56]
Loading

attenuate_feedin_peaks

Sunny day, a small household base load, and a home battery that stores 10 kWh out of the solar surplus. Bars are grid export, the line is battery charge power.

Before, the battery charges as early as it can and is already full when the solar bell reaches its maximum, so the whole midday surplus goes to the grid.

xychart-beta
    title "Before: grid export peak 8.6 kW"
    x-axis "hour of the day" ["0h", "1h", "2h", "3h", "4h", "5h", "6h", "7h", "8h", "9h", "10h", "11h"]
    y-axis "power in kW" 0 --> 9
    bar [0, 0.10, 0.07, 0, 0, 7.80, 8.60, 7.80, 5.80, 3.40, 1.40, 0.10]
    line [0, 0, 1.33, 3.40, 5.80, 0, 0, 0, 0, 0, 0, 0]
Loading

After, charging moves into the solar bell and clips its top. The export peak drops from 8.6 kW to 5.1 kW, again for the same stored energy.

xychart-beta
    title "After: grid export peak 5.1 kW"
    x-axis "hour of the day" ["0h", "1h", "2h", "3h", "4h", "5h", "6h", "7h", "8h", "9h", "10h", "11h"]
    y-axis "power in kW" 0 --> 9
    bar [0, 0.10, 1.40, 3.40, 5.05, 5.05, 5.05, 5.05, 5.05, 3.40, 1.40, 0.10]
    line [0, 0, 0, 0, 0.75, 2.75, 3.55, 2.75, 0.75, 0, 0, 0]
Loading

attenuate_grid_peaks

Mixed day with dark hours around a solar bell, grid charging allowed, a 20 kWh goal that solar alone cannot cover, and a charge power limit that keeps the battery from swallowing the whole midday surplus. Both sides of the meter carry a peak. Bars are grid import, the line is grid export.

Before, the grid charging lands in a few full power blocks in the dark hours and the surplus the battery cannot take is exported in one hump.

xychart-beta
    title "Before: import peak 4.2 kW, export peak 4.0 kW"
    x-axis "hour of the day" ["0h", "1h", "2h", "3h", "4h", "5h", "6h", "7h", "8h", "9h", "10h", "11h"]
    y-axis "power in kW" 0 --> 5
    bar [3.80, 3.70, 3.60, 3.60, 0, 0, 0, 0, 0, 0, 3.70, 4.20]
    line [0, 0, 0, 0, 0, 0, 2.45, 4.00, 3.20, 1.40, 0, 0]
Loading

After, the grid charging is spread flat over the dark hours and the battery takes as much of the bell as its charge limit allows. The import peak drops from 4.2 kW to 1.5 kW and the export peak from 4.0 kW to 1.0 kW.

xychart-beta
    title "After: import peak 1.5 kW, export peak 1.0 kW"
    x-axis "hour of the day" ["0h", "1h", "2h", "3h", "4h", "5h", "6h", "7h", "8h", "9h", "10h", "11h"]
    y-axis "power in kW" 0 --> 5
    bar [1.48, 1.48, 1.48, 1.48, 1.48, 1.20, 0, 0, 0, 1.48, 1.48, 1.48]
    line [0, 0, 0, 0, 0, 0, 0.30, 1.00, 0.20, 0, 0, 0]
Loading

On this scenario attenuate_feedin_peaks alone reaches the same 1.0 kW export peak but leaves a 3.8 kW import peak, because nothing keeps the grid charging from bunching up in the dark hours.

Model

Per selected side a single continuous variable holds the horizon maximum:

p_side_peak >= grid_energy[t] * 3600 / dt[t]     for all t
objective   -= p_side_peak * penalty_base * 1e-3

grid_energy[t] is n[t] plus e_imp_lim_exc[t] on the demand side and e[t] plus e_exp_lim_exc[t] on the feed-in side, so a limit violation is part of the peak rather than a way to hide from it. Two variables and T rows per side, no binaries, so the LP relaxation stays as tight as before.

Tests

tests/test_strategies.py covers both sides and their separation:

  • import leveling holds for attenuate_demand_peaks and attenuate_grid_peaks
  • export peak shaving holds for attenuate_feedin_peaks and attenuate_grid_peaks
  • attenuate_feedin_peaks does not level import, attenuate_demand_peaks does not shave export
  • none produces neither profile

The existing data driven cases are unaffected, none of them uses a peak shaping strategy.

Known limitation

The penalty is duration blind by design: only the horizon maximum is billed, matching how connection limits and demand charges work. A short high spike therefore costs more than a longer smaller draw. A duration weighted variant would need a multi tier piecewise linear penalty per time step and a caller supplied threshold.

Refs evcc-io/evcc#31963

… power

The strategy added a reward proportional to charge power times solar forecast.
A reward that grows monotonically with charge power always pushes the variable
to its bound, so the optimizer never picked an intermediate charge power. The
coefficient also used gross solar forecast rather than surplus or grid draw, so
the term was blind to household demand and could create a grid peak instead of
attenuating one. Its magnitude scaled with the forecast, which broke the cost
neutrality that strategy terms are supposed to keep.

The strategy now penalizes the horizon maximum of the grid import power. The
optimum sits on the resulting kink, so charging spreads over several time steps
at partial power.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@andig

andig commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

@ekkea this PR should use partial charging at least for EVs which it didn't do before, peaks where always flattened at their highest point but not spread out:

Screenshot 2026-07-21 at 09 49 54

Really, we should also do this for batteries, but ATM the required evcc battery interfaces are not available. It would still be ok to fix this on optimizer side in general.

@andig andig added the enhancement New feature or request label Jul 21, 2026
The `attenuate_grid_peaks` charging strategy never produced partial charge
power. It rewarded charge power multiplied by the solar forecast, and a reward
that grows monotonically with charge power always drives that variable to its
bound, so the optimizer chose full power or nothing. The coefficient used gross
solar forecast instead of surplus or actual grid draw, which made the term blind
to household demand and able to create a grid peak rather than attenuate one.

Peak shaping is now expressed as a penalty on the horizon maximum of the grid
power, and the metered side it applies to is selectable:

- `attenuate_demand_peaks` levels the grid import profile
- `attenuate_feedin_peaks` levels the grid export profile
- `attenuate_grid_peaks` levels both

The optimum sits on the resulting kink, so charging spreads over several time
steps at partial power. The penalty scales from `penalty_base` rather than
`min_import_price`, so negative market prices cannot flip it into a reward for
peaks. Each peak tracks total grid power including the portion beyond
`p_max_imp` / `p_max_exp`, so it stays correct in demand rate mode and when a
limit is violated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@andig andig changed the title fix: attenuate grid peaks by leveling import, not by rewarding charge power feat: split grid peak shaping into feed-in, demand and combined options Jul 21, 2026
@andig

andig commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

@ekkea per Slack feedback I've split this by energy direction plus combination (attenuate_grid_peaks).

@ekkea

ekkea commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Let me review that tomorrow night.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants