Mine Production Scheduling

Multi-period open-pit mine scheduling with NPV maximization
Published

February 8, 2026

1 Overview

This example demonstrates a multi-period mine production scheduling problem—a core optimization challenge in open-pit mining operations. We’ll maximize the Net Present Value (NPV) of extracted ore while respecting:

  • Processing plant capacity limits
  • Mining equipment capacity limits
  • Grade blending constraints (min/max ore grade)
  • Precedence constraints (can’t mine lower blocks before upper blocks)

This is directly relevant to mining operations at companies like BHP, Fortescue, and Rio Tinto.

2 Problem Setup

Consider a simplified open-pit mine with 9 blocks arranged in a cross-section:

   [0] [1] [2]    ← Level 0 (surface)
     [3] [4]      ← Level 1 (must mine 0,1 or 1,2 first)
       [5]        ← Level 2 (must mine 3,4 first)
   [6] [7] [8]    ← Level 0 (surface, separate area)

Each block has properties: tonnage, copper grade, and mining cost.

import numpy as np
from optyx import Variable, MatrixVariable, Problem

# Time periods (years)
n_periods = 4
discount_rate = 0.10  # 10% annual discount rate

# Mining blocks
n_blocks = 9

# Block properties
block_tonnage = np.array([100, 120, 110, 80, 90, 60, 95, 105, 100])  # kt
block_grade = np.array([0.8, 1.2, 0.9, 1.5, 2.0, 2.5, 0.7, 1.1, 0.85])  # % Cu
block_mining_cost = np.array([3.5, 3.8, 3.6, 4.2, 4.5, 5.0, 3.4, 3.7, 3.5])  # $/t

# Economic parameters
copper_price = 8000  # $/tonne Cu
processing_cost = 15  # $/t ore
recovery_rate = 0.90  # 90% Cu recovery

# Capacity constraints
max_mining_capacity = 250  # kt/period
max_processing_capacity = 200  # kt/period
min_blend_grade = 1.0  # Minimum average grade (% Cu)
max_blend_grade = 2.0  # Maximum average grade (% Cu)

# Precedence: block i must be mined before block j
precedence = [
    (0, 3), (1, 3),  # Blocks 0,1 must precede block 3
    (1, 4), (2, 4),  # Blocks 1,2 must precede block 4
    (3, 5), (4, 5),  # Blocks 3,4 must precede block 5
]

print(f"Blocks: {n_blocks}")
print(f"Periods: {n_periods} years")
print(f"Total ore: {block_tonnage.sum():.0f} kt")
print(f"Average grade: {np.average(block_grade, weights=block_tonnage):.2f}% Cu")
Blocks: 9
Periods: 4 years
Total ore: 860 kt
Average grade: 1.21% Cu

3 Decision Variables

We use a matrix variable \(X \in [0, 1]^{N \times T}\) representing the fraction of block \(i\) mined in period \(t\):

# x[i,t] = fraction of block i mined in period t
x = MatrixVariable("x", rows=n_blocks, cols=n_periods, lb=0, ub=1)

print(f"Decision variables: {x.size} (blocks × periods)")
Decision variables: 36 (blocks × periods)

4 Objective: Maximize NPV

The Net Present Value accounts for time value of money:

\[ \text{NPV} = \sum_{t=0}^{T-1} \frac{1}{(1+r)^t} \sum_{i=0}^{n-1} (\text{Revenue}_i - \text{Cost}_i) \cdot x_{i,t} \]

where: - Revenue = tonnage × grade × recovery × copper price - Cost = tonnage × (mining cost + processing cost)

# Calculate profit per block ($ thousands)
# Revenue = tonnage * grade * recovery * price
revenue_per_block = block_tonnage * (block_grade / 100) * recovery_rate * copper_price
# Cost = tonnage * (mining_cost + processing_cost)
cost_per_block = block_tonnage * (block_mining_cost + processing_cost)
profit_per_block = revenue_per_block - cost_per_block

