README.md

← Back to submission · View raw on GitHub

# 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.

![Mine topology](topology.png)

---

## 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`.