growkar growkar hex sticker

A toolkit for high-throughput growth curve analysis

R-CMD-check BiocCheck License: MIT

growkar is a Bioconductor-oriented R package for the analysis of high-throughput microbial growth experiments, such as plate-based optical density assays. It provides infrastructure for extracting quantitative growth phenotypes, including lag time, growth rate, doubling time, and carrying capacity, from time-series measurements, and for representing assay data and derived phenotypes in Bioconductor containers such as SummarizedExperiment. This makes it possible to integrate growth-based phenotyping with genomic, transcriptomic, and other omics data in broader functional genomics and microbial systems biology workflows.

Data generated from Agilent microplate readers, BioTek Cytation instruments, and other OD600-based microbial growth measurement platforms can be directly analysed using growkar.

growkar is built around SummarizedExperiment as its only data model. Tidy tables using the canonical columns sample, time, and od, and wide plate-reader exports, are accepted as import adapters and converted into the SE representation early in the workflow. SE-aware helpers store derived results in metadata() for downstream analysis. Core analysis functions accept tidy tables, wide plate-reader exports, and SummarizedExperiment input.

Graphing is optional: ggplot2 is declared in Suggests, so the data-structure and analysis layers install without a graphics stack. The plot_*() functions check for it at call time and return standard ggplot objects that can be customized into publication-ready figures. Qualitative palettes come from grDevices::palette.colors() in base R.

Input formats

Input data can be supplied in either:

If replicate identifiers are encoded in sample names, use a consistent suffix such as _R1 or _1 so growkar can infer replicate metadata reliably.

Data model

Class design

growkar defines two S4 classes and no S3 classes:

Tidy manipulation and display of these objects are not reimplemented here. growkar uses tidySummarizedExperiment for tidy verbs, so dplyr, tidyr, and ggplot2 grammar works directly on growkar objects.

Installation

remotes::install_github("sethiyap/growkar")

Example dataset

library(growkar)
data(yeast_growth_data)

head(yeast_growth_data)
#> # A tibble: 6 × 10
#>    Time Cg_R1 Cg_R2 Cg_R3 CgFlu_R1 CgFlu_R2 CgFlu_R3 YPD_R1 YPD_R2 YPD_R3
#>   <dbl> <dbl> <dbl> <dbl>    <dbl>    <dbl>    <dbl>  <dbl>  <dbl>  <dbl>
#> 1   0   0.115 0.116 0.117    0.131    0.133    0.132  0.105  0.105  0.104
#> 2   0.5 0.116 0.118 0.117    0.132    0.133    0.134  0.104  0.104  0.104
#> 3   1   0.118 0.119 0.118    0.134    0.136    0.138  0.104  0.104  0.104
#> 4   1.5 0.121 0.123 0.121    0.139    0.142    0.144  0.104  0.104  0.104
#> 5   2   0.126 0.129 0.126    0.15     0.151    0.151  0.104  0.104  0.104
#> 6   2.5 0.136 0.139 0.136    0.165    0.168    0.166  0.104  0.104  0.104

Import and validate data

What it does: as_tidy_growth_data() converts growth data into a tidy inspection/export format with core columns sample, time, and od.

Why use it: It is the import layer for user-supplied assay tables. Once the data have been standardized and checked, the primary workflow should continue with a SummarizedExperiment.

Minimal example:

tidy_data <- as_tidy_growth_data(yeast_growth_data)
validate_growth_data(tidy_data)
#> # 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
head(tidy_data)
#> # 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

Create the canonical GrowthExperiment

What it does: GrowthExperiment() converts growth data directly into a GrowthExperiment — an S4 extension of SummarizedExperiment with an od assay, time in rowData(), and sample metadata in colData(). Standard coercion, as(data, "GrowthExperiment"), is equivalent.

Why use it: This is the primary data object for analysis, plotting, and Bioconductor integration. as(ge, "SummarizedExperiment") hands it off to any other Bioconductor package.

Minimal example:

se <- GrowthExperiment(tidy_data)
se
#> 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:
identical(se, as(tidy_data, "GrowthExperiment"))
#> [1] TRUE

Useful accessors for SE-based workflows include growth_assay(), timepoints(), sample_data(), and growth_model_fits().

SE-native workflow

What it does: growth_metrics() computes derived phenotype summaries and stores them in metadata() of the same SummarizedExperiment.

Why use it: This is the preferred path when growth phenotypes need to be kept alongside sample metadata and reused in larger Bioconductor workflows. phase_windows() and fit_growth_models() behave the same way for exponential-phase windows and model fits.

Minimal example:

se <- growth_metrics(
  se,
  method = "rolling_window",
  average_replicates = TRUE
)
S4Vectors::metadata(se)$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>

Plot growth curves

What it does: plot_growth_curve() visualizes OD over time and returns a ggplot object.

Why use it: It is useful for quick quality control and for comparing growth patterns across samples. Because the output is a ggplot object, users can further customize it with ggplot2.

Minimal example:

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