# Discount factors for each period
discount_factors = np.array([1 / (1 + discount_rate) ** t for t in range(n_periods)])

# NPV = sum(profit[i] * x[i,t] * discount[t])
npv = 0
for t in range(n_periods):
    # Vectorized over blocks for each period: profit @ x_column
    period_profit = profit_per_block @ x[:, t]
    npv += period_profit * discount_factors[t]

print("NPV objective built with vectorized operations")
NPV objective built with vectorized operations

5 Constraints

5.1 1. Block Extraction Limits

Each block can only be mined once (total extraction ≤ 1):

\[\sum_{t=0}^{T-1} x_{i,t} \leq 1 \quad \forall i\]

5.2 2. Capacity Constraints

Mining and processing capacity per period:

\[\sum_{i=0}^{n-1} \text{tonnage}_i \cdot x_{i,t} \leq \text{Capacity} \quad \forall t\]

5.3 3. Precedence Constraints

Upper blocks must be mined before lower blocks. For each precedence pair \((i, j)\):

\[\sum_{s=0}^{t} x_{i,s} \geq \sum_{s=0}^{t} x_{j,s} \quad \forall t\]

5.4 4. Grade Blending Constraints

The average grade of ore processed each period must be within bounds. Linearized form:

\[\sum_i \text{tonnage}_i \cdot (\text{grade}_i - \text{min\_grade}) \cdot x_{i,t} \geq 0\]

prob = Problem(name="mine_scheduling")
prob.maximize(npv)

# 1. Each block mined at most once
# Sum across columns (time) for each row (block) must be <= 1
for i in range(n_blocks):
    # x[i, :] returns a VectorVariable representing the row
    prob.subject_to(x[i, :].sum() <= 1)

# 2. Mining capacity per period
for t in range(n_periods):
    # Vectorized dot product: tonnage @ x_column
    period_mining = block_tonnage @ x[:, t]
    prob.subject_to(period_mining <= max_mining_capacity)

# 3. Processing capacity per period
for t in range(n_periods):
    period_processing = block_tonnage @ x[:, t]
    prob.subject_to(period_processing <= max_processing_capacity)

# 4. Precedence constraints
for (i, j) in precedence:
    for t in range(n_periods):
        # Cumulative extraction up to time t
        # We use slice indexing x[i, :t+1] which returns a VectorVariable
        cum_i = x[i, :t+1].sum()
        cum_j = x[j, :t+1].sum()
        prob.subject_to(cum_i >= cum_j)

# 5. Grade blending constraints
for t in range(n_periods):
    # Minimum grade: sum(tonnage * (grade - min) * x) >= 0
    min_grade_coefs = block_tonnage * (block_grade - min_blend_grade)
    prob.subject_to(min_grade_coefs @ x[:, t] >= 0)
    
    # Maximum grade: sum(tonnage * (max - grade) * x) >= 0
    max_grade_coefs = block_tonnage * (max_blend_grade - block_grade)
    prob.subject_to(max_grade_coefs @ x[:, t] >= 0)

total_constraints = (
    n_blocks +                      # extraction limits
    n_periods * 2 +                 # capacity constraints
    len(precedence) * n_periods +   # precedence
    n_periods * 2                   # grade blending
)
print(f"Total constraints: {total_constraints}")
Total constraints: 49

6 Solve

solution = prob.solve(method="trust-constr")

print(f"Status: {solution.status.value}")
print(f"NPV: ${solution.objective_value:,.0f} thousand")
print(f"Iterations: {solution.iterations}")
print(f"Solve time: {solution.solve_time*1000:.1f} ms")
Status: optimal
NPV: $50,222 thousand
Iterations: 273
Solve time: 19473.8 ms

7 Results Analysis

7.1 Extraction Schedule

# Get all values at once as a numpy array
x_val = solution[x]  # Returns (n_blocks, n_periods) array

