Skip to contents
library(rxode2)
#> rxode2 5.1.8.9000 using 2 threads (see ?getRxThreads)
#>   no cache: create with `rxCreateCache()`

Why chunked solving?

Population pharmacokinetic/pharmacodynamic simulations can involve tens or hundreds of thousands of subjects. Each subject requires solver workspace, output arrays, and covariate storage, so the combined memory footprint grows linearly with the number of subjects. A 100,000-subject simulation with a two-compartment model and rich sampling can easily require 10-50 GB of RAM – more than many workstations or cloud instances provide.

rxode2 addresses this by chunked (out-of-memory) solving: subjects are split into batches, each batch is solved independently, and the result of each batch is written to a temporary file (parquet or rds). The final return value is a lightweight rxSolveOom object that reads chunks on demand rather than holding the entire dataset in RAM.

For running chunks in parallel across multiple CPU cores or compute nodes, see the Parallel rxode2 solving with mirai vignette.

The model and event table

Throughout this vignette we use a one-compartment oral PK model with between- subject variability on clearance and absorption rate. Parameters are defined using ini() and model() blocks – the standard rxode2 UI approach.

pkModel <- function() {
  ini({
    tka      <- log(0.5);  label("Typical log Ka (1/h)")
    tcl      <- log(4);    label("Typical log CL (L/h)")
    tv       <- log(70);   label("Typical log V (L)")
    eta.ka   ~ 0.09
    eta.cl   ~ 0.04
    prop.err <- 0.15
  })
  model({
    ka <- exp(tka + eta.ka)
    cl <- exp(tcl + eta.cl)
    v  <- exp(tv)
    d/dt(depot)  <- -ka * depot
    d/dt(center) <- ka * depot - cl / v * center
    cp           <- center / v
    cp ~ prop(prop.err)
  })
}

The event table includes 10 subjects. With subject IDs in the event table, rxode2 draws one set of etas per subject from the eta.ka ~ 0.09 and eta.cl ~ 0.04 variances defined in ini().

ev <- et(amt = 100, ii = 24, until = 72) |>
  et(seq(0, 72, by = 4)) |>
  et(id = 1:10)

Estimating memory usage

Before solving you can inspect the expected memory footprint with rxMemoryEstimate(). Pass the event table (as a data frame) and the compiled model; the function calls the same allocator formulas used internally by rxSolve().

mod <- rxode2(pkModel)
#> ℹ parameter labels from comments are typically ignored in non-interactive mode
#> ℹ Need to run with the source intact to parse comments
rxMemoryEstimate(as.data.frame(ev), model = mod)
#> rxSolve() memory estimate
#>   Total: 44.82 KB
#> 
#>   inds_global (per-subject structs)          17.36 KB (38.7%)
#>   outputData (estimated returned data)       11.53 KB (25.7%)
#>   gall_times (event time/dv/amt/ii/limit)    8.00 KB (17.8%)
#>   gsolve (double buffer total)               3.70 KB ( 8.2%)
#>     |_ n0: ODE state output matrix           3.20 KB ( 7.1%)
#>   gevid (event IDs)                          2.40 KB ( 5.4%)
#>   gon (int buffer)                           1.05 KB ( 2.3%)
#>   gpars (parameters)                         400 B ( 0.9%)
#>   invCdfMemo                                 320 B ( 0.7%)
#>   ordId (subject ordering)                   40 B ( 0.1%)
#>   gInfusionRate (per-thread infusion)        28 B ( 0.1%)
#>   gcov (covariates)                          0 B ( 0.0%)
#>   gomega (omega matrix)                      0 B ( 0.0%)
#>   gsigma (sigma matrix)                      0 B ( 0.0%)
#>   gall_timesS (extra sim times)              0 B ( 0.0%)
#>   indLinExpCache (per-thread exponential cache) 0 B ( 0.0%)
#>   indLinWork (per-thread indLin scratch)     0 B ( 0.0%)
#>   indOwnAlloc (per-individual event/solve arrays) 0 B ( 0.0%)
#>   gSampleCov (resampled covariate index)     0 B ( 0.0%)
#>   gEtaPre (pre-generated eta draws)          0 B ( 0.0%)
#>   delayHist (per-individual delay() history, bound) 0 B ( 0.0%)
#>   linCmtRateHist (per-individual linCmt rates, bound) 0 B ( 0.0%)
#> 
#>   Subjects: 10  |  sizeof(rx_solving_options_ind): 1736 B  |  0.0% of RAM (16.77 GB)  |  0.0% of available memory (14.34 GB available)

