MetPhenoR

Aim of this package

High-throughput metabolic phenotype microarray data is still relatively new in mammalian cell culture research. As such, many questions remain:

  • What do the results actually mean? i.e. is a lower or higher metabolic activity on anti-cancer drug plates and indicator for effectiveness of treatment ?
  • Why can we observe OD drops if the dye should be irreversible ?
  • How can growth dynamics be recorded and incorporated in the analysis for mammalian cell cultures ?
  • How do medium composition or handling affect OD readings?
  • How do we handle unexpected curve shapes?

This package aims to provide:

  • Simple reading of multiple RawRead.csv and PlateInfo.csv files into R.
  • Noise correction: OD(590nm) – OD(740nm) (as recommended by Biolog for mammalian cells).
  • Quality control plots to spot odd or problematic OD curves.
  • Filtering of entire plates or specific curve types before analysis.
  • Calculation of Area Under the Curve (AUC) as a summary metric.
  • Normalization of AUC to the mean of control wells on the same plate.
  • Visualizations of AUC, normalized AUC, and group-level patterns.
  • Annotation of chemicals and biological metadata (e.g., KEGG pathways).
  • Heatmaps and summary plots to explore biological trends.

Important Limitations:

  • Not suitable for bacteria or suspension cultures — in these systems, OD(740nm) does reflect cell growth, so noise correction is invalid.
  • AUC does not capture curve dynamics — two very different curves (e.g., early peak vs. slow rise) can have the same AUC.
  • Does not account for cell growth — if your cells are growing rapidly, this may confound results.
  • Does not interpret biological meaning — a “hit” in a metabolic module does not imply pathway activation; it only means the OD was higher or lower than controls.

Important to Know Before You Start

The read_folder() function reads every RawRead.csv and PlateInfo.csv file in the specified folder and all sub directories.

Always double-check that:

  • You’ve copied only the files you want to analyze.
  • There are no extra or duplicate files.
  • All PlateInfo.csv files have the same column order and same variable names.

Also: R is case-sensitive.
H460 ≠ h460.
0Gy ≠ 0gy.
This can cause missing data or errors later — so be careful!

Workflow

Step 1: Prepare your Data

Before you can analyze, you need to organize your files properly.

  1. Create a new folder (e.g., Analysis).
  2. Copy all your RawRead.csv and PlateInfo.csv files into this folder.
  3. Open each PlateInfo.csv file in Excel or a text editor.
  4. Choose one consistent column (e.g., Description, Sample) to store your experimental conditions.
  5. Enter your experimental conditions in this format: variable1_variable2_variable3 e.g. H460_ctrl_2h
    → Use underscores, no spaces, and consistent capitalization to separate the variables Keep a consistent order of the variables

Why? Because read_folder() splits this column into separate variables (e.g., cell.line, condition, time.h). If the format is inconsistent, it will fail or create duplicates.

Step 2: Read your Data with read_folder() function

Arguments of read_folder():

  • path: the location of your folder on your machine (for windows in this format “C:”, for Linux “~/Folder)
  • variables: the order and name of your variables in your experiment
  • sample_column: the column name you stored your experiment information in (e.g H460_ctrl_2h) the PlateInfo files in Step 1

What follows next is the R code and the corresponding output. We included example data in this package to be able to demonstrate the workflow. Everything following a “#” in code are comments that should help understand what the code does.

library(MetPhenoR)
data("RawRead") # load example RawRead file stored in MetPhenoR

If you are using RStudio an object (data frame) should now be loaded to the Environment tab. You can click on it’s name to inspect the dataframe or type

head(RawRead)
#> # A tibble: 6 × 102
#>   Id            PlateId Wavelength Actual Temperature C…¹ Target Temperature C…²
#>   <chr>         <chr>        <dbl>                  <dbl>                  <dbl>
#> 1 80aaa046-c9d… 6e1e3d…        590                   37.3                     37
#> 2 99780dd0-b47… 6e1e3d…        740                   37.3                     37
#> 3 51011480-f92… 6e1e3d…        590                   37.1                     37
#> 4 4a0b5a5c-886… 6e1e3d…        740                   37.1                     37
#> 5 a28b7969-fc1… 6e1e3d…        590                   37                       37
#> 6 be0de81b-576… 6e1e3d…        740                   37                       37
#> # ℹ abbreviated names: ¹​`Actual Temperature Celsius`,
#> #   ²​`Target Temperature Celsius`
#> # ℹ 97 more variables: `Read At` <dttm>, A01 <dbl>, A02 <dbl>, A03 <dbl>,
#> #   A04 <dbl>, A05 <dbl>, A06 <dbl>, A07 <dbl>, A08 <dbl>, A09 <dbl>,
#> #   A10 <dbl>, A11 <dbl>, A12 <dbl>, B01 <dbl>, B02 <dbl>, B03 <dbl>,
#> #   B04 <dbl>, B05 <dbl>, B06 <dbl>, B07 <dbl>, B08 <dbl>, B09 <dbl>,
#> #   B10 <dbl>, B11 <dbl>, B12 <dbl>, C01 <dbl>, C02 <dbl>, C03 <dbl>, …

