Avoiding Simultaneous Import and Export Electricity in Linear Programs

How to avoid simultaneous import and export electricity in linear programs of energy systems.

8 minute read

Simultaneous import and export in linear programs is a problem that affects most linear program models of energy systems.

To solve it, we will introduce two linear programming tricks:

  1. Constraining the upper and lower bounds of continuous variables to both a non-zero lower bound and zero,
  2. Linking two continuous variables together through binary variables.

The code in this post depends on the PuLP library for a linear programming framework, and Pandas for working with the results.

You can install them both into a Python environment with:

$ pip install pulp pandas

Context

When modelling energy systems, we are often modelling assets where electricity can flow in opposite directions. Examples include a site that can import or export electricity, or a battery that can charge or discharge electricity.

While electricity can flow in opposite directions, it cannot flow in opposite directions simultaneously.

Most linear program models of energy systems discretize time - the time between the start and end of a simulation is split into intervals, often 30 minutes long.

Energy balances are created for each interval. For a single interval of time, there can only be one of import of export electricity.

In reality, it’s possible a site both imports and exports within an interval. However, there would have been no period during that 30 minutes where both import and export occurred simultaneously.

Making a linear program modelling an energy system deal with this simultaneous import and export problem is not trivial - hence this blog post.

A Simple Site with Import, Export, Demand and Generation

To demonstrate the problem, we will setup a site that has an electric load, a generator, and a grid connection that can import and export electricity.

The code below sets this scenario as a linear program using the PuLP Python library:

import pulp


def extract_inputs_and_results(inputs: dict[str, float], prob: pulp.LpProblem) -> dict[str, float]:
    results = {f"input--{k}": v for k, v in inputs.items()}
    results["status"] = pulp.LpStatus[prob.status]
    for v in prob.variables():
        results[f"variable--{v.name}"] = v.varValue
    return results


def run_linear_program(generation: float = 25, demand: float = 50) -> dict[str, float]:
    prob = pulp.LpProblem("minimize", pulp.LpMinimize)

    site_import = pulp.LpVariable("site_import", 0, 100)
    site_export = pulp.LpVariable("site_export", 0, 100)

    prob += site_import + generation == site_export + demand
    prob.solve(pulp.PULP_CBC_CMD(msg=0))

    return extract_inputs_and_results(
        {"generation": generation, "demand": demand}, prob
    )


run_linear_program()
{
    'input--generation': 25,
    'input--demand': 50,
    'status': 'Optimal',
    'variable--__dummy': None,
    'variable--site_export': 0.0,
    'variable--site_import': 25.0
}

There are a few things to note:

  • The program has a single constraint (an energy balance around the site) - it doesn’t have an objective function,
  • the upper bounds on site_import and site_export are both set to 100 - if these are not set, the program can become infeasible.

We can check this works correctly by changing the on-site generation and seeing how the import and export change:

import pandas as pd

pd.DataFrame(
    run_linear_program(generation) for generation in [0, 25, 50, 100]
)[["input--generation", "variable--site_export", "variable--site_import"]]
   input--generation  variable--site_export  variable--site_import
0                  0                    0.0                   50.0
1                 25                    0.0                   25.0
2                 50                   -0.0                    0.0
3                100                   50.0                    0.0

The program above works fine - as generation increases, we reduce import and increase export.

Simultaneous Import and Export

We can force our linear program to import and export simultaneously by setting the import price lower than the export price.

This will require adding an objective function to our program, as well as separate import and export electricity prices.

The scenario we setup is one where export prices are lower than import prices. While this rarely happens in practice, it is possible.

A naive implementation will allow simultaneous import and export. In a scenario where export prices are lower than import prices, the optimal solution is to import and export electricity in the same interval.

import pandas as pd
import pulp


def run_linear_program(
    generation: float,
    demand: float = 50,
    import_price: float = 50,
    export_price: float = 500,
) -> None:
    prob = pulp.LpProblem("minimize", pulp.LpMinimize)

    site_import = pulp.LpVariable("site_import", 0, 100)
    site_export = pulp.LpVariable("site_export", 0, 100)

    prob += site_import + generation == site_export + demand
    prob += site_import * import_price - site_export * export_price
    prob.solve(pulp.PULP_CBC_CMD(msg=0))

    print(f"status = {pulp.LpStatus[prob.status]}")
    for v in prob.variables():
        print(v.name, "=", v.varValue)

run_linear_program(25)
status = Optimal
site_export = 75.0
site_import = 100.0

We now see that our program imports power and then exports it within the same interval.

While this is optimal for how we have setup the program (well done solver!), it’s not physically possible.

Stopping Simultaneous Import and Export

To prevent simultaneous import and export, we can introduce binary variables. This will also turn our linear program into a mixed integer linear program.

The binary variables link the import and export electricity flow together, with two additional constraints on the import and export (two constraints in total):

import pulp


def run_linear_program(
    generation: float,
    demand: float = 50,
    import_price: float = 50,
    export_price: float = 500,
    site_import_limit: int = 100,
    site_export_limit: int = 100,
) -> None:
    prob = pulp.LpProblem("minimize", pulp.LpMinimize)

    site_import = pulp.LpVariable("site_import", 0, None)
    site_export = pulp.LpVariable("site_export", 0, None)

    prob += site_import * import_price - site_export * export_price
    prob += site_import + generation == site_export + demand

    site_import_binary = pulp.LpVariable("site_import_binary", cat="Binary")
    site_export_binary = pulp.LpVariable("site_export_binary", cat="Binary")

    prob += site_import - site_import_limit * site_import_binary <= 0
    prob += site_export - site_export_limit * site_export_binary <= 0
    prob += site_import_binary + site_export_binary == 1

    prob.solve(pulp.PULP_CBC_CMD(msg=0))
    print(f"status = {pulp.LpStatus[prob.status]}")
    for v in prob.variables():
        print(v.name, "=", v.varValue)


