flowchart LR F1(Factory 1<br/>80 units) F2(Factory 2<br/>40 units) DC(Distribution Center) W1(Warehouse 1<br/>-60 units) W2(Warehouse 2<br/>-90 units) F1 -->|$7/unit| W1 F1 -->|$3/unit<br/>Max 50 units| DC F2 -->|$4/unit<br/>Max 50 units| DC DC -->|$0/unit| W1 DC -->|$2/unit<br/>Max 60 units| W2
06 | Minimum Cost Network Flow Models
Optimizing Transportation in Supply Chain Networks
1 Overview
Network flow models are powerful tools for optimizing the movement of goods through a supply chain network. The minimum cost network flow model helps determine the most cost-effective way to transport products from origins (like factories) to destinations (like warehouses).
In this module, you will learn how to:
- Understand the key components of network flow problems
- Formulate these as linear programming models
- Implement and solve them using both Excel Solver and Python/Gurobi
- Analyze different scenarios to make better supply chain decisions
2 Key Components of Network Flow Problems
A minimum cost network flow problem has three main components:
- Nodes: Points in the network representing locations
- Supply nodes: Where products originate (factories, plants)
- Transshipment nodes: Intermediate points (distribution centers)
- Demand nodes: Where products are required (warehouses, customers)
- Arcs: Connections between nodes representing
possible flow paths
- Each arc has a cost per unit of flow
- Each arc may have a capacity limit
- Flows: The amount of resource moving along each arc
- Flow must be conserved at each node (total inflow = total outflow, except at supply/demand nodes)
- Total supply must equal total demand for the problem to have feasible solutions
3 Mathematical Formulation
Let’s define:
- \(x_{ij}\) = flow from node \(i\) to node \(j\)
- \(c_{ij}\) = cost per unit flow from node \(i\) to node \(j\)
- \(u_{ij}\) = capacity of arc from node \(i\) to node \(j\)
- \(b_i\) = net flow at node \(i\) (positive for supply, negative for demand, zero for transshipment)
The general formulation is:
Objective: Minimize \(\sum_{(i,j)} c_{ij} \cdot x_{ij}\)
Subject to:
- Flow Conservation: \(\sum_{j:(i,j)} x_{ij} - \sum_{j:(j,i)} x_{ji} = b_i\) for all nodes \(i\)
- Capacity Constraints: \(0 \leq x_{ij} \leq u_{ij}\) for all arcs \((i,j)\)
4 Example 01: Distribution Unlimited Co.
Let’s apply this to a concrete example. Distribution Unlimited Co. has two factories (F1, F2) that need to ship products to two warehouses (W1, W2). There’s also a distribution center (DC) that can be used as an intermediate point.
The network includes:
- Factory 1 (F1) produces 80 units
- Factory 2 (F2) produces 40 units
- Warehouse 1 (W1) requires 60 units
- Warehouse 2 (W2) requires 90 units
Notice that total demand (150 units) exceeds total supply (120 units). This means we’ll need to handle unfulfilled demand in our model.
4.1 Handling Supply-Demand Imbalance
When total supply doesn’t equal total demand, we can:
- Add a dummy supply node (if supply < demand)
- Add a dummy demand node (if supply > demand)
For Distribution Unlimited Co., we’ll add a dummy supply of 30 units with zero transportation cost to make the problem feasible:
flowchart LR F1(Factory 1<br/>80 units) F2(Factory 2<br/>40 units) DC(Distribution Center) W1(Warehouse 1<br/>-60 units) W2(Warehouse 2<br/>-90 units) DS(Dummy Supply<br/>30 units) F1 -->|$7/unit| W1 F1 -->|$3/unit<br/>Max 50 units| DC F2 -->|$4/unit<br/>Max 50 units| DC DC -->|$0/unit| W1 DC -->|$2/unit<br/>Max 60 units| W2 DS -->|$0/unit| W2
4.2 Solving with Python
The Python implementation uses Gurobi Optimizer through its Python
interface, gurobipy
. Unlike Excel, which uses a tabular
format, Python builds the model programmatically. Let’s break down how
this works step by step.
Here’s the basic structure:
- Define nodes and their supply/demand values
- Define arcs with costs and capacities
- Create decision variables for flows
- Add flow conservation constraints
- Add capacity constraints
- Set the objective and solve the model
First, we need to import the necessary libraries:
import gurobipy as gp
from gurobipy import GRB
Gurobipy is Gurobi’s Python API, and GRB provides constants like GRB.MINIMIZE for setting the optimization direction.
4.2.1 Defining the Problem Structure
Next, we define our network structure using Python data structures:
# Define nodes
= ['F1', 'F2', 'DC', 'W1', 'W2', 'Dummy']
nodes
# Define supply/demand at each node
= {
supply_demand 'F1': 80, # Supply of 80 units
'F2': 40, # Supply of 40 units
'DC': 0, # Transshipment node (no net supply/demand)
'W1': -60, # Demand of 60 units
'W2': -90, # Demand of 90 units
'Dummy': 30 # Dummy supply of 30 units
}
# Define arcs with costs and capacities
= {
arcs 'F1', 'W1'): (7, float('inf')), # Direct route from F1 to W1
('F1', 'DC'): (3, 50), # Route from F1 to DC with capacity 50
('F2', 'DC'): (4, 50), # Route from F2 to DC with capacity 50
('DC', 'W1'): (0, float('inf')), # Route from DC to W1
('DC', 'W2'): (2, 60), # Route from DC to W2 with capacity 60
('Dummy', 'W2'): (0, float('inf')) # Dummy route to W2
( }
This approach is very flexible. We can easily add or remove nodes and arcs by modifying these data structures.
4.2.2 Creating the Optimization Model
Now we create a Gurobi model and define the flow variables:
# Create a new model
= gp.Model("MinCostNetworkFlow")
model
# Create flow variables for each arc
= {}
flow for (i, j), (cost, _) in arcs.items():
= model.addVar(name=f'flow_{i}_{j}', obj=cost) flow[(i, j)]
Each variable flow[(i, j)]
represents the amount flowing
from node i
to node j
. The
obj=cost
parameter sets the coefficient in the objective
function.
4.2.2.1 flow = {}
This initializes an empty Python dictionary called flow
.
This dictionary will store our decision variables, which represent the
amount of product flowing along each arc in the network. The
dictionary’s keys will be tuples representing arcs (from node \(i\) to node \(j\)), and the values will be Gurobi
variable objects.
4.2.2.2
arcs.items()
The arcs
variable is a dictionary where:
- Each key is a tuple
(i, j)
representing an arc from node \(i\) to node \(j\). - Each value is another tuple
(cost, capacity)
containing the cost per unit of flow and the maximum capacity for that arc.
The items()
method returns an iterator over the
key-value pairs in the dictionary, so arcs.items()
produces
pairs like: ((i, j), (cost, capacity))
.
for value in arcs.items():
print(value)
(('F1', 'W1'), (7, inf))
(('F1', 'DC'), (3, 50))
(('F2', 'DC'), (4, 50))
(('DC', 'W1'), (0, inf))
(('DC', 'W2'), (2, 60))
(('Dummy', 'W2'), (0, inf))
4.2.2.3
for (i, j), (cost, _) in arcs.items():
This line uses Python’s tuple unpacking to extract values. For each iteration:
(i, j)
captures the origin and destination nodes from the arc tuple.(cost, _)
captures the cost while ignoring the capacity (the underscore_
is a Python convention for a variable you don’t intend to use).
For example, if one entry in arcs
is
('F1', 'DC'): (3, 50)
, then in that iteration:
i
would be'F1'
(the origin node)j
would be'DC'
(the destination node)cost
would be3
(the cost per unit of flow)_
would be50
(the capacity, which we’re ignoring for now)
4.2.2.4
flow[(i, j)] = model.addVar(name=f'flow_{i}_{j}', obj=cost)
For each arc, this line creates a Gurobi decision variable and adds it to our flow dictionary:
model.addVar()
creates a new decision variable in the Gurobi model.name=f'flow_{i}_{j}'
gives the variable a descriptive name like"flow_F1_DC"
.obj=cost
sets the variable’s coefficient in the objective function to the cost of this arc.flow[(i, j)] = ...
stores the created variable in our dictionary with the arc tuple as the key.
By default, Gurobi variables are non-negative (lb = 0
)
and continuous, which is what we want for flow variables in this network
problem.
4.2.2.5 What’s Happening Overall
This loop iterates through each possible shipping route (arc) in our network. For each arc, it:
- Extracts the origin node, destination node, and shipping cost
- Creates a variable representing “how much to ship from origin to destination”
- Associates that variable with the arc’s cost in the objective function
- Stores the variable in a dictionary for later use when creating constraints
After this loop completes, the flow
dictionary contains
all the decision variables our model needs, with each variable properly
connected to its cost in the objective function. This is the foundation
of our optimization model, we’re creating variables for each possible
shipment route and telling Gurobi that we want to minimize the total
shipping cost.
The next parts of the code will add constraints to ensure flow conservation (what comes in equals what goes out at each node) and to enforce capacity limits on certain routes.
4.2.3 Adding Flow Conservation Constraints
The most important constraints ensure flow conservation at each node:
# Add flow conservation constraints for each node
for i in nodes:
# Sum of all flows leaving node i
= gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
outflow
# Sum of all flows entering node i
= gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
inflow
# Outflow - inflow = supply/demand
- inflow == supply_demand[i], name=f'node_{i}') model.addConstr(outflow
This loop creates one constraint for each node. The constraint ensures that:
- For supply nodes: \(outflow - inflow = supply amount\)
- For demand nodes: \(outflow - inflow = -demand amount\)
- For transshipment nodes: \(outflow - inflow = 0\)
4.2.3.1
for i in nodes:
This loop iterates through each node in our network. For every node (whether it’s a factory, warehouse, or distribution center), we need to create a flow conservation constraint.
Flow conservation is a fundamental principle in network flow models. It states that for any node in the network:
- The amount of flow entering the node, minus
- The amount of flow leaving the node,
- Must equal the node’s supply or demand value
This mirrors real-world physical constraints: products don’t disappear or materialize within the network (except at supply or demand nodes).
4.2.3.2
outflow = gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
This line calculates the total flow leaving node i
:
arcs.keys()
gives us all the arc tuples(i2, j)
in our network.if i2 == i
filters for only those arcs where the origin node matches our current nodei
.flow[(i, j)]
retrieves the decision variable representing flow on the arc from nodei
to some other nodej
.gp.quicksum(...)
sums up all these flow variables.
Essentially, we’re adding up all the flow variables for arcs that
originate from node i
.
The reason for using i2
here is that we’re unpacking
tuples from arcs.keys()
. Each tuple is an arc represented
as (origin, destination)
. We use i2
as a
temporary variable name to compare with our current node
i
.
4.2.3.3
inflow = gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
Similarly, this line calculates the total flow entering node
i
:
arcs.keys()
gives us all the arc tuples(j, i2)
in our network.if i2 == i
filters for only those arcs where the destination node matches our current nodei
.flow[(j, i)]
retrieves the decision variable representing flow on the arc from some other nodej
to nodei
.gp.quicksum(...)
sums up all these flow variables.
This time, we’re adding up all the flow variables for arcs that
terminate at node i
.
4.2.3.4
model.addConstr(outflow - inflow == supply_demand[i], name=f'node_{i}')
Finally, we add the constraint to our model:
outflow - inflow
calculates the net flow (outgoing minus incoming) at nodei
.supply_demand[i]
retrieves the supply/demand value for nodei
:- Positive supply for supply nodes (more going out than coming in)
- Negative for demand nodes (more coming in than going out)
- Zero for transshipment nodes (what comes in must equal what goes out)
model.addConstr(...)
adds this constraint to the Gurobi model.name=f'node_{i}'
gives the constraint a unique name for identification.
The constraint enforces different behaviors depending on the node type:
- Supply Nodes: If
supply_demand[i] = 50
, thenoutflow - inflow = 50
, meaning the node sends out 50 more units than it receives. - Demand Nodes: If
supply_demand[i] = -30
, thenoutflow - inflow = -30
, meaning the node receives 30 more units than it sends out. - Transshipment Nodes: If
supply_demand[i] = 0
, thenoutflow - inflow = 0
, meaning everything that enters the node must also leave it.
4.2.4 Adding Capacity Constraints
Some arcs have capacity limits:
# Add capacity constraints for each arc
for (i, j), (_, capacity) in arcs.items():
if capacity < float('inf'):
<= capacity, name=f'capacity_{i}_{j}') model.addConstr(flow[(i, j)]
This only adds constraints for arcs with finite capacity. We use
float('inf')
to represent unlimited capacity.
4.2.4.1
for (i, j), (_, capacity) in arcs.items():
This loop iterates through each arc in our network and its corresponding capacity. Let’s break down the unpacking:
arcs.items()
returns pairs of((i, j), (cost, capacity))
where:(i, j)
is the arc from nodei
to nodej
(cost, capacity)
contains the shipping cost and maximum capacity
(i, j), (_, capacity)
unpacks these values:i
is the origin nodej
is the destination node_
is the cost (we use underscore because we don’t need the cost for capacity constraints)capacity
is the maximum amount that can flow on this arc
For example, if arcs contains (‘F1’, ‘DC’): (3, 50), then in that iteration:
i
would be'F1'
(origin)j
would be'DC'
(destination)_
would be3
(cost, ignored here)capacity
would be50
(maximum flow allowed)
4.2.4.2
if capacity < float('inf'):
Not all arcs need capacity constraints. Many routes have no practical limit, or their limit is so large that it won’t affect the optimal solution. In our code:
float('inf')
represents infinity in Pythoncapacity < float('inf')
checks if the capacity is a finite number- If true, we need to add a capacity constraint
- If false (capacity is infinite), no constraint is needed
This condition ensures we only add constraints where necessary, keeping our model streamlined.
4.2.4.3
model.addConstr(flow[(i, j)] <= capacity, name=f'capacity_{i}_{j}')
This line creates and adds the actual capacity constraint to our model:
flow[(i, j)]
accesses the decision variable representing flow on this arcflow[(i, j)] <= capacity
creates a constraint saying the flow cannot exceed the capacitymodel.addConstr(...)
adds this constraint to the Gurobi modelname=f'capacity_{i}_{j}'
gives the constraint a descriptive name like “capacity_F1_DC”
4.2.5 Setting the Objective and Solving
Finally, we set the objective function and solve the model:
# Set objective to minimize total cost
= GRB.MINIMIZE
model.ModelSense
# Optimize the model
model.optimize()
4.2.6 Extracting and Analyzing the Solution
After solving, we can extract and analyze the results:
# Check if an optimal solution was found
if model.Status == GRB.OPTIMAL:
# Extract the solution
= {}
flow_values = 0
total_cost
for (i, j), var in flow.items():
# Get the flow amount for this arc
= var.X
flow_amount
# Only include arcs with positive flow
if flow_amount > 0.001: # Small threshold for floating-point errors
= flow_amount
flow_values[(i, j)]
# Add to the total cost
= arcs[(i, j)][0]
cost += flow_amount * cost
total_cost
print("Optimal flows:")
for (i, j), flow in sorted(flow_values.items()):
= arcs[(i, j)][0]
cost print(f"{i} → {j}: {flow:.1f} units (cost: ${cost}/unit)")
print(f"<br/>Total transportation cost: ${total_cost:.1f}")
else:
print(f"No optimal solution found. Status code: {model.Status}")
This code extracts the flow values from the solution, calculates the total cost, and prints a summary of the optimal flows.
4.2.7 All Together
import gurobipy as gp
from gurobipy import GRB
# Define nodes
= ['F1', 'F2', 'DC', 'W1', 'W2', 'Dummy']
nodes
# Define supply/demand at each node
= {
supply_demand 'F1': 80, # Supply of 80 units
'F2': 40, # Supply of 40 units
'DC': 0, # Transshipment node (no net supply/demand)
'W1': -60, # Demand of 60 units
'W2': -90, # Demand of 90 units
'Dummy': 30 # Dummy supply of 30 units
}
# Define arcs with costs and capacities
= {
arcs 'F1', 'W1'): (7, float('inf')), # Direct route from F1 to W1
('F1', 'DC'): (3, 50), # Route from F1 to DC with capacity 50
('F2', 'DC'): (4, 50), # Route from F2 to DC with capacity 50
('DC', 'W1'): (0, float('inf')), # Route from DC to W1
('DC', 'W2'): (2, 60), # Route from DC to W2 with capacity 60
('Dummy', 'W2'): (0, float('inf')) # Dummy route to W2
(
}
# Create a new model
= gp.Model("MinCostNetworkFlow")
model = 0 # should turn off unwanted gurobipy output
model.Params.LogToConsole
# Create flow variables for each arc
= {}
flow for (i, j), (cost, _) in arcs.items():
= model.addVar(name=f'flow_{i}_{j}', obj=cost)
flow[(i, j)]
# Add flow conservation constraints for each node
for i in nodes:
# Sum of all flows leaving node i
= gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
outflow
# Sum of all flows entering node i
= gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
inflow
# Outflow - inflow = supply/demand
- inflow == supply_demand[i], name=f'node_{i}')
model.addConstr(outflow
# Add capacity constraints for each arc
for (i, j), (_, capacity) in arcs.items():
if capacity < float('inf'):
<= capacity, name=f'capacity_{i}_{j}')
model.addConstr(flow[(i, j)]
# Set objective to minimize total cost
= GRB.MINIMIZE
model.ModelSense
# Optimize the model
model.optimize()
# Check if an optimal solution was found
if model.Status == GRB.OPTIMAL:
# Extract the solution
= {}
flow_values = 0
total_cost
for (i, j), var in flow.items():
# Get the flow amount for this arc
= var.X
flow_amount
# Only include arcs with positive flow
if flow_amount > 0.001: # Small threshold for floating-point errors
= flow_amount
flow_values[(i, j)]
# Add to the total cost
= arcs[(i, j)][0]
cost += flow_amount * cost
total_cost
print("Optimal flows:")
for (i, j), flow in sorted(flow_values.items()):
= arcs[(i, j)][0]
cost print(f"{i} → {j}: {flow:.1f} units (cost: ${cost}/unit)")
print(f"<br/>Total transportation cost: ${total_cost:.1f}")
else:
print(f"No optimal solution found. Status code: {model.Status}")
Set parameter Username
Set parameter LicenseID to value 2609229
Set parameter LogToConsole to value 0
Optimal flows:
DC → W1: 30.0 units (cost: $0/unit)
DC → W2: 60.0 units (cost: $2/unit)
Dummy → W2: 30.0 units (cost: $0/unit)
F1 → DC: 50.0 units (cost: $3/unit)
F1 → W1: 30.0 units (cost: $7/unit)
F2 → DC: 40.0 units (cost: $4/unit)
<br/>Total transportation cost: $640.0
flowchart LR F1(Factory 1<br/>80 units) F2(Factory 2<br/>40 units) DC(Distribution Center) W1(Warehouse 1<br/>-60 units) W2(Warehouse 2<br/>-90 units) DS(Dummy Supply<br/>30 units) F1 -->|30 units<br/>$7/unit| W1 F1 -->|50 units<br/>$3/unit| DC F2 -->|40 units<br/>$4/unit| DC DC -->|30 units<br/>$0/unit| W1 DC -->|60 units<br/>$2/unit| W2 DS -->|30 units<br/>$0/unit| W2 style F1 fill:#d4f1c5 style F2 fill:#d4f1c5 style DS fill:#d4f1c5 style W1 fill:#c5daf1 style W2 fill:#c5daf1 style DC fill:#f1e9c5
4.2.8 Advantages of the Python Approach
The Python/Gurobi approach offers several advantages:
- Scales to very large networks (hundreds of nodes and arcs)
- Easier to modify and analyze multiple scenarios
- Can be integrated with other Python tools for data analysis and visualization
- Faster for complex problems
- More sophisticated error handling and reporting
Here is a version of this problem with five factories, 3 distribution centers, and eight warehouse locations:
flowchart LR %% Supply Nodes (Factories) F1[Factory 1<br/>120 units] F2[Factory 2<br/>150 units] F3[Factory 3<br/>200 units] F4[Factory 4<br/>180 units] F5[Factory 5<br/>250 units] %% Transshipment Nodes (Distribution Centers) DC1[Distribution Center 1] DC2[Distribution Center 2] DC3[Distribution Center 3] %% Demand Nodes (Warehouses) W1[Warehouse 1<br/>-80 units] W2[Warehouse 2<br/>-110 units] W3[Warehouse 3<br/>-90 units] W4[Warehouse 4<br/>-130 units] W5[Warehouse 5<br/>-150 units] W6[Warehouse 6<br/>-100 units] W7[Warehouse 7<br/>-120 units] W8[Warehouse 8<br/>-120 units] %% Factory to DC connections F1 -->|$3/unit<br/>Max 100| DC1 F1 -->|$4/unit<br/>Max 80| DC2 F2 -->|$4/unit<br/>Max 90| DC1 F2 -->|$3/unit<br/>Max 100| DC2 F3 -->|$3/unit<br/>Max 120| DC2 F3 -->|$2/unit<br/>Max 150| DC3 F4 -->|$4/unit<br/>Max 100| DC2 F4 -->|$3/unit<br/>Max 110| DC3 F5 -->|$5/unit<br/>Max 80| DC1 F5 -->|$2/unit<br/>Max 200| DC3 %% Sample direct Factory to Warehouse connections F1 -.->|$8/unit| W1 F2 -.->|$8/unit| W2 F3 -.->|$9/unit| W4 F4 -.->|$10/unit| W5 F5 -.->|$8/unit| W8 %% DC to Warehouse connections (showing representative examples) DC1 -->|$3/unit<br/>Max 100| W1 DC1 -->|$4/unit<br/>Max 90| W2 DC1 -->|$2/unit<br/>Max 120| W3 DC2 -->|$3/unit<br/>Max 100| W3 DC2 -->|$2/unit<br/>Max 150| W4 DC2 -->|$4/unit<br/>Max 120| W5 DC3 -->|$4/unit<br/>Max 90| W6 DC3 -->|$3/unit<br/>Max 140| W7 DC3 -->|$2/unit<br/>Max 120| W8 %% Inter-DC connections DC1 -->|$1/unit<br/>Max 70| DC2 DC2 -->|$1/unit<br/>Max 90| DC3 DC3 -->|$2/unit<br/>Max 60| DC1 %% Node styling classDef supply fill:#d4f1c5,stroke:#333,stroke-width:1px classDef transship fill:#f1e9c5,stroke:#333,stroke-width:1px classDef demand fill:#c5daf1,stroke:#333,stroke-width:1px %% Apply styles class F1,F2,F3,F4,F5 supply class DC1,DC2,DC3 transship class W1,W2,W3,W4,W5,W6,W7,W8 demand
Code
"""
Minimum Cost Network Flow Solver
This script reads supply chain network data from CSV files and
solves the minimum cost network flow problem using Gurobi optimizer.
"""
import gurobipy as gp
from gurobipy import GRB
import pandas as pd
# Read the node data
print("Reading node data from nodes.csv...")
= pd.read_csv(r'data\min_cost_ex_nodes.csv')
nodes_df
# Read the arc data
print("Reading arc data from arcs.csv...")
= pd.read_csv(r'data\min_cost_ex_arcs.csv')
arcs_df
# Extract node information
= nodes_df['node_id'].tolist()
nodes = dict(zip(nodes_df['node_id'], nodes_df['supply_demand']))
supply_demand = dict(zip(nodes_df['node_id'], nodes_df['type']))
node_types
# Group nodes by type for reporting
= [node for node, type_val in node_types.items() if type_val == 'Factory']
factories = [node for node, type_val in node_types.items() if type_val == 'DC']
distribution_centers = [node for node, type_val in node_types.items() if type_val == 'Warehouse']
warehouses
# Check supply/demand balance
= sum(v for v in supply_demand.values() if v > 0)
total_supply = -sum(v for v in supply_demand.values() if v < 0)
total_demand print(f"Total supply: {total_supply}")
print(f"Total demand: {total_demand}")
if total_supply != total_demand:
print("Warning: Supply and demand are not balanced!")
# Create arcs dictionary from DataFrame
# Convert 'inf' strings to actual infinity
'capacity'] = arcs_df['capacity'].replace('inf', float('inf'))
arcs_df['capacity'] = pd.to_numeric(arcs_df['capacity'])
arcs_df[
# Create arcs dictionary
= {}
arcs for _, row in arcs_df.iterrows():
'from_node'], row['to_node'])] = (row['cost'], row['capacity'])
arcs[(row[
print(f"Network has {len(nodes)} nodes and {len(arcs)} arcs")
# Create a new Gurobi model
= gp.Model("SupplyChainNetwork")
model = 0
model.Params.LogToConsole
# Create flow variables for each arc
= {}
flow for (i, j), (cost, _) in arcs.items():
= model.addVar(name=f'flow_{i}_{j}', obj=cost)
flow[(i, j)]
# Add flow conservation constraints for each node
for i in nodes:
# Sum of flows leaving node i
= gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
outflow
# Sum of flows entering node i
= gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
inflow
# Outflow - inflow = supply/demand
- inflow == supply_demand[i], name=f'node_{i}')
model.addConstr(outflow
# Add capacity constraints for each arc
for (i, j), (_, capacity) in arcs.items():
if capacity < float('inf'):
<= capacity, name=f'capacity_{i}_{j}')
model.addConstr(flow[(i, j)]
# Set objective to minimize total cost
= GRB.MINIMIZE
model.ModelSense
# Solve the model
print("\nSolving the network flow problem...")
model.optimize()
# Check if an optimal solution was found
if model.Status == GRB.OPTIMAL:
# Calculate flow statistics
= 0
active_arcs = 0
capacity_limited_arcs = 0
total_cost
# Dictionaries to track flows by node type
= {f: 0 for f in factories}
factory_flows = {dc: 0 for dc in distribution_centers}
dc_throughput = {w: 0 for w in warehouses}
warehouse_inflows
# Extract solution
= {}
flow_values for (i, j), var in flow.items():
= var.X
flow_amount if flow_amount > 0.001: # Only count non-zero flows
= flow_amount
flow_values[(i, j)] += 1
active_arcs = arcs[(i, j)][0]
cost = arcs[(i, j)][1]
capacity += flow_amount * cost
total_cost
# Check if arc is at capacity
if capacity < float('inf') and abs(flow_amount - capacity) < 0.001:
+= 1
capacity_limited_arcs
# Update node statistics
if i in factories:
+= flow_amount
factory_flows[i] if i in distribution_centers:
+= flow_amount
dc_throughput[i] if j in warehouses:
+= flow_amount
warehouse_inflows[j]
# Print results
print("\n========== OPTIMAL SOLUTION FOUND ==========")
print(f"Total transportation cost: ${total_cost:.2f}")
print(f"Active arcs: {active_arcs} out of {len(arcs)} possible")
print(f"Arcs at capacity: {capacity_limited_arcs}")
# Factory utilization
print("\nFACTORY UTILIZATION:")
for f in factories:
= (factory_flows[f] / supply_demand[f]) * 100
utilization_pct print(f" {f}: {factory_flows[f]} units shipped ({utilization_pct:.1f}% of capacity)")
# Distribution center throughput
print("\nDISTRIBUTION CENTER THROUGHPUT:")
for dc in distribution_centers:
print(f" {dc}: {dc_throughput[dc]} units processed")
# Warehouse demand fulfillment
print("\nWAREHOUSE DEMAND FULFILLMENT:")
for w in warehouses:
= (warehouse_inflows[w] / (-supply_demand[w])) * 100
received_pct print(f" {w}: {warehouse_inflows[w]} units received ({received_pct:.1f}% of demand)")
# Detailed flow report
print("\nDETAILED FLOW REPORT (non-zero flows only):")
# Factory to warehouse direct
= [(i, j, flow_values[(i, j)]) for (i, j) in flow_values.keys()
f_to_w if i in factories and j in warehouses]
if f_to_w:
print("\n Factory → Warehouse (Direct):")
for i, j, amt in sorted(f_to_w):
= arcs[(i, j)][0]
cost print(f" {i} → {j}: {amt:.1f} units (cost: ${cost}/unit, total: ${amt*cost:.1f})")
# Factory to DC
= [(i, j, flow_values[(i, j)]) for (i, j) in flow_values.keys()
f_to_dc if i in factories and j in distribution_centers]
if f_to_dc:
print("\n Factory → Distribution Center:")
for i, j, amt in sorted(f_to_dc):
= arcs[(i, j)][0]
cost = arcs[(i, j)][1]
capacity = " (at capacity)" if abs(amt - capacity) < 0.001 else ""
at_capacity print(f" {i} → {j}: {amt:.1f} units (cost: ${cost}/unit, total: ${amt*cost:.1f}){at_capacity}")
# DC to warehouse
= [(i, j, flow_values[(i, j)]) for (i, j) in flow_values.keys()
dc_to_w if i in distribution_centers and j in warehouses]
if dc_to_w:
print("\n Distribution Center → Warehouse:")
for i, j, amt in sorted(dc_to_w):
= arcs[(i, j)][0]
cost = arcs[(i, j)][1]
capacity = " (at capacity)" if abs(amt - capacity) < 0.001 else ""
at_capacity print(f" {i} → {j}: {amt:.1f} units (cost: ${cost}/unit, total: ${amt*cost:.1f}){at_capacity}")
# Inter-DC flows
= [(i, j, flow_values[(i, j)]) for (i, j) in flow_values.keys()
dc_to_dc if i in distribution_centers and j in distribution_centers]
if dc_to_dc:
print("\n Distribution Center → Distribution Center:")
for i, j, amt in sorted(dc_to_dc):
= arcs[(i, j)][0]
cost = arcs[(i, j)][1]
capacity = " (at capacity)" if abs(amt - capacity) < 0.001 else ""
at_capacity print(f" {i} → {j}: {amt:.1f} units (cost: ${cost}/unit, total: ${amt*cost:.1f}){at_capacity}")
# Export solution to CSV
= []
solution_data for (i, j), flow_amount in flow_values.items():
= arcs[(i, j)][0]
cost = arcs[(i, j)][1]
capacity
solution_data.append({'from_node': i,
'to_node': j,
'flow': flow_amount,
'cost_per_unit': cost,
'total_cost': flow_amount * cost,
'capacity': capacity if capacity < float('inf') else 'unlimited',
'at_capacity': 'Yes' if capacity < float('inf') and abs(flow_amount - capacity) < 0.001 else 'No'
})
= pd.DataFrame(solution_data)
solution_df 'solution.csv', index=False)
solution_df.to_csv(print()
print(solution_df)
else:
print(f"No optimal solution found. Status code: {model.Status}")
print("Check your data for inconsistencies in supply/demand balance or network connectivity.")
Reading node data from nodes.csv...
Reading arc data from arcs.csv...
Total supply: 900
Total demand: 900
Network has 16 nodes and 35 arcs
Set parameter LogToConsole to value 0
Solving the network flow problem...
========== OPTIMAL SOLUTION FOUND ==========
Total transportation cost: $5120.00
Active arcs: 23 out of 35 possible
Arcs at capacity: 9
FACTORY UTILIZATION:
F1: 120.0 units shipped (100.0% of capacity)
F2: 150.0 units shipped (100.0% of capacity)
F3: 200.0 units shipped (100.0% of capacity)
F4: 180.0 units shipped (100.0% of capacity)
F5: 250.0 units shipped (100.0% of capacity)
DISTRIBUTION CENTER THROUGHPUT:
DC1: 240.0 units processed
DC2: 240.0 units processed
DC3: 460.0 units processed
WAREHOUSE DEMAND FULFILLMENT:
W1: 80.0 units received (100.0% of demand)
W2: 110.0 units received (100.0% of demand)
W3: 90.0 units received (100.0% of demand)
W4: 130.0 units received (100.0% of demand)
W5: 150.0 units received (100.0% of demand)
W6: 100.0 units received (100.0% of demand)
W7: 120.0 units received (100.0% of demand)
W8: 120.0 units received (100.0% of demand)
DETAILED FLOW REPORT (non-zero flows only):
Factory → Warehouse (Direct):
F2 → W2: 20.0 units (cost: $8/unit, total: $160.0)
Factory → Distribution Center:
F1 → DC1: 100.0 units (cost: $3/unit, total: $300.0) (at capacity)
F1 → DC2: 20.0 units (cost: $4/unit, total: $80.0)
F2 → DC1: 30.0 units (cost: $4/unit, total: $120.0)
F2 → DC2: 100.0 units (cost: $3/unit, total: $300.0) (at capacity)
F3 → DC2: 50.0 units (cost: $3/unit, total: $150.0)
F3 → DC3: 150.0 units (cost: $2/unit, total: $300.0) (at capacity)
F4 → DC2: 70.0 units (cost: $4/unit, total: $280.0)
F4 → DC3: 110.0 units (cost: $3/unit, total: $330.0) (at capacity)
F5 → DC1: 50.0 units (cost: $5/unit, total: $250.0)
F5 → DC3: 200.0 units (cost: $2/unit, total: $400.0) (at capacity)
Distribution Center → Warehouse:
DC1 → W1: 80.0 units (cost: $3/unit, total: $240.0)
DC1 → W2: 90.0 units (cost: $4/unit, total: $360.0) (at capacity)
DC1 → W3: 70.0 units (cost: $2/unit, total: $140.0)
DC2 → W3: 20.0 units (cost: $3/unit, total: $60.0)
DC2 → W4: 130.0 units (cost: $2/unit, total: $260.0)
DC2 → W5: 20.0 units (cost: $4/unit, total: $80.0)
DC2 → W6: 70.0 units (cost: $3/unit, total: $210.0)
DC3 → W5: 130.0 units (cost: $2/unit, total: $260.0) (at capacity)
DC3 → W6: 30.0 units (cost: $4/unit, total: $120.0)
DC3 → W7: 120.0 units (cost: $3/unit, total: $360.0)
DC3 → W8: 120.0 units (cost: $2/unit, total: $240.0) (at capacity)
Distribution Center → Distribution Center:
DC3 → DC1: 60.0 units (cost: $2/unit, total: $120.0) (at capacity)
from_node to_node flow cost_per_unit total_cost capacity at_capacity
0 F1 DC1 100.0 3 300.0 100.0 Yes
1 F1 DC2 20.0 4 80.0 80.0 No
2 F2 DC1 30.0 4 120.0 90.0 No
3 F2 DC2 100.0 3 300.0 100.0 Yes
4 F3 DC2 50.0 3 150.0 120.0 No
5 F3 DC3 150.0 2 300.0 150.0 Yes
6 F4 DC2 70.0 4 280.0 100.0 No
7 F4 DC3 110.0 3 330.0 110.0 Yes
8 F5 DC1 50.0 5 250.0 80.0 No
9 F5 DC3 200.0 2 400.0 200.0 Yes
10 F2 W2 20.0 8 160.0 unlimited No
11 DC1 W1 80.0 3 240.0 100.0 No
12 DC1 W2 90.0 4 360.0 90.0 Yes
13 DC1 W3 70.0 2 140.0 120.0 No
14 DC2 W3 20.0 3 60.0 100.0 No
15 DC2 W4 130.0 2 260.0 150.0 No
16 DC2 W5 20.0 4 80.0 120.0 No
17 DC2 W6 70.0 3 210.0 110.0 No
18 DC3 W5 130.0 2 260.0 130.0 Yes
19 DC3 W6 30.0 4 120.0 90.0 No
20 DC3 W7 120.0 3 360.0 140.0 No
21 DC3 W8 120.0 2 240.0 120.0 Yes
22 DC3 DC1 60.0 2 120.0 60.0 Yes
This is just for example, we will not being doing any problems like this in this course.
5 Example 02: Oil Transport Problem
Let’s look at another example. The Conch Oil Company needs to transport 30 million barrels of crude oil from Doha, Qatar to three European refineries:
- Rotterdam, Netherlands (6 million barrels)
- Toulon, France (15 million barrels)
- Palermo, Italy (9 million barrels)
There are three possible routes:
- Direct shipping around Africa (most expensive)
- Through the Suez Canal to Port Said, then to destinations
- From Suez to Damietta via pipeline (limited to 15 million barrels), then to destinations
flowchart LR Doha(Doha<br/>30M barrels) Suez(Suez) PortSaid(Port Said) Damietta(Damietta) Rotterdam(Rotterdam<br/>-6M barrels) Toulon(Toulon<br/>-15M barrels) Palermo(Palermo<br/>-9M barrels) Doha -->|$1.20/barrel| Rotterdam Doha -->|$1.40/barrel| Toulon Doha -->|$1.35/barrel| Palermo Doha -->|$0.35/barrel| Suez Suez -->|$0.20/barrel| PortSaid Suez -->|$0.16/barrel<br/>Max 15M barrels| Damietta PortSaid -->|$0.27/barrel| Rotterdam PortSaid -->|$0.28/barrel| Toulon PortSaid -->|$0.19/barrel| Palermo Damietta -->|$0.25/barrel| Rotterdam Damietta -->|$0.20/barrel| Toulon Damietta -->|$0.15/barrel| Palermo style Doha fill:#d4f1c5 style Rotterdam fill:#c5daf1 style Toulon fill:#c5daf1 style Palermo fill:#c5daf1 style Suez fill:#f1e9c5 style PortSaid fill:#f1e9c5 style Damietta fill:#f1e9c5
5.1 Optimal Solution to Oil Transport Problem
Let’s implement a complete Python solution for the Conch Oil Company problem. We’ll structure this similar to our previous example but add more detailed code comments to explain the logic.
import gurobipy as gp
from gurobipy import GRB
# Define the nodes in our network
= ['Doha', 'Suez', 'PortSaid', 'Damietta', 'Rotterdam', 'Toulon', 'Palermo']
nodes
# Define supply/demand at each node (in millions of barrels)
= {
supply_demand 'Doha': 30, # Supply: 30M barrels at origin
'Suez': 0, # Transshipment node: no net supply/demand
'PortSaid': 0, # Transshipment node: no net supply/demand
'Damietta': 0, # Transshipment node: no net supply/demand
'Rotterdam': -6, # Demand: 6M barrels
'Toulon': -15, # Demand: 15M barrels
'Palermo': -9 # Demand: 9M barrels
}
# Define arcs with (cost, capacity) tuples
# Format: (origin, destination): (cost per barrel, capacity in millions of barrels)
= {
arcs # Direct routes from Doha to refineries
'Doha', 'Rotterdam'): (1.20, float('inf')),
('Doha', 'Toulon'): (1.40, float('inf')),
('Doha', 'Palermo'): (1.35, float('inf')),
(
# Route via Suez
'Doha', 'Suez'): (0.35, float('inf')),
('Suez', 'PortSaid'): (0.20, float('inf')),
('Suez', 'Damietta'): (0.16, 15), # Pipeline has 15M barrel capacity
(
# Routes from Port Said to refineries
'PortSaid', 'Rotterdam'): (0.27, float('inf')),
('PortSaid', 'Toulon'): (0.28, float('inf')),
('PortSaid', 'Palermo'): (0.19, float('inf')),
(
# Routes from Damietta to refineries
'Damietta', 'Rotterdam'): (0.25, float('inf')),
('Damietta', 'Toulon'): (0.20, float('inf')),
('Damietta', 'Palermo'): (0.15, float('inf'))
(
}
# Create a new Gurobi model
= gp.Model("OilTransportProblem")
model = 0
model.Params.LogToConsole
# Create decision variables for flow on each arc
# Each variable represents millions of barrels flowing on that route
= {}
flow for (i, j), (cost, _) in arcs.items():
# obj=cost sets this variable's coefficient in the objective function
= model.addVar(name=f'flow_{i}_{j}', obj=cost)
flow[(i, j)]
# Add flow conservation constraints
# For each node: outflow - inflow = supply/demand
for i in nodes:
# Calculate total outflow from node i
= gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
outflow
# Calculate total inflow to node i
= gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
inflow
# Set constraint: outflow - inflow = supply/demand for this node
- inflow == supply_demand[i], name=f'node_{i}')
model.addConstr(outflow
# Add capacity constraints for arcs with limited capacity
for (i, j), (_, capacity) in arcs.items():
if capacity < float('inf'):
<= capacity, name=f'capacity_{i}_{j}')
model.addConstr(flow[(i, j)]
# Set objective to minimize total cost
= GRB.MINIMIZE
model.ModelSense
# Solve the model
model.optimize()
Set parameter LogToConsole to value 0
When executing this model, the solution shows that:
- All 30 million barrels are shipped from Doha to Suez
- From Suez, 15 million barrels go through the pipeline to Damietta (utilizing full capacity)
- The remaining 15 million barrels go to Port Said
- From Damietta and Port Said, the oil is distributed to the refineries through the most cost-effective routes
We can extract values from our model with the following code. You should be able to use the same code for similar models, like in the lab (hint hint):
import pandas as pd
# Create a list to store flow data for the CSV
= []
flow_data = 0
total_cost
# Extract non-zero flows
for (i, j), var in flow.items():
= var.X
flow_amount
# Filter out very small flows (numerical precision issues)
if flow_amount > 0.001:
# Get the cost for this arc
= arcs[(i, j)][0]
cost
# Calculate the cost contribution
= flow_amount * cost
flow_cost += flow_cost
total_cost
# Add a row with all relevant information
flow_data.append({'origin': i,
'destination': j,
'flow': round(flow_amount, 2),
'cost_per_unit': cost,
'total_cost': round(flow_cost, 2),
'capacity': arcs[(i, j)][1] if arcs[(i, j)][1] < float('inf') else 'Unlimited'
})
# Check if we have any flow data before proceeding
if flow_data:
# Create a DataFrame from the flow data
= pd.DataFrame(flow_data)
flow_df
# Sort the DataFrame without assuming column names
if 'origin' in flow_df.columns and 'destination' in flow_df.columns:
= flow_df.sort_values(by=['origin', 'destination'])
flow_df
# Save to CSV
'optimal_flows.csv', index=False)
flow_df.to_csv(
# Create a summary DataFrame with additional statistics
= [{
summary_data 'metric': 'Total Cost',
'value': round(total_cost, 2)
}, {'metric': 'Total Flow Units',
'value': round(sum(item['flow'] for item in flow_data), 2)
}, {'metric': 'Number of Active Routes',
'value': len(flow_data)
}]
= pd.DataFrame(summary_data)
summary_df 'solution_summary.csv', index=False)
summary_df.to_csv(
print(f"Solution exported to 'optimal_flows.csv' and 'solution_summary.csv'")
print(f"Total transportation cost: ${total_cost:.2f}")
print()
print(summary_df)
Solution exported to 'optimal_flows.csv' and 'solution_summary.csv'
Total transportation cost: $22.23
metric value
0 Total Cost 22.23
1 Total Flow Units 90.00
2 Number of Active Routes 6.00
This code:
- Creates a structured dataset with all key information for each flow
- Exports the detailed flows to ‘optimal_flows.csv’
- Creates a separate summary file with key metrics
- Works with any network structure (not specific to the oil transport problem)
- Preserves the total cost calculation from the original code
You can easily adapt this by:
- Changing the column names if needed
- Adding more metrics to the summary file
- Modifying the rounding precision
- Adding more details to each flow record
The CSV output will have a clean, tabular structure that can be opened in Excel or other tools for further analysis or visualization.
6 References and Resources
- Hillier & Lieberman, “Introduction to Operations Research,” Chapter 9
- The Gurobi Modeling Examples repository
7 Exercises
7.1 Simple Network Flow Problem
A shipping company needs to transport goods from two origins (O1, O2) to three destinations (D1, D2, D3). The shipping costs (in $ per unit) and supply/demand quantities are shown below:
Supply and Demand:
- Origin O1 has 150 units available
- Origin O2 has 250 units available
- Destination D1 requires 100 units
- Destination D2 requires 200 units
- Destination D3 requires 100 units
Shipping Costs:
From | To | Cost per unit |
---|---|---|
O1 | D1 | $5 |
O1 | D2 | $3 |
O1 | D3 | $6 |
O2 | D1 | $4 |
O2 | D2 | $6 |
O2 | D3 | $2 |
Formulate and solve this minimum cost network flow problem.
7.1.1 Solution
The solution demonstrates how to model and solve a basic transportation problem using Gurobi. The key components include:
- Setting up the network structure with origins (O1, O2) and destinations (D1, D2, D3)
- Defining supply and demand quantities at each node
- Creating shipping costs between each origin-destination pair
- Formulating flow conservation constraints for all nodes
- Solving the model to find the minimum cost solution
Code
import gurobipy as gp
from gurobipy import GRB
import pandas as pd
# Define the nodes in our network
= ['O1', 'O2']
origins = ['D1', 'D2', 'D3']
destinations = origins + destinations
nodes
# Define supply/demand at each node
= {
supply_demand 'O1': 150, # Origin 1 supplies 150 units
'O2': 250, # Origin 2 supplies 250 units
'D1': -100, # Destination 1 demands 100 units
'D2': -200, # Destination 2 demands 200 units
'D3': -100 # Destination 3 demands 100 units
}
# Define shipping costs
= {
shipping_costs 'O1', 'D1'): 5,
('O1', 'D2'): 3,
('O1', 'D3'): 6,
('O2', 'D1'): 4,
('O2', 'D2'): 6,
('O2', 'D3'): 2
(
}
# Define arcs with (cost, capacity)
= {}
arcs for (origin, dest), cost in shipping_costs.items():
= (cost, float('inf')) # All routes have unlimited capacity initially
arcs[(origin, dest)]
# Create a new Gurobi model
= gp.Model("SimpleTransportationProblem")
model = 0
model.Params.LogToConsole
# Create flow variables for each arc
= {}
flow for (i, j), (cost, _) in arcs.items():
= model.addVar(name=f'flow_{i}_{j}', obj=cost)
flow[(i, j)]
# Add flow conservation constraints
for i in nodes:
# Sum of flows leaving node i
= gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
outflow
# Sum of flows entering node i
= gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
inflow
# Outflow - inflow = supply/demand
- inflow == supply_demand[i], name=f'node_{i}')
model.addConstr(outflow
# Set objective to minimize total cost
= GRB.MINIMIZE
model.ModelSense
# Solve the model
model.optimize()
# Extract the solution
= {}
flow_values = 0
total_cost
for (i, j), var in flow.items():
= var.X
flow_amount
# Only include arcs with positive flow
if flow_amount > 0.001:
= flow_amount
flow_values[(i, j)]
# Add to the total cost
= arcs[(i, j)][0]
cost += flow_amount * cost
total_cost
print("\nOptimal shipping plan:")
for (origin, dest), amount in sorted(flow_values.items()):
= arcs[(origin, dest)][0]
cost print(f"{origin} → {dest}: {amount:.1f} units (cost: ${cost}/unit, total: ${amount*cost:.1f})")
print(f"\nTotal transportation cost: ${total_cost:.1f}")
# Export results to CSV (as requested)
= []
flow_data for (i, j), amount in flow_values.items():
= arcs[(i, j)][0]
cost
flow_data.append({'origin': i,
'destination': j,
'flow': round(amount, 1),
'cost_per_unit': cost,
'total_cost': round(amount * cost, 1)
})
= pd.DataFrame(flow_data)
flow_df 'exercise1_solution.csv', index=False)
flow_df.to_csv(print()
print(flow_df)
Set parameter LogToConsole to value 0
Optimal shipping plan:
O1 → D2: 150.0 units (cost: $3/unit, total: $450.0)
O2 → D1: 100.0 units (cost: $4/unit, total: $400.0)
O2 → D2: 50.0 units (cost: $6/unit, total: $300.0)
O2 → D3: 100.0 units (cost: $2/unit, total: $200.0)
Total transportation cost: $1350.0
origin destination flow cost_per_unit total_cost
0 O1 D2 150.0 3 450.0
1 O2 D1 100.0 4 400.0
2 O2 D2 50.0 6 300.0
3 O2 D3 100.0 2 200.0
The optimal solution shows how to allocate shipments to minimize the total transportation cost. In the optimal solution, we typically see that cheaper routes are preferred (like O1→D2 with cost $3/unit) over more expensive alternatives. The CSV export functionality demonstrates how to save results for further analysis.
If you’ve noticed by now, a lot of the Python code is cookie-cutter once you create the initial setup.
7.2 Network with Capacity Constraints
Extend Exercise 1 by adding the following capacity constraints:
- The route from O1 to D2 can handle at most 80 units
- The route from O2 to D3 can handle at most 60 units
Answer the following:
- How do these constraints change the optimal solution?
- Which routes are now at capacity?
- How much does the total cost increase due to these constraints?
7.2.1 Solution
This solution extends Exercise 1 by adding capacity constraints on specific routes:
- O1 to D2: maximum 80 units
- O2 to D3: maximum 60 units
The solution approach:
- Solves both the unconstrained and constrained versions of the problem
- Compares the solutions to understand the impact of capacity constraints
- Identifies which routes are at capacity in the optimal solution
- Calculates the cost increase due to the constraints
Code
# Define capacity constraints
= {
capacity_constraints 'O1', 'D2'): 80, # Route from O1 to D2 has max capacity of 80 units
('O2', 'D3'): 60 # Route from O2 to D3 has max capacity of 60 units
(
}
# Update arcs with capacity constraints
for (origin, dest) in capacity_constraints:
= arcs[(origin, dest)][0]
cost = (cost, capacity_constraints[(origin, dest)])
arcs[(origin, dest)]
# Create a new Gurobi model
= gp.Model("CapacitatedTransportationProblem")
model_constrained = 0
model_constrained.Params.LogToConsole
# Create flow variables for each arc
= {}
flow_constrained for (i, j), (cost, _) in arcs.items():
= model_constrained.addVar(name=f'flow_{i}_{j}', obj=cost)
flow_constrained[(i, j)]
# Add flow conservation constraints
for i in nodes:
# Sum of flows leaving node i
= gp.quicksum(flow_constrained[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
outflow
# Sum of flows entering node i
= gp.quicksum(flow_constrained[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
inflow
# Outflow - inflow = supply/demand
- inflow == supply_demand[i], name=f'node_{i}')
model_constrained.addConstr(outflow
# Add capacity constraints
for (i, j), (_, capacity) in arcs.items():
if capacity < float('inf'):
<= capacity, name=f'capacity_{i}_{j}')
model_constrained.addConstr(flow_constrained[(i, j)]
# Set objective to minimize total cost
= GRB.MINIMIZE
model_constrained.ModelSense
# Solve the model
model_constrained.optimize()
# Extract the solution
= {}
flow_values_constrained = 0
total_cost_constrained
for (i, j), var in flow_constrained.items():
= var.X
flow_amount
# Only include arcs with positive flow
if flow_amount > 0.001:
= flow_amount
flow_values_constrained[(i, j)]
# Add to the total cost
= arcs[(i, j)][0]
cost += flow_amount * cost
total_cost_constrained
print("\nConstrained Solution:")
print("-" * 30)
for (origin, dest), amount in sorted(flow_values_constrained.items()):
= arcs[(origin, dest)][0]
cost = arcs[(origin, dest)][1]
capacity
= ""
capacity_info if capacity < float('inf') and abs(amount - capacity) < 0.1:
= " (AT CAPACITY)"
capacity_info
print(f"{origin} → {dest}: {amount:.1f} units (cost: ${cost}/unit, total: ${amount*cost:.1f}){capacity_info}")
print(f"\nTotal transportation cost: ${total_cost_constrained:.1f}")
# Calculate cost difference
= total_cost_constrained - total_cost # from ex 1 solution
cost_difference = (cost_difference / total_cost) * 100
percentage_increase
print("\nCost Comparison:")
print(f"Cost increase due to capacity constraints: ${cost_difference:.1f} ({percentage_increase:.1f}%)")
# Export results to CSV
= []
flow_data
# Add unconstrained solution data
for (i, j), amount in flow_values.items():
= arcs[(i, j)][0]
cost = arcs[(i, j)][1]
capacity = "Yes" if capacity < float('inf') and abs(amount - capacity) < 0.1 else "No"
at_capacity
flow_data.append({'scenario': "Unconstrained",
'origin': i,
'destination': j,
'flow': round(amount, 1),
'cost_per_unit': cost,
'total_cost': round(amount * cost, 1),
'capacity': capacity if capacity < float('inf') else "Unlimited",
'at_capacity': at_capacity
})
# Add constrained solution data
for (i, j), amount in flow_values_constrained.items():
= arcs[(i, j)][0]
cost = arcs[(i, j)][1]
capacity = "Yes" if capacity < float('inf') and abs(amount - capacity) < 0.1 else "No"
at_capacity
flow_data.append({'scenario': "Constrained",
'origin': i,
'destination': j,
'flow': round(amount, 1),
'cost_per_unit': cost,
'total_cost': round(amount * cost, 1),
'capacity': capacity if capacity < float('inf') else "Unlimited",
'at_capacity': at_capacity
})
= pd.DataFrame(flow_data)
flow_df print(flow_df)
Set parameter LogToConsole to value 0
Constrained Solution:
------------------------------
O1 → D1: 30.0 units (cost: $5/unit, total: $150.0)
O1 → D2: 80.0 units (cost: $3/unit, total: $240.0) (AT CAPACITY)
O1 → D3: 40.0 units (cost: $6/unit, total: $240.0)
O2 → D1: 70.0 units (cost: $4/unit, total: $280.0)
O2 → D2: 120.0 units (cost: $6/unit, total: $720.0)
O2 → D3: 60.0 units (cost: $2/unit, total: $120.0) (AT CAPACITY)
Total transportation cost: $1750.0
Cost Comparison:
Cost increase due to capacity constraints: $400.0 (29.6%)
scenario origin destination flow cost_per_unit total_cost \
0 Unconstrained O1 D2 150.0 3 450.0
1 Unconstrained O2 D1 100.0 4 400.0
2 Unconstrained O2 D2 50.0 6 300.0
3 Unconstrained O2 D3 100.0 2 200.0
4 Constrained O1 D1 30.0 5 150.0
5 Constrained O1 D2 80.0 3 240.0
6 Constrained O1 D3 40.0 6 240.0
7 Constrained O2 D1 70.0 4 280.0
8 Constrained O2 D2 120.0 6 720.0
9 Constrained O2 D3 60.0 2 120.0
capacity at_capacity
0 80 No
1 Unlimited No
2 Unlimited No
3 60 No
4 Unlimited No
5 80 Yes
6 Unlimited No
7 Unlimited No
8 Unlimited No
9 60 Yes
The comparison provides an important real-world insight: limited shipping capacity often leads to higher transportation costs as companies are forced to use less efficient routes. The solution exports detailed results to a CSV file for further analysis, showing both the unconstrained and constrained flows side by side.
This exercise demonstrates how to implement and analyze capacity constraints in transportation networks, which is a common challenge in real-world supply chain management.