Skip to content

Commit 0397ee4

Browse files
committed
Merge remote-tracking branch 'origin/feat/level-grid-profile-deviation' into rollup/two-stage-plus-130
2 parents fb7e9e2 + fcb0337 commit 0397ee4

6 files changed

Lines changed: 81 additions & 356 deletions

File tree

README.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,9 @@ The optimizer buys all 29 kWh of grid energy in the three cheapest hours of the
3737

3838
Cheapest is not always kindest to the grid connection. The same house on a *flat* tariff — where nothing but the strategy decides when to charge — takes its 29 kWh at the full 11 kW in the last two hours before the EV deadline, and pushes the midday surplus out in two short bursts.
3939

40-
`attenuate_grid_peaks` penalizes the highest grid power over the horizon and the distance of every single step from a common level, on the import and the feed-in side. The same energy then arrives as a flat 3.6 kW plateau, and the feed-in peak drops from 1.3 kW to 0.25 kW. Nothing costs more — the connection just sees a calmer profile. `attenuate_demand_peaks` and `attenuate_feedin_peaks` do the same for one side only.
40+
`attenuate_grid_peaks` penalizes the highest grid power over the horizon, on the import and the feed-in side. The same energy then arrives as a flat 3.6 kW plateau, and the feed-in peak drops from 1.3 kW to 0.25 kW. Nothing costs more — the connection just sees a calmer profile. `attenuate_demand_peaks` and `attenuate_feedin_peaks` do the same for one side only.
4141

42-
The second term is what keeps the profile level where the peak is out of reach. A load spike the schedule cannot touch — an oven, a heat pump defrost — fixes the horizon maximum, and a peak penalty alone has nothing left to win below it: the cheapest way to charge is then flat out against the spike until the goal is met. Pricing the distance from a level instead spreads the same energy over the whole window, which is what a minimum square deviation from constant grid power asks for.
43-
44-
The level is placed freely, which leaves one gap: on a side that mostly rests at zero the level settles at zero too, and the term then only adds up the energy through that side — a plateau, a jagged profile and a single spike of the same energy score alike, and nothing below the peak tells them apart.
42+
The maximum is a single value out of the horizon, which leaves one gap: a load spike the schedule cannot touch — an oven, a heat pump defrost — fixes it, and the penalty then has nothing left to win below it. Charging flat out against the spike scores the same as spreading the same energy over the window, and the solver may pick either.
4543

4644
<picture>
4745
<source media="(prefers-color-scheme: dark)" srcset="docs/img/example-peak-dark.svg">

