Min Cost Flow Slotting › Implementation in OR-Tools

Michael BrenndoerferSeptember 1, 202520 min read
Machine Learning from ScratchAll chapters
Part 9 of 9Optimization
Chapter 63Min Cost Flow Slotting › Implementation in OR-Tools
Reading section 5 of 8 · Implementation in OR-Tools · 20 minSection 5 of 8 · 20 min

Book contents

Section 5 of 8 · 20 min read

Min Cost Flow Slotting › Implementation in OR-ToolsOne section · Sections in this chapter5 / 8 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Now that we understand the mathematical formulation, let's see how to implement it in practice. We'll use OR-Tools, Google's open-source optimization library, which provides efficient solvers specifically designed for network flow problems.

Why OR-Tools?

OR-Tools is an excellent choice for Minimum Cost flow problems because:

  1. Specialized Algorithms: The SimpleMinCostFlow solver uses highly optimized algorithms (like the successive shortest path algorithm or cost scaling) that are much faster than general-purpose linear programming solvers for network flow problems.

  2. Ease of Use: The API directly maps to our mathematical formulation. We specify the nodes and edges with capacity and cost values, then assign each node's supply. The solver handles the rest.

  3. Reliability: OR-Tools has been tested on real-world problems and handles edge cases such as infeasible problems and numerical issues gracefully.

  4. Open Source: Free to use and well-documented, making it accessible for both learning and production applications.

Mapping Mathematics to Code

The key insight is that our mathematical formulation translates directly into OR-Tools API calls:

  • Edges (i,j)∈E(i,j) \in E → add_arc_with_capacity_and_unit_cost(start_node, end_node, capacity, cost)
  • Node Supplies bib_i → set_node_supply(node, supply)
  • Decision Variables xijx_{ij} → Automatically handled by the solver (we query the results)
  • Objective Function → Automatically minimized by the solver
  • Constraints → Automatically enforced by the solver

The solver takes care of all the constraint enforcement and optimization - we just need to specify the problem structure.

Building a Reusable Solver Function

Let's create a function that encapsulates the solution process, making it easy to solve different MCF problems. This function will construct the network, run the solver, and extract the solution:

In[8]:
Code
from ortools.graph.python import min_cost_flow


def solve_minimum_cost_flow(
    start_nodes, end_nodes, capacities, unit_costs, supplies
):
    """
    Solve a minimum cost flow problem using OR-Tools.

    This function translates our mathematical formulation into OR-Tools API calls:
    - Edges (i,j) with capacity u_ij and cost c_ij → add_arc_with_capacity_and_unit_cost()
    - Node supplies b_i → set_node_supply()
    - The solver automatically handles flow conservation, capacity constraints, and optimization

    Parameters:
    -----------
    start_nodes : list
        List of starting nodes for each arc (the "i" in edge (i,j))
    end_nodes : list
        List of ending nodes for each arc (the "j" in edge (i,j))
    capacities : list
        List of capacities u_ij for each arc (upper bounds on flow)
    unit_costs : list
        List of unit costs c_ij for each arc (cost per unit of flow)
    supplies : list
        List of supplies b_i for each node (positive for sources, negative for sinks, zero for transshipment)

    Returns:
    --------
    dict : Dictionary with solution information including optimal flows and total cost
    """
    # Create the solver instance
    # This initializes the internal data structures that will represent our network
    solver = min_cost_flow.SimpleMinCostFlow()

    # Step 1: Add all edges to the network
    # This corresponds to defining the set E in our graph G = (V, E)
    # For each edge, we specify: start node, end node, capacity (u_ij), and unit cost (c_ij)
    for i in range(len(start_nodes)):
        solver.add_arc_with_capacity_and_unit_cost(
            start_nodes[i], end_nodes[i], capacities[i], unit_costs[i]
        )

    # Step 2: Set node supplies/demands
    # This corresponds to setting the values b_i for each node i ∈ V
    # The solver will use these to enforce flow conservation constraints
    for i in range(len(supplies)):
        solver.set_node_supply(i, supplies[i])

    # Step 3: Solve the optimization problem
    # The solver automatically:
    # - Enforces flow conservation: Σx_out - Σx_in = b_i for each node
    # - Enforces capacity constraints: 0 ≤ x_ij ≤ u_ij for each edge
    # - Minimizes the objective: min Σ c_ij * x_ij
    status = solver.solve()

    # Step 4: Extract and return the solution
    if status == solver.OPTIMAL:
        # The solver found an optimal solution
        solution = {
            "status": "OPTIMAL",
            "total_cost": solver.optimal_cost(),  # The value of our minimized objective function
            "flows": {},  # Dictionary mapping (start, end) → flow amount x_ij
            "arc_info": [],  # List of detailed information for each arc
        }

        # Extract the optimal flow value for each edge
        # These are the values of our decision variables x_ij that minimize cost
        for i in range(solver.num_arcs()):
            tail = solver.tail(i)  # Start node of this arc
            head = solver.head(i)  # End node of this arc
            flow = solver.flow(i)  # Optimal flow x_ij on this arc
            capacity = solver.capacity(i)  # Capacity u_ij of this arc
            unit_cost = solver.unit_cost(i)  # Unit cost c_ij of this arc
            arc_cost = flow * unit_cost  # Total cost contribution: c_ij * x_ij

            # Store the flow in our solution dictionary
            solution["flows"][(tail, head)] = flow
            solution["arc_info"].append(
                {
                    "arc": (tail, head),
                    "flow": flow,
                    "capacity": capacity,
                    "unit_cost": unit_cost,
                    "total_cost": arc_cost,
                }
            )

        return solution
    else:
        # The problem is infeasible (no solution exists) or the solver encountered an error
        return {
            "status": "INFEASIBLE"
            if status == solver.INFEASIBLE
            else "UNKNOWN",
            "total_cost": None,
            "flows": {},
            "arc_info": [],
        }


