Multi-period OPF

Kind: Problem specification · Maturity: promotion candidate · Direction: forward · Temporal: inter-temporal

solve_multiperiod_opf co-optimises a sequence of network snapshots in one JuMP model, with storage/EV devices whose state of charge links each period to the next. This is the formulation the single-snapshot solve_opf cannot express, because state of charge couples time steps.

Under the hood it uses the BMOPFTools staged API: every snapshot is built into one shared model with build_opf_model(add_objective=false), each device's state of charge is linked across the snapshots, the per-snapshot generation-cost rates are multiplied by the corresponding TimeGrid duration and summed into one interval-cost objective, KCL is enforced per snapshot, and the model is solved once. The dt_h keyword is the uniform-duration shorthand.

Worked example: battery arbitrage

Two periods with a time-varying slack import price (set via each snapshot's voltage_source cost). A cyclic battery discharges into the expensive period and recharges in the cheap one:

using PowerOptLab
using BMOPFTools: parse_bmopf

net(price) = parse_bmopf("""
{"bus":{
    "sourcebus":{"terminal_names":["1","n"],"perfectly_grounded_terminals":["n"]},
    "bus1":     {"terminal_names":["1","n"],"perfectly_grounded_terminals":["n"],
                 "v_min":[900.0],"v_max":[1100.0]}},
 "voltage_source":{"vs":{"bus":"sourcebus","terminal_map":["1"],
     "v_magnitude":[1000.0],"v_angle":[0.0],"cost":[$price]}},
 "linecode":{"lc":{"R_series_1_1":0.1}},
 "line":{"l1":{"bus_from":"sourcebus","bus_to":"bus1",
     "terminal_map_from":["1"],"terminal_map_to":["1"],"linecode":"lc","length":1.0}},
 "load":{"ld1":{"bus":"bus1","terminal_map":["1","n"],
     "configuration":"SINGLE_PHASE","p_nom":[100000.0],"q_nom":[0.0]}}}
"""; from_string=true)

nets = [net(0.20), net(0.05)]      # expensive, then cheap
bat  = StorageDevice(id="bat", bus="bus1",
                     p_charge_max=40e3, p_discharge_max=40e3,
                     energy_max=100e3, energy_init=40e3, cyclic=true)

res = solve_multiperiod_opf(nets, [bat]; dt_h=1.0)

res.dispatch["bat"].p_net   # ≈ [ +40e3, −40e3 ]  discharge then charge (W)
res.dispatch["bat"].soc     # ≈ [ 40e3, 0.0, 40e3 ]  (Wh, cyclic)

For nonuniform telemetry or tariff intervals, supply one positive duration per snapshot. The same duration weights both the objective rate and state update:

grid = TimeGrid([0.25, 0.75])
res = solve_multiperiod_opf(nets, [bat]; time_grid=grid)

Multiple devices

Pass any mix of StorageDevice and EVDevice; each gets its own state-of-charge trajectory and constraints, all co-optimised in the same model:

res = solve_multiperiod_opf(nets, [bat, ev1, ev2]; dt_h=0.5)

Per-period economics come from the snapshots themselves — a time-varying import price, differing loads, or any other per-snapshot data the BMOPFTools objective sees.

Result

solve_multiperiod_opf returns a MultiperiodResult: the per-period BMOPFTools result dicts (res.snapshots) plus each device's SI p_charge, p_discharge, p_net, q, and soc trajectories in res.dispatch. See the API reference for solve_multiperiod_opf and MultiperiodResult.

Numerical values are published only when the shared solve reaches OPTIMAL or LOCALLY_SOLVED with a feasible primal point. Iteration-limited, infeasible, or relaxed-tolerance outcomes retain their termination status and return NaN objective/trajectory values rather than presenting a candidate iterate as an optimum.