growkar-introduction

library(growkar)
library(dplyr)
#> 
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:stats':
#> 
#>     filter, lag
#> The following objects are masked from 'package:base':
#> 
#>     intersect, setdiff, setequal, union
library(knitr)
data(yeast_growth_data)

Background

High-throughput microbial growth assays are widely used to characterize phenotypic responses to genetic perturbations, environmental conditions, and drug treatments. These assays generate time-resolved measurements across large numbers of samples, analogous to other high-throughput experimental platforms in functional genomics.

While tools exist for transcriptomic and epigenomic data analysis within the Bioconductor ecosystem, there is a relative lack of standardized infrastructure for analyzing growth-based phenotypic data and integrating it with omics datasets.

The growkar package addresses this gap by providing a scalable and reproducible framework for high-throughput microbial phenotyping from growth assays. It uses Bioconductor data structures to represent assay measurements and derived phenotypes, while accepting tidy and wide plate-reader exports as import adapters.

The primary workflow is:

  1. Import assay data and build a standardized SummarizedExperiment.
  2. Validate the SE structure before downstream analysis.
  3. Explore growth curves visually.
  4. Compute empirical summaries and detect the exponential phase.
  5. Fit logistic or Gompertz models where a full-curve summary is useful.

Class design

growkar uses two S4 classes and no S3 classes:

The package defines no S3 classes and registers no S3 methods.

Build a GrowthExperiment with the constructor or with standard S4 coercion:

# Constructor (control over column-name resolution):
ge <- GrowthExperiment(yeast_growth_data)
ge
#> class: GrowthExperiment 
#> dim: 49 9 
#> metadata(1): growkar_schema
#> assays(1): od
#> rownames(49): 0 0.5 ... 23.5 24
#> rowData names(1): time
#> colnames(9): Cg_R1 Cg_R2 ... YPD_R2 YPD_R3
#> colData names(3): sample condition replicate

# Equivalent standard coercion:
ge2 <- as(yeast_growth_data, "GrowthExperiment")
identical(ge, ge2)
#> [1] TRUE

# It is a SummarizedExperiment, so it hands off cleanly to other packages:
se <- as(ge, "SummarizedExperiment")
is(ge, "SummarizedExperiment")
#> [1] TRUE

Tidy tables and wide plate-reader exports are handled purely as import adapters by as_tidy_growth_data(), which resolves vendor column labels and normalizes them to sample, time, and od before the container is built. Once data are in the container, tidy manipulation is delegated to the tidyomics stack rather than reimplemented — see Interoperability with tidyomics below.

Bioconductor container workflow

Derived summaries are stored in metadata() of the same object, so growth phenotypes travel with the container:

ge <- growth_metrics(ge, method = "rolling_window", average_replicates = TRUE)

S4Vectors::metadata(ge)$growth_metrics
#> # A tibble: 3 × 10
#>   sample      mu start_time end_time r_squared method    n_points degraded note 
#>   <chr>    <dbl>      <dbl>    <dbl>     <dbl> <chr>        <int> <lgl>    <chr>
#> 1 Cg     0.569          4.5      6.5     1.000 rolling_…        5 FALSE    roll…
#> 2 CgFlu  0.404          4.5      6.5     1.000 rolling_…        5 FALSE    roll…
#> 3 YPD    0.00128       12.5     14.5     0.500 rolling_…        5 FALSE    roll…
#> # ℹ 1 more variable: doubling_time <dbl>

This workflow is useful when growth phenotypes need to be carried forward into other Bioconductor analyses or linked to omics-derived sample annotations.

The canonical layout in growkar is:

validate_growth_experiment() checks that an object follows this layout:

validate_growth_experiment(ge)
#> class: GrowthExperiment 
#> dim: 49 9 
#> metadata(4): growkar_schema growth_metrics analysis_params
#>   growth_metrics_parameters
#> assays(1): od
#> rownames(49): 0 0.5 ... 23.5 24
#> rowData names(1): time
#> colnames(9): Cg_R1 Cg_R2 ... YPD_R2 YPD_R3
#> colData names(3): sample condition replicate

