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.
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
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)
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
Constraints
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\]
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\]
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} " )
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
Results Analysis
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
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" \n Total 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
Key Takeaways
Matrix variables : MatrixVariable provides a clean interface for 2D decision variables.
Vectorized constraints : Operations like block_tonnage @ x[:, t] make code readable and efficient.
Precedence constraints : Ensure physically feasible mining sequences.
Grade blending : Linearized ratio constraints for mill feed quality.
NPV discounting : Time value of money in multi-period planning.
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)