which prints the first rows of the dataframe to your console.

Let’s look at the corresponding example PlateInfo.csv file.

data("PlateInfo") # loads PlateInfo example file from MetPhenoR
head(PlateInfo) # shows first few rows of PlateInfo dataframe
#> # A tibble: 6 × 13
#>   PlateId  `Project Name` `Project Code` `Plate Type` Sample `Loaded At`        
#>   <chr>    <chr>          <chr>          <chr>        <chr>  <dttm>             
#> 1 c2aa34c… example_proje… exp            PM-M01       A_ctr… 2025-05-30 13:50:36
#> 2 6e1e3d1… example_proje… exp            PM-M01       A_ctr… 2025-05-30 13:50:36
#> 3 6237378… example_proje… exp            PM-M01       A_tre… 2025-05-30 13:50:36
#> 4 011a375… example_proje… exp            PM-M02       A_tre… 2025-05-30 13:50:36
#> 5 3b477cc… example_proje… exp            PM-M02       A_tre… 2025-05-30 13:50:36
#> 6 7e309fc… example_proje… exp            PM-M02       A_ctr… 2025-05-30 13:50:36
#> # ℹ 7 more variables: `Incubation Hours` <dbl>,
#> #   `Incubation Temperature Celsius` <dbl>, `Incubated Offline` <lgl>,
#> #   `Cycle Time Minutes` <dbl>, Position <chr>, Protocol <lgl>,
#> #   Description <lgl>

In this example file the column Sample contains the experimental information.

head(PlateInfo$Sample) # shows first few rows of column storing experimental information
#> [1] "A_ctrl_inhibitor_2h"      "A_ctrl_ctrl_2h"          
#> [3] "A_treatment_ctrl_2h"      "A_treatment_ctrl_2h"     
#> [5] "A_treatment_inhibitor_2h" "A_ctrl_ctrl_2h"

In the next I show an example code to load you data from a folder named “metabolic_phenotyping” on drive A: and assign it to an object called data.

# data <- read_folder(data_path = "A:/metabolic_phenotyping", # folder path
#                    sample_column = "Sample", # which column in PlateInfo contains experimental information
#                    variables = c("cell.line","condition","time.h") # sets the variable names for the following workflow)

This function does five things:

  • Read all RawRead and PlateInfo files
  • annotation (i.e. joining the information) of the experimental condition from PlateInfo to RawReads
  • conduct noise correction: calculate OD at 590nm - OD at 740nm and store result in column “diff_OD_590_740”
  • create a list of data frames that are combined in one object with elements: data containing the processed RawReads and PlateInfo files, and meta containing the provided user information about original file path, variable names and original column name containing experimental information.

Let’s load an example of multiple processed RawReads and PlateInfo files and look at the different elements:

data("processed_data") # load example output of read_folder() function

head(processed_data$data) # show first lines of element "data" in object "processed_data"
#> # A tibble: 6 × 23
#>   PlateId        Project.Name Project.Code Plate.Type Sample cell.line treatment
#>   <chr>          <chr>        <chr>        <chr>      <chr>  <chr>     <chr>    
#> 1 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 2 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 3 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 4 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 5 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 6 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> # ℹ 16 more variables: inhibitor <chr>, time <chr>, Loaded.At <dttm>,
#> #   Incubation.Hours <dbl>, Incubation.Temperature.Celsius <dbl>,
#> #   Incubated.Offline <lgl>, Cycle.Time.Minutes <dbl>, Position <chr>,
#> #   Protocol <lgl>, Description <lgl>, Actual.Temperature.Celsius <dbl>,
#> #   Target.Temperature.Celsius <dbl>, Read.At <dttm>, Hour <dbl>, Well <chr>,
#> #   diff_OD_590_740 <dbl>

You will find that you set variables are now not only stored in your sample_column but also separate variables. This comes in especially handy for custom analysis and visualization.

processed_data$meta # display meta information: folder path, sample column, variables
#> $data_path
#> [1] "~/Documents/packages/Odin data/ODIN Data 03.06.2025/"
#> 
#> $sample_column
#> [1] "Sample"
#> 
#> $variables
#> [1] "cell.line" "treatment" "inhibitor" "time"

Now that our data is read in let’s check if we read in the data correctly.

Step3: Check your data with qc_data() function

Now that the data is in, let’s inspect it.

qc_data(processed_data) # execute function qc_data() on read in data stored in object "processed_data" to create plot

This plot shows:

  • X-axis: First 5 wells (controls).
  • Y-axis: All combinations of your experimental variables.
  • Panels: All distinct plate types.
  • Color: Number of distinct OD curves per well.

What to look for:

  • One “Replicate” per well → noise correction worked.
  • No missing wells → all files were read.
  • All expected conditions → no duplicates or missing entries.
  • No grey areas → if a well is grey, it means no data was found.