Helper accessors make these components easier to inspect:

growth_assay(ge)[1:3, 1:3]
#>     Cg_R1 Cg_R2 Cg_R3
#> 0   0.115 0.116 0.117
#> 0.5 0.116 0.118 0.117
#> 1   0.118 0.119 0.118
timepoints(ge)
#> # A tibble: 49 × 1
#>     time
#>    <dbl>
#>  1   0  
#>  2   0.5
#>  3   1  
#>  4   1.5
#>  5   2  
#>  6   2.5
#>  7   3  
#>  8   3.5
#>  9   4  
#> 10   4.5
#> # ℹ 39 more rows
sample_data(ge)
#> # A tibble: 9 × 3
#>   sample   condition replicate
#>   <chr>    <fct>     <fct>    
#> 1 Cg_R1    Cg        R1       
#> 2 Cg_R2    Cg        R2       
#> 3 Cg_R3    Cg        R3       
#> 4 CgFlu_R1 CgFlu     R1       
#> 5 CgFlu_R2 CgFlu     R2       
#> 6 CgFlu_R3 CgFlu     R3       
#> 7 YPD_R1   YPD       R1       
#> 8 YPD_R2   YPD       R2       
#> 9 YPD_R3   YPD       R3

Importing plate-reader exports

as_tidy_growth_data() is the import adapter. It accepts wide plate-reader exports (time in the first column, samples in the remaining columns) as well as long tables, resolves common instrument column labels such as Time [h] and OD600, and infers condition/replicate from suffixed sample names.

tidy_growth <- as_tidy_growth_data(yeast_growth_data)

head(tidy_growth)
#> # A tibble: 6 × 5
#>    time sample      od condition replicate
#>   <dbl> <chr>    <dbl> <chr>     <chr>    
#> 1     0 Cg_R1    0.115 Cg        R1       
#> 2     0 Cg_R2    0.116 Cg        R2       
#> 3     0 Cg_R3    0.117 Cg        R3       
#> 4     0 CgFlu_R1 0.131 CgFlu     R1       
#> 5     0 CgFlu_R2 0.133 CgFlu     R2       
#> 6     0 CgFlu_R3 0.132 CgFlu     R3

The canonical columns are sample, time, and od. Additional metadata such as condition and replicate are carried alongside them.

validate_growth_data(tidy_growth)
#> # A tibble: 441 × 5
#>     time sample      od condition replicate
#>    <dbl> <chr>    <dbl> <chr>     <chr>    
#>  1   0   Cg_R1    0.115 Cg        R1       
#>  2   0   Cg_R2    0.116 Cg        R2       
#>  3   0   Cg_R3    0.117 Cg        R3       
#>  4   0   CgFlu_R1 0.131 CgFlu     R1       
#>  5   0   CgFlu_R2 0.133 CgFlu     R2       
#>  6   0   CgFlu_R3 0.132 CgFlu     R3       
#>  7   0   YPD_R1   0.105 YPD       R1       
#>  8   0   YPD_R2   0.105 YPD       R2       
#>  9   0   YPD_R3   0.104 YPD       R3       
#> 10   0.5 Cg_R1    0.116 Cg        R1       
#> # ℹ 431 more rows

Imported data are then converted into the canonical container:

GrowthExperiment(yeast_growth_data)
#> class: GrowthExperiment 
#> dim: 49 9 
#> metadata(1): growkar_schema
#> assays(1): od
#> rownames(49): 0 0.5 ... 23.5 24
#> rowData names(1): time
#> colnames(9): Cg_R1 Cg_R2 ... YPD_R2 YPD_R3
#> colData names(3): sample condition replicate

Interoperability with tidyomics

growkar does not reimplement tidy verbs or a tidy display layer for SummarizedExperiment. Install the optional tidySummarizedExperiment package to filter, mutate, summarize, and plot growkar objects with the standard tidyverse grammar, while the underlying object remains a SummarizedExperiment usable by any other Bioconductor package.

