diff --git a/README.md b/README.md index 969782a44..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,13 +44,22 @@ 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 — so a levelled day would otherwise return a schedule as arbitrary as no strategy at all, sitting on an empty battery for a day and a half. `battery_first` is the separate answer to that separate question: it holds the surplus back from the grid until the batteries have taken it, and pulls grid import forward for a battery that has no surplus to hold back. Because it is orthogonal, it combines with any `charging_strategy`, and on its own it is simply "fill the batteries first, shape nothing". + +Filling sooner is not free on either side: a battery that is full by the first hour has no room left for the midday solar peak, and one filled at full power draws a taller import peak than one trickled. Attenuation therefore keeps priority — where the two disagree, the profile the request asked to level is the one that survives. On a side a strategy levels, that peak is the entire point. On a side it does not level, there is no peak worth protecting and filling early takes it outright. So `attenuate_demand_peaks` with `battery_first` fills at full rate and spends the feed-in peak nobody asked it to shave, `attenuate_feedin_peaks` keeps its feed-in peak and fills at the rate levelling leaves it, and `attenuate_grid_peaks` levels both sides and so protects both. No case pays real money for the difference. + + + + 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). ```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/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/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 67b0d984d..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,6 +183,41 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries: # grid sides leveled by the active peak attenuation strategy, empty for all other strategies self.peak_sides = PEAK_STRATEGY_SIDES.get(strategy.charging_strategy, ()) + self.battery_first = strategy.battery_first + + # weights battery_first fills the batteries early at, one per grid side: prc_e_early makes + # early export expensive so the surplus charges the battery before it leaves, prc_n_early + # does the same for a battery that has no surplus to hold back and charges from the grid. + # + # Filling sooner is not free on either side. A battery that is full by the first hour has + # no room left for the midday solar peak, so that peak leaves over the grid instead, and + # one filled at full power draws a taller import peak than one trickled. Attenuation + # therefore outranks battery_first: where the two want different things, the profile the + # request asked to level is the one that survives. + # + # - a side an attenuation strategy levels keeps its peak, so the weight stays two orders + # below prc_p_ramp and only picks between schedules the leveling rates equal + # - a side nothing levels has no peak worth protecting, so earliness takes it outright + # + # attenuate_feedin_peaks with battery_first therefore keeps its feed-in peak and fills at + # the rate leveling leaves it, while attenuate_demand_peaks fills at full rate and spends + # the feed-in peak nobody asked it to shave. attenuate_grid_peaks levels both and protects + # both, and battery_first on its own has neither peak to respect. + # + # The unprotected weight is the coefficient the charge_before_export strategy carried + # before it became this option, so that every schedule it used to return is unchanged: its + # term was e[t] * min_import_price * 2e-5 * (T - t) and the terms below carry (T - t) / T, + # which is where the factor T comes from. Eight of the stored cases move without it. + # + # The battery count is part of that coefficient because the strategy added its term inside + # a loop over the batteries while the term itself never referenced one, so a two battery + # request weighted it twice. That is preserved here to keep this a pure refactor; it is a + # latent bug and worth removing on its own, which will move those schedules. + early_unprotected = self.min_import_price * 2e-5 * self.T * len(self.batteries) + early_protected = self.penalty_base * 1e-7 + self.prc_e_early = early_protected if 'exp' in self.peak_sides else early_unprotected + self.prc_n_early = early_protected if 'imp' in self.peak_sides else early_unprotected + def create_model(self): """ Create and initialize the MILP model @@ -387,12 +427,6 @@ def _setup_target_function(self): ############################################################################# # Secondary strategies to implement preferences without impact to actual cost - # prefer charging first, then grid export - if self.strategy.charging_strategy == 'charge_before_export': - for i, bat in enumerate(self.batteries): - for t in self.time_steps: - objective += - self.variables['e'][t] * self.min_import_price * 2e-5 * (self.T - t) - # level the grid profile to unload the public grid from peaks. attenuate_demand_peaks levels # grid import, attenuate_feedin_peaks levels grid export, attenuate_grid_peaks levels both. # the penalty sits on the horizon maximum and on the step to step ramp instead of on charge @@ -404,6 +438,18 @@ def _setup_target_function(self): objective += - self.variables[f'p_{side}_peak'] * self.prc_p_peak objective += - pulp.lpSum(self.variables[f'p_{side}_ramp']) * self.prc_p_ramp + # fill the batteries early. Peak and ramp say how high the grid profile may go, not when + # the batteries fill, and under flat commercials nothing else decides it either, so a + # levelled day would otherwise return a schedule as arbitrary as no strategy at all - the + # stored examples charged in the last third of the horizon. Deferring export holds the + # surplus back until the battery has taken it; penalizing late import pulls forward the + # charge of a battery that has no surplus to hold back. The weights carry the precedence + # against attenuation, see prc_e_early / prc_n_early. + if self.battery_first: + for t in self.time_steps: + objective += - self.variables['e'][t] * self.prc_e_early * (self.T - t) / self.T + objective += - self.variables['n'][t] * self.prc_n_early * t / self.T + # 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/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 new file mode 100644 index 000000000..7968f3e6f --- /dev/null +++ b/tests/test_early_charge.py @@ -0,0 +1,225 @@ +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, battery_first=True): + return Optimizer( + 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, + 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) + + +# 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, 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', + 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, + 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 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', + 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, + 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] + 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 = {} + 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']) + + +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 = {} + 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']) + + +@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]) + + +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])