From de49e9ae530d2e528a51a42005c50342f9df34dc Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 30 Jul 2026 12:17:26 +0200 Subject: [PATCH 1/4] fix: let the peak strategies charge before they export The peak weights say how high the grid profile may go, not when the batteries fill. Everything below the cap and inside the ramp is rated equally, so on a flat tariff, where no price decides it either, the schedule that comes back is as arbitrary as with no strategy at all. So the peak strategies defer export as well, the tie break charge_before_export already makes, at prc_e_early = penalty_base * 1e-7. That is two orders below the ramp weight, so it can only pick between schedules the leveling rates equal. Ported from #125 (merged into perf/two-stage-solve instead of main) onto main's single-objective structure; test_early_charge.py's cost-neutrality check uses s0-insensitive economics from the result instead of the two-stage branch's cost_objective, which main does not have. Co-Authored-By: Claude Sonnet 5 --- README.md | 7 ++++ docs/img/example-early-dark.svg | 1 + docs/img/example-early-light.svg | 1 + src/optimizer/optimizer.py | 16 ++++++++ tests/test_early_charge.py | 67 ++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+) create mode 100644 docs/img/example-early-dark.svg create mode 100644 docs/img/example-early-light.svg create mode 100644 tests/test_early_charge.py diff --git a/README.md b/README.md index 969782a44..c5d601245 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ Cheapest is not always kindest to the grid connection. The same house on a *flat 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 +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. The peak strategies therefore hold the surplus back from the grid the same way `charge_before_export` does, so that a levelled day still charges first and exports afterwards. The weight is two orders below the ramp, so it only picks between schedules the levelling rates equal — where the two disagree, as on the feed-in side below, levelling keeps the last word. + + + + 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 + + ## 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). diff --git a/docs/img/example-early-dark.svg b/docs/img/example-early-dark.svg new file mode 100644 index 000000000..9f5c46baf --- /dev/null +++ b/docs/img/example-early-dark.svg @@ -0,0 +1 @@ +Charge timing before and after the early charge tie breakafterbeforeattenuate_demand_peakshalf charged after 44.9 h, now after 1.9 h05101520kWh06121824303642hattenuate_feedin_peakshalf charged after 24.6 h, unchanged05101520kWh06121824303642hcharge_before_exporthalf charged after 1.9 h, unchanged05101520kWh06121824303642h \ No newline at end of file diff --git a/docs/img/example-early-light.svg b/docs/img/example-early-light.svg new file mode 100644 index 000000000..75b37d081 --- /dev/null +++ b/docs/img/example-early-light.svg @@ -0,0 +1 @@ +Charge timing before and after the early charge tie breakafterbeforeattenuate_demand_peakshalf charged after 44.9 h, now after 1.9 h05101520kWh06121824303642hattenuate_feedin_peakshalf charged after 24.6 h, unchanged05101520kWh06121824303642hcharge_before_exporthalf charged after 1.9 h, unchanged05101520kWh06121824303642h \ No newline at end of file diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 67b0d984d..27cd88d55 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -175,6 +175,12 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries: self.prc_p_peak = self.penalty_base * 1e-3 self.prc_p_ramp = self.penalty_base * 1e-5 + # weight the peak leveling strategies defer grid export at, so that a leveled profile + # still fills the batteries before it sends the surplus out. Two orders below the ramp + # weight, so leveling decides wherever the two disagree and this only picks between the + # schedules leveling rates equal. + self.prc_e_early = self.penalty_base * 1e-7 + # grid sides leveled by the active peak attenuation strategy, empty for all other strategies self.peak_sides = PEAK_STRATEGY_SIDES.get(strategy.charging_strategy, ()) @@ -404,6 +410,16 @@ 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 + # peak and ramp say how high the grid profile may go, not when the batteries fill, and + # under flat commercials that leaves most of the horizon undecided: the schedule then comes + # back as arbitrary as with no strategy at all, with the stored examples charging in the + # last third of the day. So the peak strategies defer export as well, the same tie break + # charge_before_export makes, at prc_e_early instead of its weight so that leveling keeps + # the last word. + if self.peak_sides: + for t in self.time_steps: + objective += - self.variables['e'][t] * self.prc_e_early * (self.T - t) / self.T + # prefer discharging batteries completely before importing from grid if self.strategy.discharging_strategy == 'discharge_before_import': for i, bat in enumerate(self.batteries): diff --git a/tests/test_early_charge.py b/tests/test_early_charge.py new file mode 100644 index 000000000..e1539ca2b --- /dev/null +++ b/tests/test_early_charge.py @@ -0,0 +1,67 @@ +import pytest + +from optimizer.optimizer import BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData + +# flat prices and a battery worth more than the feed-in revenue: filling it is the money answer, +# when to fill it is not decided by money at all. Four steps of surplus, room for two of them. +STEPS = 4 +SURPLUS = 2000.0 +P_N = 0.0003 +P_E = 0.0001 +P_A = 0.0004 + + +def build(charging_strategy): + 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=False, discharge_to_grid=False, + s_capacity=10000, s_min=0, s_max=4000, s_initial=0, + c_min=0, c_max=SURPLUS, d_max=0, p_a=P_A)], + time_series=TimeSeriesData(dt=[3600] * STEPS, gt=[0] * STEPS, ft=[SURPLUS] * STEPS, + p_N=[P_N] * STEPS, p_E=[P_E] * STEPS), + eta_c=1.0, eta_d=1.0, M=1e6) + + +def economics(result): + """s0-insensitive real money: import cost, export revenue, final battery value.""" + battery = result['batteries'][0] + return (- sum(gi * P_N for gi in result['grid_import']) + + sum(ge * P_E for ge in result['grid_export']) + + battery['state_of_charge'][-1] * P_A) + + +def test_a_leveled_import_profile_still_charges_before_it_exports(): + """ + The peak weights rate every schedule with the same maximum and the same ramp equally, and + nothing here draws from the grid at all, so attenuate_demand_peaks used to leave the whole + horizon open: the surplus went out first and the battery took the last two steps, the same + schedule no strategy at all returns. Grid shaping is not a reason to sit on an empty battery. + """ + result = build('attenuate_demand_peaks').solve() + + assert result['status'] == 'Optimal' + charging = result['batteries'][0]['charging_power'] + assert charging[:2] == pytest.approx([SURPLUS, SURPLUS]) + assert charging[2:] == pytest.approx([0.0, 0.0]) + + +def test_leveling_the_feed_in_side_keeps_the_last_word(): + """ + The same tie break on attenuate_feedin_peaks, where it collides with the strategy itself: + charging early leaves the export as two full steps and two empty ones. Leveling wins, the + export stays flat at half the surplus and the battery fills alongside it. + """ + result = build('attenuate_feedin_peaks').solve() + + assert result['status'] == 'Optimal' + assert result['grid_export'] == pytest.approx([SURPLUS / 2] * STEPS) + + +def test_the_tie_break_stays_cost_neutral(): + """it only picks between schedules, it does not buy the early charge with money""" + values = {} + for strategy in ('none', 'attenuate_demand_peaks'): + values[strategy] = economics(build(strategy).solve()) + + assert values['attenuate_demand_peaks'] == pytest.approx(values['none']) From 7665c38b97b767316efa45dcd09c60d50f45f7c2 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 30 Jul 2026 12:59:21 +0200 Subject: [PATCH 2/4] fix: pull grid import forward as well, not only defer export prc_e_early moves a schedule by making early export expensive, which only reaches a battery that has surplus to hold back. A battery charging purely from the grid sees e[t] = 0 whatever the timing, so nothing decided when it filled: with attenuate_feedin_peaks, which levels the feed-in side only and leaves the import profile entirely open, the charge landed in scattered late steps with the battery empty in between ([0, 0, 0, 2000, 0, 0, 0, 2000] over eight steps). prc_n_early mirrors the tie break onto import at the same weight, penalizing import that lands late. Measured on the captured cases, real money is unchanged to six decimals on all four, and the stored 024-027 attenuate cases are untouched. Note what this does not do: on the side a strategy actually levels it stays inert, because a flat profile already is the lowest peak - attenuate_demand_peaks spreads its import over the whole horizon rather than taking it early, and the tie break is correctly too weak to buy earliness with peak. Where an explicit discharging strategy pushes import the other way, discharge_before_import outweighs this by ~50x and keeps precedence. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- src/optimizer/optimizer.py | 12 ++++++++ tests/test_early_charge.py | 60 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c5d601245..29879592a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Cheapest is not always kindest to the grid connection. The same house on a *flat 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 -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. The peak strategies therefore hold the surplus back from the grid the same way `charge_before_export` does, so that a levelled day still charges first and exports afterwards. The weight is two orders below the ramp, so it only picks between schedules the levelling rates equal — where the two disagree, as on the feed-in side below, levelling keeps the last word. +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. The peak strategies therefore hold the surplus back from the grid the same way `charge_before_export` does, so that a levelled day still charges first and exports afterwards, and pull grid import forward for a battery that has no surplus to hold back. Both weights are two orders below the ramp, so they only pick between schedules the levelling rates equal — where the two disagree, as on the feed-in side below, levelling keeps the last word. diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 27cd88d55..30db079a7 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -180,6 +180,10 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries: # weight, so leveling decides wherever the two disagree and this only picks between the # schedules leveling rates equal. self.prc_e_early = self.penalty_base * 1e-7 + # same tie break for the demand side: a battery charging purely from the grid, with no + # surplus to hold back, gets nothing from prc_e_early above. Same weight, mirrored onto + # import instead of export. + self.prc_n_early = self.penalty_base * 1e-7 # grid sides leveled by the active peak attenuation strategy, empty for all other strategies self.peak_sides = PEAK_STRATEGY_SIDES.get(strategy.charging_strategy, ()) @@ -420,6 +424,14 @@ def _setup_target_function(self): for t in self.time_steps: objective += - self.variables['e'][t] * self.prc_e_early * (self.T - t) / self.T + # same tie break for a battery with no surplus to hold back: charging is the only + # flexible part of grid import (household demand gt is fixed per step), so penalizing + # import that lands late has the same effect on the import side that deferring export + # has on the feed-in side above. + if self.peak_sides: + for t in self.time_steps: + objective += - self.variables['n'][t] * self.prc_n_early * t / self.T + # prefer discharging batteries completely before importing from grid if self.strategy.discharging_strategy == 'discharge_before_import': for i, bat in enumerate(self.batteries): diff --git a/tests/test_early_charge.py b/tests/test_early_charge.py index e1539ca2b..192690089 100644 --- a/tests/test_early_charge.py +++ b/tests/test_early_charge.py @@ -23,6 +23,28 @@ def build(charging_strategy): eta_c=1.0, eta_d=1.0, M=1e6) +# the import side needs a longer horizon than the export side to show anything: with exactly two +# steps of room in four the schedule is pinned either way, and only a horizon with slack leaves the +# timing of the import open at all +GRID_STEPS = 8 + + +def build_grid_only(charging_strategy): + """ + No solar at all, so charging can only come from grid import and there is nothing to export. + prc_e_early cannot reach this case: e[t] is zero whatever the schedule does. + """ + 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=10000, s_min=0, s_max=4000, s_initial=0, + c_min=0, c_max=SURPLUS, d_max=0, p_a=P_A)], + time_series=TimeSeriesData(dt=[3600] * GRID_STEPS, gt=[0] * GRID_STEPS, ft=[0] * GRID_STEPS, + p_N=[P_N] * GRID_STEPS, p_E=[P_E] * GRID_STEPS), + eta_c=1.0, eta_d=1.0, M=1e6) + + def economics(result): """s0-insensitive real money: import cost, export revenue, final battery value.""" battery = result['batteries'][0] @@ -65,3 +87,41 @@ def test_the_tie_break_stays_cost_neutral(): values[strategy] = economics(build(strategy).solve()) assert values['attenuate_demand_peaks'] == pytest.approx(values['none']) + + +def test_a_battery_charging_from_the_grid_fills_early_when_nothing_levels_the_import(): + """ + prc_e_early can only move a schedule by deferring export, so it cannot reach a battery that + charges purely from the grid: e[t] is zero whatever the timing. attenuate_feedin_peaks levels + the feed-in side only, which leaves the import profile entirely undecided, and the charge + landed in scattered late steps with the battery empty in between. prc_n_early penalizes import + that lands late, which is the only lever that reaches this case. + """ + result = build_grid_only('attenuate_feedin_peaks').solve() + + assert result['status'] == 'Optimal' + charging = result['batteries'][0]['charging_power'] + assert charging[:2] == pytest.approx([SURPLUS, SURPLUS]) + assert charging[2:] == pytest.approx([0.0] * (GRID_STEPS - 2)) + + +def test_leveling_the_demand_side_keeps_the_last_word(): + """ + The counterpart on the side the strategy actually levels: a flat import profile is the lowest + import peak there is, so attenuate_demand_peaks spreads the same energy over the whole horizon + rather than taking it in the first two steps. The tie break is correctly too weak to buy + earliness with peak, exactly as on the feed-in side. + """ + result = build_grid_only('attenuate_demand_peaks').solve() + + assert result['status'] == 'Optimal' + assert result['grid_import'] == pytest.approx([4000.0 / GRID_STEPS] * GRID_STEPS) + + +def test_the_import_side_tie_break_stays_cost_neutral(): + """same as the export side: it picks between schedules, it does not pay for the early charge""" + values = {} + for strategy in ('none', 'attenuate_feedin_peaks'): + values[strategy] = economics(build_grid_only(strategy).solve()) + + assert values['attenuate_feedin_peaks'] == pytest.approx(values['none']) From ff1a1d57ae83e79d02cf981906e3f6b000f8017d Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 30 Jul 2026 13:17:43 +0200 Subject: [PATCH 3/4] fix: let earliness take a grid peak the strategy is not shaving The two earliness tie breaks sat two orders below the ramp weight on both sides, so filling the battery early won only where nothing competed. That is the right precedence on a side the strategy levels, and the wrong one everywhere else: attenuate_demand_peaks levels the import side only, and it was letting a feed-in peak it was never asked to shave hold its battery back. The weight is now picked per side from whether that side is being levelled. Where it is, the tie break stays below prc_p_ramp and levelling keeps the last word. Where it is not, earliness goes two orders above and takes the peak outright. Measured on the captured cases, real money is unchanged on all four and the strict 024-027 goldens are untouched, because the sides that changed weight had no peak variables to compete with. What the rule buys is that the outcome no longer depends on nothing else bidding: with early solar plus grid charging, the surplus is absorbed the moment it arrives while the import that follows it stays flat, and that holds at either weight only because the rule now says which. attenuate_feedin_peaks deliberately keeps its 2936 W feed-in peak on reduce_grid_feedin and fills at 3.1 h rather than 1.1 h. Buying that earliness costs 22 percent of the peak the strategy exists to protect. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 ++- src/optimizer/optimizer.py | 31 +++++++++++++------- tests/test_early_charge.py | 58 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 29879592a..ea920f8c6 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,9 @@ Cheapest is not always kindest to the grid connection. The same house on a *flat 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 -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. The peak strategies therefore hold the surplus back from the grid the same way `charge_before_export` does, so that a levelled day still charges first and exports afterwards, and pull grid import forward for a battery that has no surplus to hold back. Both weights are two orders below the ramp, so they only pick between schedules the levelling rates equal — where the two disagree, as on the feed-in side below, levelling keeps the last word. +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. The peak strategies therefore hold the surplus back from the grid the same way `charge_before_export` does, and pull grid import forward for a battery that has no surplus to hold back, so that a levelled day still fills its batteries first. + +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. So which of the two wins depends on whether the strategy is protecting that side at all. On a side it levels, that peak is the entire point and levelling keeps the last word. On a side it does not level, there is no peak worth protecting and filling early takes it outright. `attenuate_demand_peaks` therefore fills as fast as `charge_before_export` does 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. diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 30db079a7..cf42b6db2 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -175,19 +175,30 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries: self.prc_p_peak = self.penalty_base * 1e-3 self.prc_p_ramp = self.penalty_base * 1e-5 - # weight the peak leveling strategies defer grid export at, so that a leveled profile - # still fills the batteries before it sends the surplus out. Two orders below the ramp - # weight, so leveling decides wherever the two disagree and this only picks between the - # schedules leveling rates equal. - self.prc_e_early = self.penalty_base * 1e-7 - # same tie break for the demand side: a battery charging purely from the grid, with no - # surplus to hold back, gets nothing from prc_e_early above. Same weight, mirrored onto - # import instead of export. - self.prc_n_early = self.penalty_base * 1e-7 - # grid sides leveled by the active peak attenuation strategy, empty for all other strategies self.peak_sides = PEAK_STRATEGY_SIDES.get(strategy.charging_strategy, ()) + # weights the peak leveling strategies fill the batteries early at, one per grid side: + # prc_e_early makes early export expensive so the surplus charges the battery first, + # prc_n_early does the same for a battery that has no surplus to hold back and charges + # from the grid instead. + # + # 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. Which of the two + # wins therefore depends on whether the strategy is protecting that side at all: + # + # - a side the strategy levels is the whole point of the strategy, so the tie break stays + # two orders below prc_p_ramp and only picks between schedules leveling rates equal + # - a side it does not level has no peak worth protecting, so earliness takes it outright + # + # attenuate_feedin_peaks therefore keeps its feed-in peak and fills at the rate leveling + # leaves it, while attenuate_demand_peaks fills as fast as charge_before_export does and + # spends the feed-in peak nobody asked it to shave. attenuate_grid_peaks levels both sides + # and so protects both. + self.prc_e_early = self.penalty_base * (1e-7 if 'exp' in self.peak_sides else 1e-3) + self.prc_n_early = self.penalty_base * (1e-7 if 'imp' in self.peak_sides else 1e-3) + def create_model(self): """ Create and initialize the MILP model diff --git a/tests/test_early_charge.py b/tests/test_early_charge.py index 192690089..2953c8cc2 100644 --- a/tests/test_early_charge.py +++ b/tests/test_early_charge.py @@ -45,6 +45,24 @@ def build_grid_only(charging_strategy): eta_c=1.0, eta_d=1.0, M=1e6) +def build_mixed(charging_strategy): + """ + Early solar covering half the room, the rest to come from the grid: the only case where the + two sides pull against each other, because charging the whole battery early needs import that + a leveled import profile spreads out. + """ + 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=8000, s_initial=0, + c_min=0, c_max=SURPLUS, d_max=0, p_a=P_A)], + time_series=TimeSeriesData(dt=[3600] * GRID_STEPS, gt=[0] * GRID_STEPS, + ft=[SURPLUS, SURPLUS] + [0] * (GRID_STEPS - 2), + p_N=[P_N] * GRID_STEPS, p_E=[P_E] * GRID_STEPS), + eta_c=1.0, eta_d=1.0, M=1e6) + + def economics(result): """s0-insensitive real money: import cost, export revenue, final battery value.""" battery = result['batteries'][0] @@ -125,3 +143,43 @@ def test_the_import_side_tie_break_stays_cost_neutral(): values[strategy] = economics(build_grid_only(strategy).solve()) assert values['attenuate_feedin_peaks'] == pytest.approx(values['none']) + + +@pytest.mark.parametrize('charging_strategy, leveled', [ + ('attenuate_demand_peaks', {'imp'}), + ('attenuate_feedin_peaks', {'exp'}), + ('attenuate_grid_peaks', {'imp', 'exp'}), +]) +def test_earliness_only_outbids_a_side_the_strategy_does_not_level(charging_strategy, leveled): + """ + Filling the battery sooner is not free on either grid side, so which of the two wins depends + on whether the strategy is protecting that side: a leveled side keeps its peak and the tie + break stays below the ramp weight, an unleveled side has no peak worth protecting and + earliness takes it outright. This is the whole rule, pinned on the weights themselves because + the schedules it produces depend on the request. + """ + model = build(charging_strategy) + + for side, weight in (('exp', model.prc_e_early), ('imp', model.prc_n_early)): + if side in leveled: + assert weight < model.prc_p_ramp, f"{side} is leveled, earliness must not outbid it" + else: + assert weight > model.prc_p_ramp, f"{side} is not leveled, earliness should take it" + + +def test_solar_fills_the_battery_at_once_while_a_leveled_import_stays_flat(): + """ + Both sides at once on attenuate_demand_peaks: the surplus goes into the battery the moment it + arrives, because nothing levels the feed-in side, and the grid energy that still has to follow + it stays spread evenly, because the import side is the one being leveled. Earliness spends the + peak nobody asked it to shave and leaves the other alone. + """ + result = build_mixed('attenuate_demand_peaks').solve() + + assert result['status'] == 'Optimal' + charging = result['batteries'][0]['charging_power'] + assert charging[:2] == pytest.approx([SURPLUS, SURPLUS]) + assert result['grid_export'] == pytest.approx([0.0] * GRID_STEPS) + # the remaining 4000 Wh arrive as a flat profile over the steps that have no solar left + assert result['grid_import'][2:] == pytest.approx([4000.0 / (GRID_STEPS - 2)] * (GRID_STEPS - 2)) + assert result['grid_import'][:2] == pytest.approx([0.0, 0.0]) From 3284c01874ad946219c99b3c363d370f0ae0ca86 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 30 Jul 2026 14:03:20 +0200 Subject: [PATCH 4/4] refactor!: replace charge_before_export with an orthogonal battery_first option charging_strategy conflated two independent questions: what to shape about the grid profile, and when to fill the batteries. charge_before_export answered only the second, the three attenuate values only the first, and because they shared one enum the two could never be asked together - so 5 of 8 combinations were reachable and the peak strategies had to smuggle earliness in implicitly. battery_first is now its own boolean, so it combines with any charging_strategy, and charge_before_export is removed: it was exactly "no profile shaping plus that option" and is spelled that way now. The 14 stored cases using it are converted. Precedence is unchanged and now explicit: attenuation outranks battery_first. A side an attenuation strategy levels keeps its peak and the earliness weight stays below prc_p_ramp; a side nothing levels has no peak worth protecting, so earliness takes it outright. Verified a pure refactor on the export side: with the import term disabled, all 19 stored cases reproduce origin/main's charge_before_export schedules to 0.00 W. That required keeping two artifacts of the old term, the (T - t) coefficient and its accidental multiplication by the battery count, both documented at the weight. Head to head, 6 cases move by up to 689 W, entirely from the import side earliness added earlier in this series, with real money identical to 8 decimals. Solve time does not regress: measured over every strategy times the flag on two requests of 192 and 154 steps, battery_first=true is mostly faster (attenuate_grid_peaks 4.06 s to 1.93 s), worst case plus 16 percent. The earliness gradient gives CBC something to prune instead of a flat plateau. The option defaults to false, so a request must now ask for the early charge: on reduce_grid_feedin-2 that is step 17 and 27.5 h without it, against step 0 and 1.5 h with it. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 10 +-- client/client.gen.go | 17 ++-- openapi.yaml | 15 +++- src/optimizer/app.py | 7 +- src/optimizer/optimizer.py | 77 ++++++++++--------- test_cases/009-discharge-before-import.json | 3 +- test_cases/010-infesible-charge-goal.json | 3 +- test_cases/011-infeasible-charge-demand.json | 3 +- .../012-early-charging-not-perfect.json | 3 +- test_cases/013-grid-export-limit-hit.json | 3 +- .../014-grid-import-limit-violation.json | 3 +- test_cases/015-low-soc-initial.json | 3 +- .../016-battery-charge-priotization-1.json | 3 +- .../017-battery-charge-priotization-2.json | 3 +- test_cases/018-high-soc-initial.json | 3 +- test_cases/019-unexpected-charge-spikes.json | 3 +- test_cases/020-weird-charging-at-night.json | 3 +- ...1-min-pv-use-case-with-weird-behavior.json | 3 +- ...23-c_min-limit-kept-with-p_demand-set.json | 3 +- tests/test_early_charge.py | 60 ++++++++++++--- 20 files changed, 151 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index ea920f8c6..18cb18826 100644 --- a/README.md +++ b/README.md @@ -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 @@ -26,7 +26,7 @@ One day, hourly steps: a 10 kWh home battery, an EV that must reach 40 kWh by 08 Household demand, PV forecast and dynamic import tariff over 24 hours -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. @@ -44,9 +44,9 @@ Cheapest is not always kindest to the grid connection. The same house on a *flat 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 -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. The peak strategies therefore hold the surplus back from the grid the same way `charge_before_export` does, and pull grid import forward for a battery that has no surplus to hold back, so that a levelled day still fills its batteries first. +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. So which of the two wins depends on whether the strategy is protecting that side at all. On a side it levels, that peak is the entire point and levelling keeps the last word. On a side it does not level, there is no peak worth protecting and filling early takes it outright. `attenuate_demand_peaks` therefore fills as fast as `charge_before_export` does 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. +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. @@ -59,7 +59,7 @@ Filling sooner is not free on either side: a battery that is full by the first h ```jsonc { - "strategy": { "charging_strategy": "charge_before_export" }, + "strategy": { "battery_first": true }, "grid": { "p_max_exp": 7000 }, // W "batteries": [ { diff --git a/client/client.gen.go b/client/client.gen.go index b4fc7a356..828d19c75 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -35,7 +35,6 @@ const ( OptimizerStrategyChargingStrategyAttenuateDemandPeaks OptimizerStrategyChargingStrategy = "attenuate_demand_peaks" OptimizerStrategyChargingStrategyAttenuateFeedinPeaks OptimizerStrategyChargingStrategy = "attenuate_feedin_peaks" OptimizerStrategyChargingStrategyAttenuateGridPeaks OptimizerStrategyChargingStrategy = "attenuate_grid_peaks" - OptimizerStrategyChargingStrategyChargeBeforeExport OptimizerStrategyChargingStrategy = "charge_before_export" OptimizerStrategyChargingStrategyNone OptimizerStrategyChargingStrategy = "none" ) @@ -209,9 +208,14 @@ type OptimizationResultStatus string // OptimizerStrategy defines model for OptimizerStrategy. 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 + // BatteryFirst 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. + BatteryFirst bool `json:"battery_first,omitempty"` + + // ChargingStrategy 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 @@ -223,9 +227,8 @@ type OptimizerStrategy struct { DischargingStrategy OptimizerStrategyDischargingStrategy `json:"discharging_strategy,omitempty"` } -// 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 +// OptimizerStrategyChargingStrategy 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 diff --git a/openapi.yaml b/openapi.yaml index 43c58e788..7866283f6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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] diff --git a/src/optimizer/app.py b/src/optimizer/app.py index f9d434814..cde7aa059 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -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', { @@ -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 diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index cf42b6db2..ad094d9c2 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -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. @@ -178,26 +183,40 @@ 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, ()) - # weights the peak leveling strategies fill the batteries early at, one per grid side: - # prc_e_early makes early export expensive so the surplus charges the battery first, - # prc_n_early does the same for a battery that has no surplus to hold back and charges - # from the grid instead. + 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. Which of the two - # wins therefore depends on whether the strategy is protecting that side at all: + # 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 # - # - a side the strategy levels is the whole point of the strategy, so the tie break stays - # two orders below prc_p_ramp and only picks between schedules leveling rates equal - # - a side it does not level 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. # - # attenuate_feedin_peaks therefore keeps its feed-in peak and fills at the rate leveling - # leaves it, while attenuate_demand_peaks fills as fast as charge_before_export does and - # spends the feed-in peak nobody asked it to shave. attenuate_grid_peaks levels both sides - # and so protects both. - self.prc_e_early = self.penalty_base * (1e-7 if 'exp' in self.peak_sides else 1e-3) - self.prc_n_early = self.penalty_base * (1e-7 if 'imp' in self.peak_sides else 1e-3) + # 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): """ @@ -408,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 @@ -425,22 +438,16 @@ 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 - # peak and ramp say how high the grid profile may go, not when the batteries fill, and - # under flat commercials that leaves most of the horizon undecided: the schedule then comes - # back as arbitrary as with no strategy at all, with the stored examples charging in the - # last third of the day. So the peak strategies defer export as well, the same tie break - # charge_before_export makes, at prc_e_early instead of its weight so that leveling keeps - # the last word. - if self.peak_sides: + # 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 - - # same tie break for a battery with no surplus to hold back: charging is the only - # flexible part of grid import (household demand gt is fixed per step), so penalizing - # import that lands late has the same effect on the import side that deferring export - # has on the feed-in side above. - if self.peak_sides: - for t in self.time_steps: objective += - self.variables['n'][t] * self.prc_n_early * t / self.T # prefer discharging batteries completely before importing from grid diff --git a/test_cases/009-discharge-before-import.json b/test_cases/009-discharge-before-import.json index 1a6bf4721..24affb6f1 100644 --- a/test_cases/009-discharge-before-import.json +++ b/test_cases/009-discharge-before-import.json @@ -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": { diff --git a/test_cases/010-infesible-charge-goal.json b/test_cases/010-infesible-charge-goal.json index 66b941b29..b7ff2c15e 100644 --- a/test_cases/010-infesible-charge-goal.json +++ b/test_cases/010-infesible-charge-goal.json @@ -487,7 +487,8 @@ "eta_d": 0.9, "grid": {}, "strategy": { - "charging_strategy": "charge_before_export" + "charging_strategy": "none", + "battery_first": true }, "time_series": { "dt": [ diff --git a/test_cases/011-infeasible-charge-demand.json b/test_cases/011-infeasible-charge-demand.json index a49c0c906..fbf8d5c52 100644 --- a/test_cases/011-infeasible-charge-demand.json +++ b/test_cases/011-infeasible-charge-demand.json @@ -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": { diff --git a/test_cases/012-early-charging-not-perfect.json b/test_cases/012-early-charging-not-perfect.json index 3f87cad6b..a21e53f44 100644 --- a/test_cases/012-early-charging-not-perfect.json +++ b/test_cases/012-early-charging-not-perfect.json @@ -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": { diff --git a/test_cases/013-grid-export-limit-hit.json b/test_cases/013-grid-export-limit-hit.json index 53863f505..bb10c9b3a 100644 --- a/test_cases/013-grid-export-limit-hit.json +++ b/test_cases/013-grid-export-limit-hit.json @@ -27,7 +27,8 @@ "p_max_exp": 15000 }, "strategy": { - "charging_strategy": "charge_before_export" + "charging_strategy": "none", + "battery_first": true }, "time_series": { "dt": [ diff --git a/test_cases/014-grid-import-limit-violation.json b/test_cases/014-grid-import-limit-violation.json index 243adb4f2..604b3972c 100644 --- a/test_cases/014-grid-import-limit-violation.json +++ b/test_cases/014-grid-import-limit-violation.json @@ -350,7 +350,8 @@ "p_max_imp": 3300 }, "strategy": { - "charging_strategy": "charge_before_export" + "charging_strategy": "none", + "battery_first": true }, "time_series": { "dt": [ diff --git a/test_cases/015-low-soc-initial.json b/test_cases/015-low-soc-initial.json index e5239a192..c6bf8d121 100644 --- a/test_cases/015-low-soc-initial.json +++ b/test_cases/015-low-soc-initial.json @@ -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": { diff --git a/test_cases/016-battery-charge-priotization-1.json b/test_cases/016-battery-charge-priotization-1.json index 7e18edf91..533fee2ba 100644 --- a/test_cases/016-battery-charge-priotization-1.json +++ b/test_cases/016-battery-charge-priotization-1.json @@ -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": { diff --git a/test_cases/017-battery-charge-priotization-2.json b/test_cases/017-battery-charge-priotization-2.json index 7fd63bbec..d2a8a292d 100644 --- a/test_cases/017-battery-charge-priotization-2.json +++ b/test_cases/017-battery-charge-priotization-2.json @@ -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": { diff --git a/test_cases/018-high-soc-initial.json b/test_cases/018-high-soc-initial.json index e4da5f1b5..cfb11b7ec 100644 --- a/test_cases/018-high-soc-initial.json +++ b/test_cases/018-high-soc-initial.json @@ -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": [ diff --git a/test_cases/019-unexpected-charge-spikes.json b/test_cases/019-unexpected-charge-spikes.json index 076a928bb..e34b66a11 100644 --- a/test_cases/019-unexpected-charge-spikes.json +++ b/test_cases/019-unexpected-charge-spikes.json @@ -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": { diff --git a/test_cases/020-weird-charging-at-night.json b/test_cases/020-weird-charging-at-night.json index 6d40a9bb4..6db513bf6 100644 --- a/test_cases/020-weird-charging-at-night.json +++ b/test_cases/020-weird-charging-at-night.json @@ -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": { diff --git a/test_cases/021-min-pv-use-case-with-weird-behavior.json b/test_cases/021-min-pv-use-case-with-weird-behavior.json index 417010cca..2f378b3b6 100644 --- a/test_cases/021-min-pv-use-case-with-weird-behavior.json +++ b/test_cases/021-min-pv-use-case-with-weird-behavior.json @@ -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": { diff --git a/test_cases/023-c_min-limit-kept-with-p_demand-set.json b/test_cases/023-c_min-limit-kept-with-p_demand-set.json index 06442a172..39fdf29af 100644 --- a/test_cases/023-c_min-limit-kept-with-p_demand-set.json +++ b/test_cases/023-c_min-limit-kept-with-p_demand-set.json @@ -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": [ diff --git a/tests/test_early_charge.py b/tests/test_early_charge.py index 2953c8cc2..7968f3e6f 100644 --- a/tests/test_early_charge.py +++ b/tests/test_early_charge.py @@ -11,9 +11,10 @@ P_A = 0.0004 -def build(charging_strategy): +def build(charging_strategy, battery_first=True): return Optimizer( - strategy=OptimizationStrategy(charging_strategy=charging_strategy, discharging_strategy='none'), + strategy=OptimizationStrategy(charging_strategy=charging_strategy, discharging_strategy='none', + battery_first=battery_first), 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=10000, s_min=0, s_max=4000, s_initial=0, @@ -29,13 +30,14 @@ def build(charging_strategy): GRID_STEPS = 8 -def build_grid_only(charging_strategy): +def build_grid_only(charging_strategy, battery_first=True): """ No solar at all, so charging can only come from grid import and there is nothing to export. prc_e_early cannot reach this case: e[t] is zero whatever the schedule does. """ return Optimizer( - strategy=OptimizationStrategy(charging_strategy=charging_strategy, discharging_strategy='none'), + strategy=OptimizationStrategy(charging_strategy=charging_strategy, discharging_strategy='none', + battery_first=battery_first), 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=10000, s_min=0, s_max=4000, s_initial=0, @@ -45,14 +47,15 @@ def build_grid_only(charging_strategy): eta_c=1.0, eta_d=1.0, M=1e6) -def build_mixed(charging_strategy): +def build_mixed(charging_strategy, battery_first=True): """ Early solar covering half the room, the rest to come from the grid: the only case where the two sides pull against each other, because charging the whole battery early needs import that a leveled import profile spreads out. """ return Optimizer( - strategy=OptimizationStrategy(charging_strategy=charging_strategy, discharging_strategy='none'), + strategy=OptimizationStrategy(charging_strategy=charging_strategy, discharging_strategy='none', + battery_first=battery_first), 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=8000, s_initial=0, @@ -101,8 +104,8 @@ def test_leveling_the_feed_in_side_keeps_the_last_word(): def test_the_tie_break_stays_cost_neutral(): """it only picks between schedules, it does not buy the early charge with money""" values = {} - for strategy in ('none', 'attenuate_demand_peaks'): - values[strategy] = economics(build(strategy).solve()) + values['none'] = economics(build('none', battery_first=False).solve()) + values['attenuate_demand_peaks'] = economics(build('attenuate_demand_peaks').solve()) assert values['attenuate_demand_peaks'] == pytest.approx(values['none']) @@ -139,8 +142,8 @@ def test_leveling_the_demand_side_keeps_the_last_word(): def test_the_import_side_tie_break_stays_cost_neutral(): """same as the export side: it picks between schedules, it does not pay for the early charge""" values = {} - for strategy in ('none', 'attenuate_feedin_peaks'): - values[strategy] = economics(build_grid_only(strategy).solve()) + values['none'] = economics(build_grid_only('none', battery_first=False).solve()) + values['attenuate_feedin_peaks'] = economics(build_grid_only('attenuate_feedin_peaks').solve()) assert values['attenuate_feedin_peaks'] == pytest.approx(values['none']) @@ -183,3 +186,40 @@ def test_solar_fills_the_battery_at_once_while_a_leveled_import_stays_flat(): # the remaining 4000 Wh arrive as a flat profile over the steps that have no solar left assert result['grid_import'][2:] == pytest.approx([4000.0 / (GRID_STEPS - 2)] * (GRID_STEPS - 2)) assert result['grid_import'][:2] == pytest.approx([0.0, 0.0]) + + +def test_without_the_option_nothing_decides_when_the_battery_fills(): + """ + battery_first is what asks for the early charge, not the charging strategy: a peak strategy on + its own says how high the grid profile may go and nothing about when to charge, so the schedule + is free to sit on an empty battery again. Guards against the option being quietly implied. + """ + model = build('attenuate_demand_peaks', battery_first=False) + + assert model.battery_first is False + charging = model.solve()['batteries'][0]['charging_power'] + # the surplus leaves first and the battery takes the tail, the schedule none returns + assert charging[:2] == pytest.approx([0.0, 0.0]) + + +def test_the_option_combines_with_every_charging_strategy(): + """ + It is orthogonal, so none of the four values of charging_strategy can refuse it. none plus the + option is what the charge_before_export strategy used to be. + """ + for charging_strategy in ('none', 'attenuate_demand_peaks', + 'attenuate_feedin_peaks', 'attenuate_grid_peaks'): + result = build(charging_strategy).solve() + assert result['status'] == 'Optimal', charging_strategy + charged = sum(result['batteries'][0]['charging_power']) + assert charged > 0, charging_strategy + + +def test_no_profile_shaping_plus_the_option_fills_first(): + """what charge_before_export meant, now spelled out as the two independent choices it was""" + result = build('none').solve() + + assert result['status'] == 'Optimal' + charging = result['batteries'][0]['charging_power'] + assert charging[:2] == pytest.approx([SURPLUS, SURPLUS]) + assert charging[2:] == pytest.approx([0.0, 0.0])