library(tidySummarizedExperiment)
#> Loading required package: SummarizedExperiment
#> Loading required package: MatrixGenerics
#> Loading required package: matrixStats
#> 
#> Attaching package: 'matrixStats'
#> The following object is masked from 'package:dplyr':
#> 
#>     count
#> 
#> Attaching package: 'MatrixGenerics'
#> The following objects are masked from 'package:matrixStats':
#> 
#>     colAlls, colAnyNAs, colAnys, colAvgsPerRowSet, colCollapse,
#>     colCounts, colCummaxs, colCummins, colCumprods, colCumsums,
#>     colDiffs, colIQRDiffs, colIQRs, colLogSumExps, colMadDiffs,
#>     colMads, colMaxs, colMeans2, colMedians, colMins, colOrderStats,
#>     colProds, colQuantiles, colRanges, colRanks, colSdDiffs, colSds,
#>     colSums2, colTabulates, colVarDiffs, colVars, colWeightedMads,
#>     colWeightedMeans, colWeightedMedians, colWeightedSds,
#>     colWeightedVars, rowAlls, rowAnyNAs, rowAnys, rowAvgsPerColSet,
#>     rowCollapse, rowCounts, rowCummaxs, rowCummins, rowCumprods,
#>     rowCumsums, rowDiffs, rowIQRDiffs, rowIQRs, rowLogSumExps,
#>     rowMadDiffs, rowMads, rowMaxs, rowMeans2, rowMedians, rowMins,
#>     rowOrderStats, rowProds, rowQuantiles, rowRanges, rowRanks,
#>     rowSdDiffs, rowSds, rowSums2, rowTabulates, rowVarDiffs, rowVars,
#>     rowWeightedMads, rowWeightedMeans, rowWeightedMedians,
#>     rowWeightedSds, rowWeightedVars
#> Loading required package: GenomicRanges
#> Loading required package: stats4
#> Loading required package: BiocGenerics
#> Loading required package: generics
#> 
#> Attaching package: 'generics'
#> The following object is masked from 'package:dplyr':
#> 
#>     explain
#> The following objects are masked from 'package:base':
#> 
#>     as.difftime, as.factor, as.ordered, intersect, is.element, setdiff,
#>     setequal, union
#> 
#> Attaching package: 'BiocGenerics'
#> The following object is masked from 'package:dplyr':
#> 
#>     combine
#> The following objects are masked from 'package:stats':
#> 
#>     IQR, mad, sd, var, xtabs
#> The following object is masked from 'package:utils':
#> 
#>     data
#> The following objects are masked from 'package:base':
#> 
#>     Filter, Find, Map, Position, Reduce, anyDuplicated, aperm, append,
#>     as.data.frame, basename, cbind, colnames, dirname, do.call,
#>     duplicated, eval, evalq, get, grep, grepl, is.unsorted, lapply,
#>     mapply, match, mget, order, paste, pmax, pmax.int, pmin, pmin.int,
#>     rank, rbind, rownames, sapply, saveRDS, scale, sequence, table,
#>     tapply, transform, unique, unsplit, which.max, which.min
#> Loading required package: S4Vectors
#> 
#> Attaching package: 'S4Vectors'
#> The following objects are masked from 'package:dplyr':
#> 
#>     first, rename
#> The following object is masked from 'package:utils':
#> 
#>     findMatches
#> The following objects are masked from 'package:base':
#> 
#>     I, expand.grid, unname
#> Loading required package: IRanges
#> 
#> Attaching package: 'IRanges'
#> The following objects are masked from 'package:dplyr':
#> 
#>     collapse, desc, slice
#> Loading required package: Seqinfo
#> Loading required package: Biobase
#> Welcome to Bioconductor
#> 
#>     Vignettes contain introductory material; view with
#>     'browseVignettes()'. To cite Bioconductor, see
#>     'citation("Biobase")', and for packages 'citation("pkgname")'.
#> 
#> Attaching package: 'Biobase'
#> The following object is masked from 'package:MatrixGenerics':
#> 
#>     rowMedians
#> The following objects are masked from 'package:matrixStats':
#> 
#>     anyMissing, rowMedians
#> Loading required package: ttservice
#> 
#> Attaching package: 'ttservice'
#> The following objects are masked from 'package:dplyr':
#> 
#>     bind_cols, bind_rows
#> ℹ tidySummarizedExperiment says: By default SummarizedExperiment uses the standard display. For a tidy tibble-style display, run tidy_print_on(remember = TRUE).
#> 
#> Attaching package: 'tidySummarizedExperiment'
#> The following object is masked _by_ '.GlobalEnv':
#> 
#>     se

