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?

Challenge: Combine stopes to be 20m or less with pillars in between. Minimize oz loss in 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:

  1. the algorithm (so it’s clear and auditable),
  2. a Deswik Macro (pseudo-VB style that maps 1:1 to common Deswik macro commands and attribute ops), and
  3. 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:

  1. how well ChatGPT understood the context and even adds a strike vector or Chain attribute to control direction of grouping.
  2. 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 pillarsmaximize gold in stopes.

Dynamic programming on an ordered list of solids (index i from 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] = 0

Recover 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 StopeId to 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:

  1. Export design solids into CSV from Deswik preserving key attributes (Name, Level, Vein, AU_Oz, X, Y, Z, Chain).
  2. 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.
  3. 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.
  4. Check grouping results.
  5. 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.

Optimum solution in Deswik 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 ounces gold[i]. A “stope” is a block of length L∈{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 subchain i…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 i starts with a length-L* stope, then what follows must be optimal for the subproblem starting at next. 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:

  1. Value summation check: total pillar oz + total pillar oz = total original oz.
  2. Brute force spot check: pick a few areas of continuous solids and exhaust all possible combinations manually.
  3. Add an audit trail in the Python code.
  4. 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} = 1 if a stope of length L ∈ {1,…,5} starts at index i (valid only if i+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

Your email address will not be published. Required fields are marked *