The output lists every major allocation (ODE state buffers, event-time arrays, output data, etc.) as well as the fraction of total and free RAM the solve would consume. The freeRamBytes line is the key guard: if total exceeds 90 % of free RAM, rxode2 will refuse to proceed without file.

Automatic OOM detection

When rxode2 detects that a solve would use more than 90 % of free RAM it stops immediately and prints a message pointing to the file argument:

# This would error on a machine with insufficient RAM:
rxSolve(pkModel, ev)
#> Error: Solve requires 12.3 GB but only 6.1 GB appears free.
#> Re-run with rxSolve(..., file = 'path/prefix') to solve in chunks.

Chunked solving with rxSolve()

Pass file to opt in to chunked solving. rxode2 will:

  1. Auto-compute a chunk size from available free RAM (or use chunkSize if you supply it).
  2. Solve each chunk with a separate rxSolve() call.
  3. Write each chunk’s result to <file>_chunk_NNNNN.parquet (or .rds if the arrow package is unavailable).
  4. Save a manifest to <file>_manifest.rds.
  5. Return an rxSolveOom object.
res <- rxSolve(pkModel, ev,
               file      = tempfile("rxChunk"),
               chunkSize = 3)   # 3 subjects per chunk
#> ℹ parameter labels from comments are typically ignored in non-interactive mode
#> ℹ Need to run with the source intact to parse comments
res
#> ── Solved rxode2 object ──
#> ── Parameters (x$params): ──
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpJPR0o2/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
#> # A tibble: 10 × 7
#>    id       tka   tcl    tv prop.err  eta.ka  eta.cl
#>    <chr>  <dbl> <dbl> <dbl>    <dbl>   <dbl>   <dbl>
#>  1 1     -0.693  1.39  4.25     0.15  0.0448 -0.309 
#>  2 2     -0.693  1.39  4.25     0.15 -0.166  -0.179 
#>  3 3     -0.693  1.39  4.25     0.15  0.230   0.343 
#>  4 4     -0.693  1.39  4.25     0.15  0.0225  0.180 
#>  5 5     -0.693  1.39  4.25     0.15 -0.392   0.0824
#>  6 6     -0.693  1.39  4.25     0.15 -0.0601  0.0137
#>  7 7     -0.693  1.39  4.25     0.15  0.536   0.129 
#>  8 8     -0.693  1.39  4.25     0.15 -0.0798  0.256 
#>  9 9     -0.693  1.39  4.25     0.15  0.225   0.0241
#> 10 10    -0.693  1.39  4.25     0.15  0.0533  0.491 
#> ── Initial Conditions (x$inits): ──
#>  depot center 
#>      0      0 
#> ── First part of data (object): ──
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpJPR0o2/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
#> # A tibble: 6 × 10
#>      id  time    ka    cl     v    cp ipredSim   sim     depot center
#>   <int> <dbl> <dbl> <dbl> <dbl> <dbl>    <dbl> <dbl>     <dbl>  <dbl>
#> 1     1     0 0.523  2.94    70 0        0     0     100          0  
#> 2     1     4 0.523  2.94    70 1.12     1.12  1.02   12.3       78.5
#> 3     1     8 0.523  2.94    70 1.09     1.09  0.963   1.52      76.1
#> 4     1    12 0.523  2.94    70 0.936    0.936 0.969   0.188     65.5
#> 5     1    16 0.523  2.94    70 0.793    0.793 0.689   0.0233    55.5
#> 6     1    20 0.523  2.94    70 0.671    0.671 0.603   0.00287   47.0
#> <rxSolveOom: 4 chunks, 190 total rows, prefix='/tmp/RtmpJPR0o2/rxChunk62998b073c7' [DuckDB/Arrow-backed]>

The print method reports how many chunks were created and the total row count. With chunkSize = 3 and 10 subjects, rxode2 creates four chunks (3 + 3 + 3 + 1).

Accessing results

Materialise the full data frame

df <- as.data.frame(res)
head(df)
#>   id time        ka       cl  v        cp  ipredSim       sim        depot
#> 1  1    0 0.5229038 2.937553 70 0.0000000 0.0000000 0.0000000 1.000000e+02
#> 2  1    4 0.5229038 2.937553 70 1.1214032 1.1214032 1.0203791 1.234877e+01
#> 3  1    8 0.5229038 2.937553 70 1.0865946 1.0865946 0.9625345 1.524917e+00
#> 4  1   12 0.5229038 2.937553 70 0.9357858 0.9357858 0.9692756 1.883077e-01
#> 5  1   16 0.5229038 2.937553 70 0.7932925 0.7932925 0.6890626 2.325360e-02
#> 6  1   20 0.5229038 2.937553 70 0.6709674 0.6709674 0.6030925 2.871524e-03
#>     center
#> 1  0.00000
#> 2 78.49822
#> 3 76.06162
#> 4 65.50501
#> 5 55.53047
#> 6 46.96772