If you see multiple replicates or grey wells, check:

  • Did you miss a RawRead.csv or PlateInfo.csv?
  • Is the Sample column formatted consistently?
  • Are there spaces or typos?

Step 4 (Optional): Visualize the Final Timepoint: plot_latest_timepoint()

To get a first overview we first visualize the latest time point (i.e. end of read) in 96-well format. These plots should roughly represent how your plate looks like after the OD recording. In the title the specific experiment is automatically labelled and one plot is created per distinct 96-well plate in the read in data:

plot_latest_timepoint(processed_data)
#> $`A_ctrl_ctrl_2h_PM-M01`

#> 
#> $`A_ctrl_ctrl_2h_PM-M02`

#> 
#> $`A_ctrl_inhibitor_2h_PM-M01`

#> 
#> $`A_ctrl_inhibitor_2h_PM-M02`

#> 
#> $`A_treatment_ctrl_2h_PM-M01`

#> 
#> $`A_treatment_ctrl_2h_PM-M02`

#> 
#> $`A_treatment_inhibitor_2h_PM-M01`

#> 
#> $`A_treatment_inhibitor_2h_PM-M02`

Step 5: Annotate Chemicals to PlateType and Well with annotate_chemical()

To make sense of your data, you need to know what chemical is in each well.

The package includes built-in annotations for:

  • Metabolic plates: PM-M01, PM-M02 data (“PM_M01-M02”)
  • Anti-cancer drug plates: PM-M11 to PM-M14 data(“PM_M11_M14”)
data("PM_M01_M02") # load annotation file for PM-M01 and PM-M02
head(PM_M01_M02) # inspect for few rows
#> # A tibble: 6 × 11
#>   Plate.Type Well  Chemical        `Kegg Link` Info  KEGG  module_id module_name
#>   <chr>      <chr> <chr>           <chr>       <chr> <chr> <chr>     <chr>      
#> 1 PM-M01     A01   Negative Contr… <NA>        C-So… <NA>  <NA>      <NA>       
#> 2 PM-M01     A02   Negative Contr… <NA>        C-So… <NA>  <NA>      <NA>       
#> 3 PM-M01     A03   Negative Contr… <NA>        C-So… <NA>  <NA>      <NA>       
#> 4 PM-M01     A04   a-Cyclodextrin  https://ww… C-So… C009… <NA>      <NA>       
#> 5 PM-M01     A05   Dextrin         https://ww… C-So… C007… <NA>      <NA>       
#> 6 PM-M01     A06   Glycogen        https://ww… C-So… C001… <NA>      <NA>       
#> # ℹ 3 more variables: upper_hierarchy <chr>, middle_hierarchy <chr>,
#> #   lower_hierarchy <chr>
processed_data <- annotate_chemical(processed_data, annotation = PM_M01_M02) # annotate Chemical Information
head(processed_data$data)
#> # A tibble: 6 × 25
#>   PlateId        Project.Name Project.Code Plate.Type Sample cell.line treatment
#>   <chr>          <chr>        <chr>        <chr>      <chr>  <chr>     <chr>    
#> 1 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 2 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 3 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 4 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 5 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 6 c2aa34c0-d80f… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> # ℹ 18 more variables: inhibitor <chr>, time <chr>, Loaded.At <dttm>,
#> #   Incubation.Hours <dbl>, Incubation.Temperature.Celsius <dbl>,
#> #   Incubated.Offline <lgl>, Cycle.Time.Minutes <dbl>, Position <chr>,
#> #   Protocol <lgl>, Description <lgl>, Actual.Temperature.Celsius <dbl>,
#> #   Target.Temperature.Celsius <dbl>, Read.At <dttm>, Hour <dbl>, Well <chr>,
#> #   diff_OD_590_740 <dbl>, Chemical <chr>, control <chr>

Now you’ll see two new columns:

  • Chemical: e.g., “Glucose”, “Lactate”, “Acetate”
  • control: “negative” (e.g. No Chemical in well or Tween), “positive” (e.g. Glucose) or NA (all other metabolites and chemical)

If you want to save your annotated data at this point, e.g. for further analysis with other software, you can write a csv file with:

# write.csv(processed_data$data,  # just stores the dataframe but not meta information about original file path
#          file = "A:\results")  # your file path

If you want to store your complete object to keep the meta information (i.e. original folder path, …) you can store as an .RData object that only can be read by R.

# save(processed_data$data,
#     file = "A:\results")

Custom annotation files

Want to use your own plate annotation?

  • Create a CSV file with columns: Plate.Type, Well, Chemical and control (should only contain “positive”, “negative” or no entry).

  • Use read_csv() and pass it to annotate_chemical().

# install needed package
# if (!require("readr", quietly = TRUE)) {
#  install.packages("readr")
# }

# load custom annotation
# custom <- read_csv("A:/myfile.csv")
# processed_data <- annotate_chemical(processed_data, annotation = custom)

Step 6: Quality control of OD curves

