pythonpyomo

Limit the sum of param and var at any timestep


I'm currently writing a Python/Pyomo script that optimizes the combined usage of a battery storage system and a solar power plant.

I want to constrain the combined energy output of the solar power plant and the battery so that it is at every timestep lower than a specific constant value (i.e., the maximum allowed power generation of the solar power plant).

The error that I get with this constraint is that the optimization finishes but Pyomo vars seemingly did not get initialized at all.

My Pyomo code works fine with all the other constrains that I added, but when I add the following constraint ("maxSingleDischarge") the solver (glpk) seems to crash (but finishes?) or it doesn't do anything at all. Here's a short extract from the code, with the relevant pieces:

model = ConcreteModel()
model.T = Set(doc='hour of year', initialize=df.hour.tolist(), ordered=True) # time index
model.PV = Param(model.T,initialize=PV_curve) # time series of PV production (constant, initialized with a list of int)
model.Ein = Var(model.T, domain=NonNegativeReals) # Charged energy of the battery (optimized)
model.Eout = Var(model.T, domain=NonNegativeReals) # Discharged energy of the battery (optimized)
model.DSys = Param(initialize=maxDischarge_System) #Parameter setting the maximum allowed combined discharge (initialized with a constant int)

def maxSingleDischarge(model, t):
    return (model.Eout[t] + model.PV[t]) <= model.DSys

model.maxSingleDischarge = Constraint(model.T, rule=maxSingleDischarge)

As I mentioned earlier, adding this constraint causes vars like Ein or Eout to not be initialized at all. When I want to look at the optimization results, I get errors similar to:

Traceback (most recent call last):
  File "<input>", line 2, in <module>
  File "<input>", line 2, in <listcomp>
  File "pyomo\core\expr\numvalue.pyx", line 153, in pyomo.core.expr.numvalue.value
  File "pyomo\core\expr\numvalue.pyx", line 140, in pyomo.core.expr.numvalue.value
ValueError: No value for uninitialized NumericValue object Ein[0]

Other constraints work fine, as for example the constraint below, where I limit the sum of discharged energy from the battery within the last 24 hours to a specific value (model.Dmax). Interestingly, this constraint is in my opinion defined in a very similar way, but even a bit more complex than the one I struggle with.

def timed_discharge_limit(model, t):
    "Limit on discharge within a 24 hour period"
    max_t = model.T.last()

    if t < max_t - 24:
        return sum(model.Eout[i] for i in range(t, t + 24)) <= model.Dmax
    else:
        return Constraint.Skip

if model.Dmax != 0:
    model.limit_out = Constraint(model.T, rule=timed_discharge_limit)

My question is, where have I made a mistake in implementing the constraint "maxSingleDischarge"? Is there a better way to define that constraint? I hope I added enough information so that you might have an idea what the problem could be. I'm thankful for any suggestion how to properly add this constraint.

Edit:

Because it was requested, I add a shortened version of the code with some data with which the error is reproducable:

A main file including the data

from stackoverflow_bat import *

time_index = np.arange(0,24*7)

#PV production [kW]
PV_curve =[5410.47, 5530.605000000001, 5620.59, 5638.1849999999995, 5631.915,
           5625.72, 5625.48, 5625.42, 5625.3150000000005, 5625.225, 5619.195,
           5625.225, 5631.285, 5625.465, 5631.599999999999, 5631.735, 5631.6900000000005,
           5631.96, 5638.304999999999, 5740.65, 5824.485, 5818.35, 5818.32, 5824.215,
           5818.23, 5818.26, 5818.320000000001, 5866.32, 5860.17, 5866.125, 5866.005,
           5836.514999999999, 5859.27, 5859.09, 5859.0, 5853.03, 5859.15, 5853.195,
           5847.27, 5853.254999999999, 5853.375, 5871.599999999999, 5955.629999999999,
           5979.51, 6009.525, 6009.419999999999, 6009.299999999999, 6009.195, 6009.24,
           6009.3, 6009.375, 6021.525, 6051.4349999999995, 6051.375, 6051.254999999999,
           6051.15, 6056.985, 6056.73, 6056.549999999999, 6050.370000000001, 6050.175,
           6050.04, 6049.875, 6013.665, 5959.65, 5953.74, 5959.92, 5953.9349999999995,
           5960.084999999999, 5960.175, 5954.160000000001, 5960.085, 5960.085000000001,
           5953.949999999999, 5953.875, 5953.77, 5941.665, 5881.65, 5869.74, 5869.845,
           5869.9349999999995, 5875.769999999999, 5869.709999999999, 5875.665,
           5851.634999999999, 5821.679999999999, 5821.844999999999, 5821.9349999999995,
           5822.055, 5822.325, 5828.730000000001, 5835.165, 5907.525, 5967.464999999999,
           5985.135, 5972.775, 5972.52, 5966.13, 5953.725, 5857.44, 5803.5, 5779.59,
           5779.74, 5785.799999999999, 5785.814999999999, 5779.635, 5779.545, 5767.515,
           5701.500000000001, 5695.785, 5695.9349999999995, 5696.070000000001,
           5696.1900000000005, 5702.264999999999, 5702.295, 5702.355, 5696.475, 5702.46,
           5702.370000000001, 5696.219999999999, 5696.070000000001, 5695.949999999999,
           5695.799999999999, 5695.575, 5683.41, 5593.320000000001, 5575.44, 5575.635,
           5575.679999999999, 5569.530000000001, 5575.485, 5569.619999999999, 5581.755,
           5569.77, 5581.844999999999, 5575.785, 5575.71, 5569.665, 5575.605000000001,
           5569.485, 5551.365, 5521.41, 5521.5, 5521.41, 5509.35, 5473.35, 5449.365000000001,
           5443.469999999999, 5443.56, 5443.5, 5443.455, 5437.469999999999, 5425.349999999999,
           5371.245, 5353.425, 5359.56, 5365.695, 5359.785, 5365.875, 5365.95, 5365.98,
           5359.995, 5366.115, 5360.13, 5366.175, 5360.16, 5366.115000000001, 5359.965]

