From 35a13ad465fcf9880025125d918131912160bc3d Mon Sep 17 00:00:00 2001 From: djfanatix <43752712+djfanatix@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:06:42 +0200 Subject: [PATCH] feat: add primary_goal to prefer self-consumption over selling charging_strategy/discharging_strategy only break ties between schedules the cost stage already finds equally cheap - they never change what counts as optimal. On a flat feed-in rate (or any slot where p_a for a battery's stored energy undercuts p_E/eta_c after efficiency loss), the cost stage itself decides exporting beats storing, and no tiebreaker can undo that. primary_goal: maximize_self_consumption changes the cost stage instead: export is now weighted like import - a cost, at the same p_E it used to earn as revenue - rather than money the solver collects. A battery with headroom is then preferred over exporting even when exporting would strictly earn more, without touching the feasibility constraints, the preference stage, or (with primary_goal left at its minimize_cost default) any existing request's behavior. - optimizer.py: OptimizationStrategy gains primary_goal (default 'minimize_cost'); PRIMARY_GOALS lists the valid values next to CHARGING_STRATEGIES/DISCHARGING_STRATEGIES. _setup_target_function flips the sign on the export term for maximize_self_consumption; the import term, the p_a final-value term and penalty_base's price-based scaling are untouched, so constraint penalties stay correctly calibrated in both modes. - app.py / openapi.yaml: primary_goal on OptimizationStrategy, validated against PRIMARY_GOALS the same way the other two strategy fields are. - client: regenerated (go generate ./...). - tests/test_primary_goal.py: a single-step case where p_a is set below p_E/eta_c on purpose - minimize_cost exports the full surplus, maximize_self_consumption charges the battery with it instead; plus a default-value regression test. Co-Authored-By: Claude Sonnet 5 --- client/client.gen.go | 24 +++++++++++++++++++++ openapi.yaml | 13 ++++++++++- src/optimizer/app.py | 14 +++++++++--- src/optimizer/optimizer.py | 21 ++++++++++++++---- tests/test_primary_goal.py | 44 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 tests/test_primary_goal.py diff --git a/client/client.gen.go b/client/client.gen.go index b4fc7a356..16ccd7562 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -45,6 +45,12 @@ const ( OptimizerStrategyDischargingStrategyNone OptimizerStrategyDischargingStrategy = "none" ) +// Defines values for OptimizerStrategyPrimaryGoal. +const ( + MaximizeSelfConsumption OptimizerStrategyPrimaryGoal = "maximize_self_consumption" + MinimizeCost OptimizerStrategyPrimaryGoal = "minimize_cost" +) + // BatteryConfig defines model for BatteryConfig. type BatteryConfig struct { // CMax Maximum charge power in W @@ -221,6 +227,15 @@ type OptimizerStrategy struct { // - none (default): no strategy set // - discharge_before_import: discharge batteries before importing from grid DischargingStrategy OptimizerStrategyDischargingStrategy `json:"discharging_strategy,omitempty"` + + // PrimaryGoal 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 + PrimaryGoal OptimizerStrategyPrimaryGoal `json:"primary_goal,omitempty"` } // OptimizerStrategyChargingStrategy Sets a strategy for charging in situations where choices are cost neutral. @@ -236,6 +251,15 @@ type OptimizerStrategyChargingStrategy string // - discharge_before_import: discharge batteries before importing from grid type OptimizerStrategyDischargingStrategy string +// OptimizerStrategyPrimaryGoal 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 +type OptimizerStrategyPrimaryGoal string + // TimeSeries defines model for TimeSeries. type TimeSeries struct { // Dt Duration in seconds for each time step (s) diff --git a/openapi.yaml b/openapi.yaml index 43c58e788..3dc3d1933 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: diff --git a/src/optimizer/app.py b/src/optimizer/app.py index a355b2d1c..45f639bf2 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -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__) @@ -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', { @@ -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 diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 7746e52ad..1d61c111c 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -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. @@ -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. @@ -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 @@ -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): diff --git a/tests/test_primary_goal.py b/tests/test_primary_goal.py new file mode 100644 index 000000000..6ce8aa43a --- /dev/null +++ b/tests/test_primary_goal.py @@ -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'