Ex. D1: Simple Supply Chain (SSC)

Adapted from this Medium post and from J.C. Kantor’s notebook.

Background

A classic example of a supply chain optimization use case is the transportation problem. Usually, it deals with the distribution of goods from a set of factories (or producers, sources) to a set of customers. The objective is the minimization of the total transportation costs, while satisfying constraints on the available supplies from each source and demand requirements for each destination.

In the CDT context, this setting could correspond to data transfer between sources (instruments) and data centres, or between data centres and HPC centers, or between HPC centers and end-users.

Data

Suppose we have 2 suppliers (factories), \(\mathcal{S} = \{S_1,S_2\}\) and 6 customers, \(\mathcal{C} = \{ C_1,\ldots,C_6\}.\) To each supplier we associate a production capacity and to each client a demand.

The transport costs are pairwise, between suppliers and customers. There is no transport possible between \(S_1\) and \(C_1,\) and between \(S_2\) and \(C_2.\) The associated costs are attributed a large value (1000), to ensure that they are not chosen.

Demand = {'C1': 155, 
          'C2': 125, 
          'C3': 265, 
          'C4': 350, 
          'C5': 275, 
          'C6': 130
         } 
Supply = {'S1': 650, 
          'S2': 700
         }
T = {('C1', 'S1'): 1000, ('C1', 'S2'): 1.4, 
     ('C2', 'S1'): 2.0,  ('C2', 'S2'): 1000, 
     ('C3', 'S1'): 2.0,  ('C3', 'S2'): 1.4, 
     ('C4', 'S1'): 2.5,  ('C4', 'S2'): 1.8, 
     ('C5', 'S1'): 2.8,  ('C5', 'S2'): 2.0, 
     ('C6', 'S1'): 2.5,  ('C6', 'S2'): 1.8
    }

Model

The objective is to minimize the total shipping cost to all customers from all sources.

\[\text{minimize:}\quad \text{Cost} = \sum_{c \in \mathcal{C}}\sum_{s \in \mathcal{S}} T[c,s] x[c,s]\] or simply \[ \min_x \sum_{c \in \mathcal{C}}\sum_{s \in \mathcal{S}} T[c,s] x[c,s],\]

where for each link/arc we have a parameter \(T[c,s]\) denoting the cost of shipping a ton of goods over the link. What we need to determine is the amount of goods to be shipped over each link, which we is represented as a non-negative decision variable \(x[c,s].\)

We add two constraints.

Shipments from all sources can not exceed the manufacturing capacity of the source.

\[\sum_{c \in \mathcal{C}} x[c,s] \leq \text{Supply}[s] \qquad \forall s \in \mathcal{S}.\]

Shipments to each customer must satisfy their demand.

\[\sum_{s\in \mathcal{S}} x[c,s] = \text{Demand}[c] \qquad \forall c \in \mathcal{C}.\]

from pyomo.environ import *

# Step 0: Create an instance of the model and its dual
model = ConcreteModel()
model.dual = Suffix(direction=Suffix.IMPORT)

# Step 1: Define index sets
CUS = list(Demand.keys())
SRC = list(Supply.keys())

# Step 2: Define the decision variable
model.x = Var(CUS, SRC, domain = NonNegativeReals)

# Step 3: Define objective
@model.Objective(sense=minimize)
def cost(m):
    return sum([T[c,s]*model.x[c,s] for c in CUS for s in SRC])

# Step 4: Constraints
@model.Constraint(SRC)
def src(m, s):
    return sum([model.x[c,s] for c in CUS]) <= Supply[s]

@model.Constraint(CUS)
def dmd(m, c):
    return sum([model.x[c,s] for s in SRC]) == Demand[c]
    
# Step 5: Solve the problem
results = SolverFactory('cbc').solve(model)
results.write()
# ==========================================================
# = Solver Results                                         =
# ==========================================================
# ----------------------------------------------------------
#   Problem Information
# ----------------------------------------------------------
Problem: 
- Name: unknown
  Lower bound: 2558.0
  Upper bound: 2558.0
  Number of objectives: 1
  Number of constraints: 8
  Number of variables: 12
  Number of nonzeros: 6
  Sense: minimize
