In last week’s Friday Deswik Challenge, I posted the following question on our LinkedIn page (original post link here: Post | Feed | LinkedIn):
Assume you have 1,200 stopes designed using Stope Optimizer across 20 mining levels. They all have unique stope ID from SO. These stopes all have varying gold grade/value. Each stope is designed to be a slice of 4 metres long. You need to select a group of these stopes to merge to create a stope no more than 20 metres long because a 4-metre rib pillar would be required between stopes. The optimum grouping would result to minimum amount of gold oz (tonnes multiplied by grade) left in pillars. How would you group stopes and pillars? Please provide proof you have the optimum results.
This is a replica of a real-world challenge our team faced in our consulting practice. While there are several different approaches to it, we’d like to share an approach that turns the CAD problem into a mathematical one that can be solved. While I do not expect every mine engineer out there to be a programmer, so I shared an approach leveraging AI power so that anyone without programming experience could solve a complex design issue like this one.
Problem Statement in Deswik
Let’s visually present the challenge in Deswik.
The image below shows on a typical plan view of all stopes along with the access and sill drifts. Red/green/blue boxes represent 4-metre-long slices. Each box has their own metal content. What combination of these slices will result in the least amount of metal left in rib pillars?

Then, we thought of Deswik macro scripts to the rescue. While interested in understand AI’s capabilities in writing custom script in Deswik environment, we asked ChatGPT the following question.
Think Deswik Macro and Resort to ChatGPT
I have 2000 design solids in Deswik. Each design solid contains attributes of name, gold ounce, tonnes, level, vein name. Each solid is 4 metres long. I need to merge continuous solids so that they are not longer than 20 metres. Between 2 adjacent shapes, a 4-metre solid needs to be untouched as a pillar. The goal is to minimize the total gold ounces left in the pillars. Write a script in Deswik Macro to do that.
After 31 seconds of thinking, ChatGPT returned with the following response:
Got it — here’s a practical way to do this in Deswik with a macro-driven workflow that (a) picks the best 4 m pillars to minimize lost ounces and (b) merges stopes up to 20 m (5 solids) between those pillars.
I’ll give you:
- the algorithm (so it’s clear and auditable),
- a Deswik Macro (pseudo-VB style that maps 1:1 to common Deswik macro commands and attribute ops), and
- the post-processing steps (boolean merge by attribute) you can run right in Deswik.
If your solids already have a clean “chain order” along strike, the macro will use it. If not, it will project centroids onto a user-defined strike vector to create one.
I was pretty impressed by two things:
- how well ChatGPT understood the context and even adds a strike vector or Chain attribute to control direction of grouping.
- It understood Deswik operation at a reasonable level (e.g. macro style, solid boolean merge, etc.).
The logic it presents is solid, shown below.
Optimization logic (per Vein + Level chain)
- Each solid = 4 m along strike.
- A stope is a run of 1–5 solids (max 20 m).
- Between two adjacent stopes, exactly one solid must be left as a pillar (4 m).
- Goal: minimize gold left in pillars ⇔ maximize gold in stopes.
Dynamic programming on an ordered list of solids (index
ifrom 0…n−1):Let gold[i] = Au ounces in solid i. dp[i] = maximum Au we can include from i..end choice[i] = best stope length L (1..5) to take at i Transition: For L in 1..5: if i+L <= n: take solids [i .. i+L-1] as a stope next_i = i+L if next_i < n: # there are solids left # leave one 4 m pillar next_i = next_i + 1 candidate = sum(gold[i .. i+L-1]) + dp[next_i] dp[i] = max over candidates Base: dp[n] = 0Recover choices to mark:
- Pillars = the skipped single solids after each chosen stope (except at the very end if we end exactly at
n).- Assign a
StopeIdto each chosen stope segment; each stope’s length ≤ 5 solids by construction.
Then it proceeded to the step of creating a VB-style macro for Deswik. Here’s the initial code it generated:
This macro assumes your solids have attributes:
NAME (string)
VEIN (string)
LEVEL (string)
AU_OZ (double) ← ounces
(Optional) CHAIN (double/int) ← order along strike. If missing, we’ll compute CHAIN by projection.
The macro writes:
IS_PILLAR (int: 0/1)
STOPE_ID (string)
STOPE_LEN_M (double) on each solid that belongs to a stope (0 on pillars)
CHAIN if it had to compute order
Drop this in your Macro Editor. The macro uses only attribute read/writes and common selection/query ops. Where your Deswik build names commands slightly differently, adjust the Command(...) call names to match your menu commands.
' ============================================
' MergeStopesWithOptimizedPillars.DeskMacro
' ============================================
' === USER PARAMETERS ===
Const SOLID_LEN_M = 4.0
Const MAX_STOPE_M = 20.0
Const MAX_STOPE_SOLIDS = 5 ' 20 m / 4 m
Const ATTR_NAME = "NAME"
Const ATTR_VEIN = "VEIN"
Const ATTR_LEVEL = "LEVEL"
Const ATTR_AUOZ = "AU_OZ"
Const ATTR_CHAIN = "CHAIN" ' optional, will compute if missing
Const ATTR_ISPIL = "IS_PILLAR" ' 0/1
Const ATTR_STOPE = "STOPE_ID"
Const ATTR_LENM = "STOPE_LEN_M"
' === STEP 0: INPUT SELECTION ===
Dim solids
solids = GetSelection("Select the 4 m design solids to process")
If Count(solids) = 0 Then
MsgBox "No solids selected."
Exit Sub
End If
' === STEP 1: Ensure grouping and ordering keys are available ===
' Group key: (VEIN, LEVEL)
' Order key: CHAIN (if missing, compute projection onto user-picked strike)
Dim haveChain : haveChain = AttributeExists(solids, ATTR_CHAIN)
If Not haveChain Then
MsgBox "CHAIN attribute not found; please pick two points along strike to define ordering."
Dim p1, p2
p1 = PickPoint("Pick strike start point")
p2 = PickPoint("Pick strike end point")
Dim vx, vy, vz
vx = p2.x - p1.x : vy = p2.y - p1.y : vz = p2.z - p1.z
Dim vlen : vlen = Sqr(vx*vx + vy*vy + vz*vz)
If vlen = 0 Then
MsgBox "Invalid strike vector."
Exit Sub
End If
vx = vx / vlen : vy = vy / vlen : vz = vz / vlen
' Compute CHAIN = dot(centroid - p1, v)
Dim e
For Each e In solids
Dim c : c = GetCentroid(e)
Dim dx, dy, dz
dx = c.x - p1.x : dy = c.y - p1.y : dz = c.z - p1.z
Dim chainVal : chainVal = dx*vx + dy*vy + dz*vz
SetAttribute e, ATTR_CHAIN, chainVal
Next
End If
' === STEP 2: Normalize outputs ===
Dim s
For Each s In solids
SetAttribute s, ATTR_ISPIL, 0
SetAttribute s, ATTR_STOPE, ""
SetAttribute s, ATTR_LENM, 0.0
Next
' === STEP 3: Group by (VEIN, LEVEL) ===
Dim groups : Set groups = CreateDictionary() ' key = vein || level, value = list of entity ids
For Each s In solids
Dim vein : vein = GetAttribute(s, ATTR_VEIN)
Dim lev : lev = GetAttribute(s, ATTR_LEVEL)
Dim key : key = vein & "||" & lev
DictAppend groups, key, s
Next
' === STEP 4: Process each group (sort, DP, mark pillars/stopes) ===
Dim gkey
For Each gkey In DictKeys(groups)
Dim lst : lst = DictGet(groups, gkey)
' sort by CHAIN ascending
lst = SortByAttribute(lst, ATTR_CHAIN, True)
' collect ounces and ids
Dim n : n = Count(lst)
If n = 0 Then GoTo NextGroup
Dim gold() : ReDim gold(n-1)
Dim ids() : ReDim ids(n-1)
Dim i
For i = 0 To n-1
ids(i) = lst(i)
gold(i) = CDbl(GetAttribute(lst(i), ATTR_AUOZ))
Next
' DP arrays
Dim dp() : ReDim dp(n)
Dim choice() : ReDim choice(n-1)
dp(n) = 0
For i = n-1 To 0 Step -1
Dim best, bestL, L
best = -1.0E+30
bestL = 1
For L = 1 To MAX_STOPE_SOLIDS
If i + L <= n Then
Dim sumL : sumL = SumRange(gold, i, i+L-1)
Dim nexti : nexti = i + L
If nexti < n Then
nexti = nexti + 1 ' leave 1 pillar
End If
Dim cand : cand = sumL + dp(nexti)
If cand > best Then
best = cand
bestL = L
End If
End If
Next
dp(i) = best
choice(i) = bestL
Next
' Walk choices, mark attributes
Dim stopeIdx : stopeIdx = 1
i = 0
Do While i < n
Dim L : L = choice(i)
' Mark stope solids [i .. i+L-1]
Dim stopeId : stopeId = gkey & "_S" & CStr(stopeIdx)
Dim j
For j = i To i+L-1
SetAttribute ids(j), ATTR_STOPE, stopeId
Next
' store stope length on members (for reference)
Dim lenM : lenM = L * SOLID_LEN_M
For j = i To i+L-1
SetAttribute ids(j), ATTR_LENM, lenM
Next
stopeIdx = stopeIdx + 1
i = i + L
' Leave one pillar if not at end
If i < n Then
SetAttribute ids(i), ATTR_ISPIL, 1
' Pillar has no stope id/len
SetAttribute ids(i), ATTR_STOPE, ""
SetAttribute ids(i), ATTR_LENM, 0.0
i = i + 1
End If
Loop
NextGroup:
Next
' === STEP 5: Report totals by group (optional) ===
Dim totalPillarOz : totalPillarOz = 0#
For Each s In solids
If CInt(GetAttribute(s, ATTR_ISPIL)) = 1 Then
totalPillarOz = totalPillarOz + CDbl(GetAttribute(s, ATTR_AUOZ))
End If
Next
MsgBox "Finished. Total ounces left in pillars: " & FormatNumber(totalPillarOz, 2)
' === STEP 6: (Run manually after macro) Boolean-merge stopes by STOPE_ID ===
' Use your standard Deswik command:
' Solids → Boolean → Union by Attribute
' Grouping attribute: STOPE_ID
' This will merge each stope’s 1–5 solids into a single ≤20 m solid.
' ================== helpers ==================
Function AttributeExists(entities, attrName)
Dim e
For Each e In entities
If HasAttribute(e, attrName) Then
AttributeExists = True
Exit Function
End If
Next
AttributeExists = False
End Function
Function DictKeys(d)
DictKeys = d.Keys
End Function
Sub DictAppend(d, k, v)
If Not d.Exists(k) Then
Dim tmp : Set tmp = CreateList()
d.Add k, tmp
End If
d.Item(k).Add v
End Sub
Function DictGet(d, k)
DictGet = d.Item(k)
End Function
Function SumRange(arr, a, b)
Dim s : s = 0#
Dim t
For t = a To b
s = s + arr(t)
Next
SumRange = s
End Function
I plugged the code into Deswik Macro Editor. Unfortunately, the code compiled with errors in Deswik and couldn’t get me any output for pillar selection. I asked ChatGPT several rounds of questions to tweak the code, only to no vail. Finally, ChatGPT sensed my frustration and disappointment and suggested a totally different workflow as the following:
Error, Frustration with Deswik Macros: Pivot to Python
Rather than burning more cycles fighting the host quirks, here’s a reliable, tool-agnostic workflow that gets you the exact optimization you need and drops the result straight back into Deswik for the final boolean merge.
I’ve already created a ready-to-run Python script for you (with a download link).
What this does
- Implements the optimal dynamic-programming plan: stopes are runs of 1–5 solids (≤20 m), with a single 4 m pillar between stopes, minimizing total gold left in pillars.
- Works per (VEIN, LEVEL) chain.
- Uses your AU_OZ values and either:
- an existing CHAIN column, or
- computes CHAIN from centroids using a strike vector you provide.
It writes back three attributes for each solid:
IS_PILLAR(0/1)STOPE_ID(blank on pillars)STOPE_LEN_M(0 on pillars; 4/8/12/16/20 on stope members)
You can see the detailed Python code here.
Detailed Python code below.
#!/usr/bin/env python3
“””
Stope optimizer: choose 4 m pillars between merged stopes (<= 20 m) to MINIMIZE gold left in pillars.
– Works per (VEIN, LEVEL) chain.
– Each stope is 1..5 solids (4 m each); between stopes, exactly 1 solid is a pillar (left unmerged).
– Objective: maximize total Au in stopes (equivalently, minimize Au left in pillars).
Input CSV requirements (headers are case-insensitive):
– ID : unique ID to join back in Deswik (NAME or GUID); required
– VEIN : string; required
– LEVEL : string; required
– AU_OZ : numeric; required
– CHAIN : numeric order along strike; optional
– X,Y,Z : centroid coords for each solid; optional (only needed if CHAIN missing and you want to compute by strike)
If CHAIN is missing, you must supply –strike “x1,y1,z1 x2,y2,z2” to compute CHAIN by projection.
Usage:
python stope_optimizer.py –in solids.csv –out stope_plan.csv –strike “x1,y1,z1 x2,y2,z2”
# If CHAIN already exists in solids.csv, you can omit –strike.
The output CSV will contain (in addition to original columns):
– IS_PILLAR (0/1)
– STOPE_ID (string, blank on pillars)
– STOPE_LEN_M (numeric; 0 on pillars; in {4,8,12,16,20} on stopes)
“””
import argparse
import csv
import math
from collections import defaultdict
MAX_STOPE_SOLIDS = 5
SOLID_LEN_M = 4.0
def parse_args():
p = argparse.ArgumentParser(description=”Optimize stopes with mandatory single pillars between them.”)
p.add_argument(‘–in’, dest=’in_csv’, required=True, help=’Input CSV of 4 m solids (exported from Deswik)’)
p.add_argument(‘–out’, dest=’out_csv’, required=True, help=’Output CSV with pillar/stope attributes’)
p.add_argument(‘–strike’, dest=’strike’, default=None,
help=’Two 3D points for strike to compute CHAIN if missing, format: “x1,y1,z1 x2,y2,z2″‘)
return p.parse_args()
def norm(v):
x,y,z = v
m = math.sqrt(x*x + y*y + z*z)
return (0.0,0.0,0.0) if m==0 else (x/m, y/m, z/m)
def dot(a,b):
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
def parse_strike(s):
# “x1,y1,z1 x2,y2,z2”
try:
a,b = s.strip().split()
x1,y1,z1 = map(float, a.split(‘,’))
x2,y2,z2 = map(float, b.split(‘,’))
return (x1,y1,z1), (x2,y2,z2)
except Exception as e:
raise ValueError(“Invalid –strike format. Use \”x1,y1,z1 x2,y2,z2\””) from e
def compute_chain_if_needed(rows, strike_points):
# Add CHAIN if absent
have_chain = ‘CHAIN’ in rows[0]
if have_chain:
return rows
if strike_points is None:
raise ValueError(“CHAIN not found in input and no –strike provided. Either include CHAIN in CSV or pass –strike.”)
p1, p2 = strike_points
vx = (p2[0]-p1[0], p2[1]-p1[1], p2[2]-p1[2])
v = norm(vx)
for r in rows:
# Need X,Y,Z in the input to project
try:
X = float(r.get(‘X’) or r.get(‘x’))
Y = float(r.get(‘Y’) or r.get(‘y’))
Z = float(r.get(‘Z’) or r.get(‘z’))
except Exception:
raise ValueError(“To compute CHAIN, input needs X,Y,Z columns (case-insensitive).”)
dx,dy,dz = X – p1[0], Y – p1[1], Z – p1[2]
r[‘CHAIN’] = dot((dx,dy,dz), v)
return rows
def read_csv(path):
with open(path, newline=”, encoding=’utf-8-sig’) as f:
rdr = csv.DictReader(f)
rows = [ {k.strip().upper(): v for k,v in row.items()} for row in rdr ]
# Normalize keys back to Title-case names we use internally
norm_rows = []
for r in rows:
nr = {}
for k,v in r.items():
kk = k.upper()
if kk in (‘ID’,’GUID’,’NAME’,’VEIN’,’LEVEL’,’AU_OZ’,’CHAIN’,’X’,’Y’,’Z’):
nr[kk] = v
# Alias NAME/GUID to ID
if ‘ID’ not in nr:
if ‘GUID’ in nr and nr[‘GUID’]:
nr[‘ID’] = nr[‘GUID’]
elif ‘NAME’ in nr and nr[‘NAME’]:
nr[‘ID’] = nr[‘NAME’]
norm_rows.append(nr)
return norm_rows
def write_csv(path, rows, header_order=None):
if header_order is None:
# default: original-order-ish
header_order = [‘ID’,’VEIN’,’LEVEL’,’AU_OZ’,’CHAIN’,’IS_PILLAR’,’STOPE_ID’,’STOPE_LEN_M’]
# include any pass-through columns
keys = set()
for r in rows:
keys.update(r.keys())
cols = []
for c in header_order:
if c in keys:
cols.append(c)
for k in sorted(keys):
if k not in cols:
cols.append(k)
with open(path, ‘w’, newline=”, encoding=’utf-8′) as f:
w = csv.DictWriter(f, fieldnames=cols)
w.writeheader()
for r in rows:
w.writerow(r)
def to_float(v, default=0.0):
try:
if v is None or v == ”:
return default
return float(v)
except:
return default
def optimize_chain(items):
“””
items: list of dicts with keys: ID, AU_OZ, (and we keep all fields)
Must be in ascending CHAIN.
Returns items with added: IS_PILLAR (0/1), STOPE_ID, STOPE_LEN_M
“””
n = len(items)
gold = [to_float(it.get(‘AU_OZ’), 0.0) for it in items]
dp = [0.0]*(n+1)
choice = [1]*n
def sum_range(a, b):
return sum(gold[a:b+1]) if b >= a else 0.0
for i in range(n-1, -1, -1):
best = -1e30
bestL = 1
for L in range(1, MAX_STOPE_SOLIDS+1):
if i+L <= n:
sL = sum_range(i, i+L-1)
nexti = i+L
if nexti < n:
nexti += 1 # one pillar
cand = sL + dp[nexti]
if cand > best:
best = cand
bestL = L
dp[i] = best
choice[i] = bestL
# Walk choices
i = 0
stope_idx = 1
while i < n:
L = choice[i]
stope_id = f”S{stope_idx:04d}”
len_m = L * SOLID_LEN_M
# mark stope members
for j in range(i, i+L):
items[j][‘IS_PILLAR’] = 0
items[j][‘STOPE_ID’] = stope_id
items[j][‘STOPE_LEN_M’] = f”{len_m:.2f}”
stope_idx += 1
i += L
# pillar
if i < n:
items[i][‘IS_PILLAR’] = 1
items[i][‘STOPE_ID’] = ”
items[i][‘STOPE_LEN_M’] = “0”
i += 1
return items
def main():
args = parse_args()
rows = read_csv(args.in_csv)
# Check required fields
for req in (‘ID’,’VEIN’,’LEVEL’,’AU_OZ’):
if req not in rows[0]:
raise SystemExit(f”Input missing required column: {req}”)
strike_points = None
if args.strike:
strike_points = parse_strike(args.strike)
rows = compute_chain_if_needed(rows, strike_points)
# Group by VEIN+LEVEL
groups = defaultdict(list)
for r in rows:
groups[(r[‘VEIN’], r[‘LEVEL’])].append(r)
# Sort and optimize each group
out_rows = []
for (vein, level), items in groups.items():
items.sort(key=lambda r: to_float(r.get(‘CHAIN’), 0.0))
optimized = optimize_chain(items)
out_rows.extend(optimized)
write_csv(args.out_csv, out_rows)
if __name__ == ‘__main__’:
main()
Essentially here’s the workflow:
- Export design solids into CSV from Deswik preserving key attributes (Name, Level, Vein, AU_Oz, X, Y, Z, Chain).
- Run the Python optimizer code which then spits out a StopePlan.csv file indicating pillars by setting Is_Pillar to True, calculating stope lengths in Stope_Len_M, assigning a stope name with Stope_ID attribute.
- Import the StopePlan.csv file into Deswik as attributes using the Name attribute as a primary key to match existing solids in the design file.
- Check grouping results.
- Merge stopes in Deswik using attribute.
Solution in Action
With the workflow figured out, I went back to solve the challenge. Here is a step-by-step video of how we ran the solution suggested by ChatGPT.
This is a screenshot of the solution with red representing pillars.