docs/comparison_objective_terms.md

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
| | Final SOC value | `bat.p_a` | *(raw, now also feeds `penalty_base` directly)* | ~1.7e2 (up to whatever `p_a` is configured) | actual currency |
1515
| | Demand-rate charge | `grid.prc_p_exc_imp` | *(raw; `prc_p_exc_imp·3600/min(dt)` now also feeds `penalty_base`, only when `p_max_imp` makes the rate active)* | unbounded, but tier 1 now tracks it | actual currency |
1616
| **3 — tie-break nudges** | Peak leveling | `prc_p_peak = penalty_base·1e-3` | ×1e-3 | 0.16 – 300 | shape, not cost |
17-
| | Level deviation | `prc_p_dev = penalty_base·1e-4`, per step weighted by `dt[t]/Σdt` | ×1e-4·dt[t]/Σdt | 2.5e-5 – 2.5e-2 | shape, not cost |
1817
| | Charge-before-export | `min_import_price·2e-5·(T−t)` | ×2e-5·(T−t) | ~1e-3 – tens | shape, not cost |
1918
| | Discharge-before-import | `min_import_price·5e-6·(T−t)` | ×5e-6·(T−t) | ~1e-4 – tens | shape, not cost |
2019
| | Charging priority | `min_import_price·5e-5·(T−t)·c_priority`, on `c` and on `d` | ×5e-5·(T−t)·c_priority | ~1e-3 – tens | shape, not cost |
@@ -24,21 +23,17 @@
2423
- The SOC penalty is charged per time step, so a violation lasting the whole horizon is paid for
2524
once per step while one confined to a single step is paid for once. `penalty_base` is sized
2625
against the single-step case, which is the weaker of the two.
27-
- The two leveling terms are ordered per W, not per horizon. Peak leveling is charged once on a
28-
single value, level deviation is a time average, so neither grows with the horizon and the order
29-
between them holds at any sampling.
30-
- Level deviation carries the smallest coefficients in the model, a decade below peak leveling and
31-
smaller again once the time average is spread over the steps. Level and deviation variables are
32-
therefore bounded by the power the side can physically carry: unbounded they relax into the same
33-
range as the big M of 1e6 and, on the largest stored case, cost the solver a factor of ten.
34-
- Level deviation has a blind spot. The level is free, and on a side that mostly rests at zero it
35-
settles there; with `p_grid ≥ 0` the term is then `Σ p_grid[t]·dt[t]`, the energy through the
36-
side, which the energy balance already fixes. A plateau, a jagged profile and a single spike of
37-
the same energy score identically and only the peak separates them. A ramp leveling term at
38-
`penalty_base·1e-5` used to order them; it was dropped because it priced transitions only and
39-
cost around a third of the solve time of a leveling case. `test_peak_leveling` pins both halves.
26+
- Peak leveling is charged once on a single value, not per step, so its weight does not grow with
27+
the horizon or change with the sampling.
28+
- Peak leveling has a blind spot. The maximum is one value out of the horizon, so once a load the
29+
schedule cannot touch pins it, nothing orders the steps below it: a plateau, a jagged profile and
30+
a single spike of the same energy score identically. A ramp leveling term at `penalty_base·1e-5`
31+
used to order them; it was dropped because it priced transitions only, so it could not tell a
32+
straight climb from a plateau either, and the second absolute value system over the same grid
33+
power cost around a fifth of the solve time of a leveling case. `test_peak_leveling` pins both
34+
halves.
4035
- The tier 3 terms keyed off `min_import_price` invert sign when market prices go negative, the
41-
reason peak and level deviation leveling use `penalty_base` instead.
36+
reason peak leveling uses `penalty_base` instead.
4237
- Tier 3 is not solved together with the tiers above it. `solve()` maximizes tiers 1 and 2 first,
4338
then maximizes tier 3 over the schedules that keep that value, so the distance between the tiers
4439
no longer decides whether a preference is respected. The ranges listed above are what the second

src/optimizer/optimizer.py

Lines changed: 16 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -227,21 +227,12 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries:
227227
# solar power
228228
self.prc_e_grid_exp_pen = self.penalty_base * 10e1
229229

230-
# weights of the grid peak leveling strategies. Capping the horizon maximum alone leaves the
231-
# profile below the cap arbitrary: once the maximum is pinned by a load the schedule cannot
232-
# touch, the term has nothing left to win below it. The deviation from a free level closes
233-
# that gap, it is what a mean square deviation from constant grid power would measure and it
234-
# prices every single time step rather than one value out of the horizon.
235-
# Per unit of W the peak outweighs the deviation, so lowering the peak still wins a direct
236-
# trade against leveling the profile underneath it.
237-
#
238-
# A step to step ramp term sat between these two until the deviation term arrived. It priced
239-
# the transitions only, so it could not tell a straight climb from a plateau, and carrying
240-
# both absolute value systems over the same grid power cost around a third of the solve time
241-
# of a leveling case for no measurable gain in deviation. See the blind spot noted in
242-
# _add_energy_balance_constraints for what its removal gives up.
230+
# weight of the grid peak leveling strategies. A step to step ramp term at penalty_base·1e-5
231+
# sat next to it until this change. It priced the transitions only, so it could not tell a
232+
# straight climb from a plateau, and the second absolute value system over the same grid
233+
# power cost around a fifth of the solve time of a leveling case. See the blind spot noted
234+
# in _add_energy_balance_constraints for what its removal gives up.
243235
self.prc_p_peak = self.penalty_base * 1e-3
244-
self.prc_p_dev = self.penalty_base * 1e-4
245236