# Print schedule table
print(f"\n{'Block':<8}", end="")
for t in range(n_periods):
    print(f"{'Year '+str(t+1):>10}", end="")
print(f"{'Total':>10}")
print("-" * (8 + 10 * (n_periods + 1)))

total_by_period = [0] * n_periods
for i in range(n_blocks):
    print(f"Block {i:<3}", end="")
    block_total = 0
    for t in range(n_periods):
        val = x_val[i, t]
        print(f"{val:>10.2f}", end="")
        block_total += val
        total_by_period[t] += val * block_tonnage[i]
    print(f"{block_total:>10.2f}")

print("-" * (8 + 10 * (n_periods + 1)))
print(f"{'Tonnage':<8}", end="")
for t in range(n_periods):
    print(f"{total_by_period[t]:>10.0f}", end="")
print(f"{sum(total_by_period):>10.0f} kt")

Block       Year 1    Year 2    Year 3    Year 4     Total
----------------------------------------------------------
Block 0        0.36      0.36      0.07      0.22      1.00
Block 1        0.36      0.36      0.29      0.00      1.00
Block 2        0.36      0.36      0.29      0.00      1.00
Block 3        0.36      0.36      0.07      0.22      1.00
Block 4        0.36      0.36      0.29      0.00      1.00
Block 5        0.36      0.36      0.07      0.22      1.00
Block 6        0.00      0.00      0.00      0.37      0.37
Block 7        0.00      0.00      0.89      0.11      1.00
Block 8        0.00      0.00      0.00      1.00      1.00
----------------------------------------------------------
Tonnage        200       200       200       200       800 kt

7.2 Grade by Period

print(f"\n{'Period':<12} {'Tonnage (kt)':>15} {'Avg Grade (%Cu)':>18}")
print("-" * 45)

for t in range(n_periods):
    # Vectorized calculation using the result array
    weighted_grade = (block_grade * block_tonnage) @ x_val[:, t]
    
    if total_by_period[t] > 0:
        avg_grade = weighted_grade / total_by_period[t]
        print(f"Year {t+1:<7} {total_by_period[t]:>15.0f} {avg_grade:>18.2f}")
    else:
        print(f"Year {t+1:<7} {0:>15.0f} {'N/A':>18}")

Period          Tonnage (kt)    Avg Grade (%Cu)
---------------------------------------------
Year 1                   200               1.38
Year 2                   200               1.38
Year 3                   200               1.23
Year 4                   200               1.00

7.3 Economic Summary

total_tonnage = sum(total_by_period)

# Total copper = sum(grade * tonnage * total_extraction_per_block)
total_extraction = x_val.sum(axis=1)
total_cu = (block_grade / 100 * block_tonnage) @ total_extraction

print(f"\nTotal ore mined: {total_tonnage:,.0f} kt")
print(f"Total copper content: {total_cu:,.1f} kt Cu")
print(f"Average grade: {total_cu / total_tonnage * 100:.2f}% Cu")
print(f"NPV: ${solution.objective_value:,.0f} thousand")
print(f"NPV per tonne ore: ${solution.objective_value / total_tonnage:.2f}/t")

Total ore mined: 800 kt
Total copper content: 10.0 kt Cu
Average grade: 1.25% Cu
NPV: $50,222 thousand
NPV per tonne ore: $62.78/t

8 Key Takeaways

  1. Matrix variables: MatrixVariable provides a clean interface for 2D decision variables.
  2. Vectorized constraints: Operations like block_tonnage @ x[:, t] make code readable and efficient.
  3. Precedence constraints: Ensure physically feasible mining sequences.
  4. Grade blending: Linearized ratio constraints for mill feed quality.
  5. NPV discounting: Time value of money in multi-period planning.

9 Extensions

This model can be extended with:

  • Binary variables for discrete block extraction (MILP)
  • Stockpile management and blending from stockpiles
  • Stochastic optimization for price/grade uncertainty
  • Equipment scheduling and haul route optimization
  • Environmental constraints (dust, water, rehabilitation)