Designing the Intelligence Behind Energy Storage

The schedule with the lowest bill quietly spends the battery’s life, and “charge when cheap” can lose to having no battery at all. Designing a degradation-aware MILP dispatch optimiser, from the domain model down to working Python.

Author Avatar

Fernando

  ·  21 min read

Every battery makes one decision, over and over: for each half hour of the day, should it charge, discharge or wait, and at what power? That holds whether it sits in a garage, behind a factory meter, at an EV depot or on a grid-scale site. The hardware stores the energy. What makes the battery smart is the software making that decision well. In this post I design that software. I use a home battery as the running example, because it’s the smallest setting that still has every moving part (solar, load, a grid limit, a deadline), but nothing in the core knows it’s a home. I start from the domain model, work down to a mixed-integer linear program, and end with about 50 lines of Python that solve it. Along the way I check each design choice against the research, because a few of the “obvious” choices turn out to be wrong.

The inputs are what I expect to happen (solar generation, demand), what energy costs or earns in each interval, and what the battery physically allows. The output is a schedule.


The problem #

Where the optimiser sits #

graph TB
    S["Solar PV"]
    H["Home / site load"]
    B["Battery"]
    G["Grid"]
    O["Optimiser<br/><small>prices · forecasts · wear cost</small>"]

    S -->|generation| H
    H <-->|charge / discharge| B
    H <-->|import / export| G
    B <-.->|"schedule ↓ · live SOC ↑"| O

Energy gets into the battery two ways: surplus solar, or the grid when electricity is cheap. It leaves to cover the site’s own load when power is expensive, or gets sold back when export prices are high. The optimiser never touches any of that energy. It sits beside the battery and picks which flows to use in each interval, trading the price spread against the wear each cycle costs.

Why bother? #

Because there’s a lot of storage going in, and it’s all being operated somehow. The IEA reports that battery storage additions more than doubled in 2023 to 42 GW, and its net-zero pathway needs 1,200 GW of global capacity by 2030.1 BloombergNEF put 2024 stationary installations at about 170 GWh.2

How you operate a battery decides what it earns, and that number moves a lot. Modo Energy’s GB benchmark swings between roughly £47k and £76k per MW per year from one month to the next.3 Operation also decides how long the thing lives. Xu et al. dispatched batteries with no ageing cost in the objective and got negative profit and a very short battery life in every market they tested.4 So you don’t get to optimise dispatch without also deciding how much battery life you’re willing to spend.

A plan, not a rule #

The first thing anyone writes is a rule: charge when cheap, discharge when expensive. It falls over the moment decisions start interacting:

  • Discharging at 17:00 leaves less for 19:00 unless the battery recharges in between.
  • Filling up overnight leaves no room for solar at midday.
  • An EV (or a depot of electric buses) that must be at 80% by 07:30 constrains every decision before it.

Whether a decision is any good depends on the rest of the horizon. So the real question is:

Given the forecasts and prices for the next 24 hours, what is the optimal schedule for all 48 half-hour intervals, and what does it tell the battery to do now?

Why MILP #

Written down properly, the problem has a known objective, hard physical limits and a finite horizon. That’s a mixed-integer linear program (MILP), a very well-trodden problem class with good open-source solvers. I didn’t have to invent anything clever here, which is exactly what I want from the core of a system.

PropertyWhat it gives the design
OptimalityProvably optimal for the given inputs
DeterminismSame inputs, same schedule
AuditabilityEvery decision traces back to a price or a constraint
CostOpen-source solver (HiGHS), about 30 ms per household-scale solve

MILP is an established approach in home energy management,5 and Gomes et al. ran a MILP-based controller in a real household in the Algarve that beat a commercial PV-battery controller on measured data.6

One problem, many settings #

Residential (HEMS)Business site / EV depotMerchant storage
Battery physics and constraintsSameSameSame
Decision per intervalCharge / discharge / waitCharge / discharge / waitCharge / discharge / wait
Price signalRetail tariff (NightSaver, Octopus Agile)Commercial tariff with demand chargesWholesale market (SEMOpx, Nord Pool, EPEX)
ObjectiveMinimise the billMinimise the bill and the peak importMaximise trading revenue

When I put the use cases side by side, only two rows differ: where the prices come from, and what “best” means. Everything else in the design follows from that.