# Example: Supply chain optimization
def supply_chain_example():
    """
    Example of supply chain optimization using MCF.

    This implements the supply chain problem we worked through in the Example section:
    - Factory (node 0) produces 10 units (supply = 10)
    - Warehouse (node 1) and Distribution (node 2) are transshipment nodes (supply = 0)
    - Market (node 3) requires 10 units (supply = -10, which represents demand)

    The network has 5 edges representing different shipping routes, each with
    capacity limits and transportation costs. The solver will find the cheapest
    way to route all 10 units from factory to market.
    """

    # Define the network structure
    # Each list has the same length, with index i representing the i-th edge
    start_nodes = [0, 0, 1, 1, 2]  # From nodes: edges start at these nodes
    end_nodes = [1, 2, 2, 3, 3]  # To nodes: edges end at these nodes
    # Together, these define edges: (0,1), (0,2), (1,2), (1,3), (2,3)

    # Capacity constraints: u_ij for each edge
    # These are the upper bounds that prevent overloading any route
    capacities = [15, 8, 5, 10, 12]  # Maximum flow allowed on each edge

    # Unit costs: c_ij for each edge
    # These determine the cost per unit of flow, used in the objective function
    unit_costs = [4, 6, 1, 2, 3]  # Cost per unit of flow on each edge

    # Node supplies: b_i for each node
    # Positive = supply (source), negative = demand (sink), zero = transshipment
    supplies = [10, 0, 0, -10]  # Factory supplies 10, Market demands 10

    # Solve the optimization problem
    # This function call translates our problem into the mathematical formulation
    # and uses OR-Tools to find the optimal flow values x_ij
    solution = solve_minimum_cost_flow(
        start_nodes, end_nodes, capacities, unit_costs, supplies
    )

    return solution


# Run the example
# This solves the supply chain problem we analyzed mathematically earlier
supply_chain_solution = supply_chain_example()

Now let's display the results from the supply chain example:

Out[9]:
Console
Supply Chain Network:
Nodes: Factory(0), Warehouse(1), Distribution(2), Market(3)
Supplies/Demands: [10, 0, 0, -10]
(Positive = supply, Negative = demand, Zero = transshipment)

Optimal solution found!
Total cost: 60.0
(This is the minimized value of Σ c_ij * x_ij)

