NOVA: summarising how conditions move through state space

1. The idea

After PCA, each MEA culture is a point in state space, and a timecourse is a path through it. NOVA’s nova_trajectory_summary() describes that path simply and honestly: how far each condition moved away from baseline, how directly, and when — using the replicate wells to put error bands on the key figure.

It deliberately stops there. A typical MEA experiment has only a handful of timepoints and a few wells per condition: enough to say “this drug moved the network far and fast and it held”, but not enough to fit velocities, “stable vs unstable” regimes, or transition models without turning noise into false precision. So those are intentionally out of scope.

library(NOVA)
library(ggplot2)

2. Get a PCA result (real data if available, else simulated)

This runs the standard NOVA workflow on the bundled MEA agonist data when it is present, and otherwise falls back to a small simulated dataset with the same shape, so the vignette always builds.

example_dir <- system.file("extdata", "MEA Neuronal Agonists", package = "NOVA")
if (!nzchar(example_dir)) {
  alt <- file.path("..", "Example", "MEA Neuronal Agonists")
  if (dir.exists(alt)) example_dir <- alt
}

pca <- NULL
if (nzchar(example_dir) && dir.exists(example_dir)) {
  proc <- tryCatch(process_mea_flexible(
    main_dir = example_dir, selected_experiments = c("MEA012", "MEA013"),
    grouping_variables = c("Experiment", "Treatment", "Well"),
    baseline_timepoint = "baseline", verbose = FALSE), error = function(e) NULL)
  if (!is.null(proc))
    pca <- tryCatch(pca_analysis_enhanced(processing_result = proc,
      grouping_variables = c("Treatment", "Well"), verbose = FALSE), error = function(e) NULL)
}

if (is.null(pca)) {
  timepoints <- c("baseline", "0min", "15min", "30min", "1h", "1h30", "2h")
  tnum <- nova_time_to_minutes(timepoints); tnum[1] <- -10
  s <- (tnum - min(tnum)) / (max(tnum) - min(tnum))
  proto <- list(PBS = function(s) cbind(0 * s, 0 * s),
                KA  = function(s) cbind(5 * (1 - exp(-3 * s)), 2 * (1 - exp(-3 * s))),
                Gabazine = function(s) cbind(-6 * s, 4 * s))
  rows <- list()
  for (tr in names(proto)) for (w in paste0("W", 1:6)) {
    xy <- proto[[tr]](s) + matrix(rnorm(length(s) * 2, 0, 0.4), ncol = 2)
    xy[1, ] <- xy[1, ] * 0
    rows[[paste(tr, w)]] <- data.frame(PC1 = xy[, 1], PC2 = xy[, 2],
      Treatment = tr, Well = w, Timepoint = timepoints)
  }
  pca <- list(plot_data = do.call(rbind, rows),
              variance_explained = c(PC1 = 40, PC2 = 22))
}
cat("Timepoints:", paste(nova_order_timepoints(pca$plot_data$Timepoint), collapse = " -> "), "\n")
#> Timepoints: baseline -> 0min -> 15min -> 30min -> 45min -> 1h -> 1h15 -> 1h30 -> 1h45 -> 2h

3. Trajectories (standard NOVA)

plot_pca_trajectories_general(
  pca, trajectory_grouping = "Treatment",
  timepoint_order = nova_order_timepoints(pca$plot_data$Timepoint),
  color_by = "Treatment", save_plots = FALSE, verbose = FALSE)$plots$combined_average
#> Displaying: pbs_all
#> Displaying: pbs_avg
#> Displaying: dhpg_all
#> Displaying: dhpg_avg
#> Displaying: ka_all
#> Displaying: ka_avg
#> Displaying: gabazine_all
#> Displaying: gabazine_avg
#> Displaying: combined_all
#> Displaying: combined_avg
#> Displaying: combined_average

4. Trajectory summary