The domain model #

Ubiquitous language #

TermMeaning
Battery (spec)Capacity, power limits, efficiencies, SOC floor and ceiling
State of Charge (SOC)How full the battery is: the state linking each interval to the next
Horizon / IntervalThe planning window (24 h) and its unit of decision (30 min)
ForecastExpected generation and demand per interval
Price signalImport and export price per interval, from a tariff or a market
Grid connectionThe site’s import/export limit; power flows one way at a time
Objective profileThe definition of “best”: lowest bill, highest revenue, lowest peak
Hard constraintA commitment the plan must meet, e.g. SOC ≥ 80% at interval 15
Degradation costThe value of battery life consumed by moving energy
ScheduleCharge, discharge, import, export and SOC per interval

In code these turn into small, immutable value objects:

 1@dataclass(frozen=True)
 2class BatterySpec:
 3    capacity_kwh: float
 4    min_soc: float
 5    max_soc: float
 6    charge_rate_kw: float
 7    discharge_rate_kw: float
 8    charge_efficiency: float
 9    discharge_efficiency: float
10
11@dataclass(frozen=True)
12class SocAtInterval:               # a hard commitment
13    interval_index: int
14    min_soc: float
15
16@dataclass(frozen=True)
17class OptimizationInput:
18    battery: BatterySpec
19    current_soc: float
20    interval_minutes: int
21    grid_limit_kw: float
22    forecasted_generation_kw: list[float]   # len 48
23    forecasted_demand_kw: list[float]       # len 48
24    import_prices_per_kwh: list[float]      # len 48
25    export_prices_per_kwh: list[float]      # len 48
26    hard_constraints: list[SocAtInterval]

None of these types mentions a tariff name, a hardware brand or a market. I kept them out on purpose.

Stable core, interchangeable profiles #

Since only the price signal and the objective vary, I went with one MILP core and pluggable objective profiles:

  • The core owns the decision variables and the physics constraints.
  • A profile contributes its objective term, and optionally a few extra constraints.
  • Degradation cuts across both. The core charges it to every profile, so no objective gets to treat the battery as free.

Going from residential to merchant means swapping the profile and nothing else. New profiles (PeakShaving, DemandResponse, CarbonMinimisation) plug in without touching the solver.

Bounded contexts #

graph LR
    P["Pricing<br/>tariffs, wholesale markets"] -->|price per interval| C
    F["Forecasting<br/>solar, demand"] -->|kW per interval| C
    A["Asset<br/>battery spec, degradation"] -->|limits, wear cost| C
    C["Dispatch Core<br/>MILP + objective profile"] -->|schedule| O
    O["Operations<br/>rolling re-optimisation"] -->|next action| D
    D["Device Integration<br/>hardware adapters"] -->|live SOC| O
    E["Evaluation<br/>simulation, backtesting"] -.->|replays history through| O

Every context hands the core data in the core’s own terms, and the core depends on nothing outside itself. That makes the most valuable logic the easiest to test and the slowest to change.

Anti-corruption layers #

Two boundaries translate real-world vocabulary into the core’s model.

Tariffs. Nobody has “an array of 48 prices”. They have a tariff with a name. A TariffProvider expands it for a given day:

1class TariffProvider(Protocol):
2    def import_prices(self, day: date, interval_minutes: int) -> list[float]: ...
3    def export_prices(self, day: date, interval_minutes: int) -> list[float]: ...

Hardware. The optimiser never learns which brand it’s driving. A DeviceProvider exposes four verbs, and the adapters sit behind it (cloud-to-cloud via Enode, or local SunSpec Modbus):

1class DeviceProvider(Protocol):
2    async def get_state(self, device_id: str) -> DeviceState: ...
3    async def charge_to(self, device_id: str, target_soc: float, by: datetime) -> None: ...
4    async def discharge(self, device_id: str, power_kw: float) -> None: ...
5    async def set_idle(self, device_id: str) -> None: ...

The formulation #

Decision variables #

For each interval $t \in \{0, \dots, 47\}$: charge $c_t$ and discharge $d_t$ (kW), grid import $g_t^{in}$ and export $g_t^{out}$ (kW), state of charge $s_t$ (from 0 to 1), binaries $u^c_t, u^d_t$ for charging or discharging, and a binary $u^g_t$ for whether the site is importing.

