feat: split grid peak shaping into feed-in, demand and combined options#96
Open
andig wants to merge 2 commits into
Open
feat: split grid peak shaping into feed-in, demand and combined options#96andig wants to merge 2 commits into
andig wants to merge 2 commits into
Conversation
… 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>
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:
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. |
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>
Member
Author
|
@ekkea per Slack feedback I've split this by energy direction plus combination (attenuate_grid_peaks). |
Contributor
|
Let me review that tomorrow night. |
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.

The
attenuate_grid_peakscharging 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_strategyattenuate_demand_peaksattenuate_feedin_peaksattenuate_grid_peakspenalty_baserather thanmin_import_price, so negative market prices cannot flip it into a reward for peaks.p_max_imp/p_max_exp, so it stays correct in demand rate mode and when a limit is violated.attenuate_grid_peakskeeps 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_peaksOvernight 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]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]attenuate_feedin_peaksSunny 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]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]attenuate_grid_peaksMixed 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]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]On this scenario
attenuate_feedin_peaksalone 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:
grid_energy[t]isn[t]pluse_imp_lim_exc[t]on the demand side ande[t]pluse_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 andTrows per side, no binaries, so the LP relaxation stays as tight as before.Tests
tests/test_strategies.pycovers both sides and their separation:attenuate_demand_peaksandattenuate_grid_peaksattenuate_feedin_peaksandattenuate_grid_peaksattenuate_feedin_peaksdoes not level import,attenuate_demand_peaksdoes not shave exportnoneproduces neither profileThe 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