Optimal flow on each arc:
Arc    Flow / Capacity  Unit Cost  Total Cost
-------------------------------------------------------
0 -> 1     10 /       15       4.0        40.0
0 -> 2      0 /        8       6.0         0.0
1 -> 2      0 /        5       1.0         0.0
1 -> 3     10 /       10       2.0        20.0
2 -> 3      0 /       12       3.0         0.0

Note: Only edges with positive flow are used in the optimal solution.
The solver automatically set unused edges to zero flow.

The solver found an optimal solution with a total cost of 60 cost units. This matches our mathematical analysis from the Example section, where we determined that routing all 10 units through Factory → Warehouse → Market (cost 6 per unit) is optimal. The solution shows that edges (0,1) and (1,3) carry the full flow of 10 units each, while other edges remain unused. This makes operational sense: the cheapest path has sufficient capacity to handle all demand, so there's no need to split flow across multiple routes. The unused edges (0,2), (1,2), and (2,3) have zero flow, indicating they're not part of the optimal solution.

A More Complex Example: Time Slotting

The power of the Minimum Cost flow formulation becomes even clearer when we tackle more complex problems. Let's consider a time slotting problem where we need to allocate resources across multiple time periods to complete various tasks. This demonstrates how MCF can model problems with multiple stages and complex routing requirements.

The key insight is that we can model time as a dimension in our network: resources flow through time slots, and then through tasks, creating a multi-stage network structure.

