Extended cardiovascular systems (CVS) model, rat (Snelder 2014)
Source:vignettes/articles/Snelder_2014_cardiovascular_rat.Rmd
Snelder_2014_cardiovascular_rat.RmdModel and source
- Citation: Snelder N, Ploeger BA, Luttringer O, Rigel DF, Fu F, Beil M, Stanski DR, Danhof M. Drug effects on the CVS in conscious rats: separating cardiac output into heart rate and stroke volume using PKPD modelling. Br J Pharmacol. 2014 Nov;171(22):5076-5092. doi:10.1111/bph.12824. PMCID PMC4253457.
- Article (open access): Br J Pharmacol 2014;171(22):5076-5092 (PMCID PMC4253457)
Snelder 2014 challenged the cardiovascular system of conscious rats with eight drugs of deliberately diverse mechanism – amiloride, amlodipine, atropine, enalapril, fasudil, hydrochlorothiazide (HCTZ), prazosin and propranolol – and analysed every animal and every compound simultaneously in one NONMEM run. The design separates system-specific parameters, which every compound must share, from drug-specific parameters. Two rat strains were used: the spontaneously hypertensive rat (SHR) and its normotensive control strain, the Wistar-Kyoto (WKY) rat.
The paper’s structural contribution is to parse cardiac output into heart rate and stroke volume. Where the Snelder 2013 predecessor had two turnover states (CO and TPR), this model has three:
- HR, heart rate (beats/min)
-
SVT, the stroke-volume turnover state (mL/beat) –
written
SV*in the paper - TPR, total peripheral resistance (mmHg/(mL/min))
all three suppressed by a single shared linear negative feedback of
MAP. Actual stroke volume adds a direct inverse log-linear coupling to
heart rate, SV = SVT * (1 - HR_SV * ln(HR / BSL_HR)),
representing the shortening of left-ventricular filling time as the
cardiac interval shortens; then CO = HR * SV and
MAP = CO * TPR. Two 24 h cosines multiply the HR and TPR
production rates, and an exponentially decaying handling artefact –
brief manual restraint and oral gavage – transiently raises both.
The production rate constants are not estimated: they are derived from the baselines and dissipation rate constants so the system sits at its reported baseline (Equation 5). The feedback constant is likewise not a free per-strain parameter: it is a power function of the animal’s own baseline MAP (Equation 9), which is how the paper’s finding that feedback is roughly twice as strong in normotensive as in hypertensive rats is expressed.
Relationship to the two sibling models in nlmixr2lib
nlmixr2lib ships two other members of the Snelder CVS family, and their parameter sets must not be mixed.
| Model | Structure | Feedback | Circadian | Provenance |
|---|---|---|---|---|
Snelder_2013_cardiovascular_rat |
2 states (CO, TPR) | two constants, FB1 on CO and FB2 on TPR | five-harmonic cosine, additive on MAP | fitted, SHR only |
Snelder_2014_cardiovascular_rat (this file) |
3 states (HR, SVT, TPR) | one shared constant, a power function of individual BSL_MAP | two cosines, multiplicative on Kin_HR and Kin_TPR | fitted, SHR + WKY |
Fu_2023_cardiovascular_qsp |
3 states (HR, SVT, TPR) | one shared constant, fixed at 0.0029 | two cosines, multiplicative | inherits this paper’s system parameters, with a hypothetical drug |
Fu_2023_cardiovascular_qsp is a
stochastic-simulation-and-re-estimation identifiability study that fixes
the system parameters to “the parameter values from the published CVS
model”. Its vignette records that “the Snelder 2013, 2014 papers that
supply the rat baseline values are not on disk”. This extraction closes
that gap for the 2014 paper: every value below is transcribed from the
primary source. Fu 2023’s Supplemental S1 NONMEM control stream is used
in one place only – to settle whether Table 5’s IIV and residual “CV%”
columns are standard deviations or the log-normal
sqrt(exp(omega) - 1) form – and that use is flagged
explicitly where it occurs.
Population
pop <- nlmixr2lib::readModelDb("Snelder_2014_cardiovascular_rat")()$population
tibble::tibble(
Field = names(pop),
Value = vapply(pop, function(x) paste(as.character(x), collapse = "; "), character(1))
) |>
knitr::kable(caption = "Study population (Snelder 2014 Methods and Table 1)")| Field | Value |
|---|---|
| species | rat (male spontaneously hypertensive rat, SHR, and male normotensive Wistar-Kyoto rat, WKY; both Taconic Farms) |
| n_subjects | 12 |
| n_studies | 2 |
| study_names | Study 1 (single administrations of different doses on separate days, one vehicle day first; MAP, HR and SV measured with CO and TPR derived; amiloride, amlodipine, enalapril, fasudil, hydrochlorothiazide or prazosin; SHR and WKY rats); Study 2 (single, sequential or combined administration of atropine 10 mg/kg and/or propranolol 30 mg/kg with a 3 h interval; SHR only, 8 rats) |
| age_range | 41-54 weeks (SHR) and 35-38 weeks (WKY) at time of study. |
| weight_range | 367-504 g (SHR) and 499-600 g (WKY). |
| sex_female_pct | 0 |
| sex_notes | All animals were male (Methods, ‘Animals’). |
| disease_state | Spontaneous (genetic) hypertension in the SHR arm (BSL_MAP = 155 mmHg) versus normotension in the WKY arm (BSL_MAP = 102 mmHg); no induced disease model. Snelder 2014 states in the Conclusions that applications of the identified system-parameter set are limited to SHR and WKY rats. |
| dose_range | Study 1 (p.o., one dose per day on separate days after a vehicle day): amiloride 10 mg/kg; amlodipine 0.3, 1, 3, 10 mg/kg; enalapril 3, 10, 30 mg/kg; fasudil 3, 10, 30 mg/kg; hydrochlorothiazide 0.1, 0.3, 1, 3 mg/kg (first occasion) and 10, 30 mg/kg (second occasion); prazosin 0.04, 0.2, 1, 5 mg/kg. Study 2 (p.o.): atropine 10 mg/kg and propranolol 30 mg/kg, alone, sequentially 3 h apart, or combined. All compounds were given by oral gavage at 2 mL/kg. |
| regions | Preclinical; in-life work at Novartis Institutes for BioMedical Research, East Hanover, NJ, USA, with modelling at Leiden Academic Centre for Drug Research, The Netherlands. |
| instrumentation | Rats were surgically instrumented with BOTH an ascending-aortic transit-time flow probe and a femoral-artery catheter / radiotransmitter (as in Snelder et al. 2013a), giving continuous MAP, HR and CO. Flow cables were disconnected between 1700 h and 0700 h, so overnight only MAP and HR were captured. On experiment days baseline data were collected 0700-1000 h, drug was given at 1000 h (and at 1300 h in Study 2), and collection continued to 1700 h. Rats were housed on a 12 h light/dark cycle with lights on 0600-1800 h. |
| n_ode_states | 5 |
| notes | Ten SHR and two WKY rats were used across both studies, with repeated experiments in the same animals over periods of up to 6 months and sufficient washout between them, so the per-compound counts in Table 1 reflect reuse rather than distinct animals. Data from one SHR in Study 2 were excluded because it learned to disconnect its flow cable and responded far more strongly than the others. SV and TPR were never estimated directly: experimentally they were derived from the measured MAP, CO and HR, and in the modelling BSL_HR, BSL_MAP and BSL_CO were the estimated parameters with BSL_SV and BSL_TPR derived from them. Only HR, MAP and CO carry residual-error models for the same reason. The system was initialised at t = 0 h and pharmacological intervention started at t = 336 h (two weeks, determined empirically) so that the circadian oscillation had settled into its oscillating steady state before dosing; since dosing occurred at 1000 h clock time, model time t = 0 corresponds to 1000 h. Propranolol was administered and modelled but its effect was too small to quantify, so it contributes no drug-specific parameters and no covariate column. |
Ten SHR and two WKY rats in total, aged 41-54 weeks (SHR) and 35-38 weeks (WKY), 367-504 g and 499-600 g respectively. All were male. Animals were reused across compounds over periods of up to 6 months with washout between experiments, so the per-compound counts in Table 1 are not distinct animals. Each rat carried both an ascending-aortic transit-time flow probe and a femoral-artery catheter/radiotransmitter, giving continuous MAP, HR and CO; SV and TPR were derived from those three, both experimentally and in the model. Data from one SHR were excluded because it learned to disconnect its flow cable.
Housing was a 12 h light/dark cycle with lights on 0600-1800 h. Baseline data were collected 0700-1000 h, drug was given at 1000 h (and again at 1300 h in Study 2), and collection continued to 1700 h.
Source trace
Every value in
inst/modeldb/therapeuticArea/Snelder_2014_cardiovascular_rat.R
comes from the locations below; the same references appear as
# comments on each ini() line.
A convenient property of Snelder 2014 Table 5 is that it reports the
estimate, the relative standard error and the 95% confidence interval
for every parameter. Those three are redundant –
estimate * (1 +/- 1.96 * RSE / 100) must reproduce the
printed interval – so the table validates its own transcription. The
check is run in code below and covers all 19 estimated parameters.
System-specific parameters (Table 5)
| Model parameter | Paper symbol | Value | Fixed? | RSE % | 95% CI | Source |
|---|---|---|---|---|---|---|
lrbase_HR_shr |
BSL_HR_SHR | 310 beats/min | 1.12 | 303 - 317 | Table 5 | |
lrbase_HR_wky |
BSL_HR_WKY | 323 beats/min | 1.61 | 313 - 333 | Table 5 | |
lrbase_MAP_shr |
BSL_MAP_SHR | 155 mmHg | 0.684 | 153 - 157 | Table 5 | |
lrbase_MAP_wky |
BSL_MAP_WKY | 102 mmHg | 0.884 | 100 - 104 | Table 5 | |
lrbase_CO_shr |
BSL_CO_SHR | 69.0 mL/min | 4.17 | 63.4 - 74.6 | Table 5 | |
lrbase_CO_wky |
BSL_CO_WKY | 129 mL/min | 1.47 | 125 - 133 | Table 5 | |
lkout_HR |
kout_HR | 11.6 1/h | 19.1 | 7.27 - 15.9 | Table 5 | |
lkout_SV |
kout_SV | 0.126 1/h | 30.7 | 0.0501 - 0.202 | Table 5 | |
lkout_TPR |
kout_TPR | 3.58 1/h | 29.1 | 1.54 - 5.62 | Table 5 | |
lfb0 |
FB0 | 0.00290 1/mmHg | 5.93 | 0.00256 - 0.00324 | Table 5 | |
e_bslmap_fb |
FB0_MAP | -1.98 | 10.6 | -2.39 - -1.57 | Table 5; Equation 9 | |
lhrsv |
HR_SV | 0.312 | 15.6 | 0.216 - 0.408 | Table 5 | |
lkhd |
kHD | 4.70 1/h | 8.19 | 3.95 - 5.45 | Table 5 | |
lphd_HR |
P_HR | 0.632 | 9.67 | 0.512 - 0.752 | Table 5 | |
lphd_TPR |
P_TPR | 0.331 | 12.9 | 0.247 - 0.415 | Table 5 | |
hor_HR |
hor_HR | 8.73 h | 3.10 | 8.20 - 9.26 | Table 5 | |
amp_HR |
amp_HR | 0.0918 | 5.15 | 0.0825 - 0.101 | Table 5 | |
hor_TPR |
hor_TPR | 19.3 h | 1.92 | 18.6 - 20.0 | Table 5 | |
amp_TPR_ratio |
amp_TPR | 1 (= amp_HR) | FIX | Table 5 row “ampTPR: Fixed to ampHR” |
BSL_SV and BSL_TPR are derived, not estimated: the
Data analysis section states that “the BSL_MAP and BSL_CO and BSL_HR
were estimated and BSL_SV and BSL_TPR were derived from these
parameters”, via BSL_SV = BSL_CO / BSL_HR and
BSL_TPR = BSL_MAP / BSL_CO.
Drug-specific parameters (Table 5, with site and direction from Table 4)
The site of action and the direction of each effect come from Table
4’s “Effect” column and are restated verbatim in the Figure 3, S1 and S2
captions. Table 5 reports only the magnitude, so the model file keeps
every number byte-identical to the table and applies the sign in
model().
| Compound | Site | Direction | Form | Parameters | Source |
|---|---|---|---|---|---|
| Amiloride | SV | inhibit | Emax, Emax FIX 1 | EC50 245 ng/mL (RSE 25.1, CI 125-365) | Table 5; Table 4; Fig. S1 caption |
| Amlodipine | TPR | inhibit | Emax, Emax FIX 1 | EC50 82.8 ng/mL (RSE 4.99, CI 74.7-90.9) | Table 5; Table 4; Fig. 3 caption |
| Atropine | HR | stimulate | linear | SL 0.00149 per ng/mL (RSE 32.3, CI 0.000547-0.00243) | Table 5; Table 4; Fig. S2 caption |
| Enalapril | TPR and SV | inhibit | Emax, Emax FIX 1, effect cmt | EC50 1200 ng/mL (RSE 4.03, CI 1110-1290); ke0 0.163 1/h (RSE 5.07, CI 0.147-0.179) | Table 5; Table 4; Fig. S1 caption |
| Fasudil | TPR | inhibit | Emax, Emax FIX 1 | EC50 0.172 ng/mL (RSE 18.4, CI 0.110-0.234) | Table 5; Table 4; Fig. S1 caption |
| HCTZ | SV | inhibit | Emax, Emax FIX 1 | EC50 28900 ng/mL (RSE 7.65, CI 24600-33200) | Table 5; Table 4; Fig. S1 caption |
| Prazosin | TPR | inhibit | power | SL 0.328 (RSE 5.58, CI 0.292-0.364); POW 0.0910 (RSE 6.05, CI 0.0802-0.102) | Table 5; Table 4; Fig. S1 caption |
| Propranolol | HR | inhibit | – | none: “the effect of propranolol was too small to be quantified” | Results, Drug effects |
Emax is fixed to 1 for the five Emax compounds. Table 3 Assumption 2: “For compounds for which the maximum effect was not observed, complete inhibition (i.e. Emax = 1) was assumed at infinite concentrations to ensure identification of the EC50 parameter.” Each Table 5 sub-heading repeats it.
Two drug-specific parameters in Table 5 are not carried in the model file because they belong to literature PK models the paper does not otherwise publish: atropine’s Ka (1.17 1/h) and prazosin’s Ka (fixed to 99 1/h, stated in the Results rather than tabulated). Neither reconstructs a concentration-time profile on its own; see Assumptions and deviations.
Variability (Table 5)
| Model parameter | Paper row | Reported | Encoded |
|---|---|---|---|
etalrbase_HR |
BSL_HR (CV%) | 6.1 | variance 0.003721 |
etalrbase_MAP |
BSL_MAP (CV%) | 3.7 | variance 0.001369 |
etalrbase_CO |
BSL_CO (CV%) | 22.7 | variance 0.051529 |
propSd_HR |
Prop. Res. Error HR (CV%) | 7.8 | SD 0.078 |
propSd_MAP |
Prop. Res. Error MAP (CV%) | 6.0 | SD 0.060 |
propSd_CO |
Prop. Res. Error CO (CV%) | 6.9 | SD 0.069 |
IIV is on the three estimated baselines only (Results: “interindividual variations in the baseline values of the parameters, BSL_HR, BSL_MAP and BSL_CO, were allowed”), and residual error is proportional on the three measured readouts only (“The residual errors of TPR and SV were derived from these parameters”), which is why SV and TPR are exposed as outputs with no error model.
The IIV encoding turns on whether Table 5’s “CV%” is
sqrt(omega) * 100 or the exact log-normal
sqrt(exp(omega) - 1) * 100. Those differ by 2.5% for
BSL_CO, which is small but not nothing. Fu 2023 Supplemental S1, a
NONMEM control stream that re-encodes this same model, settles it: its
$OMEGA values are 0.00372, 0.00137 and 0.0515, whose square
roots are exactly 6.1%, 3.7% and 22.7%. The model file therefore uses
variance = (CV / 100)^2; the identity is checked in code
below.
Equations
| Model line | Paper |
|---|---|
d/dt(hr), d/dt(svt),
d/dt(tpr)
|
Equation 6 (= Equation 4 with the drug term added) |
sv <- svt * (1 - hrsv * log(hr / bsl_HR));
co <- hr * sv; map <- co * tpr
|
Equation 2 |
cr_HR, cr_TPR
|
Equation 3 |
hd_HR, hd_TPR,
d/dt(handling)
|
Equation 4 |
kin_HR, kin_SV, kin_TPR
|
Equation 5 |
eff_HR, eff_SV, eff_TPR
|
Equations 6 and 7 |
d/dt(effect) |
Equation 8 |
fb <- exp(lfb0) * (bsl_MAP / 155)^e_bslmap_fb |
Equation 9 |
bsl_SV, bsl_TPR
|
Data analysis narrative |
Note the asymmetry in Equation 6, which is the paper’s own: the circadian rhythm and the handling effect act on HR and TPR only, while all three states receive the MAP feedback and can receive a drug effect. SV has no circadian term of its own – the paper points out that the rhythm observed in SV, CO and MAP follows from the feedback and the HR coupling rather than being modelled directly.
Handling effect: closed form vs ODE state
Equation 4 writes the handling artefact as
HD_X = P_X * exp(-kHD * (t - tHD)) for
t > tHD. The model file realises it as a first-order
decay state receiving a unit impulse at each handling event. For a
single event the two are the same function; for the repeated handling of
Study 2 (dosing at 1000 h and again at 1300 h) the ODE superposes
correctly, whereas the closed form as printed covers one
tHD only. Both properties are verified below. The practical
consequence for a user is that handling events are supplied as
amt = 1, evid = 1, cmt = "handling" dose records.
Dimensional analysis
| Term | Units of the factors | Result |
|---|---|---|
kin_HR = kout_HR * bsl_HR / fb_bsl |
(1/h) * (beats/min) / 1 | (beats/min)/h |
d/dt(hr) = kin_HR * (1 + cr_HR) * (1 - fb * map) * (1 + eff_HR + hd_HR) - kout_HR * hr |
(beats/min)/h * 1 * 1 * 1 - (1/h)*(beats/min) | (beats/min)/h |
fb * map |
(1/mmHg) * mmHg | unitless |
fb = exp(lfb0) * (bsl_MAP / 155)^e_bslmap_fb |
(1/mmHg) * (mmHg/mmHg)^unitless | 1/mmHg |
cr_HR = amp_HR * cos(2 * pi * (t + hor_HR) / 24) |
1 * cos(h/h) | unitless |
hd_HR = phd_HR * handling |
1 * 1 | unitless |
sv = svt * (1 - hrsv * log(hr / bsl_HR)) |
(mL/beat) * (1 - 1 * log(1)) | mL/beat |
co = hr * sv |
(beats/min) * (mL/beat) | mL/min |
map = co * tpr |
(mL/min) * mmHg/(mL/min) | mmHg |
eff_HR = slope_atropine * CP_ATROPINE_NGML |
(mL/ng) * (ng/mL) | unitless |
Emax terms emax * C / (ec50 + C)
|
1 * (ng/mL)/(ng/mL) | unitless |
d/dt(effect) = ke0 * (C - effect) |
(1/h) * (ng/mL) | (ng/mL)/h |
d/dt(handling) = -khd * handling |
(1/h) * 1 | 1/h |
One published unit is inconsistent as printed. Table 5 labels the
prazosin power coefficient SL as (ng mL-1)-1, but for
EFF = SL * C^POW to be unitless SL must carry
(ng/mL)^-POW, i.e. (ng/mL)^-0.0910. The value
is used exactly as printed and the label is not “corrected” – the file
reproduces the paper – but the discrepancy is recorded here and in the
parameter’s label().
Load the model and set up
mod <- nlmixr2lib::readModelDb("Snelder_2014_cardiovascular_rat")
ui <- rxode2::rxode2(mod)
#> ℹ parameter labels from comments will be replaced by 'label()'
tv <- rxode2::zeroRe(ui) # typical-value model: etas and residual error zeroed
rxode2::rxSetSeed(20261102)
# Published values, restated here so every check below compares against the
# PAPER rather than against the model file it is meant to be testing.
pub <- list(
BSL_HR_SHR = 310, BSL_MAP_SHR = 155, BSL_CO_SHR = 69.0,
BSL_HR_WKY = 323, BSL_MAP_WKY = 102, BSL_CO_WKY = 129,
kout_HR = 11.6, kout_SV = 0.126, kout_TPR = 3.58,
FB0 = 0.00290, FB0_MAP = -1.98, HR_SV = 0.312,
kHD = 4.70, P_HR = 0.632, P_TPR = 0.331,
hor_HR = 8.73, amp_HR = 0.0918, hor_TPR = 19.3,
EC50_amiloride = 245, EC50_amlodipine = 82.8, SL_atropine = 0.00149,
EC50_enalapril = 1200, ke0_enalapril = 0.163, EC50_fasudil = 0.172,
EC50_hctz = 28900, SL_prazosin = 0.328, POW_prazosin = 0.0910,
CV_BSL_HR = 6.1, CV_BSL_MAP = 3.7, CV_BSL_CO = 22.7,
CV_res_HR = 7.8, CV_res_MAP = 6.0, CV_res_CO = 6.9
)
CP_COLS <- c("CP_AMILORIDE_NGML", "CP_AMLODIPINE_NGML", "CP_ATROPINE_NGML",
"CP_ENALAPRIL_NGML", "CP_FASUDIL_NGML", "CP_HCTZ_NGML",
"CP_PRAZOSIN_NGML")The model is a multi-endpoint model with three observed readouts, so
observation records name the endpoint in cmt
("HR", "MAP", "CO"); the solver
returns every derived quantity, including the unobserved SV
and TPR, as columns alongside them. Handling events are
dose records into the handling state.
# Build an event table: observations of the three measured readouts on a regular
# grid, optional handling impulses, all seven exposure columns defaulting to 0.
cvs_events <- function(tmax, by = 0.05, strain = 1, handling_times = numeric(0),
exposure = list()) {
tt <- seq(0, tmax, by = by)
obs <- do.call(rbind, lapply(c("HR", "MAP", "CO"), function(e) {
data.frame(time = tt, amt = NA_real_, evid = 0L, cmt = e)
}))
if (length(handling_times) > 0) {
obs <- rbind(obs, data.frame(time = handling_times, amt = 1,
evid = 1L, cmt = "handling"))
}
obs$id <- 1L
obs$STRAIN_SHR <- strain
for (nm in CP_COLS) obs[[nm]] <- 0
for (nm in names(exposure)) obs[[nm]] <- exposure[[nm]]
obs[order(obs$time, obs$evid), ]
}
# Solve the typical-value model and return one row per time point.
cvs_solve <- function(ev, ...) {
s <- rxode2::rxSolve(tv, ev, omega = NA, returnType = "data.frame",
addDosing = FALSE, ...)
keep <- c("time", "HR", "MAP", "CO", "SV", "TPR", "effect", "handling",
"bsl_HR", "bsl_MAP", "bsl_CO", "bsl_SV", "bsl_TPR", "fb",
"kin_HR", "kin_SV", "kin_TPR")
unique(s[, keep])
}1. Table 5 validates its own transcription
Every estimated parameter in Table 5 is printed with a value, an RSE
and a 95% confidence interval. The Wald interval
value * (1 +/- 1.96 * RSE/100) must reproduce the printed
bounds, so a mistyped digit in any one of the three shows up
immediately. This check covers all 19 estimated parameters.
tab5 <- tibble::tribble(
~parameter, ~value, ~rse, ~llci, ~ulci,
"BSL_HR_SHR", 310, 1.12, 303, 317,
"BSL_MAP_SHR", 155, 0.684, 153, 157,
"BSL_CO_SHR", 69.0, 4.17, 63.4, 74.6,
"BSL_HR_WKY", 323, 1.61, 313, 333,
"BSL_MAP_WKY", 102, 0.884, 100, 104,
"BSL_CO_WKY", 129, 1.47, 125, 133,
"kout_HR", 11.6, 19.1, 7.27, 15.9,
"kout_SV", 0.126, 30.7, 0.0501, 0.202,
"kout_TPR", 3.58, 29.1, 1.54, 5.62,
"FB0", 0.00290, 5.93, 0.00256, 0.00324,
"FB0_MAP", -1.98, 10.6, -2.39, -1.57,
"HR_SV", 0.312, 15.6, 0.216, 0.408,
"kHD", 4.70, 8.19, 3.95, 5.45,
"P_HR", 0.632, 9.67, 0.512, 0.752,
"P_TPR", 0.331, 12.9, 0.247, 0.415,
"hor_HR", 8.73, 3.10, 8.20, 9.26,
"amp_HR", 0.0918, 5.15, 0.0825, 0.101,
"hor_TPR", 19.3, 1.92, 18.6, 20.0,
"EC50_amiloride", 245, 25.1, 125, 365,
"EC50_amlodipine", 82.8, 4.99, 74.7, 90.9,
"SL_atropine", 0.00149, 32.3, 0.000547, 0.00243,
"EC50_enalapril", 1200, 4.03, 1110, 1290,
"ke0_enalapril", 0.163, 5.07, 0.147, 0.179,
"EC50_fasudil", 0.172, 18.4, 0.110, 0.234,
"EC50_hctz", 28900, 7.65, 24600, 33200,
"SL_prazosin", 0.328, 5.58, 0.292, 0.364,
"POW_prazosin", 0.0910, 6.05, 0.0802, 0.102
)
# Every number in Table 5 is printed to three significant figures, so the
# comparison tolerance is set by that rounding rather than chosen by hand. One
# unit in the last printed place is `ulp()`; the reconstructed width can differ
# from the printed one by up to half a ulp on each bound, plus the propagated
# effect of rounding the estimate and the RSE themselves.
ulp <- function(x) 10^(floor(log10(abs(x))) - 2)
tab5 <- tab5 |>
dplyr::mutate(
se = abs(value) * rse / 100,
wald_low = value - 1.96 * se,
wald_high = value + 1.96 * se,
printed_width = ulci - llci,
wald_width = wald_high - wald_low,
tol = 1.25 * ((ulp(llci) + ulp(ulci)) / 2 +
2 * 1.96 * (ulp(value) / 2 * rse / 100 +
abs(value) * ulp(rse) / 100 / 2)),
ok = abs(wald_width - printed_width) <= tol,
headroom = tol / abs(wald_width - printed_width)
)
tab5 |>
dplyr::transmute(Parameter = parameter, Value = value, `RSE %` = rse,
`Printed CI` = sprintf("%.4g to %.4g", llci, ulci),
`Wald CI from RSE` = sprintf("%.4g to %.4g", wald_low, wald_high),
`Width discrepancy` = sprintf("%.3g", abs(wald_width - printed_width)),
`Rounding tolerance` = sprintf("%.3g", tol),
OK = ok) |>
knitr::kable(caption = "Snelder 2014 Table 5 is internally redundant: the RSE reconstructs the printed 95% CI for every estimated parameter, to within the rounding of the printed numbers.")| Parameter | Value | RSE % | Printed CI | Wald CI from RSE | Width discrepancy | Rounding tolerance | OK |
|---|---|---|---|---|---|---|---|
| BSL_HR_SHR | 3.10e+02 | 1.120 | 303 to 317 | 303.2 to 316.8 | 0.39 | 1.35 | TRUE |
| BSL_MAP_SHR | 1.55e+02 | 0.684 | 153 to 157 | 152.9 to 157.1 | 0.156 | 1.27 | TRUE |
| BSL_CO_SHR | 6.90e+01 | 4.170 | 63.4 to 74.6 | 63.36 to 74.64 | 0.079 | 0.152 | TRUE |
| BSL_HR_WKY | 3.23e+02 | 1.610 | 313 to 333 | 312.8 to 333.2 | 0.385 | 1.37 | TRUE |
| BSL_MAP_WKY | 1.02e+02 | 0.884 | 100 to 104 | 100.2 to 103.8 | 0.465 | 1.27 | TRUE |
| BSL_CO_WKY | 1.29e+02 | 1.470 | 125 to 133 | 125.3 to 132.7 | 0.567 | 1.32 | TRUE |
| kout_HR | 1.16e+01 | 19.100 | 7.27 to 15.9 | 7.257 to 15.94 | 0.0552 | 0.144 | TRUE |
| kout_SV | 1.26e-01 | 30.700 | 0.0501 to 0.202 | 0.05018 to 0.2018 | 0.000267 | 0.00175 | TRUE |
| kout_TPR | 3.58e+00 | 29.100 | 1.54 to 5.62 | 1.538 to 5.622 | 0.00378 | 0.0284 | TRUE |
| FB0 | 2.90e-03 | 5.930 | 0.00256 to 0.00324 | 0.002563 to 0.003237 | 5.88e-06 | 1.47e-05 | TRUE |
| FB0_MAP | -1.98e+00 | 10.600 | -2.39 to -1.57 | -2.391 to -1.569 | 0.00273 | 0.0199 | TRUE |
| HR_SV | 3.12e-01 | 15.600 | 0.216 to 0.408 | 0.2166 to 0.4074 | 0.00121 | 0.0024 | TRUE |
| kHD | 4.70e+00 | 8.190 | 3.95 to 5.45 | 3.946 to 5.454 | 0.00893 | 0.0157 | TRUE |
| P_HR | 6.32e-01 | 9.670 | 0.512 to 0.752 | 0.5122 to 0.7518 | 0.000432 | 0.00164 | TRUE |
| P_TPR | 3.31e-01 | 12.900 | 0.247 to 0.415 | 0.2473 to 0.4147 | 0.00062 | 0.00238 | TRUE |
| hor_HR | 8.73e+00 | 3.100 | 8.2 to 9.26 | 8.2 to 9.26 | 0.00087 | 0.0154 | TRUE |
| amp_HR | 9.18e-02 | 5.150 | 0.0825 to 0.101 | 0.08253 to 0.1011 | 3.26e-05 | 0.000723 | TRUE |
| hor_TPR | 1.93e+01 | 1.920 | 18.6 to 20 | 18.57 to 20.03 | 0.0526 | 0.134 | TRUE |
| EC50_amiloride | 2.45e+02 | 25.100 | 125 to 365 | 124.5 to 365.5 | 1.06 | 2.47 | TRUE |
| EC50_amlodipine | 8.28e+01 | 4.990 | 74.7 to 90.9 | 74.7 to 90.9 | 0.00366 | 0.158 | TRUE |
| SL_atropine | 1.49e-03 | 32.300 | 0.000547 to 0.00243 | 0.0005467 to 0.002433 | 3.58e-06 | 1.84e-05 | TRUE |
| EC50_enalapril | 1.20e+03 | 4.030 | 1110 to 1290 | 1105 to 1295 | 9.57 | 13.8 | TRUE |
| ke0_enalapril | 1.63e-01 | 5.070 | 0.147 to 0.179 | 0.1468 to 0.1792 | 0.000395 | 0.00141 | TRUE |
| EC50_fasudil | 1.72e-01 | 18.400 | 0.11 to 0.234 | 0.11 to 0.234 | 6.02e-05 | 0.00212 | TRUE |
| EC50_hctz | 2.89e+04 | 7.650 | 2.46e+04 to 3.32e+04 | 2.457e+04 to 3.323e+04 | 66.5 | 151 | TRUE |
| SL_prazosin | 3.28e-01 | 5.580 | 0.292 to 0.364 | 0.2921 to 0.3639 | 0.000255 | 0.00147 | TRUE |
| POW_prazosin | 9.10e-02 | 6.050 | 0.0802 to 0.102 | 0.08021 to 0.1018 | 0.000218 | 0.000725 | TRUE |
cat(sprintf("all %d parameters consistent: %s (tightest row: %s, %.2fx headroom)\n",
nrow(tab5), all(tab5$ok),
tab5$parameter[which.min(tab5$headroom)], min(tab5$headroom)))
#> all 27 parameters consistent: TRUE (tightest row: EC50_enalapril, 1.44x headroom)
stopifnot(
# Every reconstructed interval width matches the printed one within rounding.
all(tab5$ok),
# The printed estimate lies strictly inside its own printed interval.
all(tab5$value > tab5$llci & tab5$value < tab5$ulci),
# And the printed interval is symmetric about the estimate, as a Wald interval
# must be -- asymmetry would mean the interval is not the one the RSE implies.
all(abs((tab5$ulci - tab5$value) - (tab5$value - tab5$llci)) <=
tab5$tol + ulp(tab5$value))
)This matters most for EC50_fasudil = 0.172 ng/mL, which
is roughly 1900-fold away from the 321 ng/mL that Snelder 2013 reported
for the same compound on the same experimental platform. Because 0.172
reconstructs its own printed interval (0.110 to 0.234) exactly, the
value is not a transcription or PDF-extraction artefact: it is what the
paper reports. See Assumptions and deviations.
2. Baseline steady-state hold
With no drug, no handling and the circadian amplitude set to zero,
the system must sit exactly at its reported baseline forever – that is
what Equation 5 constructs Kin to guarantee. This is run in
both strains, because the strain enters through three different
baselines and, via Equation 9, through the feedback constant as
well.
ss_check <- function(strain) {
s <- cvs_solve(cvs_events(48, by = 0.25, strain = strain), params = c(amp_HR = 0))
tibble::tibble(
Strain = if (strain == 1) "SHR" else "WKY",
HR = s$HR[1], MAP = s$MAP[1], CO = s$CO[1], SV = s$SV[1], TPR = s$TPR[1],
`max drift HR` = max(abs(s$HR - s$HR[1])),
`max drift MAP` = max(abs(s$MAP - s$MAP[1])),
`max drift CO` = max(abs(s$CO - s$CO[1]))
)
}
ss <- dplyr::bind_rows(ss_check(1), ss_check(0))
knitr::kable(ss, digits = 6,
caption = "Baseline steady-state hold over 48 h with the circadian rhythm switched off.")| Strain | HR | MAP | CO | SV | TPR | max drift HR | max drift MAP | max drift CO |
|---|---|---|---|---|---|---|---|---|
| SHR | 310 | 155 | 69 | 0.222581 | 2.246377 | 0 | 0 | 0 |
| WKY | 323 | 102 | 129 | 0.399381 | 0.790698 | 0 | 0 | 0 |
stopifnot(
# The state does not move at all: this is an exact algebraic fixed point.
max(ss$`max drift HR`, ss$`max drift MAP`, ss$`max drift CO`) < 1e-8,
# And it sits on the published baselines.
abs(ss$HR[1] - pub$BSL_HR_SHR) < 1e-6,
abs(ss$MAP[1] - pub$BSL_MAP_SHR) < 1e-6,
abs(ss$CO[1] - pub$BSL_CO_SHR) < 1e-6,
abs(ss$HR[2] - pub$BSL_HR_WKY) < 1e-6,
abs(ss$MAP[2] - pub$BSL_MAP_WKY) < 1e-6,
abs(ss$CO[2] - pub$BSL_CO_WKY) < 1e-6
)The fixed point is not a coincidence of the numbers: at
HR = BSL_HR the HR-on-SV coupling term is
1 - HR_SV * ln(1) = 1, so SV = BSL_SV and
MAP = BSL_HR * BSL_SV * BSL_TPR = BSL_CO * BSL_TPR = BSL_MAP.
Substituting into Equation 5 gives production
= kout_X * BSL_X exactly, cancelling the loss term for each
of the three states.
3. Derived baselines BSL_SV and BSL_TPR
derived <- tibble::tibble(
Strain = c("SHR", "WKY"),
BSL_HR = c(pub$BSL_HR_SHR, pub$BSL_HR_WKY),
BSL_MAP = c(pub$BSL_MAP_SHR, pub$BSL_MAP_WKY),
BSL_CO = c(pub$BSL_CO_SHR, pub$BSL_CO_WKY)
) |>
dplyr::mutate(
`BSL_SV = BSL_CO/BSL_HR` = BSL_CO / BSL_HR,
`BSL_TPR = BSL_MAP/BSL_CO` = BSL_MAP / BSL_CO,
`model SV` = ss$SV,
`model TPR` = ss$TPR
)
knitr::kable(derived, digits = 6,
caption = "BSL_SV and BSL_TPR are derived from the three estimated baselines.")| Strain | BSL_HR | BSL_MAP | BSL_CO | BSL_SV = BSL_CO/BSL_HR | BSL_TPR = BSL_MAP/BSL_CO | model SV | model TPR |
|---|---|---|---|---|---|---|---|
| SHR | 310 | 155 | 69 | 0.222581 | 2.246377 | 0.222581 | 2.246377 |
| WKY | 323 | 102 | 129 | 0.399381 | 0.790698 | 0.399381 | 0.790698 |
stopifnot(
max(abs(derived$`BSL_SV = BSL_CO/BSL_HR` - derived$`model SV`)) < 1e-9,
max(abs(derived$`BSL_TPR = BSL_MAP/BSL_CO` - derived$`model TPR`)) < 1e-9
)The paper’s own qualitative statement follows: SHR have a lower BSL_SV and a higher BSL_TPR than WKY rats.
stopifnot(
derived$`model SV`[1] < derived$`model SV`[2],
derived$`model TPR`[1] > derived$`model TPR`[2]
)4. Equation 9: feedback declines with baseline MAP
Equation 9 is
FB = FB0 * (IBSL_MAP / TVBSL_MAP_SHR)^FB0_MAP, with
TVBSL_MAP_SHR = 155 mmHg, the SHR typical value, used as
the reference for both strains. The paper reports the
consequence rather than the WKY number: “Overall, the feedback is about
twofold higher in WKY rats as compared with SHR.” That statement is the
check.
fb_pub <- function(bsl_map) pub$FB0 * (bsl_map / pub$BSL_MAP_SHR)^pub$FB0_MAP
fb_tab <- tibble::tibble(
Strain = c("SHR", "WKY"),
BSL_MAP = c(pub$BSL_MAP_SHR, pub$BSL_MAP_WKY),
`FB (Eq 9)` = fb_pub(c(pub$BSL_MAP_SHR, pub$BSL_MAP_WKY)),
`FB (model)` = ss$MAP * 0 + c(
cvs_solve(cvs_events(1, by = 0.5, strain = 1), params = c(amp_HR = 0))$fb[1],
cvs_solve(cvs_events(1, by = 0.5, strain = 0), params = c(amp_HR = 0))$fb[1]
)
) |>
dplyr::mutate(`1 - FB*BSL_MAP` = 1 - `FB (model)` * BSL_MAP)
knitr::kable(fb_tab, digits = 6,
caption = "Feedback constant by strain (Equation 9, reference 155 mmHg).")| Strain | BSL_MAP | FB (Eq 9) | FB (model) | 1 - FB*BSL_MAP |
|---|---|---|---|---|
| SHR | 155 | 0.002900 | 0.002900 | 0.550500 |
| WKY | 102 | 0.006641 | 0.006641 | 0.322629 |
ratio <- fb_tab$`FB (model)`[2] / fb_tab$`FB (model)`[1]
cat(sprintf("WKY / SHR feedback ratio: %.3f\n", ratio))
#> WKY / SHR feedback ratio: 2.290
stopifnot(
# The model reproduces Equation 9 evaluated by hand.
max(abs(fb_tab$`FB (model)` - fb_tab$`FB (Eq 9)`)) < 1e-12,
# "About twofold higher in WKY rats" (Results, SHR versus WKY rats).
ratio > 1.8 && ratio < 2.8,
# The production-rate multiplier must stay positive in both strains or the
# steady-state Kin of Equation 5 would be negative.
all(fb_tab$`1 - FB*BSL_MAP` > 0)
)The 1 - FB*BSL_MAP column is worth noting for anyone
simulating large perturbations: it is 0.55 in SHR but only 0.32 in WKY
rats, so the normotensive strain has appreciably less headroom before
the production term would change sign.
5. Flux balance at the fixed point
At the (non-oscillating) baseline, production and loss must cancel exactly for each of the three turnover states. Done symbolically from the published values, independent of the solver.
flux <- function(bsl_map, bsl_hr, bsl_co) {
fb <- fb_pub(bsl_map)
bsl <- c(HR = bsl_hr, SV = bsl_co / bsl_hr, TPR = bsl_map / bsl_co)
kout <- c(HR = pub$kout_HR, SV = pub$kout_SV, TPR = pub$kout_TPR)
kin <- kout * bsl / (1 - fb * bsl_map)
tibble::tibble(State = names(bsl),
`Kin (Eq 5)` = unname(kin),
Production = unname(kin * (1 - fb * bsl_map)),
Loss = unname(kout * bsl),
Net = unname(kin * (1 - fb * bsl_map) - kout * bsl))
}
fl <- dplyr::bind_rows(
dplyr::mutate(flux(pub$BSL_MAP_SHR, pub$BSL_HR_SHR, pub$BSL_CO_SHR), Strain = "SHR"),
dplyr::mutate(flux(pub$BSL_MAP_WKY, pub$BSL_HR_WKY, pub$BSL_CO_WKY), Strain = "WKY")
) |>
dplyr::select(Strain, State, `Kin (Eq 5)`, Production, Loss, Net)
knitr::kable(fl, digits = 8,
caption = "Production and loss fluxes cancel at baseline. Note that Kin is the zero-order rate CONSTANT of Equation 5; the production FLUX is Kin * (1 - FB*MAP), which is what must equal the loss.")| Strain | State | Kin (Eq 5) | Production | Loss | Net |
|---|---|---|---|---|---|
| SHR | HR | 6.532243e+03 | 3.596000e+03 | 3.596000e+03 | 0 |
| SHR | SV | 5.094489e-02 | 2.804516e-02 | 2.804516e-02 | 0 |
| SHR | TPR | 1.460859e+01 | 8.042029e+00 | 8.042029e+00 | 0 |
| WKY | HR | 1.161334e+04 | 3.746800e+03 | 3.746800e+03 | 0 |
| WKY | SV | 1.559748e-01 | 5.032198e-02 | 5.032198e-02 | 0 |
| WKY | TPR | 8.773847e+00 | 2.830698e+00 | 2.830698e+00 | 0 |
# The solver's own Kin values must equal the hand computation from Equation 5.
ms <- cvs_solve(cvs_events(1, by = 0.5, strain = 1), params = c(amp_HR = 0))
kin_shr <- fl$`Kin (Eq 5)`[fl$Strain == "SHR"]
names(kin_shr) <- fl$State[fl$Strain == "SHR"]
stopifnot(
max(abs(fl$Net)) < 1e-9,
abs(ms$kin_HR[1] - kin_shr[["HR"]]) < 1e-9,
abs(ms$kin_SV[1] - kin_shr[["SV"]]) < 1e-9,
abs(ms$kin_TPR[1] - kin_shr[["TPR"]]) < 1e-9
)6. Circadian rhythm (Equation 3)
Two 24 h cosines multiply the HR and TPR production rates. The paper reports the amplitude as 0.09, “indicating that the variation in Kin_HR and Kin_TPR is maximally 9% during the day”, and states that the two horizontal displacements are significantly different, “even if one of the cosines would have been replaced by a sine (i.e. a shift of 12 h)”.
circ <- cvs_solve(cvs_events(336 + 48, by = 0.25, strain = 1))
last48 <- circ[circ$time >= 336, ]
circ_summary <- tibble::tibble(
Readout = c("HR", "MAP", "CO", "SV", "TPR"),
Mean = vapply(c("HR", "MAP", "CO", "SV", "TPR"),
function(v) mean(last48[[v]]), numeric(1)),
Min = vapply(c("HR", "MAP", "CO", "SV", "TPR"),
function(v) min(last48[[v]]), numeric(1)),
Max = vapply(c("HR", "MAP", "CO", "SV", "TPR"),
function(v) max(last48[[v]]), numeric(1))
) |>
dplyr::mutate(`Peak-to-trough, % of mean` = 100 * (Max - Min) / Mean)
knitr::kable(circ_summary, digits = 3,
caption = "Oscillating steady state over the last 48 h before intervention.")| Readout | Mean | Min | Max | Peak-to-trough, % of mean |
|---|---|---|---|---|
| HR | 310.326 | 279.734 | 342.098 | 20.096 |
| MAP | 154.812 | 152.157 | 157.438 | 3.412 |
| CO | 69.060 | 64.453 | 73.749 | 13.460 |
| SV | 0.223 | 0.215 | 0.231 | 6.790 |
| TPR | 2.248 | 2.068 | 2.430 | 16.108 |
last48 |>
dplyr::select(time, HR, MAP, CO, SV, TPR) |>
tidyr::pivot_longer(-time, names_to = "Readout", values_to = "Value") |>
ggplot2::ggplot(ggplot2::aes(time - 336, Value)) +
ggplot2::geom_line() +
ggplot2::facet_wrap(~Readout, scales = "free_y", ncol = 2) +
ggplot2::labs(x = "Time (h) after the 336 h initialisation period",
y = NULL,
title = "Circadian rhythm in all five CVS readouts",
subtitle = "Cosines are applied to Kin_HR and Kin_TPR only; the rhythm in SV, CO and MAP follows from the feedback") +
ggplot2::theme_bw()
# The circadian rhythm is imposed on the production rates with amplitude 0.0918,
# so no readout's oscillation may exceed roughly twice that in relative terms.
stopifnot(all(circ_summary$`Peak-to-trough, % of mean` < 2 * 100 * pub$amp_HR * 1.2))
# Equation 5 sets Kin ignoring the rhythm, so the paper expects the oscillation
# to sit AROUND the baseline rather than on it. Confirm the mean is close.
mean_dev <- abs(circ_summary$Mean[circ_summary$Readout == "MAP"] / pub$BSL_MAP_SHR - 1)
cat(sprintf("mean MAP over the last 48 h is %.3f%% from BSL_MAP\n", 100 * mean_dev))
#> mean MAP over the last 48 h is 0.121% from BSL_MAP
stopifnot(mean_dev < 0.01)
# Phase. The production-rate cosines peak where 2*pi*(t + hor)/24 is a multiple
# of 2*pi, i.e. at t = 24 - hor (mod 24): 15.27 h for HR and 4.70 h for TPR. The
# STATE peak lags the production peak, so allow a couple of hours.
peak_hr <- (last48$time[which.max(last48$HR)]) %% 24
peak_tpr <- (last48$time[which.max(last48$TPR)]) %% 24
cat(sprintf("HR state peaks at t mod 24 = %.2f h (Kin_HR peak %.2f h)\n",
peak_hr, (24 - pub$hor_HR) %% 24))
#> HR state peaks at t mod 24 = 15.75 h (Kin_HR peak 15.27 h)
cat(sprintf("TPR state peaks at t mod 24 = %.2f h (Kin_TPR peak %.2f h)\n",
peak_tpr, (24 - pub$hor_TPR) %% 24))
#> TPR state peaks at t mod 24 = 4.50 h (Kin_TPR peak 4.70 h)
stopifnot(
abs(peak_hr - (24 - pub$hor_HR) %% 24) < 2,
abs(peak_tpr - (24 - pub$hor_TPR) %% 24) < 2
)Because dosing happened at 1000 h clock time and the paper started
pharmacological intervention at t = 336 h, which is exactly
14 x 24 h, model time t = 0 corresponds to 1000 h. On that
clock the HR production rate peaks at 0116 h and the TPR production rate
at 1442 h – HR in the dark phase and TPR in the light phase, which is
the expected direction for a nocturnal species on the paper’s 0600-1800
h light cycle. This is a consistency observation, not a paper claim.
7. Handling effect (Equation 4)
h1 <- cvs_solve(cvs_events(6, by = 0.02, handling_times = 0), params = c(amp_HR = 0))
h2 <- cvs_solve(cvs_events(6, by = 0.02, handling_times = c(0, 3)), params = c(amp_HR = 0))
closed_1 <- exp(-pub$kHD * h1$time)
closed_2 <- exp(-pub$kHD * h2$time) +
ifelse(h2$time >= 3, exp(-pub$kHD * (h2$time - 3)), 0)
cat(sprintf("single event : max |ODE state - exp(-kHD*t)| = %.2e\n",
max(abs(h1$handling - closed_1))))
#> single event : max |ODE state - exp(-kHD*t)| = 1.42e-08
cat(sprintf("two events : max |ODE state - superposition| = %.2e\n",
max(abs(h2$handling - closed_2))))
#> two events : max |ODE state - superposition| = 1.31e-07
cat(sprintf("handling half-life = %.4f h (ln(2)/kHD = %.4f h)\n",
stats::approx(h1$handling, h1$time, xout = 0.5)$y, log(2) / pub$kHD))
#> handling half-life = 0.1477 h (ln(2)/kHD = 0.1475 h)
stopifnot(
max(abs(h1$handling - closed_1)) < 1e-6,
max(abs(h2$handling - closed_2)) < 1e-6,
abs(stats::approx(h1$handling, h1$time, xout = 0.5)$y - log(2) / pub$kHD) < 1e-3
)
h2 |>
dplyr::select(time, HR, MAP, CO, SV, TPR) |>
tidyr::pivot_longer(-time, names_to = "Readout", values_to = "Value") |>
ggplot2::ggplot(ggplot2::aes(time, Value)) +
ggplot2::geom_line() +
ggplot2::geom_vline(xintercept = c(0, 3), linetype = "dashed", colour = "grey50") +
ggplot2::facet_wrap(~Readout, scales = "free_y", ncol = 2) +
ggplot2::labs(x = "Time (h)", y = NULL,
title = "Handling artefact after vehicle administration at 0 h and 3 h",
subtitle = "Reproduces the qualitative pattern of Snelder 2014 Figure 2: HR, TPR, CO and MAP rise, SV falls") +
ggplot2::theme_bw()
The paper’s description of the artefact (Methods, “Data analysis”) is that handling “caused a temporary increase in HR, TPR, CO and MAP and decrease in SV that was independent of drug exposure”. The model reproduces all five directions even though the handling term appears only on the HR and TPR production rates: the SV fall is a consequence of the direct HR-on-SV coupling, and the CO and MAP rises follow from the products.
peak <- function(v) h1[[v]][which.max(abs(h1[[v]] - h1[[v]][1]))]
dirs <- tibble::tibble(
Readout = c("HR", "TPR", "CO", "MAP", "SV"),
Baseline = vapply(c("HR", "TPR", "CO", "MAP", "SV"), function(v) h1[[v]][1], numeric(1)),
Extreme = vapply(c("HR", "TPR", "CO", "MAP", "SV"), peak, numeric(1))
) |>
dplyr::mutate(`% change at the extreme` = 100 * (Extreme / Baseline - 1))
knitr::kable(dirs, digits = 4,
caption = "Direction of the handling effect on each readout (single event at t = 0).")| Readout | Baseline | Extreme | % change at the extreme |
|---|---|---|---|
| HR | 310.0000 | 374.7209 | 20.8777 |
| TPR | 2.2464 | 2.3260 | 3.5451 |
| CO | 69.0000 | 78.3787 | 13.5923 |
| MAP | 155.0000 | 182.2729 | 17.5954 |
| SV | 0.2226 | 0.2091 | -6.0462 |
stopifnot(
dirs$`% change at the extreme`[dirs$Readout == "HR"] > 0,
dirs$`% change at the extreme`[dirs$Readout == "TPR"] > 0,
dirs$`% change at the extreme`[dirs$Readout == "CO"] > 0,
dirs$`% change at the extreme`[dirs$Readout == "MAP"] > 0,
dirs$`% change at the extreme`[dirs$Readout == "SV"] < 0
)8. Enalapril effect compartment (Equation 8)
Equation 8 is dCe/dt = ke0 * (C - Ce). Under a step
input of C held constant the analytical solution is
Ce(t) = C * (1 - exp(-ke0 * t)), and the time to reach half
the plateau is ln(2)/ke0, which the Results report as “the
half-life of this additional delay was 4.3 h”.
ev <- cvs_events(48, by = 0.05, exposure = list(CP_ENALAPRIL_NGML = pub$EC50_enalapril))
ec <- cvs_solve(ev, params = c(amp_HR = 0))
ana <- pub$EC50_enalapril * (1 - exp(-pub$ke0_enalapril * ec$time))
t_half <- stats::approx(ec$effect, ec$time, xout = pub$EC50_enalapril / 2)$y
cat(sprintf("max |Ce(ODE) - Ce(analytic)| = %.2e ng/mL\n", max(abs(ec$effect - ana))))
#> max |Ce(ODE) - Ce(analytic)| = 3.87e-06 ng/mL
cat(sprintf("half-time to plateau = %.3f h; ln(2)/ke0 = %.3f h; paper reports 4.3 h\n",
t_half, log(2) / pub$ke0_enalapril))
#> half-time to plateau = 4.252 h; ln(2)/ke0 = 4.252 h; paper reports 4.3 h
stopifnot(
max(abs(ec$effect - ana)) < 1e-3,
abs(t_half - log(2) / pub$ke0_enalapril) < 0.01,
abs(t_half - 4.3) < 0.15
)9. Concentration-effect forms (Equation 7)
Each compound’s EFF must have the defining property of
its published form: an Emax curve reaches Emax/2 at its own EC50; the
linear form is proportional to concentration with the published slope;
the power form has the published exponent. This checks that the
log-scale lec50_* entries were exponentiated correctly and
that the signs are the ones Table 4 specifies.
emax_form <- function(cc, ec50) cc / (ec50 + cc)
conc <- 10^seq(-3, 6, length.out = 200)
ce <- dplyr::bind_rows(
tibble::tibble(Compound = "amiloride (SV)", Conc = conc,
EFF = -emax_form(conc, pub$EC50_amiloride)),
tibble::tibble(Compound = "amlodipine (TPR)", Conc = conc,
EFF = -emax_form(conc, pub$EC50_amlodipine)),
tibble::tibble(Compound = "enalapril (TPR+SV)", Conc = conc,
EFF = -emax_form(conc, pub$EC50_enalapril)),
tibble::tibble(Compound = "fasudil (TPR)", Conc = conc,
EFF = -emax_form(conc, pub$EC50_fasudil)),
tibble::tibble(Compound = "HCTZ (SV)", Conc = conc,
EFF = -emax_form(conc, pub$EC50_hctz)),
tibble::tibble(Compound = "atropine (HR)", Conc = conc,
EFF = pub$SL_atropine * conc),
tibble::tibble(Compound = "prazosin (TPR)", Conc = conc,
EFF = -pub$SL_prazosin * conc^pub$POW_prazosin)
)
ce |>
dplyr::filter(EFF >= -1.05, EFF <= 1.05) |>
ggplot2::ggplot(ggplot2::aes(Conc, EFF, colour = Compound)) +
ggplot2::geom_line() +
ggplot2::geom_hline(yintercept = c(-1, -0.5, 0), linetype = "dotted", colour = "grey60") +
ggplot2::scale_x_log10() +
ggplot2::labs(x = "Plasma concentration (ng/mL, log scale)",
y = "EFF (signed; negative = inhibition of production)",
title = "Concentration-effect relationships (Snelder 2014 Equation 7, Table 5)") +
ggplot2::theme_bw()
# Emax forms: EFF = -0.5 exactly at the published EC50.
half <- c(amiloride = pub$EC50_amiloride, amlodipine = pub$EC50_amlodipine,
enalapril = pub$EC50_enalapril, fasudil = pub$EC50_fasudil,
hctz = pub$EC50_hctz)
stopifnot(max(abs(-emax_form(half, half) - (-0.5))) < 1e-12)
# Linear form: doubling the concentration doubles the effect.
stopifnot(abs(pub$SL_atropine * 2000 - 2 * (pub$SL_atropine * 1000)) < 1e-15)
# Power form: the exponent is recovered from the slope on the log-log scale.
pw <- diff(log(pub$SL_prazosin * c(10, 1000)^pub$POW_prazosin)) / diff(log(c(10, 1000)))
stopifnot(abs(pw - pub$POW_prazosin) < 1e-12)
# Prazosin's effect is far from maximal over the studied range, which is the
# paper's stated reason for preferring the power form.
cat(sprintf("prazosin EFF magnitude at 1 / 100 / 10000 ng/mL: %.3f / %.3f / %.3f\n",
pub$SL_prazosin * 1^pub$POW_prazosin,
pub$SL_prazosin * 100^pub$POW_prazosin,
pub$SL_prazosin * 10000^pub$POW_prazosin))
#> prazosin EFF magnitude at 1 / 100 / 10000 ng/mL: 0.328 / 0.499 / 0.758
stopifnot(pub$SL_prazosin * 10000^pub$POW_prazosin < 1)Fasudil’s EC50 of 0.172 ng/mL puts its curve four orders of magnitude to the left of every other compound in the plot. That is the discrepancy discussed in Assumptions and deviations, shown here rather than hidden.
10. Signature profiles: the direction of every drug effect
Snelder 2014’s central claim is that the site of action of a compound can be identified from the pattern of changes across MAP, CO, HR, SV and TPR – the “signature profile” of Figure 4 – and Table 4 plus the Figure 3, S1 and S2 captions state the expected pattern for each compound. This check drives each compound at a constant exposure and compares all five directions against the published description. It is deliberately PK-free: the directions are properties of the system model, not of the concentration-time profile.
drug_exposure <- list(
amiloride = list(CP_AMILORIDE_NGML = pub$EC50_amiloride),
amlodipine = list(CP_AMLODIPINE_NGML = pub$EC50_amlodipine),
atropine = list(CP_ATROPINE_NGML = 300),
enalapril = list(CP_ENALAPRIL_NGML = pub$EC50_enalapril),
fasudil = list(CP_FASUDIL_NGML = pub$EC50_fasudil),
hctz = list(CP_HCTZ_NGML = pub$EC50_hctz),
prazosin = list(CP_PRAZOSIN_NGML = 1)
)
sig_one <- function(nm) {
s <- cvs_solve(cvs_events(72, by = 0.1, exposure = drug_exposure[[nm]]),
params = c(amp_HR = 0))
n <- nrow(s)
tibble::tibble(
Compound = nm,
HR = 100 * (s$HR[n] / s$HR[1] - 1),
SV = 100 * (s$SV[n] / s$SV[1] - 1),
TPR = 100 * (s$TPR[n] / s$TPR[1] - 1),
CO = 100 * (s$CO[n] / s$CO[1] - 1),
MAP = 100 * (s$MAP[n] / s$MAP[1] - 1)
)
}
sig <- dplyr::bind_rows(lapply(names(drug_exposure), sig_one))
sig |>
dplyr::mutate(Site = c("SV", "TPR", "HR", "TPR + SV", "TPR", "SV", "TPR"),
Direction = c("inhibit", "inhibit", "stimulate", "inhibit",
"inhibit", "inhibit", "inhibit")) |>
dplyr::select(Compound, Site, Direction, HR, SV, TPR, CO, MAP) |>
knitr::kable(digits = 2,
caption = "Per cent change from baseline after 72 h of constant exposure. Site and direction are Snelder 2014 Table 4.")| Compound | Site | Direction | HR | SV | TPR | CO | MAP |
|---|---|---|---|---|---|---|---|
| amiloride | SV | inhibit | 18.02 | -44.04 | 18.02 | -33.96 | -22.06 |
| amlodipine | TPR | inhibit | 18.02 | 11.92 | -40.99 | 32.08 | -22.06 |
| atropine | HR | stimulate | 35.58 | -15.20 | -6.30 | 14.97 | 7.72 |
| enalapril | TPR + SV | inhibit | 35.59 | -38.64 | -32.20 | -16.80 | -43.59 |
| fasudil | TPR | inhibit | 18.02 | 11.92 | -40.99 | 32.08 | -22.06 |
| hctz | SV | inhibit | 18.02 | -44.04 | 18.02 | -33.96 | -22.06 |
| prazosin | TPR | inhibit | 10.29 | 6.92 | -25.89 | 17.92 | -12.60 |
get <- function(nm, v) sig[[v]][sig$Compound == nm]
stopifnot(
# Figure S1 caption, SV-acting compounds: "Amiloride and HCTZ have an
# inhibiting effect on SV. Therefore, SV and, consequently, CO, decrease ...
# As a result of the indirect feedback, HR and TPR increase. MAP changes in
# the same direction as the initial effect."
all(vapply(c("amiloride", "hctz"), function(d) {
get(d, "SV") < 0 && get(d, "CO") < 0 && get(d, "HR") > 0 &&
get(d, "TPR") > 0 && get(d, "MAP") < 0
}, logical(1))),
# Figure 3 / S1 captions, TPR-acting compounds: "TPR decreases ... As a result
# of the indirect feedback, HR, SV and CO increase ... MAP decreases."
all(vapply(c("amlodipine", "fasudil", "prazosin"), function(d) {
get(d, "TPR") < 0 && get(d, "HR") > 0 && get(d, "SV") > 0 &&
get(d, "CO") > 0 && get(d, "MAP") < 0
}, logical(1))),
# Figure S2 caption, atropine: "Atropine has a stimulating effect on HR.
# Therefore, HR and, consequently, CO, increase ... As a result of the
# indirect feedback, SV and TPR decrease. MAP changes in the same direction
# as the initial effect."
get("atropine", "HR") > 0, get("atropine", "CO") > 0,
get("atropine", "SV") < 0, get("atropine", "TPR") < 0,
get("atropine", "MAP") > 0,
# Figure S1 caption, enalapril: it inhibits TPR AND SV, so unlike the pure
# TPR-acting compounds "the initial decrease in SV is not reversed by the
# indirect feedback".
get("enalapril", "TPR") < 0, get("enalapril", "SV") < 0,
get("enalapril", "MAP") < 0,
# Figure 4: "inhibition of HR, SV or TPR always results in a decrease in MAP,
# demonstrating that homeostatic feedback cannot be stronger than the primary
# effect." Every inhibitory compound lowers MAP; the one stimulator raises it.
all(sig$MAP[sig$Compound != "atropine"] < 0),
sig$MAP[sig$Compound == "atropine"] > 0
)The transient SV dip under a TPR-acting compound
Figure 3’s caption calls out a second-order feature: under amlodipine “the initial decrease in SV is related to the direct inverse relationship between HR and SV”, and Figure S1 adds that “subsequently, this decrease is reversed by the indirect feedback”. The model must therefore take SV below baseline first and above baseline later – a sign change that the 72 h endpoint of the table above cannot see.
amlo <- cvs_solve(cvs_events(48, by = 0.02,
exposure = list(CP_AMLODIPINE_NGML = pub$EC50_amlodipine)),
params = c(amp_HR = 0))
enal <- cvs_solve(cvs_events(48, by = 0.02,
exposure = list(CP_ENALAPRIL_NGML = pub$EC50_enalapril)),
params = c(amp_HR = 0))
dplyr::bind_rows(
dplyr::mutate(amlo, Compound = "amlodipine (TPR only)"),
dplyr::mutate(enal, Compound = "enalapril (TPR and SV)")
) |>
dplyr::mutate(`SV, % of baseline` = 100 * SV / SV[1]) |>
dplyr::group_by(Compound) |>
dplyr::mutate(`SV, % of baseline` = 100 * SV / dplyr::first(SV)) |>
dplyr::ungroup() |>
ggplot2::ggplot(ggplot2::aes(time, `SV, % of baseline`, colour = Compound)) +
ggplot2::geom_line() +
ggplot2::geom_hline(yintercept = 100, linetype = "dashed", colour = "grey50") +
ggplot2::labs(x = "Time (h) of constant EC50-level exposure", y = "SV (% of baseline)",
title = "Stroke volume under a TPR-only versus a TPR-and-SV inhibitor",
subtitle = "Amlodipine: HR-coupled dip then feedback-driven recovery above baseline. Enalapril: no recovery.") +
ggplot2::theme_bw()
cat(sprintf("amlodipine SV: minimum %.2f%% of baseline at t = %.2f h, final %.2f%%\n",
100 * min(amlo$SV) / amlo$SV[1], amlo$time[which.min(amlo$SV)],
100 * amlo$SV[nrow(amlo)] / amlo$SV[1]))
#> amlodipine SV: minimum 95.20% of baseline at t = 0.62 h, final 111.91%
cat(sprintf("enalapril SV: minimum %.2f%% of baseline, final %.2f%%\n",
100 * min(enal$SV) / enal$SV[1],
100 * enal$SV[nrow(enal)] / enal$SV[1]))
#> enalapril SV: minimum 61.46% of baseline, final 61.46%
stopifnot(
# Amlodipine: dips below baseline, then recovers above it.
min(amlo$SV) < amlo$SV[1],
amlo$SV[nrow(amlo)] > amlo$SV[1],
# Enalapril: the SV effect is direct, so there is no recovery above baseline.
enal$SV[nrow(enal)] < enal$SV[1]
)Figure 4: the MAP delay is longer for an SV effect than for a TPR effect
The other Figure 4 claim is about timing rather than direction: “the
delay between the stimulus and the response for MAP was longer for the
drug effect on SV as compared with TPR.” The model’s mechanism for this
is kout_SV = 0.126 1/h against
kout_TPR = 3.58 1/h, a 28-fold difference in dissipation
rate.
t90 <- function(s) {
d <- s$MAP - s$MAP[1]
s$time[which(abs(d) >= abs(0.9 * d[nrow(s)]))[1]]
}
delay <- tibble::tibble(
Compound = names(drug_exposure),
Site = c("SV", "TPR", "HR", "TPR + SV", "TPR", "SV", "TPR"),
`t90 of the MAP change (h)` = vapply(names(drug_exposure), function(nm) {
t90(cvs_solve(cvs_events(120, by = 0.05, exposure = drug_exposure[[nm]]),
params = c(amp_HR = 0)))
}, numeric(1))
)
knitr::kable(delay, digits = 2,
caption = "Time to 90% of the final MAP change under constant exposure.")| Compound | Site | t90 of the MAP change (h) |
|---|---|---|
| amiloride | SV | 15.95 |
| amlodipine | TPR | 0.25 |
| atropine | HR | 0.05 |
| enalapril | TPR + SV | 17.45 |
| fasudil | TPR | 0.25 |
| hctz | SV | 15.95 |
| prazosin | TPR | 0.25 |
sv_only <- delay$`t90 of the MAP change (h)`[delay$Site == "SV"]
tpr_only <- delay$`t90 of the MAP change (h)`[delay$Site == "TPR"]
stopifnot(
# Every SV-acting compound is slower than every TPR-acting compound.
min(sv_only) > max(tpr_only),
# And by a wide margin, not a marginal one.
min(sv_only) / max(tpr_only) > 5
)11. Perturbation-recovery
Displacing a state away from baseline must bring the whole coupled
system back. Because hr(0), svt(0) and
tpr(0) are set inside model(), they override
rxSolve(inits = ); the displacement is therefore applied as
a bolus dose record on the ODE state itself.
perturb <- function(state, amount) {
ev <- cvs_events(72, by = 0.05)
ev <- rbind(ev, data.frame(
time = 0, amt = amount, evid = 1L, cmt = state, id = 1L, STRAIN_SHR = 1,
CP_AMILORIDE_NGML = 0, CP_AMLODIPINE_NGML = 0, CP_ATROPINE_NGML = 0,
CP_ENALAPRIL_NGML = 0, CP_FASUDIL_NGML = 0, CP_HCTZ_NGML = 0,
CP_PRAZOSIN_NGML = 0
))
s <- cvs_solve(ev[order(ev$time, ev$evid), ], params = c(amp_HR = 0))
dplyr::mutate(s, Perturbation = sprintf("%s %+g", state, amount))
}
pert <- dplyr::bind_rows(
perturb("hr", 60), perturb("hr", -60),
perturb("svt", 0.05), perturb("svt", -0.05),
perturb("tpr", 0.5), perturb("tpr", -0.5)
)
pert |>
dplyr::select(time, MAP, Perturbation) |>
ggplot2::ggplot(ggplot2::aes(time, MAP, colour = Perturbation)) +
ggplot2::geom_line() +
ggplot2::geom_hline(yintercept = pub$BSL_MAP_SHR, linetype = "dashed", colour = "grey40") +
ggplot2::coord_cartesian(xlim = c(0, 48)) +
ggplot2::labs(x = "Time (h)", y = "MAP (mmHg)",
title = "Perturbation-recovery: every displacement returns to BSL_MAP",
subtitle = "Dashed line is the published SHR baseline of 155 mmHg") +
ggplot2::theme_bw()
recovery <- pert |>
dplyr::group_by(Perturbation) |>
dplyr::summarise(
`MAP at t = 0` = dplyr::first(MAP),
`MAP at t = 72` = dplyr::last(MAP),
`HR at t = 72` = dplyr::last(HR),
`TPR at t = 72` = dplyr::last(TPR),
.groups = "drop"
)
knitr::kable(recovery, digits = 4,
caption = "State at the end of a 72 h recovery after a bolus displacement.")| Perturbation | MAP at t = 0 | MAP at t = 72 | HR at t = 72 | TPR at t = 72 |
|---|---|---|---|---|
| hr +60 | 174.7876 | 155.0000 | 310.0000 | 2.2464 |
| hr -60 | 133.3893 | 155.0000 | 310.0000 | 2.2464 |
| svt +0.05 | 189.8188 | 155.0001 | 309.9999 | 2.2464 |
| svt -0.05 | 120.1812 | 154.9999 | 310.0001 | 2.2464 |
| tpr +0.5 | 189.5000 | 155.0000 | 310.0000 | 2.2464 |
| tpr -0.5 | 120.5000 | 155.0000 | 310.0000 | 2.2464 |
stopifnot(
# Every arm returns to the published baseline.
max(abs(recovery$`MAP at t = 72` - pub$BSL_MAP_SHR)) < 1e-3,
max(abs(recovery$`HR at t = 72` - pub$BSL_HR_SHR)) < 1e-3,
# And the perturbations really did move MAP away from baseline to begin with.
max(abs(recovery$`MAP at t = 0` - pub$BSL_MAP_SHR)) > 1
)12. Inter-individual variability
A cohort of 200 rats per strain – well above the paper’s 12, but the point is to recover the published variance rather than to reproduce the study – is simulated with no drug, no handling and the circadian rhythm switched off, and the baseline distribution of each readout is compared with Table 5.
iiv_cohort <- function(strain, n = 200) {
ev <- cvs_events(1, by = 0.5, strain = strain)
s <- rxode2::rxSolve(ui, ev, nSub = n, params = c(amp_HR = 0),
returnType = "data.frame", addDosing = FALSE)
b <- s[s$time == 0, ]
b <- b[!duplicated(b$sim.id), ]
tibble::tibble(
Strain = if (strain == 1) "SHR" else "WKY",
Readout = c("BSL_HR", "BSL_MAP", "BSL_CO"),
`Simulated mean` = c(mean(b$HR), mean(b$MAP), mean(b$CO)),
`Simulated CV %` = c(100 * sd(b$HR) / mean(b$HR),
100 * sd(b$MAP) / mean(b$MAP),
100 * sd(b$CO) / mean(b$CO)),
`Table 5 CV %` = c(pub$CV_BSL_HR, pub$CV_BSL_MAP, pub$CV_BSL_CO),
`Table 5 typical value` = if (strain == 1) {
c(pub$BSL_HR_SHR, pub$BSL_MAP_SHR, pub$BSL_CO_SHR)
} else {
c(pub$BSL_HR_WKY, pub$BSL_MAP_WKY, pub$BSL_CO_WKY)
}
)
}
iiv <- dplyr::bind_rows(iiv_cohort(1), iiv_cohort(0))
knitr::kable(iiv, digits = 3,
caption = "Simulated baseline distribution (200 rats per strain) against Snelder 2014 Table 5.")| Strain | Readout | Simulated mean | Simulated CV % | Table 5 CV % | Table 5 typical value |
|---|---|---|---|---|---|
| SHR | BSL_HR | 310.155 | 5.709 | 6.1 | 310 |
| SHR | BSL_MAP | 155.353 | 3.398 | 3.7 | 155 |
| SHR | BSL_CO | 69.707 | 24.409 | 22.7 | 69 |
| WKY | BSL_HR | 324.079 | 5.752 | 6.1 | 323 |
| WKY | BSL_MAP | 101.579 | 3.711 | 3.7 | 102 |
| WKY | BSL_CO | 133.310 | 23.382 | 22.7 | 129 |
The arithmetic identity that fixes the IIV scale is checked separately from the simulation, because it is exact while a 200-subject sample is not.
scale_check <- tibble::tibble(
Parameter = c("BSL_HR", "BSL_MAP", "BSL_CO"),
`Table 5 CV %` = c(pub$CV_BSL_HR, pub$CV_BSL_MAP, pub$CV_BSL_CO),
`Fu 2023 S1 $OMEGA` = c(0.00372, 0.00137, 0.0515)
) |>
dplyr::mutate(
`sqrt(omega) as CV %` = 100 * sqrt(`Fu 2023 S1 $OMEGA`),
`sqrt(exp(omega)-1) as CV %` = 100 * sqrt(exp(`Fu 2023 S1 $OMEGA`) - 1),
`variance encoded in the file` = (`Table 5 CV %` / 100)^2
)
knitr::kable(scale_check, digits = 5,
caption = "Table 5's CV% is sqrt(omega), not the exact log-normal form: Fu 2023's re-encoding of this model settles the ambiguity.")| Parameter | Table 5 CV % | Fu 2023 S1 $OMEGA | sqrt(omega) as CV % | sqrt(exp(omega)-1) as CV % | variance encoded in the file |
|---|---|---|---|---|---|
| BSL_HR | 6.1 | 0.00372 | 6.09918 | 6.10486 | 0.00372 |
| BSL_MAP | 3.7 | 0.00137 | 3.70135 | 3.70262 | 0.00137 |
| BSL_CO | 22.7 | 0.05150 | 22.69361 | 22.98895 | 0.05153 |
err_sqrt <- abs(scale_check$`sqrt(omega) as CV %` - scale_check$`Table 5 CV %`)
err_lnorm <- abs(scale_check$`sqrt(exp(omega)-1) as CV %` - scale_check$`Table 5 CV %`)
cat(sprintf("BSL_CO: |sqrt(omega) - 22.7| = %.4f, |sqrt(exp(omega)-1) - 22.7| = %.4f (%.0fx worse)\n",
err_sqrt[3], err_lnorm[3], err_lnorm[3] / err_sqrt[3]))
#> BSL_CO: |sqrt(omega) - 22.7| = 0.0064, |sqrt(exp(omega)-1) - 22.7| = 0.2890 (45x worse)
stopifnot(
# The rounded Fu 2023 omegas and the file's (CV/100)^2 agree to the rounding.
max(abs(scale_check$`variance encoded in the file` -
scale_check$`Fu 2023 S1 $OMEGA`)) < 5e-5,
# sqrt(omega) reproduces the printed CV% to the rounding of the omegas.
max(err_sqrt) < 0.02,
# The exact log-normal form does not, and the gap grows with omega, so BSL_CO
# is what makes the two readings distinguishable at all. The two forms agree
# to second order, so the discriminating statement is relative, not absolute.
err_lnorm[3] / err_sqrt[3] > 10,
err_lnorm[3] > max(err_sqrt)
)
# The simulated cohort is a sanity check, not a precision check: with n = 200 the
# standard error of a CV estimate is about CV / sqrt(2n), roughly 5% relative, so
# assert on the centre with generous room rather than on any per-rat extreme.
stopifnot(
all(abs(iiv$`Simulated CV %` / iiv$`Table 5 CV %` - 1) < 0.35),
all(abs(iiv$`Simulated mean` / iiv$`Table 5 typical value` - 1) < 0.05)
)13. No PKNCA validation
PKNCA is not used in this vignette and no NCA parameters are
computed. There is nothing to integrate: the model has no drug
compartment, no dose of drug and no concentration-time profile of its
own. Snelder 2014 took every plasma profile from a separate literature
PK model (Table 2) and published no PK parameter values, so exposure
enters the packaged model as seven time-varying
CP_<drug>_NGML covariate columns that a user must
supply. The validation route above is the one appropriate to a
mechanistic turnover model: steady-state hold, derived-baseline
identities, closed-form agreement for the circadian, handling and
effect-compartment sub-models, flux balance, perturbation-recovery, and
reproduction of the published direction and relative timing of every
drug effect.
Assumptions and deviations
Exposure is an input, not a model output. Snelder
2014 Table 2 names the literature PK model behind each compound’s
concentration-time profile (Segre 1998, Stopher 1988, Perlstein 2002,
Lin 1988 with Li 2007, Ikegaki 2001, Asdaq & Inamdar 2009, Hamilton
1985, van Steeg 2010 with Belpaire 1990) and prints no CL, V, Ka, F or
lag time for any of them. The packaged model therefore has no internal
PK and no dosing compartment for drug; concentrations are supplied
through the seven CP_<drug>_NGML covariate columns,
in ng/mL to match the Table 5 potency units. This is the same treatment
the sibling Snelder_2013_cardiovascular_rat model uses. The
paper’s own caution applies: “the PK models were descriptive and the PK
and drug-specific PD parameters should only be interpreted in the
context of this model.”
Two PK parameters in Table 5 are not carried.
Atropine’s Ka (1.17 1/h, estimated simultaneously with the PD) and
prazosin’s Ka (fixed to 99 1/h, stated in the Results rather than
tabulated) are absorption constants of literature PK models whose
disposition parameters the paper does not publish. A Ka alone does not
reconstruct a profile, and the model has no absorption compartment for
them to act in, so they are recorded in the relevant
covariateData notes rather than in ini().
Nothing in the packaged model depends on them.
Propranolol carries no parameters. It was dosed at
30 mg/kg p.o. in Study 2 and modelled, but “the effect of propranolol
was too small to be quantified” (Results, Drug effects), so it has no
row in Table 5. It is therefore absent from both ini() and
covariateData – there is nothing to encode. Note that
Snelder_2013_cardiovascular_rat does carry
propranolol parameters, from a different study design (continuous dosing
at 1 mg/mL in drinking water).
Fasudil’s EC50 differs ~1900-fold from the 2013 companion paper. Snelder 2014 Table 5 gives 0.172 ng/mL; Snelder 2013 Table 6 gives 321 ng/mL for the same compound, in the same laboratory, at the same 3-30 mg/kg oral doses, with the same literature PK source. Neither paper mentions the difference. Section 1 above shows that 0.172 reconstructs its own printed RSE and confidence interval exactly, so it is not a transcription or PDF-extraction artefact; the model file transcribes the paper as published and does not reconcile the two. The practical consequence for a user is that a fasudil concentration-time profile constructed to be consistent with one paper’s potency scale must not be reused with the other’s.
Effect direction comes from Table 4, not Table 5.
Equation 6 applies the drug through the factor (1 + EFF),
so EFF must be negative for the six inhibitory compounds
and positive for atropine. Table 5 reports magnitudes only (Emax “fixed
to 1”, SL 0.328), so the sign is written explicitly in
model() with the Table 4 “Effect” column and the Figure 3 /
S1 / S2 captions as the source. This differs from
Fu_2023_cardiovascular_qsp, which folds the sign into a
signed emax parameter; the choice here keeps every
ini() number byte-identical to the printed table. Section
10 verifies all five directions per compound against the published
captions.
The handling effect is encoded as an ODE state rather than
the printed closed form. Equation 4 writes
HD_X = P_X * exp(-kHD * (t - tHD)) for a single handling
time tHD. The model file uses a first-order decay state
that receives a unit impulse at each handling event. Section 7 shows the
two are numerically identical for one event; for the repeated handling
of Study 2 (1000 h and 1300 h) the ODE superposes, which the printed
single-tHD form does not express. Handling events are
supplied as amt = 1, evid = 1, cmt = "handling" dose
records; a dataset with no such records simply has no handling
artefact.
IIV scale. Table 5 reports IIV as “CV%” without
stating the formula. The file encodes variance = (CV/100)^2
on the basis of Fu 2023 Supplemental S1, which re-encodes this same
model as a NONMEM control stream with $OMEGA 0.00372 /
0.00137 / 0.0515 – exactly the squares of 6.1% / 3.7% / 22.7%. This is
the one place where a source other than Snelder 2014 was consulted, and
it settles an interpretation rather than supplying a value. Section 12
shows the check.
The circadian reference time. Equation 3’s cosines
are functions of the model time t. The paper initialised
the system at t = 0 and began pharmacological intervention
at t = 336 h, and states that dosing took place at 1000 h
clock time; since 336 h is exactly 14 x 24 h, model t = 0
corresponds to 1000 h. The model file does not enforce this –
t is whatever the user’s dataset says – but a dataset whose
time origin is not 1000 h will place the circadian peaks at the wrong
clock time. Section 6 records the resulting peak times and notes that
they are consistent with a nocturnal species on the paper’s light cycle;
that consistency is an observation of ours, not a claim of the
paper’s.
amp_TPR is fixed, not estimated. Table
5’s row reads “ampTPR: Fixed to ampHR” with no value, RSE or interval,
because the two amplitudes “could not be distinguished”. The file
encodes this as amp_TPR_ratio <- fixed(1).
Prazosin’s published slope unit is dimensionally
inconsistent. Table 5 labels SL as (ng mL-1)-1,
which is exact only if POW = 1; for EFF = SL * C^POW to be
unitless, SL must carry (ng/mL)^-POW. The value is used as
printed and the equation reproduces the paper; the label discrepancy is
recorded in the parameter label() and in the
dimensional-analysis table above, not silently corrected. Note also that
the power form is unbounded above, unlike an Emax form, so it must not
be extrapolated far beyond the studied 0.04-5 mg/kg range.
The strain covariate is STRAIN_SHR, newly
registered. Snelder 2014 expresses the strain through
_SHR / _WKY suffixes on the Table 5 baseline
rows rather than as a dataset column. The packaged model uses a binary
STRAIN_SHR column (1 = SHR, 0 = WKY) to select the baseline
triple, registered as a rat member of the existing
STRAIN_<GROUP> family. The strain does not enter the
feedback constant directly: the twofold-stronger feedback in WKY rats
emerges from Equation 9 acting on their lower baseline MAP, as Section 4
shows.
No re-fit of the HR-and-MAP-only analysis. The paper’s second aim – showing that the site of action can be identified from HR and MAP measurements alone – required re-estimating each compound with the system parameters fixed, and reported that amlodipine’s EC50 came back as 84.9 ng/mL (95% CI 75.4-94.4) against 82.8 (74.7-90.9) from the full data. That is an estimation result, not a simulation result, and is not reproduced here; the packaged model carries the final full-data parameter set of Table 5.
No IIV on WKY-specific baselines. The three IIV terms are applied to the selected log baseline regardless of strain, because Table 5 reports one CV% per baseline rather than one per strain per baseline. The paper gives no basis for strain-specific variances.