246237
# grid sides leveled by the active peak attenuation strategy, empty for all other strategies
247238
self.peak_sides = PEAK_STRATEGY_SIDES.get(strategy.charging_strategy, ())
@@ -339,23 +330,9 @@ def _setup_variables(self):
339330
self.variables['p_max_imp_exc'] = pulp.LpVariable("p_max_imp_exc", lowBound=0)
340331

341332
# highest grid power over the whole horizon (W) per side, used by the peak attenuation
342-
# strategies.
343-
# p_{side}_lvl is the constant the profile is leveled towards and p_{side}_dev the distance
344-
# of each step from it (W). The level is a variable and not the mean of the profile, so the
345-
# optimizer places it where the profile is cheapest to level, and nothing forces the schedule
346-
# towards a level picked up front.
333+
# strategies
347334
for side in self.peak_sides:
348-
# the profile of a side cannot exceed what the horizon can physically carry there.
349-
# bounding level and distance by it keeps the relaxation of the new rows from wandering
350-
# off into big M territory, the same reason the flow direction constraints below prefer
351-
# a natural cap over the global big M.
352-
p_side_max = self._peak_side_bound(side)
353335
self.variables[f'p_{side}_peak'] = pulp.LpVariable(f"p_{side}_peak", lowBound=0)
354-
self.variables[f'p_{side}_lvl'] = pulp.LpVariable(f"p_{side}_lvl", lowBound=0, upBound=p_side_max)
355-
self.variables[f'p_{side}_dev'] = [
356-
pulp.LpVariable(f"p_{side}_dev_{t}", lowBound=0, upBound=p_side_max)
357-
for t in self.time_steps
358-
]
359336

360337
# Binary variable: power flow direction to / from grid variables
361338
# these variables
@@ -384,30 +361,6 @@ def _setup_variables(self):
384361
for t in self.time_steps
385362
]
386363