In[10]:
Code
def time_slotting_example():
    """
    Example of resource allocation across time slots using MCF.

    This demonstrates how MCF can model slotting problems where resources
    must be allocated across time periods. The network structure is:

    Source → Resources → Time Slots → Tasks → Sink

    This creates a multi-stage flow where:
    1. Resources are allocated from a source
    2. Resources are assigned to time slots (with costs that may vary by time)
    3. Time slots are connected to tasks (with costs that may vary by task priority)
    4. Tasks flow to a sink (representing completed work)

    The solver finds the optimal allocation that minimizes total cost while
    respecting capacity constraints at each stage.
    """

    # Define the problem dimensions
    time_slots = 3  # We have 3 time periods to allocate resources
    resources = 2  # We have 2 resources (e.g., workers, machines)
    tasks = 4  # We need to complete 4 tasks

    # Build the network structure layer by layer
    # This creates a multi-stage network: Source → Resources → Time Slots → Tasks → Sink
    start_nodes = []
    end_nodes = []
    capacities = []
    unit_costs = []

    # Stage 1: Source to Resources
    # The source node (node 0) represents the total available capacity
    # We connect it to each resource, allowing flow to be allocated to different resources
    source = 0
    for r in range(resources):
        start_nodes.append(source)
        end_nodes.append(r + 1)  # Resource nodes are numbered 1, 2, ...
        capacities.append(
            10
        )  # Each resource has capacity to handle 10 units total
        unit_costs.append(
            0
        )  # No cost for allocating resources (costs occur when using them)

    # Stage 2: Resources to Time Slots
    # Each resource can be assigned to work in different time slots
    # This creates edges from each resource to each time slot
    # Node numbering: resources are 1..resources, time slots are (resources+1)..(resources+time_slots)
    for r in range(resources):
        for t in range(time_slots):
            start_nodes.append(r + 1)  # From resource r
            end_nodes.append(resources + 1 + t)  # To time slot t
            capacities.append(
                5
            )  # Each resource can work 5 units per time slot (capacity constraint)
            unit_costs.append(
                1
            )  # Base cost for a resource working in a time slot

    # Stage 3: Time Slots to Tasks
    # Each time slot can contribute to completing different tasks
    # This creates edges from each time slot to each task
    # Node numbering: tasks are (resources+time_slots+1)..(resources+time_slots+tasks)
    for t in range(time_slots):
        for task in range(tasks):
            start_nodes.append(resources + 1 + t)  # From time slot t
            end_nodes.append(resources + time_slots + 1 + task)  # To task
            capacities.append(3)  # Each time slot can handle 3 units per task
            unit_costs.append(
                t + 1
            )  # Cost increases with later time slots (urgency penalty)
            # This models that completing work earlier is preferred

    # Stage 4: Tasks to Sink
    # The sink node represents completed work
    # All tasks must flow to the sink, representing that all tasks must be completed
    sink = resources + time_slots + tasks + 1
    for task in range(tasks):
        start_nodes.append(resources + time_slots + 1 + task)  # From task
        end_nodes.append(sink)  # To sink
        capacities.append(8)  # Each task can accept up to 8 units of work
        unit_costs.append(
            0
        )  # No cost for completing tasks (costs are in the process)

    # Define node supplies/demands
    # This enforces that:
    # - Source supplies total capacity (20 units)
    # - All intermediate nodes are transshipment (supply = 0)
    # - Sink demands the same amount (20 units) to ensure all work is completed
    supplies = [20] + [0] * (resources + time_slots + tasks) + [-20]

    # Solve the optimization problem
    # The solver will find the optimal way to route flow through all stages
    # while minimizing total cost and respecting all capacity constraints
    solution = solve_minimum_cost_flow(
        start_nodes, end_nodes, capacities, unit_costs, supplies
    )

    # Analyze the solution by aggregating flows at each stage
    # This helps us understand how the solver allocated resources
    resource_usage = {}  # How much each resource is used
    time_slot_usage = {}  # How much work is done in each time slot
    task_usage = {}  # How much work is allocated to each task

    # Parse the solution to extract flow information
    # We examine each edge in the solution and aggregate flows by stage
    if solution["status"] == "OPTIMAL":
        for arc_info in solution["arc_info"]:
            arc = arc_info["arc"]
            flow = arc_info["flow"]  # The optimal flow x_ij on this edge

            if flow > 0:  # Only consider edges that are actually used
                start, end = arc

                # Stage 1: Source to Resources
                # Track how much flow is allocated to each resource
                if start == 0:  # Edge starts at source
                    resource_usage[end] = resource_usage.get(end, 0) + flow

                # Stage 2: Resources to Time Slots
                # Track how much work each resource does in each time slot
                elif 1 <= start <= resources:  # Edge starts at a resource
                    time_slot = end - resources - 1  # Extract time slot index
                    time_slot_usage[time_slot] = (
                        time_slot_usage.get(time_slot, 0) + flow
                    )

                # Stage 3: Time Slots to Tasks
                # Track how much work each time slot contributes to each task
                elif (
                    resources + 1 <= start <= resources + time_slots
                ):  # Edge starts at a time slot
                    task = (
                        end - resources - time_slots - 1
                    )  # Extract task index
                    task_usage[task] = task_usage.get(task, 0) + flow

    return (
        solution,
        resource_usage,
        time_slot_usage,
        task_usage,
        resources,
        time_slots,
        tasks,
    )


# Run the time slotting example
(
    time_slotting_solution,
    resource_usage,
    time_slot_usage,
    task_usage,
    num_resources,
    num_time_slots,
    num_tasks,
) = time_slotting_example()

Now let's display the results from the time slotting example:

Out[11]:
Console
Time Slotting Network:
Resources: 2, Time Slots: 3, Tasks: 4
Total nodes: 11

Optimal solution found!
Total cost: 50.0
(This represents the minimized cost of allocating resources across time slots and tasks)

Optimal Resource Allocation:
========================================
  Resource 1: 10 units allocated
  Resource 2: 10 units allocated

Time Slot Utilization:
========================================
  Time slot 0: 10 units of work
  Time slot 1: 10 units of work

Task Work Allocation:
========================================
  Task 0: 2 units of work allocated
  Task 1: 6 units of work allocated
  Task 2: 6 units of work allocated
  Task 3: 6 units of work allocated

The solver found an optimal allocation that routes resources through time slots to tasks while minimizing total cost. The solution balances multiple competing factors: resource capacity limits prevent over-allocation, time slot availability constrains when work can be done, and task requirements ensure all work is completed. Notice that the cost structure (increasing costs for later time slots) encourages the solver to prefer earlier time slots when possible, which aligns with operational preferences for completing work sooner rather than later. The total cost reflects the trade-offs between these constraints, and the allocation shows how resources are efficiently distributed across the network to achieve this optimal balance.