# energy price [€/MWh]
price = [ 1.2060e+01, -1.0000e-01, -6.6000e-01, -1.9900e+00, -2.4200e+00,
       -1.8600e+00, -3.3500e+00,  0.0000e+00, -2.2000e-01,  2.9000e-01,
        7.3000e-01,  1.1400e+00,  1.3400e+00,  1.5300e+00,  5.7800e+00,
        2.3850e+01,  3.6540e+01,  4.6030e+01,  5.2710e+01,  5.4950e+01,
        4.9050e+01,  4.4140e+01,  4.5960e+01,  3.5000e+01,  8.2060e+01,
        7.3030e+01,  5.2930e+01,  4.6120e+01,  5.0080e+01,  7.8100e+01,
        1.0508e+02,  1.4064e+02,  1.4708e+02,  1.4923e+02,  1.4561e+02,
        1.4335e+02,  1.4438e+02,  1.4376e+02,  1.4820e+02,  1.5534e+02,
        1.6300e+02,  1.7132e+02,  1.7474e+02,  1.6490e+02,  1.5300e+02,
        1.4167e+02,  1.3491e+02,  1.2422e+02,  1.3001e+02,  1.2000e+02,
        1.1876e+02,  1.1500e+02,  1.1363e+02,  1.1627e+02,  1.3892e+02,
        1.5992e+02,  1.6917e+02,  1.7118e+02,  1.6857e+02,  1.6949e+02,
        1.6450e+02,  1.6091e+02,  1.6306e+02,  1.7142e+02,  1.7499e+02,
        1.7127e+02,  1.6902e+02,  1.6099e+02,  1.4962e+02,  1.3469e+02,
        1.1944e+02,  1.0397e+02,  1.0510e+02,  9.0850e+01,  8.3100e+01,
        8.3060e+01,  9.3940e+01,  1.1508e+02,  1.5995e+02,  1.6001e+02,
        1.5504e+02,  1.5792e+02,  1.5792e+02,  1.7413e+02,  1.6745e+02,
        1.5745e+02,  1.5606e+02,  1.4974e+02,  1.5799e+02,  1.7856e+02,
        1.6736e+02,  1.7756e+02,  1.7589e+02,  1.4997e+02,  1.4797e+02,
        1.0382e+02,  1.0801e+02,  9.7780e+01,  9.5090e+01,  5.3850e+01,
        5.6350e+01,  7.5610e+01,  1.0498e+02,  1.1155e+02,  1.2394e+02,
        1.4213e+02,  1.4003e+02,  1.4580e+02,  1.4235e+02,  1.4281e+02,
        1.5103e+02,  1.5948e+02,  1.7273e+02,  1.9202e+02,  1.9467e+02,
        1.7798e+02,  1.6399e+02,  1.5200e+02,  1.4464e+02,  1.2700e+02,
        1.0721e+02,  1.0110e+02,  9.6730e+01,  8.6760e+01,  9.9090e+01,
        9.9970e+01,  1.0504e+02,  1.4203e+02,  1.4413e+02,  1.4190e+02,
        1.3894e+02,  1.2344e+02,  1.2222e+02,  1.1609e+02,  1.3942e+02,
        1.5591e+02,  1.6391e+02,  1.5593e+02,  1.6350e+02,  1.4674e+02,
        1.3939e+02,  1.3190e+02,  1.2994e+02,  1.2151e+02,  1.0808e+02,
        1.0505e+02,  1.0103e+02,  1.0502e+02,  1.0806e+02,  1.3931e+02,
        1.5592e+02,  1.5594e+02,  1.5251e+02,  1.6390e+02,  1.5991e+02,
        1.6221e+02,  1.6393e+02,  1.6394e+02,  1.6481e+02,  1.8211e+02,
        1.6867e+02,  1.7498e+02,  1.7840e+02,  1.6690e+02,  1.6002e+02,
        1.5596e+02,  1.5528e+02,  1.0808e+02]