# The SE prints and behaves as a tibble abstraction, without being converted.
se
#> class: SummarizedExperiment 
#> dim: 49 9 
#> metadata(1): growkar_schema
#> assays(1): od
#> rownames(49): 0 0.5 ... 23.5 24
#> rowData names(1): time
#> colnames(9): Cg_R1 Cg_R2 ... YPD_R2 YPD_R3
#> colData names(3): sample condition replicate

se |>
  filter(condition == "Cg") |>
  filter(time <= 6) |>
  select(.feature, .sample, od, condition, replicate)
#> class: SummarizedExperiment 
#> dim: 13 3 
#> metadata(3): growkar_schema latest_filter_scope_report
#>   latest_select_scope_report
#> assays(1): od
#> rownames(13): 0 0.5 ... 5.5 6
#> rowData names(0):
#> colnames(3): Cg_R1 Cg_R2 Cg_R3
#> colData names(2): condition replicate

se |>
  group_by(condition) |>
  summarise(max_od = max(od), .groups = "drop")
#> ℹ tidySummarizedExperiment says: A data frame is returned for independent data analysis.
#> # A tibble: 3 × 2
#>   condition max_od
#>   <fct>      <dbl>
#> 1 Cg         2.02 
#> 2 CgFlu      1.97 
#> 3 YPD        0.105

growkar results stored in metadata(se) are ordinary tibbles, so they slot directly into the same downstream workflow.

Exploratory plotting and quality control

Graphing is optional in growkar: ggplot2 and RColorBrewer are declared in Suggests, so the data-structure and analysis layers install without a graphics stack. The plot_*() functions check for these packages at call time and return standard ggplot objects that can be customized further.

plot_growth_curve(
  se,
  average_replicates = TRUE,
  colour_col = "condition",
  palette_name = "Dark2"
)

Replicate-level faceting remains available when averaging is disabled:

plot_growth_curve(
  se,
  average_replicates = FALSE,
  colour_col = "condition",
  facet_col = "replicate",
  palette_name = "Dark2"
)

Empirical summaries

metrics <- summarize_growth_metrics(
  se,
  method = "rolling_window",
  average_replicates = TRUE
)

knitr::kable(metrics, digits = 3)
sample mu start_time end_time r_squared method n_points degraded note doubling_time
Cg 0.569 4.5 6.5 1.0 rolling_window 5 FALSE rolling_window_ranked 1.219
CgFlu 0.404 4.5 6.5 1.0 rolling_window 5 FALSE rolling_window_ranked 1.714
YPD 0.001 12.5 14.5 0.5 rolling_window 5 FALSE rolling_window_ranked 539.788

For a single sample, the empirical growth-rate estimate can be inspected in more detail:

gr <- compute_growth_rate(se, method = "rolling_window")
#> Warning: Sample `YPD_R1`: Exponential phase detection did not yield a positive
#> growth slope (rolling_window_ranked).
#> Warning: Sample `YPD_R2`: Exponential phase detection did not yield a positive
#> growth slope (rolling_window_ranked).

knitr::kable(head(gr), digits = 3)
sample mu start_time end_time r_squared method n_points degraded note
Cg_R1 0.576 4.5 6.5 1.000 rolling_window 5 FALSE rolling_window_ranked
Cg_R2 0.560 4.5 6.5 1.000 rolling_window 5 FALSE rolling_window_ranked
Cg_R3 0.571 4.5 6.5 0.999 rolling_window 5 FALSE rolling_window_ranked
CgFlu_R1 0.403 4.5 6.5 1.000 rolling_window 5 FALSE rolling_window_ranked
CgFlu_R2 0.407 4.5 6.5 1.000 rolling_window 5 FALSE rolling_window_ranked
CgFlu_R3 0.403 4.5 6.5 1.000 rolling_window 5 FALSE rolling_window_ranked