In real experiments, we’ve seen:

  • Unexpected OD drops after an initial rise (in theory the dye provided by Biolog is irreversible in physiological pH ranges).
  • Wells with no increase at all.
  • Flat or noisy curves.

This step helps you identify and filter problematic curves.

Classify_growth_curves() adds an element classification to the our object processed_data. The classification of OD curves can be tweaked with parameters. For details type ?classify_growth_curve_types in R or click on the function name and press F1, which opens the help page.

processed_data <- classify_growth_curve_types(processed_data) # classify growth curves
head(processed_data$classification)
#> $derivative_data
#> # A tibble: 241,384 × 27
#>    cell.line treatment inhibitor time  Plate.Type Well  PlateId     Project.Name
#>    <chr>     <chr>     <chr>     <chr> <chr>      <chr> <chr>       <chr>       
#>  1 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  2 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  3 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  4 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  5 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  6 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  7 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  8 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  9 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#> 10 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#> # ℹ 241,374 more rows
#> # ℹ 19 more variables: Project.Code <chr>, Sample <chr>, Loaded.At <dttm>,
#> #   Incubation.Hours <dbl>, Incubation.Temperature.Celsius <dbl>,
#> #   Incubated.Offline <lgl>, Cycle.Time.Minutes <dbl>, Position <chr>,
#> #   Protocol <lgl>, Description <lgl>, Actual.Temperature.Celsius <dbl>,
#> #   Target.Temperature.Celsius <dbl>, Read.At <dttm>, Hour <dbl>,
#> #   diff_OD_590_740 <dbl>, Chemical <chr>, control <chr>, spline <dbl>, …
#> 
#> $well_summary
#> # A tibble: 768 × 17
#>    cell.line treatment inhibitor time  Plate.Type Well  n_points has_drop
#>    <chr>     <chr>     <chr>     <chr> <chr>      <chr>    <int> <lgl>   
#>  1 A         ctrl      ctrl      2h    PM-M01     A01        143 FALSE   
#>  2 A         ctrl      ctrl      2h    PM-M01     A02        143 FALSE   
#>  3 A         ctrl      ctrl      2h    PM-M01     A03        143 FALSE   
#>  4 A         ctrl      ctrl      2h    PM-M01     A04        143 FALSE   
#>  5 A         ctrl      ctrl      2h    PM-M01     A05        143 FALSE   
#>  6 A         ctrl      ctrl      2h    PM-M01     A06        143 FALSE   
#>  7 A         ctrl      ctrl      2h    PM-M01     A07        143 FALSE   
#>  8 A         ctrl      ctrl      2h    PM-M01     A08        143 FALSE   
#>  9 A         ctrl      ctrl      2h    PM-M01     A09        143 FALSE   
#> 10 A         ctrl      ctrl      2h    PM-M01     A10        286 FALSE   
#> # ℹ 758 more rows
#> # ℹ 9 more variables: has_growth <lgl>, all_flat <lgl>, max_od <dbl>,
#> #   derivative_sd <dbl>, od_range <dbl>, qc_low_points <lgl>,
#> #   qc_high_noise <lgl>, qc_flag <lgl>, curve_type <chr>

The classification can visualized with the plot_growth_curve() function, that creates one plot per 96-well plate:

plot_growth_curve(processed_data)
#> $data
#> # A tibble: 241,384 × 25
#>    PlateId       Project.Name Project.Code Plate.Type Sample cell.line treatment
#>    <chr>         <chr>        <chr>        <chr>      <chr>  <chr>     <chr>    
#>  1 c2aa34c0-d80… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#>  2 c2aa34c0-d80… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#>  3 c2aa34c0-d80… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#>  4 c2aa34c0-d80… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#>  5 c2aa34c0-d80… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#>  6 c2aa34c0-d80… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#>  7 c2aa34c0-d80… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#>  8 c2aa34c0-d80… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#>  9 c2aa34c0-d80… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> 10 c2aa34c0-d80… example_pro… exp          PM-M01     A_ctr… A         ctrl     
#> # ℹ 241,374 more rows
#> # ℹ 18 more variables: inhibitor <chr>, time <chr>, Loaded.At <dttm>,
#> #   Incubation.Hours <dbl>, Incubation.Temperature.Celsius <dbl>,
#> #   Incubated.Offline <lgl>, Cycle.Time.Minutes <dbl>, Position <chr>,
#> #   Protocol <lgl>, Description <lgl>, Actual.Temperature.Celsius <dbl>,
#> #   Target.Temperature.Celsius <dbl>, Read.At <dttm>, Hour <dbl>, Well <chr>,
#> #   diff_OD_590_740 <dbl>, Chemical <chr>, control <chr>
#> 
#> $meta
#> $meta$data_path
#> [1] "~/Documents/packages/Odin data/ODIN Data 03.06.2025/"
#> 
#> $meta$sample_column
#> [1] "Sample"
#> 
#> $meta$variables
#> [1] "cell.line" "treatment" "inhibitor" "time"     
#> 
#> 
#> $classification
#> $classification$derivative_data
#> # A tibble: 241,384 × 27
#>    cell.line treatment inhibitor time  Plate.Type Well  PlateId     Project.Name
#>    <chr>     <chr>     <chr>     <chr> <chr>      <chr> <chr>       <chr>       
#>  1 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  2 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  3 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  4 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  5 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  6 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  7 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  8 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#>  9 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#> 10 A         ctrl      ctrl      2h    PM-M01     A01   6e1e3d13-5… example_pro…
#> # ℹ 241,374 more rows
#> # ℹ 19 more variables: Project.Code <chr>, Sample <chr>, Loaded.At <dttm>,
#> #   Incubation.Hours <dbl>, Incubation.Temperature.Celsius <dbl>,
#> #   Incubated.Offline <lgl>, Cycle.Time.Minutes <dbl>, Position <chr>,
#> #   Protocol <lgl>, Description <lgl>, Actual.Temperature.Celsius <dbl>,
#> #   Target.Temperature.Celsius <dbl>, Read.At <dttm>, Hour <dbl>,
#> #   diff_OD_590_740 <dbl>, Chemical <chr>, control <chr>, spline <dbl>, …
#> 
#> $classification$well_summary
#> # A tibble: 768 × 17
#>    cell.line treatment inhibitor time  Plate.Type Well  n_points has_drop
#>    <chr>     <chr>     <chr>     <chr> <chr>      <chr>    <int> <lgl>   
#>  1 A         ctrl      ctrl      2h    PM-M01     A01        143 FALSE   
#>  2 A         ctrl      ctrl      2h    PM-M01     A02        143 FALSE   
#>  3 A         ctrl      ctrl      2h    PM-M01     A03        143 FALSE   
#>  4 A         ctrl      ctrl      2h    PM-M01     A04        143 FALSE   
#>  5 A         ctrl      ctrl      2h    PM-M01     A05        143 FALSE   
#>  6 A         ctrl      ctrl      2h    PM-M01     A06        143 FALSE   
#>  7 A         ctrl      ctrl      2h    PM-M01     A07        143 FALSE   
#>  8 A         ctrl      ctrl      2h    PM-M01     A08        143 FALSE   
#>  9 A         ctrl      ctrl      2h    PM-M01     A09        143 FALSE   
#> 10 A         ctrl      ctrl      2h    PM-M01     A10        286 FALSE   
#> # ℹ 758 more rows
#> # ℹ 9 more variables: has_growth <lgl>, all_flat <lgl>, max_od <dbl>,
#> #   derivative_sd <dbl>, od_range <dbl>, qc_low_points <lgl>,
#> #   qc_high_noise <lgl>, qc_flag <lgl>, curve_type <chr>
#> 
#> 
#> $growth_curve_plots
#> $growth_curve_plots$`A_ctrl_ctrl_2h_PM-M01`

#> 
#> $growth_curve_plots$`A_ctrl_ctrl_2h_PM-M02`

#> 
#> $growth_curve_plots$`A_ctrl_inhibitor_2h_PM-M01`

#> 
#> $growth_curve_plots$`A_ctrl_inhibitor_2h_PM-M02`

#> 
#> $growth_curve_plots$`A_treatment_ctrl_2h_PM-M01`

#> 
#> $growth_curve_plots$`A_treatment_ctrl_2h_PM-M02`

#> 
#> $growth_curve_plots$`A_treatment_inhibitor_2h_PM-M01`

#> 
#> $growth_curve_plots$`A_treatment_inhibitor_2h_PM-M02`

Left panel: 96-well layout with color-coded curve types.
Right panel: Individual curves for non-growing types.

You can choose to store these plots in your “processed_data” object (for documentation) by assigning the results to your object. This creates a new element in processed_data called growth_curve_plots, in which every distinct plate is stored separately with it corresponding name compounded of the experimental variables and the plate type.

Limitation As we only saw the before mentioned growth types MetPhenoR is also only able to detect these.

processed_data <- plot_growth_curve(processed_data) # store plots
processed_data$growth_curve_plots$`A_treatment_inhibitor_2h_PM-M01` # just show one plot

If you make the decision to omit a complete 96-well plate (e.g. due to flat_low OD curves in the control Well, unexplainable OD drops) the function filter_data() can remove one plate from processed_data.

Let’s first recapitulate which conditions we have:

# qc_plot for overview of plate types
qc_data(processed_data)

If we made the decision that the 96-well plate with plate type “PM-M01” in cell line A, at treatment “treatment” and inhibitor “inhibitor” and at time point 2h is not suited for further analysis we can omit this plate with:

processed_data <- filter_data(processed_data,
  plate_type = "PM-M01",
  filters = list(
    cell.line = "A",
    treatment = "treatment",
    inhibitor = "inhibitor",
    time = "2h"
  )
)

We can re-use the qc_data function to check if we omitted the correct plate:

# qc_plot for overview of plate types
qc_data(processed_data)

The omitted plate has now no observations (no color).

Step 7: Summarize curves to Area under Curve (AUC)

Now that your data is clean, we can compute the AUC for each well. The compute_auc_ci() function may run for a couple seconds depending on the size of your dataset and stores the results in the element auc_results in object processed_data.

Depending on your inspection of the curve types and after potentially omitting complete plates from analysis you can decide which curve types to keep. In our example data set I demonstrate how to omit the curves with OD drops (and therefore keep “growing”, “flat_low” and “slow_growing”). The default setting is only to keep growing.

processed_data <- compute_auc_ci(processed_data,
  target_curve_type = c( # keep these curve types for AUC calculation
    "growing",
    "flat_low",
    "slow_growing"
  ), 
  n_boot = 10 # low number of bootstraps for vignette
)
head(processed_data$auc_results)
#> # A tibble: 6 × 12
#>   Plate.Type Well  cell.line treatment inhibitor time    AUC AUC_low AUC_high
#>   <chr>      <chr> <chr>     <chr>     <chr>     <chr> <dbl>   <dbl>    <dbl>
#> 1 PM-M01     A01   A         ctrl      ctrl      2h    11.5    11.4     11.5 
#> 2 PM-M01     A01   A         ctrl      inhibitor 2h    11.0    10.9     11.0 
#> 3 PM-M01     A01   A         treatment ctrl      2h     9.61    9.58     9.63
#> 4 PM-M01     A02   A         ctrl      ctrl      2h    10.4    10.4     10.5 
#> 5 PM-M01     A02   A         ctrl      inhibitor 2h    11.1    11.0     11.2 
#> 6 PM-M01     A02   A         treatment ctrl      2h    10.8    10.7     10.8 
#> # ℹ 3 more variables: n_points <int>, Chemical <chr>, control <chr>

The AUC column contains the area under the curve. AUC_low and AUC_high are the 95% confidence intervals (from bootstrapping).

Export for external analysis:

# write.csv(processed_data$auc_results,file="A:\results")

Step 9: Check Control Well Variability

To get an idea about the variance of control wells (and wells containing Tween) of the same biological condition between different 96-well plates (e.g. Control wells on PM-M01 and PM-M02) we can employ the function plot_controls_tween_all_plate(). In case of anti-cancer-drug plates only the control wells are displayed. If you used custom annotation and labelled your control wells as either “negative” or “positive” in the ‘control’ column of your custom annotation file they will be displayed here.

plot_controls_tween_all_plate(processed_data)
#> `height` was translated to `width`.

This returns a plot with:

  • x-axis: AUC per Well
  • y-axis: wells containing negative or positive controls
  • x-axis panels: experimental conditions
  • y-axis panels: experimental conditions
  • symbol of points: type of control Well
  • color of points: Plate Type
  • dashed vertical lines: range between minimum and maximum OD of Negative Control or No Chemical over all measured wells

In the upper plot in this case we can spot that the variance between control wells of different plates is pretty low. We can also get an idea about the variance in the positive control of Wells containing Glucose (Glucose = red squares). In the upper case the variance is low in the lower-left plot and high in the upper right plot.

Look for high variability, e.g., if 0Gy_treatment_inhibitor shows large spread, you may need to re-evaluate that condition.

If we want to get a first glimpse on how the AUCs of different biological conditions compare on the same plate we can use the plot_control_tween_per_plate() function.

plot_controls_tween_per_plate(processed_data,
  selected_plate = "PM-M01"
) # which Plate Type to compare
#> `height` was translated to `width`.

  • x-axis: AUC per Well
  • y-axis: wells containing negative or positive controls
  • x-axis and y-axis panels: experimental conditions
  • symbol of points: type of control Well
  • dashed vertical lines: range between minimum and maximum OD of Negative Control or No Chemical

In the above plot we can e.g. see that the variance of control wells is comparably small in all biological conditions, but that the AUCs in Wells containing Glucose differ between conditions.

Step 10: Compare AUCs Per Plate

After we inspected the variance of the control Wells we can proceed with visualizing all Wells on one plate type in all biological conditions:

plot_auc(processed_data,
  selected_plate = "PM-M02", # Plate Type to display
  facet_vars = c("treatment") # which two biological conditions should be compared, first variable goes on y-axis panel, second on x-axis panel
)
#> [[1]]

#> 
#> [[2]]

In the resulting plots the metabolites are ordered by their mean AUC across all conditions which results in ordering from highest to lowest along the y-axis while still preserving the ability to easily compare metabolites between conditions.

The dashed lines show the range between minimum and maximum OD of the ‘negative’ control wells to preserve an indicator for data variability. In the second plot on the left (PM-M02, ctrl) we can e.g. observe that the AUC of most wells lies within the range of AUC wells observed in control wells.

Step 11: Normalize AUC to AUC ratios and visualize

The above visualization does not scale for comparison between multiple experimental conditions. One way to facilitate comparison of more conditions it to normalize the AUC to the mean of the control wells of the respective 96-well plate. The caveat is that we loose the information about variability of control wells, so decide after data inspection if you should proceed. We can do this with the function compute_auc_ratio(). The results are stored in element auc_ratio results of processed_data

processed_data <- compute_auc_ratio(processed_data)
head(processed_data$auc_ratio)
#> # A tibble: 6 × 17
#>   Plate.Type Well  cell.line treatment inhibitor time    AUC AUC_low AUC_high
#>   <chr>      <chr> <chr>     <chr>     <chr>     <chr> <dbl>   <dbl>    <dbl>
#> 1 PM-M01     A01   A         ctrl      ctrl      2h    11.5    11.4     11.5 
#> 2 PM-M01     A01   A         ctrl      inhibitor 2h    11.0    10.9     11.0 
#> 3 PM-M01     A01   A         treatment ctrl      2h     9.61    9.58     9.63
#> 4 PM-M01     A02   A         ctrl      ctrl      2h    10.4    10.4     10.5 
#> 5 PM-M01     A02   A         ctrl      inhibitor 2h    11.1    11.0     11.2 
#> 6 PM-M01     A02   A         treatment ctrl      2h    10.8    10.7     10.8 
#> # ℹ 8 more variables: n_points <int>, Chemical <chr>, control <chr>,
#> #   mean_ctrl_AUC <dbl>, sd_ctrl_AUC <dbl>, mean_ctrl_AUC_low <dbl>,
#> #   mean_ctrl_AUC_high <dbl>, AUC_ratio <dbl>

The AUC ratios can of course be exported for to a csv file with:

# write.csv(processed_data$auc_ratio)

We can visualize the AUC ratios as a heatmap with plot_auc_ratio_heatmap(). Depending on which biological conditions you want to compare you can set the x_var argument to the variable that should be displayed on the x-axis.

plot_auc_ratio_heatmap(processed_data,
  plate_type = "PM-M01", # which plate type to visualize
  x_var = "treatment"  # which variable should go on x-axis, all other go in x-axis panels
) 

Missing values due to filtering or dropping of OD curve types will be displayed in grey.

In some cases, e.g. a very strong signal from Glucose making distinction of other Wells impossible, it might be helpful to display the AUC ratio on the log-scale. Please note that this distorts your perception of results with higher visual emphasis on small AUC ratios compared to natural scale.

plot_auc_ratio_heatmap(processed_data,
  plate_type = "PM-M01",
  x_var = "treatment",
  scale_type = "log10" # switch from natural to log10 scale
)

Step 12: Annotation of metadata

If we do not only want to visualize per plate type but leverage the fact that metabolite or drugs on different plates might belong to one biological meaningfull group we can annotate metadata. In case of the metabolic plates PM-M01 and PM-M02 MetPhenoR contains the annotation of metabolites to different hierarchies (module_name = pathway plus different hierarchical orders of pathways) of the KEGG database.

head(PM_M01_M02)
#> # A tibble: 6 × 11
#>   Plate.Type Well  Chemical        `Kegg Link` Info  KEGG  module_id module_name
#>   <chr>      <chr> <chr>           <chr>       <chr> <chr> <chr>     <chr>      
#> 1 PM-M01     A01   Negative Contr… <NA>        C-So… <NA>  <NA>      <NA>       
#> 2 PM-M01     A02   Negative Contr… <NA>        C-So… <NA>  <NA>      <NA>       
#> 3 PM-M01     A03   Negative Contr… <NA>        C-So… <NA>  <NA>      <NA>       
#> 4 PM-M01     A04   a-Cyclodextrin  https://ww… C-So… C009… <NA>      <NA>       
#> 5 PM-M01     A05   Dextrin         https://ww… C-So… C007… <NA>      <NA>       
#> 6 PM-M01     A06   Glycogen        https://ww… C-So… C001… <NA>      <NA>       
#> # ℹ 3 more variables: upper_hierarchy <chr>, middle_hierarchy <chr>,
#> #   lower_hierarchy <chr>

In case of plates PM-M11 to PM-14 it contains various information from ‘Affected Pathway’ to ‘Clinical Status’.

We can annotate the metadata with:

processed_data <- annotate_metadata(processed_data, annotation = PM_M01_M02)

This adds additional columns to the element auc_ratio of processed_data, which can be exported as previously shown. In case of custom annotation you just need a data frame with one column named Chemical and as many columns containing information on the Chemicals as needed.

Step 13: Visualiation of AUC Ratios by group

We can now group our heat map by any column of the metadata that we find suitable. In case of many distinct entries in our chosen column it might be helpful to only display certain entries in one specific plot. We can again choose the x-axis variable and whether we want to display the AUC ratio on the natural or log10 scale.

plot_auc_ratio_by_group(processed_data,
  group_var = "middle_hierarchy", # column in metadata
  x_var = "treatment",
  group_filter = c(
    "Nucleotide metabolism", #  which entries in 'group_var' to display
    "Amino acid metabolism"
  )
)

