Analysis of E. coli growth data¶

(c) 2024 Justin Bois. This work is licensed under a Creative Commons Attribution License CC-BY 4.0. All code contained herein is licensed under an MIT license.

This document was prepared at Caltech with support financial support from the Donna and Benjamin M. Rosen Bioengineering Center.

No description has been provided for this image

This tutorial was generated from an Jupyter notebook. You can download the notebook here.


In [1]:
import io

import polars as pl
import polars.selectors as cs
import numpy as np
import scipy.optimize

import colorcet
import iqplot

import bokeh.io
import bokeh.plotting
bokeh.io.output_notebook()
Loading BokehJS ...

In this tutorial, we walk through analysis of growth data as acquired in our bulk assay using the plate reader. The analysis presents three main challenges that we have not covered in previous tutorials.

  1. We need to wrangle the data exported from the plate reader in a text file to enable easy access and analysis.
  2. We need to make plots, some on semilogarithmic scale, to visualize the results.
  3. We need to consider portions of the data set in performing regressions, so we need to select and use only parts of the data set.

Data wrangling¶

Our first step is taking the plate reader data and converting it to an easy format to interpret. As a sample data set, we will use data obtained by the TAs in one of their experiments while developing this module. The data set is in the file 20240422_ecoli_growth_trial_1.txt, which is available here. The first 57 lines of this file are shown below.



Software Version	3.10.06



Experiment File Path:	C:\Users\User\Desktop\20240506_ecoli_full_plate_test.xpt
Protocol File Path:	C:\Users\Public\Documents\Protocols\od600.prt



Plate Number	Plate 1
Date	5/6/2024
Time	8:56:32 AM
Reader Type:	Synergy H1
Reader Serial Number:	2107069
Reading Type	Reader

Procedure Details

Plate Type	96 WELL PLATE (Use plate lid)
Eject plate on completion	
Set Temperature	Setpoint 37�C
Read	Absorbance Endpoint
    Full Plate
    Wavelengths:  600
    Read Speed: Normal,  Delay: 100 msec,  Measurements/Data Point: 8
Start Kinetic	Runtime 24:00:00 (HH:MM:SS), Interval 0:06:00, 241 Reads
    Shake	Linear: Continuous
    Frequency: 1096 cpm (1 mm)
    Read	Absorbance Endpoint
    Full Plate
    Wavelengths:  600
    Read Speed: Normal,  Delay: 100 msec,  Measurements/Data Point: 8
End Kinetic	



Actual Temperature:	37.0

Results
    1	2	3	4	5	6	7	8	9	10	11	12
A	0.091	0.091	0.094	0.098	0.095	0.096	0.086	0.087	0.088	0.099	0.095	0.094	Read 1:600
B	0.103	0.106	0.118	0.087	0.092	0.101	0.095	0.101	0.090	0.097	0.094	0.090	Read 1:600
C	0.093	0.089	0.091	0.088	0.092	0.089	0.087	0.089	0.086	0.089	0.092	0.092	Read 1:600
D	0.096	0.098	0.094	0.089	0.101	0.103	0.093	0.101	0.093	0.096	0.095	0.092	Read 1:600
E	0.089	0.088	0.089	0.085	0.087	0.085	0.095	0.093	0.088	0.094	0.086	0.089	Read 1:600
F	0.098	0.092	0.094	0.092	0.094	0.091	0.100	0.103	0.094	0.097	0.087	0.089	Read 1:600
G	0.085	0.086	0.089	0.092	0.088	0.100	0.089	0.098	0.092	0.099	0.086	0.089	Read 1:600
H	0.087	0.094	0.096	0.088	0.089	0.090	0.087	0.106	0.083	0.099	0.080	0.097	Read 1:600

Read 2:600

Time	T� Read 2:600	A1	A2	A3	A4	A5	A6	A7	A8	A9	A10	A11	A12	B1	B2	B3	B4	B5	B6	B7	B8	B9	B10	B11	B12	C1	C2	C3	C4	C5	C6	C7	C8	C9	C10	C11	C12	D1	D2	D3	D4	D5	D6	D7	D8	D9	D10	D11	D12	E1	E2	E3	E4	E5	E6	E7	E8	E9	E10	E11	E12	F1	F2	F3	F4	F5	F6	F7	F8	F9	F10	F11	F12	G1	G2	G3	G4	G5	G6	G7	G8	G9	G10	G11	G12	H1	H2	H3	H4	H5	H6	H7	H8	H9	H10	H11	H12
0:05:10	37.0	0.090	0.091	0.094	0.105	0.102	0.104	0.088	0.088	0.088	0.097	0.097	0.093	0.112	0.108	0.109	0.092	0.110	0.107	0.104	0.110	0.097	0.095	0.096	0.090	0.093	0.092	0.093	0.093	0.091	0.092	0.090	0.095	0.089	0.095	0.096	0.092	0.102	0.098	0.097	0.097	0.103	0.103	0.100	0.105	0.097	0.098	0.102	0.094	0.094	0.090	0.092	0.092	0.091	0.095	0.094	0.098	0.093	0.094	0.093	0.097	0.098	0.098	0.097	0.097	0.095	0.096	0.093	0.105	0.094	0.097	0.094	0.096	0.094	0.105	0.098	0.096	0.094	0.097	0.095	0.105	0.094	0.102	0.096	0.091	0.096	0.094	0.093	0.092	0.093	0.094	0.089	0.109	0.083	0.098	0.086	0.100
0:11:10	37.0	0.090	0.090	0.094	0.105	0.102	0.104	0.086	0.088	0.088	0.096	0.096	0.093	0.110	0.107	0.109	0.092	0.110	0.106	0.104	0.109	0.097	0.095	0.095	0.090	0.093	0.091	0.092	0.092	0.091	0.091	0.090	0.095	0.089	0.095	0.096	0.093	0.101	0.097	0.096	0.096	0.103	0.102	0.100	0.102	0.096	0.097	0.101	0.094	0.094	0.090	0.091	0.092	0.091	0.095	0.093	0.097	0.092	0.093	0.093	0.095	0.096	0.096	0.096	0.096	0.095	0.095	0.093	0.104	0.094	0.097	0.093	0.095	0.094	0.103	0.098	0.096	0.094	0.097	0.095	0.104	0.094	0.101	0.095	0.091	0.100	0.093	0.093	0.092	0.093	0.093	0.090	0.109	0.084	0.097	0.085	0.099
0:17:10	37.0	0.090	0.090	0.094	0.105	0.103	0.104	0.086	0.088	0.087	0.096	0.096	0.093	0.109	0.107	0.109	0.092	0.110	0.106	0.104	0.109	0.097	0.095	0.095	0.090	0.093	0.091	0.092	0.092	0.091	0.091	0.090	0.095	0.089	0.098	0.096	0.093	0.101	0.097	0.096	0.096	0.102	0.102	0.100	0.102	0.095	0.097	0.101	0.093	0.094	0.089	0.091	0.092	0.091	0.094	0.093	0.097	0.092	0.093	0.093	0.095	0.096	0.096	0.096	0.096	0.095	0.095	0.092	0.104	0.093	0.097	0.093	0.095	0.093	0.104	0.098	0.096	0.094	0.097	0.095	0.104	0.094	0.101	0.090	0.091	0.102	0.093	0.092	0.092	0.093	0.093	0.090	0.110	0.087	0.098	0.086	0.100