Prove Optimum Results Are Presented
Impressed by its speed to churn out the results, I was skeptical if the results returned were truly optimal as any program can spit out any combination of stopes and pillars. I then pressed ChatGPT to provide proof.
Why the result is optimal (proof sketch)
Treat the ordered solids in a vein+level chain as indices
i = 0…n−1, each 4 m long with ouncesgold[i]. A “stope” is a block of lengthL∈{1,…,5}and every stope (except possibly the last) must be followed by exactly one skipped solid (pillar).Define the value function:
dp[i]= maximum ounces recoverable from subchaini…n−1.Bellman recursion (Principle of Optimality):
dp[n] = 0 dp[i] = max over L=1..5 and i+L ≤ n of ( sum(gold[i..i+L-1]) + dp[next] ) where next = i+L if i+L = n (no pillar needed at the end) next = i+L+1 otherwise (skip one 4 m pillar)Optimal substructure: If an optimal plan from
istarts with a length-L*stope, then what follows must be optimal for the subproblem starting atnext. If it weren’t, swapping it for a better subsolution would improve the total, contradicting optimality.Greedy choices don’t work (local best-ounces stope may force bad pillars later), but the DP explores all feasible first choices and commits to the one whose immediate ounces + optimal future return is maximum. By induction on
(n−i), the recursion yields the global optimum. Therefore the complement (total ounces −dp[0]) is the minimum possible pillar ounces.
ChatGPT then moves on to suggest a few ways to verify the data:
- Value summation check: total pillar oz + total pillar oz = total original oz.
- Brute force spot check: pick a few areas of continuous solids and exhaust all possible combinations manually.
- Add an audit trail in the Python code.
- Formulae an integer linear programming problem and solve it with an MIP solver.
Mathematical Representation of the Challenge
Given an ordered chain of n 4-m solids with ounces gold[0..n-1].
Decision variables (binary):
z_{i,L} = 1if a stope of lengthL ∈ {1,…,5}starts at indexi(valid only ifi+L ≤ n).
Objective:

Coverage (no overlap):

Pillar spacing (≥1 pillar after each stope unless at the end):

PuLP library in Python is designed to solve linear programming problems like this.
Final Thoughts
I was very impressed by ChatGPT’s understanding of Deswik content, but most importantly, my frustration. I set out to solve the issue with something I feel more comfortable with, Deswik macros. However, ChatGPT suggested a much more efficient method with ready-to-use library to solve this optimization challenge. The solution literally took seconds to run and so far the results seem optimal to me.
However, ChatGPT really stumbles on Deswik macro. I think this is because there’s not a whole lot of public knowledge in this regard readily available. I remember some years ago I specifically asked for a reference document outlining their available methods, properties, etc. While I super appreciate the help from Deswik staff then for sharing a doc with me, the knowledge is not publicly available for ChatGPT’s training.
Lastly, I wanted to share this process with anyone in the mining engineering field interested in design optimization: one doesn’t need programming experience to solve this type of optimization challenges – as long as you’re willing to step out of your comfort zone and learn with the help of AI.

Leave a Reply