Cyanide inhalation PBPK in fire-related deaths (Harada 2025)
Source:vignettes/articles/Harada_2025_cyanide_pbpk.Rmd
Harada_2025_cyanide_pbpk.RmdModel and source
mod <- rxode2::rxode(readModelDb("Harada_2025_cyanide_pbpk"))- Citation: Harada K, Tokugawa Y, Henmi K, Miyashita Y, Sakahashi Y, Nishihori T, Sakamoto Y, Yang C, Isobe Y, Sugimoto K, Nakama K, Katada R, Matsumoto H. Analysis of cyanide exposure status in fire-related deaths using a physiologically based pharmacokinetic model. Forensic Toxicol. 2025;43(2):303-312. doi:10.1007/s11419-025-00713-8. Model structure and parameters inherited from Stamyr K, Mork AK, Johanson G. Physiologically based pharmacokinetic modeling of hydrogen cyanide levels in human breath. Arch Toxicol. 2015;89:1287-1296. doi:10.1007/s00204-014-1310-y (not open access; all values used here are taken from the Harada 2025 Supplemental Material 1 Python script, which lists them explicitly).
- Article: https://doi.org/10.1007/s11419-025-00713-8
- Supplemental Material 1-4 (Springer electronic supplementary
material,
11419_2025_713_MOESM1_ESM.docx): the Python simulation script, the estimation script, and the sensitivity-analysis table. Supplemental Material 1 is the authoritative on-disk source for every parameter value in this model, because the upstream Stamyr et al. 2015 paper that the structure and parameters come from is not open access.
Harada et al. (2025) measured cyanide (CN) and thiocyanate (SCN) in the left and right cardiac blood of 29 fire-related deaths autopsied at Osaka University, then used the hydrogen-cyanide inhalation PBPK model of Stamyr et al. (2015) to work backwards from those paired measurements to the inhaled HCN air concentration and exposure duration each decedent experienced at the fire scene.
The forensic point of the paper turns on a structural feature of the model: after the onset of inhalation, arterial CN rises faster than mixed-venous CN, and the arterial-venous gap keeps widening. Left cardiac blood (arterial) and right cardiac blood (venous) therefore carry different information, and a decedent who inhaled a very high HCN concentration for a very short time can present with a right-cardiac CN concentration below the 1 ug/mL toxic threshold while the left-cardiac concentration is several-fold above it. Diagnosing cyanide poisoning from venous blood alone will miss those cases.
What is and is not from this paper
This is an important provenance boundary for anyone using the model:
| Element | Source |
|---|---|
| ODE structure (6 states) | Stamyr 2015, transcribed verbatim in Harada 2025 Supplemental Material 1 |
| Every parameter value | Stamyr 2015, listed explicitly with units in Harada 2025 Supplemental Material 1 |
| Unit conversion factors (24 ppm per umol/L; 27 ug/L per umol/L) | Harada 2025 Supplemental Material 1 header comment |
| Initial conditions | Harada 2025 Supplemental Material 1
(init = [0, 0, 0, 1, 0, 0]) |
| The 29 autopsy cases, paired L/R cardiac CN, COHb%, SCN | Harada 2025 Table 1 |
| The 13 reconstructed exposures (ppm, min) | Harada 2025 Table 2 |
| Sensitivity of the reconstruction to +/- 15% assay error | Harada 2025 Supplemental Material 4 |
No parameter in this model was fitted by Harada et al. The 29 cases are the application dataset, not an estimation dataset. Harada et al. estimated only two per-case quantities – inhaled air concentration and exposure duration – by grid search. Consequently the model carries no between-subject variability and no residual error, and it is a deterministic typical-value model.
Population
pop <- mod$meta$population
tibble::tibble(Field = names(pop), Value = vapply(pop, function(x) paste(as.character(x), collapse = "; "), character(1))) |>
knitr::kable()| Field | Value |
|---|---|
| species | human |
| n_subjects | 29 |
| n_studies | 1 |
| age_range | 31-89 years |
| age_median | 70 years |
| sex_female_pct | 37.9 |
| disease_state | Fire-related deaths examined at forensic autopsy. No resuscitation was attempted before death was confirmed. Carboxyhaemoglobin exceeded the 2.0% non-smoker reference in every case (range 5.4-100%); cyanide was above the 0.2 ug/mL detection limit in the left or right cardiac blood of 23 of 29 cases (79.3%), and thiocyanate was detectable in all 29 (0.92-8.5 ug/mL). |
| dose_range | Estimated inhaled HCN air concentrations 84-16,632 ppm with estimated exposure durations 0.05-13.65 min across the 13 cases that had usable paired left/right cardiac cyanide measurements (Table 2). |
| regions | Osaka, Japan (Department of Legal Medicine, Osaka University; autopsies April 2014 - March 2020) |
| notes | Baseline demographics, paired left/right cardiac cyanide and thiocyanate concentrations, and carboxyhaemoglobin percentages are in Harada 2025 Table 1; the per-case exposure reconstructions are in Table 2. IMPORTANT: the 29 autopsy cases are the APPLICATION dataset, not the estimation dataset. No parameter in this model was fitted to them - every structural and physiological value is inherited unchanged from Stamyr et al. 2015, whose PBPK model was built for healthy adult humans in a controlled HCN-in-breath study. Harada 2025 estimated only two per-case quantities (inhaled air concentration and exposure duration) by grid search against the paired cardiac measurements. The paper’s own Discussion flags that the Stamyr physiological parameters may need adjustment for individual decedents (body weight, body-fat percentage, lung capacity) and that post-mortem redistribution of cyanide from lung into left cardiac blood is only implicitly accommodated by treating the arterial compartment as lung + arterial blood combined. |
The 29 decedents (Harada 2025 Table 1) were 11 female and 18 male, aged 31-89 years (median 70). Carboxyhaemoglobin exceeded the 2.0% non-smoker reference in every case. Cyanide was detectable (> 0.2 ug/mL) in the left or right cardiac blood of 23 of 29 cases (79.3%) and, in every case where it was detected, the left cardiac concentration exceeded the right – the observation that motivates treating the two chambers as arterial and venous samples.
Note the mismatch that the paper’s own Discussion raises: the physiological parameters describe healthy adults in a controlled breath study, not decedents, and body weight, body-fat percentage and lung capacity are all expected to shift cyanide distribution. Treat the reconstructions as order-of-magnitude forensic inference, not as measurement.
Source trace
Every value below is from the Harada 2025 Supplemental Material 1 Python script parameter block, which names each variable and gives its value and units in a trailing comment. That script is the only on-disk source that states these numbers, since Stamyr et al. 2015 is paywalled.
| Model element | Symbol in source | Value | Source location |
|---|---|---|---|
| Plasma:air partition coefficient | Ppa |
281 | Suppl. Material 1 parameter block |
| Liver:plasma partition coefficient | Php |
5.1 | Suppl. Material 1 parameter block |
| Muscle:plasma partition coefficient | Pmp |
2.8 | Suppl. Material 1 parameter block |
| Other-tissue:plasma partition coefficient | Pop |
5.4 | Suppl. Material 1 parameter block |
| Alveolar ventilation | Qalv |
16.5 L/min | Suppl. Material 1 parameter block |
| Cardiac output | Qtot |
10.7 L/min | Suppl. Material 1 parameter block |
| Liver blood flow | Qh |
1.6 L/min | Suppl. Material 1 parameter block |
| Muscle blood flow | Qm |
4.9 L/min | Suppl. Material 1 parameter block |
| Other-tissue blood flow | Qo |
4.2 L/min | Suppl. Material 1 parameter block |
| Plasma fraction of whole blood | Fp |
0.6 | Suppl. Material 1 parameter block |
| Plasma-erythrocyte exchange clearance | Cle |
65.9 L/min | Suppl. Material 1 parameter block |
| Plasma volume | Vp |
2.9 L | Suppl. Material 1 parameter block |
| Erythrocyte volume | Ve |
1.9 L | Suppl. Material 1 parameter block |
| Liver volume | Vh |
1.6 L | Suppl. Material 1 parameter block |
| Muscle volume | Vm |
35 L | Suppl. Material 1 parameter block |
| Other-tissue volume | Vo |
18 L | Suppl. Material 1 parameter block |
| Whole-blood volume | Vb |
4.8 L | Suppl. Material 1 parameter block |
| Max erythrocyte binding | Emax |
140 | Suppl. Material 1 parameter block |
| Erythrocyte affinity constant | Kaff |
1 | Suppl. Material 1 parameter block |
| Thiocyanate formation rate constant | Kscn |
0.01 | Suppl. Material 1 parameter block |
| Sulfur-donor formation rate | Kfs |
2.2 | Suppl. Material 1 parameter block |
| Sulfur-donor elimination rate constant | Kes |
0.0027 | Suppl. Material 1 parameter block |
| Plasma ODE | dCp |
– | Suppl. Material 1 func body, line 1 |
| Erythrocyte ODE | dCe |
– | Suppl. Material 1 func body, line 2 |
| Liver ODE | dCh |
– | Suppl. Material 1 func body, line 3 |
| Sulfur-donor ODE | dS |
– | Suppl. Material 1 func body, line 4 |
| Muscle ODE | dCm |
– | Suppl. Material 1 func body, line 5 |
| Other-tissue ODE | dCo |
– | Suppl. Material 1 func body, line 6 |
| Arterial whole-blood observable | Cb |
– | Suppl. Material 1, post-solve block |
| Venous plasma / whole-blood observables |
Cv, Cvb
|
– | Suppl. Material 1, post-solve block |
| Initial conditions | init |
[0,0,0,1,0,0] |
Suppl. Material 1 |
| ppm to umol/L | – | 24 ppm per umol/L | Suppl. Material 1 header comment |
| umol/L to ug/mL | – | 0.027 | Suppl. Material 1 header comment |
| Exposure grid searched | – | 0-18,000 ppm / 12 ppm; 0-30 min / 0.05 min | Methods, “Simulation of blood cyanide concentration” |
mod$theta |> round(6)
#> lppa lkp_liver lkp_muscle lkp_other
#> 5.638355 1.629241 1.029619 1.686399
#> lq_alv lq_tot lq_liver lq_muscle
#> 2.803360 2.370244 0.470004 1.589235
#> lq_other lv_plasma lv_erythrocytes lv_liver
#> 1.435085 1.064711 0.641854 0.470004
#> lv_muscle lv_other lv_blood fp_blood
#> 3.555348 2.890372 1.568616 0.600000
#> lcl_erythrocytes emax_erythrocytes kaff_erythrocytes lkscn
#> 4.188138 140.000000 1.000000 -4.605170
#> lkfs lkes mw_hcn ppm_per_umol
#> 0.788457 -5.914504 27.000000 24.000000Simulation helper
The model has no dosing events. Exposure is supplied entirely through
the CONC_HCN_PPM covariate column, so a “cohort” is just a
set of air concentrations. All simulations below use the paper’s own
0.05 min output grid.
simulate_exposure <- function(ppm, tmax = 30, dt = 0.05) {
tgrid <- seq(0, tmax, by = dt)
ev <- data.frame(
id = rep(seq_along(ppm), each = length(tgrid)),
time = rep(tgrid, times = length(ppm))
)
ev$CONC_HCN_PPM <- ppm[ev$id]
s <- rxode2::rxSolve(mod, ev, atol = 1e-8, rtol = 1e-8, returnType = "data.frame")
# rxode2 omits the `id` column for a single-subject solve, so restore it
# before using it as an index.
if (!"id" %in% names(s)) s$id <- 1L
s$id <- as.integer(s$id)
s$ppm <- ppm[s$id]
s
}Validation
Harada et al. report no NCA-style analysis of any model output –
there is no dose, no administered amount, and no reported Cmax / AUC /
half-life – so PKNCA is not the right validation instrument here and is
deliberately not used (see
references/endogenous-validation.md). What the paper
does provide is far stronger: 13 published answer
keys. For each reconstructed case, Table 2 gives an air
concentration and an exposure time that the model must map back onto the
measured left and right cardiac cyanide concentrations. Those are
checked below, followed by the reverse direction – re-running the
paper’s grid search to see whether it returns the published
estimates.
1. Zero-exposure and sign checks
The first thing to test is the degenerate case: clean air must not create cyanide. This is where the shipped (published) equations reveal a defect, so the check is reported rather than asserted.
s0 <- simulate_exposure(0)
zero_summary <- tibble::tibble(
State = c("plasma", "erythrocytes", "liver", "muscle", "other", "sulfur_donor"),
`t = 0` = vapply(c("plasma","erythrocytes","liver","muscle","other","sulfur_donor"),
function(v) s0[[v]][1], numeric(1)),
`t = 30 min` = vapply(c("plasma","erythrocytes","liver","muscle","other","sulfur_donor"),
function(v) s0[[v]][nrow(s0)], numeric(1))
)
knitr::kable(zero_summary, digits = 4)| State | t = 0 | t = 30 min |
|---|---|---|
| plasma | 0 | -0.1294 |
| erythrocytes | 0 | -18.0118 |
| liver | 0 | -9.3208 |
| muscle | 0 | -0.1299 |
| other | 0 | -0.2244 |
| sulfur_donor | 1 | 335.8714 |
Under zero exposure the sulfur-donor pool grows – which is correct
and intended, since its ODE has a zero-order supply term
Kfs – but every cyanide state drifts
negative. This is a genuine property of the published
equations, diagnosed and quantified in the Errata section below. It
bounds the model’s validity domain and does not affect any published
result, because every exposure Harada et al. report is far above the
range where the drift matters.
For a real exposure, cyanide accumulates monotonically and arterial always leads venous, matching the paper’s description of Fig. 3:
s_mono <- simulate_exposure(1000)
gap_max_t <- s_mono$time[which.max(s_mono$avGap)]
tibble::tibble(
Check = c(
"Arterial CN is non-decreasing over 0-30 min",
"Venous CN is non-decreasing over 0-30 min",
"Arterial CN >= venous CN at every time point",
"Arterial-venous gap is non-decreasing over Figure 3's 0-10 min window",
"Time at which the arterial-venous gap peaks (min)",
"Gap decline from its peak to 30 min (ug/mL)"
),
Result = c(
as.character(all(diff(s_mono$Carterial) >= -1e-9)),
as.character(all(diff(s_mono$Cvenous) >= -1e-9)),
as.character(all(s_mono$Carterial >= s_mono$Cvenous - 1e-9)),
as.character(all(diff(s_mono$avGap[s_mono$time <= 10]) >= -1e-3)),
sprintf("%.2f", gap_max_t),
sprintf("%.4f (%.2f%% of the peak)",
max(s_mono$avGap) - s_mono$avGap[nrow(s_mono)],
100 * (max(s_mono$avGap) - s_mono$avGap[nrow(s_mono)]) / max(s_mono$avGap))
)
) |> knitr::kable()| Check | Result |
|---|---|
| Arterial CN is non-decreasing over 0-30 min | TRUE |
| Venous CN is non-decreasing over 0-30 min | TRUE |
| Arterial CN >= venous CN at every time point | TRUE |
| Arterial-venous gap is non-decreasing over Figure 3’s 0-10 min window | TRUE |
| Time at which the arterial-venous gap peaks (min) | 5.30 |
| Gap decline from its peak to 30 min (ug/mL) | 0.0081 (0.48% of the peak) |
Both blood curves rise monotonically and arterial leads venous throughout, as the paper describes. The arterial-venous gap is not monotone over the full 30 min, however: it peaks at about 5.3 min and then declines by roughly half a percent out to 30 min. Within the 0-10 min window that Figure 3 actually plots the decline is smaller than the line width, so the paper’s description of a gap that “continued to expand slightly” is accurate for what it shows; it simply does not extend to 30 min. This is reported rather than asserted because a strict monotonicity assertion over 0-30 min would fail here while the model is behaving correctly.
2. Reproducing the 13 published exposure reconstructions (Table 2)
Table 2 lists, per case, the measured right (venous) and left
(arterial) cardiac CN concentrations alongside the inhaled HCN
concentration and exposure time that Harada et al.’s grid search
selected. Feeding the published (ppm, time) pair into the
packaged model must return the measured concentrations.
table2 <- tibble::tribble(
~case, ~group, ~R_obs, ~L_obs, ~ppm, ~t_min,
5L, "Ca_high T_short", 0.58, 2.7, 14100, 0.05,
7L, "Ca_high T_short", 1.2, 1.7, 1260, 0.40,
9L, "Ca_high T_short", 1.1, 11.0, 16212, 0.20,
13L, "Ca_high T_short", 0.75, 4.0, 11040, 0.10,
17L, "Ca_high T_short", 0.48, 2.7, 14280, 0.05,
18L, "Ca_high T_short", 1.0, 3.1, 16632, 0.05,
19L, "Ca_high T_short", 1.1, 2.4, 6624, 0.10,
23L, "Ca_high T_short", 0.82, 1.8, 5004, 0.10,
1L, "Ca_low T_long", 2.0, 3.7, 1032, 8.35,
2L, "Ca_low T_long", 1.7, 2.9, 696, 6.60,
4L, "Ca_low T_long", 0.64, 0.67, 84, 2.40,
10L, "Ca_low T_long", 2.3, 4.0, 996, 13.65,
25L, "Ca_low T_long", 1.8, 2.9, 648, 9.10
)
sim_at <- function(ppm, t_min) {
s <- simulate_exposure(ppm, tmax = max(t_min, 1))
j <- which.min(abs(s$time - t_min))
c(L_sim = s$Carterial[j], R_sim = s$Cvenous[j])
}
fit <- t(mapply(sim_at, table2$ppm, table2$t_min))
table2_chk <- table2 |>
mutate(L_sim = fit[, "L_sim"], R_sim = fit[, "R_sim"],
L_pct = 100 * (L_sim - L_obs) / L_obs,
R_pct = 100 * (R_sim - R_obs) / R_obs)
table2_chk |>
select(case, group, ppm, t_min, L_obs, L_sim, L_pct, R_obs, R_sim, R_pct) |>
rename("Case" = case, "Group" = group, "HCN (ppm)" = ppm, "Time (min)" = t_min,
"Left obs" = L_obs, "Left sim" = L_sim, "Left %diff" = L_pct,
"Right obs" = R_obs, "Right sim" = R_sim, "Right %diff" = R_pct) |>
knitr::kable(digits = c(0, 0, 0, 2, 2, 2, 1, 2, 2, 1))| Case | Group | HCN (ppm) | Time (min) | Left obs | Left sim | Left %diff | Right obs | Right sim | Right %diff |
|---|---|---|---|---|---|---|---|---|---|
| 5 | Ca_high T_short | 14100 | 0.05 | 2.70 | 2.61 | -3.2 | 0.58 | 0.80 | 37.2 |
| 7 | Ca_high T_short | 1260 | 0.40 | 1.70 | 1.70 | 0.1 | 1.20 | 1.19 | -0.4 |
| 9 | Ca_high T_short | 16212 | 0.20 | 11.00 | 10.55 | -4.1 | 1.10 | 1.53 | 39.1 |
| 13 | Ca_high T_short | 11040 | 0.10 | 4.00 | 3.96 | -1.0 | 0.75 | 1.31 | 74.8 |
| 17 | Ca_high T_short | 14280 | 0.05 | 2.70 | 2.65 | -1.9 | 0.48 | 0.80 | 67.2 |
| 18 | Ca_high T_short | 16632 | 0.05 | 3.10 | 3.08 | -0.6 | 1.00 | 0.89 | -11.0 |
| 19 | Ca_high T_short | 6624 | 0.10 | 2.40 | 2.39 | -0.4 | 1.10 | 1.02 | -6.9 |
| 23 | Ca_high T_short | 5004 | 0.10 | 1.80 | 1.81 | 0.6 | 0.82 | 0.85 | 3.7 |
| 1 | Ca_low T_long | 1032 | 8.35 | 3.70 | 3.74 | 1.2 | 2.00 | 1.99 | -0.5 |
| 2 | Ca_low T_long | 696 | 6.60 | 2.90 | 2.92 | 0.6 | 1.70 | 1.74 | 2.1 |
| 4 | Ca_low T_long | 84 | 2.40 | 0.67 | 0.67 | 0.1 | 0.64 | 0.64 | 0.1 |
| 10 | Ca_low T_long | 996 | 13.65 | 4.00 | 3.98 | -0.6 | 2.30 | 2.29 | -0.6 |
| 25 | Ca_low T_long | 648 | 9.10 | 2.90 | 2.92 | 0.6 | 1.80 | 1.82 | 0.9 |
The arterial (left cardiac) reproduction is excellent throughout – the median absolute difference is well under the paper’s own +/- 15% assay uncertainty:
tibble::tibble(
Metric = c("Median |%diff|, arterial (left)",
"Max |%diff|, arterial (left)",
"Median |%diff|, venous (right)",
"Cases with arterial |%diff| < 15%",
"Total sum of squares over all 26 paired values"),
Value = c(
sprintf("%.2f%%", median(abs(table2_chk$L_pct))),
sprintf("%.2f%%", max(abs(table2_chk$L_pct))),
sprintf("%.2f%%", median(abs(table2_chk$R_pct))),
sprintf("%d of %d", sum(abs(table2_chk$L_pct) < 15), nrow(table2_chk)),
sprintf("%.4f", sum((table2_chk$L_sim - table2_chk$L_obs)^2 +
(table2_chk$R_sim - table2_chk$R_obs)^2))
)
) |> knitr::kable()| Metric | Value |
|---|---|
| Median |%diff|, arterial (left) | 0.59% |
| Max |%diff|, arterial (left) | 4.12% |
| Median |%diff|, venous (right) | 3.75% |
| Cases with arterial |%diff| < 15% | 13 of 13 |
| Total sum of squares over all 26 paired values | 0.8919 |
The venous residuals are larger for four of the
Ca_high T_short cases (9, 13, 5, 17), and this is
not a translation error – it is the paper’s central
finding. Harada et al.’s objective minimises the joint squared
error in both chambers, and for a brief exposure to a very high
concentration the model simply cannot put a low enough concentration in
venous blood while matching a high arterial concentration. The
unavoidable residual is the quantitative expression of the claim that
venous blood under-reports these exposures.
table2_chk |>
mutate(Chamber = "arterial (left)") |>
select(case, group, obs = L_obs, sim = L_sim, Chamber) |>
bind_rows(table2_chk |> mutate(Chamber = "venous (right)") |>
select(case, group, obs = R_obs, sim = R_sim, Chamber)) |>
ggplot(aes(obs, sim, colour = group, shape = Chamber)) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed") +
geom_point(size = 2.5) +
scale_x_log10() + scale_y_log10() +
labs(x = "Measured cardiac CN (ug/mL)", y = "Model-predicted CN (ug/mL)",
colour = "Group", shape = "Chamber",
title = "Observed vs predicted cardiac cyanide at the Table 2 reconstructions") +
theme_bw() + theme(legend.position = "bottom")
3. Re-running the paper’s grid search
The stronger test runs the estimation in the same direction the paper
did: given only the measured left and right cardiac CN, search the
published grid (0-18,000 ppm in 12 ppm steps; 0-30 min in 0.05 min
steps) for the (ppm, time) pair that minimises the joint
squared error, and compare against Table 2.
ppm_grid <- seq(0, 18000, by = 12)
grid_sim <- simulate_exposure(ppm_grid, tmax = 30 - 0.05)
estimate_case <- function(R_obs, L_obs) {
d <- (R_obs - grid_sim$Cvenous)^2 + (L_obs - grid_sim$Carterial)^2
j <- which.min(d)
c(ppm_est = grid_sim$ppm[j], t_est = grid_sim$time[j], ssq = d[j])
}
est <- t(mapply(estimate_case, table2$R_obs, table2$L_obs))
grid_chk <- table2 |>
mutate(ppm_est = est[, "ppm_est"], t_est = est[, "t_est"],
ppm_steps = abs(ppm_est - ppm) / 12,
t_steps = abs(t_est - t_min) / 0.05)
grid_chk |>
select(case, group, ppm, ppm_est, ppm_steps, t_min, t_est, t_steps) |>
rename("Case" = case, "Group" = group,
"ppm published" = ppm, "ppm re-estimated" = ppm_est, "ppm grid steps apart" = ppm_steps,
"Time published (min)" = t_min, "Time re-estimated (min)" = t_est,
"Time grid steps apart" = t_steps) |>
knitr::kable(digits = c(0, 0, 0, 0, 1, 2, 2, 1))| Case | Group | ppm published | ppm re-estimated | ppm grid steps apart | Time published (min) | Time re-estimated (min) | Time grid steps apart |
|---|---|---|---|---|---|---|---|
| 5 | Ca_high T_short | 14100 | 14304 | 17 | 0.05 | 0.05 | 0 |
| 7 | Ca_high T_short | 1260 | 1260 | 0 | 0.40 | 0.40 | 0 |
| 9 | Ca_high T_short | 16212 | 16932 | 60 | 0.20 | 0.20 | 0 |
| 13 | Ca_high T_short | 11040 | 10956 | 7 | 0.10 | 0.10 | 0 |
| 17 | Ca_high T_short | 14280 | 14196 | 7 | 0.05 | 0.05 | 0 |
| 18 | Ca_high T_short | 16632 | 16824 | 16 | 0.05 | 0.05 | 0 |
| 19 | Ca_high T_short | 6624 | 4500 | 177 | 0.10 | 0.15 | 1 |
| 23 | Ca_high T_short | 5004 | 4956 | 4 | 0.10 | 0.10 | 0 |
| 1 | Ca_low T_long | 1032 | 996 | 3 | 8.35 | 8.90 | 11 |
| 2 | Ca_low T_long | 696 | 708 | 1 | 6.60 | 5.65 | 19 |
| 4 | Ca_low T_long | 84 | 84 | 0 | 2.40 | 2.40 | 0 |
| 10 | Ca_low T_long | 996 | 996 | 0 | 13.65 | 13.95 | 6 |
| 25 | Ca_low T_long | 648 | 648 | 0 | 9.10 | 8.70 | 8 |
Before scoring the group assignment, note which of the paper’s two
stated group criteria its own Table 2 actually follows. The Results
define Ca_high T_short as “>= 5000 ppm
and < 1 min”, but Table 2 files case 7 (1260 ppm,
0.40 min) under Ca_high T_short, so the published grouping
cannot be reproduced from the concentration criterion. It is reproduced
exactly by the duration criterion.
grp <- table2 |>
mutate(paper_high = group == "Ca_high T_short",
by_ppm = ppm >= 5000,
by_time = t_min < 1)
tibble::tibble(
`Criterion applied to the published Table 2 values` = c(
"Concentration only (>= 5000 ppm)",
"Duration only (< 1 min)",
"Both together (>= 5000 ppm AND < 1 min)"),
`Cases matching the paper's own grouping` = c(
sprintf("%d of %d (case %s disagrees)", sum(grp$by_ppm == grp$paper_high), nrow(grp),
paste(grp$case[grp$by_ppm != grp$paper_high], collapse = ", ")),
sprintf("%d of %d", sum(grp$by_time == grp$paper_high), nrow(grp)),
sprintf("%d of %d", sum((grp$by_ppm & grp$by_time) == grp$paper_high), nrow(grp)))
) |> knitr::kable()| Criterion applied to the published Table 2 values | Cases matching the paper’s own grouping |
|---|---|
| Concentration only (>= 5000 ppm) | 12 of 13 (case 7 disagrees) |
| Duration only (< 1 min) | 13 of 13 |
| Both together (>= 5000 ppm AND < 1 min) | 12 of 13 |
tibble::tibble(
Metric = c("Exposure time reproduced exactly",
"Air concentration reproduced exactly",
"Air concentration within 5% of published",
"Exposure time within 10% of published",
"Same group as the paper, by its operative < 1 min criterion"),
Value = c(
sprintf("%d of %d", sum(grid_chk$t_steps == 0), nrow(grid_chk)),
sprintf("%d of %d", sum(grid_chk$ppm_steps == 0), nrow(grid_chk)),
sprintf("%d of %d", sum(abs(grid_chk$ppm_est - grid_chk$ppm) <= 0.05 * grid_chk$ppm), nrow(grid_chk)),
sprintf("%d of %d", sum(abs(grid_chk$t_est - grid_chk$t_min) <= 0.10 * grid_chk$t_min), nrow(grid_chk)),
sprintf("%d of %d",
sum((grid_chk$t_est < 1) == (grid_chk$group == "Ca_high T_short")),
nrow(grid_chk))
)
) |> knitr::kable()| Metric | Value |
|---|---|
| Exposure time reproduced exactly | 7 of 13 |
| Air concentration reproduced exactly | 4 of 13 |
| Air concentration within 5% of published | 12 of 13 |
| Exposure time within 10% of published | 11 of 13 |
| Same group as the paper, by its operative < 1 min criterion | 13 of 13 |
The reconstruction is reproduced, but not always to the exact grid cell, and the reason is worth stating because it limits how much weight a forensic conclusion can carry. At short exposures the objective surface is a flat ridge: what the paired measurements identify is close to the product of concentration and time (a Haber’s-rule cumulative dose), not the two factors separately. Case 19 is the clearest example.
ridge <- tibble::tribble(
~label, ~ppm, ~t_min,
"Published (Table 2)", 6624, 0.10,
"Re-estimated here", 4500, 0.15
) |>
rowwise() |>
mutate(v = list(sim_at(ppm, t_min))) |>
mutate(L_sim = v[["L_sim"]], R_sim = v[["R_sim"]]) |>
ungroup() |>
mutate(`ppm x min` = ppm * t_min,
ssq = (2.4 - L_sim)^2 + (1.1 - R_sim)^2) |>
select(-v)
ridge |>
rename("Reading" = label, "HCN (ppm)" = ppm, "Time (min)" = t_min,
"Left sim" = L_sim, "Right sim" = R_sim,
"Cumulative (ppm x min)" = `ppm x min`, "Objective SSq" = ssq) |>
knitr::kable(digits = c(0, 0, 2, 3, 3, 0, 5))| Reading | HCN (ppm) | Time (min) | Left sim | Right sim | Cumulative (ppm x min) | Objective SSq |
|---|---|---|---|---|---|---|
| Published (Table 2) | 6624 | 0.10 | 2.391 | 1.024 | 662 | 0.00589 |
| Re-estimated here | 4500 | 0.15 | 2.386 | 1.166 | 675 | 0.00450 |
Both readings of case 19 (measured left 2.4, right 1.1 ug/mL) fit
essentially equally well and agree closely on cumulative exposure while
differing 1.5-fold in concentration. The forensically load-bearing
conclusion – the binary Ca_high T_short /
Ca_low T_long split, which the metrics table above shows is
reproduced for every case on the duration criterion the paper’s Table 2
actually uses – is robust to this. The individual ppm and minute values
are not, and should not be read as precise. Note that under the
concentration criterion the split would not be robust: case
19’s re-estimate of 4500 ppm falls below the 5000 ppm line that its
published 6624 ppm clears, purely by moving along the ridge.
4. Replicating Figure 3
Figure 3 shows arterial and venous whole-blood CN for inhaled concentrations of 50, 250, 1000, 5000 and 15,000 ppm, over 0-10 min (top row) and 0-1 min (bottom row).
fig3_ppm <- c(50, 250, 1000, 5000, 15000)
fig3 <- simulate_exposure(fig3_ppm, tmax = 10) |>
select(time, ppm, Carterial, Cvenous) |>
pivot_longer(c(Carterial, Cvenous), names_to = "chamber", values_to = "conc") |>
mutate(chamber = recode(chamber,
Carterial = "arterial (left cardiac)",
Cvenous = "venous (right cardiac)"),
panel = factor(paste0(ppm, " ppm"), levels = paste0(fig3_ppm, " ppm")))
bind_rows(
fig3 |> mutate(window = "0-10 min"),
fig3 |> filter(time <= 1) |> mutate(window = "0-1 min")
) |>
ggplot(aes(time, conc, colour = chamber)) +
geom_line(linewidth = 0.7) +
facet_grid(window ~ panel, scales = "free") +
labs(x = "Time from onset of inhalation (min)",
y = "Whole-blood CN (ug/mL)", colour = NULL,
title = "Replicates Figure 3 of Harada et al. (2025)") +
theme_bw() + theme(legend.position = "bottom")
The qualitative features the paper describes in the Results are present: both curves rise rapidly then decelerate; the deceleration happens earlier in venous blood, opening an arterial-venous gap; and over the plotted window that gap keeps widening after both curves have flattened. (As quantified in section 1, the gap actually turns over at about 5.3 min and then declines by ~0.5% out to 30 min – invisible at Figure 3’s scale, but worth knowing before extrapolating.)
f <- simulate_exposure(1000, tmax = 10)
tibble::tibble(
Feature = c(
"Venous curve decelerates before arterial (time of peak 2nd derivative)",
"Arterial-venous gap at 1 min (ug/mL)",
"Arterial-venous gap at 10 min (ug/mL)",
"Gap still widening between 1 and 10 min"
),
Value = c(
sprintf("venous %.2f min vs arterial %.2f min",
f$time[which.min(diff(diff(f$Cvenous)))],
f$time[which.min(diff(diff(f$Carterial)))]),
sprintf("%.3f", f$avGap[which.min(abs(f$time - 1))]),
sprintf("%.3f", f$avGap[which.min(abs(f$time - 10))]),
as.character(f$avGap[which.min(abs(f$time - 10))] > f$avGap[which.min(abs(f$time - 1))])
)
) |> knitr::kable()| Feature | Value |
|---|---|
| Venous curve decelerates before arterial (time of peak 2nd derivative) | venous 0.50 min vs arterial 0.65 min |
| Arterial-venous gap at 1 min (ug/mL) | 1.185 |
| Arterial-venous gap at 10 min (ug/mL) | 1.699 |
| Gap still widening between 1 and 10 min | TRUE |
5. Reproducing the +/- 15% sensitivity analysis (Supplemental Material 4)
The paper re-ran the reconstruction for cases 7, 10 and 17 with the
measured cyanide concentrations perturbed by +/- 15% (the assay
uncertainty), and reports the resulting ranges in the text: case 17
stays at 11,988-16,632 ppm and 0.05 min (“a consistent estimate”); case
10 varies widely, 588-1560 ppm and 5.10-29.90 min, but stays in the
Ca_low T_long group; case 7 mostly separates from
Ca_high T_short but flips at one corner.
sens_cases <- tibble::tribble(
~case, ~R_obs, ~L_obs,
17L, 0.48, 2.7,
10L, 2.3, 4.0,
7L, 1.2, 1.7
)
sens <- sens_cases |>
rowwise() |>
reframe(case = case,
R = R_obs * c(0.85, 1, 1.15),
L = rep(L_obs, 3)) |>
tidyr::expand_grid(Lmult = c(0.85, 1, 1.15)) |>
mutate(L = L * Lmult) |>
rowwise() |>
mutate(e = list(estimate_case(R, L))) |>
mutate(ppm_est = e[["ppm_est"]], t_est = e[["t_est"]]) |>
ungroup()
sens_summary <- sens |>
group_by(case) |>
summarise(`ppm range (re-estimated)` = sprintf("%s - %s", format(min(ppm_est), big.mark = ","),
format(max(ppm_est), big.mark = ",")),
`time range, min (re-estimated)` = sprintf("%.2f - %.2f", min(t_est), max(t_est)),
`High group by < 1 min` = sprintf("%d of %d", sum(t_est < 1), n()),
`High group by >= 5000 ppm` = sprintf("%d of %d", sum(ppm_est >= 5000), n()),
.groups = "drop")
sens_summary |>
mutate(`ppm range (paper)` = c("11,988 - 16,632", "588 - 1,560", "48 - 3,660")[match(case, c(17L, 10L, 7L))],
`time range, min (paper)` = c("0.05 - 0.05", "5.10 - 29.90", "0.15 - 26.80")[match(case, c(17L, 10L, 7L))]) |>
select(case, `ppm range (paper)`, `ppm range (re-estimated)`,
`time range, min (paper)`, `time range, min (re-estimated)`,
`High group by < 1 min`, `High group by >= 5000 ppm`) |>
rename("Case" = case) |>
knitr::kable()| Case | ppm range (paper) | ppm range (re-estimated) | time range, min (paper) | time range, min (re-estimated) | High group by < 1 min | High group by >= 5000 ppm |
|---|---|---|---|---|---|---|
| 7 | 48 - 3,660 | 48 - 3,660 | 0.15 - 26.80 | 0.15 - 26.75 | 7 of 9 | 0 of 9 |
| 10 | 588 - 1,560 | 588 - 1,560 | 5.10 - 29.90 | 5.15 - 29.95 | 0 of 9 | 0 of 9 |
| 17 | 11,988 - 16,632 | 11,988 - 16,404 | 0.05 - 0.05 | 0.05 - 0.05 | 9 of 9 | 9 of 9 |
The published ranges are reproduced closely, and for two of the three cases exactly: case 10 returns 588-1,560 ppm against the paper’s 588-1,560, and case 7 returns 48-3,660 ppm against the paper’s 48-3,660. The exposure-time ranges differ by at most a single 0.05 min grid step, which is the resolution of the search.
The qualitative pattern the paper reports is reproduced too. Case 17
is stable – the exposure time is pinned at the shortest grid step across
all nine perturbations, and every corner stays in the high group under
either criterion. Case 10 is very wide in both concentration and time
yet never leaves the low group. Case 7 is the ambiguous one, and the
last two columns show exactly how it is ambiguous: seven of its
nine perturbations place it in the high group by exposure duration,
while none of the nine do by air concentration. Both kinds of
disagreement are present at once – between the two criteria, and within
the duration criterion across the assay-error corners. That is what sits
behind the paper’s own hedged sentence that case 7 “could mostly be
distinguished from the Ca_high T_short group” and behind Table 2 filing
it as Ca_high T_short at only 1260 ppm. The two corners
that fall out of the high group by duration are the ones where the right
concentration rises to 1.380 ug/mL and the reconstruction jumps to 1.70
and 26.80 min. Notably the corner the paper itself singles out – right
1.380 and left 1.955 ug/mL, which it reports “was interpreted as part of
the Ca_low T_long group” – reconstructs here to 876 ppm at 0.70 min,
i.e. low by the concentration criterion but high by
the duration criterion. The paper’s remark therefore applies the
concentration criterion, while its Table 2 grouping applies the duration
criterion. That is the same inconsistency recorded in the Errata,
surfacing a second time.
6. Dimensional and mass-balance checks
The flow table closes exactly, which is the arithmetic check that the tissue lumping is self-consistent:
p <- setNames(exp(mod$theta[c("lq_liver", "lq_muscle", "lq_other", "lq_tot")]),
c("q_liver", "q_muscle", "q_other", "q_tot"))
tibble::tibble(
Check = c("q_liver + q_muscle + q_other (L/min)",
"q_tot (L/min)",
"Flows close to cardiac output",
"v_plasma + v_erythrocytes (L)",
"v_blood (L)"),
Value = c(sprintf("%.4f", sum(p[1:3])),
sprintf("%.4f", p[["q_tot"]]),
as.character(isTRUE(all.equal(sum(p[1:3]), p[["q_tot"]], tolerance = 1e-8))),
sprintf("%.4f", sum(exp(mod$theta[c("lv_plasma", "lv_erythrocytes")]))),
sprintf("%.4f", exp(mod$theta[["lv_blood"]])))
) |> knitr::kable()| Check | Value |
|---|---|
| q_liver + q_muscle + q_other (L/min) | 10.7000 |
| q_tot (L/min) | 10.7000 |
| Flows close to cardiac output | TRUE |
| v_plasma + v_erythrocytes (L) | 4.8000 |
| v_blood (L) | 4.8000 |
Dimensional analysis of each ODE, with states in umol/L and time in min:
| ODE | Term | Dimensions |
|---|---|---|
plasma |
q_alv * (ca - plasma/ppa) / v_plasma |
(L/min * umol/L) / L = umol/L/min |
plasma |
cl_erythrocytes * (...) / v_plasma |
(L/min * umol/L) / L = umol/L/min |
plasma |
fp * q_tot * (cvPlasma - plasma) / v_plasma |
(L/min * umol/L) / L = umol/L/min |
erythrocytes |
cl_erythrocytes * (...) / v_erythrocytes |
(L/min * umol/L) / L = umol/L/min |
liver |
fp * q_liver * (...) / v_liver |
(L/min * umol/L) / L = umol/L/min |
liver |
kscn * liver * sulfur_donor / v_liver |
(L/(umolmin) umol/L * umol/L) / L |
sulfur_donor |
kfs |
umol/L/min |
sulfur_donor |
kscn * liver * sulfur_donor |
L/(umolmin) umol/L * umol/L = umol/L/min |
sulfur_donor |
kes * sulfur_donor |
1/min * umol/L = umol/L/min |
Two dimensional irregularities in the source are worth flagging, and both are inherited unchanged:
- The
kscn * liver * sulfur_donorterm in the liver equation is divided byv_liverwhile the identical term in the sulfur_donor equation is not, so the two are not dimensionally parallel. Because the terms cancel in the liver equation (see Errata), this has no numerical consequence for the liver state. - The erythrocyte binding denominator
emax / (plasma + kaff)is an effective partition coefficient with dimensions of (umol/L)/(umol/L) only ifemaxandkaffare both concentrations, which is how they are labelled here.
Assumptions and deviations / Errata
Structural: the liver equation’s thiocyanate terms cancel
This is the most important thing to know about the packaged model. Supplemental Material 1 writes the liver ODE as
dCh = (Fp*Qh*(Cp-(Ch/Php)) - Kscn*Ch*S - (Kfs - Kscn*Ch*S - Kes*S))/Vh
The bracketed group is exactly dS, i.e. the sulfur-donor
derivative, subtracted from the liver equation. Expanding it, the two
Kscn*Ch*S terms cancel:
dCh = (Fp*Qh*(Cp - Ch/Php) - Kfs + Kes*S) / Vh
Three consequences follow, and each is a deviation between what the model says and what it does:
-
The shipped liver equation contains no cyanide-metabolism
sink, despite
kscnbeing present in the source and despite the paper’s Discussion stating that “the PBPK model used in this study included CN metabolism”. This is a text-versus-equation conflict; per the extraction convention the equation governs, so the equation is encoded verbatim. -
It contains an unconditional constant drain of
-Kfs = -2.2umol/L/min on the liver that does not depend on how much cyanide is present. That is what drives the negative concentrations seen in the zero-exposure check above. -
kscnstill has a real effect on the model, because it remains in thesulfur_donorequation and therefore governs pool depletion, and the pool feeds back into the liver through the+Kes*Sterm. It is not inert.
The model is encoded verbatim because Supplemental Material 1 is the code that produced Figure 3 and Table 2 of the published paper, and reproducing published results is this vignette’s job. The alternative reading – dropping the bracket so that the liver equation carries the rhodanese sink and nothing else – was simulated for comparison. Over the entire published exposure range the two are numerically indistinguishable in the blood observables (maximum difference 9e-3 ug/mL at the lowest published exposure of 84 ppm, falling to 2e-4 ug/mL at 15,000 ppm; total Table 2 sum of squares 0.89192 verbatim vs 0.89211 simplified), and the grid-search reconstructions are unchanged. The two readings diverge only below about 250 ppm, and only at zero exposure does the difference become qualitative.
Validity domain. Use the model at exposures at or above roughly 250 ppm, which covers every exposure Harada et al. reconstruct except case 4 (84 ppm, where the liver excursion reaches -1.27 umol/L against a liver peak two orders of magnitude larger, and the arterial observable is unaffected to within 0.009 ug/mL). Do not use it to model clean-air washout, post-exposure decay, or an intermittent exposure profile that returns to zero: with no cyanide present the liver state runs negative at 2.2/1.6 umol/L/min and the blood observables follow it below zero. A user who needs that behaviour should replace the liver ODE with the simplified reading above, and should re-validate, because that form is not the one the paper published.
Parameters and provenance
-
Clris not encoded. The Supplemental Material 1 parameter block definesClr = 0.0041(“first-order metabolic clearance of CN via remaining pathways”), but no ODE in the published script references it. It therefore has no effect on any published result. It is documented in a comment in the model file rather than carried inini(), which would leave a parameter that the model never uses. - Stamyr et al. 2015 is not on disk. It is not open access. Every parameter value used here comes from the Harada 2025 Supplemental Material 1 script, which lists each one explicitly with a name, a value and a unit comment, so no value was inferred, substituted from a class-typical default, or taken from any other paper. What cannot be independently verified is whether Harada et al. transcribed Stamyr’s values correctly; the excellent reproduction of Table 2 and Figure 3 confirms internal consistency but not fidelity to Stamyr.
-
Every parameter is
fixed(). Nothing in this paper is estimated. There is no IIV and no residual error, because the paper reports none; none has been invented. -
Unit conversions use HCN, not CN-. The authors’
factor of 0.027 ug/mL per umol/L corresponds to the molar mass of HCN
(27.03 g/mol), not of the cyanide anion actually measured in blood (CN-,
26.02 g/mol) – roughly a 4% difference. The authors’ factor is retained
because changing it would stop the model reproducing their published
concentrations. It is surfaced as the
mw_hcnparameter so a user can substitute 26.02 deliberately. - The air-phase conversion is 24 ppm per umol/L, from the supplement’s header comment. This corresponds to ideal-gas behaviour at about 25 C and 1 atm; the paper does not state a reference temperature, and fire-scene air is obviously much hotter, which is an unquantified bias in every reconstructed ppm value.
The published group criteria are not self-consistent
The Results define the two exposure groups as
Ca_high T_short = “>= 5000 ppm and < 1 min” and
Ca_low T_long = “< 5000 ppm and >= 1 min”. Table 2
files case 7 under Ca_high T_short at 1260 ppm and 0.40
min, which satisfies the duration half of the definition but not the
concentration half. Applying the stated concentration criterion to the
paper’s own Table 2 values therefore reproduces 12 of 13 group
assignments, while applying the duration criterion reproduces all 13
(section 3 shows this explicitly). The duration criterion is taken as
the operative one when scoring group agreement in this vignette. Nothing
in the model is affected – this is a reporting inconsistency in the
source, noted so that a reader who re-derives the grouping is not
surprised.
Exposure model
-
Exposure is constant from t = 0. The published
analysis holds the inhaled concentration fixed for the whole simulation
and reads the reconstructed “inhalation time” off the curve as the time
of death, rather than modelling cessation of breathing. The model
reproduces that design.
CONC_HCN_PPMis a covariate column, so a time-varying profile can be supplied, but note the validity-domain warning above before letting it fall to zero. - The paper’s Discussion names this as a limitation: real fire-scene HCN concentrations fluctuate dramatically over short periods, and the constant- exposure assumption is unlikely to hold.
-
The grid searched here is 0-18,000 ppm, from the
Methods text. The Supplemental Material 3 estimation script as published
loops over
range(0, 751)in 0.5 umol/L steps, i.e. only 0-9,000 ppm, which cannot have produced the published estimates up to 16,632 ppm. The Methods text is therefore taken as authoritative over the supplement’s loop bound.
Physiology and forensic interpretation
- Left cardiac blood is treated as arterial and right as venous. This is the paper’s central modelling assumption and it is an approximation. The Discussion argues that post-mortem diffusion of cyanide from lung tissue – where cyanide is known to concentrate – into the left atrium may be implicitly absorbed by treating the model’s arterial compartment as lung plus arterial blood combined. That reconciliation is asserted, not demonstrated, and post-mortem redistribution is not modelled.
- Methaemoglobin is not modelled. Combustion gases contain nitrogen oxides that generate methaemoglobin, which binds cyanide and detoxifies it. The paper states plainly that its model does not account for this.
-
Physiology is that of healthy adults, not
decedents.
Qalv= 16.5 L/min andQtot= 10.7 L/min are both well above adult resting values (roughly 6 and 5-6 L/min), consistent with Stamyr’s original study design rather than with a person at rest, let alone one dying. Body weight is not a covariate: all volumes and flows are fixed absolute values, so the model cannot be scaled to an individual decedent’s size. The paper’s Discussion identifies exactly this as needing refinement. - The reconstruction is not identified in both factors at short exposures. Section 3 above quantifies this: paired cardiac measurements pin down cumulative exposure much better than they pin down concentration and duration separately.