Evidently, the plate reader returns a text file with some useful, but unformatted metadata about the measurement, followed by the data. Following the metadata is the first set of absorbance measurements in a table. Following that is the text Read 2:600, which contains a tab-delimited table of absorbance measurements.

Pulling the first time point out is not important for our measurements (the plate reader has not come to temperature anyway), so we will neglect it. Therefore, to pull the data set out of the file, we need to load in the last table. The information we need to do this are the file name, the row number (with zero-based indexing) of the header for the table (containing the well labels), and the last row of the file containing data. We can get all of this information by opening the file in a text editor. For this particular data set, these parameters are defined below.

In [2]:
# Useful info from file from plate reader
fname = "20240514_ecoli_growth_1.txt"
header_row = 53
last_row = 294

We can now write a function to pull the data out and store them in a data frame. Importantly, we will add a column labeled 'time (s)' which is the amount of time, in seconds, since the first measurement we consider, and a column labeled 'time (hr)', which is the same time, but in units of hours.

In [3]:
def timestamp_to_seconds(t_stamp):
    """Convert H:M:S string to seconds. This is necessary
    to hand-build because the datetime utilities do not handle
    times over 24 hours."""
    h, m, s = t_stamp.split(':')
    return 3600 * int(h) + 60 * int(m) + float(s)


def read_plate_reader(fname, header_row, last_row):
    """Read in time series data from Bi 1x plate reader.

    Parameters
    ----------
    fname : str
        Path to file outputted from plate reader.
    header_row : int
        Number of row containing header column (zero-indexed)
    last_row : int
        Last row of the file containing the time series data (zero-indexed)

    Returns
    -------
    output : DataFrame
        Polars DataFrame containing time series data.
    """
    # Get relevant contents of file as string
    with open(fname, "r", encoding="ISO-8859-1") as f:
        lines = f.readlines()
    file_str = "".join(lines[header_row : last_row + 1])

    # How many total lines to read
    n_rows = last_row - header_row

    # Read in data frame
    df = pl.read_csv(
        io.StringIO(file_str),
        separator="\t",
        encoding="ISO-8859-1",
    )

    # Rename temperature column
    T_col = [col for col in df.columns if "T°" in col][0]
    df = df.rename({T_col: "temperature (deg C)"})

    # Parse the time column
    df = df.with_columns(
        pl.col("Time")
        .map_elements(timestamp_to_seconds, return_dtype=float)
        .alias("time (s)")
    ).with_columns(
        pl.col('time (s)') - pl.col('time (s)').min()
    )

    # Time in units of hours
    df = df.with_columns((pl.col('time (s)') / 3600).alias('time (hr)'))

    return df

Let's use this function to load in the data and take a look.

In [4]:
df = read_plate_reader(fname, header_row, last_row)

