Search code examples
pythonoptimizationbatch-processinglinear-programmingpyomo

Optimizing a batch process in pyomo Python - How to set a constant operational power for each batch?


I want to optimize an industrial batch process using pyomo in Python. The objective function is to minimize the cost of the system. In my simplified example, I specify production costs with random values. The uniqueness of the batch process is:

  1. That once the system is turned on, it must run for four or a multiple of four time steps (multiple batches in a row). --> That ist already implemented.
  2. Once a batch is started, the operational power for this batch should be constant. --> Not solved

I tried already this constraint:

# Ensure that when the plant starts, it runs for the current and next three periods whith equal power.
def block_production_rule_1(model, t):
    if t <= T-3:
        return model.production[t+1]  >= model.production[t] * model.start[t]
    return pyo.Constraint.Skip
model.block_production_rule_1 = pyo.Constraint(model.timesteps, rule=block_production_rule_1)

def block_production_rule_2(model,t):
    if t <= T-3:
        return model.production[t+2] >= model.production[t] * model.start[t]
    return pyo.Constraint.Skip
model.block_production_rule_2 = pyo.Constraint(model.timesteps, rule=block_production_rule_2)

def block_production_rule_3(model,t):
    if t <= T-3:
        return model.production[t+3] >= model.production[t] * model.start[t]
    return pyo.Constraint.Skip
model.block_production_rule_3 = pyo.Constraint(model.timesteps, rule=block_production_rule_3)

But with this, I only ensure, that the operational power can´t become smaller in the course of the process.

Does anyone has an idea how I can solve this problem?

Update

I changed the objective to:

def obj_rule(model):
    return sum((production_cost[t-1] + production_cost[t] + production_cost[t+1] + production_cost[t+2]) * model.run_power[t] for t in model.timesteps)
model.obj = pyo.Objective(rule=obj_rule, sense=pyo.minimize)

If I don´t miss anything it is working now. I also changed some other parts in the code (see below). Code:

import pyomo.environ as pyo
import numpy as np
import random 


# Define the model
model = pyo.ConcreteModel()

# 1. Define the model, sets, and parameters.
T = 56  # number of timesteps
production_goal = 32
maxPower = 2
minPower = 0
model.timesteps = pyo.RangeSet(1, T)

# Production cost function
# production_cost = [1, 2, 0, 3, 4, 1, 0, 2, 4, 1, 3, 2, 0, 4, 2, 1, 0, 4, 3, 2, 1, 0, 4, 3, 2, 1, 0, 4, 3, 2, 1,0,3,3,3]
production_cost = (np.sin(np.linspace(0, 8*np.pi, T)) + 1) * 50
production_cost = [_ for _ in range(T+3)]
production_cost = [random.randint(0,4) for _ in range(T+3)]
production_cost_sum = []
for i in range(len(production_cost)):
    total = 0
    for j in range(i, min(i + 4, len(production_cost))):  # Beachte das Randproblem am Ende
        total += production_cost[j]
    production_cost_sum.append(total)

# 2. Define the decision variables.
model.start = pyo.Var(model.timesteps, within=pyo.Binary)  # start of the plant
model.run_power = pyo.Var(model.timesteps, within=pyo.Integers)  # power of the plant

# 3. Define the objective function.
def obj_rule(model):
    return sum((production_cost[t-1] + production_cost[t] + production_cost[t+1] + production_cost[t+2]) * model.run_power[t] for t in model.timesteps)
model.obj = pyo.Objective(rule=obj_rule, sense=pyo.minimize)

# 4. Define the constraints.
# Total production constraint
def total_production_rule(model):
    return sum(model.run_power[t] for t in model.timesteps) == production_goal/4
model.total_production_con = pyo.Constraint(rule=total_production_rule)

# Production is only possible if plant is on
def production_on_rule_1(model, t):
    return model.run_power[t] <= model.start[t] * maxPower
model.production_on_con_1 = pyo.Constraint(model.timesteps, rule=production_on_rule_1)

def production_on_rule_2(model, t):
    return model.run_power[t] >= model.start[t] 
model.production_on_con_2 = pyo.Constraint(model.timesteps, rule=production_on_rule_2)


# Start if no start occurred in preceding 3 periods
def start_rule(model, t):
    return model.start[t] <= 1 - sum(model.start[tt] for tt in range(t-3, t) if tt > 0)
model.start_con = pyo.Constraint(model.timesteps, rule=start_rule)




# 5. Solve the model using Gurobi.
solver = pyo.SolverFactory('gurobi')
results = solver.solve(model)









import matplotlib.pyplot as plt
# 6. Plot the results.
x_values = [model.run_power[t]() for t in model.timesteps]
i = 0
while i < len(x_values):
    if x_values[i] > 0:
        value_to_propagate = x_values[i]
        for j in range(i, min(i + 4, len(x_values))):
            x_values[j] = value_to_propagate
        i += 4
    else:
        i += 1
y_values = [model.run_power[t]() for t in model.timesteps]
batch_over_values = [model.start[t]() for t in model.timesteps]

# Create a plot with 3 subplots
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, sharex=True, figsize=(15, 10))

# Plot the production decision
ax1.step(range(1, T+1), x_values, where='post', label="Production (x)", color='blue')
ax1.set_ylabel("Value")
ax1.legend()
ax1.grid(True)

# Plot the plant status
ax2.step(range(1, T+1), y_values, where='post', label="Plant running", color='red', linestyle='--')
ax2.step(range(1, T+1), batch_over_values, where='post', label="Plant started", color='blue', linestyle=':')
ax2.set_ylabel("Value")
ax2.legend()
ax2.grid(True)

# Setze die x-Achse für alle Achsen gleich
ax3.step(range(1, T+4), production_cost, where='post', label="Production Cost", color='green', linestyle=':')
ax3.set_ylabel("Value")
ax3.set_xlabel("Timesteps")
ax3.legend()
ax3.grid(True)
ax3.set_xlim(1, T+4)  # Setze die x-Achsen-Grenzen für alle Plots

# Füge Hilfslinien bei jedem Zeitschritt hinzu
ax3.set_xticks(range(1, T+5))  # Hier wird 5 anstelle von 4 verwendet, um auch den letzten Zeitschritt zu berücksichtigen

# Plot the summarizied production cost
ax3.step(range(1, T+4), production_cost_sum, where='post', label="Production Cost Sum", color='red', linestyle=':')
ax3.set_ylabel("Value")
ax3.set_xlabel("Timesteps")
ax3.legend()
ax3.grid(True)

for t in range(1, T+4):
    value = production_cost_sum[t-1]
    ax3.text(t, value, str(value), ha='center', va='bottom')

# Zeige die Grafik
plt.show()


Solution

  • I'm assuming that "power" is synonymous with the production level, so if a run starts at time 5 with power of 3, it will produce 3 widgets in periods 5, 6, 7, and 8 for a total of 12 towards your production goal...

    So here is an idea: reformulate your problem. It may have just gotten easier because the production is constant the run, right? So as above, if I tell you it starts in period 5 with power 3, you know the outcome ==> you probably don't need the running/production variables.

    So try reformulating your start variable as a binary to indicate the start occurred (to help with the constraint covering non-overlapping starts), and another run_power to be an integer, the power level for the start at any time period. See if you can think of the constraints and the cost function in terms of just those 2 variables, which is still indexed by time.

    Continuing the example above:

    • If I tell you a run starts at t=5 at power 3, can you figure out the cost of that specific run by using the run_power variable? (Yes.) Something like:

     cost[5] = run_power[5] * sum(price[5-8])
    
    • Can you aggregate that over all the periods that might have a run_power to get the total cost with a little creativity? (Yes.)

    • Can you re-use the constraint to prevent multiple overlapping runs and the binary start variable? (Yes)

    • Can you use a big-M constraint to link the start variable to the run_power variable at the same timestep?

    • Can you (similar to cost) roll up the total # of widgets produced to make a constraint to make that greater than or equal to demand? ...