Skip to contents

This vignette illustrates how the two high-level functions in the tread package can be used in analysis workflows. While the thematic focus of this vignette is microbiology, there is no reason why the same principles could not be applied to another field that makes use of plate readers.

Reading and Plotting of OD Values

First we will import the optical density measurements from the .xlsx file using tparse() The file we are using contains OD measurements of a 96-well plate at a single timepoint and multiple measurements per well.

# get path to Excel file
file_OD <- system.file(
  "extdata",
  "single_time_multiple_reads.xlsx",
  package = "tread"
)

# read data from file
dat_OD <- file_OD |> tparse()
#>  Multiple reads per well detected

The table we get back from tparse() contains the four OD measurements for each well in addition to their mean and standard deviation. It’s important to note that while this data set contains measurements of all wells of a 96-well plate, tread’s functions can import data for any number of measured wells.

dat_OD
#> # A tibble: 96 × 7
#>    well    mean  st_dev   x1_2   x0_1   x1_0   x2_1
#>    <chr>  <dbl>   <dbl>  <dbl>  <dbl>  <dbl>  <dbl>
#>  1 A1    0.104  0.0243  0.0960 0.0896 0.0891 0.140 
#>  2 A2    0.0951 0.00730 0.0913 0.0913 0.0918 0.106 
#>  3 A3    0.105  0.0279  0.0944 0.0885 0.0893 0.146 
#>  4 A4    0.102  0.0215  0.0933 0.0899 0.0907 0.134 
#>  5 A5    0.0950 0.0122  0.0887 0.0887 0.0891 0.113 
#>  6 A6    0.0933 0.00280 0.0944 0.0928 0.0897 0.0964
#>  7 A7    0.0968 0.00940 0.0932 0.0932 0.0900 0.111 
#>  8 A8    0.0941 0.00560 0.0914 0.0901 0.0925 0.102 
#>  9 A9    0.0945 0.00910 0.0898 0.0897 0.0903 0.108 
#> 10 A10   0.0935 0.00680 0.0901 0.0905 0.0897 0.104 
#> # ℹ 86 more rows

We can now plot this data, e.g. using ggplate::plate_plot().

library(ggplate)
#> 📊 Welcome to ggplate version 0.1.3! 📈
#>                             
#> 🖍 Have fun plotting your data! 💻

dat_OD |> plate_plot(
  position = well,
  value = mean,
  plate_size = 96,
  plate_type = "round"
)

We can also add additional information to the table, e.g. which wells contain particular becterial strains liquid medium only for blanking. We recommend to use spreadsheet software such as Excel to create a similar table for you own needs and then import it into R instead of typing our the columns as is shown here.

# we create a table that describes the content of each well
annotation_OD <- tibble::tibble(
  well = dat_OD$well,
  content = c(
    "buffer", "buffer", "buffer", "buffer", "buffer", "buffer", "buffer",
    "buffer", "buffer", "buffer", "buffer", "buffer", "A1", "buffer", "A3",
    "buffer", "D1", "buffer", "D3", "buffer", "G1", "buffer", "G3",
    "buffer", "buffer", "A2", "buffer", "A4", "buffer", "D2", "buffer",
    "D4", "buffer", "G2", "buffer", "blank", "B1", "buffer", "B3",
    "buffer", "E1", "buffer", "E3", "buffer", "H1", "buffer", "H3",
    "buffer", "buffer", "B2", "buffer", "B4", "buffer", "E2", "buffer",
    "E4", "buffer", "H2", "buffer", "blank", "C1", "buffer", "C3",
    "buffer", "F1", "buffer", "F3", "buffer", "J1", "buffer", "J3",
    "buffer", "buffer", "C2", "buffer", "C4", "buffer", "F2", "buffer",
    "F4", "buffer", "J2", "buffer", "blank", "buffer", "buffer",
    "buffer", "buffer", "buffer", "buffer", "buffer", "buffer", "buffer",
    "buffer", "buffer", "buffer"
  )
)

# we join the annotation table to the OD table
dat_OD <- dat_OD |>
  dplyr::left_join(annotation_OD, by = dplyr::join_by("well")) |>
  dplyr::relocate("content", .after = "well")

dat_OD
#> # A tibble: 96 × 8
#>    well  content   mean  st_dev   x1_2   x0_1   x1_0   x2_1
#>    <chr> <chr>    <dbl>   <dbl>  <dbl>  <dbl>  <dbl>  <dbl>
#>  1 A1    buffer  0.104  0.0243  0.0960 0.0896 0.0891 0.140 
#>  2 A2    buffer  0.0951 0.00730 0.0913 0.0913 0.0918 0.106 
#>  3 A3    buffer  0.105  0.0279  0.0944 0.0885 0.0893 0.146 
#>  4 A4    buffer  0.102  0.0215  0.0933 0.0899 0.0907 0.134 
#>  5 A5    buffer  0.0950 0.0122  0.0887 0.0887 0.0891 0.113 
#>  6 A6    buffer  0.0933 0.00280 0.0944 0.0928 0.0897 0.0964
#>  7 A7    buffer  0.0968 0.00940 0.0932 0.0932 0.0900 0.111 
#>  8 A8    buffer  0.0941 0.00560 0.0914 0.0901 0.0925 0.102 
#>  9 A9    buffer  0.0945 0.00910 0.0898 0.0897 0.0903 0.108 
#> 10 A10   buffer  0.0935 0.00680 0.0901 0.0905 0.0897 0.104 
#> # ℹ 86 more rows

Having medium-only wells allows us to blank the measurements.

# first we calculate the blanking value from the average of medium-only wells
blank <- dat_OD |>
  dplyr::filter(content == "blank") |>
  dplyr::pull(mean) |>
  mean()

# then we substract the blanking value from all OD values
# (mean and all four reading from each well)
dat_OD <- dat_OD |>
  dplyr::mutate(
    dplyr::across(
      c("mean", tidyselect::starts_with("x")),
      ~ .x - blank
    )
  )

dat_OD
#> # A tibble: 96 × 8
#>    well  content     mean  st_dev    x1_2    x0_1    x1_0      x2_1
#>    <chr> <chr>      <dbl>   <dbl>   <dbl>   <dbl>   <dbl>     <dbl>
#>  1 A1    buffer  -0.00387 0.0243  -0.0115 -0.0179 -0.0184  0.0322  
#>  2 A2    buffer  -0.0124  0.00730 -0.0162 -0.0162 -0.0157 -0.00137 
#>  3 A3    buffer  -0.00287 0.0279  -0.0131 -0.0190 -0.0182  0.0389  
#>  4 A4    buffer  -0.00547 0.0215  -0.0142 -0.0176 -0.0168  0.0268  
#>  5 A5    buffer  -0.0125  0.0122  -0.0188 -0.0188 -0.0184  0.00573 
#>  6 A6    buffer  -0.0142  0.00280 -0.0131 -0.0147 -0.0178 -0.0111  
#>  7 A7    buffer  -0.0107  0.00940 -0.0143 -0.0143 -0.0175  0.00323 
#>  8 A8    buffer  -0.0134  0.00560 -0.0161 -0.0174 -0.0150 -0.00507 
#>  9 A9    buffer  -0.0130  0.00910 -0.0177 -0.0178 -0.0172  0.000733
#> 10 A10   buffer  -0.0140  0.00680 -0.0174 -0.0170 -0.0178 -0.00377 
#> # ℹ 86 more rows

Finally, we can filter out any wells that do not contain bacteria and plot their blanked OD values.

library(ggplot2)
library(ggbeeswarm)

# filter out wells that do not contain bacteria and separate the population name
# into the strain name and the replicate number
dat_OD <- dat_OD |>
  dplyr::filter(content != "buffer" & content != "blank") |>
  tidyr::separate("content", into = c("strain", "replicate"), sep = 1)

dat_OD
#> # A tibble: 33 × 9
#>    well  strain replicate  mean  st_dev  x1_2  x0_1  x1_0  x2_1
#>    <chr> <chr>  <chr>     <dbl>   <dbl> <dbl> <dbl> <dbl> <dbl>
#>  1 B1    A      1         0.168 0.0245  0.169 0.150 0.151 0.202
#>  2 B3    A      3         0.169 0.0215  0.164 0.155 0.156 0.201
#>  3 B5    D      1         0.123 0.0124  0.122 0.110 0.119 0.139
#>  4 B7    D      3         0.128 0.0134  0.146 0.114 0.124 0.130
#>  5 B9    G      1         0.128 0.0141  0.116 0.121 0.128 0.148
#>  6 B11   G      3         0.124 0.0126  0.122 0.118 0.114 0.142
#>  7 C2    A      2         0.185 0.0211  0.187 0.169 0.170 0.214
#>  8 C4    A      4         0.171 0.0232  0.167 0.153 0.159 0.205
#>  9 C6    D      2         0.125 0.0165  0.118 0.110 0.123 0.148
#> 10 C8    D      4         0.119 0.00620 0.116 0.111 0.124 0.124
#> # ℹ 23 more rows

