Resourcing: Product Mix of Aluminium Windows

Introduction

In this notebook we will use:

We wil learn how to model problems in a way that scales to hundreds or thousands of variables and how to get all valuable information from the results generated by PuLP.

Warning, you cannot test this notebook unless you install Gurobi in your environment.

Problem Definition

Weyland Corp is a British firm that manufactures solar panel cells. This firm supplies three different types of cell called: fast, normal and ultra. There are three operations involved in the manufacturing process. The following table describes the hours/month required to manufacture each model:

Table 1: hours/month requirements

Operation

Fast (hours/cell)

Normal (hours/cell)

Ultra (hours/cell)

1

1

3

2

2

2

0

3

3

1

4

0

And the following table describes the total hours available for each operation:

Table 2: Total hours/month available

Operation

Hours/Month

1

400

2

600

3

600

The profit per each model is specified in the following table: Table 3: Unit of profit per model

Model

Profit/Unit

Fast

30

Normal

20

Ultra

40

Problem Model

Decision variables

The decision variables are:

\(x_1:\)Units of Fast cells / month

\(x_2:\)Units of Normal cells / month

\(x_3:\)Units of Ultra cells / month

Objective Function

The objective function is:

\(\max z=30x_1+20x_2+40x_3\)

Constraints

Subject to the following constraints:

\(x_1+3x_2+2x_3 \leq 400\)

\(2x_1+0x_2+3x_3 \leq 600\)

\(x_1+4x_2+0x_3 \leq 600\)

Problem Solution with Pulp

Let us solve the problem with pulp. We start by importing PULP and instantiating the problem:

[10]:
import pandas as pd
import numpy as np
from IPython.display import display, Markdown
import pulp
[11]:
# Instantiate our problem class
model = pulp.LpProblem("Maximising profits for Weyland Corp", pulp.LpMaximize)
c:\python38\lib\site-packages\pulp\pulp.py:1199: UserWarning: Spaces are not permitted in the name. Converted to '_'
  warnings.warn("Spaces are not permitted in the name. Converted to '_'")

In this problem, we have three decision variables. We could name them individually like in the first example, but this does not scale (imagine we had hundreds). Instead, we will use lists and tuple lists which can be really useful to minimise the problem configuration.

First we create the lists:

[12]:
# Construct our decision variable lists
cell_types = ['fast', 'normal', 'ultra']

The decision variables have the same characteristics (lower bound of 0, continuous variables). We can use the LpVariable object’s dict functionality to create the variables from the tuples. The good thing about tuples is that we could add more dimensions (e.g. cell categories) and still create all decision variables in one line of code.

[13]:
cell_units = pulp.LpVariable.dicts("cells",
                                     (i for i in cell_types),
                                     lowBound=0,
                                     cat='Continuous')

PuLP provides an lpSum vector calculation for the sum of a list of linear expressions. Although we only have 6 decision variables, we will use lpSum to construct the expression using this feature, again for the sake of scalability.

[14]:
# Technological Coefficients:
unit_profits = [30, 20, 40]
# Objective Function

model += (
    pulp.lpSum([
        unit_profits[i] * cell_units[cell_types[i]]
        for i in range(len(cell_types))])
)

Now we add the constraints using the tuples to identify the coefficients

[15]:
# Operations
operations = ["Operation 1", "Operation 2", "Operation 3"]
# Constraints
A=[[1, 3, 2], #Coefficients of the first constraint
   [2, 0, 3], #Coefficients of the second constraint
   [1, 4, 0]] #Coefficients of the third constraint

# Capacities
b = [400, 600, 600]
 #Iterate matrix raws
for i in range(len(A)):
    model += pulp.lpSum([
        A[i][j] * cell_units[cell_types[j]]
        for j in range(len(cell_types))]) <= b[i] , operations[i]
#note that in this case all constraints are of type less or equal
[16]:
# Solve our problem
model.solve(solver=pulp.GUROBI(msg = 0))

pulp.LpStatus[model.status]
[16]:
'Optimal'
[17]:
total_profit = pulp.value(model.objective)
print("Total profit is %0.2f €"%total_profit)

print("The following table shows the decision variables: ")
var_df = pd.DataFrame.from_dict(cell_units, orient="index",
                                columns = ["Variables"])