run_linear_program(25)
status = Optimal
site_export = 0.0
site_export_binary = 0.0
site_import = 25.0
site_import_binary = 1.0

Now we see that our site only imports electricity.

Explaining the Tricks

There are two tricks we play to get this to work:

  1. Constrain the upper bound on site_import and site_export,
  2. Link two continuous variables together through two binary variables.

Constraining Upper and Lower Bounds

When defining a linear program variable in PuLP, we can easily set the upper and lower bounds:

import pulp

var = pulp.LpVariable("a-name", 0, 100)

What we cannot easily do is create a variable that be discontinuous - a variable that could range from 100 to 50, or be zero.

We can create this variable by introducing a binary variable and two constraints:

prob = pulp.LpProblem("minimize", pulp.LpMinimize)
var = pulp.LpVariable("var", 0, 100)
binary = pulp.LpVariable("binary", cat="Binary")
upper_limit = 100
lower_limit = 50

prob += var - upper_limit * binary <= 0
prob += lower_limit * binary - var <= 0

The table below shows the possible states of the continuous and binary variables, along with their feasibility when the binary variable is 1:

Var Binary Upper Limit Constraint Lower Limit Constraint Feasible
150 1 150 - 100 * 1 = 50 50 * 1 - 150 = -100 No
100 1 100 - 100 * 1 = 0 50 * 1 - 100 = -50 Yes
75 1 75 - 100 * 1 = -25 50 * 1 - 75 = -25 Yes
50 1 50 - 100 * 1 = -50 50 * 1 - 50 = 0 Yes
25 1 25 - 100 * 1 = -75 50 * 1 - 25 = 25 No

Table below does the same for when the binary variable is 0:

Var Binary Upper Limit Constraint Lower Limit Constraint Feasible
150 0 150 - 100 * 0 = 150 50 * 0 - 150 = -150 No
100 0 100 - 100 * 0 = 100 50 * 0 - 100 = -100 No
75 0 75 - 100 * 0 = 75 50 * 0 - 75 = -75 No
50 0 50 - 100 * 0 = 50 50 * 0 - 50 = -50 No
25 0 25 - 100 * 0 = 25 50 * 0 - 25 = -25 No

Linking Two Continuous Variables Together

Linking the two continuous variables can be done with a simple sum constraint, that limits the sum of both binary variables to be 1:

prob = pulp.LpProblem("minimize", pulp.LpMinimize)

a = pulp.LpVariable("a", 0, 100)
a_binary = pulp.LpVariable("a_binary", cat="Binary")
a_upper_limit = 100
a_lower_limit = 50

prob += a - a_upper_limit * a_binary <= 0
prob += a_lower_limit * a_binary - a <= 0

b = pulp.LpVariable("b", 0, 100)
b_binary = pulp.LpVariable("b_binary", cat="Binary")
b_upper_limit = 100
b_lower_limit = 50

prob += b - b_upper_limit * b_binary <= 0
prob += b_lower_limit * b_binary - b <= 0

prob += a_binary + b_binary == 1

Full Code

Here is the final working code:

import pulp


def run_linear_program(
    generation: float,
    demand: float = 50,
    import_price: float = 50,
    export_price: float = 500,
    site_import_limit: int = 100,
    site_export_limit: int = 100,
) -> None:
    prob = pulp.LpProblem("minimize", pulp.LpMinimize)

    site_import = pulp.LpVariable("site_import", 0, None)
    site_export = pulp.LpVariable("site_export", 0, None)

    prob += site_import * import_price - site_export * export_price
    prob += site_import + generation == site_export + demand

    site_import_binary = pulp.LpVariable("site_import_binary", cat="Binary")
    site_export_binary = pulp.LpVariable("site_export_binary", cat="Binary")

    prob += site_import - site_import_limit * site_import_binary <= 0
    prob += site_export - site_export_limit * site_export_binary <= 0
    prob += site_import_binary + site_export_binary == 1

    prob.solve(pulp.PULP_CBC_CMD(msg=0))
    print(f"status = {pulp.LpStatus[prob.status]}")
    for v in prob.variables():
        print(v.name, "=", v.varValue)


run_linear_program(25)

Summary

This post introduced two linear programming tricks that allows us to avoid common problems when modelling energy systems as linear programs. The tricks are:

  1. Constrain the upper and lower bounds of continuous variables to both a non-zero lower bound and zero,
  2. Linking two continuous variables together through binary variables.

These tricks allow us to avoid simultaneous import and export power in linear programs of energy systems.


Thanks for reading!

If you are interested in linear programming for energy systems, check out energy-py-linear:

$ pip install energypylinear
import energypylinear as epl

#  2.0 MW, 4.0 MWh battery
asset = epl.Battery(
    power_mw=2,
    capacity_mwh=4,
    efficiency_pct=0.9,
    # different electricity prices for each interval
    # length of electricity_prices is the length of the simulation
    electricity_prices=[100.0, 50, 200, -100, 0, 200, 100, -100],
    # a constant value for each interval
    export_electricity_prices=40,
)

simulation = asset.optimize()

Created: