# Synthetic Mine Throughput Simulation (Benchmark 001)
A genuine **discrete-event simulation in SimPy** of an 8-hour ore-haulage
shift in a synthetic open-pit mine. It estimates ore throughput to the primary
crusher and answers six operational decision questions about fleet size, the
narrow ramp, and crusher service time.
Headline result: under the baseline 8-truck configuration the mine delivers
**≈ 12,547 t/shift (1,568 t/h), 95% CI [12,491, 12,602] t**, and the system is
**crusher-bound** — not ramp-bound.

---
## 1. Installation
Python 3.11+ (developed and tested on 3.13). From this submission folder:
```bash
pip install -r requirements.txt
```
Dependencies (all from the allowed list): `simpy`, `numpy`, `pandas`,
`scipy`, `matplotlib`, `networkx`, `PyYAML`. `pytest` is needed only for the
test suite. Pillow ships transitively with matplotlib for the GIF writer.
The code is a package under `src/mine_sim/`. Either install it
(`pip install -e .`) or prefix commands with `PYTHONPATH=src`.
---
## 2. Running the simulation
```bash
# Produce all deliverables (7 scenarios × 30 replications) at the folder root:
PYTHONPATH=src python -m mine_sim run-all
# One scenario (quick smoke test):
PYTHONPATH=src python -m mine_sim run baseline --reps 5
# List available scenarios:
PYTHONPATH=src python -m mine_sim list
# Render topology.png + animation.gif from the event log:
PYTHONPATH=src python -m mine_sim render
```
`run-all` writes the three machine-readable artefacts — `results.csv`,
`event_log.csv`, `summary.json` — directly to the submission root.
Useful flags: `--reps N` (override replication count), `--output-dir DIR`,
`--event-log-scope {first,all}`.
### Reproducing the required scenario results
```bash
PYTHONPATH=src python -m mine_sim run-all # 30 reps each, the canonical run
PYTHONPATH=src python -m pytest -q # 78 focused tests
```
Reproducibility is exact: the per-replication seed is
`base_random_seed (12345) + replication_index`, drawn through independent
numpy `SeedSequence` streams, so any `(scenario, replication)` reproduces
bit-for-bit regardless of run order. `event_log.csv` defaults to
`--event-log-scope first` (replication 0 of each scenario) to stay small and
inspectable; **all 30 replications feed the metrics and confidence intervals**.
---
## 3. Conceptual model (summary)
Full detail is in [`conceptual_model.md`](conceptual_model.md). In brief:
- **Entities:** trucks (one SimPy process each); ore payload credited at dump.
- **Resources (capacity-1):** loaders `L_N`/`L_S`, crusher `D_CRUSH`, and the
8 single-lane road segments (ramp `E03_*`, crusher approach `E05_*`,
pit-face roads `E07_*`/`E09_*`).
- **Cycle:** `PARK → choose loader → travel empty → load → travel loaded →
dump → repeat`, with a hard stop at t = 480 min (`env.run(until=480)`).
- **Boundary:** ore only. Waste haulage, the waste dump, and the maintenance
bay are out of scope (they stay in the graph but carry no traffic).
---
## 4. Main assumptions
- **Hard shift cut at 480 min:** only dumps that *close* before the cut count.
- **Stochasticity:** per-edge lognormal travel multiplier (mean 1, cv = 0.10);
truncated-normal load/dump times floored at `max(0.1, sample)`.
- **Homogeneous fleet:** 100 t trucks, empty/loaded speed factors 1.00 / 0.85,
`availability = 1.0` (no breakdowns), all released at t = 0 from `PARK`.
- **Crusher always ready** (no downstream stockpile back-pressure).
- **Each ramp direction is its own single lane** (`E03_UP` ≠ `E03_DOWN`).
- **95% CIs:** Student-t with 29 degrees of freedom over 30 replications.
The full split of *data-derived* vs *introduced* assumptions and limitations
is in `conceptual_model.md` §6.
---
## 5. Routing and dispatching logic
- **Routing — static shortest-*time*.** One Dijkstra pass per scenario on
free-flow edge times (`distance_m / (max_speed_kph × 1000 / 60)`),
recomputed when a scenario closes or upgrades edges. A truck commits to its
path at dispatch and does not re-plan mid-leg. If any required origin-
destination pair is unreachable, the model **fails loudly** at scenario load
(a `ReachabilityError`) rather than producing misleading numbers.
- **Dispatching — dynamic nearest-available loader.** Each empty truck is sent
to the loader minimising
```
travel_to_loader + queue_len × mean_load_time + own_mean_load
```
where `queue_len` counts trucks in service plus waiting. Ties break by lower
`loader_id`. So the route is static, but the *loader choice* responds to live
queues — a truck will pick the further, idle face over the nearer, busy one.
### The asymmetric-ramp finding (important)
Running Dijkstra on the real graph shows that in the **baseline (ramp open)**:
- The shortest-time `PARK → LOAD_N` path **already uses the western bypass**
(J2 → J7 → J5), *not* the ramp.
- **Both loaded `LOAD_* → CRUSH` legs descend via E04 / E12 and never touch the
ramp.**
- The ramp (`E03_UP`) sits only on `PARK → LOAD_S` (the first empty leg) and
`E03_DOWN` only on `CRUSH → PARK` (the end-of-shift return).
So the ramp carries very little ore-cycle traffic. This is why upgrading or
closing it has only a small, asymmetric effect — and why the model's behaviour
is the *opposite* of the naïve "narrow ramp = main bottleneck" intuition. When
the ramp is closed, the bypass keeps every face reachable
(`PARK → LOAD_S` reroutes J2 → J7 → J8 → J6; `CRUSH → PARK` via J4 → J8 → J7
→ J2).
---
## 6. Key results
30 replications per scenario, 8-hour shift. Full numbers and CIs in
`summary.json`; per-replication rows in `results.csv`.
| Scenario | Trucks | Tonnes / shift | t / h | Crusher util | Crusher queue (min) |
|---|---:|---:|---:|---:|---:|
| `trucks_4` | 4 | 7,650 | 956 | 0.56 | 0.7 |
| **`baseline`** | **8** | **12,547** | **1,568** | **0.91** | **3.3** |
| `trucks_12` | 12 | 12,907 | 1,613 | 0.94 | 14.2 |
| `ramp_upgrade` | 8 | 12,607 | 1,576 | 0.92 | 3.3 |
| `crusher_slowdown` | 8 | 6,513 | 814 | 0.95 | 26.6 |
| `ramp_closed` | 8 | 12,363 | 1,545 | 0.90 | 3.2 |
| `trucks_12_ramp_upgrade` | 12 | 12,953 | 1,619 | 0.94 | 14.3 |
Baseline 95% CIs: total tonnes **[12,491, 12,602]**, t/h **[1,561, 1,575]**.
---
## 7. Answers to the operational decision questions
1. **Expected baseline throughput?**
≈ **12,547 t/shift (1,568 t/h)**, 95% CI [12,491, 12,602] t. Truck
utilisation is ~99% and crusher utilisation ~91%.
2. **Likely bottlenecks?**
The **primary crusher (`D_CRUSH`)** is the dominant constraint (utilisation
0.91, composite score ≈ 2.99), then loader **`L_S`** (0.80) and **`L_N`**
(0.60). The narrow ramp is **not** a system bottleneck — although `E03_UP`
shows a long *per-traversal* wait (~11 min) on the rare occasions it is
used, its utilisation is only ~5%, so it does not gate throughput.
3. **Do more trucks materially help, or does the system saturate?**
It **saturates**. Going 4 → 8 trucks adds **+4,897 t** (+64%), but 8 → 12
adds only **+360 t** (+2.9%) while the crusher queue more than quadruples
(3.3 → 14.2 min). Beyond ~8 trucks the crusher is the ceiling and extra
trucks mostly wait.
4. **Would improving the narrow ramp materially help?**
**No** — `ramp_upgrade` lifts throughput by only ~**+60 t (+0.5%)**, within
noise of the baseline. The loaded legs to the crusher never use the ramp, so
speeding it up barely matters. Ramp investment is **not** justified by
throughput.
5. **How sensitive is throughput to crusher service time?**
**Very.** Raising mean dump time from 3.5 → 7.0 min cuts throughput from
12,547 → **6,513 t (−48%)** and drives the crusher queue to ~27 min. The
crusher is the binding resource, so its service rate maps almost
one-for-one onto throughput.
6. **Operational impact of losing the main ramp?**
**Modest and absorbable.** `ramp_closed` still delivers **12,363 t
(−1.5%)** because the bypass keeps every face reachable and the loaded legs
never used the ramp anyway. Losing the ramp is an inconvenience for the
empty `PARK → LOAD_S` leg and the end-of-shift return, not a production
emergency.
**Bottom line for the operator:** spend on **crusher capacity/throughput**, not
on the ramp or on a bigger truck fleet. The fleet is already near the
crusher-bound knee at 8 trucks, and the ramp is a red herring.
---
## 8. Bottlenecks (how they are ranked)
Resources are ranked by a composite score **utilisation × mean queue wait**,
which separates *the throughput ceiling* (high utilisation) from *occasional
choke points* (high per-event wait). Baseline ranking:
| Rank | Resource | Kind | Utilisation | Mean queue wait | Score |
|---|---|---|---:|---:|---:|
| 1 | `D_CRUSH` | crusher | 0.91 | 3.28 | 2.99 |
| 2 | `L_S` | loader | 0.80 | 2.45 | 1.97 |
| 3 | `L_N` | loader | 0.60 | 2.62 | 1.58 |
| 4 | `E03_UP` | edge (ramp) | 0.05 | 10.89 | 0.57 |
| 5 | `E05_TO_CRUSH` | edge | 0.42 | 0.15 | 0.06 |
The ramp's high per-traversal wait but tiny utilisation (rank 4) is exactly the
asymmetric-ramp finding in numbers. Under `crusher_slowdown` the crusher's
score jumps to ~25, dwarfing everything else.
---
## 9. Limitations
Summarised here, detailed in `conceptual_model.md` §6:
- Separate single-lane ramp directions (a truly shared lane would be worse).
- Static routing — no live re-routing around queued single-lane edges, so
single-lane queueing is an upper bound.
- Boundary under-count: phases straddling t = 480 add no time/tonnes.
- No truck breakdowns; `availability = 1.0` ⇒ an upper-bound estimate.
- Crusher never blocks downstream (no stockpile back-pressure).
- Homogeneous 100 t payload; free-flow edges have unlimited capacity; no
warm-up trimming.
These mean the figures are best read as a **fully-available, well-dispatched
upper bound**; real throughput would be somewhat lower.
---
## 10. Suggested improvements / further scenarios
- **Crusher reliability:** inject random short crusher outages to size the
surge-pile buffer — the crusher is the binding constraint.
- **Faster crusher:** cut mean dump time to 2.5 min and re-run `trucks_12` to
value a tip upgrade (likely the highest-ROI lever).
- **Dynamic re-routing:** re-plan when a capacity-1 edge queue exceeds a
threshold, to bound the upside of a smarter dispatcher.
- **Heterogeneous fleet / mid-shift loader outage:** trade payload vs cycle
count; size single-loader fall-back tonnes.
- **`trucks_12_ramp_upgrade`** is included here as the optional **7th
scenario**: it shows the two investments are near-independent (12,953 t,
essentially `trucks_12` plus a negligible ramp contribution) — confirming the
ramp adds little even alongside a larger fleet.
---
## 11. Project layout
```
src/mine_sim/
events.py # event-log row schema (header source of truth)
rng.py # reproducible seeds + distributions
scenarios.py # YAML load + inheritance -> immutable ScenarioConfig
topology.py # CSV load + per-scenario immutable Topology
routing.py # Dijkstra routing, reachability, dispatch cost
metrics.py # per-replication accumulator -> ReplicationMetrics
model.py # the SimPy simulation (one process per truck)
runner.py # one-replication entry point
scenario_runner.py# multi-rep / multi-scenario orchestration
aggregate.py # Student-t CIs + bottleneck ranking
narrative.py # assumptions / limitations / scenario text
io_writers.py # results.csv / event_log.csv / summary.json (flat schema)
viz.py # topology.png + animation.gif from model data
cli.py # argparse CLI: run / run-all / list / render
tests/ # 78 focused unit + integration tests
data/ # input CSVs + scenario YAMLs (incl. the 7th combo)
```
Outputs at the root: `results.csv`, `summary.json`, `event_log.csv`,
`topology.png`, `animation.gif`, plus this `README.md` and
`conceptual_model.md`.
README.md
← Back to submission · View raw on GitHub