Constraints #

With $\Delta t$ the interval length in hours and $E$ the capacity in kWh:

$$ s_t = s_{t-1} + \frac{\Delta t}{E}\left(\eta_c\, c_t - \frac{d_t}{\eta_d}\right) \qquad \text{(SOC continuity, } s_{-1} = \text{current SOC)} $$

$$ s_{\min} \le s_t \le s_{\max} \qquad 0 \le c_t \le P_c\, u^c_t \qquad 0 \le d_t \le P_d\, u^d_t \qquad u^c_t + u^d_t \le 1 $$

$$ \text{gen}_t + d_t + g_t^{in} = \text{demand}_t + c_t + g_t^{out} \qquad \text{(balance)} $$

$$ 0 \le g_t^{in} \le G\, u^g_t \qquad 0 \le g_t^{out} \le G\,(1 - u^g_t) \qquad \text{(grid limit } G\text{, one direction)} $$

$$ s_k \ge s_k^{target} \quad \text{(commitments)} \qquad s_{T} \ge s_{-1} \quad \text{(terminal SOC)} $$

In English: energy doesn’t appear from nowhere, the battery has walls and a speed limit, it moves in one direction at a time, the site balances in every interval, the grid connection has a limit and also flows one way at a time, and the plan can’t borrow from tomorrow.

The grid constraint wasn’t in my first version, and I only found out because I ran the code. On a perfectly ordinary Irish setup (night import at €0.09/kWh, export at €0.15/kWh) HiGHS came back with infeasible_or_unbounded. The cause was embarrassingly simple. Nothing stopped the model from importing and exporting in the same half hour, and neither flow had an upper bound. So the optimal plan was to buy infinite energy at 9c and sell it straight back at 15c, which is a great business model right up until you meet a physical meter. Bounding both flows by the connection limit and adding one binary for direction fixed it. The model can still buy cheap and sell dear, it just has to put the energy through the battery to do it, like everyone else.

Two more took more thought than I expected:

  • The binaries stay. With non-negative prices you can drop them and the linear relaxation is still exact.7 It’s a tempting shortcut. But wholesale markets do clear at negative prices, and a relaxed model will then happily charge and discharge at the same time, burning energy through conversion losses to get paid for consuming it. I checked this: give the arbitrage profile four hours of €-0.05/kWh prices, and the relaxed model pushes 9.5 kWh through charge and discharge simultaneously. With the binaries in, that figure is exactly zero. (The solver is fine with this. The battery’s warranty probably isn’t.) The same core serves merchant profiles, so the binaries stay.
  • The terminal constraint. Leave it out and the optimiser drains the battery at the end of every horizon, because nothing in the objective values the energy left over. Xu et al. fix the final SOC for exactly this reason,4 and Prat et al. show how to test whether a horizon is long enough.8

Commitments also mean a valid plan sometimes doesn’t exist. Ask for 90% SOC by the second interval from a battery sitting at 10%, and there simply isn’t enough charge power in an hour to get there. The core returns INFEASIBLE. I’d much rather the caller get an explicit failure than a schedule that quietly misses the EV deadline.

Objectives #

The retail objective minimises the net bill. The arbitrage objective maximises revenue against the wholesale price:

$$ \min \sum_{t} \left( g_t^{in}\, p_t^{imp} - g_t^{out}\, p_t^{exp} \right) \Delta t + \text{wear} \qquad \max \sum_{t} \left( g_t^{out} - g_t^{in} \right) p_t^{wholesale}\, \Delta t - \text{wear} $$

Same variables, same constraints. Only the price signal changes.

Degradation #

A battery that thinks cycling is free will cycle on every tiny price wiggle. I find it helps to think of wear like tyre tread: every kilometre costs some, whether or not the trip was worth making. So wear is a term in every objective:

$$ \text{wear} = c_{deg} \sum_t (c_t + d_t)\, \Delta t, \qquad c_{deg} = \frac{\text{cell replacement cost}}{2 \times \text{rated cycles} \times \text{usable capacity (kWh)}} $$