var_df["Solution (GRB)"] = var_df["Variables"].apply(lambda item: "{:.2f}".format(item.solverVar.X))
var_df["Reduced cost (GRB)"] = var_df["Variables"].apply(lambda item: "{:.2f}".format(item.solverVar.RC))
var_df["Objective Coefficient (GRB)"] = var_df["Variables"].apply(lambda item: "{:.2f}".format(item.solverVar.Obj))
var_df["Objective Lower bound (GRB)"] = var_df["Variables"].apply(lambda item: "{:.2f}".format(item.solverVar.SAObjLow) if item.solverVar.SAObjLow > -0.1 else "-Inf" )
var_df["Objective Upper bound (GRB)"] = var_df["Variables"].apply(lambda item: "{:.2f}".format(item.solverVar.SAObjUp) if item.solverVar.SAObjUp != item.solverVar.UB else "Inf")


print(var_df.to_markdown())


const_dict = dict(model.constraints)
con_df = pd.DataFrame.from_records(list(const_dict.items()), exclude=["Expression"], columns=["Constraint", "Expression"])
con_df["Right Hand Side"]=con_df["Constraint"].apply(lambda item: "{:.2f}".format(const_dict[item].solverConstraint.RHS))
con_df["Shadow Price"]=con_df["Constraint"].apply(lambda item: "{:.2f}".format(const_dict[item].solverConstraint.Pi))
con_df["Slack"]=con_df["Constraint"].apply(lambda item: "{:.2f}".format(const_dict[item].solverConstraint.Slack))
con_df["Min RHS"]=con_df["Constraint"].apply(lambda item: "{:.2f}".format(const_dict[item].solverConstraint.SARHSLow) )
con_df["Max RHS"]=con_df["Constraint"].apply(lambda item: "{:.2f}".format(const_dict[item].solverConstraint.SARHSUp) if const_dict[item].solverConstraint.SARHSUp < 1e10 else "Inf" )


print("The following table shows the constraints: ")
print(con_df.to_markdown())
Total profit is 9666.67 €
The following table shows the decision variables:
|        | Variables    |   Solution (GRB) |   Reduced cost (GRB) |   Objective Coefficient (GRB) |   Objective Lower bound (GRB) |   Objective Upper bound (GRB) |
|:-------|:-------------|-----------------:|---------------------:|------------------------------:|------------------------------:|------------------------------:|
| fast   | cells_fast   |           300    |                 0    |                            30 |                         24.44 |                        inf    |
| normal | cells_normal |            33.33 |                 0    |                            20 |                         -0    |                         90    |
| ultra  | cells_ultra  |             0    |                -8.33 |                            40 |                       -inf    |                         48.33 |
The following table shows the constraints:
|    | Constraint   |   Right Hand Side |   Shadow Price |   Slack |   Min RHS |   Max RHS |
|---:|:-------------|------------------:|---------------:|--------:|----------:|----------:|
|  0 | Operation_1  |               400 |           6.67 |    0    |    300    |       525 |
|  1 | Operation_2  |               600 |          11.67 |    0    |      0    |       800 |
|  2 | Operation_3  |               600 |           0    |  166.67 |    433.33 |       inf |

Question 1

Peter Weyland believes that, for the reputation of the company, Weyland should manufacture at least 40 ultra cells per month. If this plan goes ahead, how would it affect the current profit?

[18]:
answer_1 = -40*cell_units["ultra"].solverVar.RC;
print("40 extra units of ultra would lower the profits: **%0.3f€**"%answer_1)
40 extra units of ultra would lower the profits: **333.333€**

Question 2

The engineer informed management that due to maintenance operations, the capacities of Operation 1, 2, and 3 have changed to 360, 600 and 500 respectively. How would this affect the profit?

[19]:
print("We need to check the shadow price of Operation 1 and 2. Operation 3 has a surplus of capacity and the minimum RHS is below the new capacity, so it will have no impact.")

b2 = [360, 600, 500]
maintenance = np.subtract(b,b2)
answer_2=np.dot(maintenance, pd.to_numeric(con_df["Shadow Price"]))


print("The profit would be reduced in **%0.2f** € per month"%answer_2)

We need to check the shadow price of Operation 1 and 2. Operation 3 has a surplus of capacity and the minimum RHS is below the new capacity, so it will have no impact.
The profit would be reduced in **266.80** € per month

Question 3

Mr. Weyland insisted that the company needs to change the strategy towards producing ultra-fast cells to position Weyland Corp in this segment. The company reviewed the prices of the ultra-fast cells and changed them to 30€, 10€ and 50€ per fast, normal and ultra cells. With the new prices, would it be profitable to produce ultra-fast cells?

