Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Inspired by https://github.com/Akkudoktor-EOS/EOS/pull/462
- **Respects charging goals**: an EV can be required to reach a given state of charge by a given time step, or to charge at a minimum power while it is plugged in.
- **Honours grid limits** for import and export power, and supports a demand rate charged on the highest power drawn beyond a threshold.
- **Never returns "infeasible" for a goal it cannot reach.** Goals, minimum charge demand and grid limits are soft constraints backed by penalties, so an over-constrained request still yields the best achievable schedule plus a flag telling you which limit was violated.
- **Optional strategies** break ties that cost nothing: charge before exporting, discharge before importing, or level grid peaks on the import side, the feed-in side, or both.
- **Optional strategies** break ties that cost nothing: level grid peaks on the import side, the feed-in side, or both, and discharge before importing. Independently of those, `battery_first` fills the batteries as early as the rest of the model allows.

## Example

Expand All @@ -26,7 +26,7 @@ One day, hourly steps: a 10 kWh home battery, an EV that must reach 40 kWh by 08
<img alt="Household demand, PV forecast and dynamic import tariff over 24 hours" src="docs/img/example-input-light.svg">
</picture>

The optimizer buys all 29 kWh of grid energy in the three cheapest hours of the night, filling the EV to its goal by 04:00 — four hours early, because energy later is more expensive. Midday PV surplus goes into the home battery instead of the grid, since `charge_before_export` makes self-consumption the tie-breaker. The 44 ct evening peak is then covered entirely from storage: after 04:00 the house imports nothing at all.
The optimizer buys all 29 kWh of grid energy in the three cheapest hours of the night, filling the EV to its goal by 04:00 — four hours early, because energy later is more expensive. Midday PV surplus goes into the home battery instead of the grid, since `battery_first` makes self-consumption the tie-breaker. The 44 ct evening peak is then covered entirely from storage: after 04:00 the house imports nothing at all.

<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/img/example-result-dark.svg">
Expand All @@ -44,13 +44,22 @@ Cheapest is not always kindest to the grid connection. The same house on a *flat
<img alt="Grid exchange over 24 hours without a strategy and with attenuate_grid_peaks, showing the import peak dropping from 11.5 kW to 3.6 kW" src="docs/img/example-peak-light.svg">
</picture>

A cap and a ramp limit say how high the grid profile may go, not when the batteries fill, and on a flat tariff nothing else decides it either — so a levelled day would otherwise return a schedule as arbitrary as no strategy at all, sitting on an empty battery for a day and a half. `battery_first` is the separate answer to that separate question: it holds the surplus back from the grid until the batteries have taken it, and pulls grid import forward for a battery that has no surplus to hold back. Because it is orthogonal, it combines with any `charging_strategy`, and on its own it is simply "fill the batteries first, shape nothing".

Filling sooner is not free on either side: a battery that is full by the first hour has no room left for the midday solar peak, and one filled at full power draws a taller import peak than one trickled. Attenuation therefore keeps priority — where the two disagree, the profile the request asked to level is the one that survives. On a side a strategy levels, that peak is the entire point. On a side it does not level, there is no peak worth protecting and filling early takes it outright. So `attenuate_demand_peaks` with `battery_first` fills at full rate and spends the feed-in peak nobody asked it to shave, `attenuate_feedin_peaks` keeps its feed-in peak and fills at the rate levelling leaves it, and `attenuate_grid_peaks` levels both sides and so protects both. No case pays real money for the difference.

<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/img/example-early-dark.svg">
<img alt="Energy charged into the batteries over two days for three strategies, before and after the early charge tie break: attenuate_demand_peaks moves its half charge point from hour 45 to hour 2, the other two are unchanged" src="docs/img/example-early-light.svg">
</picture>

## API

`POST /optimize/charge-schedule` takes the whole problem as one JSON document and returns the schedule. `GET /optimize/health` is the liveness probe. Every field is documented in [`openapi.yaml`](openapi.yaml).