This reads and rbinds every chunk file. For very large results keep the rxSolveOom object in place and only materialise columns you actually need.

Lazy column access

cp_all <- res$cp
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpJPR0o2/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
length(cp_all)
#> [1] 190

res$col reads only the requested column from each chunk, avoiding loading columns you do not need.

Row and dimension queries

nrow(res)
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpJPR0o2/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
#> [1] 190
dim(res)
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpJPR0o2/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
#> [1] 190  10

Faking low memory in demos and tests

The easiest way to demonstrate or test the chunked path on a small dataset is to set chunkSize directly – no need to modify system RAM. With chunkSize = 2 on 10 subjects you get 5 chunks of 2 subjects each:

res_small <- rxSolve(pkModel, ev,
                     file      = tempfile("rxDemo"),
                     chunkSize = 2)
res_small
#> ── Solved rxode2 object ──
#> ── Parameters (x$params): ──
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpJPR0o2/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
#> # A tibble: 10 × 7
#>    id       tka   tcl    tv prop.err  eta.ka   eta.cl
#>    <chr>  <dbl> <dbl> <dbl>    <dbl>   <dbl>    <dbl>
#>  1 1     -0.693  1.39  4.25     0.15 -0.134  -0.112  
#>  2 2     -0.693  1.39  4.25     0.15 -0.230  -0.369  
#>  3 3     -0.693  1.39  4.25     0.15 -0.102  -0.0439 
#>  4 4     -0.693  1.39  4.25     0.15 -0.517  -0.00782
#>  5 5     -0.693  1.39  4.25     0.15 -0.0234 -0.428  
#>  6 6     -0.693  1.39  4.25     0.15 -0.119  -0.513  
#>  7 7     -0.693  1.39  4.25     0.15 -0.114  -0.232  
#>  8 8     -0.693  1.39  4.25     0.15 -0.559   0.0137 
#>  9 9     -0.693  1.39  4.25     0.15 -0.0484 -0.0114 
#> 10 10    -0.693  1.39  4.25     0.15 -0.417   0.442  
#> ── Initial Conditions (x$inits): ──
#>  depot center 
#>      0      0 
#> ── First part of data (object): ──
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpJPR0o2/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
#> # A tibble: 6 × 10
#>      id  time    ka    cl     v    cp ipredSim   sim    depot center
#>   <int> <dbl> <dbl> <dbl> <dbl> <dbl>    <dbl> <dbl>    <dbl>  <dbl>
#> 1     1     0 0.437  3.58    70 0        0     0     100         0  
#> 2     1     4 0.437  3.58    70 1.04     1.04  0.844  17.4      72.6
#> 3     1     8 0.437  3.58    70 1.03     1.03  0.953   3.02     71.8
#> 4     1    12 0.437  3.58    70 0.868    0.868 0.864   0.525    60.7
#> 5     1    16 0.437  3.58    70 0.713    0.713 0.855   0.0912   49.9
#> 6     1    20 0.437  3.58    70 0.582    0.582 0.726   0.0159   40.7
#> <rxSolveOom: 5 chunks, 190 total rows, prefix='/tmp/RtmpJPR0o2/rxDemo629970e3e800' [DuckDB/Arrow-backed]>

In production code, omit chunkSize entirely and rxode2 auto-computes an appropriate chunk size from rxMemoryEstimate().

File-backed event tables

For datasets so large that even the event table does not fit in RAM, save it to a file first and wrap the path with rxEventTableFile():

et_path <- tempfile(fileext = ".parquet")
arrow::write_parquet(as.data.frame(ev), et_path)

et_file <- rxEventTableFile(et_path, format = "parquet")

res_file <- rxSolve(pkModel, et_file,
                    file      = tempfile("rxFile"),
                    chunkSize = 3)