df
Out[4]:
shape: (241, 100)
Timetemperature (deg C)A1A2A3A4A5A6A7A8A9A10A11A12B1B2B3B4B5B6B7B8B9B10B11B12C1C2C3C4C5C6C7C8C9C10C11…F2F3F4F5F6F7F8F9F10F11F12G1G2G3G4G5G6G7G8G9G10G11G12H1H2H3H4H5H6H7H8H9H10H11H12time (s)time (hr)
strf64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64…f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64
"0:05:10"37.00.080.0830.0860.1180.1220.1210.0970.0890.0860.1130.1170.1170.1110.1160.0850.1150.1250.1330.1010.1130.1110.1020.1120.1110.1120.130.1080.1110.1070.1270.1140.1150.1240.1090.125…0.1070.1060.1150.1110.1160.1090.1130.1270.1150.110.1110.110.1070.1210.120.110.110.1110.1180.1150.1110.1070.0950.2020.2140.1340.1310.1080.110.1590.2390.2180.1890.1150.130.00.0
"0:11:10"37.10.0810.0830.0860.1190.1220.1220.0970.0890.0860.1120.1160.1170.1180.120.0860.1130.1220.1430.1010.1130.1170.1030.1120.110.1130.1220.110.1120.1080.1240.1160.1160.1260.1070.124…0.1060.1050.1140.1110.1180.1090.1120.1210.1150.110.1110.1080.1070.120.1240.1080.1060.110.1160.1230.110.1080.0980.2020.2110.1330.130.1090.1110.1610.2150.2310.1890.1140.119360.00.1
"0:17:10"37.00.0830.0830.0860.1210.1230.1230.0970.0890.0860.1130.1160.1150.1010.1180.0860.1140.1220.1210.1020.1140.1120.1040.1130.1110.1130.120.1140.1120.110.1250.120.1110.1240.1120.126…0.1050.1050.1140.1110.1180.1090.1130.120.1160.1110.1130.110.1070.1190.120.110.1060.1110.120.1270.1040.1150.1010.2020.2150.1330.130.1090.1120.1640.2150.2240.1870.1140.113720.00.2
"0:23:10"37.00.0830.0830.0860.1230.1260.1260.0970.0890.0860.1140.1180.1170.1020.1170.0850.1140.1230.1360.1020.1140.1120.1020.1150.1120.1160.1170.1120.1130.1110.1270.1160.1130.1260.1130.126…0.1060.1050.1160.1120.1190.110.1130.1260.1170.1130.1170.110.1070.1190.1230.1080.1060.1120.1310.1290.1030.1110.1040.2020.2090.1340.1330.1110.1130.1580.2140.2240.170.1140.1151080.00.3
"0:29:10"37.10.0810.0820.0860.1260.1290.1290.0970.0890.0860.1150.1190.1180.1020.1410.0850.1160.1250.1440.1030.1160.1130.1030.1150.1140.1150.1180.1130.1150.1130.1280.1170.1230.1280.110.128…0.1050.1060.1220.1130.1160.1110.1150.1270.1180.1130.1110.110.1070.1190.1240.110.1060.1130.1330.1320.1030.1120.1050.2030.2110.1350.130.1120.1140.160.2150.220.1670.1140.1151440.00.4
………………………………………………………………………………………………………………………………………………………………………………………………………
"23:41:10"37.11.6910.0830.0851.7711.7741.7770.0970.0880.0860.91.1211.1240.9220.9130.0851.2061.2361.0260.9911.1161.0481.0861.1321.1491.171.091.0881.0641.0591.0681.0661.0871.0811.0951.116…0.9190.9031.0251.0351.0260.9951.0241.040.990.9841.040.6790.6180.631.0320.9420.821.0821.1550.790.8871.091.0370.2930.2990.1470.4881.0561.0770.7630.9630.2970.2541.0651.10684960.023.6
"23:47:10"37.11.690.0820.0851.771.7731.7760.0970.0880.0860.91.1191.1220.920.9130.0851.2041.2281.0260.991.1141.0471.0861.1311.1471.1691.0891.0871.0621.0571.0671.0651.0871.081.0931.115…0.9180.9021.0241.0351.0250.9951.0231.0390.990.9831.040.6790.6180.6311.0320.9410.8251.0821.1550.790.8871.0891.0350.2930.2990.1450.4881.0551.0750.7620.9640.2820.2541.0641.10585320.023.7
"23:53:10"37.01.6880.0830.0851.771.7731.7750.0970.0880.0860.8991.1181.1210.920.9120.0851.2031.2281.0360.991.1141.0461.0871.131.1471.171.0891.0861.0611.0561.0661.0641.0851.0791.0921.115…0.9170.9021.0251.0351.0250.9951.0241.040.9890.9841.040.6780.6180.631.0320.9420.8191.0821.1550.790.8881.0931.0360.3240.30.1440.4851.0541.0760.7640.9630.30.2541.0641.10985680.023.8
"23:59:10"37.01.6870.0830.0851.7691.7721.7740.0970.0880.0860.8991.1191.1220.9190.9120.0851.2041.2331.0250.991.1141.0451.0871.131.1471.1681.0871.0851.061.0571.0651.0631.0841.0791.0921.114…0.9160.9011.0261.0341.0250.9951.0231.0390.9880.9831.0390.6780.6170.631.0330.9430.821.0821.1550.790.8891.0941.0360.2940.2990.1450.4871.0541.0760.7620.9630.2960.2541.0631.10486040.023.9
"24:05:10"37.01.6870.0830.0851.7681.7711.7730.0970.0890.0860.8981.1181.1210.9180.9120.0851.2031.2261.0240.991.1131.0451.0871.1291.1461.1681.0871.0841.061.0561.0641.0621.0841.0781.0911.114…0.9150.8991.0251.0341.0250.9941.0221.0380.9880.9821.0460.6760.6170.631.0320.9420.821.0831.1560.7890.8881.091.0380.2930.3010.1440.4851.0531.0740.7610.9640.30.2541.0621.10486400.024.0

We now conveniently have a data set with our results.

We are, however, missing some metadata. We want to know, e.g., what conditions were present in well C6, for example. We can create a dictionary for our samples.

In [5]:
well_metadata = dict(
    A1="LB blank",
    A2="LB blank",
    A3="LB blank",
    A4="LB",
    A5="LB",
    A6="LB",
    A7="M9 blank",
    A8="M9 blank",
    A9="M9 blank",
    A10="glucose",
    A11="glucose",
    A12="glucose",
    B1="xylose",
    B2="xylose",
    B3="xylose",
    B4="xylose:glucose 4:1",
    B5="xylose:glucose 4:1",
    B6="xylose:glucose 4:1",
    B7="xylose:glucose 2:1",
    B8="xylose:glucose 2:1",
    B9="xylose:glucose 2:1",
    B10="xylose:glucose 1:1",
    B11="xylose:glucose 1:1",
    B12="xylose:glucose 1:1",
    C1="sorbitol",
    C2="sorbitol",
    C3="sorbitol",
    C4="sorbitol:glucose 4:1",
    C5="sorbitol:glucose 4:1",
    C6="sorbitol:glucose 4:1",
    C7="sorbitol:glucose 2:1",
    C8="sorbitol:glucose 2:1",
    C9="sorbitol:glucose 2:1",
    C10="sorbitol:glucose 1:1",
    C11="sorbitol:glucose 1:1",
    C12="sorbitol:glucose 1:1",
    D1="galactose",
    D2="galactose",
    D3="galactose",
    D4="galactose:glucose 4:1",
    D5="galactose:glucose 4:1",
    D6="galactose:glucose 4:1",
    D7="galactose:glucose 2:1",
    D8="galactose:glucose 2:1",
    D9="galactose:glucose 2:1",
    D10="galactose:glucose 1:1",
    D11="galactose:glucose 1:1",
    D12="galactose:glucose 1:1",
    E1="rhamnose",
    E2="rhamnose",
    E3="rhamnose",
    E4="rhamnose:glucose 4:1",
    E5="rhamnose:glucose 4:1",
    E6="rhamnose:glucose 4:1",
    E7="rhamnose:glucose 2:1",
    E8="rhamnose:glucose 2:1",
    E9="rhamnose:glucose 2:1",
    E10="rhamnose:glucose 1:1",
    E11="rhamnose:glucose 1:1",
    E12="rhamnose:glucose 1:1",
    F1="ribose",
    F2="ribose",
    F3="ribose",
    F4="ribose:glucose 4:1",
    F5="ribose:glucose 4:1",
    F6="ribose:glucose 4:1",
    F7="ribose:glucose 2:1",
    F8="ribose:glucose 2:1",
    F9="ribose:glucose 2:1",
    F10="ribose:glucose 1:1",
    F11="ribose:glucose 1:1",
    F12="ribose:glucose 1:1",
    G1="arabinose",
    G2="arabinose",
    G3="arabinose",
    G4="arabinose:glucose 4:1",
    G5="arabinose:glucose 4:1",
    G6="arabinose:glucose 4:1",
    G7="arabinose:glucose 2:1",
    G8="arabinose:glucose 2:1",
    G9="arabinose:glucose 2:1",
    G10="arabinose:glucose 1:1",
    G11="arabinose:glucose 1:1",
    G12="arabinose:glucose 1:1",
    H1="student choice 1",
    H2="student choice 2",
    H3="student choice 3",
    H4="student choice 4",
    H5="student choice 5",
    H6="student choice 6",
    H7="student choice 7",
    H8="student choice 8",
    H9="student choice 9",
    H10="student choice 10",
    H11="student choice 11",
    H12="student choice 12",
)