To view individual replicates as separate panels, use facet_col = "replicate" with average_replicates = FALSE.

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

p_rep

To average replicates first and facet by sample family automatically, use plot_growth_curve_facets().

p_facet <- plot_growth_curve_facets(
  yeast_growth_data,
  palette_name = "Dark2"
)

p_facet

Detect exponential phase

What it does: detect_exponential_phase() identifies likely exponential-phase windows automatically.

Why use it: It is a diagnostic and explanatory function. Use it when you want to inspect which time interval appears most consistent with exponential growth before moving to final summary metrics.

This schematic shows how the candidate interval is chosen on a mock growth curve for each empirical method.

mock_curve <- tibble::tibble(
  time = seq(0, 12, by = 0.25),
  od = 0.08 + 1 / (1 + exp(-0.9 * (time - 5)))
)

method_windows <- tibble::tribble(
  ~method,            ~xmin, ~xmax, ~label,
  "rolling_window",    3.0,   5.0,  "Best local log-linear window",
  "defined_interval",  2.0,   6.0,  "User-defined interval",
  "rule_based",        2.5,   5.5,  "OD-doubling anchored interval"
)

method_plot_data <- dplyr::bind_rows(
  lapply(method_windows$method, function(method_name) {
    dplyr::mutate(mock_curve, method = method_name)
  })
)

ggplot2::ggplot(method_plot_data, ggplot2::aes(time, od)) +
  ggplot2::geom_rect(
    data = method_windows,
    ggplot2::aes(xmin = xmin, xmax = xmax, ymin = -Inf, ymax = Inf),
    inherit.aes = FALSE,
    fill = "#DDAA33",
    alpha = 0.18
  ) +
  ggplot2::geom_line(linewidth = 0.9, colour = "#0B3C5D") +
  ggplot2::geom_text(
    data = dplyr::mutate(method_windows, x = xmax - 0.1, y = 0.95),
    ggplot2::aes(x = x, y = y, label = label),
    inherit.aes = FALSE,
    hjust = 1,
    size = 3.2
  ) +
  ggplot2::facet_wrap(~method, ncol = 1) +
  ggplot2::labs(x = "Time", y = "OD", title = "How each empirical method selects the growth segment") +
  ggplot2::theme_minimal(base_size = 11)

Minimal example:

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)

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

Estimate growth rate

What it does: compute_growth_rate() estimates the specific growth rate from log(od) versus time. In the returned table, mu is the estimated growth rate.

Why use it: It is useful for estimating exponential growth directly from observed data, using methods such as "rolling_window", "defined_interval", and "rule_based".

Briefly, growth rate is estimated as the slope of log(OD) versus time over the selected interval, and doubling time is then calculated as log(2) / mu.

Method options:

Minimal example:

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

Use the same interval for all samples:

gr_defined_all <- compute_growth_rate(
  se,
  method = "defined_interval",
  interval = c(2, 6)
)
#> Warning: Sample `YPD_R1`: Defined interval did not yield a positive growth
#> slope.
#> Warning: Sample `YPD_R2`: Defined interval did not yield a positive growth
#> slope.
#> Warning: Sample `YPD_R3`: Defined interval did not yield a positive growth
#> slope.

knitr::kable(head(gr_defined_all), digits = 3)
sample mu start_time end_time r_squared method n_points degraded note
Cg_R1 0.400 2 6 0.956 defined_interval 9 FALSE defined_interval_fit
Cg_R2 0.394 2 6 0.959 defined_interval 9 FALSE defined_interval_fit
Cg_R3 0.386 2 6 0.953 defined_interval 9 FALSE defined_interval_fit
CgFlu_R1 0.339 2 6 0.990 defined_interval 9 FALSE defined_interval_fit
CgFlu_R2 0.342 2 6 0.989 defined_interval 9 FALSE defined_interval_fit
CgFlu_R3 0.345 2 6 0.988 defined_interval 9 FALSE defined_interval_fit

Use different intervals for different samples:

interval_tbl <- tibble::tibble(
  sample = unique(gr$sample)[1:2],
  start_time = c(2, 3),
  end_time = c(5, 6)
)

se_subset <- se[, SummarizedExperiment::colData(se)$sample %in% interval_tbl$sample]

gr_defined_by_sample <- compute_growth_rate(
  se_subset,
  method = "defined_interval",
  interval = interval_tbl
)

knitr::kable(gr_defined_by_sample, digits = 3)
sample mu start_time end_time r_squared method n_points degraded note
Cg_R1 0.326 2 5 0.955 defined_interval 7 FALSE defined_interval_fit
Cg_R2 0.468 3 6 0.985 defined_interval 7 FALSE defined_interval_fit

Compute doubling time

What it does: compute_doubling_time() calculates doubling time from growth rate as log(2) / mu.

Why use it: It converts the estimated growth rate into a more biologically interpretable measure of growth kinetics. This is the low-level helper when you already have one or more growth-rate estimates. For a combined table across all samples, use summarize_growth_metrics().

Minimal example:

doubling_time_tbl <- tibble::tibble(
  sample = gr$sample,
  growth_rate = gr$mu,
  doubling_time = compute_doubling_time(gr$mu)
)

knitr::kable(doubling_time_tbl, 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

Summarize growth metrics across samples

What it does: summarize_growth_metrics() computes growth rate and doubling time across all samples.

Why use it: It is the reporting function for final sample- or group-level results. Use it when you want a tidy table for comparing strains, conditions, or replicates. Internally, it derives doubling time from the estimated growth rate using compute_doubling_time().

Minimal example:

metrics <- summarize_growth_metrics(se)
#> 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(metrics, digits = 3)
sample mu start_time end_time r_squared method n_points degraded note doubling_time
Cg_R1 0.576 4.5 6.5 1.000 rolling_window 5 FALSE rolling_window_ranked 1.204
Cg_R2 0.560 4.5 6.5 1.000 rolling_window 5 FALSE rolling_window_ranked 1.238
Cg_R3 0.571 4.5 6.5 0.999 rolling_window 5 FALSE rolling_window_ranked 1.214
CgFlu_R1 0.403 4.5 6.5 1.000 rolling_window 5 FALSE rolling_window_ranked 1.719
CgFlu_R2 0.407 4.5 6.5 1.000 rolling_window 5 FALSE rolling_window_ranked 1.702
CgFlu_R3 0.403 4.5 6.5 1.000 rolling_window 5 FALSE rolling_window_ranked 1.721
YPD_R1 NA NA NA NA rolling_window 5 FALSE rolling_window_ranked NA
YPD_R2 NA NA NA NA rolling_window 5 FALSE rolling_window_ranked NA
YPD_R3 0.004 12.5 14.5 0.500 rolling_window 5 FALSE rolling_window_ranked 179.350

To summarize replicate-level doubling time by condition and compare each group to a reference condition, supply comparison_col and compare_to.

dt_stats <- summarize_growth_metrics(
  se,
  method = "rolling_window",
  comparison_col = "condition",
  compare_to = "Cg",
  select_replicates = c("R1", "R2", "R3")
)
#> 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(dt_stats, digits = 3)
condition mean_mu mean_doubling_time sd_doubling_time n_replicates error_bar p_value p_value_label
Cg 0.569 1.219 0.017 3 0.010 NA ref
CgFlu 0.404 1.714 0.010 3 0.006 0 ****
YPD 0.004 179.350 NA 1 NA NA NA

This summary includes numeric p-values in p_value and asterisk-form significance labels in p_value_label.

Plot doubling time

What it does: plot_doubling_time() summarizes replicate-level doubling times as a bar plot with error bars and optional comparison brackets annotated with significance asterisks.

Why use it: It is useful for comparing conditions or strains at the doubling-time level while showing replicate variability and a reference-group comparison.

Minimal example:

plot_doubling_time(
  se,
  comparison_col = "condition",
  compare_to = "Cg",
  exclude_groups = "YPD",
  select_replicates = c("R1", "R2", "R3"),
  palette_name = "Dark2"
)
#> 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).

Fit a growth model

What it does: fit_growth_curve() fits a logistic or Gompertz growth model to one sample.

Why use it: It is useful when a smooth model-based summary of the full growth curve is preferred over a purely empirical estimate. The result is a GrowthFit S4 object supporting show(), summary(), coef(), fitted(), residuals(), and nobs().

Minimal example:

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

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

fit
#> <GrowthFit> sample=Cg_R1, model=logistic, status=converged, n_points=49
coef(fit)
#>         K         r        t0 
#> 2.0271408 0.7772492 6.9688283

Extract fitted parameters

What it does: extract_params() extracts fitted coefficients and model-derived quantities such as the asymptote and model-based doubling time.

Why use it: It is useful for reporting fitted summaries in a tidy format. Here, the asymptote is the fitted upper plateau of the growth curve, often interpreted as the model-predicted maximum OD reached at late time points.

Minimal example:

params <- extract_params(fit)
params
#> # 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

Plot fitted curves

What it does: plot_fitted_curve() overlays observed OD points and the fitted growth model, and returns a ggplot object.

Why use it: It is useful for visually checking model fit quality. Because the output is a ggplot object, users can further customize it with ggplot2.

Minimal example:

pf <- plot_fitted_curve(fit)
pf

To fit and view individual replicates as separate panels from raw data, use facet_col = "replicate" with average_replicates = FALSE.

pf_rep <- plot_fitted_curve(
  se,
  model = "logistic",
  average_replicates = FALSE,
  colour_col = "condition",
  facet_col = "replicate",
  palette_name = "Dark2"
)

pf_rep

Supported API

The supported SE-native interface includes:

Optional graphing interface (requires ggplot2):

Example analysis

Complete worked examples can be found at:

  1. KN99 CDK7 growkar workflow example
  2. ScBS181 growkar workflow example

Contributing and issues

Bug reports, feature requests, and suggestions are welcome at:

https://github.com/sethiyap/growkar/issues

License

MIT License