The doubling time helper is useful when you already have growth-rate values:

knitr::kable(
  tibble(
    sample = gr$sample,
    growth_rate = gr$mu,
    doubling_time = compute_doubling_time(gr$mu)
  ),
  digits = 3
)
sample growth_rate doubling_time
Cg_R1 0.576 1.204
Cg_R2 0.560 1.238
Cg_R3 0.571 1.214
CgFlu_R1 0.403 1.719
CgFlu_R2 0.407 1.702
CgFlu_R3 0.403 1.721
YPD_R1 NA NA
YPD_R2 NA NA
YPD_R3 0.004 179.350

Exponential phase detection

detect_exponential_phase() returns the ranked candidate windows and metadata describing whether the chosen interval required any degraded fallback.

phase_tbl <- detect_exponential_phase(se)

knitr::kable(head(phase_tbl), digits = 3)
sample rank start_time end_time slope r_squared n_points selection_reason degraded
Cg_R1 1 4.5 6.5 0.576 1.000 5 rolling_window_ranked FALSE
Cg_R1 2 4.0 6.0 0.551 0.997 5 rolling_window_ranked FALSE
Cg_R1 3 5.0 7.0 0.551 0.997 5 rolling_window_ranked FALSE
Cg_R1 4 3.5 5.5 0.490 0.990 5 rolling_window_ranked FALSE
Cg_R1 5 5.5 7.5 0.490 0.991 5 rolling_window_ranked FALSE
Cg_R1 6 6.0 8.0 0.414 0.987 5 rolling_window_ranked FALSE

Model fitting and diagnostics

For a full-curve model-based summary, fit one of the supported parametric models. fit_growth_curve() returns a GrowthFit S4 object.

sample_id <- unique(gr$sample)[1]
fit_input <- as_tidy_growth_data(se) |>
  filter(sample == sample_id)

cg_fit <- fit_growth_curve(fit_input, model = "logistic")

cg_fit
#> <GrowthFit> sample=Cg_R1, model=logistic, status=converged, n_points=49
isVirtualClass("GrowthFit")
#> [1] FALSE
extract_params(cg_fit)
#> # A tibble: 1 × 6
#>   sample model    asymptote     r    t0 doubling_time_model
#>   <chr>  <chr>        <dbl> <dbl> <dbl>               <dbl>
#> 1 Cg_R1  logistic      2.03 0.777  6.97               0.892
summary(cg_fit)
#> # A tibble: 1 × 9
#>   sample model    converged status    message n_points    rss   aic   bic
#>   <chr>  <chr>    <lgl>     <chr>     <chr>      <int>  <dbl> <dbl> <dbl>
#> 1 Cg_R1  logistic TRUE      converged <NA>          49 0.0688 -175. -167.

GrowthFit supports the standard modelling generics and a small set of accessors, so slots never need to be touched directly:

fit_sample(cg_fit)
#> [1] "Cg_R1"
fit_model(cg_fit)
#> [1] "logistic"
fit_status(cg_fit)
#> [1] "converged"
fit_converged(cg_fit)
#> [1] TRUE
coef(cg_fit)
#>         K         r        t0 
#> 2.0271408 0.7772492 6.9688283
head(fitted(cg_fit))
#> [1] 0.008965938 0.013196595 0.019404332 0.028490904 0.041744026 0.060974028
nobs(cg_fit)
#> [1] 49

Failed fits remain machine-readable and do not crash downstream helpers:

flat_fit <- fit_growth_curve(
  tibble(
    sample = "flat",
    time = 0:4,
    od = rep(0.2, 5)
  ),
  model = "logistic"
)

summary(flat_fit)
#> # A tibble: 1 × 9
#>   sample model    converged status     message        n_points   rss   aic   bic
#>   <chr>  <chr>    <lgl>     <chr>      <chr>             <int> <dbl> <dbl> <dbl>
#> 1 flat   logistic FALSE     flat_curve Model fitting…        5    NA    NA    NA