It is convenient to invert the dictionary so that we can look at which wells correspond to a given condition (e.g., wells B2 and C2 have glucose).

In [6]:
def invert_dict(input_dict):
    """Invert a dictionary ignore None values."""
    # Create an empty dictionary to store the inverted mapping
    output_dict = {}

    # Iterate over items in the input dictionary
    for key, value in input_dict.items():
        if value is not None:
            if value in output_dict:
                output_dict[value].append(key)
            else:
                output_dict[value] = [key]

    return output_dict

Let's now make and check out our inverse dictionary.

In [7]:
conditions = invert_dict(well_metadata)

conditions
Out[7]:
{'LB blank': ['A1', 'A2', 'A3'],
 'LB': ['A4', 'A5', 'A6'],
 'M9 blank': ['A7', 'A8', 'A9'],
 'glucose': ['A10', 'A11', 'A12'],
 'xylose': ['B1', 'B2', 'B3'],
 'xylose:glucose 4:1': ['B4', 'B5', 'B6'],
 'xylose:glucose 2:1': ['B7', 'B8', 'B9'],
 'xylose:glucose 1:1': ['B10', 'B11', 'B12'],
 'sorbitol': ['C1', 'C2', 'C3'],
 'sorbitol:glucose 4:1': ['C4', 'C5', 'C6'],
 'sorbitol:glucose 2:1': ['C7', 'C8', 'C9'],
 'sorbitol:glucose 1:1': ['C10', 'C11', 'C12'],
 'galactose': ['D1', 'D2', 'D3'],
 'galactose:glucose 4:1': ['D4', 'D5', 'D6'],
 'galactose:glucose 2:1': ['D7', 'D8', 'D9'],
 'galactose:glucose 1:1': ['D10', 'D11', 'D12'],
 'rhamnose': ['E1', 'E2', 'E3'],
 'rhamnose:glucose 4:1': ['E4', 'E5', 'E6'],
 'rhamnose:glucose 2:1': ['E7', 'E8', 'E9'],
 'rhamnose:glucose 1:1': ['E10', 'E11', 'E12'],
 'ribose': ['F1', 'F2', 'F3'],
 'ribose:glucose 4:1': ['F4', 'F5', 'F6'],
 'ribose:glucose 2:1': ['F7', 'F8', 'F9'],
 'ribose:glucose 1:1': ['F10', 'F11', 'F12'],
 'arabinose': ['G1', 'G2', 'G3'],
 'arabinose:glucose 4:1': ['G4', 'G5', 'G6'],
 'arabinose:glucose 2:1': ['G7', 'G8', 'G9'],
 'arabinose:glucose 1:1': ['G10', 'G11', 'G12'],
 'student choice 1': ['H1'],
 'student choice 2': ['H2'],
 'student choice 3': ['H3'],
 'student choice 4': ['H4'],
 'student choice 5': ['H5'],
 'student choice 6': ['H6'],
 'student choice 7': ['H7'],
 'student choice 8': ['H8'],
 'student choice 9': ['H9'],
 'student choice 10': ['H10'],
 'student choice 11': ['H11'],
 'student choice 12': ['H12']}

These kind of dictionaries need to be hand-built, based on what you do as an experimenter. For convenience, below is code for making a dictionary for your experiment. You should update the wells in row H to reflect what you and your classmates decided to put in there.

well_metadata = dict(
    A1="LB blank",
    A2="LB blank",
    A3="LB blank",
    A4="LB",
    A5="LB",
    A6="LB",
    A7="M9 blank",
    A8="M9 blank",
    A9="M9 blank",
    A10="glucose",
    A11="glucose",
    A12="glucose",
    B1="xylose",
    B2="xylose",
    B3="xylose",
    B4="xylose:glucose 4:1",
    B5="xylose:glucose 4:1",
    B6="xylose:glucose 4:1",
    B7="xylose:glucose 2:1",
    B8="xylose:glucose 2:1",
    B9="xylose:glucose 2:1",
    B10="xylose:glucose 1:1",
    B11="xylose:glucose 1:1",
    B12="xylose:glucose 1:1",
    C1="sorbitol",
    C2="sorbitol",
    C3="sorbitol",
    C4="sorbitol:glucose 4:1",
    C5="sorbitol:glucose 4:1",
    C6="sorbitol:glucose 4:1",
    C7="sorbitol:glucose 2:1",
    C8="sorbitol:glucose 2:1",
    C9="sorbitol:glucose 2:1",
    C10="sorbitol:glucose 1:1",
    C11="sorbitol:glucose 1:1",
    C12="sorbitol:glucose 1:1",
    D1="galactose",
    D2="galactose",
    D3="galactose",
    D4="galactose:glucose 4:1",
    D5="galactose:glucose 4:1",
    D6="galactose:glucose 4:1",
    D7="galactose:glucose 2:1",
    D8="galactose:glucose 2:1",
    D9="galactose:glucose 2:1",
    D10="galactose:glucose 1:1",
    D11="galactose:glucose 1:1",
    D12="galactose:glucose 1:1",
    E1="rhamnose",
    E2="rhamnose",
    E3="rhamnose",
    E4="rhamnose:glucose 4:1",
    E5="rhamnose:glucose 4:1",
    E6="rhamnose:glucose 4:1",
    E7="rhamnose:glucose 2:1",
    E8="rhamnose:glucose 2:1",
    E9="rhamnose:glucose 2:1",
    E10="rhamnose:glucose 1:1",
    E11="rhamnose:glucose 1:1",
    E12="rhamnose:glucose 1:1",
    F1="ribose",
    F2="ribose",
    F3="ribose",
    F4="ribose:glucose 4:1",
    F5="ribose:glucose 4:1",
    F6="ribose:glucose 4:1",
    F7="ribose:glucose 2:1",
    F8="ribose:glucose 2:1",
    F9="ribose:glucose 2:1",
    F10="ribose:glucose 1:1",
    F11="ribose:glucose 1:1",
    F12="ribose:glucose 1:1",
    G1="lactose",
    G2="lactose",
    G3="lactose",
    G4="lactose:glucose 4:1",
    G5="lactose:glucose 4:1",
    G6="lactose:glucose 4:1",
    G7="lactose:glucose 2:1",
    G8="lactose:glucose 2:1",
    G9="lactose:glucose 2:1",
    G10="lactose:glucose 1:1",
    G11="lactose:glucose 1:1",
    G12="lactose:glucose 1:1",
    H1="student choice 1",
    H2="student choice 2",
    H3="student choice 3",
    H4="student choice 4",
    H5="student choice 5",
    H6="student choice 6",
    H7="student choice 7",
    H8="student choice 8",
    H9="student choice 9",
    H10="student choice 10",
    H11="student choice 11",
    H12="student choice 12",
)