Alternative Implementation: Direct Solver Usage

Sometimes it's useful to work directly with the OR-Tools solver object rather than using a wrapper function. This gives you more control and helps you understand exactly how the solver API maps to our mathematical formulation. Let's see how to build a network step-by-step:

In[12]:
Code
def manual_network_construction():
    """
    Manually construct and solve a simple MCF problem using direct solver API.

    This demonstrates the direct mapping between mathematical concepts and code:
    - Each edge (i,j) → add_arc_with_capacity_and_unit_cost(i, j, u_ij, c_ij)
    - Each node supply b_i → set_node_supply(i, b_i)
    - Query results → flow(i), optimal_cost()

    This approach is useful when you want fine-grained control over the problem
    construction or when building networks incrementally.
    """

    # Initialize the solver
    # This creates an empty network that we'll build edge by edge
    solver = min_cost_flow.SimpleMinCostFlow()

    # Step 1: Add edges (arcs) to the network
    # Each call adds one edge (i,j) with capacity u_ij and unit cost c_ij
    # This directly corresponds to defining the set E in our graph G = (V, E)

    solver.add_arc_with_capacity_and_unit_cost(
        0, 1, 10, 2
    )  # Edge (0,1): u=10, c=2
    solver.add_arc_with_capacity_and_unit_cost(
        0, 2, 8, 4
    )  # Edge (0,2): u=8, c=4
    solver.add_arc_with_capacity_and_unit_cost(
        1, 2, 5, 1
    )  # Edge (1,2): u=5, c=1
    solver.add_arc_with_capacity_and_unit_cost(
        1, 3, 12, 3
    )  # Edge (1,3): u=12, c=3
    solver.add_arc_with_capacity_and_unit_cost(
        2, 3, 15, 2
    )  # Edge (2,3): u=15, c=2

    # Step 2: Set node supplies/demands
    # This sets the values b_i for each node, which the solver uses to enforce
    # flow conservation: Σx_out - Σx_in = b_i for each node i

    solver.set_node_supply(0, 15)  # Node 0: supply node (b_0 = 15)
    solver.set_node_supply(1, 0)  # Node 1: transshipment (b_1 = 0)
    solver.set_node_supply(2, 0)  # Node 2: transshipment (b_2 = 0)
    solver.set_node_supply(3, -15)  # Node 3: demand node (b_3 = -15)

    # Step 3: Solve the optimization problem
    # The solver automatically:
    # - Enforces flow conservation at each node
    # - Enforces capacity constraints on each edge
    # - Minimizes total cost: min Σ c_ij * x_ij
    status = solver.solve()

    # Extract solution information
    solution_data = {
        "status": status,
        "optimal": status == solver.OPTIMAL,
        "cost": solver.optimal_cost() if status == solver.OPTIMAL else None,
        "arcs": [],
    }

    if status == solver.OPTIMAL:
        # Extract the solution: the optimal values of our decision variables x_ij
        # The solver stores arcs in the order they were added
        for i in range(solver.num_arcs()):
            tail = solver.tail(i)  # Start node of arc i
            head = solver.head(i)  # End node of arc i
            flow = solver.flow(i)  # Optimal flow x_ij on this arc
            capacity = solver.capacity(i)  # Capacity u_ij
            unit_cost = solver.unit_cost(i)  # Unit cost c_ij
            total_cost = flow * unit_cost  # Cost contribution: c_ij * x_ij

            solution_data["arcs"].append(
                {
                    "tail": tail,
                    "head": head,
                    "flow": flow,
                    "capacity": capacity,
                    "unit_cost": unit_cost,
                    "total_cost": total_cost,
                }
            )

    return solution_data


# Run manual example
# This demonstrates the direct API usage, which is useful for understanding
# how the mathematical formulation maps to code
manual_solution = manual_network_construction()

Now let's display the results from the manual network construction:

Out[13]:
Console
Manual Network Construction:
==================================================
Optimal total cost: 80.0
(This is the minimized value of Σ c_ij * x_ij)