The factor of 2 is easy to get wrong. Throughput counts energy going in and coming out, so the lifetime figure has to count both as well, or every cycle gets charged twice. The term is linear in variables the model already has, so it stays a MILP. Seydenschwanz et al. show this kind of linear ageing cost drops straight into MILP dispatch.9

Here’s what it does in practice. Take a 10 kWh home battery, rated for 6,000 cycles, with the cells costing roughly €4,000 to replace (illustrative numbers, but in the right neighbourhood for LFP). That gives $c_{deg} = 4000 / (2 \times 6000 \times 10) \approx$ €0.033 per kWh of throughput, and I round it to €0.03.

Now follow one kWh from the night rate (€0.09) to the evening peak (€0.32). At 95% efficiency each way, delivering 1 kWh at 18:00 means buying about 1.11 kWh at night, which costs €0.10. The wear is charged on both legs, $(1.11 + 1) \times 0.03 \approx$ €0.06. That leaves €0.32 − €0.10 − €0.06 ≈ €0.16 per kWh, comfortably positive, so the optimiser charges. Shrink the spread to 5 cents and the trade loses money whatever the base price (break-even sits around 7 cents), so the battery sits still. The optimiser has stopped asking “what’s cheapest today?” and started asking “is this cycle worth the battery life it costs?”

There was a side effect I didn’t expect. Without the wear term, HiGHS took about 1.4 s to solve a day. With it, about 27 ms, roughly 50× faster. My guess is that a free battery has a huge number of near-identical optimal schedules (shuffle a few kWh between any two cheap intervals and nothing changes), so branch-and-bound has to wade through all those ties. Pricing wear breaks the ties. So being honest about the physics also turns out to be good for the solver.

Real ageing isn’t linear, so I tiered the fidelity:

TierModelScope
1Linear cost per kWh of throughputDefault
2Piecewise-linear cost by cycle depthOpt-in
3Rainflow cycle countingRoadmap

Tier 1 has known flaws. Xu et al. found a single linear segment too conservative: it overprices shallow cycles and underprices deep ones, and a multi-segment cycle-depth model earned the most.4 That segment method is the standard way to build Tier 2. Tier 3 can live inside a MILP too, with under 3% error against a true rainflow count.10

Two more simplifications I should be upfront about. The true marginal degradation cost changes as the battery’s state of health drops,11 and calendar ageing (fastest at high SOC and high temperature) isn’t priced at all in v1.1213 I suspect the calendar term matters more for home batteries than people assume, since they spend a lot of time sitting full, but I haven’t measured it.


The core logic #

The core builds the invariant model, asks the profile for its objective, adds wear and solves. It uses linopy on top of HiGHS. On my machine, HiGHS takes about 27 ms for a 48-interval day. Building the model in Python takes another ~330 ms, so the solver is not the slow part. That’s still fine for a loop that runs every 15 minutes.

 1import numpy as np
 2import pandas as pd
 3import xarray as xr
 4import linopy
 5
 6
 7class BatteryDispatchOptimizer:
 8    def __init__(self, profile: OptimizationProfile, degradation: CycleCost):
 9        self.profile = profile
