Avoiding Simultaneous Import and Export Electricity in Linear Programs
How to avoid simultaneous import and export electricity in linear programs of energy systems.
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:
- Constraining the upper and lower bounds of continuous variables to both a non-zero lower bound and zero,
- 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
andsite_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:
- Constrain the upper bound on
site_import
andsite_export
, - 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:
- Constrain the upper and lower bounds of continuous variables to both a non-zero lower bound and zero,
- 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()