From 537ac158418ec78f6a2841c140b091936125c602 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 21 Jul 2026 09:33:46 +0200 Subject: [PATCH 1/3] fix: attenuate grid peaks by leveling import, not by rewarding charge power The strategy added a reward proportional to charge power times solar forecast. A reward that grows monotonically with charge power always pushes the variable to its bound, so the optimizer never picked an intermediate charge power. The coefficient also used gross solar forecast rather than surplus or grid draw, so the term was blind to household demand and could create a grid peak instead of attenuating one. Its magnitude scaled with the forecast, which broke the cost neutrality that strategy terms are supposed to keep. The strategy now penalizes the horizon maximum of the grid import power. The optimum sits on the resulting kink, so charging spreads over several time steps at partial power. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/client.gen.go | 4 +-- openapi.yaml | 2 +- src/optimizer/optimizer.py | 34 ++++++++++++++++------- tests/test_strategies.py | 55 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 13 deletions(-) create mode 100644 tests/test_strategies.py diff --git a/client/client.gen.go b/client/client.gen.go index cf50a8c0f..ece7bb601 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -200,7 +200,7 @@ type OptimizerStrategy struct { // ChargingStrategy 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 - // - attenuate_grid_peaks: charge at times with high solar yield to reduce the grid load + // - attenuate_grid_peaks: level the grid import profile, charging at partial power over several time steps instead of one peak ChargingStrategy OptimizerStrategyChargingStrategy `json:"charging_strategy,omitempty"` // DischargingStrategy Sets a strategy for charging in situations where choices are cost neutral. @@ -212,7 +212,7 @@ type OptimizerStrategy struct { // OptimizerStrategyChargingStrategy 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 -// - attenuate_grid_peaks: charge at times with high solar yield to reduce the grid load +// - attenuate_grid_peaks: level the grid import profile, charging at partial power over several time steps instead of one peak type OptimizerStrategyChargingStrategy string // OptimizerStrategyDischargingStrategy Sets a strategy for charging in situations where choices are cost neutral. diff --git a/openapi.yaml b/openapi.yaml index 7447c542e..a8d2e0b75 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -158,7 +158,7 @@ components: 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 - - attenuate_grid_peaks: charge at times with high solar yield to reduce the grid load + - attenuate_grid_peaks: level the grid import profile, charging at partial power over several time steps instead of one peak discharging_strategy: type: string enum: [none, discharge_before_import] diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index b878ec8e6..7fcafb231 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -82,19 +82,19 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries: self.max_import_price = np.max(self.time_series.p_N) # scaling base for penalty parameters. Make sure goal_penalty is always positive. - penalty_base = np.max([self.max_import_price, 0.1e-3]) + self.penalty_base = np.max([self.max_import_price, 0.1e-3]) # scaling for penalty parameters - self.prc_e_goal_pen = penalty_base * 10e1 - self.prc_p_goal_pen = penalty_base * np.max(self.time_series.dt) / 3600 * 10e1 - self.prc_soc_exc_pen = penalty_base * 10e2 + self.prc_e_goal_pen = self.penalty_base * 10e1 + self.prc_p_goal_pen = self.penalty_base * np.max(self.time_series.dt) / 3600 * 10e1 + self.prc_soc_exc_pen = self.penalty_base * 10e2 # penalty for exceeding grid import limit. Result shall not become infeasible but report the violation # with helpful information - self.prc_e_grid_imp_pen = penalty_base * 10e1 + self.prc_e_grid_imp_pen = self.penalty_base * 10e1 # penalty for exceeding the grid export limit. Result shall not become infeasible but report the 'lost' # solar power - self.prc_e_grid_exp_pen = penalty_base * 10e1 + self.prc_e_grid_exp_pen = self.penalty_base * 10e1 # if there is a demand rate given in the input, the grid import limit will be interpreted as the # threshold beyond wich the demand rate is to be applied. Compute a demand rate flag for use in the @@ -192,6 +192,10 @@ def _setup_variables(self): if self.is_grid_demand_rate_active: self.variables['p_max_imp_exc'] = pulp.LpVariable("p_max_imp_exc", lowBound=0) + # highest grid import power over the whole horizon (W), used by the peak attenuation strategy + if self.strategy.charging_strategy == 'attenuate_grid_peaks': + self.variables['p_imp_peak'] = pulp.LpVariable("p_imp_peak", lowBound=0) + # Binary variable: power flow direction to / from grid variables # these variables # 1. avoid direct export from import if export remuneration is greater than import cost @@ -305,11 +309,13 @@ def _setup_target_function(self): for t in self.time_steps: objective += - self.variables['e'][t] * self.min_import_price * 2e-5 * (self.T - t) - # prefer charging at high solar production times to unload public grid from peaks + # level the grid import profile to unload the public grid from peaks. + # the penalty sits on the horizon maximum instead of on charge power, so the optimizer spreads + # charging at partial power over several time steps rather than running one step at full power. + # penalty_base is used instead of min_import_price because negative market prices would turn + # this penalty into a reward for peaks. if self.strategy.charging_strategy == 'attenuate_grid_peaks': - for i, bat in enumerate(self.batteries): - for t in self.time_steps: - objective += self.variables['c'][i][t] * self.time_series.ft[t] * self.min_import_price * 1e-6 + objective += - self.variables['p_imp_peak'] * self.penalty_base * 1e-3 # prefer discharging batteries completely before importing from grid if self.strategy.discharging_strategy == 'discharge_before_import': @@ -402,6 +408,14 @@ def _add_energy_balance_constraints(self): self.problem += (self.variables['e_exp_lim_exc'][t] <= self.M * (1 - self.variables['z_exp_lim'][t])) + # track the horizon maximum of the total import power for the peak attenuation strategy + if self.strategy.charging_strategy == 'attenuate_grid_peaks': + for t in self.time_steps: + e_grid_imp = self.variables['n'][t] + if self.grid.p_max_imp is not None: + e_grid_imp = e_grid_imp + self.variables['e_imp_lim_exc'][t] + self.problem += e_grid_imp <= self.variables['p_imp_peak'] * self.time_series.dt[t] / 3600 + # if demand rate is applied, the maximum grid import power value # of all time steps drives the demand rate charge if self.is_grid_demand_rate_active: diff --git a/tests/test_strategies.py b/tests/test_strategies.py new file mode 100644 index 000000000..ca198a58d --- /dev/null +++ b/tests/test_strategies.py @@ -0,0 +1,55 @@ + +import numpy + +from optimizer.optimizer import BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData + + +def _optimizer(charging_strategy: str) -> Optimizer: + # four identical hours, no solar and no household load, so grid import equals battery charging. + # the charge goal needs less energy than a single time step can deliver, which leaves the + # optimizer free to choose between one full power step and several partial power steps. + return Optimizer( + strategy=OptimizationStrategy(charging_strategy=charging_strategy, discharging_strategy='none'), + grid=GridConfig(p_max_imp=None, p_max_exp=None, prc_p_exc_imp=None), + batteries=[BatteryConfig( + charge_from_grid=True, + discharge_to_grid=False, + s_capacity=20000, + s_min=0, + s_max=20000, + s_initial=0, + c_min=0, + c_max=8000, + d_max=0, + p_a=0, + s_goal=[0, 0, 0, 4000], + )], + time_series=TimeSeriesData( + dt=[3600] * 4, + gt=[0] * 4, + ft=[0] * 4, + p_N=[0.3e-3] * 4, + p_E=[0.0] * 4, + ), + ) + + +def test_attenuate_grid_peaks_charges_at_partial_power(): + result = _optimizer('attenuate_grid_peaks').solve() + + assert result['status'] == 'Optimal' + + charging_power = result['batteries'][0]['charging_power'] + # the goal is reached with the charge losses on top, spread evenly over all four time steps + expected = 4000 / 0.95 / 4 + + assert numpy.allclose(charging_power, expected, rtol=1e-3), \ + f"charging power {charging_power}, expected {expected} in every time step" + + +def test_no_strategy_leaves_charging_unleveled(): + # without the strategy nothing keeps the optimizer from putting all energy into few time steps + charging_power = _optimizer('none').solve()['batteries'][0]['charging_power'] + expected = 4000 / 0.95 / 4 + + assert not numpy.allclose(charging_power, expected, rtol=1e-3) From 046a8f7c57d94c0fcaeec3299a7c1f371c977e5a Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 21 Jul 2026 14:25:04 +0200 Subject: [PATCH 2/3] feat: split grid peak shaping into feed-in, demand and combined options The `attenuate_grid_peaks` charging strategy never produced partial charge power. It rewarded charge power multiplied by the solar forecast, and a reward that grows monotonically with charge power always drives that variable to its bound, so the optimizer chose full power or nothing. The coefficient used gross solar forecast instead of surplus or actual grid draw, which made the term blind to household demand and able to create a grid peak rather than attenuate one. Peak shaping is now expressed as a penalty on the horizon maximum of the grid power, and the metered side it applies to is selectable: - `attenuate_demand_peaks` levels the grid import profile - `attenuate_feedin_peaks` levels the grid export profile - `attenuate_grid_peaks` levels both The optimum sits on the resulting kink, so charging spreads over several time steps at partial power. The penalty scales from `penalty_base` rather than `min_import_price`, so negative market prices cannot flip it into a reward for peaks. Each peak tracks total grid power including the portion beyond `p_max_imp` / `p_max_exp`, so it stays correct in demand rate mode and when a limit is violated. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/client.gen.go | 16 +++++--- openapi.yaml | 6 ++- src/optimizer/optimizer.py | 46 ++++++++++++++++------ tests/test_strategies.py | 79 +++++++++++++++++++++++++++++--------- 4 files changed, 110 insertions(+), 37 deletions(-) diff --git a/client/client.gen.go b/client/client.gen.go index ece7bb601..081f91f64 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -31,9 +31,11 @@ const ( // Defines values for OptimizerStrategyChargingStrategy. const ( - OptimizerStrategyChargingStrategyAttenuateGridPeaks OptimizerStrategyChargingStrategy = "attenuate_grid_peaks" - OptimizerStrategyChargingStrategyChargeBeforeExport OptimizerStrategyChargingStrategy = "charge_before_export" - OptimizerStrategyChargingStrategyNone OptimizerStrategyChargingStrategy = "none" + OptimizerStrategyChargingStrategyAttenuateDemandPeaks OptimizerStrategyChargingStrategy = "attenuate_demand_peaks" + OptimizerStrategyChargingStrategyAttenuateFeedinPeaks OptimizerStrategyChargingStrategy = "attenuate_feedin_peaks" + OptimizerStrategyChargingStrategyAttenuateGridPeaks OptimizerStrategyChargingStrategy = "attenuate_grid_peaks" + OptimizerStrategyChargingStrategyChargeBeforeExport OptimizerStrategyChargingStrategy = "charge_before_export" + OptimizerStrategyChargingStrategyNone OptimizerStrategyChargingStrategy = "none" ) // Defines values for OptimizerStrategyDischargingStrategy. @@ -200,7 +202,9 @@ type OptimizerStrategy struct { // ChargingStrategy 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 - // - attenuate_grid_peaks: level the grid import profile, charging at partial power over several time steps instead of one peak + // - 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 ChargingStrategy OptimizerStrategyChargingStrategy `json:"charging_strategy,omitempty"` // DischargingStrategy Sets a strategy for charging in situations where choices are cost neutral. @@ -212,7 +216,9 @@ type OptimizerStrategy struct { // OptimizerStrategyChargingStrategy 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 -// - attenuate_grid_peaks: level the grid import profile, charging at partial power over several time steps instead of one peak +// - 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 type OptimizerStrategyChargingStrategy string // OptimizerStrategyDischargingStrategy Sets a strategy for charging in situations where choices are cost neutral. diff --git a/openapi.yaml b/openapi.yaml index a8d2e0b75..e41b10ce9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -153,12 +153,14 @@ components: properties: charging_strategy: type: string - enum: [none, charge_before_export, attenuate_grid_peaks] + enum: [none, charge_before_export, 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 - - attenuate_grid_peaks: level the grid import profile, charging at partial power over several time steps instead of one peak + - 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 discharging_strategy: type: string enum: [none, discharge_before_import] diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 7fcafb231..c3cf96140 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -14,6 +14,21 @@ class OptimizationStrategy: discharging_strategy: str +# charging strategies that level grid peaks, mapped to the metered sides they level. +# 'imp' is the demand side (grid import), 'exp' the feed-in side (grid export). +PEAK_STRATEGY_SIDES = { + 'attenuate_demand_peaks': ('imp',), + 'attenuate_feedin_peaks': ('exp',), + 'attenuate_grid_peaks': ('imp', 'exp'), +} + +# grid energy variable and limit exceedance variable per leveled side +PEAK_SIDE_VARIABLES = { + 'imp': ('n', 'e_imp_lim_exc'), + 'exp': ('e', 'e_exp_lim_exc'), +} + + @dataclass class GridConfig: p_max_imp: float @@ -103,6 +118,9 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries: if self.grid.p_max_imp is not None and self.grid.prc_p_exc_imp is not None: self.is_grid_demand_rate_active = True + # grid sides leveled by the active peak attenuation strategy, empty for all other strategies + self.peak_sides = PEAK_STRATEGY_SIDES.get(strategy.charging_strategy, ()) + def create_model(self): """ Create and initialize the MILP model @@ -192,9 +210,9 @@ def _setup_variables(self): if self.is_grid_demand_rate_active: self.variables['p_max_imp_exc'] = pulp.LpVariable("p_max_imp_exc", lowBound=0) - # highest grid import power over the whole horizon (W), used by the peak attenuation strategy - if self.strategy.charging_strategy == 'attenuate_grid_peaks': - self.variables['p_imp_peak'] = pulp.LpVariable("p_imp_peak", lowBound=0) + # highest grid power over the whole horizon (W) per side, used by the peak attenuation strategies + for side in self.peak_sides: + self.variables[f'p_{side}_peak'] = pulp.LpVariable(f"p_{side}_peak", lowBound=0) # Binary variable: power flow direction to / from grid variables # these variables @@ -309,13 +327,14 @@ def _setup_target_function(self): for t in self.time_steps: objective += - self.variables['e'][t] * self.min_import_price * 2e-5 * (self.T - t) - # level the grid import profile to unload the public grid from peaks. + # 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 instead of on charge power, so the optimizer spreads # charging at partial power over several time steps rather than running one step at full power. # penalty_base is used instead of min_import_price because negative market prices would turn # this penalty into a reward for peaks. - if self.strategy.charging_strategy == 'attenuate_grid_peaks': - objective += - self.variables['p_imp_peak'] * self.penalty_base * 1e-3 + for side in self.peak_sides: + objective += - self.variables[f'p_{side}_peak'] * self.penalty_base * 1e-3 # prefer discharging batteries completely before importing from grid if self.strategy.discharging_strategy == 'discharge_before_import': @@ -408,13 +427,16 @@ def _add_energy_balance_constraints(self): self.problem += (self.variables['e_exp_lim_exc'][t] <= self.M * (1 - self.variables['z_exp_lim'][t])) - # track the horizon maximum of the total import power for the peak attenuation strategy - if self.strategy.charging_strategy == 'attenuate_grid_peaks': + # track the horizon maximum of the total grid power for every side the strategy levels. + # the peak includes the portion beyond p_max_imp / p_max_exp, so it stays correct in + # demand rate mode and when a limit is violated. + for side in self.peak_sides: + grid_var, lim_exc_var = PEAK_SIDE_VARIABLES[side] for t in self.time_steps: - e_grid_imp = self.variables['n'][t] - if self.grid.p_max_imp is not None: - e_grid_imp = e_grid_imp + self.variables['e_imp_lim_exc'][t] - self.problem += e_grid_imp <= self.variables['p_imp_peak'] * self.time_series.dt[t] / 3600 + e_grid = self.variables[grid_var][t] + if lim_exc_var in self.variables: + e_grid = e_grid + self.variables[lim_exc_var][t] + self.problem += e_grid <= self.variables[f'p_{side}_peak'] * self.time_series.dt[t] / 3600 # if demand rate is applied, the maximum grid import power value # of all time steps drives the demand rate charge diff --git a/tests/test_strategies.py b/tests/test_strategies.py index ca198a58d..dbe8d9c8b 100644 --- a/tests/test_strategies.py +++ b/tests/test_strategies.py @@ -1,18 +1,27 @@ import numpy +import pytest from optimizer.optimizer import BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData +# charge energy needed to reach the 4 kWh goal, including the charge losses +EXPECTED_CHARGE = 4000 / 0.95 -def _optimizer(charging_strategy: str) -> Optimizer: - # four identical hours, no solar and no household load, so grid import equals battery charging. - # the charge goal needs less energy than a single time step can deliver, which leaves the - # optimizer free to choose between one full power step and several partial power steps. +# charging spread evenly over all four steps, which is the flat grid import profile +LEVELED_DEMAND = [EXPECTED_CHARGE / 4] * 4 +# charging placed on the two high yield steps, which is the flat grid export profile +SHAVED_FEEDIN = [0, EXPECTED_CHARGE / 2, EXPECTED_CHARGE / 2, 0] + + +def _optimizer(charging_strategy: str, ft: list, charge_from_grid: bool, p_E: float) -> Optimizer: + # four identical hours with a flat price profile, so placing the charge energy is cost neutral. + # the goal needs less energy than a single time step can deliver, which leaves the optimizer + # free to choose between one full power step and several partial power steps. return Optimizer( strategy=OptimizationStrategy(charging_strategy=charging_strategy, discharging_strategy='none'), grid=GridConfig(p_max_imp=None, p_max_exp=None, prc_p_exc_imp=None), batteries=[BatteryConfig( - charge_from_grid=True, + charge_from_grid=charge_from_grid, discharge_to_grid=False, s_capacity=20000, s_min=0, @@ -27,29 +36,63 @@ def _optimizer(charging_strategy: str) -> Optimizer: time_series=TimeSeriesData( dt=[3600] * 4, gt=[0] * 4, - ft=[0] * 4, + ft=ft, p_N=[0.3e-3] * 4, - p_E=[0.0] * 4, + p_E=[p_E] * 4, ), ) -def test_attenuate_grid_peaks_charges_at_partial_power(): - result = _optimizer('attenuate_grid_peaks').solve() +def _demand_case(charging_strategy: str) -> Optimizer: + # no solar and no household load, so grid import equals battery charging + return _optimizer(charging_strategy, ft=[0] * 4, charge_from_grid=True, p_E=0.0) + + +def _feedin_case(charging_strategy: str) -> Optimizer: + # solar surplus with a hump in the middle and no grid charging, so the battery can only take + # energy out of the export. the export peak drops only if charging happens inside the hump. + return _optimizer(charging_strategy, ft=[2000, 6000, 6000, 2000], charge_from_grid=False, p_E=0.1e-3) + + +@pytest.mark.parametrize('charging_strategy', ['attenuate_demand_peaks', 'attenuate_grid_peaks']) +def test_demand_peaks_are_leveled(charging_strategy): + result = _demand_case(charging_strategy).solve() assert result['status'] == 'Optimal' + charging_power = result['batteries'][0]['charging_power'] + + assert numpy.allclose(charging_power, LEVELED_DEMAND, rtol=1e-3), \ + f"charging power {charging_power}, expected {LEVELED_DEMAND}" + +@pytest.mark.parametrize('charging_strategy', ['attenuate_feedin_peaks', 'attenuate_grid_peaks']) +def test_feedin_peaks_are_shaved(charging_strategy): + result = _feedin_case(charging_strategy).solve() + + assert result['status'] == 'Optimal' charging_power = result['batteries'][0]['charging_power'] - # the goal is reached with the charge losses on top, spread evenly over all four time steps - expected = 4000 / 0.95 / 4 - assert numpy.allclose(charging_power, expected, rtol=1e-3), \ - f"charging power {charging_power}, expected {expected} in every time step" + assert numpy.allclose(charging_power, SHAVED_FEEDIN, atol=1), \ + f"charging power {charging_power}, expected {SHAVED_FEEDIN}" -def test_no_strategy_leaves_charging_unleveled(): - # without the strategy nothing keeps the optimizer from putting all energy into few time steps - charging_power = _optimizer('none').solve()['batteries'][0]['charging_power'] - expected = 4000 / 0.95 / 4 +def test_feedin_strategy_leaves_demand_peaks_unleveled(): + # the feed-in option must not level grid import, otherwise the options are not separable + charging_power = _demand_case('attenuate_feedin_peaks').solve()['batteries'][0]['charging_power'] + + assert not numpy.allclose(charging_power, LEVELED_DEMAND, rtol=1e-3) + - assert not numpy.allclose(charging_power, expected, rtol=1e-3) +def test_demand_strategy_leaves_feedin_peaks_unshaved(): + # the demand option must not shave grid export, otherwise the options are not separable + charging_power = _feedin_case('attenuate_demand_peaks').solve()['batteries'][0]['charging_power'] + + assert not numpy.allclose(charging_power, SHAVED_FEEDIN, atol=1) + + +def test_no_strategy_leaves_charging_unleveled(): + # without a strategy nothing keeps the optimizer from putting all energy into few time steps + assert not numpy.allclose( + _demand_case('none').solve()['batteries'][0]['charging_power'], LEVELED_DEMAND, rtol=1e-3) + assert not numpy.allclose( + _feedin_case('none').solve()['batteries'][0]['charging_power'], SHAVED_FEEDIN, atol=1) From 76168e8882ae6ce30ec7061271c33ae07b0f8d1f Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 24 Jul 2026 09:55:21 +0200 Subject: [PATCH 3/3] Delete tests/test_strategies.py --- tests/test_strategies.py | 98 ---------------------------------------- 1 file changed, 98 deletions(-) delete mode 100644 tests/test_strategies.py diff --git a/tests/test_strategies.py b/tests/test_strategies.py deleted file mode 100644 index dbe8d9c8b..000000000 --- a/tests/test_strategies.py +++ /dev/null @@ -1,98 +0,0 @@ - -import numpy -import pytest - -from optimizer.optimizer import BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData - -# charge energy needed to reach the 4 kWh goal, including the charge losses -EXPECTED_CHARGE = 4000 / 0.95 - -# charging spread evenly over all four steps, which is the flat grid import profile -LEVELED_DEMAND = [EXPECTED_CHARGE / 4] * 4 -# charging placed on the two high yield steps, which is the flat grid export profile -SHAVED_FEEDIN = [0, EXPECTED_CHARGE / 2, EXPECTED_CHARGE / 2, 0] - - -def _optimizer(charging_strategy: str, ft: list, charge_from_grid: bool, p_E: float) -> Optimizer: - # four identical hours with a flat price profile, so placing the charge energy is cost neutral. - # the goal needs less energy than a single time step can deliver, which leaves the optimizer - # free to choose between one full power step and several partial power steps. - return Optimizer( - strategy=OptimizationStrategy(charging_strategy=charging_strategy, discharging_strategy='none'), - grid=GridConfig(p_max_imp=None, p_max_exp=None, prc_p_exc_imp=None), - batteries=[BatteryConfig( - charge_from_grid=charge_from_grid, - discharge_to_grid=False, - s_capacity=20000, - s_min=0, - s_max=20000, - s_initial=0, - c_min=0, - c_max=8000, - d_max=0, - p_a=0, - s_goal=[0, 0, 0, 4000], - )], - time_series=TimeSeriesData( - dt=[3600] * 4, - gt=[0] * 4, - ft=ft, - p_N=[0.3e-3] * 4, - p_E=[p_E] * 4, - ), - ) - - -def _demand_case(charging_strategy: str) -> Optimizer: - # no solar and no household load, so grid import equals battery charging - return _optimizer(charging_strategy, ft=[0] * 4, charge_from_grid=True, p_E=0.0) - - -def _feedin_case(charging_strategy: str) -> Optimizer: - # solar surplus with a hump in the middle and no grid charging, so the battery can only take - # energy out of the export. the export peak drops only if charging happens inside the hump. - return _optimizer(charging_strategy, ft=[2000, 6000, 6000, 2000], charge_from_grid=False, p_E=0.1e-3) - - -@pytest.mark.parametrize('charging_strategy', ['attenuate_demand_peaks', 'attenuate_grid_peaks']) -def test_demand_peaks_are_leveled(charging_strategy): - result = _demand_case(charging_strategy).solve() - - assert result['status'] == 'Optimal' - charging_power = result['batteries'][0]['charging_power'] - - assert numpy.allclose(charging_power, LEVELED_DEMAND, rtol=1e-3), \ - f"charging power {charging_power}, expected {LEVELED_DEMAND}" - - -@pytest.mark.parametrize('charging_strategy', ['attenuate_feedin_peaks', 'attenuate_grid_peaks']) -def test_feedin_peaks_are_shaved(charging_strategy): - result = _feedin_case(charging_strategy).solve() - - assert result['status'] == 'Optimal' - charging_power = result['batteries'][0]['charging_power'] - - assert numpy.allclose(charging_power, SHAVED_FEEDIN, atol=1), \ - f"charging power {charging_power}, expected {SHAVED_FEEDIN}" - - -def test_feedin_strategy_leaves_demand_peaks_unleveled(): - # the feed-in option must not level grid import, otherwise the options are not separable - charging_power = _demand_case('attenuate_feedin_peaks').solve()['batteries'][0]['charging_power'] - - assert not numpy.allclose(charging_power, LEVELED_DEMAND, rtol=1e-3) - - -def test_demand_strategy_leaves_feedin_peaks_unshaved(): - # the demand option must not shave grid export, otherwise the options are not separable - charging_power = _feedin_case('attenuate_demand_peaks').solve()['batteries'][0]['charging_power'] - - assert not numpy.allclose(charging_power, SHAVED_FEEDIN, atol=1) - - -def test_no_strategy_leaves_charging_unleveled(): - # without a strategy nothing keeps the optimizer from putting all energy into few time steps - assert not numpy.allclose( - _demand_case('none').solve()['batteries'][0]['charging_power'], LEVELED_DEMAND, rtol=1e-3) - assert not numpy.allclose( - _feedin_case('none').solve()['batteries'][0]['charging_power'], SHAVED_FEEDIN, atol=1)