387-
def _peak_side_bound(self, side):
388-
"""
389-
Highest grid power the horizon can put on a leveled side (W), or the big M where there
390-
is no natural cap.
391-
392-
Import is the demand of a step plus everything the batteries may pull from the grid,
393-
export the production plus everything they may push back into it, both taken over the
394-
step that carries the most. The flow direction constraints keep the two sides apart and
395-
make those caps hold - except for the excess variable of a configured limit, which stays
396-
outside them. With a limit on the opposite side that side can feed this one past its
397-
natural cap, the same case where the flow direction constraints fall back to the big M.
398-
"""
399-
if side == 'imp':
400-
if self.grid.p_max_exp is not None:
401-
return self.M
402-
series = self.time_series.gt
403-
cap = sum(bat.c_max for bat in self.batteries if bat.charge_from_grid)
404-
else:
405-
if self.grid.p_max_imp is not None:
406-
return self.M
407-
series = self.time_series.ft
408-
cap = sum(bat.d_max for bat in self.batteries if bat.discharge_to_grid)
409-
return max(series[t] * 3600. / self.time_series.dt[t] for t in self.time_steps) + cap
410-
411364
def _setup_target_function(self):
412365
"""
413366
Gather all target function contributions and instantiate the objective
@@ -501,21 +454,13 @@ def _setup_target_function(self):
501454

502455
# level the grid profile to unload the public grid from peaks. attenuate_demand_peaks levels
503456
# grid import, attenuate_feedin_peaks levels grid export, attenuate_grid_peaks levels both.
504-
# the penalty sits on the horizon maximum and on the distance of every step from a free
505-
# level instead of on charge power, so the optimizer spreads charging at partial power over
506-
# several time steps rather than running one step at full power, and keeps the profile below
507-
# the cap leveled too.
457+
# the penalty sits on the horizon maximum instead of on charge power, so the optimizer
458+
# spreads charging at partial power over several time steps rather than running one step at
459+
# full power.
508460
# penalty_base is used instead of min_import_price because negative market prices would turn
509461
# this penalty into a reward for peaks.
510-
horizon = float(sum(self.time_series.dt))
511462
for side in self.peak_sides:
512463
preference += - self.variables[f'p_{side}_peak'] * self.prc_p_peak
513-
# weighted by step length and divided by the horizon, which makes the term the time
514-
# average of the distance from the level. A step average would let the same day weigh
515-
# differently depending on how finely it is sampled, and would count a 15 min excursion
516-
# like one lasting an hour.
517-
preference += - pulp.lpSum(self.variables[f'p_{side}_dev'][t] * self.time_series.dt[t]
518-
for t in self.time_steps) / horizon * self.prc_p_dev
519464

520465
# prefer discharging batteries completely before importing from grid
521466
if self.strategy.discharging_strategy == 'discharge_before_import':
@@ -629,17 +574,14 @@ def _add_energy_balance_constraints(self):
629574
self.problem += (self.variables['e_exp_lim_exc'][t]
630575
<= self.M * (1 - self.variables['z_exp_lim'][t]))
631576

632-
# track the horizon maximum and the distance from the level of the total grid power for every
633-
# side the strategy levels. Both include the portion beyond p_max_imp / p_max_exp, so they
634-
# stay correct in demand rate mode and when a limit is violated.
577+
# track the horizon maximum of the total grid power for every side the strategy levels. It
578+
# includes the portion beyond p_max_imp / p_max_exp, so it stays correct in demand rate mode
579+
# and when a limit is violated.
635580
#
636-
# Known blind spot: the level is free, and on a side that mostly rests at zero it settles at
637-
# zero. With p_grid >= 0 the term is then sum(p_grid[t] * dt[t]), the energy through the
638-
# side, which the energy balance already fixes - so it scores a plateau, a jagged profile and
639-
# a single spike of the same energy identically and orders none of them. Only the peak still
640-
# separates them there. The step to step ramp used to, and test_peak_leveling pins what that
641-
# costs; on the stored corpus it is worth at most 8 W of deviation, and it is not worth a
642-
# third of the solve time.
581+
# Known blind spot: the maximum is a single value out of the horizon, so once it is pinned by
582+
# a load the schedule cannot touch, nothing orders the steps below it any more - a plateau, a
583+
# jagged profile and a single spike of the same energy all score alike. A step to step ramp
584+
# term used to order them, and test_peak_leveling pins what dropping it costs.
643585
for side in self.peak_sides:
644586
grid_var, lim_exc_var = PEAK_SIDE_VARIABLES[side]
645587
# total grid power of this side per time step (W)
@@ -652,10 +594,6 @@ def _add_energy_balance_constraints(self):
652594

653595
for t in self.time_steps:
654596
self.problem += p_grid[t] <= self.variables[f'p_{side}_peak']
655-
# distance from the level: p_dev[t] >= |p_grid[t] - p_lvl|
656-
for t in self.time_steps:
657-
self.problem += self.variables[f'p_{side}_dev'][t] >= p_grid[t] - self.variables[f'p_{side}_lvl']
658-
self.problem += self.variables[f'p_{side}_dev'][t] >= self.variables[f'p_{side}_lvl'] - p_grid[t]
659597

660598
# if demand rate is applied, the maximum grid import power value
661599
# of all time steps drives the demand rate charge

test_cases/027-attenuate-feedin-peaks-below-cap.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,10 @@
6969
{
7070
"charging_power": [
7171
50.0,
72-
42.5,
73-
42.5,
74-
42.5,
75-
42.5
72+
50.0,
73+
50.0,
74+
50.0,
75+
20.0
7676
],
7777
"discharging_power": [
7878
0.0,
@@ -83,9 +83,9 @@
8383
],
8484
"state_of_charge": [
8585
50.0,
86-
92.5,
87-
135.0,
88-
177.5,
86+
100.0,
87+
150.0,
88+
200.0,
8989
220.0
9090
]
9191
}
@@ -99,10 +99,10 @@
9999
],
100100
"grid_export": [
101101
950.0,
102-
57.5,
103-
57.5,
104-
57.5,
105-
57.5
102+
50.0,
103+
50.0,
104+
50.0,
105+
80.0
106106
],
107107
"flow_direction": [
108108
1,

0 commit comments

Comments
 (0)