Kinetic model comparison for post-export RNA conversion

Introduction

postexportKinetics fits a compartment-resolved kinetic model to time courses of nuclear and cytoplasmic, unprocessed and processed RNA. It asks whether an additional post-export conversion component improves the fit to the data beyond what the model explains without it.

The package implements the method of the accompanying manuscript. Its numerical core is ported verbatim from the frozen manuscript implementation (tag manuscript-revision-v1.0) and is regression-tested against it.

This vignette works through one small synthetic dataset:

  • data formatting and validation;
  • fitting, testing, interpretation, multiple testing and exploratory ranking;
  • plotting, simulation and design diagnostics;
  • limitations.

Installation

Once the package has been accepted and released through Bioconductor, it will be installed with BiocManager. This route does not work before acceptance:

if (!requireNamespace("BiocManager", quietly = TRUE))
    install.packages("BiocManager")
BiocManager::install("postexportKinetics")

In the current development state, install from GitHub, either the released version 0.1.0 or the development line:

# install.packages("remotes")
remotes::install_github("bioinformatics-sannio/[email protected]")
remotes::install_github("bioinformatics-sannio/postexportKinetics",
                        build_vignettes = TRUE)

Load the package:

library(postexportKinetics)

The four-state model

Each event (for example a retained-intron event) is described by four states:

State Compartment Processing
N nuclear unprocessed
N_s nuclear processed
C cytoplasmic unprocessed
C_s cytoplasmic processed

with dynamics

dN/dt   = R(t) - (sigma_n + tau) N
dN_s/dt = sigma_n N - tau_s N_s
dC/dt   = tau N - (sigma_c + alpha) C
dC_s/dt = tau_s N_s + sigma_c C - alpha_s C_s

The rates are:

  • R(t): transcription;
  • sigma_n: nuclear processing;
  • tau and tau_s: export of unprocessed and processed RNA;
  • alpha and alpha_s: cytoplasmic decay;
  • sigma_c: the post-export conversion rate from C to C_s.

Two nested models are compared:

  • the null model, with sigma_c = 0: no post-export conversion component;
  • the full model, with sigma_c >= 0: a non-negative post-export conversion rate.

sigma_c is a phenomenological parameter. A positive estimate, or a small p-value, indicates kinetic patterns consistent with an additional post-export conversion component within this model. It does not identify a unique molecular mechanism, and it is not by itself evidence of cytoplasmic splicing.

Transcription follows a schedule with a transcriptional onset and a common intervention time t_star. After t_star, transcription stops completely (shutoff) in the analyses shown here.

Example data and input format

The package ships a small synthetic dataset, postexport_example. It was simulated with the package’s simulator (see ?postexport_example and the generation script generate_postexport_example.R, installed in the package’s scripts directory):

  • eight events: four simulated with sigma_c > 0 (alt_*) and four with sigma_c = 0 (null_*);
  • complete shutoff at t_star = 332 min;
  • 5 time points and 5 destructive replicates per time point;
  • RNA-seq-type noise.

The design was selected from the manuscript benchmark table before any data were generated. It is the calibrated design (Type-I error interval containing 0.05) with the highest benchmark power among small shutoff designs, and that power is only about 0.24. The simulation seed was fixed a priori; neither the design nor the data were chosen by searching over seeds. The example therefore shows realistic uncertainty and does not guarantee detection.

data(postexport_example)
head(postexport_example)
#>   event time replicate        N      N_s       C      C_s
#> 1 alt_1  322         1 544.8247 6097.662 42.9393 1593.651
#> 2 alt_1  322         2 547.0577 6909.916 46.0974 1531.072
#> 3 alt_1  322         3 490.6547 6817.219 37.9880 1724.369
#> 4 alt_1  322         4 502.5323 6538.311 40.1591 1714.128
#> 5 alt_1  322         5 519.2125 7049.748 43.8828 1456.415
#> 6 alt_1  332         1 547.7388 6940.421 48.4467 1632.381

The canonical wide format has one row per destructive sample, with columns event, time, replicate, N, N_s, C and C_s.

  • Replicate labels identify samples within a time point. They are never used to pair samples across time points.
  • time_unit is required, because all rates, including sigma_c, are per this unit.
x <- postexport_data(postexport_example, time_unit = "min")
x
#> <postexport_data> 200 samples, 8 event(s); time unit: min 
#>   alt_1: 5 time points (322..362), 5 replicates per time
#>   alt_2: 5 time points (322..362), 5 replicates per time
#>   alt_3: 5 time points (322..362), 5 replicates per time
#>   alt_4: 5 time points (322..362), 5 replicates per time
#>   null_1: 5 time points (322..362), 5 replicates per time
#>   ... 3 more event(s)