Plotting data¶

We now have a nice, tidy data set to work with. Let's start making some plots.

Temperature check¶

First, we'll plot the temperature versus time to make sure there we no problems with the temperature setting in the plate reader.

In [8]:
p = bokeh.plotting.figure(
    frame_width=400,
    frame_height=200,
    x_axis_label='time (hr)',
    y_axis_label='temperature (deg. C)',
    x_range=[0, 24],
)

p.line(x=df['time (hr)'], y=df['temperature (deg C)'], line_width=2)

bokeh.io.show(p)

The temperature was held steady, so we're all good there.

Checking blanks and computing the background¶

Next, let's look at the background traces. We will plot all time series for the blanks, and then overlay the average blank,which we will store as the background.

In [9]:
# Background traces
p = bokeh.plotting.figure(
    frame_width=400,
    frame_height=200,
    x_axis_label='time (hr)',
    y_axis_label='absorbance',
    x_range=[0, 24],
)

# Plot all background traces
for well in conditions['M9 blank']:
    p.line(x=df['time (hr)'], y=df[well])

# Compute background by averaging background values at each time point
df = df.with_columns(pl.mean_horizontal(conditions['M9 blank']).alias('background'))

# Overlay average blank
p.line(x=df['time (hr)'], y=df['background'], color='tomato', line_width=2)

bokeh.io.show(bokeh.layouts.column(p))

All of the blanks are more or less steady, with a typical absorbance of 0.088.

Plotting growth curves in pure sugars¶

Now, we can begin plotting the background-subtracted absorbance. Let us start with a simple example, pure glocose.

In [10]:
# Instantiate figure
p = bokeh.plotting.figure(
    frame_width=400,
    frame_height=200,
    x_axis_label='time (hr)',
    y_axis_label='absorbance',
    x_range=[0, 24],
)

# Loop through and add a line for each pure glucose trace
for well in conditions['glucose']:
    p.line(x=df['time (hr)'], y=df[well] - df['background'])

bokeh.io.show(p)

This is interesting. We immediately see exponential growth, which ends at about the seven hour mark, when the bacterial abruptly stop growing. There is a pause for a couple hours, another growth phase for about five hours, and then a slow decline. We will postulate in class why there may be an extra growth phase.

We would like to compare with the other pure sugar sources as well, so let's include galactose, sorbitol, and xylose in our plot. We will color galactose red, sorbitol orange, and xylose purple.

In [11]:
# Loop through and add a line for each pure galactose trace
for well in conditions['galactose']:
    p.line(x=df['time (hr)'], y=df[well] - df['background'], color='orange')
    
# Loop through and add a line for each pure sorbitol trace
for well in conditions['sorbitol']:
    p.line(x=df['time (hr)'], y=df[well] - df['background'], color='green')

# Loop through and add a line for each pure xylose trace
for well in conditions['xylose']:
    p.line(x=df['time (hr)'], y=df[well] - df['background'], color='tomato')

bokeh.io.show(p)

All curves appear to show exponential growth. Glucose, xylose, and sorbitol all then have a pause, and then secondary growth and then decay Though note that one of the xylose curves never grew.

This plot is fine and good, but it leaves a bit to be desired. First, we would like to have the absorbance axis on a logarithmic scale so we can better see the exponential growth; it will appear as a line, with the growth rate being the slope. Second, we would like to have a legend, so we do not have to just remember which curve was which. To get a logarithmic vertical axis, we use the y_axis_type='log' keyword argument when we instantiate the plot. There are many ways to get a legend; I prefer hand-building it and then placing it outside the plot. We can also make the legend clickable, so that we can show and hide specific curves. The code below accomplishes all of this.

In [12]:
# Instantiate figure
p = bokeh.plotting.figure(
    frame_width=400,
    frame_height=200,
    x_axis_label="time (hr)",
    y_axis_label="absorbance",
    y_axis_type="log",
    x_range=[0, 24],
    toolbar_location="above",
)

# Items we will place in the legend
legend_items = []

# Loop through conditions with pretty colors
for condition, color in zip(
    ["glucose", "galactose", "sorbitol", "xylose", "ribose", "rhamnose", "arabinose"],
    colorcet.b_glasbey_category10,
):
    # Instantiate plotted lines for a given sugar
    lines = []

    # Loop through each well corresponding to the sugar
    for well in conditions[condition]:
        # A line of the time series of absorbance
        lines.append(
            p.line(x=df["time (hr)"], y=df[well] - df["background"], line_color=color)
        )

    # Add the lines to the legend
    legend_items.append((condition, lines))

# Create the legend from the items
legend = bokeh.models.Legend(items=legend_items, click_policy="hide")

# Add the legend to the plot
p.add_layout(legend, "right")