[20]:
print("We need to verify the bounds for the objective coefficients for the three variables.")
print("First we check if the new coefficients of the objective function would change the basic solution.")
display(Markdown("the variables that change the basic solution are:"))
new_profits = [30, 10, 50]
entering_df = var_df[(new_profits < pd.to_numeric(var_df["Objective Lower bound (GRB)"])) | (new_profits > pd.to_numeric(var_df["Objective Upper bound (GRB)"]))]
print(entering_df.to_markdown())

answer_3 = "It is profitable to produce ultra fast cells with the new prices" if ("ultra" in entering_df.index) else "It is not profitable to produce ultra fast cells with the new prices"
print(answer_3)
We need to verify the bounds for the objective coefficients for the three variables.
First we check if the new coefficients of the objective function would change the basic solution.
|       | Variables   |   Solution (GRB) |   Reduced cost (GRB) |   Objective Coefficient (GRB) |   Objective Lower bound (GRB) |   Objective Upper bound (GRB) |
|:------|:------------|-----------------:|---------------------:|------------------------------:|------------------------------:|------------------------------:|
| ultra | cells_ultra |                0 |                -8.33 |                            40 |                          -inf |                         48.33 |
It is profitable to produce ultra fast cells with the new prices

the variables that change the basic solution are:

Question 4

What is the impact in the new profit if we opted for this new prices?

[21]:
print("We need to define a new model (model 2) for the reviewed price.")

model2 = pulp.LpProblem("Updated model for Weyland Corp", pulp.LpMaximize)
# Technological Coefficients:
unit_profits2 = [30, 10, 50]

# Objective Function
model2 += (
    pulp.lpSum([
        unit_profits2[i] * cell_units[cell_types[i]]
        for i in range(len(cell_types))])
)

#Iterate matrix raws
#Since there is no change in the capacities, we can use the same constraints
for i in range(len(A)):
    model2 += pulp.lpSum([
        A[i][j] * cell_units[cell_types[j]]
        for j in range(len(cell_types))]) <= b[i] , operations[i]

#note that in this case all constraints are of type less or equal
model2.solve()
pulp.LpStatus[model2.status]
total_profit2 = pulp.value(model2.objective)

print("The total profit is **%.2f** € per month"%total_profit2)

answer_4 = total_profit2 - total_profit
print("The profit would change in **%0.2f** € per month"%answer_4)

We need to define a new model (model 2) for the reviewed price.
The total profit is **10000.00** € per month
The profit would change in **333.33** € per month
c:\python38\lib\site-packages\pulp\pulp.py:1199: UserWarning: Spaces are not permitted in the name. Converted to '_'
  warnings.warn("Spaces are not permitted in the name. Converted to '_'")

Question 5

The engineer is studying a new type of fast cells that would require 2 hours of operation 1, 0 hours of operation 2 and 1 hour of operation 3. The estimated market price is 20€. How would this affect the production plan?

[25]:
print("We need to define a new model (model 3) for the new standard cell type.")

model3 = pulp.LpProblem("Updated model for Weyland Corp II", pulp.LpMaximize)
cell_types3 = ['fast', 'standard', 'normal', 'ultra']

# Technological Coefficients:
unit_profits3 = [30, 20, 20, 40]

# New variables
cell_units3 = pulp.LpVariable.dicts("cells",
                                     (i for i in cell_types3),
                                     lowBound=0,
                                     cat='Continuous')
# Objective Function
model3 += (
    pulp.lpSum([
        unit_profits3[i] * cell_units3[cell_types3[i]]
        for i in range(len(cell_types3))])
)
#Update the constraints
A=[[1, 2, 3, 2], #Coefficients of the first constraint
   [2, 0, 0, 3], #Coefficients of the second constraint
   [1, 2, 4, 0]] #Coefficients of the third constraint

# Capacities
b = [400, 600, 600]

# Iterate matrix raws
# note that in this case all constraints are of type less or equal
for i in range(len(A)):
    model3 += pulp.lpSum([
        A[i][j] * cell_units3[cell_types3[j]]
        for j in range(len(cell_types3))]) <= b[i] , operations[i]

model3.solve(solver=pulp.GUROBI(msg = 0))
pulp.LpStatus[model3.status]

total_profit3 = pulp.value(model3.objective)