```jsonc
{
"strategy": { "charging_strategy": "charge_before_export" },
"strategy": { "battery_first": true },
"grid": { "p_max_exp": 7000 }, // W
"batteries": [
{
Expand Down
17 changes: 10 additions & 7 deletions client/client.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/img/example-early-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/img/example-early-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 11 additions & 4 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,21 @@ components:
properties:
charging_strategy:
type: string
enum: [none, charge_before_export, attenuate_demand_peaks, attenuate_feedin_peaks, attenuate_grid_peaks]
enum: [none, attenuate_demand_peaks, attenuate_feedin_peaks, attenuate_grid_peaks]
description: |
Sets a strategy for charging in situations where choices are cost neutral.
- none (default): no strategy set
- charge_before_export: charge batteries before exporting to grid
Selects what to shape about the grid profile, in situations where choices are cost neutral.
- none (default): no profile shaping
- attenuate_demand_peaks: level the grid import profile, charging at partial power over several time steps instead of one peak
- attenuate_feedin_peaks: level the grid export profile, charging to shave solar feed-in peaks
- attenuate_grid_peaks: level both the grid import and the grid export profile
battery_first:
type: boolean
default: false
description: |
Fill the batteries as early as the rest of the model allows, holding the surplus back
from the grid until they have taken it. Orthogonal to charging_strategy, so it combines
with any of them; where the two disagree, attenuation keeps priority and the profile
being levelled is the one that survives.
discharging_strategy:
type: string
enum: [none, discharge_before_import]
Expand Down
7 changes: 5 additions & 2 deletions src/optimizer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ def handle_validation_error(error):
# Input models for API documentation
strategy_model = api.model('OptimizationStrategy', {
'charging_strategy': fields.String(required=False, description='Sets a strategy for charging in situations where choices are cost neutral.'),
'discharging_strategy': fields.String(required=False, description='Sets a strategy for discharging in situations where choices are cost neutral.')
'discharging_strategy': fields.String(required=False, description='Sets a strategy for discharging in situations where choices are cost neutral.'),
'battery_first': fields.Boolean(required=False, description='Fill the batteries as early as possible. '
'Combines with any charging_strategy; attenuation keeps priority.')
})

grid_model = api.model('GridConfig', {
Expand Down Expand Up @@ -177,7 +179,8 @@ def post(self):
strat_data = data.get('strategy', {})
strategy = OptimizationStrategy(
charging_strategy=strat_data.get('charging_strategy', 'none'),
discharging_strategy=strat_data.get('discharging_strategy', 'none')
discharging_strategy=strat_data.get('discharging_strategy', 'none'),
battery_first=strat_data.get('battery_first', False)
)

# parse grid configuration
Expand Down
58 changes: 52 additions & 6 deletions src/optimizer/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
class OptimizationStrategy:
charging_strategy: str
discharging_strategy: str
# fill the batteries as early as the rest of the model allows. Orthogonal to
# charging_strategy, which says what to shape about the grid profile and not when to charge,
# so it combines with any of them - including none, which is what asking for an early charge
# and no profile shaping at all means.
battery_first: bool = False


# charging strategies that level grid peaks, mapped to the metered sides they level.
Expand Down Expand Up @@ -178,6 +183,41 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries:
# grid sides leveled by the active peak attenuation strategy, empty for all other strategies
self.peak_sides = PEAK_STRATEGY_SIDES.get(strategy.charging_strategy, ())

self.battery_first = strategy.battery_first

# weights battery_first fills the batteries early at, one per grid side: prc_e_early makes
# early export expensive so the surplus charges the battery before it leaves, prc_n_early
# does the same for a battery that has no surplus to hold back and charges from the grid.
#
# Filling sooner is not free on either side. A battery that is full by the first hour has
# no room left for the midday solar peak, so that peak leaves over the grid instead, and
# one filled at full power draws a taller import peak than one trickled. Attenuation
# therefore outranks battery_first: where the two want different things, the profile the
# request asked to level is the one that survives.
#
# - a side an attenuation strategy levels keeps its peak, so the weight stays two orders
# below prc_p_ramp and only picks between schedules the leveling rates equal
# - a side nothing levels has no peak worth protecting, so earliness takes it outright
#
# attenuate_feedin_peaks with battery_first therefore keeps its feed-in peak and fills at
# the rate leveling leaves it, while attenuate_demand_peaks fills at full rate and spends
# the feed-in peak nobody asked it to shave. attenuate_grid_peaks levels both and protects
# both, and battery_first on its own has neither peak to respect.
#
# The unprotected weight is the coefficient the charge_before_export strategy carried
# before it became this option, so that every schedule it used to return is unchanged: its
# term was e[t] * min_import_price * 2e-5 * (T - t) and the terms below carry (T - t) / T,
# which is where the factor T comes from. Eight of the stored cases move without it.
#
# The battery count is part of that coefficient because the strategy added its term inside
# a loop over the batteries while the term itself never referenced one, so a two battery
# request weighted it twice. That is preserved here to keep this a pure refactor; it is a
# latent bug and worth removing on its own, which will move those schedules.
early_unprotected = self.min_import_price * 2e-5 * self.T * len(self.batteries)
early_protected = self.penalty_base * 1e-7
self.prc_e_early = early_protected if 'exp' in self.peak_sides else early_unprotected
self.prc_n_early = early_protected if 'imp' in self.peak_sides else early_unprotected

def create_model(self):
"""
Create and initialize the MILP model
Expand Down Expand Up @@ -387,12 +427,6 @@ def _setup_target_function(self):
#############################################################################
# Secondary strategies to implement preferences without impact to actual cost

# prefer charging first, then grid export
if self.strategy.charging_strategy == 'charge_before_export':
for i, bat in enumerate(self.batteries):
for t in self.time_steps:
objective += - self.variables['e'][t] * self.min_import_price * 2e-5 * (self.T - t)

# level the grid profile to unload the public grid from peaks. attenuate_demand_peaks levels
# grid import, attenuate_feedin_peaks levels grid export, attenuate_grid_peaks levels both.
# the penalty sits on the horizon maximum and on the step to step ramp instead of on charge
Expand All @@ -404,6 +438,18 @@ def _setup_target_function(self):
objective += - self.variables[f'p_{side}_peak'] * self.prc_p_peak
objective += - pulp.lpSum(self.variables[f'p_{side}_ramp']) * self.prc_p_ramp

# fill the batteries early. Peak and ramp say how high the grid profile may go, not when
# the batteries fill, and under flat commercials nothing else decides it either, so a
# levelled day would otherwise return a schedule as arbitrary as no strategy at all - the
# stored examples charged in the last third of the horizon. Deferring export holds the
# surplus back until the battery has taken it; penalizing late import pulls forward the
# charge of a battery that has no surplus to hold back. The weights carry the precedence
# against attenuation, see prc_e_early / prc_n_early.
if self.battery_first:
for t in self.time_steps:
objective += - self.variables['e'][t] * self.prc_e_early * (self.T - t) / self.T
objective += - self.variables['n'][t] * self.prc_n_early * t / self.T
Comment thread
andig marked this conversation as resolved.

# prefer discharging batteries completely before importing from grid
if self.strategy.discharging_strategy == 'discharge_before_import':
for i, bat in enumerate(self.batteries):
Expand Down
3 changes: 2 additions & 1 deletion test_cases/009-discharge-before-import.json
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,8 @@
"eta_d": 0.9,
"grid": {},
"strategy": {
"charging_strategy": "charge_before_export",
"charging_strategy": "none",
"battery_first": true,
"discharging_strategy": "discharge_before_import"
},
"time_series": {
Expand Down
3 changes: 2 additions & 1 deletion test_cases/010-infesible-charge-goal.json
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,8 @@
"eta_d": 0.9,
"grid": {},
"strategy": {
"charging_strategy": "charge_before_export"
"charging_strategy": "none",
"battery_first": true
},
"time_series": {
"dt": [
Expand Down
3 changes: 2 additions & 1 deletion test_cases/011-infeasible-charge-demand.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
"eta_d": 0.9,
"grid": {},
"strategy": {
"charging_strategy": "charge_before_export",
"charging_strategy": "none",
"battery_first": true,
"discharging_strategy": "discharge_before_import"
},
"time_series": {
Expand Down
3 changes: 2 additions & 1 deletion test_cases/012-early-charging-not-perfect.json
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,8 @@
"eta_d": 0.9,
"grid": {},
"strategy": {
"charging_strategy": "charge_before_export",
"charging_strategy": "none",
"battery_first": true,
"discharging_strategy": "discharge_before_import"
},
"time_series": {
Expand Down
3 changes: 2 additions & 1 deletion test_cases/013-grid-export-limit-hit.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"p_max_exp": 15000
},
"strategy": {
"charging_strategy": "charge_before_export"
"charging_strategy": "none",
"battery_first": true
},
"time_series": {
"dt": [
Expand Down
3 changes: 2 additions & 1 deletion test_cases/014-grid-import-limit-violation.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,8 @@
"p_max_imp": 3300
},
"strategy": {
"charging_strategy": "charge_before_export"
"charging_strategy": "none",
"battery_first": true
},
"time_series": {
"dt": [
Expand Down
3 changes: 2 additions & 1 deletion test_cases/015-low-soc-initial.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"eta_c": 0.9,
"eta_d": 0.9,
"strategy": {
"charging_strategy": "charge_before_export",
"charging_strategy": "none",
"battery_first": true,
"discharging_strategy": "discharge_before_import"
},
"time_series": {
Expand Down
3 changes: 2 additions & 1 deletion test_cases/016-battery-charge-priotization-1.json
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,8 @@
"eta_d": 0.9,
"grid": {},
"strategy": {
"charging_strategy": "charge_before_export",
"charging_strategy": "none",
"battery_first": true,
"discharging_strategy": "discharge_before_import"
},
"time_series": {
Expand Down
3 changes: 2 additions & 1 deletion test_cases/017-battery-charge-priotization-2.json
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,8 @@
"eta_d": 0.9,
"grid": {},
"strategy": {
"charging_strategy": "charge_before_export",
"charging_strategy": "none",
"battery_first": true,
"discharging_strategy": "discharge_before_import"
},
"time_series": {
Expand Down
3 changes: 2 additions & 1 deletion test_cases/018-high-soc-initial.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"eta_c": 0.9,
"eta_d": 0.9,
"strategy": {
"charging_strategy": "charge_before_export"
"charging_strategy": "none",
"battery_first": true
},
"time_series": {
"dt": [
Expand Down
3 changes: 2 additions & 1 deletion test_cases/019-unexpected-charge-spikes.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"eta_d": 0.9,
"grid": {},
"strategy": {
"charging_strategy": "charge_before_export",
"charging_strategy": "none",
"battery_first": true,
"discharging_strategy": "discharge_before_import"
},
"time_series": {
Expand Down
3 changes: 2 additions & 1 deletion test_cases/020-weird-charging-at-night.json
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,8 @@
"eta_d": 0.9,
"grid": {},
"strategy": {
"charging_strategy": "charge_before_export",
"charging_strategy": "none",
"battery_first": true,
"discharging_strategy": "discharge_before_import"
},
"time_series": {
Expand Down
3 changes: 2 additions & 1 deletion test_cases/021-min-pv-use-case-with-weird-behavior.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@
"eta_d": 0.9,
"grid": {},
"strategy": {
"charging_strategy": "charge_before_export",
"charging_strategy": "none",
"battery_first": true,
"discharging_strategy": "discharge_before_import"
},
"time_series": {
Expand Down
3 changes: 2 additions & 1 deletion test_cases/023-c_min-limit-kept-with-p_demand-set.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@
"eta_c": 0.9,
"eta_d": 0.9,
"strategy": {
"charging_strategy": "charge_before_export"
"charging_strategy": "none",
"battery_first": true
},
"time_series": {
"dt": [
Expand Down
Loading
Loading