bokeh.io.show(p)

This plot is clear. There is about a 2.5-hour lag phase for glucose before exponential growth kicks in. The lag is a bit longer for sorbitol and xylose. Sorbitol and xylose seem to have bacteria with similar growth rates, which are smaller than that in glucose. We will quantify these more precisely when we do regressions in a moment.

Plotting multiple sugar sources¶

Let us now look at plots of growth curves in the presence of two sugars. We will look at glucose and sorbitol to start with.

It is good practice to put all of the curves on the same plot so that they can be easily compared. Since we want to do this for multiple sugar sources, we can write a function to generate the plots.

In [13]:
def plot_sugar_source(df, sugar_source, y_axis_type='log'):
    """Make a plot of all glucose + sugar_source conditions"""
    # Instantiate figure
    p = bokeh.plotting.figure(
        frame_width=450,
        frame_height=250,
        x_axis_label="time (hr)",
        y_axis_label="absorbance",
        y_axis_type=y_axis_type,
        x_range=[0, 24],
        toolbar_location="above",
    )

    # Conditions to consider
    conds = ["glucose", sugar_source] + [
        f"{sugar_source}:glucose {ratio}" for ratio in ["1:1", "2:1", "4:1"]
    ]

    # Color glucose in blue, pure other sugar in orange, grays for ratios
    colors = ["#1f77b4", "orange"] + list(bokeh.palettes.Greys5[1:-1])

    # Populate glyphs, legend items
    legend_items = []
    for cond, color in zip(conds, colors):
        lines = []
        for well in conditions[cond]:
            lines.append(
                p.line(
                    x=df["time (hr)"],
                    y=df[well] - df["background"],
                    line_color=color,
                )
            )
        legend_items.append((cond, lines))

    # Build and add legend
    legend = bokeh.models.Legend(items=legend_items, click_policy="hide")
    p.add_layout(legend, "right")

    return p

Let's take this for a spin! We'll use sorbitol.

In [14]:
bokeh.io.show(plot_sugar_source(df, 'sorbitol'))

This plot clearly shows diauxic growth! All curves follow glucose at short times, and then when the glucose is eaten, they switch to sorbitol with a different slope.

Obtaining the growth rates¶

We learned how to do a nonlinear regression in a previous tutorial. Here, we need to extract the region of the growth curve of interest (exponential growth phase) and then directly what we applied in the previous tutorial.

Extracting data of interest¶

To extract data of interest from a data frame, we can use Boolean indexing. Let's look at the pure glucose curves, shown again here.

In [15]:
# Instantiate figure
p = bokeh.plotting.figure(
    frame_width=400,
    frame_height=200,
    x_axis_label='time (hr)',
    y_axis_label='absorbance',
    y_axis_type='log',
    x_range=[0, 24],
)

# Loop through and add a line for each pure glucose trace
for well in conditions['glucose']:
    p.line(x=df['time (hr)'], y=df[well] - df['background'])

bokeh.io.show(p)

In looking at the graph, exponential growth phase is between when the background-subtracted OD is between about 0.1, and 0.4. To extract those parts of the curve for a given well, we use the following syntax. Be sure to note the use of parentheses and the & operator.

In [16]:
OD_range = [0.1, 0.4]
col = conditions['glucose'][0]

sub_df = (
    df
    .filter(
        (pl.col(col) - pl.col('background') >= OD_range[0]) 
        & (pl.col(col) - pl.col('background') <= OD_range[1])
    )
    .select('time (hr)', 'background', col)
)

Now, we have a sub-data frame that only has time points between three and five hours.

Performing a regression to get the growth rate¶

Let us now consider one of the glucose curves and perform a curve fit. As in the first tutorial, we need to define a theoretical function for exponential growth. Since we are considering background-substracted growth, the equation is

\begin{align} A = A_0\,\mathrm{e}^{r t}, \end{align}

where $A$ is absorbance and $r$ is the growth rate.

In [17]:
# Define exponential growth function
def exp_growth(t, A0, r):
    return A0 * np.exp(r * t)

Next, get guess parameters.

In [18]:
# Parameter guesses
A0_guess = 0.01
r_guess = 1.0 # 1/hr

Next, we pull out the time points and absorbance measurements, making sure to do background subtraction.

In [19]:
# Pull out time points and absorbances
t = sub_df['time (hr)'].to_numpy()
A = (sub_df[conditions['glucose'][0]] - sub_df['background']).to_numpy()

Finally, we use scipy.optimize.curve_fit() to get the best-fit parameters.

In [20]:
# Perform optimization
popt, _ = scipy.optimize.curve_fit(exp_growth, t, A, p0=(A0_guess, r_guess))

# Extract parameters
A0, r = popt

# What was our growth rate?
print(f"growth rate = {r} inverse hours")
growth rate = 0.5933536217655401 inverse hours

A growth rate of 0.6 inverse hours corresponds to a division time of $\ln 2 / 0.6 \approx 1$ hour.

Let's overlay a plot.

In [21]:
# Generator theoretical curve, going a bit beyond
# both sides of curve fit region
t_theor = np.linspace(t.min(), t.max(), 200)
A_theor = exp_growth(t_theor, *popt)

# Add to plot
p.line(t_theor, A_theor, line_color='tomato')

bokeh.io.show(p)

Looks nice!

Growth rates for single sugars¶

We should automate the regression by writing a function to perform it.

In [22]:
def fit_growth(df, well, OD_range):
    """Obtain estimates for A0 and r for a given sub-trace"""
    # Parameter guesses
    A0_guess = 0.01
    r_guess = 1.0  # 1/hr

    # Pull out data
    sub_df = (
        df
        .filter(
            (pl.col(well) - pl.col('background') >= OD_range[0]) 
            & (pl.col(well) - pl.col('background') <= OD_range[1])
        )
        .select('time (hr)', 'background', well)
    )

    t = sub_df["time (hr)"].to_numpy()
    A = (sub_df[well] - sub_df["background"]).to_numpy()

    # Perform optimization
    popt, _ = scipy.optimize.curve_fit(exp_growth, t, A, p0=(A0_guess, r_guess))

    # Return best fit parameters and also the time range for the fit
    return *popt, [t.min(), t.max()]