Optimal flow on each arc:
Arc    Flow    Capacity  Unit Cost  Total Cost
--------------------------------------------------
  0 -> 1:    10          10       2.0        20.0
  0 -> 2:     5           8       4.0        20.0
  1 -> 2:     5           5       1.0         5.0
  1 -> 3:     5          12       3.0        15.0
  2 -> 3:    10          15       2.0        20.0

Key insight: The solver found the optimal flow values x_ij that
minimize total cost while satisfying all flow conservation and
capacity constraints. Notice that some edges may have zero flow
if they're not part of the optimal solution.

The solver successfully found an optimal solution for this network. The total cost is the minimum achievable for this network under its capacity limits and unit costs. Each arc's flow value shows how the solver allocated flow to minimize total cost while respecting capacity constraints. Arcs with zero flow indicate that those edges are not part of the optimal solution. The solver determined that routing flow through alternative paths is more cost-effective. This demonstrates how the MCF solver automatically identifies the most efficient routing strategy without requiring manual path selection.

Key Parameters

Below are the main parameters and methods that affect how Minimum Cost Flow problems are solved using OR-Tools.

Key Parameters

The SimpleMinCostFlow solver in OR-Tools doesn't expose many configurable parameters directly, as it uses specialized algorithms optimized for network flow problems. However, the problem structure itself is defined by several key parameters:

  • start_nodes: List of starting nodes for each arc (the "i" in edge (i,j)(i,j)). Each element corresponds to one edge in the network. Should have the same length as end_nodes, capacities, and unit_costs.

  • end_nodes: List of ending nodes for each arc (the "j" in edge (i,j)(i,j)). Together with start_nodes, this defines the network topology. Node indices should be non-negative integers starting from 0.

  • capacities: List of capacity values uiju_{ij} for each arc (maximum flow allowed). Should be non-negative. Use realistic values based on actual operational constraints rather than arbitrarily large numbers. Values should be in the same units as flow variables.

  • unit_costs: List of unit cost values cijc_{ij} for each arc (cost per unit of flow). Can be any real number (positive, negative, or zero). Negative costs can model revenue or benefits. Ensure all costs use consistent units (cost per flow unit).

  • supplies: List of supply values bib_i for each node. Positive values represent sources (supply nodes), negative values represent sinks (demand nodes), and zero represents transshipment nodes. The sum of all positive supplies should equal the absolute sum of all negative demands for the problem to be feasible.

Key Methods

The following are the most commonly used methods for working with the SimpleMinCostFlow solver:

  • add_arc_with_capacity_and_unit_cost(start_node, end_node, capacity, unit_cost): Adds an edge to the network with specified capacity and unit cost. Call this method once for each edge in your network. The order of addition doesn't affect the solution, but it determines the arc index used for querying results.

  • set_node_supply(node, supply): Sets the supply value bib_i for a node. Call this method once for each node in your network. Node indices should be consecutive starting from 0. Positive values indicate supply, negative values indicate demand, and zero indicates transshipment.

  • solve(): Solves the optimization problem and returns a status code. Returns solver.OPTIMAL if an optimal solution was found, solver.INFEASIBLE if no feasible solution exists, or solver.UNKNOWN if the solver encountered issues. Check the status before querying solution values.

  • optimal_cost(): Returns the minimized total cost (the value of ∑(i,j)∈Ecij⋅xij\sum_{(i,j) \in E} c_{ij} \cdot x_{ij}) after solving. Only valid if solve() returned OPTIMAL. This represents the minimum achievable cost for routing flow through the network.

  • flow(arc_index): Returns the optimal flow value xijx_{ij} for the arc at the given index. Arc indices correspond to the order in which arcs were added using add_arc_with_capacity_and_unit_cost(). Only valid after a successful solve.

  • num_arcs(): Returns the total number of arcs (edges) in the network. Useful for iterating over all arcs to extract solution information.

  • tail(arc_index) and head(arc_index): Return the start and end nodes of an arc, respectively. Useful for identifying which edge each flow value corresponds to when extracting solutions.