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 GRBGurobipy 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
nodes = ['F1', 'F2', 'DC', 'W1', 'W2', 'Dummy']
# 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
model = gp.Model("MinCostNetworkFlow")
# Create flow variables for each arc
flow = {}
for (i, j), (cost, _) in arcs.items():
flow[(i, j)] = model.addVar(name=f'flow_{i}_{j}', obj=cost)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:
iwould be'F1'(the origin node)jwould be'DC'(the destination node)costwould 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=costsets 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
outflow = gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
# Sum of all flows entering node i
inflow = gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
# Outflow - inflow = supply/demand
model.addConstr(outflow - inflow == supply_demand[i], name=f'node_{i}')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 == ifilters 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 nodeito 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 == ifilters 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 nodejto 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 - inflowcalculates 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'):
model.addConstr(flow[(i, j)] <= capacity, name=f'capacity_{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 nodeito nodej(cost, capacity)contains the shipping cost and maximum capacity
(i, j), (_, capacity)unpacks these values:iis the origin nodejis the destination node_is the cost (we use underscore because we don’t need the cost for capacity constraints)capacityis the maximum amount that can flow on this arc
For example, if arcs contains (‘F1’, ‘DC’): (3, 50), then in that iteration:
iwould be'F1'(origin)jwould be'DC'(destination)_would be3(cost, ignored here)capacitywould 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)] <= capacitycreates 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
model.ModelSense = GRB.MINIMIZE
# 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 = {}
total_cost = 0
for (i, j), var in flow.items():
# Get the flow amount for this arc
flow_amount = var.X
# Only include arcs with positive flow
if flow_amount > 0.001: # Small threshold for floating-point errors
flow_values[(i, j)] = flow_amount
# Add to the total cost
cost = arcs[(i, j)][0]
total_cost += flow_amount * cost
print("Optimal flows:")
for (i, j), flow in sorted(flow_values.items()):
cost = arcs[(i, j)][0]
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
nodes = ['F1', 'F2', 'DC', 'W1', 'W2', 'Dummy']
# 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
model = gp.Model("MinCostNetworkFlow")
model.Params.LogToConsole = 0 # should turn off unwanted gurobipy output
# Create flow variables for each arc
flow = {}
for (i, j), (cost, _) in arcs.items():
flow[(i, j)] = model.addVar(name=f'flow_{i}_{j}', obj=cost)
# Add flow conservation constraints for each node
for i in nodes:
# Sum of all flows leaving node i
outflow = gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
# Sum of all flows entering node i
inflow = gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
# Outflow - inflow = supply/demand
model.addConstr(outflow - inflow == supply_demand[i], name=f'node_{i}')
# Add capacity constraints for each arc
for (i, j), (_, capacity) in arcs.items():
if capacity < float('inf'):
model.addConstr(flow[(i, j)] <= capacity, name=f'capacity_{i}_{j}')
# Set objective to minimize total cost
model.ModelSense = GRB.MINIMIZE
# Optimize the model
model.optimize()
# Check if an optimal solution was found
if model.Status == GRB.OPTIMAL:
# Extract the solution
flow_values = {}
total_cost = 0
for (i, j), var in flow.items():
# Get the flow amount for this arc
flow_amount = var.X
# Only include arcs with positive flow
if flow_amount > 0.001: # Small threshold for floating-point errors
flow_values[(i, j)] = flow_amount
# Add to the total cost
cost = arcs[(i, j)][0]
total_cost += flow_amount * cost
print("Optimal flows:")
for (i, j), flow in sorted(flow_values.items()):
cost = arcs[(i, j)][0]
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...")
nodes_df = pd.read_csv(r'data\min_cost_ex_nodes.csv')
# Read the arc data
print("Reading arc data from arcs.csv...")
arcs_df = pd.read_csv(r'data\min_cost_ex_arcs.csv')
# Extract node information
nodes = nodes_df['node_id'].tolist()
supply_demand = dict(zip(nodes_df['node_id'], nodes_df['supply_demand']))
node_types = dict(zip(nodes_df['node_id'], nodes_df['type']))
# Group nodes by type for reporting
factories = [node for node, type_val in node_types.items() if type_val == 'Factory']
distribution_centers = [node for node, type_val in node_types.items() if type_val == 'DC']
warehouses = [node for node, type_val in node_types.items() if type_val == 'Warehouse']
# Check supply/demand balance
total_supply = sum(v for v in supply_demand.values() if v > 0)
total_demand = -sum(v for v in supply_demand.values() if v < 0)
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
arcs_df['capacity'] = arcs_df['capacity'].replace('inf', float('inf'))
arcs_df['capacity'] = pd.to_numeric(arcs_df['capacity'])
# Create arcs dictionary
arcs = {}
for _, row in arcs_df.iterrows():
arcs[(row['from_node'], row['to_node'])] = (row['cost'], row['capacity'])
print(f"Network has {len(nodes)} nodes and {len(arcs)} arcs")
# Create a new Gurobi model
model = gp.Model("SupplyChainNetwork")
model.Params.LogToConsole = 0
# Create flow variables for each arc
flow = {}
for (i, j), (cost, _) in arcs.items():
flow[(i, j)] = model.addVar(name=f'flow_{i}_{j}', obj=cost)
# Add flow conservation constraints for each node
for i in nodes:
# Sum of flows leaving node i
outflow = gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
# Sum of flows entering node i
inflow = gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
# Outflow - inflow = supply/demand
model.addConstr(outflow - inflow == supply_demand[i], name=f'node_{i}')
# Add capacity constraints for each arc
for (i, j), (_, capacity) in arcs.items():
if capacity < float('inf'):
model.addConstr(flow[(i, j)] <= capacity, name=f'capacity_{i}_{j}')
# Set objective to minimize total cost
model.ModelSense = GRB.MINIMIZE
# 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
active_arcs = 0
capacity_limited_arcs = 0
total_cost = 0
# Dictionaries to track flows by node type
factory_flows = {f: 0 for f in factories}
dc_throughput = {dc: 0 for dc in distribution_centers}
warehouse_inflows = {w: 0 for w in warehouses}
# Extract solution
flow_values = {}
for (i, j), var in flow.items():
flow_amount = var.X
if flow_amount > 0.001: # Only count non-zero flows
flow_values[(i, j)] = flow_amount
active_arcs += 1
cost = arcs[(i, j)][0]
capacity = arcs[(i, j)][1]
total_cost += flow_amount * cost
# Check if arc is at capacity
if capacity < float('inf') and abs(flow_amount - capacity) < 0.001:
capacity_limited_arcs += 1
# Update node statistics
if i in factories:
factory_flows[i] += flow_amount
if i in distribution_centers:
dc_throughput[i] += flow_amount
if j in warehouses:
warehouse_inflows[j] += flow_amount
# 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:
utilization_pct = (factory_flows[f] / supply_demand[f]) * 100
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:
received_pct = (warehouse_inflows[w] / (-supply_demand[w])) * 100
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
f_to_w = [(i, j, flow_values[(i, j)]) for (i, j) in flow_values.keys()
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):
cost = arcs[(i, j)][0]
print(f" {i} → {j}: {amt:.1f} units (cost: ${cost}/unit, total: ${amt*cost:.1f})")
# Factory to DC
f_to_dc = [(i, j, flow_values[(i, j)]) for (i, j) in flow_values.keys()
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):
cost = arcs[(i, j)][0]
capacity = arcs[(i, j)][1]
at_capacity = " (at capacity)" if abs(amt - capacity) < 0.001 else ""
print(f" {i} → {j}: {amt:.1f} units (cost: ${cost}/unit, total: ${amt*cost:.1f}){at_capacity}")
# DC to warehouse
dc_to_w = [(i, j, flow_values[(i, j)]) for (i, j) in flow_values.keys()
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):
cost = arcs[(i, j)][0]
capacity = arcs[(i, j)][1]
at_capacity = " (at capacity)" if abs(amt - capacity) < 0.001 else ""
print(f" {i} → {j}: {amt:.1f} units (cost: ${cost}/unit, total: ${amt*cost:.1f}){at_capacity}")
# Inter-DC flows
dc_to_dc = [(i, j, flow_values[(i, j)]) for (i, j) in flow_values.keys()
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):
cost = arcs[(i, j)][0]
capacity = arcs[(i, j)][1]
at_capacity = " (at capacity)" if abs(amt - capacity) < 0.001 else ""
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():
cost = arcs[(i, j)][0]
capacity = arcs[(i, j)][1]
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'
})
solution_df = pd.DataFrame(solution_data)
solution_df.to_csv('solution.csv', index=False)
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
nodes = ['Doha', 'Suez', 'PortSaid', 'Damietta', 'Rotterdam', 'Toulon', 'Palermo']
# 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
model = gp.Model("OilTransportProblem")
model.Params.LogToConsole = 0
# 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
flow[(i, j)] = model.addVar(name=f'flow_{i}_{j}', obj=cost)
# Add flow conservation constraints
# For each node: outflow - inflow = supply/demand
for i in nodes:
# Calculate total outflow from node i
outflow = gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
# Calculate total inflow to node i
inflow = gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
# Set constraint: outflow - inflow = supply/demand for this node
model.addConstr(outflow - inflow == supply_demand[i], name=f'node_{i}')
# Add capacity constraints for arcs with limited capacity
for (i, j), (_, capacity) in arcs.items():
if capacity < float('inf'):
model.addConstr(flow[(i, j)] <= capacity, name=f'capacity_{i}_{j}')
# Set objective to minimize total cost
model.ModelSense = GRB.MINIMIZE
# 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 = []
total_cost = 0
# Extract non-zero flows
for (i, j), var in flow.items():
flow_amount = var.X
# Filter out very small flows (numerical precision issues)
if flow_amount > 0.001:
# Get the cost for this arc
cost = arcs[(i, j)][0]
# Calculate the cost contribution
flow_cost = flow_amount * cost
total_cost += flow_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
flow_df = pd.DataFrame(flow_data)
# Sort the DataFrame without assuming column names
if 'origin' in flow_df.columns and 'destination' in flow_df.columns:
flow_df = flow_df.sort_values(by=['origin', 'destination'])
# Save to CSV
flow_df.to_csv('optimal_flows.csv', index=False)
# 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)
}]
summary_df = pd.DataFrame(summary_data)
summary_df.to_csv('solution_summary.csv', index=False)
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
origins = ['O1', 'O2']
destinations = ['D1', 'D2', 'D3']
nodes = origins + destinations
# 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():
arcs[(origin, dest)] = (cost, float('inf')) # All routes have unlimited capacity initially
# Create a new Gurobi model
model = gp.Model("SimpleTransportationProblem")
model.Params.LogToConsole = 0
# Create flow variables for each arc
flow = {}
for (i, j), (cost, _) in arcs.items():
flow[(i, j)] = model.addVar(name=f'flow_{i}_{j}', obj=cost)
# Add flow conservation constraints
for i in nodes:
# Sum of flows leaving node i
outflow = gp.quicksum(flow[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
# Sum of flows entering node i
inflow = gp.quicksum(flow[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
# Outflow - inflow = supply/demand
model.addConstr(outflow - inflow == supply_demand[i], name=f'node_{i}')
# Set objective to minimize total cost
model.ModelSense = GRB.MINIMIZE
# Solve the model
model.optimize()
# Extract the solution
flow_values = {}
total_cost = 0
for (i, j), var in flow.items():
flow_amount = var.X
# Only include arcs with positive flow
if flow_amount > 0.001:
flow_values[(i, j)] = flow_amount
# Add to the total cost
cost = arcs[(i, j)][0]
total_cost += flow_amount * cost
print("\nOptimal shipping plan:")
for (origin, dest), amount in sorted(flow_values.items()):
cost = arcs[(origin, dest)][0]
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():
cost = arcs[(i, j)][0]
flow_data.append({
'origin': i,
'destination': j,
'flow': round(amount, 1),
'cost_per_unit': cost,
'total_cost': round(amount * cost, 1)
})
flow_df = pd.DataFrame(flow_data)
flow_df.to_csv('exercise1_solution.csv', index=False)
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:
cost = arcs[(origin, dest)][0]
arcs[(origin, dest)] = (cost, capacity_constraints[(origin, dest)])
# Create a new Gurobi model
model_constrained = gp.Model("CapacitatedTransportationProblem")
model_constrained.Params.LogToConsole = 0
# Create flow variables for each arc
flow_constrained = {}
for (i, j), (cost, _) in arcs.items():
flow_constrained[(i, j)] = model_constrained.addVar(name=f'flow_{i}_{j}', obj=cost)
# Add flow conservation constraints
for i in nodes:
# Sum of flows leaving node i
outflow = gp.quicksum(flow_constrained[(i, j)] for (i2, j) in arcs.keys() if i2 == i)
# Sum of flows entering node i
inflow = gp.quicksum(flow_constrained[(j, i)] for (j, i2) in arcs.keys() if i2 == i)
# Outflow - inflow = supply/demand
model_constrained.addConstr(outflow - inflow == supply_demand[i], name=f'node_{i}')
# Add capacity constraints
for (i, j), (_, capacity) in arcs.items():
if capacity < float('inf'):
model_constrained.addConstr(flow_constrained[(i, j)] <= capacity, name=f'capacity_{i}_{j}')
# Set objective to minimize total cost
model_constrained.ModelSense = GRB.MINIMIZE
# Solve the model
model_constrained.optimize()
# Extract the solution
flow_values_constrained = {}
total_cost_constrained = 0
for (i, j), var in flow_constrained.items():
flow_amount = var.X
# Only include arcs with positive flow
if flow_amount > 0.001:
flow_values_constrained[(i, j)] = flow_amount
# Add to the total cost
cost = arcs[(i, j)][0]
total_cost_constrained += flow_amount * cost
print("\nConstrained Solution:")
print("-" * 30)
for (origin, dest), amount in sorted(flow_values_constrained.items()):
cost = arcs[(origin, dest)][0]
capacity = arcs[(origin, dest)][1]
capacity_info = ""
if capacity < float('inf') and abs(amount - capacity) < 0.1:
capacity_info = " (AT CAPACITY)"
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
cost_difference = total_cost_constrained - total_cost # from ex 1 solution
percentage_increase = (cost_difference / total_cost) * 100
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():
cost = arcs[(i, j)][0]
capacity = arcs[(i, j)][1]
at_capacity = "Yes" if capacity < float('inf') and abs(amount - capacity) < 0.1 else "No"
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():
cost = arcs[(i, j)][0]
capacity = arcs[(i, j)][1]
at_capacity = "Yes" if capacity < float('inf') and abs(amount - capacity) < 0.1 else "No"
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
})
flow_df = pd.DataFrame(flow_data)
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.