10        self.degradation = degradation
11
12    def solve(self, inp: OptimizationInput) -> OptimizationResult:
13        b = inp.battery
14        t = pd.RangeIndex(len(inp.import_prices_per_kwh), name="t")
15        dt = inp.interval_minutes / 60                      # hours per interval
16        m = linopy.Model()
17
18        # Decision variables
19        charge    = m.add_variables(lower=0, upper=b.charge_rate_kw,    coords=[t], name="charge")
20        discharge = m.add_variables(lower=0, upper=b.discharge_rate_kw, coords=[t], name="discharge")
21        grid_in   = m.add_variables(lower=0, coords=[t], name="import")
22        grid_out  = m.add_variables(lower=0, coords=[t], name="export")
23        soc       = m.add_variables(lower=b.min_soc, upper=b.max_soc, coords=[t], name="soc")
24        is_charging    = m.add_variables(binary=True, coords=[t], name="b_charge")
25        is_discharging = m.add_variables(binary=True, coords=[t], name="b_discharge")
26        is_importing   = m.add_variables(binary=True, coords=[t], name="b_import")
27
28        # Power limits, one direction at a time
29        m.add_constraints(charge    <= b.charge_rate_kw    * is_charging,    name="charge_limit")
30        m.add_constraints(discharge <= b.discharge_rate_kw * is_discharging, name="discharge_limit")
31        m.add_constraints(is_charging + is_discharging <= 1,                 name="one_direction")
32
33        # Grid connection: bounded, and never importing and exporting at once
34        m.add_constraints(grid_in  <= inp.grid_limit_kw * is_importing,       name="import_limit")
35        m.add_constraints(grid_out <= inp.grid_limit_kw * (1 - is_importing), name="export_limit")
36
37        # The site balances in every interval
38        gen    = xr.DataArray(inp.forecasted_generation_kw, coords=[t])
39        demand = xr.DataArray(inp.forecasted_demand_kw,     coords=[t])
40        m.add_constraints(discharge + grid_in - charge - grid_out == demand - gen, name="balance")
41
42        # SOC continuity (s[-1] is the current SOC)
43        energy_in = (b.charge_efficiency * charge - (1 / b.discharge_efficiency) * discharge) * (dt / b.capacity_kwh)
44        start = xr.DataArray(np.where(t == 0, inp.current_soc, 0.0), coords=[t])
45        m.add_constraints(soc - soc.shift(t=1) - energy_in == start, name="soc_continuity")
46
47        # Commitments and terminal SOC
48        for c in inp.hard_constraints:
49            m.add_constraints(soc.isel(t=c.interval_index) >= c.min_soc,
50                              name=f"commitment_{c.interval_index}")
51        m.add_constraints(soc.isel(t=-1) >= inp.current_soc, name="terminal_soc")
52
53        # Profile-specific terms, plus wear charged to every profile
54        v = DispatchVariables(charge, discharge, grid_in, grid_out, soc)
55        ctx = DispatchContext(inp, t, dt)
56        self.profile.constraints(m, v, ctx)
57        wear = self.degradation.cost_per_kwh_cycled * ((charge + discharge) * dt).sum()
58        m.add_objective(self.profile.objective(m, v, ctx) + wear)   # always minimised
59
60        status, _ = m.solve(solver_name="highs")
61        return OptimizationResult.from_model(m, status, inp)

The core has no idea whether it’s saving a household money or trading on a market. That knowledge lives in the profile, which is exactly where I want it.

Profiles as strategies #

A profile returns a cost to minimise (revenue is just negative cost) and can add constraints of its own:

 1class OptimizationProfile:
 2    def objective(self, m, v, ctx) -> linopy.LinearExpression: ...
 3    def constraints(self, m, v, ctx) -> None:
 4        pass                                   # most profiles add none
 5
 6
 7class RetailSelfConsumption(OptimizationProfile):
 8    def objective(self, m, v, ctx):
 9        p_imp = xr.DataArray(ctx.input.import_prices_per_kwh, coords=[ctx.t])
10        p_exp = xr.DataArray(ctx.input.export_prices_per_kwh, coords=[ctx.t])
11        return ((v.grid_in * p_imp - v.grid_out * p_exp) * ctx.dt).sum()
12
13
14class WholesaleArbitrage(OptimizationProfile):
15    def __init__(self, day_ahead: list[float]):
16        self.day_ahead = day_ahead
17
18    def objective(self, m, v, ctx):
19        p = xr.DataArray(self.day_ahead, coords=[ctx.t])
20        return -(((v.grid_out - v.grid_in) * p) * ctx.dt).sum()   # maximise revenue
21
22
23class PeakShaving(OptimizationProfile):
24    def constraints(self, m, v, ctx):
25        self.peak = m.add_variables(lower=0, name="peak_import")
26        m.add_constraints(self.peak >= v.grid_in, name="peak_bound")
27
28    def objective(self, m, v, ctx):
29        return self.peak                        # minimise the highest grid import

At the call site, residential and merchant differ by one line:

1optimizer = BatteryDispatchOptimizer(
2    profile=RetailSelfConsumption(),            # or WholesaleArbitrage(day_ahead=...)
3    degradation=CycleCost(cost_per_kwh_cycled=0.03),
4)
5result = optimizer.solve(inp)

Time and uncertainty #

