Skip to content

Commit fcb0337

Browse files
committed
Drop the free level deviation term again, leaving the peak alone
The measurements that justified it do not hold up: on the cases where the two terms differ the search is truncated by the time limit, so the profile that comes out is the incumbent CBC happens to hold, not what the term ordered. On one production request the whole peak penalty is 8e-4 against a remaining gap of 4.7e4, seven orders below anything the solver reacts to. What is left is the ramp removal, which stands on its own.
1 parent 2874b12 commit fcb0337

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,19 +23,15 @@
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

src/optimizer/optimizer.py

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

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

188179
# grid sides leveled by the active peak attenuation strategy, empty for all other strategies
189180
self.peak_sides = PEAK_STRATEGY_SIDES.get(strategy.charging_strategy, ())
@@ -281,23 +272,9 @@ def _setup_variables(self):
281272
self.variables['p_max_imp_exc'] = pulp.LpVariable("p_max_imp_exc", lowBound=0)
282273

283274
# highest grid power over the whole horizon (W) per side, used by the peak attenuation
284-
# strategies.
285-
# p_{side}_lvl is the constant the profile is leveled towards and p_{side}_dev the distance
286-
# of each step from it (W). The level is a variable and not the mean of the profile, so the
287-
# optimizer places it where the profile is cheapest to level, and nothing forces the schedule
288-
# towards a level picked up front.
275+
# strategies
289276
for side in self.peak_sides:
290-
# the profile of a side cannot exceed what the horizon can physically carry there.
291-
# bounding level and distance by it keeps the relaxation of the new rows from wandering
292-
# off into big M territory, the same reason the flow direction constraints below prefer
293-
# a natural cap over the global big M.
294-
p_side_max = self._peak_side_bound(side)
295277
self.variables[f'p_{side}_peak'] = pulp.LpVariable(f"p_{side}_peak", lowBound=0)
296-
self.variables[f'p_{side}_lvl'] = pulp.LpVariable(f"p_{side}_lvl", lowBound=0, upBound=p_side_max)
297-
self.variables[f'p_{side}_dev'] = [
298-
pulp.LpVariable(f"p_{side}_dev_{t}", lowBound=0, upBound=p_side_max)
299-
for t in self.time_steps
300-
]
301278

302279
# Binary variable: power flow direction to / from grid variables
303280
# these variables
@@ -326,30 +303,6 @@ def _setup_variables(self):
326303
for t in self.time_steps
327304
]
328305