The long format has one row per sample and state, with columns compartment (nuclear/cytoplasmic), state (unprocessed/processed) and abundance. The same observations are installed as a CSV file:

path <- system.file("extdata", "postexport_example_long.csv",
                    package = "postexportKinetics")
long <- read.csv(path)
head(long, 4)
#>   event time replicate compartment       state abundance
#> 1 alt_1  322         1     nuclear unprocessed  544.8247
#> 2 alt_1  322         1     nuclear   processed 6097.6621
#> 3 alt_1  322         1 cytoplasmic unprocessed   42.9393
#> 4 alt_1  322         1 cytoplasmic   processed 1593.6515
x_long <- postexport_data(long, time_unit = "min", format = "long")

Different column names can be declared with columns, for example columns = c(event = "event_id", time = "minutes").

For retained-intron events, the mapping of inclusion to unprocessed and skipping to processed is the user’s responsibility. It is not inferred.

SummarizedExperiment input

Data already held in a SummarizedExperiment can be converted with postexport_data_from_se(). The object must already be state-resolved (layout A):

  • rows are events;
  • columns are destructive samples;
  • colData holds time and replicate;
  • there are four assays, N, N_s, C and C_s.

The converter builds the wide table and passes it to postexport_data(), so the result is exactly the canonical object:

events <- unique(postexport_example$event)
samples <- unique(postexport_example[, c("time", "replicate")])
rownames(samples) <- sprintf("t%g_r%d", samples$time, samples$replicate)
cell <- cbind(match(postexport_example$event, events),
              match(paste(postexport_example$time,
                          postexport_example$replicate),
                    paste(samples$time, samples$replicate)))
state_matrix <- function(state) {
    m <- matrix(NA_real_, length(events), nrow(samples),
                dimnames = list(events, rownames(samples)))
    m[cell] <- postexport_example[[state]]
    m
}
se <- SummarizedExperiment::SummarizedExperiment(
    assays = list(N = state_matrix("N"), N_s = state_matrix("N_s"),
                  C = state_matrix("C"), C_s = state_matrix("C_s")),
    colData = samples)
se
#> class: SummarizedExperiment 
#> dim: 8 25 
#> metadata(0):
#> assays(4): N N_s C C_s
#> rownames(8): alt_1 alt_2 ... null_3 null_4
#> rowData names(0):
#> colnames(25): t322_r1 t322_r2 ... t362_r4 t362_r5
#> colData names(2): time replicate
x_se <- postexport_data_from_se(se, time_unit = "min")
identical(x_se, x)
#> [1] TRUE

Other assay or colData names can be given with the assays, time and replicate arguments.

The converter does not do any of the following:

  • pair fraction-specific (nuclear and cytoplasmic) libraries;
  • normalise counts;
  • convert rMATS output;
  • decide which quantity is unprocessed or processed (inclusion or skipping).

These choices must be made before building the object. Missing values remain errors.

Validation

postexport_data() validates its input. validate_postexport_data() reports the findings without constructing an object, and never modifies the data.

validate_postexport_data(postexport_example, t_star = 332)
#> <postexport_validation> 0 error(s), 0 warning(s), 0 note(s)
  • Errors: missing states, missing or non-finite values, duplicated samples, fewer than two time points, or no time point with at least two replicates. Missing values are never set to zero or imputed.
  • Warnings: negative abundances and an intervention time outside the sampled window.
  • Notes: diagnostic information.

Fitting the full and null models

fit_postexport_model() fits both models without bootstrap. The estimation uses:

  • trapezoidal interval balances between consecutive time points;
  • within-time covariance estimation with shrinkage and an eigenvalue floor;
  • covariance propagation and whitening;
  • non-negative least squares.

The intervention time t_star is always an explicit argument.

sel <- postexport_example$event == "alt_3"
one <- postexport_data(postexport_example[sel, ], time_unit = "min")
fit <- fit_postexport_model(one, t_star = 332)
fit
#> <postexport_fit> event: alt_3 
#>   Status      : ok  
#>   Design      : shutoff at t_star = 332 min (complete shutoff assumed); 5 time points (322..362 min), 5 replicate(s) per time 
#>   Effect size : sigma_c = 0.6746 min^-1 (phenomenological post-export conversion rate)
#>   Fit         : IR = 0.06411 (relative RSS improvement over the sigma_c = 0 null); T_obs = 19.77
#>   Boundary    : no
#>   No bootstrap inference; see test_postexport_conversion().