Across a plate, fits are stored in metadata() of the same SummarizedExperiment:

se <- fit_growth_models(se, model = "logistic")

S4Vectors::metadata(se)$growth_model_parameters
#> # A tibble: 9 × 6
#>   sample   model    asymptote          r       t0 doubling_time_model
#>   <chr>    <chr>        <dbl>      <dbl>    <dbl>               <dbl>
#> 1 Cg_R1    logistic     2.03  0.777        6.97                 0.892
#> 2 Cg_R2    logistic     2.01  0.784        6.95                 0.884
#> 3 Cg_R3    logistic     2.02  0.784        7.03                 0.884
#> 4 CgFlu_R1 logistic     1.98  0.563        7.51                 1.23 
#> 5 CgFlu_R2 logistic     1.96  0.569        7.40                 1.22 
#> 6 CgFlu_R3 logistic     1.90  0.589        7.29                 1.18 
#> 7 YPD_R1   logistic     0.208 0.00000001   0.0423        69314718.   
#> 8 YPD_R2   logistic     0.208 0.00000001   0.0423        69314718.   
#> 9 YPD_R3   logistic     0.208 0.00000001 812.            69314718.

Observed and fitted values can be visualized together:

plot_fitted_curve(cg_fit)

Supported API

The supported SE-native interface includes:

Limitations and interpretation notes

Session information

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.4 LTS
#> 
#> Matrix products: default
#> BLAS:   /home/biocbuild/bbs-3.24-bioc/R/lib/libRblas.so 
#> LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.12.0  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_GB              LC_COLLATE=C              
#>  [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: America/New_York
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats4    stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#>  [1] ggplot2_4.0.3                   tidyr_1.3.2                    
#>  [3] tidySummarizedExperiment_1.23.2 ttservice_0.5.3                
#>  [5] SummarizedExperiment_1.43.0     Biobase_2.73.2                 
#>  [7] GenomicRanges_1.65.3            Seqinfo_1.3.2                  
#>  [9] IRanges_2.47.5                  S4Vectors_0.51.9               
#> [11] BiocGenerics_0.59.12            generics_0.1.4                 
#> [13] MatrixGenerics_1.25.0           matrixStats_1.5.0              
#> [15] knitr_1.51                      dplyr_1.2.1                    
#> [17] growkar_0.99.2                 
#> 
#> loaded via a namespace (and not attached):
#>  [1] gtable_0.3.6        xfun_0.60           bslib_0.12.0       
#>  [4] htmlwidgets_1.6.4   lattice_0.23-1      vctrs_0.7.3        
#>  [7] tools_4.6.1         tidyprint_1.1.0     tibble_3.3.1       
#> [10] fansi_1.0.7         pkgconfig_2.0.3     Matrix_1.7-6       
#> [13] data.table_1.18.6.1 RColorBrewer_1.1-3  S7_0.2.2           
#> [16] lifecycle_1.0.5     compiler_4.6.1      farver_2.1.2       
#> [19] stringr_1.6.0       htmltools_0.5.9     sass_0.4.10        
#> [22] yaml_2.3.12         plotly_4.12.1       pillar_1.11.1      
#> [25] jquerylib_0.1.4     ellipsis_0.3.3      DelayedArray_0.39.6
#> [28] cachem_1.1.0        abind_1.4-8         tidyselect_1.2.1   
#> [31] digest_0.6.39       stringi_1.8.9       purrr_1.2.2        
#> [34] labeling_0.4.3      fastmap_1.2.0       grid_4.6.1         
#> [37] cli_3.6.6           SparseArray_1.13.2  magrittr_2.0.5     
#> [40] S4Arrays_1.13.0     dichromat_2.0-1     utf8_1.2.6         
#> [43] withr_3.0.3         scales_1.4.0        rmarkdown_2.31     
#> [46] XVector_0.53.0      httr_1.4.8          otel_0.2.0         
#> [49] evaluate_1.0.5      viridisLite_0.4.3   rlang_1.3.0        
#> [52] glue_1.8.1          jsonlite_2.0.0      R6_2.6.1