print("The total profit is **%.2f** € per month"%total_profit3)

# Print our decision variable values
print("The following tables show the values obtained: ")

# Now instead of using Pandas Dataframe apply, we use a for loop to create a dictionary
variable_names3 = []
values3 = []
reduced_costs3 = []
lower_bounds3 = []
upper_bounds3 = []
for v in model3.variables():
    variable_names3.append(v.name)
    values3.append(v.varValue)
    reduced_costs3.append(v.dj)
    lower_bounds3.append(v.solverVar.SAObjLow)
    upper_bounds3.append(v.solverVar.SAObjUp)

variable_raw_data3 = {"Decision Variable": variable_names3,
                     "Solution value": values3,
                     "Unit profit": unit_profits3,
                    "Reduced cost": reduced_costs3,
                      "Lower bound": lower_bounds3,
                      "Upper bound": upper_bounds3}

var_df3 = pd.DataFrame(variable_raw_data3)
print(var_df3.to_markdown())

# Print out the values of the constraints
constraint_names3 = []
constants3 = []
senses3 = []
pis3 = []
slacks3 = []
shadow_prices3 = []
rhs_upper_bounds3 = []
rhs_lower_bounds3 = []

for name, c in list(model3.constraints.items()):
    constraint_names3.append(name)
    print(c.to_dict())
    constants3.append(-c.constant)
    senses3.append(c.sense)
    pis3.append(c.pi)
    slacks3.append(c.slack)
    shadow_prices3.append(c.modified)
    rhs_lower_bounds3.append(c.solverConstraint.SARHSLow)
    rhs_upper_bounds3.append(c.solverConstraint.SARHSUp)

print("The following table shows the problem constraints: ")

constraint_raw_data3 = {"Constraint":constraint_names3,
            "Left-hand side":np.subtract(constants3,slacks3),
            "Direction":senses3,
            "Right-hand side":constants3,
            "Slack": slacks3,
            "Shadow price": pis3,
            "Lower bound": rhs_lower_bounds3,
            "Upper bound": rhs_upper_bounds3
            }

const_df3 = pd.DataFrame(constraint_raw_data3)
print(const_df3.to_markdown())

answer_4 = total_profit2 - total_profit
print("The profit would change in **%0.2f** € per month"%answer_4)


We need to define a new model (model 3) for the new standard cell type.
The total profit is **10000.00** € per month
The following tables show the values obtained:
|    | Decision Variable   |   Solution value |   Unit profit |   Reduced cost |   Lower bound |   Upper bound |
|---:|:--------------------|-----------------:|--------------:|---------------:|--------------:|--------------:|
|  0 | cells_fast          |              300 |            30 |              0 |       23.3333 |           inf |
|  1 | cells_normal        |                0 |            20 |            -10 |     -inf      |            30 |
|  2 | cells_standard      |               50 |            20 |              0 |       13.3333 |            60 |
|  3 | cells_ultra         |                0 |            40 |            -10 |     -inf      |            50 |
[{'name': 'cells_fast', 'value': 1}, {'name': 'cells_standard', 'value': 2}, {'name': 'cells_normal', 'value': 3}, {'name': 'cells_ultra', 'value': 2}]
[{'name': 'cells_fast', 'value': 2}, {'name': 'cells_ultra', 'value': 3}]
[{'name': 'cells_fast', 'value': 1}, {'name': 'cells_standard', 'value': 2}, {'name': 'cells_normal', 'value': 4}]
The following table shows the problem constraints:
|    | Constraint   |   Left-hand side |   Direction |   Right-hand side |   Slack |   Shadow price |   Lower bound |   Upper bound |
|---:|:-------------|-----------------:|------------:|------------------:|--------:|---------------:|--------------:|--------------:|
|  0 | Operation_1  |              400 |          -1 |               400 |       0 |             10 |           300 |           600 |
|  1 | Operation_2  |              600 |          -1 |               600 |       0 |             10 |             0 |           800 |
|  2 | Operation_3  |              400 |          -1 |               600 |     200 |              0 |           400 |           inf |
The profit would change in **333.33** € per month
c:\python38\lib\site-packages\pulp\pulp.py:1199: UserWarning: Spaces are not permitted in the name. Converted to '_'
  warnings.warn("Spaces are not permitted in the name. Converted to '_'")

Question 6

Mr. Weyland thinks that the operation time for Operation 2 for the normal cells can be cut to only 3h. How would this improve the production plan?