# ----------------------------------------------------------
#   Solver Information
# ----------------------------------------------------------
Solver: 
- Status: ok
  User time: -1.0
  System time: 0.0
  Wallclock time: 0.01
  Termination condition: optimal
  Termination message: Model was solved to optimality (subject to tolerances), and an optimal solution is available.
  Statistics: 
    Branch and bound: 
      Number of bounded subproblems: None
      Number of created subproblems: None
    Black box: 
      Number of iterations: 1
  Error rc: 0
  Time: 0.036546945571899414
# ----------------------------------------------------------
#   Solution Information
# ----------------------------------------------------------
Solution: 
- number of solutions: 0
  number of solutions displayed: 0
#model.dual = Suffix(direction=Suffix.IMPORT)
if 'ok' == str(results.Solver.status):
    print("Total Shipping Costs = ", model.cost())
    print("\nShipping Table:")
    for s in SRC:
        for c in CUS:
            if model.x[c,s]() > 0:
                print("Ship from ", s," to ", c, ":", model.x[c,s]())
else:
    print("No Valid Solution Found")
Total Shipping Costs =  2558.0

Shipping Table:
Ship from  S1  to  C2 : 125.0
Ship from  S1  to  C3 : 265.0
Ship from  S1  to  C4 : 210.0
Ship from  S2  to  C1 : 155.0
Ship from  S2  to  C4 : 140.0
Ship from  S2  to  C5 : 275.0
Ship from  S2  to  C6 : 130.0

Sensitivity Analysis

The solution of the dual problem provides the marginal values: the economic return deriving from a unit (ton) change (increase or decrease) in the availability of resources or in the demand.

We begin with an analysis by supplier.

if 'ok' == str(results.Solver.status):
    print("Source      Capacity   Shipped    Margin")
    for s in SRC:
        print(f"{s:10s}{Supply[s]:10.1f}{model.src[s]():10.1f}{model.dual[model.src[s]]:10.4f}")
else:
    print("No Valid Solution Found")
Source      Capacity   Shipped    Margin
S1             650.0     600.0    0.0000
S2             700.0     700.0   -0.7000

The ‘marginal’ values tell us how much the total costs will be increased for each one ton increase in the available supply from each source. The optimization calculation says that only 600 tons of the 650 available from \(S_1\) should used for a minimum cost solution, which rules out any further cost reductions by increasing the available supply. In fact, we could decrease the supply \(S_1\) without any harm. The marginal value of \(S_1\) is 0.

The source \(S_2\) is a different matter. First, all 700 available tons are being used. Second, from the marginal value we see that the total transportations costs would be reduced by \(0.7\) Euros for each additional ton of supply.

The management conclusion we can draw is that there is excess supply available at \(S_1\) which should, if feasible, be moved to \(S_2.\)

Now we perform a customer analysis.

if 'ok' == str(results.Solver.status):    
    print("Customer      Demand   Shipped    Margin")
    for c in CUS:
        print(f"{c:10s}{Demand[c]:10.1f}{model.dmd[c]():10.1f}{model.dual[model.dmd[c]]:10.4f}")
else:
#    print("No Valid Solution Found")
Customer      Demand   Shipped    Margin
C1             155.0     155.0    2.1000
C2             125.0     125.0    2.0000
C3             265.0     265.0    2.0000
C4             350.0     350.0    2.5000
C5             275.0     275.0    2.7000
C6             130.0     130.0    2.5000

Conclusions

Looking at the demand constraints, we see that all of the required demands have been met by the optimal solution.

The marginal values of these constraints indicate how much the total transportation costs will increase if there is an additional ton of demand at any of the locations.

With reference to the matrix \(T\) we can notice differences in two locations: \(C_1\) and \(C_5.\) In \(C_1\) an increase of one ton of demand causes an increase of cost, greater than the list price for shipping to \(C_1\) (2.1 vs 1.4). In \(C_5\) an increase of one ton of demand slightly decreases the cost,less than the list price for shipping to \(C_5\) (2.8 vs 2.7)

To see what’s going on, we re-solve the problem with a one ton increase in the demand at \(C_1.\)

from pyomo.environ import *

Demand1 = {'C1': 156, 
          'C2': 125, 
          'C3': 265, 
          'C4': 350, 
          'C5': 275, 
          'C6': 130
         } 