summary() shows the full and null coefficients, the residual sums of squares, the boundary information and numerical diagnostics such as condition numbers and ranks.

summary(fit)
#> Post-export kinetic model fit - event: alt_3 
#> Status: ok  
#> 
#> Design
#>   shutoff at t_star = 332 min (complete shutoff assumed); 5 time points (322..362 min), 5 replicate(s) per time
#>   times: 322, 332, 342, 352, 362 
#>   replicates per time: 5, 5, 5, 5, 5 
#> 
#> Estimates (rates per min)
#>  parameter full     null    
#>  R         53.02    54.28   
#>  tau       0.03975  0.03908 
#>  tau_s     0.006482 0.006365
#>  sigma_c   0.6746   0       
#>  sigma_n   0.1059   0.1097  
#>  alpha     0        0.665   
#>  alpha_s   0.01204  0.009443
#>   sigma_c (effect-size estimate): 0.6746 
#> 
#> Fit (whitened scale)
#>   RSS null (sigma_c = 0): 308.3 
#>   RSS full (sigma_c >= 0): 288.6 
#>   T_obs = max(0, RSS0 - RSS1): 19.77 
#>   IR (relative RSS improvement): 0.06411 
#> 
#> Boundary
#>   frozen tolerance 1e-10 * max(1, |RSS0|, |RSS1|): 3.083e-08 
#>   at boundary (T_obs <= tolerance): no 
#>   sigma_c exactly zero (|sigma_c| < 1e-12): no 
#> 
#> Diagnostics
#>   condition number (whitened full design): 533.9 
#>   minimum singular value: 0.005951 
#>   rank full / null: 7 / 6 (of 7 / 6 columns)
#>   structurally zero design columns: none 
#>   condition number over non-zero columns (diagnostic only): 533.9 
#> 
#> Control: frozen manuscript defaults 
#> Frozen reference: manuscript-revision-v1.0 65c3b7368fb7 
#> 
#> sigma_c is a phenomenological post-export conversion rate within the
#> four-state model. A small bootstrap p-value indicates kinetic evidence
#> consistent with an additional post-export conversion component within the
#> model; it does not identify a molecular mechanism.

Inference: bootstrap test of sigma_c = 0

test_postexport_conversion() compares the models with the statistic T = max(0, RSS0 - RSS1), a difference of whitened non-negative least-squares residual sums of squares. This is not a likelihood-ratio test.

