Skip to contents

Functions optimize_demand(), add_battery_optimization() and smart_charging() solve one independent optimization problem per optimization window (typically one day). Depending on the objective and the variables involved, the window problem is solved either as a quadratic program (QP) with the OSQP backend, or as a linear or mixed-integer linear program (LP/MILP) with the HiGHS backend. Currently, the flextools package allows to optimize a time-series power load considering the following objectives:

In this article, we’ll cover the optimization problem for the energy cost minimization objective, for both the flexible demand (e.g. heatpumps, electric vehicles, etc.) and the battery.

Our energy cost optimization takes into account multiple profiles of energy prices with the objective to minimize the total cost, considering:

  • Imported energy cost
  • Exported energy income
  • Income from balancing markets (turn up/down demand), for the flexible demand only

Demand optimization

To minimize the energy cost using the flexibility from a power demand profile, the objective function of the optimization problem has been raised in the following way:

minOt,It,Ett=1T(ItPItEtPEtPTUt(OtLFt)PTDt(LFtOt))+λt=1T1(Ot+1Ot)2 \min_{O_t,\, I_t,\, E_t} \; \sum_{t=1}^{T} \Big( I_t \cdot PI_t - E_t \cdot PE_t - PTU_t\,(O_t-LF_t) - PTD_t\,(LF_t-O_t) \Big) + \lambda \sum_{t=1}^{T-1}\left(O_{t+1} - O_t\right)^{2}

The objective function and constraints of this optimization problem are represented below, where:

  • TT : Number of time intervals within the optimization window
  • GtG_t : Local power generation time-series vector
  • LStLS_t : Non-flexible (static) load time-series vector
  • LFtLF_t : Flexible load time-series vector (if not optimized)
  • OtO_t : Optimal flexible load time-series vector (decision variable)
  • ItI_t : Imported power (decision variable, non-negative)
  • EtE_t : Exported power (decision variable, non-negative)
  • yty_t : Binary grid mode (1 = importing, 0 = exporting)
  • LFmaxtLFmax_t : Maximum power that the flexible load can consume (load_capacity)
  • ICtIC_t, ECtEC_t : Grid import and export capacity
  • PItPI_t : Imported energy price
  • PEtPE_t : Exported energy price
  • PTUtPTU_t : Balancing price for turn-up power
  • PTDtPTD_t : Balancing price for turn-down power
  • hh : Time horizon, in number of time slots
  • λ\lambda : Ramping penalty weight

Since LFtLF_t is a known input, the two balancing terms collapse into a single linear coefficient on the decision variable, and the implementation minimizes the equivalent expression (both differ only by a constant):

minOt,It,Ett=1T(ItPItEtPEt+(PTDtPTUt)Ot)+λt=1T1(Ot+1Ot)2 \min_{O_t,\, I_t,\, E_t} \; \sum_{t=1}^{T} \Big( I_t \cdot PI_t - E_t \cdot PE_t + (PTD_t - PTU_t)\,O_t \Big) + \lambda \sum_{t=1}^{T-1}\left(O_{t+1} - O_t\right)^{2}

Note that λ\lambda penalizes slot-to-slot changes in the optimal load (ramping), not the deviation from the original flexible load profile.

The decision variables are X=[Ot,It,Et]X = [O_t, I_t, E_t], i.e. 3T3T continuous variables, and the solver depends on λ\lambda:

lambda Solver Extra variables Import/export exclusivity
0 (default) HiGHS MILP yty_t (TT binary) Enforced exactly by the binary grid mode
> 0 OSQP QP No binary variable; see the note below

Moreover, this optimization problem has the following constraints:

  • The energy consumed by the flexible load must remain the same than the expected behavior:

t=1TOtΔt=t=1TLFtΔt \sum_{t=1}^T O_t \Delta t = \sum_{t=1}^T LF_t \Delta t

  • Energy balance behind-the-meter:

ItEt=Ot+LStGttT I_t - E_t = O_t + LS_t - G_t \quad t \in T

  • The optimal flexible load must respect both its own maximum power and the grid connection capacity, combined into a single box bound exactly as described in the net power article:

O_tOtLF¯ttT \underline{O}_t \;\le\; O_t \;\le\; \overline{LF}_t \quad t \in T

LF¯t=min(max(GtLSt+ICt,0),LFmaxt),O_t=min(max(GtLStECt,0),LF¯t) \overline{LF}_t = \min\!\left(\max\!\left(G_t - LS_t + IC_t,\; 0\right),\; LFmax_t\right), \qquad \underline{O}_t = \min\!\left(\max\!\left(G_t - LS_t - EC_t,\; 0\right),\; \overline{LF}_t\right)

  • The imported and exported power must remain between 0 and the grid import and export capacity:

0ItICt0EtECttT 0 \le I_t \le IC_t \quad 0 \le E_t \le EC_t \quad t \in T

  • Imported and exported energy can not be positive at the same time. When λ=0\lambda = 0 this is enforced exactly with one binary grid mode variable per time slot:

0ItMtIyt0EtMtE(1yt)yt{0,1}tT 0 \le I_t \le M^I_t\, y_t \qquad 0 \le E_t \le M^E_t\,(1-y_t) \qquad y_t \in \{0,1\} \quad t \in T

Where MtIM^I_t and MtEM^E_t are the tightest per-slot bounds implied by the optimal load bounds and the site balance:

MtI=min(ICt,max(LF¯t+LStGt,0)),MtE=min(ECt,max(GtLStO_t,0)) M^I_t = \min\!\left(IC_t,\; \max\!\left(\overline{LF}_t + LS_t - G_t,\; 0\right)\right), \qquad M^E_t = \min\!\left(EC_t,\; \max\!\left(G_t - LS_t - \underline{O}_t,\; 0\right)\right)

  • The forward and backward time horizon constraints are identical to those described in the net power article, and are not repeated here.

When lambda > 0. The quadratic ramping term makes the problem a QP, which is solved with OSQP over the same 3T3T continuous variables but without the binary grid mode. Two adjustments keep that QP well posed:

  • Export prices are clipped to PEtmin(PEt,PIt)PE_t \leftarrow \min(PE_t, PI_t). Without this, a slot where exporting pays more than importing costs would make the objective unbounded below. A warning is emitted once when clipping occurs.
  • The upper bounds on ItI_t and EtE_t are tightened to MtIM^I_t and MtEM^E_t above, which is what keeps the problem bounded when import prices are negative.

Because there is no binary variable on this path, the solver may in principle return a small simultaneous import and export in the same slot. Since only the net flow ItEtI_t - E_t is physically meaningful and it is preserved, the reported profiles are collapsed to a single direction per slot afterwards. If a strict per-slot exclusivity guarantee is required, use the default lambda = 0.

Infeasible windows. The same minimal grid capacity relaxation described in the net power article applies here, falling back to the original profile LFLF if the solver still fails.

Battery optimization

To minimize the energy cost using the flexibility from a battery, the objective function of the optimization problem has been raised in the following way:

minCt,Dt,It,Ett=1T(ItPItEtPEt+ccycBcapDt)+λt=1T1((Ct+1Dt+1)(CtDt))2 \min_{C_t,\, D_t,\, I_t,\, E_t} \; \sum_{t=1}^{T} \left( I_t \cdot PI_t - E_t \cdot PE_t + \frac{c_{cyc}}{B_{cap}} \cdot D_t \right) + \lambda \sum_{t=1}^{T-1}\Big((C_{t+1} - D_{t+1}) - (C_t - D_t)\Big)^{2}

Where:

  • TT : Number of time intervals within the optimization window
  • GtG_t : Local power generation time-series vector
  • LtL_t : Power load time-series vector
  • CtC_t : Battery charging power (decision variable, non-negative)
  • DtD_t : Battery discharging power (decision variable, non-negative)
  • Bt=CtDtB_t = C_t - D_t : Net battery exchange (positive = charging)
  • ItI_t : Imported power (decision variable, non-negative)
  • EtE_t : Exported power (decision variable, non-negative)
  • yty_t : Binary grid mode (1 = importing, 0 = exporting)
  • PItPI_t : Imported energy price
  • PEtPE_t : Exported energy price
  • λ\lambda : Ramping penalty weight

Additionally, this optimization problem also counts with the following parameters used in the constraints below:

  • BcapB_{cap} : Battery capacity
  • BcB_c : Maximum charging power
  • BdB_d : Maximum discharging power
  • SOCminSOC_{min} : Minimum state of charge of the battery (%)
  • SOCmaxSOC_{max} : Maximum state of charge of the battery (%)
  • SOCiniSOC_{ini} : State of charge at the beginning/end of the optimization window (%)
  • ηc\eta_c : Battery charging efficiency (charge_eff)
  • ηd\eta_d : Battery discharging efficiency (discharge_eff)
  • ccycc_{cyc} : Cycle degradation cost per kWh cycled (cycle_cost)

Unlike the net power objective, the cost objective uses separate charging and discharging variables CtC_t and DtD_t, which is what allows the charging and discharging efficiencies to be applied correctly in the state-of-charge constraints.

Note also that the balancing market prices (PTUtPTU_t, PTDtPTD_t) only apply to the flexible demand optimization; they are not part of the battery objective.

Optimization constraints:

  • Charging and discharging power limits:

