0

I'm trying to determine the maximum revenue that can be earned from a battery connected to the grid using linear programming. The battery can earn revenues in two markets, the energy market and the frequency market. My model is throwing an error when I include a binary constraint in the objective function (TypeError: Non-constant expressions cannot be multiplied).

My Objective function is:

formula

  • N is the time horizon of the optimisation
  • formula is the energy price at time t
  • formula are the allocated discharge and charge power at time t
  • formula is the frequency price at time t
  • formula is the allocated frequency power at time t

The battery should only be active in one market (energy or frequency) at each time period t. So needs a constraint that looks something like this:

formula

where formula is a binary variable that activates activity x.

Ok, so that's what I trying to achieve. I'm struggling to create such a constraint in pulp that essentially switches off participation in one of the markets if the value in the other is higher (all other constraints being met). In my battery class, I've created decision variables for each of the power activities and also for their on/off status.


self.charge = \
pulp.LpVariable.dicts(
    "charging_power",
    ('c_t_' + str(i) for i in range(0,time_horizon)),
    lowBound=0, upBound=max_charge_power_capacity,
    cat='Continuous')

self.discharge = \
pulp.LpVariable.dicts(
    "discharging_power",
    ('d_t_' + str(i) for i in range(0,time_horizon)),
    lowBound=0, upBound=max_discharge_power_capacity,
    cat='Continuous')

self.freq = \
pulp.LpVariable.dicts(
    "freq_power",
    ('f_t_' + str(i) for i in range(0,time_horizon)),
    lowBound=0, upBound=max_freq_power_capacity,
    cat='Continuous')

self.charge_status = \
pulp.LpVariable.dicts(
    "charge_status",
    ('c_status_t_' + str(i) for i in range(0,time_horizon)),
    cat='Binary')

self.discharge_status = \
pulp.LpVariable.dicts(
    "discharge_status",
    ('d_status_t_' + str(i) for i in range(0,time_horizon)),
    cat='Binary')

self.freq_status = \
pulp.LpVariable.dicts(
    "freq_status",
    ('ds3_status_t_' + str(i) for i in range(0,time_horizon)),
    cat='Binary')

In my objective function, I included these binary variables.

    
self.model = pulp.LpProblem("Max Profit", pulp.LpMaximize)

self.model +=\
pulp.lpSum(
    [self.charge['c_t_' + str(i)]*-1*prices[i] *
     self.charge_status['c_status_t_' + str(i)] for i in range(0,self.time_horizon)]
    + [self.discharge['d_t_' + str(i)]*prices[i] * 
     self.discharge_status['d_status_t_' + str(i)] for i in range(0,self.time_horizon)]
    + [self.freq['f_t_' + str(i)]*freq_prices[i] *
     self.freq_status['freq_status_t_' + str(i)] for i in range(0,self.time_horizon)]
)

The constraint for these binary variables, I set up as follows:


 for hour_of_sim in range(1,self.time_horizon+1):     
    self.model += \
        pulp.lpSum([self.charge_status['c_status_t_' + str(i)] for i in range(0,self.time_horizon)] +\
                   [self.discharge_status['d_status_t_' + str(i)] for i in range(0,self.time_horizon)] +\
                   [self.freq_status['freq_status_t_' + str(i)] for i in range(0,self.time_horizon)]
                  ) <= 1

When I try to solve, I get a

TypeError: Non-constant expressions cannot be multiplied

on the objective function. Doesn't like my binary variables, runs if they are removed. There must be an alternative way of setting this up which is escaping me?

sharkdawg
  • 958
  • 1
  • 8
  • 20
  • 1
    You cannot multiply variables in general. You need to linearize this at model-time. Google for linearization of a product of some continuous and some binary variable. – sascha Feb 10 '22 at 12:08

1 Answers1

3

The comment is correct... you are violating "linearity" by multiplying 2 variables together. Fortunately, this is easy to linearize. You have a binary variable controlling the mode, so the key element you are looking for (google it) is a Big-M constraint, where you use the binary variable multiplied by a max value (or just something sufficiently large) to limit the other variable either to the max, or clamp it to zero.

An example below. I also re-arranged things a bit. You might find this style more readable. Two main things on style:

  1. You are constantly re-creating the indices you are using which is really painful to read and error prone. Just make them and re-use them...and you don't need to get complicated with the index set values

  2. You can easily double-index this model, which I think is more clear than making multiple sets of variables. You essentially have 2 sets you are working with: Time periods, and Op modes. Just make those sets and double index.

