demovuln modelThis tutorial introduces the demographic vulnerability framework
implemented in demovuln. It is designed to be read
sequentially by new users, but each section can also be used as a
template for a specific analysis.
The package simulates temporally structured perturbations acting on matrix population models and quantifies the resulting demographic impact. The central output is the integrated vulnerability metric, usually denoted by \(\Phi\), which summarizes the mean population reduction across a user-defined perturbation space.
The tutorial covers four elements:
The framework starts from a standard stage-structured matrix population model,
\[ \mathbf{n}_{t+1} = \mathbf{A}_t \mathbf{n}_t, \]
where \(\mathbf{n}_t\) is the vector of abundances in each life-history stage at time \(t\), and \(\mathbf{A}_t\) is the projection matrix. Columns are source stages at time \(t\), and rows are destination stages at time \(t + 1\). Matrix entries can represent survival, growth, stasis, regression, or fecundity.
In an unperturbed model, \(\mathbf{A}_t = \mathbf{A}\). In a perturbed model, selected matrix elements are temporarily reduced.
A perturbation is defined by three ingredients.
The first is magnitude, \(m\), which is the proportional reduction applied to the selected matrix entries. If a matrix element is \(x\), the perturbed value is
\[ x \longrightarrow (1 - m)x. \]
Thus, \(m = 0\) means no perturbation and \(m = 1\) means a complete reduction of the selected entries.
The second is duration, \(d\), which is the number of consecutive projection intervals during which the perturbation remains active. A duration of one projection interval is a pulse perturbation. Larger values correspond to press-like perturbations.
The third is recurrence. In the paper this is described in terms of
frequency, usually denoted by \(\nu\).
In the R implementation the direct simulation argument is
period, which is the number of projection intervals between
the start of consecutive perturbation events. The helper function
perturbation_grid_from_frequencies() converts frequencies
into periods.
For a given perturbation regime, the package compares the final population size under the perturbed dynamics with the final population size under the unperturbed baseline. The percent population reduction is
\[ \rho(m,d,\nu) = 100\left(1 - \frac{N_{m,d,\nu}(T)}{N_{0}(T)}\right), \]
where \(N_{m,d,\nu}(T)\) is the final abundance under the perturbed regime and \(N_0(T)\) is the final abundance under the unperturbed baseline over the same time horizon. This comparison makes the metric independent of the arbitrary scale of the initial population vector.
The full perturbation space is
\[ \Omega = \{m,d,\nu\}. \]
The integrated vulnerability metric is the average population reduction across all simulated perturbation regimes,
\[ \Phi = \langle \rho(m,d,\nu) \rangle_{\Omega}. \]
In discrete form,
\[ \Phi = \frac{1}{|\Omega|}\sum_{(m,d,\nu)\in\Omega}\rho(m,d,\nu). \]
High values of \(\Phi\) indicate high average vulnerability across the perturbation regimes considered. Low values indicate that the population remains comparatively resistant across the same perturbation space.
The metric is not a classical elasticity or sensitivity of asymptotic growth rate. Instead, it explicitly incorporates finite perturbations, temporal structure, transient dynamics, and recovery after forcing. The same matrix can therefore show different vulnerability values depending on whether perturbations act on adult survival, juvenile survival, fecundity, all entries, or a custom subset of matrix elements.
The package does not decide which perturbation grid is ecologically meaningful. That choice remains part of the biological modelling problem. For comparative work across life histories, durations and simulation horizons are often defined relative to generation time. For management-oriented applications, the same functions can be used with calendar-time horizons instead.
Install the package from GitHub with:
install.packages("remotes")
remotes::install_github("agimenezromero/demovuln-r", build_vignettes = TRUE)Load demovuln and ggplot2. The tutorial
uses ggplot2 only for figures; the core framework is
implemented in demovuln.
We use a simple three-stage life cycle with juveniles, subadults, and adults. The matrix follows the standard matrix-population-model convention: columns are source stages at time \(t\), and rows are destination stages at time \(t+1\).
stage_names <- c("Juvenile", "Subadult", "Adult")
A <- matrix(
c(
0.00, 0.00, 1.60,
0.35, 0.55, 0.05,
0.00, 0.25, 0.86
),
nrow = 3,
byrow = TRUE,
dimnames = list(
destination = stage_names,
source = stage_names
)
)
A
#> source
#> destination Juvenile Subadult Adult
#> Juvenile 0.00 0.00 1.60
#> Subadult 0.35 0.55 0.05
#> Adult 0.00 0.25 0.86Biologically, this matrix contains fecundity from adults into the juvenile class, juvenile survival or growth into the subadult class, subadult stasis and maturation, and adult stasis.
demovuln modelThe function matrix_population_model() stores the
projection matrix and defines which entries belong to fecundity, adult
survival, and juvenile survival targets.
model <- matrix_population_model(
A,
fecundity_rows = 1,
adult_stages = 3,
juvenile_stages = c(1, 2),
name = "Example three-stage model"
)
model$lambda_
#> [1] 1.10851
stable_stage_distribution(model)
#> [1] 0.4199017 0.2891824 0.2909159The argument fecundity_rows = 1 means that positive
entries in the first row are interpreted as fecundity. The adult and
juvenile definitions refer to source-stage columns. In this example,
adults are column 3 and pre-reproductive stages are columns 1 and 2.
For real empirical matrices, it is often better to define these arguments explicitly rather than relying on automatic inference.
demovuln supports five target types:
adult_survival: perturb entries associated with adult
source stages;juvenile_survival: perturb entries associated with
juvenile source stages;fecundity: perturb fecundity entries;all: perturb all entries;custom: perturb a user-defined logical mask.The next plot shows which matrix entries are affected by the default target definitions.
targets <- c("adult_survival", "juvenile_survival", "fecundity", "all")
target_labels <- c(
adult_survival = "Adult survival",
juvenile_survival = "Juvenile survival",
fecundity = "Fecundity",
all = "All parameters"
)
mask_to_data <- function(mask, target_label) {
idx <- expand.grid(
destination = seq_len(nrow(mask)),
source = seq_len(ncol(mask))
)
idx$selected <- as.vector(mask)
idx$target <- target_label
idx
}
mask_table <- do.call(
rbind,
lapply(targets, function(target) {
mask <- build_target_mask(model, target = target)
mask_to_data(mask, target_labels[[target]])
})
)
mask_table$source_stage <- factor(mask_table$source, labels = stage_names)
mask_table$destination_stage <- factor(mask_table$destination, labels = stage_names)
p_masks <- ggplot(mask_table, aes(x = source_stage, y = destination_stage, fill = selected)) +
geom_tile(color = "grey70") +
facet_wrap(~ target, nrow = 1) +
scale_y_discrete(limits = rev(stage_names)) +
scale_fill_manual(values = c(`FALSE` = "white", `TRUE` = "steelblue")) +
labs(
x = "Source stage at time t",
y = "Destination stage at time t + 1",
fill = "Perturbed",
title = "Matrix entries selected by each demographic target"
) +
theme_minimal(base_size = 11) +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
p_masksBy default, survival perturbations act on whole source-stage columns.
This means that an adult-survival perturbation can also scale adult
fecundity if fecundity is in an adult source column. To perturb only
non-fecundity entries in adult or juvenile columns, use
survival_affects_fecundity = FALSE.
B_with_fecundity <- apply_perturbation(
model,
target = "adult_survival",
magnitude = 0.5,
survival_affects_fecundity = TRUE
)
B_without_fecundity <- apply_perturbation(
model,
target = "adult_survival",
magnitude = 0.5,
survival_affects_fecundity = FALSE
)
B_with_fecundity
#> source
#> destination Juvenile Subadult Adult
#> Juvenile 0.00 0.00 0.800
#> Subadult 0.35 0.55 0.025
#> Adult 0.00 0.25 0.430
B_without_fecundity
#> source
#> destination Juvenile Subadult Adult
#> Juvenile 0.00 0.00 1.600
#> Subadult 0.35 0.55 0.025
#> Adult 0.00 0.25 0.430A single simulation requires a target, a magnitude, a duration, a period, a forcing window, and optionally a recovery window.
generation_time <- 20
sim_adult <- simulate_dynamics(
model,
target = "adult_survival",
magnitude = 0.30,
duration = 4,
period = 10,
t_max = 3 * generation_time,
recovery_steps = 4 * generation_time,
normalize_by_lambda = TRUE
)
sim_adult$reduction
#> [1] 98.98419Here, duration = 4 means that each perturbation event
lasts four projection intervals, and period = 10 means that
a new event starts every ten projection intervals. The forcing window
lasts three reference generations, and the recovery window lasts four
additional generations.
With normalize_by_lambda = TRUE, the unperturbed and
perturbed matrices are divided by the dominant eigenvalue of the
unperturbed matrix. This removes baseline exponential growth or decline
and makes the trajectories easier to compare as relative demographic
responses.
trajectory_df <- data.frame(
time = seq_along(sim_adult$abundance) - 1,
time_generations = (seq_along(sim_adult$abundance) - 1) / generation_time,
baseline = sim_adult$baseline_abundance,
perturbed = sim_adult$abundance
)
trajectory_plot_df <- rbind(
data.frame(
time_generations = trajectory_df$time_generations,
abundance = trajectory_df$baseline,
scenario = "Unperturbed baseline"
),
data.frame(
time_generations = trajectory_df$time_generations,
abundance = trajectory_df$perturbed,
scenario = "Perturbed"
)
)
p_single <- ggplot(trajectory_plot_df, aes(x = time_generations, y = abundance, linetype = scenario)) +
geom_line(linewidth = 0.9) +
labs(
x = "Time (reference generations)",
y = "Relative population size",
linetype = NULL,
title = "One temporally structured perturbation regime"
) +
theme_minimal(base_size = 12)
p_singleThe same perturbation regime can have different consequences depending on the demographic process that is affected.
simulations <- lapply(
c("adult_survival", "juvenile_survival", "fecundity"),
function(target) {
simulate_dynamics(
model,
target = target,
magnitude = 0.30,
duration = 4,
period = 10,
t_max = 3 * generation_time,
recovery_steps = 4 * generation_time,
normalize_by_lambda = TRUE
)
}
)
names(simulations) <- c("adult_survival", "juvenile_survival", "fecundity")
trajectory_panel_data <- do.call(
rbind,
lapply(names(simulations), function(target) {
sim <- simulations[[target]]
time <- seq_along(sim$abundance) - 1
rbind(
data.frame(
time_generations = time / generation_time,
abundance = sim$baseline_abundance,
scenario = "Unperturbed baseline",
target = target_labels[[target]]
),
data.frame(
time_generations = time / generation_time,
abundance = sim$abundance,
scenario = "Perturbed",
target = target_labels[[target]]
)
)
})
)
p_trajectories <- ggplot(
trajectory_panel_data,
aes(x = time_generations, y = abundance, linetype = scenario)
) +
geom_line(linewidth = 0.85) +
facet_wrap(~ target, nrow = 1) +
labs(
x = "Time (reference generations)",
y = "Relative population size",
linetype = NULL,
title = "The same perturbation regime applied to different demographic targets"
) +
theme_minimal(base_size = 11)
p_trajectoriesThe corresponding percent reductions are:
regime_reductions <- data.frame(
target = target_labels[names(simulations)],
population_reduction = vapply(simulations, function(x) x$reduction, numeric(1))
)
regime_reductions
#> target population_reduction
#> adult_survival Adult survival 98.98419
#> juvenile_survival Juvenile survival 93.89671
#> fecundity Fecundity 61.56456A full vulnerability analysis requires a grid of perturbation
regimes. The direct constructor is perturbation_grid(),
which uses magnitudes, durations, and periods.
grid_direct <- perturbation_grid(
magnitudes = seq(0, 0.8, by = 0.1),
durations = seq(0, generation_time, by = 2),
periods = c(5, 10, 20, 40)
)
grid_scenarios(grid_direct)[1:10, ]
#> magnitude duration period feasible
#> 1 0.0 0 5 TRUE
#> 2 0.1 0 5 TRUE
#> 3 0.2 0 5 TRUE
#> 4 0.3 0 5 TRUE
#> 5 0.4 0 5 TRUE
#> 6 0.5 0 5 TRUE
#> 7 0.6 0 5 TRUE
#> 8 0.7 0 5 TRUE
#> 9 0.8 0 5 TRUE
#> 10 0.0 2 5 TRUEIf you prefer to think in frequencies per reference generation, use
perturbation_grid_from_frequencies(). For example,
frequency = 1 means one event per reference generation and
frequency = 4 means four events per reference
generation.
frequencies <- c(0.25, 0.5, 1, 2, 4)
grid <- perturbation_grid_from_frequencies(
magnitudes = seq(0, 0.8, by = 0.1),
durations = seq(0, generation_time, by = 2),
frequencies = frequencies,
generation_time = generation_time,
rounding = "nearest"
)
frequency_period_table <- data.frame(
frequency_per_generation = frequencies,
period = pmax(1L, as.integer(round(generation_time / frequencies)))
)
frequency_period_table
#> frequency_per_generation period
#> 1 0.25 80
#> 2 0.50 40
#> 3 1.00 20
#> 4 2.00 10
#> 5 4.00 5Durations and periods must be expressed in projection intervals.
Scenarios with duration > period are infeasible because
a new event would start before the previous one ends. By default,
run_grid() skips these regimes.
run_grid() evaluates all feasible perturbation regimes
in the grid and returns a table of population reductions plus the
integrated vulnerability metric.
out_adult <- run_grid(
model,
target = "adult_survival",
grid = grid,
t_max = 3 * generation_time,
recovery_steps = 4 * generation_time,
normalize_by_lambda = TRUE,
skip_infeasible = TRUE
)
out_adult$vulnerability
#> [1] 70.85371
head(out_adult$table)
#> target magnitude duration period feasible population_reduction
#> 1 adult_survival 0.0 0 5 TRUE 0
#> 2 adult_survival 0.1 0 5 TRUE 0
#> 3 adult_survival 0.2 0 5 TRUE 0
#> 4 adult_survival 0.3 0 5 TRUE 0
#> 5 adult_survival 0.4 0 5 TRUE 0
#> 6 adult_survival 0.5 0 5 TRUE 0
#> final_population baseline_final_population
#> 1 1 1
#> 2 1 1
#> 3 1 1
#> 4 1 1
#> 5 1 1
#> 6 1 1The returned object contains:
table: one row per simulated perturbation regime;vulnerability: the mean population reduction across
feasible regimes;trajectories: optional individual simulation outputs if
return_trajectories = TRUE.The usual workflow is to run the same perturbation grid for several demographic targets.
grid_results <- lapply(targets, function(target) {
run_grid(
model,
target = target,
grid = grid,
t_max = 3 * generation_time,
recovery_steps = 4 * generation_time,
normalize_by_lambda = TRUE,
skip_infeasible = TRUE
)
})
names(grid_results) <- targets
vulnerability_table <- data.frame(
target = unname(target_labels[targets]),
Phi = vapply(grid_results, function(x) x$vulnerability, numeric(1))
)
vulnerability_table
#> target Phi
#> adult_survival Adult survival 70.85371
#> juvenile_survival Juvenile survival 65.38875
#> fecundity Fecundity 49.09369
#> all All parameters 75.30900p_phi <- ggplot(vulnerability_table, aes(x = target, y = Phi)) +
geom_col(width = 0.7, fill = "grey40") +
labs(
x = "Perturbed demographic target",
y = expression(Phi),
title = "Integrated vulnerability across demographic targets"
) +
theme_minimal(base_size = 12) +
theme(axis.text.x = element_text(angle = 25, hjust = 1))
p_phiThe full perturbation space is three-dimensional. A useful
representation is to fix one dimension and plot the other two. Here we
fix period = generation_time, corresponding to one event
per reference generation, and plot population reduction across magnitude
and duration.
grid_table <- do.call(
rbind,
lapply(targets, function(target) {
tab <- grid_results[[target]]$table
tab$target <- target_labels[[target]]
tab
})
)
heatmap_data <- subset(grid_table, feasible & period == generation_time)
p_heatmap <- ggplot(
heatmap_data,
aes(x = magnitude, y = duration, fill = population_reduction)
) +
geom_tile() +
facet_wrap(~ target, nrow = 2) +
scale_fill_gradient(low = "white", high = "firebrick", limits = c(0, 100)) +
labs(
x = "Perturbation magnitude",
y = "Perturbation duration (projection intervals)",
fill = "Population\nreduction (%)",
title = "Vulnerability surfaces at one event per reference generation"
) +
theme_minimal(base_size = 11)
p_heatmapThis figure is often the most useful diagnostic plot. It shows whether high vulnerability is restricted to extreme perturbations or whether moderate perturbations already generate large demographic reductions.
Some applications require perturbing a specific set of matrix entries rather than one of the predefined biological categories. For example, suppose we want to perturb only maturation into the adult stage and adult stasis.
custom_mask <- matrix(
FALSE,
nrow = nrow(A),
ncol = ncol(A),
dimnames = dimnames(A)
)
custom_mask["Adult", "Subadult"] <- TRUE
custom_mask["Adult", "Adult"] <- TRUE
custom_mask
#> source
#> destination Juvenile Subadult Adult
#> Juvenile FALSE FALSE FALSE
#> Subadult FALSE FALSE FALSE
#> Adult FALSE TRUE TRUE
A_custom_perturbed <- apply_perturbation(
model,
target = "custom",
custom_mask = custom_mask,
magnitude = 0.40
)
A_custom_perturbed
#> source
#> destination Juvenile Subadult Adult
#> Juvenile 0.00 0.00 1.600
#> Subadult 0.35 0.55 0.050
#> Adult 0.00 0.15 0.516sim_custom <- simulate_dynamics(
model,
target = "custom",
custom_mask = custom_mask,
magnitude = 0.40,
duration = 4,
period = 10,
t_max = 3 * generation_time,
recovery_steps = 4 * generation_time,
normalize_by_lambda = TRUE
)
sim_custom$reduction
#> [1] 99.79894The same custom mask can be passed to run_grid().
By default, simulations start from the stable stage distribution of
the unperturbed matrix. A custom initial state can be supplied with
initial_state.
Set return_stage_vectors = TRUE when the stage-level
trajectory is needed.
sim_stages <- simulate_dynamics(
model,
target = "juvenile_survival",
magnitude = 0.30,
duration = 4,
period = 10,
t_max = 3 * generation_time,
recovery_steps = 4 * generation_time,
return_stage_vectors = TRUE
)
head(sim_stages$stage_vectors)
#> [,1] [,2] [,3]
#> [1,] 0.4199017 0.2891824 0.2909159
#> [2,] 0.4199017 0.2063643 0.2713503
#> [3,] 0.3916611 0.1767179 0.2430965
#> [4,] 0.3508802 0.1589053 0.2164965
#> [5,] 0.3124864 0.1425057 0.1930477
#> [6,] 0.2786410 0.1780775 0.1819085By default, perturbations stop after t_max, and the
system is projected with the unperturbed matrix during
recovery_steps. If the perturbation schedule should
continue during the recovery window, set
force_during_recovery = TRUE.
sim_recovery_off <- simulate_dynamics(
model,
target = "adult_survival",
magnitude = 0.30,
duration = 4,
period = 10,
t_max = 3 * generation_time,
recovery_steps = 4 * generation_time,
force_during_recovery = FALSE
)
sim_recovery_on <- simulate_dynamics(
model,
target = "adult_survival",
magnitude = 0.30,
duration = 4,
period = 10,
t_max = 3 * generation_time,
recovery_steps = 4 * generation_time,
force_during_recovery = TRUE
)
c(
perturbations_stop_before_recovery = sim_recovery_off$reduction,
perturbations_continue_during_recovery = sim_recovery_on$reduction
)
#> perturbations_stop_before_recovery perturbations_continue_during_recovery
#> 98.98419 99.99777If skip_infeasible = FALSE, regimes with
duration > period are kept in the output table and
marked as infeasible, with missing population reduction.
grid_with_infeasible <- perturbation_grid(
magnitudes = 0.5,
durations = c(1, 4, 8),
periods = c(2, 5)
)
out_with_infeasible <- run_grid(
model,
target = "adult_survival",
grid = grid_with_infeasible,
t_max = 30,
skip_infeasible = FALSE
)
out_with_infeasible$table
#> target magnitude duration period feasible population_reduction
#> 1 adult_survival 0.5 1 2 TRUE 99.57041
#> 2 adult_survival 0.5 4 2 FALSE NA
#> 3 adult_survival 0.5 8 2 FALSE NA
#> 4 adult_survival 0.5 1 5 TRUE 88.53869
#> 5 adult_survival 0.5 4 5 TRUE 99.96509
#> 6 adult_survival 0.5 8 5 FALSE NA
#> final_population baseline_final_population
#> 1 0.0042959316 1
#> 2 NA NA
#> 3 NA NA
#> 4 0.1146131388 1
#> 5 0.0003490529 1
#> 6 NA NAA typical empirical analysis has the following structure.
First, assemble one projection matrix per species, population, or scenario. Check that columns are source stages and rows are destination stages.
Second, define the biological meaning of the stages. In particular,
explicitly specify adult_stages,
juvenile_stages, and either fecundity_rows or
fecundity_mask.
Third, decide the perturbation scale. For comparative analyses across
species, define generation_time externally and use it to
set t_max, recovery_steps, durations, and
frequencies. For management applications, use projection intervals
directly as calendar time.
Fourth, define the perturbation grid. Keep the grid coarse during model development and increase the resolution only once the workflow is correct.
Fifth, run the same grid for each demographic target. Compare both full vulnerability surfaces and the integrated metric \(\Phi\).
Sixth, interpret \(\Phi\) only with respect to the perturbation space that was simulated. A species or model with low \(\Phi\) under one grid can have high \(\Phi\) under another grid if the perturbation regimes are more severe, more recurrent, or targeted at a different demographic process.
The whole workflow can be condensed as follows.
library(demovuln)
A <- matrix(
c(
0.00, 0.00, 1.60,
0.35, 0.55, 0.05,
0.00, 0.25, 0.86
),
nrow = 3,
byrow = TRUE
)
model <- matrix_population_model(
A,
fecundity_rows = 1,
adult_stages = 3,
juvenile_stages = c(1, 2)
)
generation_time <- 20
grid <- perturbation_grid_from_frequencies(
magnitudes = seq(0, 0.8, by = 0.1),
durations = seq(0, generation_time, by = 2),
frequencies = c(0.25, 0.5, 1, 2, 4),
generation_time = generation_time
)
out <- run_grid(
model,
target = "adult_survival",
grid = grid,
t_max = 3 * generation_time,
recovery_steps = 4 * generation_time
)
out$vulnerability
head(out$table)Before reporting a vulnerability analysis, check the following points.
t_max, and
recovery_steps expressed in the same projection interval as
the matrix?normalize_by_lambda appropriate for the intended
comparison?sessionInfo()
#> R version 4.3.3 (2024-02-29)
#> Platform: x86_64-pc-linux-gnu (64-bit)
#> Running under: Ubuntu 24.04.2 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=es_ES.UTF-8 LC_COLLATE=C
#> [5] LC_MONETARY=es_ES.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=es_ES.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=es_ES.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Europe/Madrid
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] ggplot2_3.5.1 demovuln_0.1.0
#>
#> loaded via a namespace (and not attached):
#> [1] vctrs_0.6.5 cli_3.6.6 knitr_1.45 rlang_1.2.0
#> [5] xfun_0.41 highr_0.10 generics_0.1.3 jsonlite_2.0.0
#> [9] labeling_0.4.3 glue_1.7.0 colorspace_2.1-0 htmltools_0.5.7
#> [13] sass_0.4.8 fansi_1.0.5 scales_1.3.0 rmarkdown_2.31
#> [17] grid_4.3.3 evaluate_1.0.5 munsell_0.5.0 jquerylib_0.1.4
#> [21] tibble_3.2.1 fastmap_1.1.1 yaml_2.3.12 lifecycle_1.0.5
#> [25] compiler_4.3.3 dplyr_1.1.4 pkgconfig_2.0.3 farver_2.1.1
#> [29] digest_0.6.34 R6_2.6.1 tidyselect_1.2.0 utf8_1.2.4
#> [33] pillar_1.9.0 magrittr_2.0.3 bslib_0.6.1 withr_3.0.2
#> [37] tools_4.3.3 gtable_0.3.4 cachem_1.0.8