We can now use this function to get growth rates for all of the pure sugars. To do so, we will need to specify the OD ranges for the plots above. In your analysis, you will need to obtain these bounds, but for now, we will assume they are all the same, [0.1, 0.4] for a single sugar and [0.1, 0.25], [0.4, 0.6] for diauxic growth.

In [23]:
OD_range = {
    cond: [
        [(0.1, 0.25), (0.4, 0.6)] 
        if ':' in cond 
        else [(0.1, 0.4)]
        for _ in range(len(conditions[cond]))
        ]
    for cond in conditions
}

# Take a look
OD_range
Out[23]:
{'LB blank': [[(0.1, 0.4)], [(0.1, 0.4)], [(0.1, 0.4)]],
 'LB': [[(0.1, 0.4)], [(0.1, 0.4)], [(0.1, 0.4)]],
 'M9 blank': [[(0.1, 0.4)], [(0.1, 0.4)], [(0.1, 0.4)]],
 'glucose': [[(0.1, 0.4)], [(0.1, 0.4)], [(0.1, 0.4)]],
 'xylose': [[(0.1, 0.4)], [(0.1, 0.4)], [(0.1, 0.4)]],
 'xylose:glucose 4:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'xylose:glucose 2:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'xylose:glucose 1:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'sorbitol': [[(0.1, 0.4)], [(0.1, 0.4)], [(0.1, 0.4)]],
 'sorbitol:glucose 4:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'sorbitol:glucose 2:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'sorbitol:glucose 1:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'galactose': [[(0.1, 0.4)], [(0.1, 0.4)], [(0.1, 0.4)]],
 'galactose:glucose 4:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'galactose:glucose 2:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'galactose:glucose 1:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'rhamnose': [[(0.1, 0.4)], [(0.1, 0.4)], [(0.1, 0.4)]],
 'rhamnose:glucose 4:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'rhamnose:glucose 2:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'rhamnose:glucose 1:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'ribose': [[(0.1, 0.4)], [(0.1, 0.4)], [(0.1, 0.4)]],
 'ribose:glucose 4:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'ribose:glucose 2:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'ribose:glucose 1:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'arabinose': [[(0.1, 0.4)], [(0.1, 0.4)], [(0.1, 0.4)]],
 'arabinose:glucose 4:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'arabinose:glucose 2:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'arabinose:glucose 1:1': [[(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)],
  [(0.1, 0.25), (0.4, 0.6)]],
 'student choice 1': [[(0.1, 0.4)]],
 'student choice 2': [[(0.1, 0.4)]],
 'student choice 3': [[(0.1, 0.4)]],
 'student choice 4': [[(0.1, 0.4)]],
 'student choice 5': [[(0.1, 0.4)]],
 'student choice 6': [[(0.1, 0.4)]],
 'student choice 7': [[(0.1, 0.4)]],
 'student choice 8': [[(0.1, 0.4)]],
 'student choice 9': [[(0.1, 0.4)]],
 'student choice 10': [[(0.1, 0.4)]],
 'student choice 11': [[(0.1, 0.4)]],
 'student choice 12': [[(0.1, 0.4)]]}

Of course, you'll have to fill out this dictionary with OD ranges that make sense for your analysis. Since we'll play with sorbitol, I'll add good ranges for that here.

In [24]:
OD_range['sorbitol:glucose 4:1'] = [
    [(0.1, 0.2), (0.4, 0.6)],
    [(0.1, 0.2), (0.4, 0.6)],
    [(0.1, 0.2), (0.4, 0.6)]
]

OD_range['sorbitol:glucose 2:1'] = [
    [(0.1, 0.25), (0.45, 0.7)],
    [(0.1, 0.25), (0.45, 0.7)],
    [(0.1, 0.25), (0.45, 0.7)]
]

OD_range['sorbitol:glucose 1:1'] = [
    [(0.1, 0.3), (0.65, 0.8)],
    [(0.1, 0.3), (0.65, 0.8)],
    [(0.1, 0.3), (0.65, 0.8)]
]

OD_range['sorbitol'] = [
    [(0.1, 0.6)],
    [(0.1, 0.6)],
    [(0.1, 0.6)],
]

We will perform the regressions to get the growth rates. We will store each result as a dictionary containing the sugar source, trial number, and value of the growth rate from the regression. Then, we can convert a list of these results dictionaries into a data frame for easy viewing and plotting.

In [25]:
results = []
for sugar in ['glucose', 'galactose', 'sorbitol', 'xylose']:
    for trial, well in enumerate(conditions[sugar]):
        for ODr in OD_range[sugar][trial]:
            try:
                A0, r, _ = fit_growth(df, well, ODr)
                results.append(dict(sugar=sugar, trial=trial, OD_low=ODr[0], OD_high=ODr[1], A0=A0, r=r))
            except:
                print(f'WARNING: {sugar}, {well}, {ODr} has no data in OD range; skipping.')

# Make a data frame from the list of results
df_growth_rates = pl.DataFrame(results)

# Take a look
df_growth_rates
WARNING: xylose, B3, (0.1, 0.4) has no data in OD range; skipping.
Out[25]:
shape: (11, 6)
sugartrialOD_lowOD_highA0r
stri64f64f64f64f64
"glucose"00.10.40.0242820.593354
"glucose"10.10.40.0256890.60446
"glucose"20.10.40.027510.594018
"galactose"00.10.40.0279920.229443
"galactose"10.10.40.0265370.253469
………………
"sorbitol"00.10.60.0275450.392449
"sorbitol"10.10.60.0261910.400065
"sorbitol"20.10.60.0240770.412325
"xylose"00.10.40.0147330.442673
"xylose"10.10.40.0109560.449941

If we like, we can summarize our results in a plot.

In [26]:
p = iqplot.strip(
    df_growth_rates,
    q="r",
    cats="sugar",
    q_axis='y',
    y_axis_label="growth rate (1/hr)",
    spread="swarm",
    y_range=[0, 1],
)

bokeh.io.show(p)

This provides a nice look into the growth rates.

As a check, we should make sure the curve fits look ok. We can again overlay them on the plot. So, let's make a function that performs the regressions for a set of conditions and, if desired, makes a plot with the growth curves with regressions overlaid. It's a fair amount of code to do it, but it's straightforward. We will write it as a function, since we will use this again and again.