res_file
#> ── Solved rxode2 object ──
#> ── Parameters (x$params): ──
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpJPR0o2/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
#> # A tibble: 10 × 7
#>    id       tka   tcl    tv prop.err  eta.ka   eta.cl
#>    <chr>  <dbl> <dbl> <dbl>    <dbl>   <dbl>    <dbl>
#>  1 1     -0.693  1.39  4.25     0.15 -0.132   0.432  
#>  2 2     -0.693  1.39  4.25     0.15 -0.336  -0.00790
#>  3 3     -0.693  1.39  4.25     0.15 -0.374  -0.154  
#>  4 4     -0.693  1.39  4.25     0.15  0.351   0.192  
#>  5 5     -0.693  1.39  4.25     0.15  0.0977  0.00740
#>  6 6     -0.693  1.39  4.25     0.15  0.199  -0.263  
#>  7 7     -0.693  1.39  4.25     0.15  0.0423 -0.106  
#>  8 8     -0.693  1.39  4.25     0.15 -0.102  -0.151  
#>  9 9     -0.693  1.39  4.25     0.15 -0.0752  0.0679 
#> 10 10    -0.693  1.39  4.25     0.15  0.137  -0.00752
#> ── Initial Conditions (x$inits): ──
#>  depot center 
#>      0      0 
#> ── First part of data (object): ──
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpJPR0o2/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
#> # A tibble: 6 × 10
#>      id  time    ka    cl     v    cp ipredSim   sim    depot center
#>   <int> <dbl> <dbl> <dbl> <dbl> <dbl>    <dbl> <dbl>    <dbl>  <dbl>
#> 1     1     0 0.438  6.16    70 0        0     0     100         0  
#> 2     1     4 0.438  6.16    70 0.947    0.947 0.917  17.3      66.3
#> 3     1     8 0.438  6.16    70 0.830    0.830 0.935   3.01     58.1
#> 4     1    12 0.438  6.16    70 0.612    0.612 0.762   0.521    42.9
#> 5     1    16 0.438  6.16    70 0.435    0.435 0.533   0.0904   30.5
#> 6     1    20 0.438  6.16    70 0.307    0.307 0.325   0.0157   21.5
#> <rxSolveOom: 4 chunks, 190 total rows, prefix='/tmp/RtmpJPR0o2/rxFile629990f4178' [DuckDB/Arrow-backed]>

rxEventTableFile() also accepts "csv", "fst", and "rds" formats.

Persistent output files

By default file points to a tempfile() prefix, so the chunk files and manifest are cleaned up when R’s garbage collection removes the temp directory. To keep results across sessions, point file at a directory in your project:

res_persist <- rxSolve(pkModel, ev,
                       file      = "output/my_sim",
                       chunkSize = 5000)

Files written will be output/my_sim_chunk_00001.parquet, …, output/my_sim_manifest.rds. Reload the manifest later:

manifest <- readRDS("output/my_sim_manifest.rds")

Working with Arrow datasets

The combination of chunked solving and Arrow datasets means rxode2 can handle populations that are too large to fit in memory at any stage – not just during the solve, but also when post-processing the results. Chunk files stay on disk; Arrow reads only what your query needs.

When the arrow package is installed (which it must be for parquet chunk files), rxode2 exposes two Arrow-native accessors on the rxSolveOom object.

Lazy Arrow Dataset – querying without loading everything

as.arrow() (an rxode2 generic; arrow itself has no such function) opens all chunk files as a single lazy arrow::Dataset. No rows are read from disk until you explicitly collect them. You can push filters, column selections, and aggregations down to the Arrow engine so that only the result – not the full dataset – is brought into R memory:

ds <- as.arrow(res)
ds
#> FileSystemDataset with 4 Parquet files
#> 10 columns
#> id: int32
#> time: double
#> ka: double
#> cl: double
#> v: double
#> cp: double
#> ipredSim: double
#> sim: double
#> depot: double
#> center: double
#> 
#> See $metadata for additional Schema metadata

Because ds is lazy you can chain dplyr verbs and only call dplyr::collect() at the end to materialize the (small) result:

# Compute the peak concentration per subject without loading all rows.
# Arrow cannot translate argmax expressions like time[which.max(cp)], so
# tmax comes from a lazy self-join: keep the rows where cp hits the
# per-subject maximum.
cmax_tbl <- ds |>
  dplyr::group_by(id) |>
  dplyr::summarise(cmax = max(cp, na.rm = TRUE))

pk_summary <- ds |>
  dplyr::inner_join(cmax_tbl, by = "id") |>
  dplyr::filter(cp == cmax) |>
  dplyr::select(id, cmax, tmax = time) |>
  dplyr::collect() |>
  dplyr::arrange(id)

pk_summary
#> # A tibble: 10 × 3
#>       id  cmax  tmax
#>    <int> <dbl> <dbl>
#>  1     1  1.78    52
#>  2     2  1.59    52
#>  3     3  1.25    52
#>  4     4  1.33    52
#>  5     5  1.29    52
#>  6     6  1.45    52
#>  7     7  1.44    52
#>  8     8  1.25    52
#>  9     9  1.50    52
#> 10    10  1.12    52