The null distribution of T comes from a replicate-level generative bootstrap under the fitted null model. The interval system is rebuilt in every bootstrap replicate, and the p-value uses the add-one rule (1 + #{T* >= T_obs}) / (B + 1). The alternative is one-sided, sigma_c > 0.

Several events can be tested in one call. They are processed sequentially, and each event gets its own named seed:

events <- unique(x$event)
seeds <- setNames(seq_along(events), events)
res <- test_postexport_conversion(
    x, t_star = 332,
    control = postexport_control(B = 499, seed = seeds))
res
#> <postexport_test_set> 8 events; t_star = 332 min 
#>   event status sigma_c        IR at_boundary p_value
#>   alt_1     ok 0.21162 2.123e-02       FALSE   0.164
#>   alt_2     ok 0.00000 1.540e-15        TRUE   1.000
#>   alt_3     ok 0.67460 6.411e-02       FALSE   0.008
#>   alt_4     ok 0.40954 1.934e-02       FALSE   0.224
#>  null_1     ok 0.00000 0.000e+00        TRUE   1.000
#>  null_2     ok 0.12841 1.452e-02       FALSE   0.366
#>  null_3     ok 0.08349 7.957e-02       FALSE   0.140
#>  null_4     ok 0.07204 9.958e-03       FALSE   0.436
#> p_value is the per-event bootstrap p-value (no multiple-testing adjustment).

B = 499 is used only to keep this vignette fast and is not recommended for final scientific analysis. The smallest attainable p-value is 1 / (B + 1), here 0.002. The package default is B = 1999, and the manuscript’s final real-data analyses used larger values.

With an explicit seed, the caller’s random-number state is restored afterwards. With seed = NULL, the global stream is used and consumed.

Interpreting the results

The result table is easiest to read with the simulation truth next to it (available here only because the data are synthetic):

data(postexport_example_truth)
tab <- as.data.frame(res)
cmp <- merge(postexport_example_truth[, c("event", "truth", "sigma_c")],
             tab[, c("event", "sigma_c", "IR", "p_value", "at_boundary")],
             by = "event", suffixes = c("_true", "_est"))
cmp
#>    event       truth sigma_c_true sigma_c_est           IR p_value at_boundary
#> 1  alt_1 alternative   0.12657949  0.21161806 2.123188e-02   0.164       FALSE
#> 2  alt_2 alternative   0.05599857  0.00000000 1.539549e-15   1.000        TRUE
#> 3  alt_3 alternative   0.17854245  0.67460087 6.411106e-02   0.008       FALSE
#> 4  alt_4 alternative   0.15047768  0.40954462 1.934045e-02   0.224       FALSE
#> 5 null_1        null   0.00000000  0.00000000 0.000000e+00   1.000        TRUE
#> 6 null_2        null   0.00000000  0.12840808 1.452157e-02   0.366       FALSE
#> 7 null_3        null   0.00000000  0.08349130 7.956793e-02   0.140       FALSE
#> 8 null_4        null   0.00000000  0.07203508 9.958088e-03   0.436       FALSE
  • sigma_c is the full-model estimate of the post-export conversion rate, per minute. It is an effect-size estimate, not a test.
    • Estimates can be positive for events simulated under the null.
    • Estimates can differ strongly from the simulated value. In alt_3, for example, the full fit trades sigma_c against the cytoplasmic decay rate alpha, since both remove C. The manuscript’s practical identifiability analysis examines this.
  • IR, (RSS0 - RSS1) / RSS0, is the relative improvement in fit from adding sigma_c.
  • p_value is the per-event bootstrap p-value, before any multiple-testing adjustment.
  • Boundary status. When the full fit does not improve on the null (T_obs at or below the frozen tolerance 1e-10 * max(1, |RSS0|, |RSS1|)), the event is at the boundary.
    • sigma_c is then typically estimated as 0.
    • The p-value is set to 1 by the frozen boundary rule.
    • Such an event gives no kinetic support for an additional conversion component.
  • status reports numerical failures, such as a failed observed fit or an unstable bootstrap. Only events with status "ok" are valid tests.

In this design only one of the four simulated alternatives has a small p-value. That is expected: in the manuscript benchmark, this design had empirical Type-I error close to 0.05 but power of only about 0.24 (see the design diagnostics below).

Multiple testing

Adjustment is an explicit step. It is never performed inside the test. adjust_postexport_pvalues():

  • uses Benjamini-Hochberg by default, via stats::p.adjust();
  • adjusts only valid tests (status "ok" and a finite p-value);
  • adds q_value and leaves raw p-values unchanged.
adj <- adjust_postexport_pvalues(res)
adj$summary[, c("event", "status", "p_value", "q_value")]
#>    event status p_value   q_value
#> 1  alt_1     ok   0.164 0.4373333
#> 2  alt_2     ok   1.000 1.0000000
#> 3  alt_3     ok   0.008 0.0640000
#> 4  alt_4     ok   0.224 0.4480000
#> 5 null_1     ok   1.000 1.0000000
#> 6 null_2     ok   0.366 0.5813333
#> 7 null_3     ok   0.140 0.4373333
#> 8 null_4     ok   0.436 0.5813333

The testing family should follow the analysis plan. The manuscript adjusted each dataset separately; groups gives one family per label. A single test can also be adjusted, as a family of size one.

Exploratory ranking

rank_postexport_candidates() orders the tested events by the manuscript’s exploratory composite score

score = sigma_c * IR * min(-log10(max(q, 1e-10)), 6)
  • It requires q-values; raw p-values are never substituted.
  • Ranks are computed within each adjustment family.
  • Ties share the minimum rank.
  • Failed events are kept, with no score.

The ranking is a prioritisation aid for follow-up. It is not an inferential quantity and not an optimised discrimination score.

rank_postexport_candidates(adj)
#> <postexport_ranking> exploratory prioritization, 8 events ( 8 ranked )
#>   Exploratory prioritization only: score = sigma_c * IR * min(-log10(max(q,
#>   1e-10)), 6), the composite of the manuscript. It is not an inferential
#>   quantity and not an optimized discrimination score; p- and q-values are
#>   the statistical evidence, sigma_c is the effect-size estimate and IR the
#>   fit improvement.
#>  rank  event status     score q_value sigma_c        IR at_boundary
#>     1  alt_3     ok 0.0516320  0.0640 0.67460 6.411e-02       FALSE
#>     2  alt_4     ok 0.0027621  0.4480 0.40954 1.934e-02       FALSE
#>     3 null_3     ok 0.0023862  0.4373 0.08349 7.957e-02       FALSE
#>     4  alt_1     ok 0.0016138  0.4373 0.21162 2.123e-02       FALSE
#>     5 null_2     ok 0.0004393  0.5813 0.12841 1.452e-02       FALSE
#>     6 null_4     ok 0.0001690  0.5813 0.07204 9.958e-03       FALSE
#>     7  alt_2     ok 0.0000000  1.0000 0.00000 1.540e-15        TRUE
#>     7 null_1     ok 0.0000000  1.0000 0.00000 0.000e+00        TRUE

Plots

All plots are ggplot2 objects. They never refit or modify results.

The fit plot shows the observations, time-point means, sampling times, t_star, and the fitted full and null models. The fitted curves are display reconstructions, propagated with the ODE model from the observed replicate-mean state at the first sampled time. Inference does not use them; it uses the interval-balance / Crank-Nicolson machinery.

plot(res$results$alt_3)

The bootstrap plot shows the bootstrap distribution of T, the observed statistic and the fraction of bootstrap replicates at the boundary:

plot(res$results$alt_3, type = "bootstrap")

Set-level plots are descriptive:

  • "status", "boundary" and "sigma_IR";
  • after adjustment, "sigma_q", "IR_q" and "score".
plot(adj, type = "sigma_q")

Simulation

simulate_postexport_kinetics() exposes the manuscript simulator. It supports:

  • a transcriptional onset and a common intervention time;
  • "NONE", "SHUTOFF" and "PSEUDO_SHUTOFF" regimes;
  • replicate-level rate variability;
  • the benchmark assay-noise presets (Gaussian, RNA-seq, RT-qPCR).
p <- c(R = 100, tau = 0.03, tau_s = 0.015, sigma_c = 0.1, sigma_n = 0.1,
       alpha = 0.2, alpha_s = 0.08)
sim <- simulate_postexport_kinetics(
    p, times = c(90, 100, 110, 120, 130), onset_time = 0, t_star = 100,
    regime = "SHUTOFF", time_unit = "min", n_replicates = 3,
    param_cv = 0.05, noise = list(platform = "rnaseq", level = "low"),
    seed = 1)
sim
#> <postexport_simulation> event: simulated 
#>   Regime      : SHUTOFF  
#>   Timing      : onset_time = 0, t_star = 100 min
#>   Sampling    : 5 time points (90, 100, 110, 120, 130), 3 replicate(s), param_cv = 0.05
#>   Noise       : rnaseq, low 
#>   sigma_c     : 0.1 min^-1 (phenomenological; simulated data do not establish a mechanism)
#>   Tables      : $latent (393 rows), $sampled (15 rows), $observed (15 rows)
plot(sim)

sim$observed has the wide input format and can be passed directly to postexport_data(). Simulating with sigma_c > 0 generates data from the kinetic model; it does not represent any molecular mechanism.

Operational-domain diagnostics

Calibration of the bootstrap test depends on the design: the regime, sampling times, replicates and noise. check_operational_domain() compares a design with the designs of the manuscript’s corrected factorial benchmark and reports their empirical Type-I error and power.

dom <- check_operational_domain(x, regime = "SHUTOFF", t_star = 332,
                                platform = "rnaseq", noise_level = "very_low")
dom
#> <postexport_domain_check> diagnostic comparison with the manuscript benchmark
#> 
#> Design 1: regime SHUTOFF; 5 time points; 5 replicates; interval 10; events: alt_1, alt_2, alt_3, alt_4, null_1, null_2, null_3, null_4
#>   Match: exact 
#>   In the manuscript benchmark, the 1 configuration(s) matching this design
#>   (rnaseq platform(s), very_low noise level(s)) had empirical Type-I error
#>   at alpha = 0.05 of 0.0424 (Wilson 95% intervals from 0.0336 to 0.0534);
#>   the manuscript's operational criterion (Wilson 95% interval containing
#>   0.05) was met in 1 of 1 of these benchmark configurations; power at alpha
#>   = 0.05 was 0.242.
#>   Caveats: 2 (see summary())
plot(dom)

The check is diagnostic only. Similarity to a benchmark design is not a calibration guarantee for any real dataset, and the check never blocks inference.

Continuous transcription was strongly anti-conservative in the benchmark:

check_operational_domain(regime = "NONE", n_time_points = 5, n_replicates = 5,
                         sampling_interval = 10, time_unit = "min")
#> <postexport_domain_check> diagnostic comparison with the manuscript benchmark
#> 
#> Design 1: regime NONE; 5 time points; 5 replicates; interval 10
#>   Match: exact 
#>   In the manuscript benchmark, the 48 configuration(s) matching this design
#>   (all platform(s), all noise level(s)) had empirical Type-I error at alpha
#>   = 0.05 of 0.293 to 0.388 (Wilson 95% intervals from 0.271 to 0.412); the
#>   manuscript's operational criterion (Wilson 95% interval containing 0.05)
#>   was met in 0 of 48 of these benchmark configurations; power at alpha =
#>   0.05 was 0.354 to 0.583.
#>   In the manuscript benchmark, continuous-transcription (NONE) designs were
#>   strongly anti-conservative across all tested configurations: the
#>   operational criterion was met in 0 of 576 NONE configurations (median
#>   empirical Type-I error 0.394 at alpha = 0.05).
#>   Caveats: 4 (see summary())

Limitations

  • Phenomenological rate. sigma_c is phenomenological. Statistical support for sigma_c > 0 indicates kinetic patterns consistent with additional post-export conversion within the four-state model. It does not establish cytoplasmic splicing or any unique molecular mechanism.
  • Design-dependent calibration. In the manuscript benchmark, Type-I error and power varied strongly with the regime, the sampling interval, the number of time points and replicates, and the noise. Several designs were anti-conservative.
  • Pseudo-shutoff assumptions matter. The analysis assumes the transcription schedule given by t_star (complete shutoff here). Residual transcription after the intervention, or misassigned fractions, can bias the test. Pseudo-shutoff designs are reported as not_benchmarked by the operational-domain check.
  • Benchmark similarity is not a calibration guarantee. The benchmark used simulated data from the model itself.
  • Continuous transcription. Without an intervention, the test was anti-conservative in the manuscript benchmark.
  • Exploratory ranking. The composite score is a prioritisation aid, not a test.

Reproducibility and provenance

The numerical core reproduces the frozen manuscript implementation:

Results are bitwise reproducible only within a matched numerical environment. The bootstrap draws use MASS::mvrnorm(), whose output depends on the LAPACK/BLAS build. Across platforms, bootstrap p-values are different Monte-Carlo realisations of the same bootstrap distribution. The deterministic fit quantities agree to rounding level.

Use citation("postexportKinetics") to cite the package and the manuscript.

Session information

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
#>  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
#>  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
#>  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
#> 
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] postexportKinetics_0.99.0 rmarkdown_2.32           
#> 
#> loaded via a namespace (and not attached):
#>  [1] sass_0.4.10                 generics_0.1.4             
#>  [3] SparseArray_1.13.3          lattice_0.23-1             
#>  [5] digest_0.6.39               evaluate_1.0.5             
#>  [7] grid_4.6.1                  RColorBrewer_1.1-3         
#>  [9] fastmap_1.2.0               jsonlite_2.0.0             
#> [11] Matrix_1.7-6                deSolve_1.42               
#> [13] scales_1.4.0                jquerylib_0.1.4            
#> [15] abind_1.4-8                 cli_3.6.6                  
#> [17] rlang_1.3.0                 XVector_0.53.0             
#> [19] Biobase_2.73.2              withr_3.0.3                
#> [21] cachem_1.1.0                DelayedArray_0.39.7        
#> [23] yaml_2.3.12                 otel_0.2.0                 
#> [25] S4Arrays_1.13.1             tools_4.6.1                
#> [27] ggplot2_4.0.3               SummarizedExperiment_1.43.0
#> [29] BiocGenerics_0.59.12        buildtools_1.0.0           
#> [31] vctrs_0.7.3                 R6_2.6.1                   
#> [33] matrixStats_1.5.0           stats4_4.6.1               
#> [35] lifecycle_1.0.5             Seqinfo_1.3.2              
#> [37] S4Vectors_0.51.10           IRanges_2.47.5             
#> [39] MASS_7.3-66                 bslib_0.12.0               
#> [41] gtable_0.3.6                glue_1.8.1                 
#> [43] xfun_0.61                   GenomicRanges_1.65.4       
#> [45] sys_3.4.3                   MatrixGenerics_1.25.0      
#> [47] knitr_1.52                  farver_2.1.2               
#> [49] htmltools_0.5.9             maketools_1.3.2            
#> [51] labeling_0.4.3              compiler_4.6.1             
#> [53] S7_0.2.2                    nnls_1.6