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
24 changes: 24 additions & 0 deletions client/client.gen.go

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

13 changes: 12 additions & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,19 @@ components:
enum: [none, discharge_before_import]
description: |
Sets a strategy for charging in situations where choices are cost neutral.
- none (default): no strategy set
- none (default): no strategy set
- discharge_before_import: discharge batteries before importing from grid
primary_goal:
type: string
enum: [minimize_cost, maximize_self_consumption]
description: |
Selects what the cost stage itself optimizes for. Unlike charging_strategy and
discharging_strategy, which only break ties between equally cheap schedules, this
changes what counts as optimal in the first place.
- minimize_cost (default): money, weighted by the price signals
- maximize_self_consumption: export is weighted as a cost like import instead of
revenue, so a battery with headroom is preferred over exporting even when both
are equally cheap in money terms
GridConfig:
type: object
properties:
Expand Down
14 changes: 11 additions & 3 deletions src/optimizer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from flask_restx import Api, Resource, fields
from werkzeug.exceptions import BadRequest

from .optimizer import CHARGING_STRATEGIES, DISCHARGING_STRATEGIES, BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData
from .optimizer import CHARGING_STRATEGIES, DISCHARGING_STRATEGIES, PRIMARY_GOALS, BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData
from .settings import OptimizerSettings

app = Flask(__name__)
Expand Down Expand Up @@ -94,7 +94,14 @@ def handle_validation_error(error):
'charging_strategy': fields.String(required=False, enum=list(CHARGING_STRATEGIES),
description='Sets a strategy for charging in situations where choices are cost neutral.'),
'discharging_strategy': fields.String(required=False, enum=list(DISCHARGING_STRATEGIES),
description='Sets a strategy for discharging in situations where choices are cost neutral.')
description='Sets a strategy for discharging in situations where choices are cost neutral.'),
'primary_goal': fields.String(required=False, enum=list(PRIMARY_GOALS),
description='Selects what the cost stage itself optimizes for, unlike charging_strategy/'
'discharging_strategy which only break ties between equally cheap schedules. '
'minimize_cost (default): money, weighted by the price signals. '
'maximize_self_consumption: export is weighted as a cost like import instead '
'of revenue, so a battery with headroom is preferred over exporting even when '
'both are equally cheap in money terms.')
})

grid_model = api.model('GridConfig', {
Expand Down Expand Up @@ -179,7 +186,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'),
primary_goal=strat_data.get('primary_goal', 'minimize_cost')
)

# parse grid configuration
Expand Down
21 changes: 17 additions & 4 deletions src/optimizer/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
class OptimizationStrategy:
charging_strategy: str
discharging_strategy: str
primary_goal: str = 'minimize_cost'


# charging strategies that level grid peaks, mapped to the metered sides they level.
Expand All @@ -31,6 +32,15 @@ class OptimizationStrategy:
CHARGING_STRATEGIES = ('none', 'charge_before_export', *PEAK_STRATEGY_SIDES)
DISCHARGING_STRATEGIES = ('none', 'discharge_before_import')

# primary_goal selects what the cost-stage objective (the real, non-negotiable optimization,
# see _setup_target_function) actually optimizes for. 'minimize_cost' is the historical
# behavior: money, weighted by the price signals. 'maximize_self_consumption' keeps the same
# price-weighted terms but treats export like import - a cost to minimize rather than revenue
# to collect - so a battery with headroom is preferred over exporting even when both are
# equally cheap in money terms. Unlike charging_strategy's preferences (a same-cost tiebreak,
# see _solve_preferences), this changes what counts as optimal in the first place.
PRIMARY_GOALS = ('minimize_cost', 'maximize_self_consumption')

# magnitude the largest objective coefficient is placed at before the model goes to the solver.
# CBC judges improvements against absolute tolerances (~1e-7), and with prices given per Wh the
# raw coefficients land close to that bound, so real improvements get pruned as numerical noise.
Expand Down Expand Up @@ -372,7 +382,12 @@ def _setup_target_function(self):
############################################################################
# actual cost & benefit elements

# Grid import cost (negative because we want to minimize cost) [currency unit]
# Grid import cost (negative because we want to minimize cost) [currency unit].
# Grid export revenue, or under maximize_self_consumption a cost instead (see
# PRIMARY_GOALS): both stay weighted by the real price signals, so a genuine feed-in
# spike still outweighs holding the energy, and the constraint penalties below (also
# calibrated from these prices via penalty_base) stay correctly scaled either way.
export_sign = -1 if self.strategy.primary_goal == 'maximize_self_consumption' else 1
for t in self.time_steps:
# if a demand rate beyond p_max_imp is applied, both portions have to be considered
# for energy cost. If only an import limit is given, there should never be power
Expand All @@ -389,9 +404,7 @@ def _setup_target_function(self):
# standard case
objective -= self.variables['n'][t] * self.time_series.p_N[t]

# Grid export revenue [currency unit]
for t in self.time_steps:
objective += self.variables['e'][t] * self.time_series.p_E[t]
objective += export_sign * self.variables['e'][t] * self.time_series.p_E[t]

# Final state of charge value [currency unit]
for i, bat in enumerate(self.batteries):
Expand Down
44 changes: 44 additions & 0 deletions tests/test_primary_goal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import pytest

from optimizer.optimizer import BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData


def build(primary_goal):
# one step, all PV surplus (ft) is above household demand (gt=0): a battery with full
# headroom (s_initial=0) could take all of it, but p_a is set far below p_E so under
# minimize_cost selling it now genuinely earns more than storing it does - export should win
return Optimizer(
strategy=OptimizationStrategy(charging_strategy='none', discharging_strategy='none',
primary_goal=primary_goal),
grid=GridConfig(p_max_imp=None, p_max_exp=None, prc_p_exc_imp=None),
batteries=[BatteryConfig(charge_from_grid=False, discharge_to_grid=False,
s_capacity=2000, s_min=0, s_max=2000, s_initial=0,
c_min=0, c_max=2000, d_max=0, p_a=0.00001)],
time_series=TimeSeriesData(dt=[3600], gt=[0], ft=[2000], p_N=[0.0003], p_E=[0.0003]),
eta_c=0.95, eta_d=0.95, M=1e6)


def test_minimize_cost_exports_when_storing_is_worth_less_than_selling():
result = build('minimize_cost').solve()

assert result['status'] == 'Optimal'
assert result['grid_export'][0] == pytest.approx(2000.0)
assert result['batteries'][0]['charging_power'][0] == pytest.approx(0.0)


def test_maximize_self_consumption_charges_instead_of_exporting():
# same request, only primary_goal differs: exporting is no longer revenue but a cost
# weighted the same as p_E, so a battery with headroom is preferred over selling even
# though selling would have earned strictly more money
result = build('maximize_self_consumption').solve()

assert result['status'] == 'Optimal'
assert result['batteries'][0]['charging_power'][0] == pytest.approx(2000.0)
assert result['grid_export'][0] == pytest.approx(0.0)


def test_primary_goal_defaults_to_minimize_cost():
# OptimizationStrategy without an explicit primary_goal behaves like minimize_cost,
# so existing callers that never set the field are unaffected
strategy = OptimizationStrategy(charging_strategy='none', discharging_strategy='none')
assert strategy.primary_goal == 'minimize_cost'