# we plot the blanked OD values grouped by the strain name
dat_OD |> ggplot(aes(x = strain, mean, fill = replicate)) +
  geom_hline(yintercept = 0, linetype = 2, alpha = 0.5) +
  geom_beeswarm(cex = 2.5, size = 3, color = "black", shape = 21) +
  scale_fill_viridis_d() +
  labs(
    x = "Strain",
    y = bquote("Bacterial Density ("*OD[600]*")"),
    fill = "Replicate"
  ) +
  theme_light(15)

Joining Multiple Pieces and Plotting of a Growth Curve

When running longer kinetic measurements (e.g. growth curves), plate readers cannot be used by other members of the lab. While the kinetic measurement programs can be stopped and restarted, the plate reader software i-control™ will always place the data from these restarts in a separate Excel sheet. Merging these segments of a kinetic measurements is laborious when done manually.

Using tunite() saves a considerable amount of time. The function does this by:

  1. Importing data from each single sheet inside of an Excel file
  2. Getting the exact start time and duration of each segment
  3. Calculating the time offsets necessary to merge the segments into a continuous time series

Because tparse() is used for step (1), tunite() will print diagnostic messages for each of the sheets it imports. You can suppress these using suppressMessages().

# get path to Excel file
file_gc <- system.file(
  "extdata",
  "time_series_segments.xlsx",
  package = "tread"
)

# read data from file
dat_gc <- file_gc |> tunite()
#>  Time series detected
#>  Multiple reads per well detected
#>  Time series detected
#>  Multiple reads per well detected

# repeat with message suppression
dat_gc <- file_gc |> tunite() |> suppressMessages()

The table that tunite() returns contains a time series of OD measurements for a number of wells.

dat_gc
#> # A tibble: 3,432 × 9
#>    well   time  temp  mean st_dev  x1_2  x0_1  x1_0  x2_1
#>    <chr> <dbl> <dbl> <dbl>  <dbl> <dbl> <dbl> <dbl> <dbl>
#>  1 B1        0  30.6 0.131 0.0254 0.131 0.111 0.114 0.166
#>  2 B1      900  30.4 0.129 0.0216 0.132 0.112 0.115 0.159
#>  3 B1     1500  30.5 0.133 0.0226 0.146 0.113 0.114 0.157
#>  4 B1     2100  30.7 0.132 0.0226 0.143 0.113 0.115 0.160
#>  5 B1     2700  30.6 0.133 0.0220 0.146 0.114 0.116 0.158
#>  6 B1     3300  30.2 0.133 0.0208 0.144 0.114 0.116 0.156
#>  7 B1     3900  30.6 0.135 0.0227 0.147 0.115 0.117 0.161
#>  8 B1     4500  30.6 0.134 0.0210 0.146 0.116 0.118 0.158
#>  9 B1     5100  30.5 0.135 0.0208 0.147 0.117 0.119 0.159
#> 10 B1     5700  30.5 0.136 0.0201 0.147 0.118 0.120 0.158
#> # ℹ 3,422 more rows

Again we will add additional information to the table. In this case it is the name of the bacterial population as well as a number to signify the replicate growth curve.

# we create a table that contains information which population is inside which
# well and also gives the replicate wells a replicate number
annotation_gc <- tibble::tibble(
  well = c(
    "B1", "B2", "B3", "B4", "C4", "C5", "C6", "C8", "D9", "D10", "D11", "D12",
    "E12", "F1", "F2", "F4", "G5","G6", "G7", "G8", "H8", "H9", "H10", "H12"
  ),
  population = c(
    "D1", "D2", "D3", "D4", "H1", "H2", "H3", "blank", "D1", "D2", "D3", "D4",
    "H1", "H2", "H3", "blank", "D1", "D2", "D3", "D4", "H1", "H2", "H3", "blank"
  ),
  gc_rep = c(
    1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3
  )
)