This is the key advantage over materialising with as.data.frame(): for a 100,000-subject simulation that would require 40 GB of RAM as an R data frame, the summary above reads each chunk sequentially and returns only one row per subject – a result that fits easily in memory even when the full dataset does not.

Other useful patterns:

# Keep only observations after the first dose interval
late_obs <- ds |>
  dplyr::filter(time > 24) |>
  dplyr::select(id, time, cp) |>
  dplyr::collect()

head(late_obs)
#> # A tibble: 6 × 3
#>      id  time    cp
#>   <int> <dbl> <dbl>
#> 1     4    28 1.28 
#> 2     4    32 1.11 
#> 3     4    36 0.863
#> 4     4    40 0.658
#> 5     4    44 0.501
#> 6     4    48 0.381

In-memory Arrow Table

arrow::as_arrow_table() reads and concatenates all chunk files into a single arrow::Table in columnar memory. Use this when the full result fits in RAM but you want the speed of Arrow columnar operations rather than R’s row-oriented rbind():

tbl <- arrow::as_arrow_table(res)
tbl
#> Table
#> 190 rows x 10 columns
#> $id <int32>
#> $time <double>
#> $ka <double>
#> $cl <double>
#> $v <double>
#> $cp <double>
#> $ipredSim <double>
#> $sim <double>
#> $depot <double>
#> $center <double>
#> 
#> See $metadata for additional Schema metadata

Materialisation via Arrow

When arrow is available, as.data.frame(), tibble::as_tibble(), and data.table::as.data.table() all route through as_arrow_table() internally, so they benefit from Arrow’s concatenation speed rather than R’s rbind():

df  <- as.data.frame(res)       # Arrow path when arrow installed
tib <- tibble::as_tibble(res)   # also Arrow-backed

Parallel chunk solving

Each chunk is independent and can be solved on a separate CPU or compute node using the mirai package. Pass parallel to control the number of worker processes:

res_par <- rxSolve(pkModel, ev,
                   file      = tempfile("rxPar"),
                   chunkSize = 3,
                   parallel  = 2)

See the Parallel rxode2 solving with mirai vignette for a full walkthrough of daemon setup, performance tuning, and custom parallel workflows.

Session information

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.5 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=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
#>  [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
#>  [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
#> [10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   
#> 
#> time zone: UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] rxode2_5.1.8.9000
#> 
#> loaded via a namespace (and not attached):
#>  [1] utf8_1.2.6          sass_0.4.10         generics_0.1.4     
#>  [4] digest_0.6.39       magrittr_2.0.5      evaluate_1.0.5     
#>  [7] grid_4.6.1          RColorBrewer_1.1-3  fastmap_1.2.0      
#> [10] lotri_1.0.5         jsonlite_2.0.0      rxode2ll_2.0.18    
#> [13] backports_1.5.1     DBI_1.3.0           purrr_1.2.2        
#> [16] scales_1.4.0        textshaping_1.0.5   jquerylib_0.1.4    
#> [19] duckdb_2.0.0        cli_3.6.6           crayon_1.5.3       
#> [22] rlang_1.3.0         bit64_4.8.6         withr_3.0.3        
#> [25] cachem_1.1.0        yaml_2.3.12         otel_0.2.0         
#> [28] tools_4.6.1         memoise_2.0.1       checkmate_2.3.4    
#> [31] dplyr_1.2.1         rxode2lincmt_0.1.1  ggplot2_4.0.3      
#> [34] assertthat_0.2.1    vctrs_0.7.3         R6_2.6.1           
#> [37] lifecycle_1.0.5     fs_2.1.0            htmlwidgets_1.6.4  
#> [40] bit_4.6.0           ragg_1.5.2          PreciseSums_0.7    
#> [43] arrow_26.0.0        pkgconfig_2.0.3     desc_1.4.3         
#> [46] pkgdown_2.2.1       pillar_1.11.1       bslib_0.12.0       
#> [49] gtable_0.3.6        data.table_1.18.6.1 glue_1.8.1         
#> [52] Rcpp_1.1.2          systemfonts_1.3.2   xfun_0.62          
#> [55] tibble_3.3.1        tidyselect_1.2.1    sys_3.4.3          
#> [58] knitr_1.52          farver_2.1.2        dparser_1.3.1-14   
#> [61] htmltools_0.5.9     rmarkdown_2.32      compiler_4.6.1     
#> [64] S7_0.2.2