329-
def _peak_side_bound(self, side):
330-
"""
331-
Highest grid power the horizon can put on a leveled side (W), or the big M where there
332-
is no natural cap.
333-
334-
Import is the demand of a step plus everything the batteries may pull from the grid,
335-
export the production plus everything they may push back into it, both taken over the
336-
step that carries the most. The flow direction constraints keep the two sides apart and
337-
make those caps hold - except for the excess variable of a configured limit, which stays
338-
outside them. With a limit on the opposite side that side can feed this one past its
339-
natural cap, the same case where the flow direction constraints fall back to the big M.
340-
"""
341-
if side == 'imp':
342-
if self.grid.p_max_exp is not None:
343-
return self.M
344-
series = self.time_series.gt
345-
cap = sum(bat.c_max for bat in self.batteries if bat.charge_from_grid)
346-
else:
347-
if self.grid.p_max_imp is not None:
348-
return self.M
349-
series = self.time_series.ft
350-
cap = sum(bat.d_max for bat in self.batteries if bat.discharge_to_grid)
351-
return max(series[t] * 3600. / self.time_series.dt[t] for t in self.time_steps) + cap
352-
353306
def _setup_target_function(self):
354307
"""
355308
Gather all target function contributions and instantiate the objective
@@ -438,21 +391,13 @@ def _setup_target_function(self):
438391

439392
# level the grid profile to unload the public grid from peaks. attenuate_demand_peaks levels
440393
# grid import, attenuate_feedin_peaks levels grid export, attenuate_grid_peaks levels both.
441-
# the penalty sits on the horizon maximum and on the distance of every step from a free
442-
# level instead of on charge power, so the optimizer spreads charging at partial power over
443-
# several time steps rather than running one step at full power, and keeps the profile below
444-
# the cap leveled too.
394+
# the penalty sits on the horizon maximum instead of on charge power, so the optimizer
395+
# spreads charging at partial power over several time steps rather than running one step at
396+
# full power.
445397
# penalty_base is used instead of min_import_price because negative market prices would turn
446398
# this penalty into a reward for peaks.
447-
horizon = float(sum(self.time_series.dt))
448399
for side in self.peak_sides:
449400
objective += - self.variables[f'p_{side}_peak'] * self.prc_p_peak
450-
# weighted by step length and divided by the horizon, which makes the term the time
451-
# average of the distance from the level. A step average would let the same day weigh
452-
# differently depending on how finely it is sampled, and would count a 15 min excursion
453-
# like one lasting an hour.
454-
objective += - pulp.lpSum(self.variables[f'p_{side}_dev'][t] * self.time_series.dt[t]
455-
for t in self.time_steps) / horizon * self.prc_p_dev
456401

457402
# prefer discharging batteries completely before importing from grid
458403
if self.strategy.discharging_strategy == 'discharge_before_import':
@@ -559,17 +504,14 @@ def _add_energy_balance_constraints(self):
559504
self.problem += (self.variables['e_exp_lim_exc'][t]
560505
<= self.M * (1 - self.variables['z_exp_lim'][t]))
561506

562-
# track the horizon maximum and the distance from the level of the total grid power for every
563-
# side the strategy levels. Both include the portion beyond p_max_imp / p_max_exp, so they
564-
# stay correct in demand rate mode and when a limit is violated.
507+
# track the horizon maximum of the total grid power for every side the strategy levels. It
508+
# includes the portion beyond p_max_imp / p_max_exp, so it stays correct in demand rate mode
509+
# and when a limit is violated.
565510
#
566-
# Known blind spot: the level is free, and on a side that mostly rests at zero it settles at
567-
# zero. With p_grid >= 0 the term is then sum(p_grid[t] * dt[t]), the energy through the
568-
# side, which the energy balance already fixes - so it scores a plateau, a jagged profile and
569-
# a single spike of the same energy identically and orders none of them. Only the peak still
570-
# separates them there. The step to step ramp used to, and test_peak_leveling pins what that
571-
# costs; on the stored corpus it is worth at most 8 W of deviation, and it is not worth a
572-
# third of the solve time.
511+
# Known blind spot: the maximum is a single value out of the horizon, so once it is pinned by
512+
# a load the schedule cannot touch, nothing orders the steps below it any more - a plateau, a
513+
# jagged profile and a single spike of the same energy all score alike. A step to step ramp
514+
# term used to order them, and test_peak_leveling pins what dropping it costs.
573515
for side in self.peak_sides:
574516
grid_var, lim_exc_var = PEAK_SIDE_VARIABLES[side]
575517
# total grid power of this side per time step (W)
@@ -582,10 +524,6 @@ def _add_energy_balance_constraints(self):
582524

583525
for t in self.time_steps:
584526
self.problem += p_grid[t] <= self.variables[f'p_{side}_peak']
585-
# distance from the level: p_dev[t] >= |p_grid[t] - p_lvl|
586-
for t in self.time_steps:
587-
self.problem += self.variables[f'p_{side}_dev'][t] >= p_grid[t] - self.variables[f'p_{side}_lvl']
588-
self.problem += self.variables[f'p_{side}_dev'][t] >= self.variables[f'p_{side}_lvl'] - p_grid[t]
589527

590528
# if demand rate is applied, the maximum grid import power value
591529
# 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,

test_cases/028-attenuate-peaks-below-a-pinned-peak.json

Lines changed: 0 additions & 158 deletions
This file was deleted.

0 commit comments

Comments
 (0)