# we join the annotation table to the OD table
dat_gc <- dat_gc |>
  dplyr::left_join(annotation_gc, by = dplyr::join_by("well")) |>
  dplyr::relocate(c("population", "gc_rep"), .after = "well")

dat_gc
#> # A tibble: 3,432 × 11
#>    well  population gc_rep  time  temp  mean st_dev  x1_2  x0_1  x1_0  x2_1
#>    <chr> <chr>       <dbl> <dbl> <dbl> <dbl>  <dbl> <dbl> <dbl> <dbl> <dbl>
#>  1 B1    D1              1     0  30.6 0.131 0.0254 0.131 0.111 0.114 0.166
#>  2 B1    D1              1   900  30.4 0.129 0.0216 0.132 0.112 0.115 0.159
#>  3 B1    D1              1  1500  30.5 0.133 0.0226 0.146 0.113 0.114 0.157
#>  4 B1    D1              1  2100  30.7 0.132 0.0226 0.143 0.113 0.115 0.160
#>  5 B1    D1              1  2700  30.6 0.133 0.0220 0.146 0.114 0.116 0.158
#>  6 B1    D1              1  3300  30.2 0.133 0.0208 0.144 0.114 0.116 0.156
#>  7 B1    D1              1  3900  30.6 0.135 0.0227 0.147 0.115 0.117 0.161
#>  8 B1    D1              1  4500  30.6 0.134 0.0210 0.146 0.116 0.118 0.158
#>  9 B1    D1              1  5100  30.5 0.135 0.0208 0.147 0.117 0.119 0.159
#> 10 B1    D1              1  5700  30.5 0.136 0.0201 0.147 0.118 0.120 0.158
#> # ℹ 3,422 more rows

We will now use the medium-only wells to blank all OD measurements.

# first we calculate the blanking value from the average of medium-only wells
blank <- dat_gc |>
  dplyr::filter(population == "blank") |>
  dplyr::pull(mean) |>
  mean()

# then we substract the blanking value from all OD values
# (mean and all four reading from each well)
dat_gc <- dat_gc |>
  dplyr::mutate(
    dplyr::across(
      c("mean", tidyselect::starts_with("x")),
      ~ .x - blank
    )
  )

dat_gc
#> # A tibble: 3,432 × 11
#>    well  population gc_rep  time  temp    mean st_dev    x1_2     x0_1     x1_0
#>    <chr> <chr>       <dbl> <dbl> <dbl>   <dbl>  <dbl>   <dbl>    <dbl>    <dbl>
#>  1 B1    D1              1     0  30.6 0.00893 0.0254 0.00933 -0.0111  -0.00727
#>  2 B1    D1              1   900  30.4 0.00743 0.0216 0.00983 -0.0102  -0.00697
#>  3 B1    D1              1  1500  30.5 0.0109  0.0226 0.0241  -0.00907 -0.00737
#>  4 B1    D1              1  2100  30.7 0.0107  0.0226 0.0209  -0.00877 -0.00687
#>  5 B1    D1              1  2700  30.6 0.0115  0.0220 0.0239  -0.00787 -0.00607
#>  6 B1    D1              1  3300  30.2 0.0111  0.0208 0.0225  -0.00737 -0.00537
#>  7 B1    D1              1  3900  30.6 0.0131  0.0227 0.0250  -0.00697 -0.00477
#>  8 B1    D1              1  4500  30.6 0.0128  0.0210 0.0243  -0.00577 -0.00387
#>  9 B1    D1              1  5100  30.5 0.0135  0.0208 0.0248  -0.00467 -0.00307
#> 10 B1    D1              1  5700  30.5 0.0139  0.0201 0.0258  -0.00397 -0.00207
#> # ℹ 3,422 more rows
#> # ℹ 1 more variable: x2_1 <dbl>

Now we are ready to plot the growth curves.

library(ggplot2)

# filter out blank wells
dat_gc <- dat_gc |> dplyr::filter(population != "blank")

# plot OD values over time
dat_gc |> ggplot(aes(time / 3600, mean, group = well)) +
  facet_wrap(vars(population), nrow = 2, scales = "free_x") +
  geom_line(linewidth = 0.5) +
  scale_x_continuous(limits = c(0, 24), breaks = seq(0, 24, 4)) +
  labs(
    x = "Time [h]",
    y = "OD600"
  ) +
  theme_light(15)