s <- nova_trajectory_summary(pca, group_var = "Treatment", verbose = FALSE)
s$metrics
#> # A tibble: 4 × 7
#>   group net_displacement path_length directness peak_timepoint peak_displacement
#>   <chr>            <dbl>       <dbl>      <dbl> <chr>                      <dbl>
#> 1 dhpg             4.67        11.0      0.423  0min                       5.81 
#> 2 gaba…            7.00        27.3      0.257  1h45                       8.50 
#> 3 ka               3.24         5.24     0.618  15min                      4.02 
#> 4 pbs              0.336        4.07     0.0825 1h15                       0.727
#> # ℹ 1 more variable: final_displacement <dbl>

The key figure — distance from baseline over time, with a mean ± SEM band across replicate wells:

s$plots$displacement

The same paths drawn in PC space:

s$plots$map

5. Plain-language summary

invisible(nova_describe(s))

– Trajectory summary – Across 4 conditions, ‘gabazine’ moved farthest from baseline (7.00 PC units) and ‘pbs’ moved least (0.34).

‘gabazine’ moved 7.00 PC units from baseline via a wandering path, with most of the change by 1h45.

‘dhpg’ moved 4.67 PC units from baseline via a wandering path, with most of the change by 0min.

‘ka’ moved 3.24 PC units from baseline via a moderately direct path, with most of the change by 15min.

‘pbs’ moved 0.34 PC units from baseline via a wandering path, peaking near 1h15 then partly returning toward baseline.

6. How to read it

  • net_displacement — how far the condition ended up from baseline.
  • directness (net / path, 0–1) — close to 1 means the network moved straight out and stayed; low values mean it wandered or returned.
  • peak_timepoint — when the displacement was largest, a simple readout of response kinetics (fast vs slow) without claiming a velocity.

In an acute neuronal-agonist experiment this lines up with the biology: a vehicle control barely leaves baseline, while an excitatory agonist moves the network far and early and then holds — exactly the comparison a drug or genotype screen needs, described without over-interpreting the dynamics.

Session info

#> R version 4.6.0 (2026-04-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.4 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=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] ggplot2_4.0.3  NOVA_0.3.0     rmarkdown_2.31
#> 
#> loaded via a namespace (and not attached):
#>  [1] viridis_0.6.5      utf8_1.2.6         sass_0.4.10        generics_0.1.4    
#>  [5] tidyr_1.3.2        stringi_1.8.7      hms_1.1.4          digest_0.6.39     
#>  [9] magrittr_2.0.5     evaluate_1.0.5     grid_4.6.0         RColorBrewer_1.1-3
#> [13] fastmap_1.2.0      jsonlite_2.0.0     ggrepel_0.9.8      gridExtra_2.3     
#> [17] purrr_1.2.2        viridisLite_0.4.3  scales_1.4.0       jquerylib_0.1.4   
#> [21] cli_3.6.6          crayon_1.5.3       rlang_1.2.0        bit64_4.8.2       
#> [25] withr_3.0.3        cachem_1.1.0       yaml_2.3.12        otel_0.2.0        
#> [29] parallel_4.6.0     tools_4.6.0        tzdb_0.5.0         dplyr_1.2.1       
#> [33] DT_0.34.0          buildtools_1.0.0   vctrs_0.7.3        R6_2.6.1          
#> [37] lifecycle_1.0.5    stringr_1.6.0      bit_4.6.0          htmlwidgets_1.6.4 
#> [41] vroom_1.7.1        pkgconfig_2.0.3    pillar_1.11.1      bslib_0.11.0      
#> [45] gtable_0.3.6       glue_1.8.1         Rcpp_1.1.1-1.1     xfun_0.59         
#> [49] tibble_3.3.1       tidyselect_1.2.1   sys_3.4.3          knitr_1.51        
#> [53] farver_2.1.2       htmltools_0.5.9    labeling_0.4.3     maketools_1.3.2   
#> [57] readr_2.2.0        pheatmap_1.0.13    compiler_4.6.0     S7_0.2.2