sandtablR is a framework for mass-casualty incident
tabletop exercises: generate a casualty cohort, run it across a board,
capture the decisions that were made, then model and explain them.
The steps below are the whole workflow. Everything the Shiny workbench does, it does by calling these functions.
A cohort is a validated tibble subclass. Every casualty carries a card: demographics, life points, whether they are ventilated, the injury region, and the true sighting category and therapy concept.
co <- sdt_synthetic_cohort(n = 40, seed = 1)
co
#> # A cohort: 40 casualties
#> # Triage: red: 18 yellow: 10 green: 12
#> id age sex life_points ventilated injury_region triage therapy
#> <chr> <int> <chr> <int> <lgl> <chr> <chr> <chr>
#> 1 P001 78 m 2 TRUE multiple red TASC
#> 2 P002 41 m 2 TRUE thorax red DCS
#> 3 P003 52 m 5 FALSE extremity green none
#> 4 P004 42 m 4 FALSE thorax yellow TASC
#> 5 P005 31 m 2 TRUE multiple red DCS
#> 6 P006 45 m 4 FALSE multiple yellow TASC
#> 7 P007 15 f 4 FALSE extremity yellow DCS
#> 8 P008 64 m 6 FALSE extremity green none
#> 9 P009 44 m 5 FALSE head green none
#> 10 P010 75 f 1 FALSE thorax red TASC
#> # ℹ 30 more rows
#> # ℹ 1 more variable: arrival_order <int>Marginals come from sdt_reference(), which separates two
things that are easy to conflate: values attributed to the published
evaluation, and modelling choices sandtablR made where the
source does not pin one.
Validation is vectorised over columns rather than looping over rows, so a bad import reports everything wrong with it at once rather than one problem per attempt:
A scenario is a cohort plus the board’s resource capacities.
sc <- sdt_scenario(co, resources = list(or = 3, icu_imc = 6, ward = 20),
name = "concert")
sc
#> <scenario> concert: 40 casualties
#> triage mix — red: 18, yellow: 10, green: 12
#> resources — triage: 1, ed_red: 1, ed_yellow: 1, diagnostics: 1, or: 3, recovery: 1, icu_imc: 6, ward: 20, green_area: 1, morgue: 1, external: 1, not_seen: 1
summary(sc)
#> # A cohort: 8 casualties
#> # Triage: red: 2 yellow: 3 green: 3
#> triage therapy n
#> <chr> <chr> <int>
#> 1 green DCS 1
#> 2 green TASC 2
#> 3 green none 9
#> 4 red DCS 5
#> 5 red TASC 13
#> 6 yellow DCS 4
#> 7 yellow TASC 5
#> 8 yellow none 1Bar chart of casualties per sighting category. Synthetic demonstration.
sdt_simulate() maps the scenario onto a discrete-event
model and routes each casualty by sighting category and therapy
concept.
run <- sdt_simulate(sc, replications = 5, seed = 1, verbose = FALSE)
run
#> <game_run> 5 replications over 270 minutes
#> 42 casualty records, 455 area eventsThe engine’s real output is the event log: one row per casualty per board area, with when it entered and left.
head(run$events)
#> # A tibble: 6 × 8
#> replication id step area start end duration service_time
#> <int> <chr> <int> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 1 P001 1 triage 1.86 10.00 8.14 8.14
#> 2 1 P001 2 ed_red 10.00 21.3 11.3 11.3
#> 3 1 P001 3 diagnostics 21.3 40.0 18.7 18.7
#> 4 1 P001 4 or 40.0 103. 62.9 62.9
#> 5 1 P001 5 icu_imc 103. 153. 49.8 49.8
#> 6 1 P002 1 triage 6.07 21.3 15.2 3.73Three dispositions are terminal by design, and that is what reproduces the published end-of-game distribution: critical care is where a casualty stays, a ward bed is the end of the line, and a casualty who is worked up but not taken to theatre is still being held in the emergency department when the game is called.
The quickest way to see what the engine did is to look at the board.
Every casualty is a point in the area it had reached at that moment,
laid out as the nine areas of the published board. Change
at and you watch the stream move across it.
The nine board areas with casualties drawn as coloured points. Synthetic demonstration.
summary(run, reference = TRUE)
#> # A tibble: 8 × 6
#> area label n proportion reference difference
#> <chr> <chr> <int> <dbl> <dbl> <dbl>
#> 1 triage Screening 109 0.545 0.08 0.465
#> 2 ed Emergency department 23 0.115 0.16 -0.045
#> 3 diagnostics Radiology 3 0.015 0.04 -0.025
#> 4 or Operating room 7 0.035 0.11 -0.075
#> 5 recovery Recovery 0 0 0.01 -0.01
#> 6 icu_imc ICU / IMC 28 0.14 0.23 -0.09
#> 7 ward Ward 30 0.15 0.35 -0.2
#> 8 not_seen Not yet seen 0 0 0 0Simulated end-of-game location against the published reference. Synthetic demonstration.
That calibration is checkable rather than asserted:
sdt_reference_scenario() builds the board the engine was
tuned on, and the package’s own test suite re-runs the comparison.
Because the event log records every area entry, each casualty has a full ordered pathway.
head(sdt_decision_paths(run), 8)
#> # A tibble: 8 × 7
#> replication id step area start end duration
#> <int> <chr> <int> <chr> <dbl> <dbl> <dbl>
#> 1 1 P001 1 triage 1.86 10.00 8.14
#> 2 1 P001 2 ed_red 10.00 21.3 11.3
#> 3 1 P001 3 diagnostics 21.3 40.0 18.7
#> 4 1 P001 4 or 40.0 103. 62.9
#> 5 1 P001 5 icu_imc 103. 153. 49.8
#> 6 1 P002 1 triage 6.07 21.3 15.2
#> 7 1 P002 2 ed_red 21.3 40.0 18.7
#> 8 1 P002 3 diagnostics 40.0 46.1 6.11sdt_decision_consistency() asks how repeatably the same
casualty was handled across replications, as a distance between its
pathways rather than agreement on a single label.
consistency <- sdt_decision_consistency(run)
head(consistency[order(consistency$consistency), ])
#> # A tibble: 6 × 4
#> id n_paths n_distinct consistency
#> <chr> <int> <int> <dbl>
#> 1 P019 5 3 0.44
#> 2 P014 5 3 0.45
#> 3 P020 5 3 0.5
#> 4 P022 5 4 0.508
#> 5 P021 5 3 0.517
#> 6 P010 5 4 0.525Share of casualties in each board area by position in the pathway. Synthetic demonstration.
With TraMineR and WeightedCluster
installed, sdt_seq_cluster() groups pathways into a few
characteristic routes through the board.
The card’s true category follows from life points by construction, so
every engine scores near 1 on it and the comparison says nothing. What
is worth modelling is what a participant called.
sdt_add_decisions() simulates that using the published
per-category hit rates.
labelled <- sdt_add_decisions(co, seed = 2)
sdt_consistency_score(labelled$triage, labelled$called_triage)
#> # A tibble: 1 × 4
#> .metric .estimator .estimate n
#> <chr> <chr> <dbl> <int>
#> 1 kap multiclass 0.563 40fit <- sdt_fit(labelled, target = "called_triage", engine = "tree",
tune = FALSE, folds = 3, seed = 1, verbose = FALSE)
fit
#> <sdt_model> tree (glass-box) -> called_triage, roc_auc 0.606
#> 5 predictors: age, sex, life_points, ventilated, injury_region
sdt_metrics(fit)
#> # A tibble: 4 × 3
#> .metric .estimator .estimate
#> <chr> <chr> <dbl>
#> 1 accuracy multiclass 0.583
#> 2 f_meas macro 0.5
#> 3 kap multiclass 0.333
#> 4 roc_auc hand_till 0.606sdt_workflowset() screens every installed engine onto
one leaderboard, with glass-box and black-box models side by side. See
vignette("model-bench", package = "sandtablR").
Explanations are model-agnostic, so the same views work on a decision tree and on a random forest.
Permutation variable importance. Synthetic demonstration.
sdt_run_app() launches an interactive workbench over all
of the above: build a scenario, run the board, annotate decisions, train
and compare models, explain them, and analyse decision paths. It also
renders the session to a self-contained HTML report.
The domain model (sighting categories, ETC/DCS/TASC therapy concepts,
board areas, patient-card fields) follows the TDSC tabletop exercise as
described in the open-access evaluation by Achatz et al. (2023), Eur
J Trauma Emerg Surg 49(2):607–617, doi:10.1007/s00068-022-02217-1.
sandtablR is a generic re-implementation built from
published specifications, and is not affiliated with the TDSC
course.
path <- tempfile(fileext = ".rds")
saveRDS(list(scenario = sc, run = run, seed = 1,
session = sessionInfo()), path)
restored <- readRDS(path)
stopifnot(identical(run$events, restored$run$events))Retain the software revision and scenario alongside every seed. A reproducible simulation is not evidence that its assumptions describe a real incident. See the model bench for a separate synthetic prediction example.
sessionInfo()
#> R version 4.6.2 (2026-10-29)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] sandtablR_0.0.0.9000 rmarkdown_2.32
#>
#> loaded via a namespace (and not attached):
#> [1] rlang_1.3.0 magrittr_2.0.5 furrr_0.4.0
#> [4] tailor_0.1.0 otel_0.2.0 compiler_4.6.2
#> [7] png_0.1-9 vctrs_0.7.3 tune_2.1.0
#> [10] pkgconfig_2.0.3 shape_1.4.6.1 fastmap_1.2.0
#> [13] labeling_0.4.3 utf8_1.2.6 prodlim_2026.03.11
#> [16] purrr_1.2.2 xfun_0.62 glmnet_5.2
#> [19] simmer_4.4.7 cachem_1.1.0 jsonlite_2.0.0
#> [22] recipes_1.4.0 parallel_4.6.2 tensorflow_2.20.0
#> [25] R6_2.6.1 bslib_0.12.0 rsample_1.3.2
#> [28] RColorBrewer_1.1-3 ranger_0.18.0 reticulate_1.47.0
#> [31] parallelly_1.50.0 rpart_4.1.27 lubridate_1.9.5
#> [34] jquerylib_0.1.4 xgboost_3.2.1.1 Rcpp_1.1.2
#> [37] dials_1.4.4 iterators_1.0.14 knitr_1.52
#> [40] future.apply_1.21.0 base64enc_0.1-6 Matrix_1.7-6
#> [43] splines_4.6.2 nnet_7.3-21 timechange_0.4.0
#> [46] tidyselect_1.2.1 yaml_2.3.12 timeDate_4052.112
#> [49] codetools_0.2-20 listenv_1.1.0 lattice_0.23-1
#> [52] tibble_3.3.1 withr_3.0.3 S7_0.2.2
#> [55] evaluate_1.0.5 future_1.82.0 survival_3.8-12
#> [58] kernlab_0.9-33 pillar_1.11.1 whisker_0.4.1
#> [61] foreach_1.5.2 generics_0.1.4 ggplot2_4.0.3
#> [64] scales_1.4.0 plotmo_3.7.1 globals_0.20.0
#> [67] class_7.3-24 glue_1.8.1 ingredients_2.3.0
#> [70] maketools_1.3.2 tools_4.6.2 sys_3.4.3
#> [73] data.table_1.18.6.1 gower_1.0.2 buildtools_1.0.0
#> [76] grid_4.6.2 plotrix_3.8-14 yardstick_1.4.0
#> [79] DALEX_2.5.4 tidyr_1.3.2 dotty_0.1.0
#> [82] keras3_1.5.1 ipred_0.9-16 DALEXtra_2.3.1
#> [85] earth_5.3.6 Formula_1.2-6 cli_3.6.6
#> [88] DiceDesign_1.10 tfruns_1.5.4 workflows_1.3.0
#> [91] parsnip_1.6.1 viridisLite_0.4.3 lava_1.10
#> [94] dplyr_1.2.1 gtable_0.3.6 zeallot_0.2.0
#> [97] sass_0.4.10 digest_0.6.39 farver_2.1.2
#> [100] htmltools_0.5.9 lifecycle_1.0.5 hardhat_1.4.3
#> [103] MASS_7.3-66 sparsevctrs_0.4.0