0CtBc0DtBdtT 0 \le C_t \le B_c \qquad 0 \le D_t \le B_d \quad t \in T

There is no binary battery mode variable forcing CtDt=0C_t \cdot D_t = 0. Simultaneous charging and discharging is avoided implicitly: it either has no benefit (at equal prices there is nothing to gain from cycling energy through the battery) or is directly penalized, since a positive cycle_cost makes it economically self-defeating.

  • State of charge limits, considering charging and discharging efficiencies:

SOCminSOCini+100Bcapk=1t(ηcCkDkηd)ΔtSOCmaxt=1,,T SOC_{min} \le SOC_{ini} + \frac{100}{B_{cap}} \sum_{k=1}^t \left(\eta_c C_k - \frac{D_k}{\eta_d}\right) \Delta t \le SOC_{max} \quad t = 1, \dots, T

  • The balance of stored energy must be 0 at the end of the optimization window to have the same initial state of charge at the beginning of every optimization window:

t=1T(ηcCtDtηd)Δt=0 \sum_{t=1}^T \left(\eta_c C_t - \frac{D_t}{\eta_d}\right) \Delta t = 0

  • Energy balance behind-the-meter:

ItEt=CtDt+LtGttT I_t - E_t = C_t - D_t + L_t - G_t \quad t \in T

  • The imported and exported power must remain between 0 and the grid import and export capacity:

0ItICt0EtECttT 0 \le I_t \le IC_t \quad 0 \le E_t \le EC_t \quad t \in T

Solver paths

The combination of lambda and cycle_cost selects one of three formulations:

lambda cycle_cost Solver Variables Import/export exclusivity
0 (default) 0 (default) HiGHS MILP Ct,Dt,It,EtC_t, D_t, I_t, E_t + yty_t binary Enforced exactly by the binary grid mode
0 > 0 HiGHS LP Ct,Dt,It,EtC_t, D_t, I_t, E_t Not enforced; see below
> 0 any OSQP QP Ct,Dt,It,EtC_t, D_t, I_t, E_t + SOC state Not enforced; see below

MILP path (default). Imported and exported energy can not be positive at the same time, enforced with one binary grid mode variable per time slot:

0ItMtIyt0EtMtE(1yt)yt{0,1}tT 0 \le I_t \le M^I_t\, y_t \qquad 0 \le E_t \le M^E_t\,(1-y_t) \qquad y_t \in \{0,1\} \quad t \in T

MtI=min(ICt,max(LtGt+Bc,0)),MtE=min(ECt,max(GtLt+Bd,0)) M^I_t = \min\!\left(IC_t,\; \max\!\left(L_t - G_t + B_c,\; 0\right)\right), \qquad M^E_t = \min\!\left(EC_t,\; \max\!\left(G_t - L_t + B_d,\; 0\right)\right)

This is the most accurate but also the most expensive path, since a branch-and-bound problem is solved for every optimization window.

LP path (cycle_cost > 0). A positive cycle cost adds the linear term ccycBcapDt\frac{c_{cyc}}{B_{cap}} D_t to the objective, which converts the per-kWh degradation cost into a penalty on discharged power, so the optimizer trades energy cost savings against battery wear. This penalty alone makes simultaneous charging and discharging uneconomical, so the binary grid mode is dropped and the problem becomes a pure LP — substantially faster than the MILP. On this path the import and export bounds are tightened to their physically achievable values, which is what keeps the LP bounded when import prices are negative:

0Itmin(ICt,max(LtGt+Bc,0)),0Etmin(ECt,max(GtLt+Bd,0)) 0 \le I_t \le \min\!\left(IC_t,\; \max\!\left(L_t - G_t + B_c,\; 0\right)\right), \qquad 0 \le E_t \le \min\!\left(EC_t,\; \max\!\left(G_t - L_t + B_d,\; 0\right)\right)

QP path (lambda > 0). The quadratic ramping term is applied to the net battery power Bt=CtDtB_t = C_t - D_t and the problem is solved with OSQP. Both cycle_cost and lambda are active simultaneously when both are positive. As for the demand QP, export prices are clipped to PEtmin(PEt,PIt)PE_t \leftarrow \min(PE_t, PI_t) to keep the objective bounded below, with a warning emitted once. If OSQP fails to converge, the solver falls back to the LP path, which retains the cycle cost but loses the ramping term.

Neither the LP nor the QP path includes a binary variable enforcing ItEt=0I_t \cdot E_t = 0. In practice the state-of-charge constraint couples all slots of the window strongly enough that within-slot simultaneous flows are not profitable. When a strict exclusivity guarantee is required, use the default MILP path (lambda = 0 and cycle_cost = 0).