Supply = {'S1': 650, 
          'S2': 700
         }
T = {('C1', 'S1'): 1000, ('C1', 'S2'): 1.4, 
     ('C2', 'S1'): 2.0,  ('C2', 'S2'): 1000, 
     ('C3', 'S1'): 2.0,  ('C3', 'S2'): 1.4, 
     ('C4', 'S1'): 2.5,  ('C4', 'S2'): 1.8, 
     ('C5', 'S1'): 2.8,  ('C5', 'S2'): 2.0, 
     ('C6', 'S1'): 2.5,  ('C6', 'S2'): 1.8
    }
# Step 0: Create an instance of the model and its dual
model1 = ConcreteModel()
model1.dual = Suffix(direction=Suffix.IMPORT)

# Step 1: Define index sets
CUS = list(Demand1.keys())
SRC = list(Supply.keys())

# Step 2: Define the decision variable
model1.x = Var(CUS, SRC, domain = NonNegativeReals)

# Step 3: Define objective
@model1.Objective(sense=minimize)
def cost(m):
    return sum([T[c,s]*model1.x[c,s] for c in CUS for s in SRC])

# Step 4: Constraints
@model1.Constraint(SRC)
def src(m, s):
    return sum([model1.x[c,s] for c in CUS]) <= Supply[s]

@model1.Constraint(CUS)
def dmd(m, c):
    return sum([model1.x[c,s] for s in SRC]) == Demand1[c]
# Step 5': Solve the problem
results1 = SolverFactory('cbc').solve(model1)
results1.write()
# ==========================================================
# = Solver Results                                         =
# ==========================================================
# ----------------------------------------------------------
#   Problem Information
# ----------------------------------------------------------
Problem: 
- Name: unknown
  Lower bound: 2560.1
  Upper bound: 2560.1
  Number of objectives: 1
  Number of constraints: 8
  Number of variables: 12
  Number of nonzeros: 6
  Sense: minimize
# ----------------------------------------------------------
#   Solver Information
# ----------------------------------------------------------
Solver: 
- Status: ok
  User time: -1.0
  System time: 0.0
  Wallclock time: 0.01
  Termination condition: optimal
  Termination message: Model was solved to optimality (subject to tolerances), and an optimal solution is available.
  Statistics: 
    Branch and bound: 
      Number of bounded subproblems: None
      Number of created subproblems: None
    Black box: 
      Number of iterations: 1
  Error rc: 0
  Time: 0.04635906219482422
# ----------------------------------------------------------
#   Solution Information
# ----------------------------------------------------------
Solution: 
- number of solutions: 0
  number of solutions displayed: 0

Conclusion

We see the total cost has increased from 2558.0 to 2560.1 Euros, an increase of 2.1 Euros just as predicted by the marginal value assocated with the demand constraint for \(C_1.\)

if 'ok' == str(results1.Solver.status):
    print("Total Shipping Costs = ", model1.cost())
    print("\nShipping Table:")
    for s in SRC:
        for c in CUS:
            if model1.x[c,s]() > 0:
                print("Ship from ", s," to ", c, ":", model1.x[c,s]())
else:
    print("No Valid Solution Found")
Total Shipping Costs =  2560.1

Shipping Table:
Ship from  S1  to  C2 : 125.0
Ship from  S1  to  C3 : 265.0
Ship from  S1  to  C4 : 211.0
Ship from  S2  to  C1 : 156.0
Ship from  S2  to  C4 : 139.0
Ship from  S2  to  C5 : 275.0
Ship from  S2  to  C6 : 130.0
if 'ok' == str(results.Solver.status):    
    print("Customer      Demand   Shipped")
    for c in CUS:
        print(f"{c:10s}{Demand1[c]:10.1f}{model1.dmd[c]():10.1f}")
else:
    print("No Valid Solution Found")
Customer      Demand   Shipped
C1             156.0     156.0
C2             125.0     125.0
C3             265.0     265.0
C4             350.0     350.0
C5             275.0     275.0
C6             130.0     130.0

General Conclusion

The dual variables provide an important economic interpretation of the final solution when investigating changes in the parameters of an LP problem.