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
-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
+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.
+
+
+
+
+
+
## 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 @@
+
\ 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 @@
+
\ 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])