How the blind scheduler plans, and what planning one period at a time showed
Today in short
- The blind scheduler works in a loop. Plan the whole future, do only the first step, look again, re-plan.
- Every re-plan starts from an exact snapshot of the mine, so nothing already done is counted twice or forgotten.
- A simple check confirms this. The plan’s total value stays fixed between steps and only jumps when digging reveals new ore.
- One gap found. When deciding whether the 20 t minimum can be met, the blind code counts rock it can see rather than rock it can reach. The partial code already fixes this. Blind should get the same fix so the two are compared fairly.
- Why one period at a time. Applying the minimum to every future period made partial ration its stripping. My supervisor preferred not to change the rule, so we added a new baseline instead, full information planned one period at a time.
- On the lens layout, that baseline loses only 3 to 4% against full planning. A good shaft with planning ahead (10,167) beats knowing everything without it (9,792).
- Decision. The main method goes back to planning over the full horizon, since that’s what the MILP is built for and one period at a time adds complexity on larger instances.
Today was mostly about making sure I understand exactly what my code does before running more tests. This post explains the blind scheduler using one real run.
The example mine
A small vertical slice, 7 blocks wide and 3 layers deep (blocks 1 to 21), over 6 periods, with a mill that can process 60 t per period. Each number is the block’s grade.
| Layer | |||||||
|---|---|---|---|---|---|---|---|
| 0 (surface) | 1 · 0.15 | 2 · 0.16 | 3 · 0.70 | 4 · 0 | 5 · 0.46 | 6 · 0 | 7 · 0.83 |
| 1 | 8 · 0.56 | 9 · 0.91 | 10 · 0.35 | 11 · 0.62 | 12 · 0.41 | 13 · 0.28 | 14 · 0.54 |
| 2 | 15 · 0.12 | 16 · 0.39 | 17 · 0.71 | 18 · 0.52 | 19 · 0.38 | 20 · 1.00 | 21 · 0.52 |
At the start, the blind scheduler sees only the top row. A block below becomes visible only once the block directly above it is fully dug out.
The loop. Plan everything, do one thing, look again
Think of a site manager with a map that only shows what’s been uncovered so far. Every time something changes, they redraw the full plan for the rest of the job, but they only carry out the very first item on it. Then they look at the mine again and redraw.
Each turn of the loop has six steps.
- Ask the mine what’s allowed right now. The simulator lists every legal move, taking into account which blocks are uncovered, how much capacity is left this period, and the ore grade limits at the mill.
- Re-plan if anything changed. A fresh optimisation model is built using only the visible blocks, from the current period to the end. Hidden blocks simply don’t exist in the model, so anything under them is off limits.
- Pick one move from the plan. From this period’s part of the plan, it takes one action, digging before processing and bigger amounts first, and trims it to what the mine allows.
- Carry it out. The move goes through the same simulator code that the greedy and LLM schedulers use, so every method is scored the same way.
- Reveal. If a block is now fully dug, the block below it becomes visible.
- End the period when there’s nothing left to do. When the plan has nothing more for this period, the clock moves on. Periods end because the plan runs out, not because of a counter.
Technical detail. Where each step lives in the code
- Step 1 is
scheduler.possible_actions(), which callsMine.action_list(t). This is the ground truth on precedence, capacity and grade blending. - Step 2 runs when
need_solveis true (start of each period, after every action, after every reveal).build_and_solve(mine, visible, period)builds a new Gurobi model over periodst0..T. Only the periodt0part of the solution is kept, as{(1, block): tonnes, (2, block): tonnes}. - Step 3 is
pick_action. It sorts extraction before processing and larger tonnage first, matches againstoffered, and clips to the offered limits, preferring an amount that already fits. - Step 4 is
apply_action, which callsscheduler.action(...), updatesgrade_blendand addscalculate_NPVtomine.npv. - Step 5 is
reveal_new_blocks. A visible block withmine_extraction >= 1adds the blockBELOWit tovisible. - Step 6. If
pick_actionreturns None,time_tick()runs andneed_solveis set again.
What that looked like in the real run
| Step | Period | What it did | Why |
|---|---|---|---|
| 1 | 1 | Dug 39.63 t of block 7 | It sees only the surface, and block 7 (0.83) is the richest |
| 2 | 1 | Dug 30.37 t of block 3 | Fills the rest of the 70 t digging limit with block 3 (0.70) |
| 3 | 1 | Processed 32.50 t of block 7 | Only the dug part is ready. 39.63/50 of its 41 t of ore is 32.50 t |
| 4 | 1 | Processed 21.26 t of block 3 | Uses the remaining mill capacity while keeping the grade mix within limits |
| Nothing left planned | Move to period 2 | ||
| 5 | 2 | Dug all 50 t of block 5 | Fully dug, so block 12 below it is revealed |
| 6 | 2 | Finished block 3 (19.63 t) | Block 10 is revealed |
| … | |||
| 27 | 6 | Processed 10.8 t of block 14 | Last action. Final NPV 11,914.53 |
27 actions took 27 re-plans, one per action. Each took about 0.01 seconds, so re-planning every time is cheap at this size.
The first plan expected to earn only 7,569 in total, because it could only see the surface. The run finished at 11,915 because every reveal showed ore it didn’t know about, and it re-planned around it. Full information reached 12,029, so blind lost under 1%. Greedy reached 11,286.
Two design details
Why digging comes before processing. The mine only offers a processing move once ore has been dug. If it processed first, there would be less ore ready than the plan assumed, and the move would get cut short.
Why there’s a small “reveal bonus”. Block 7 was only partly dug after period 2 and finished in period 3, so block 14 underneath stayed hidden until then. When several visible blocks are equally valuable, the model has no reason to finish any one of them, and it can chip at several without uncovering anything. A tiny bonus for fully clearing a block that sits above a hidden one breaks those ties towards finishing blocks early. The bonus is not counted in the NPV.
How each re-plan stays consistent with the past
Each re-plan starts from a snapshot of the mine. Past periods are dropped entirely, because their money is already banked. Only the current period and the future are planned. The snapshot has five parts. As an example, take the 4th re-plan, in period 1, right after steps 1 to 3 above.
- How much of each block is already dug and processed. Block 7 is 79.26% dug and 79.26% processed. Block 3 is 60.74% dug and 0% processed. The plan can only touch what’s left.
- Capacity already used this period. 70 t has been dug in period 1, which is the full limit, so the plan can’t dig anything more this period. The mill has used 32.50 t of its 60 t, leaving 27.50 t.
- The grade mix already at the mill this period. The 32.50 t of 0.83-grade ore puts the mill comfortably above its 0.4 minimum average grade. That leaves room to blend in up to about 55.9 t of low-grade (0.15) ore this period and still stay above 0.4. Without remembering this, the plan could pick a mix that breaks the limit once combined with what’s already been processed.
- Only process what’s been dug. Block 7 can’t be dug further this period, so none of it can be processed further either. That’s why the 4th re-plan processes block 3 instead. Exactly 21.26 t of it was dug and ready, and that’s what step 4 processed.
- Money is always counted in today’s terms. Discounting uses the real period number, so every plan’s value is in the same units as the money already banked.
Technical detail. The five frozen pieces in code
Dug and processed fractions. Computed from tonnes remaining, not from the stored fractions, because Mine.update rounds mine_process to 4 decimals.
e = {n: 1.0 if mine.mine_extraction[n] >= 1.0 else 1 - mine.To_be_extracted[n] / tt[n] for n in blocks}
p = {n: 1.0 if (o[n] == 0 or mine.mine_process[n] >= 1.0) else 1 - mine.To_be_processes[n] / o[n] for n in blocks}
| Block | Tonnage | Ore (int(g×50)) |
e (dug) |
p (processed) |
|---|---|---|---|---|
| 7 (0.83) | 50 | 41 | 1 − 10.37/50 = 0.7926 | 1 − 8.50/41 = 0.7926 |
| 3 (0.70) | 50 | 35 | 30.37/50 = 0.6074 | 0 |
These set the variable bounds (y[7,t] ≤ 0.2074, x[7,t] ≤ 0.2074), the at-most-once totals, and the cumulative mined_by, which is what lets precedence and process-after-mine see earlier work.
def mined_by(n, t):
if n not in visible:
return mine.mine_extraction[n] # hidden: a constant (0)
return e[n] + gp.quicksum(y[n, j] for j in range(t0, t + 1))
Capacity used. used = mine.Mining_capacity_used[t] if t == t0 else 0.0, so the constraint becomes 70 + Σ 50·y[n,1] ≤ 70, which forces every y[n,1] = 0.
Grade blend. The head-grade limit is linear, written as tonnes × (grade − limit).
done_ub = sum((g - G_ub[t]) * amt for amt, g in blend) # (0.83 − 1.0) × 32.50 = −5.52
done_lb = sum((g - G_lb[t]) * amt for amt, g in blend) # (0.83 − 0.4) × 32.50 = +13.97
13.97 / (0.4 − 0.15) ≈ 55.9 t of 0.15-grade ore can still be blended in.
Process after mine. done = min(p[n], e[n]), then done + Σ x[n,j] ≤ mined_by(n,t). For block 7 this reads 0.7926 + x[7,1] ≤ 0.7926 + y[7,1], and since y[7,1] = 0, x[7,1] = 0. The min(p, e) absorbs tiny rounding.
Discounting. (vt[n] * x[n, t] - q[n] * y[n, t]) / (1 + r) ** t, where t is the real period, not t − t0.
Rebuilt each solve. The binaries b (started), u (fully mined) and l (minimum active) are new each time, but the frozen values force them to agree with the past. For block 3, mined_by ≥ 0.6074 > 0 forces b[3,t] = 1.
The check that proves it works
If the snapshot is right, then money already banked plus the money the new plan expects should stay exactly the same from one re-plan to the next, as long as nothing new is revealed. Doing a step just moves value from “planned” to “banked”. It should only jump when a reveal shows the model new ore.
| Re-plan | Period | Banked | + Planned future | = Total | What happened |
|---|---|---|---|---|---|
| 1 | 1 | 0.00 | 7,569.30 | 7,569.30 | Start |
| 2 | 1 | −360.29 | 7,929.59 | 7,569.30 | |
| 3 | 1 | −636.37 | 8,205.67 | 7,569.30 | |
| 4 | 1 | 2,317.99 | 5,251.30 | 7,569.29 | |
| 5 | 2 | 4,250.52 | 3,318.77 | 7,569.29 | New period, still the same |
| 6 | 2 | 3,837.30 | 4,014.16 | 7,851.46 | ↑ Block 12 revealed |
| 7 | 2 | 3,675.05 | 4,835.98 | 8,511.03 | ↑ Block 10 revealed |
| 8 to 10 | 2 | … | … | 8,511.04 | Same again |
That’s exactly what happens. If any part of the snapshot were wrong, the total would drift between steps even without a reveal. This makes a good automatic test, and a good figure for the thesis.
A gap found. “Can see” isn’t the same as “can reach”
My mining rule says the mine should dig at least 20 t per period, as long as enough rock is available. Before applying that rule, the code checks how much rock is left.
In the blind code, that check counts every visible block that isn’t dug yet. But a block can be visible without being reachable yet. In this mine, block 12 becomes visible as soon as block 5 above it is cleared. It still can’t be dug until its neighbours 4 and 6 are also cleared, because of the pit slope.
Here’s how that can go wrong. Say the check finds
- 10 t of rock it can actually reach, and
- 50 t of rock it can see but can’t reach yet.
It concludes 60 t is available, so it insists on the 20 t minimum. Only 10 t is really reachable, so the plan becomes impossible to satisfy. The code’s fallback then drops the minimum for that whole re-plan, which means the mine might dig less than 20 t that period.
The partial code already fixes this. It counts only rock that is currently reachable. The blind code still uses the old count.
Has it affected any results so far? No. I checked all 15 saved blind runs, and none of them ever had to drop the minimum. The 20 t default is low enough that the problem never showed up. It could show up with a higher minimum (for example 100 t), or on wider mines where more blocks have to be cleared to reach the ones underneath.
Why fix it anyway. Blind and partial should use the same rule for deciding whether the minimum can be met. Otherwise a difference between them could come from this code detail instead of from the drilling information, which is what I’m actually trying to measure.
Technical detail. The line in question
In rolling_milp.py the minimum’s “remaining rock” sums over all visible blocks.
remaining = used + gp.quicksum(tt[n] * (1 - e[n] - ...) for n in blocks)
rolling_milp_partial.py restricts this to a reachable set. The fix is to port that into rolling_milp.py and run_configs.py.
Why we ended up planning one period at a time
This took several steps, each one a response to something odd in the results.
1. The minimum rule had already caused one problem. On the 10 × 10 lens with the 10 → 91 shaft, partial sat idle for 5 periods. The minimum was counting deep drilled blocks the plan couldn’t reach yet, so every plan was impossible and the minimum got dropped. That was fixed by counting only reachable rock (the same gap described above for blind).
2. Then the 4 × 5 × 4 lens showed a subtler problem. At realistic capacities, with 10 t blocks, the shafts worked, but the 20 t minimum made partial worse, not better.
| Shallow shaft | No minimum | 20 t minimum |
|---|---|---|
| Partial | 10,167 | 8,151 |
| Blind | 1,273 | 5,861 |
Partial knew where the ore was and the whole surface was open, yet it dug only 20 t in period 1. Why?
3. The first plan showed the reason. With the minimum, the plan spread the stripping out at exactly 20 t per period, reaching ore block 26 in period 4 and block 31 in period 7, instead of periods 2 and 3. Each plan applied the 20 t minimum to every future period too, and in those future periods the plan could only see waste. It believed “I’ll have to mine 20 t of waste every period anyway”, so it saved the stripping to cover those future minimums instead of stripping fast. It couldn’t know that mining would uncover more ore and fill those periods.
4. That led to the idea of applying the minimum only to the current period. Each plan enforces the minimum only for the period being carried out now. Every period still meets it when its turn comes, but the plan no longer assumes a future of forced waste. On the lens layout, partial went from 8,489 to 10,221 and blind from 5,861 to 8,489.
5. My supervisor felt this approach might not be feasible. So it stays in the code as a switch, CURRENT_PERIOD_MINIMUM_ONLY, but it is now off by default, which keeps my original method. Instead, we followed the supervisor’s suggestion and added a new baseline. Full information, solved one period at a time. Its plan only ever contains the current period, so it can’t have the future-minimum problem. It also shows how much planning ahead is worth when every grade is known.
An everyday version of the problem
The situation. You’re a freelancer, and your contract says you must bill at least 2 hours every day.
- On your desk is a pile of unpaid admin work (the waste), 8 hours of it.
- A friend tells you there’s a well-paid project waiting behind that admin work. You can only start it once the admin is cleared. This is the shaft showing ore under the waste.
- What you don’t know yet is that finishing that project will lead the client to send you more paid projects. This is the rest of the ore lens, revealed as you mine.
Every morning you plan the rest of the week, using only the work you can see right now.
Old rule. Minimum in every planned day. Each morning you plan so that every day this week has at least 2 hours of work. Looking at your desk, the only work you can see for later days is the admin pile. So you think
“If I clear all 8 hours of admin today and tomorrow, I’ll have nothing left for Thursday and Friday, and then I’d need to find more unpaid filler to meet my 2-hour minimum. Better to stretch the admin out, 2 hours a day, so it covers every day.”
So you do 2 hours of admin a day, and the paid project doesn’t start until the end of the week. In reality, the moment you’d finished the admin and started the paid project, new paid work would have arrived to fill Thursday and Friday. You delayed the money to protect against an empty week that was never going to happen.
New rule. Minimum only for today. Each morning you only make sure today has at least 2 hours. Future days are planned freely.
“Admin today and tomorrow at full speed, then start the paid project.”
You clear the admin fast and start earning early. When Thursday comes, you plan again, and by then the new paid work is there, so the minimum is easily met.
Both ways, you work at least 2 hours every single day. The only difference is whether your plan assumes the future holds nothing but admin.
| Freelancer | The mine |
|---|---|
| Must bill at least 2 hours every day | Must mine at least 20 t every period |
| Unpaid admin pile | Waste blocks on the surface |
| Paid project behind the admin | Ore block found by the shaft (26, 30 or 31) |
| New paid work that appears later | Rest of the ore lens, revealed as mining goes |
| Planning each morning from what’s on the desk | Rolling MILP re-planning from visible blocks |
| Stretching admin out to cover every day | Stripping 2 blocks (20 t) per period, ore reached in periods 4 and 7 |
| Clearing admin fast, re-planning daily | Stripping at full capacity, ore in periods 2 and 3 |
The old rule made the planner treat its blind spot (future ore it couldn’t see) as if it were certain emptiness. The new rule lets it plan only today’s commitment and leave the future open for when it knows more.
The new baseline. Full information, one period at a time
Lens layout (4 × 5 × 4), with the switch off, so partial uses the original method and matches the earlier runs exactly.
| NPV | Blind | Diagonal | Shallow | Vertical | Full, one period at a time | Full, planned all at once | Greedy |
|---|---|---|---|---|---|---|---|
| No minimum | 1,273 | 10,167 | 10,167 | 1,273 | 9,792 | 10,223 | 10,174 |
| 20 t minimum | 5,861 | 8,489 | 8,151 | 6,677 | 9,907 | 10,223 | 10,174 |
1. Planning one period at a time costs full information only about 3 to 4% here. Knowing every grade but planning only the current period gives 9,792 to 9,907, against 10,223 when the whole schedule is planned at once. On this layout, with 10 t blocks and 70 t of digging per period, the plan can often strip waste and dig the ore underneath in the same period, so not looking ahead costs little. On the Decoy layout it was very different, about 29%.
2. With no minimum, a good shaft plus planning ahead beats knowing everything but not planning ahead. Diagonal and shallow reach 10,167, above full information planned one period at a time (9,792). A cheap shaft is worth more than perfect knowledge if the planner can’t look ahead. Planning ahead matters as much as the information.
3. With the 20 t minimum, partial falls below full one-period (8,489 and 8,151 against 9,907). This is the rationing effect from step 3 above. Full one-period can’t have it, because its plan only ever contains one period. So the gap shows what that rationing costs partial, about 1,400 to 1,750.
4. Full one-period does better with the minimum than without it (9,907 against 9,792). Without a minimum, a one-period planner sometimes skips stripping that only pays off next period, because it can’t see next period. The minimum forces it to dig anyway, which helps. That’s the same reason blind improves with the minimum.
5. The shafts behave as before. Diagonal and shallow pay well in both settings. Vertical pays only with the minimum (+816 against a cost of 46) and is worthless without it.
Where the value comes from
With no minimum, the gap from blind to full information splits into two parts.
| Step | NPV | Added |
|---|---|---|
| Blind | 1,273 | |
| Know every grade, but plan one period at a time | 9,792 | +8,519 |
| Also plan the whole schedule ahead | 10,223 | +431 |
On this layout almost all the value comes from the information. On Decoy it was the other way round, with look-ahead worth far more. That contrast is worth showing my supervisor.
Decision. Back to planning over the full horizon
After looking at these results, I’m going back to giving the MILP the full planning horizon, all remaining periods at once, as the main method.
- The MILP is built to work on the full horizon. Its strength is weighing what happens now against what happens later. Cutting it down to one period at a time throws that away, and these results show look-ahead can be worth a lot (about 29% on Decoy).
- One period at a time doesn’t scale. On larger instances it means many more separate solves and extra bookkeeping between them, which increases the complexity of the method instead of reducing it.
So full information planned one period at a time stays as a comparison on these small cases, to show how much of the value comes from the information and how much from planning ahead. The main results use the full-horizon MILP.
What this means for the thesis
- The current-period-only rule is off. All main results use the original method, where each plan applies the minimum to every period.
- Partial’s shortfall under the minimum (8,151 to 8,489 against 9,907) is a known, explained effect of that method. It can be described in the thesis, or fixed later if my supervisor agrees.
- The main method plans over the full horizon. Full information planned one period at a time is used only as a comparison, to separate the value of knowing the grades from the value of planning ahead.
For reference. What the current-period switch did when it was on
| Mine | Blind, old rule | Blind, current period only | Partial, old rule | Partial, current period only |
|---|---|---|---|---|
| Lens (4 × 5 × 4) | 5,861 | 8,489 | 8,489 | 10,221 |
| Decoy | 8,416 | 8,105 | 10,248 | 10,271 |
| Dipping orebody | 13,668 | 13,408 |
With the switch on, the lens schedule dug 70 t in every period, so the 20 t minimum was still met. The switch also changed blind, sometimes for the worse, which is another reason to keep it off for the main results. Each saved result records the setting as current_period_minimum_only.
Next
Tomorrow
- A new mine shape. Test a convex block model instead of a cube, so the pit narrows with depth the way a real open pit does.
- Sim + LLM on the shaft approach. Run the simulator with the LLM scheduler on the partial (drilled shaft) setting, and compare it with the MILP results.
Also on the list
- Port the reachability fix into the blind code (
rolling_milp.py,run_configs.py). - Re-run the saved blind runs and confirm the results don’t change.
- Test cases where it could matter, such as a 100 t minimum and a wider mine.
- Turn the “banked + planned stays constant” check into an automatic test.
- Keep the full-horizon MILP as the main method for all batch runs.
- Run full information one period at a time on the small batch instances only (Decoy, Dipping orebody and the rest), as a comparison, and build the “information vs planning ahead” split for each.
- Show my supervisor the lens vs Decoy contrast and agree whether to address partial’s rationing under the minimum.
- Label every results table with the minimum rule it used.