qt = 1                          # factor for adjusting time index spacing relative to 1 hour;
                                # qt = 1 means the time index represents hours
maxStorage = 2000               # maximum capacity of the battery [kWh]
maxFlow = 2000                  # maximum charging/discharging power of the battery [kW]
maxDischargePerDay = 4000       # maximum amount of energy allowed to be discharged within a day [kW]
efficiency = 0.9                # round trip efficiency of the battery
minSoC = 0                      # minimum allowed State of Charge of the battery [kWh]
maxSoC = 2000                   # maximum allowed State of Charge of the battery [kWh]

df = pd.DataFrame(data={"time_index":time_index, "price": price})

results = optimize_year(df,PV_curve, last_model_time_index=df["time_index"].values[-1],maxFlow=maxFlow,
                               maxStorage=maxStorage,maxDischargePerDay=maxDischargePerDay,
                               efficiency=efficiency, minSoC=minSoC, maxSoC=maxSoC,qt=qt,
                               maxDischarge_System=13000,allowed_load=2000)

And a file "stackoverflow_bat" handling the battery optimization:

import numpy as np
import pandas as pd
from pyomo.environ import *

def model_to_df(model, first_time_index, last_time_index):
    time_index = range(model.T[first_time_index + 1], model.T[last_time_index + 1] + 1)
    Ein = [value(model.Ein[i]) for i in time_index]
    Eout = [value(model.Eout[i]) for i in time_index]
    price = [model.P.extract_values()[None][i] for i in time_index]
    SoC = [value(model.SoC[i]) for i in time_index]

    df = {"time_index":time_index,
          "Ein":Ein,
          "Eout":Eout,
          "price":price,
          "SoC":SoC}

    return pd.DataFrame(data=df)