A schedule is built from one moment’s forecast and SOC, and both start drifting the second the solve finishes. So the Operations context wraps the core in a receding-horizon loop. Every 15 minutes it takes the latest forecasts and live SOC, re-plans the next 24 hours, dispatches only the next action and throws the rest away. It’s the same trick a sat-nav uses: plan the whole route, drive the next turn, re-plan when the road disagrees.

 1rolling = RollingOptimizer(
 2    profile=RetailSelfConsumption(),
 3    degradation=CycleCost(0.03),
 4    cadence_minutes=15,
 5    horizon_hours=24,
 6)
 7
 8action = rolling.step(now=datetime.now(), current_soc=device_state.soc,
 9                      forecasts=latest_forecasts, tariff=OctopusAgile(...))
10# action.charge_kw / action.discharge_kw → DeviceProvider

Operations owns time and state. The core stays a stateless, fast function from current facts to an optimal plan. It’s the same pattern Gomes et al. validated in a real home.6

The formulation still assumes the forecasts are right, and they won’t be. This is the part of the literature I found most humbling: in a real-home trial, Ruddick et al. found an optimising controller came within 0.6% of a simple rule-based one on cost, mostly because of forecast and data errors.14 All that optimality, and forecast error ate almost the entire gap. ¯\(ツ)/¯ Stochastic optimisation is out of scope for v1, so the rolling loop itself does the work of absorbing uncertainty. On top of that I plan a sensitivity study using real forecast errors, and an evaluation that compares real forecasts against actual outcomes instead of assuming perfect foresight.


Proving it works #

The real test is the Evaluation context: replay a year of historical weather, demand and prices through the rolling loop and report annual saving, payback and cycles. I haven’t built that yet. What I have run is a single synthetic day, which is enough to see whether the design behaves the way the arguments above say it should.

The setup: a 10 kWh battery (5 kW, 95% efficient each way, SOC between 10% and 100%), a 12 kW grid connection, 16 kWh of household load with a morning bump and a big evening peak, and a night-rate tariff: €0.09 from 23:00 to 08:00, €0.32 from 17:00 to 19:00, €0.23 otherwise, and €0.15 for exports. The sunny day makes 36 kWh of solar. The overcast day makes a quarter of that. “Net cost” is the bill plus the battery life used at €0.03 per kWh of throughput, and negative means the house came out ahead.

StrategySunny: billCyclesNet costOvercast: billCyclesNet cost
No battery−€2.790−€2.79€1.570€1.57
Naive rule (charge at night, cover load)−€2.800.68−€2.43€0.900.93€1.40
MILP, wear ignored−€4.252.82−€2.72−€0.282.82€1.24
MILP, wear priced−€3.270.39−€3.06€0.550.72€0.94

A few things jump out:

  • On the sunny day, the naive rule does worse than having no battery at all. It saves one cent on the bill and burns 37 cents of battery doing it. Charging from the grid overnight means there’s no room left for free solar at midday, which is exactly the interaction from the start of this post, showing up in the numbers.
  • The wear-blind MILP posts the best bill on both days, and it’s a trap. It cycles 2.8 times a day, spending €1.52 of battery life to squeeze out the last euro. Once you count the wear, it loses to the wear-aware version on both days. That’s the Xu et al. result4 at household scale: the most profitable-looking schedule is the one quietly using up the asset.
  • The wear-aware MILP wins on net cost on both days, and cycles less than any other battery strategy. It beats having no battery by €0.27 on the sunny day and €0.63 on the overcast one.

Those margins are cents, and I want to be careful with them. This is one made-up day, with perfect foresight: every strategy sees the true solar and load. That’s precisely the assumption Ruddick et al. found eats most of the advantage in a real home.14 So the question the year-long backtest has to answer is whether the gap over the naive rule survives real forecasts. If it doesn’t, that’s the finding, and I’ll write it up as such.

The same run doubled as a first pass at the tests, which encode the domain’s rules: the SOC never goes below the floor, a feasible commitment is met exactly (80% by 07:30, starting from 20%), an impossible one returns INFEASIBLE, and pricing wear cuts cycling on the sunny day from 2.82 to 0.39.


Where this goes next #

The v1 boundaries leave a few things out:

  • Multi-asset dispatch: EVs, heat pumps and other flexible loads behind one abstraction.
  • Higher-fidelity ageing: rainflow counting, temperature and calendar effects.
  • Stochastic optimisation: planning over forecast distributions.
  • Revenue stacking: frequency response and capacity markets alongside arbitrage.