In [27]:
def multi_reg_plot(
    df,
    conds,
    colors=colorcet.b_glasbey_category10,
    OD_range=None,
    show_regression=True,
    return_plot=True,
):
    """Perform regression for multiple conditions and, if asked,
    generate a plot with multiple conditions, with or without
    showing regression lines."""
    if return_plot:
        # Instantiate figure
        p = bokeh.plotting.figure(
            frame_width=400,
            frame_height=200,
            x_axis_label="time (hr)",
            y_axis_label="absorbance",
            y_axis_type="log",
            x_range=[0, 24],
            toolbar_location="above",
        )
    
        # Items we will place in the legend
        legend_items = []

        # Loop through conditions with pretty colors
        for cond, color in zip(conds, colors):
            # Instantiate plotted lines for a given condition
            lines = []
    
            # Loop through each well corresponding to the sugar
            for well in conditions[cond]:
                # A line of the time series of absorbance
                lines.append(
                    p.line(
                        x=df["time (hr)"], y=df[well] - df["background"], line_color=color
                    )
                )
    
            # Add the lines to the legend
            legend_items.append((cond, lines))

    # Loop through and perform regressions
    fit_lines = []
    results = []
    for cond in conds:
        # Loop through each well corresponding to the sugar
        for trial, well in enumerate(conditions[cond]):
            # Get best fit curve for each OD range
            for diaux_phase, ODr in enumerate(OD_range[cond][trial]):
                try:
                    # Get best fit parameters
                    A0, r, t_range = fit_growth(df, well, ODr)

                    # Time points for theoretical curve
                    t_theor = np.linspace(*t_range, 200)

                    # Theoretical absorbance
                    A_theor = exp_growth(t_theor, A0, r)

                    results.append(
                        dict(
                            condition=cond,
                            trial=trial,
                            diaux_phase=diaux_phase,
                            OD_low=ODr[0],
                            OD_high=ODr[1],
                            A0=A0,
                            r=r,
                        )
                    )

                    # Plot theoretical curve in gray
                    if return_plot and show_regression:
                        fit_lines.append(
                            p.line(x=t_theor, y=A_theor, line_color="gray")
                        )
                except:
                    print(
                        f"WARNING: {sugar}, {well}, {ODr} has no data in OD range; skipping."
                    )

    # Add the fit lines to the legend
    if return_plot and show_regression:
        legend_items.append(("regression", fit_lines))

    # Make a data frame from the list of results
    df_growth_rates = pl.DataFrame(results)

    if return_plot:
        # Create the legend from the items
        legend = bokeh.models.Legend(items=legend_items, click_policy="hide")
    
        # Add the legend to the plot
        p.add_layout(legend, "right")

        return df_growth_rates, p
    else:
        return p

Let's now do our regressions and make our plot to do the check!

In [28]:
df_pure_sugars, p = multi_reg_plot(
    df,
    ["glucose", "galactose", "sorbitol", "xylose"],
    OD_range=OD_range,
)

bokeh.io.show(p)
WARNING: xylose, B3, (0.1, 0.4) has no data in OD range; skipping.

Fitting diauxic curves¶

Fitting a diauxic curve is a bit more challenging, since we need to look carefully at the respective exponential growth regimes. Let's consider again the sorbitol curve as an example.

In [29]:
df_sorb, p = multi_reg_plot(
    df,
    ["glucose", "sorbitol", 'sorbitol:glucose 4:1', 'sorbitol:glucose 2:1', 'sorbitol:glucose 1:1'],
    colors=['#1f77b4', 'orange'] + list(bokeh.palettes.Purples5[1:-1]),
    OD_range=OD_range,
)


bokeh.io.show(p)

The regressions look good! Now let's make a plot of the growth rates we got from the regression.

In [30]:
# Look at result
df_sorb
Out[30]:
shape: (24, 7)
conditiontrialdiaux_phaseOD_lowOD_highA0r
stri64i64f64f64f64f64
"glucose"000.10.40.0242820.593354
"glucose"100.10.40.0256890.60446
"glucose"200.10.40.027510.594018
"sorbitol"000.10.60.0275450.392449
"sorbitol"100.10.60.0261910.400065
…………………
"sorbitol:glucose 1:1"010.650.80.1010140.281996
"sorbitol:glucose 1:1"100.10.30.0264590.597242
"sorbitol:glucose 1:1"110.650.80.1016540.298754
"sorbitol:glucose 1:1"200.10.30.0265990.561807
"sorbitol:glucose 1:1"210.650.80.1059690.278731

Looks good; now let's make a plot!

In [31]:
# Specify the order of the conditions
order = (
    ("glucose", 0),
    ("sorbitol", 0),
    ("sorbitol:glucose 4:1", 0),
    ("sorbitol:glucose 4:1", 1),
    ("sorbitol:glucose 2:1", 0),
    ("sorbitol:glucose 2:1", 1),
    ("sorbitol:glucose 1:1", 0),
    ("sorbitol:glucose 1:1", 1),
)

# Colors
colors = ['#1f77b4', 'orange'] * 4

# Make a strip plot of the growth rates
p = iqplot.strip(
    df_sorb,
    q="r",
    cats=["condition", "diaux_phase"],
    y_axis_label="growth rate (1/hr)",
    spread="swarm",
    q_axis="y",
    order=order,
    palette=colors,
    frame_width=500,
    y_range=[0, 0.8],
)

bokeh.io.show(p)

We see that, indeed, all cells seem to be growing on glucose at the same rate as in pure glucose, but as glucose runs out, the cells grow roughly at the same rate as in pure sorbitol, though with a decreasing rate as the sorbitol:glucose ratio drops.

Further analysis¶

It is up to you to look carefully at the growth curves and set the appropriate OD ranges for the regressions and do analysis for the rest of the sugars and diauxic combinations, as per the instructions in the handout.

Computing environment¶

In [32]:
%load_ext watermark
%watermark -v -p  numpy,scipy,pandas,bokeh,colorcet,bi1x,jupyterlab
Python implementation: CPython
Python version       : 3.12.5
IPython version      : 8.27.0

numpy     : 1.26.4
scipy     : 1.13.1
pandas    : 2.2.2
bokeh     : 3.6.0
colorcet  : 3.1.0
bi1x      : 0.0.14
jupyterlab: 4.2.5