def optimize_year(df,PV_curve, first_model_time_index=0, last_model_time_index=8759, maxFlow = 4000,
                  maxStorage=16000 ,maxDischargePerDay=1000, efficiency=0.9, minSoC = 0, maxSoC = 1, qt=1,
                  maxDischarge_System=None, allowed_load=0):

    # reduce data if necessary
    df = df.loc[first_model_time_index:last_model_time_index, :]

    model = ConcreteModel()

    # Model parameters
    model.T = Set(doc='time index', initialize=df.time_index.tolist(), ordered=True)
    model.Rmax = Param(initialize=maxFlow, doc='max charging or discharging power (kW)')
    model.Smax = Param(initialize=maxStorage, doc='Max capacity (kWh)')
    model.Dmax = Param(initialize=maxDischargePerDay, doc='Max amount of discharge in a day (24*qt time_index periode)')
    model.P = Param(initialize=df.price.tolist(), doc='prices')

    if not maxDischarge_System is None:
        model.DSys = Param(initialize=maxFlow, doc='Max allowed discharge by whole system: battery + PV (kW)')
    else:
        model.DSys = Param(initialize=maxDischarge_System, doc='Max allowed discharge by whole system: battery + PV (kW)')

    model.PV = Param(model.T,initialize=PV_curve[0:len(df)], doc='PV production')

    model.maxCharge = Param(model.T,initialize=np.array(PV_curve[0:len(df)])+allowed_load, doc='Max possible charge by whole system (kW)')

    # Charge, discharge, and SoC to be optimized
    model.Ein = Var(model.T, domain=NonNegativeReals)
    model.Eout = Var(model.T, domain=NonNegativeReals)
    model.SoC = Var(model.T, bounds=(0, model.Smax))

    # Constraints
    def SoC(model, t):
        # Battery State of Charge, including efficiency losses for charging and discharging
        # Set state of charge at first time index to half of max SoC
        if t == model.T.first():
            return model.SoC[t] == model.Smax/2
        else:
            return (model.SoC[t] == (model.SoC[t - 1]
                                   + (model.Ein[t - 1] * np.sqrt(efficiency))
                                   - (model.Eout[t - 1] / np.sqrt(efficiency))))

    model.charge_state = Constraint(model.T, rule=SoC)

    def model_end_state(model, t):
        # Set state of charge at last time index to half of max SoC
        if t == model.T.last():
            return model.SoC[t] == model.Smax / 2
        else:
            return Constraint.Skip

    model.charge_end_state = Constraint(model.T, rule=model_end_state)

    def minimum_SoC(model, t):
        # minimum allowed SoC
        return model.SoC[t] >= minSoC

    model.minimum_SoC = Constraint(model.T, rule=minimum_SoC)

    def maximum_SoC(model, t):
        # maximum allowed SoC
        return model.SoC[t] <= maxSoC

    model.maximum_SoC = Constraint(model.T, rule=maximum_SoC)

    def max_discharge_constraint(model, t):
        # maximum allowed discharge by the battery alone
        return model.Eout[t] <= model.Rmax

    model.max_discharge = Constraint(model.T,rule=max_discharge_constraint)

    def charge_constraint(model, t):
        #Maximum charge within a single time_index: Limiting factor are PV production and the amount the battery is
        #allowed to charge

        if model.Rmax.value <= model.maxCharge[t]:
            constraint = model.Rmax.value
        else:
            constraint = model.maxCharge[t]

        return model.Ein[t] <= constraint

    model.charge = Constraint(model.T, rule=charge_constraint)

    def discharge_constraint(model, t):
        # Limiting the amount of energy discharged by the battery to the amount of energy stored in the battery
        # (including losses)
        return model.Eout[t] <= model.SoC[t] / np.sqrt(efficiency)

    model.discharge_constraint = Constraint(model.T, rule=discharge_constraint)

    def neg_price_constraint(model, t):
        # Prohibit discharging of the battery when prices are negative
        if df.loc[t, 'price'] < 0:
            return model.Eout[t] <= 0
        else:
            return Constraint.Skip

    model.neg_price_constraint = Constraint(model.T, rule=neg_price_constraint)

    def discharge_limit(model, t):
        # Limit the amount of energy discharged within a specific timeframe (24*qt)
        max_t = model.T.last()

        if t < max_t - 24 * qt:
            return sum(model.Eout[i] for i in range(t, t + 24*qt)) <= model.Dmax
        else:
            return Constraint.Skip

    if model.Dmax > 0:
        model.discharge_limit = Constraint(model.T, rule=discharge_limit)

    def maxSingleDischarge(model, t):
        # Limit on discharge of the whole system within a timestep
        return (model.Eout[t] + model.PV[t]) <= model.DSys

    model.maxSingleDischarge = Constraint(model.T, rule=maxSingleDischarge)

    # Profit function to be optimized
    profit = sum((model.PV[t] - model.Ein[t] + model.Eout[t])/1000*df.loc[t, 'price'] for t in model.T)
    model.objective = Objective(expr=profit, sense=maximize)

    solver = SolverFactory('glpk')
    solver.solve(model)

    results = model_to_df(model, first_time_index=first_model_time_index,
                             last_time_index=last_model_time_index)

    return results

When the constraint maxSingleDischarge is removed

#model.maxSingleDischarge = Constraint(model.T, rule=maxSingleDischarge)

the optimization works perfectly fine. Otherwise I get aforementioned error.

It is not the most elegant code ever written and I'm sure there are lots of things to improve, but I hope it helps with identifying the problem in my code. Thank you!


Solution

  • Here's what's happening: When you add that constraint in, you model is infeasible. The solver can sometimes deduce that without going through any machinations of putting values in your variables, and in that case, when you go to inspect/use the results, it barfs because the variable (and likely many others) are never used.

    You are committing one of the cardinal sins of optimization: not checking the solver status before proceeding. There are a couple ways to do this. If it is a stand-alone solve, you can print the captured results. You could also (if interested) select tee=True in the solve to see the log on screen. Or, if you intend to grab the result and do something with it as you are, you can use pyomo's built in function to check the result as shown below. Note, if you substitute this code into yours at the solve statement, it will print out the constraint you are having trouble with and you'll see why it is infeasible rather quickly.

    solver = SolverFactory('glpk')
    solve_res = solver.solve(model)
    
    # print it:  good idea!
    print(solve_res)
    
    # check it:
    if check_optimal_termination(solve_res):  # <--- built in pyomo function
    
        results = model_to_df(model, first_time_index=first_model_time_index,
                             last_time_index=last_model_time_index)
        return results
    
    else:
        # non-optimal termination.... do something
        model.maxSingleDischarge.pprint()
    
        return None