Each of these fits the existing structure as a new profile, a new price provider or a new set of constraints. That’s how I’d judge the domain model: the roadmap should keep adding pieces without moving any of the boundaries.

Looking a bit further out, I suspect the interesting problem stops being “one battery, one house” fairly soon. With 1,200 GW of storage on the IEA’s 2030 path, a growing share of it will be small batteries in homes making the same half-hourly decision against the same price signals. When thousands of them all see the same cheap interval and charge together, they move the price they were optimising against. At that point the optimiser has to treat the price as partly its own doing, and that’s a different, much harder problem. I’d like to get there. First, though, it has to beat the naive rule for a whole year on real forecasts, not just for one day on perfect ones.


References #


  1. International Energy Agency, Batteries and Secure Energy Transitions, 2024. iea.org ↩︎

  2. BloombergNEF, stationary storage outlook, reported in “BloombergNEF: Stationary storage installations surge to 170 GWh in 2024,” ESS News, December 2024. ess-news.com ↩︎

  3. Modo Energy, “How does battery energy storage make money?” and GB BESS revenue benchmarks. modoenergy.com ↩︎

  4. B. Xu, J. Zhao, T. Zheng, E. Litvinov, D. S. Kirschen, “Factoring the Cycle Aging Cost of Batteries Participating in Electricity Markets,” IEEE Transactions on Power Systems, 33(2), 2248–2259, 2018. doi:10.1109/TPWRS.2017.2733339 ↩︎ ↩︎ ↩︎ ↩︎

  5. “Optimisation algorithms used in home energy management systems: A review,” Energy and Buildings, 347, 116338, 2025. sciencedirect.com ↩︎

  6. I. L. R. Gomes, M. G. Ruano, A. E. Ruano, “MILP-based model predictive control for home energy management systems: A real case study in Algarve, Portugal,” Energy and Buildings, 2023. sciencedirect.com ↩︎ ↩︎

  7. Z. Li, Q. Guo, H. Sun, J. Wang, “Sufficient Conditions for Exact Relaxation of Complementarity Constraints for Storage-Concerned Economic Dispatch,” IEEE Transactions on Power Systems, 31(2), 1653–1654, 2016. ieeexplore.ieee.org ↩︎

  8. E. Prat, R. M. Lusby, J. M. Morales, S. Pineda, P. Pinson, “How long is long enough? Finite-horizon approximation of energy storage scheduling problems,” arXiv:2411.17463, 2024 (preprint). arxiv.org ↩︎

  9. M. Seydenschwanz, K. Majewski, C. Gottschalk, R. Fink, “Linear Approximation of Cyclic Battery Aging Costs for MILP-Based Power Dispatch Optimization,” IEEE PES ISGT-Europe, 2019. ieeexplore.ieee.org ↩︎

  10. R. Nebuloni, V. Ilea, A. Berizzi, “A Real-Time Cycle Counting Method for Battery Degradation Calculation in MILP Models,” IEEE EEEIC / I&CPS Europe, 2023. ieeexplore.ieee.org ↩︎

  11. G. He, S. Kar, J. Mohammadi, P. Moutis, J. F. Whitacre, “Power System Dispatch With Marginal Degradation Cost of Battery Storage,” IEEE Transactions on Power Systems, 2021. doi:10.1109/TPWRS.2020.3048401 ↩︎

  12. J. Schmalstieg, S. Käbitz, M. Ecker, D. U. Sauer, “A holistic aging model for Li(NiMnCo)O2 based 18650 lithium-ion batteries,” Journal of Power Sources, 257, 325–334, 2014. sciencedirect.com ↩︎

  13. N. Collath, B. Tepe, S. Englberger, A. Jossen, H. Hesse, “Aging aware operation of lithium-ion battery energy storage systems: A review,” Journal of Energy Storage, 55, 105634, 2022. doi:10.1016/j.est.2022.105634 ↩︎

  14. J. Ruddick, G. Ceusters, G. Van Kriekinge, et al., “Real-world validation of safe reinforcement learning, model predictive control and decision tree-based home energy management systems,” Energy and AI, 2024. doi:10.1016/j.egyai.2024.100448 ↩︎ ↩︎