Another summary visualization are violin plots implemented in function plot_auc_ratio_violin() which makes comparisons of AUC ratios of many groups at the same time possible. In this plot we loose the information which metabolite or drug belongs to which point in the violin, but for detailed inspection the heat maps are available.

plot_auc_ratio_violin(processed_data,
  group_var = "middle_hierarchy",
  color_var = "treatment"
) # which variable should be displayed on color scale
#> Warning: Groups with fewer than two datapoints have been dropped.
#> ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
#> Groups with fewer than two datapoints have been dropped.
#> ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
#> Warning in max(data$density, na.rm = TRUE): no non-missing arguments to max;
#> returning -Inf
#> Warning in max(data[["n"]]): no non-missing arguments to max; returning -Inf
#> Warning: Computation failed in `stat_ydensity()`.
#> Caused by error in `$<-.data.frame`:
#> ! replacement has 1 row, data has 0
#> Warning: Groups with fewer than two datapoints have been dropped.
#> ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
#> Warning in max(data$density, na.rm = TRUE): no non-missing arguments to max;
#> returning -Inf
#> Warning in max(data[["n"]]): no non-missing arguments to max; returning -Inf
#> Warning: Computation failed in `stat_ydensity()`.
#> Caused by error in `$<-.data.frame`:
#> ! replacement has 1 row, data has 0
#> Warning in min(x): no non-missing arguments to min; returning Inf
#> Warning in max(x): no non-missing arguments to max; returning -Inf
#> Warning in min(x): no non-missing arguments to min; returning Inf
#> Warning in max(x): no non-missing arguments to max; returning -Inf

We now have the AUC ratio on the x-axis and every point represents one metabolite/drug associated with the group on the y-axis. We can choose the variable that will be color coded and all other experimental variables are displayed in the x-axis facets. We can also again switch from natural to log-scale.

plot_auc_ratio_violin(processed_data,
  group_var = "middle_hierarchy",
  color_var = "inhibitor", # switch color variable
  scale_type = "log10" # log 10
)
#> Warning: Groups with fewer than two datapoints have been dropped.
#> ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
#> Groups with fewer than two datapoints have been dropped.
#> ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
#> Warning in max(data$density, na.rm = TRUE): no non-missing arguments to max;
#> returning -Inf
#> Warning in max(data[["n"]]): no non-missing arguments to max; returning -Inf
#> Warning: Computation failed in `stat_ydensity()`.
#> Caused by error in `$<-.data.frame`:
#> ! replacement has 1 row, data has 0
#> Warning: Groups with fewer than two datapoints have been dropped.
#> ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
#> Warning in max(data$density, na.rm = TRUE): no non-missing arguments to max;
#> returning -Inf
#> Warning in max(data[["n"]]): no non-missing arguments to max; returning -Inf
#> Warning: Computation failed in `stat_ydensity()`.
#> Caused by error in `$<-.data.frame`:
#> ! replacement has 1 row, data has 0
#> Warning in min(x): no non-missing arguments to min; returning Inf
#> Warning in max(x): no non-missing arguments to max; returning -Inf
#> Warning in min(x): no non-missing arguments to min; returning Inf
#> Warning in max(x): no non-missing arguments to max; returning -Inf

sessionInfo()
#> R version 4.6.2 (2026-10-29)
#> 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] MetPhenoR_1.3  rmarkdown_2.32
#> 
#> loaded via a namespace (and not attached):
#>  [1] sass_0.4.10        utf8_1.2.6         generics_0.1.4     tidyr_1.3.2       
#>  [5] stringi_1.8.9      hms_1.1.4          digest_0.6.39      magrittr_2.0.5    
#>  [9] evaluate_1.0.5     grid_4.6.2         RColorBrewer_1.1-3 fastmap_1.2.0     
#> [13] jsonlite_2.0.0     purrr_1.2.2        viridisLite_0.4.3  scales_1.4.0      
#> [17] tweenr_2.0.3       jquerylib_0.1.4    cli_3.6.6          rlang_1.3.0       
#> [21] polyclip_1.10-7    splines_4.6.2      withr_3.0.3        cachem_1.1.0      
#> [25] yaml_2.3.12        otel_0.3.0         tools_4.6.2        tzdb_0.5.0        
#> [29] dplyr_1.2.1        ggplot2_4.0.3      buildtools_1.0.0   vctrs_0.7.3       
#> [33] R6_2.6.1           lifecycle_1.0.5    stringr_1.6.0      MASS_7.3-66       
#> [37] pkgconfig_2.0.3    pillar_1.11.1      bslib_0.12.0       gtable_0.3.6      
#> [41] glue_1.8.1         ggforce_0.5.0      xfun_0.62          tibble_3.3.1      
#> [45] tidyselect_1.2.1   sys_3.4.3          knitr_1.52         farver_2.1.2      
#> [49] patchwork_1.3.2    htmltools_0.5.9    labeling_0.4.3     maketools_1.3.2   
#> [53] readr_2.2.0        compiler_4.6.2     S7_0.2.2