Example

# battery modes

import pulp

# some data
num_periods = 3
rate_limits = { 'energy'    : 10,
                'freq'      : 20}
price = 2  # this could be a table or double-indexed table of [t, m] or ....

# SETS
M = rate_limits.keys()   # modes.  probably others...  discharge?
T = range(num_periods)   # the time periods

TM = {(t, m) for t in T for m in M}

model = pulp.LpProblem('Batts', pulp.LpMaximize)

# VARS
model.batt = pulp.LpVariable.dicts('batt_state', indexs=TM, lowBound=0, cat='Continuous')
model.op_mode = pulp.LpVariable.dicts('op_mode', indexs=TM, cat='Binary')

# Constraints

# only one op mode in each time period...
for t in T:
    model += sum(model.op_mode[t, m] for m in M) <= 1

# Big-M constraint. limit rates for each rate, in each period.
# this does 2 things:  it is equivalent to the upper bound parameter in the var declaration
#                      It is a Big-M type of constraint which uses the binary var as a control <-- key point
for t, m in TM:
    model += model.batt[t, m] <= rate_limits[m] * model.op_mode[t, m]

# OBJ
model += sum(model.batt[t, m] * price for t, m in TM)

print(model)

#  solve...

Yields:

Batts:
MAXIMIZE
2*batt_state_(0,_'energy') + 2*batt_state_(0,_'freq') + 2*batt_state_(1,_'energy') + 2*batt_state_(1,_'freq') + 2*batt_state_(2,_'energy') + 2*batt_state_(2,_'freq') + 0
SUBJECT TO
_C1: op_mode_(0,_'energy') + op_mode_(0,_'freq') <= 1

_C2: op_mode_(1,_'energy') + op_mode_(1,_'freq') <= 1

_C3: op_mode_(2,_'energy') + op_mode_(2,_'freq') <= 1

_C4: batt_state_(2,_'freq') - 20 op_mode_(2,_'freq') <= 0

_C5: batt_state_(2,_'energy') - 10 op_mode_(2,_'energy') <= 0

_C6: batt_state_(1,_'freq') - 20 op_mode_(1,_'freq') <= 0

_C7: batt_state_(1,_'energy') - 10 op_mode_(1,_'energy') <= 0

_C8: batt_state_(0,_'freq') - 20 op_mode_(0,_'freq') <= 0

_C9: batt_state_(0,_'energy') - 10 op_mode_(0,_'energy') <= 0

VARIABLES
batt_state_(0,_'energy') Continuous
batt_state_(0,_'freq') Continuous
batt_state_(1,_'energy') Continuous
batt_state_(1,_'freq') Continuous
batt_state_(2,_'energy') Continuous
batt_state_(2,_'freq') Continuous
0 <= op_mode_(0,_'energy') <= 1 Integer
0 <= op_mode_(0,_'freq') <= 1 Integer
0 <= op_mode_(1,_'energy') <= 1 Integer
0 <= op_mode_(1,_'freq') <= 1 Integer
0 <= op_mode_(2,_'energy') <= 1 Integer
0 <= op_mode_(2,_'freq') <= 1 Integer
AirSquid
  • 10,214
  • 2
  • 7
  • 31
  • I think I've implemented big M now and removed the variable multiplication from the obj func. For the binary variables at each hour, I multiply by M and set it >= the power decision variable for that hour. When the power decision variable is positive, it should force the binary variable to 1. I keep the binary constraint where the sum of the binary variables <=1. Although the model now runs, this constraint doesnt seem to be working. When I look at the binary variables they all remain at 0 throughout the horizon. Anything obvious I'm missing? Good point on the style/readability – sharkdawg Feb 11 '22 at 12:20
  • I’m not sure…. What you state in the comment sounds correct. You have “maximize” set? Are any variables/the objective positive in the result? Have you inspected the solver output to ensure it is feasible/optimal? If you are still stuck, you probably need to whittle it down to another example and post another question with same tags (and or flag me in a comment on it) – AirSquid Feb 11 '22 at 15:45
  • Yes solver is optimal. Think it could be an issue with my indexing which you've already pointed out is error prone. Reluctant to refactor until I've a working model and feel like I'm pretty close. I'll spend some more time on it and post a follow up if needed. I'll mark this as answered, appreciate the help. – sharkdawg Feb 11 '22 at 16:39