Analysis of growth data in the efflux experiment¶

(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 numpy as np
import pandas as pd
import scipy.optimize

import iqplot

import bokeh.io
import bokeh.plotting
bokeh.io.output_notebook()
BokehJS 3.4.1 successfully loaded.

In this tutorial, we will explore how to analyze the data from the efflux pump experiment. We will then develop some tools for parsing the data set and obtaining growth rates from the curves. Then, we will learn how to compute the growth rate for a given set of parameters and antibiotic concentration. This is necessary to be able to perform a regression to obtain an estimate the parameter qmaxex,0qex,0max for each strain.

Data wrangling¶

The data will again come from our plate reader in the same format as for the E. coli growth experiment. You can use the parser from that module to load in the data into a data frame. Remember that you need to specify the filename, header row, and last row of the data to do so. Here is that parser for convenience.

In [2]:
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
        Pandas DataFrame containing time series data.
    """
    # Find out how many rows in input file
    with open(fname, "rb") as fp:
        n_rows = len(fp.readlines())
    
    # How many lines in footer to skip
    skipfooter = n_rows - last_row - 1

    # Read in data frame
    df = pd.read_csv(
        fname,
        sep="\t",
        skiprows=header_row,
        skipfooter=skipfooter,
        engine="python",
        encoding='ISO-8859-1',
    )

    # Rename temperature column
    df = df.rename(columns={df.columns[df.columns.str.contains('T°')][0]: 'temperature (deg C)'})

    # Parse the time column
    df['time (s)'] = pd.to_timedelta(df['Time']).dt.total_seconds()

    # Set start time to zero
    df['time (s)'] -= df['time (s)'].min()

    # Time in units of hours
    df['time (hr)'] = df['time (s)'] / 3600

    return df

Well metadata¶

In this experiment, we will have two data sets to load in, one from Group A, and one from Group B. You will perform regressions to get growth rates on each data set separately. That is, you will determine λ0λ0 and λλ for each time course, reporting relative growth rate and λ/λ0λ/λ0, where the λ0λ0 is determined from the data set from the same plate. That is, you should have a λ0λ0 for Group A and a separate λ0λ0 for Group B.

For this demonstration, we will work with one data set from a dry run from this experiment in the file 20240527_tet_test.txt. The analysis is analogous for a second data set. As in the E. coli growth module, we need to specify the header row and the last row of data.

In [3]:
# This is a dry run of this experiment
fname_A = '20240527_tet_test.txt'
header_row_A = 53
last_row_A = 223

# Load in the data frame for "Group A" (which is just the dry run in this case)
dfA = read_plate_reader(fname_A, header_row_A, last_row_A)

It is also useful to have a dictionary with the metadata for each well in the plates, in this case the strain and tetracycline concentration. We have two dictionaries, one for the plate from Group A and one from Group B. Below are dictionaries for the metadata for this experiment. The abbreviated strain is followed by the tetracycline concentration in µM, separated by a dash. Blank wells are labeled as "blank."

In [4]:
well_metadata_A = dict(
    A1="MG-0.0",
    A2="WT-0.21",
    A3="MG-0.0",
    A4="WT-0.90",
    A5="MG-0.0",
    A6="WT-3.90",
    A7="MG-0.0",
    A8="WT-16.90",
    A9="MG-0.0",
    A10="WT-73.15",
    A11="MG-0.0",
    A12="WT-208.33",
    B1="3.19-0.0",
    B2="UV5-0.21",
    B3="3.19-0.0",
    B4="UV5-0.90",
    B5="3.19-0.0",
    B6="UV5-3.90",
    B7="3.19-0.0",
    B8="UV5-16.90",
    B9="3.19-0.0",
    B10="UV5-73.15",
    B11="3.19-0.0",
    B12="UV5-208.33",
    C1="IW-0.0",
    C2="MG-0.21",
    C3="IW-0.0",
    C4="MG-0.90",
    C5="IW-0.0",
    C6="MG-3.90",
    C7="IW-0.0",
    C8="MG-16.90",
    C9="IW-0.0",
    C10="MG-73.15",
    C11="IW-0.0",
    C12="MG-208.33",
    D1="WT-0.0",
    D2="3.19-0.21",
    D3="WT-0.0",
    D4="3.19-0.90",
    D5="WT-0.0",
    D6="3.19-3.90",
    D7="WT-0.0",
    D8="3.19-16.90",
    D9="WT-0.0",
    D10="3.19-73.15",
    D11="WT-0.0",
    D12="3.19-208.33",
    E1="UV5-0.0",
    E2="IW-0.21",
    E3="UV5-0.0",
    E4="IW-0.90",
    E5="UV5-0.0",
    E6="IW-3.90",
    E7="UV5-0.0",
    E8="IW-16.90",
    E9="UV5-0.0",
    E10="IW-73.15",
    E11="UV5-0.0",
    E12="IW-208.33",
    F1="MG-0.21",
    F2="WT-0.21",
    F3="MG-0.90",
    F4="WT-0.90",
    F5="MG-3.90",
    F6="WT-3.90",
    F7="MG-16.90",
    F8="WT-16.90",
    F9="MG-73.15",
    F10="WT-73.15",
    F11="MG-208.33",
    F12="WT-208.33",
    G1="3.19-0.21",
    G2="UV5-0.21",
    G3="3.19-0.90",
    G4="UV5-0.90",
    G5="3.19-3.90",
    G6="UV5-3.90",
    G7="3.19-16.90",
    G8="UV5-16.90",
    G9="3.19-73.15",
    G10="UV5-73.15",
    G11="3.19-208.33",
    G12="UV5-208.33",
    H1="IW-0.21",
    H2="blank",
    H3="IW-0.90",
    H4="blank",
    H5="IW-3.90",
    H6="blank",
    H7="IW-16.90",
    H8="blank",
    H9="IW-73.15",
    H10="blank",
    H11="IW-208.33",
    H12="blank",
)

well_metadata_B = dict(
    A1="MG-0.0",
    A2="WT-0.44",
    A3="MG-0.0",
    A4="WT-1.88",
    A5="MG-0.0",
    A6="WT-8.13",
    A7="MG-0.0",
    A8="WT-35.17",
    A9="MG-0.0",
    A10="WT-152.19",
    A11="MG-0.0",
    A12="WT-312.50",
    B1="3.19-0.0",
    B2="UV5-0.44",
    B3="3.19-0.0",
    B4="UV5-1.88",
    B5="3.19-0.0",
    B6="UV5-8.13",
    B7="3.19-0.0",
    B8="UV5-35.17",
    B9="3.19-0.0",
    B10="UV5-152.19",
    B11="3.19-0.0",
    B12="UV5-312.50",
    C1="IW-0.0",
    C2="MG-0.44",
    C3="IW-0.0",
    C4="MG-1.88",
    C5="IW-0.0",
    C6="MG-8.13",
    C7="IW-0.0",
    C8="MG-35.17",
    C9="IW-0.0",
    C10="MG-152.19",
    C11="IW-0.0",
    C12="MG-312.50",
    D1="WT-0.0",
    D2="3.19-0.44",
    D3="WT-0.0",
    D4="3.19-1.88",
    D5="WT-0.0",
    D6="3.19-8.13",
    D7="WT-0.0",
    D8="3.19-35.17",
    D9="WT-0.0",
    D10="3.19-152.19",
    D11="WT-0.0",
    D12="3.19-312.50",
    E1="UV5-0.0",
    E2="IW-0.44",
    E3="UV5-0.0",
    E4="IW-1.88",
    E5="UV5-0.0",
    E6="IW-8.13",
    E7="UV5-0.0",
    E8="IW-35.17",
    E9="UV5-0.0",
    E10="IW-152.19",
    E11="UV5-0.0",
    E12="IW-312.50",
    F1="MG-0.44",
    F2="WT-0.44",
    F3="MG-1.88",
    F4="WT-1.88",
    F5="MG-8.13",
    F6="WT-8.13",
    F7="MG-35.17",
    F8="WT-35.17",
    F9="MG-152.19",
    F10="WT-152.19",
    F11="MG-312.50",
    F12="WT-312.50",
    G1="3.19-0.44",
    G2="UV5-0.44",
    G3="3.19-1.88",
    G4="UV5-1.88",
    G5="3.19-8.13",
    G6="UV5-8.13",
    G7="3.19-35.17",
    G8="UV5-35.17",
    G9="3.19-152.19",
    G10="UV5-152.19",
    G11="3.19-312.50",
    G12="UV5-312.50",
    H1="IW-0.44",
    H2="blank",
    H3="IW-1.88",
    H4="blank",
    H5="IW-8.13",
    H6="blank",
    H7="IW-35.17",
    H8="blank",
    H9="IW-152.19",
    H10="blank",
    H11="IW-312.50",
    H12="blank",
)

Tidy data¶

To more easily work with this data set, we will put it in tidy format, where each row in the data frame is a single absorbance measurement and each column is a property associated with that measurement, such as time, time stamp, temperature, well, well metadata, and, of course absorbance. The function below converts the data to tidy format given the well metadata.

In [5]:
def conc(well, well_metadata):
    """Return abx concentration for a given well"""
    strain_conc = well_metadata[well]
    if strain_conc == "blank":
        return 0.0
    else:
        ind = strain_conc.find("-")
        return float(strain_conc[ind + 1 :])


def strain(well, well_metadata):
    """Return strain for a given well"""
    strain_conc = well_metadata[well]
    if strain_conc == "blank":
        return "blank"
    else:
        ind = strain_conc.find("-")
        return strain_conc[:ind]


def tidy(df, well_metadata, group):
    """Convert data frame to tidy format."""
    # Convert to tall format
    df_tidy = df.melt(
        id_vars=["Time", "temperature (deg C)", "time (s)", "time (hr)"],
        var_name="well",
        value_name="absorbance",
    )

    # Add metadata columns
    df_tidy["conc"] = df_tidy["well"].apply(conc, args=(well_metadata,))
    df_tidy["strain"] = df_tidy["well"].apply(strain, args=(well_metadata,))
    df_tidy["group"] = group

    df_tidy = df_tidy.sort_values(
        by=["group", "strain", "conc", "well", "time (hr)"], ignore_index=True
    )

    return df_tidy

We can put it to use to tidy our data frame.

In [6]:
dfA = tidy(dfA, well_metadata_A, "A")

# Take a look
dfA.head()
Out[6]:
Time temperature (deg C) time (s) time (hr) well absorbance conc strain group
0 0:05:10 37.0 0.0 0.0 B1 0.085 0.0 3.19 A
1 0:11:10 37.1 360.0 0.1 B1 0.085 0.0 3.19 A
2 0:17:10 37.1 720.0 0.2 B1 0.085 0.0 3.19 A
3 0:23:10 37.0 1080.0 0.3 B1 0.085 0.0 3.19 A
4 0:29:10 37.0 1440.0 0.4 B1 0.085 0.0 3.19 A

For simplicity, I have labeled the antibiotic concentration column "conc", but recall that the concentration is in units of µM.

Checking the blanks¶

As before, we can check the blanks. To do this, we first pull out the part of the data frame that contains blanks. We then use Pandas's convenient groupby operation to group the data frame by well. We can iterate over the GroupBy object with a for loop, which gives us a group name (which will be the well in this case) and a sub-data frame that has just the data for that well. We can then add the trace of that blank to the plot.

In [7]:
p = bokeh.plotting.figure(
    frame_width=350,
    frame_height=200,
    x_axis_label='time (hr)',
    y_axis_label='absorbance'
)

# Pull out only entries for blanks
dfA_blank = dfA.loc[dfA['strain']=='blank', :]

# Groupby and iterate, adding to plot
for group, sub_df in dfA_blank.groupby('well'):
    p.line(sub_df['time (hr)'], sub_df['absorbance'])

bokeh.io.show(p)

The blanks are all really consistent. We can therefore define our blank concentration to be the average of all blank measurement.

In [8]:
blank_absorbance = dfA_blank['absorbance'].mean()

We can now add a column to the data frame for the OD600, which is the background substracted absorbance.

In [9]:
dfA['OD600'] = dfA['absorbance'] - blank_absorbance

Make plots¶

We can now use this kind of groupby structure to make all kinds of plots. For example, we can plot growth curves for each strain without antibiotic.

In [10]:
p = bokeh.plotting.figure(
    frame_width=350,
    frame_height=200,
    x_axis_label="time (hr)",
    y_axis_label="OD600",
    y_axis_type="log",
    toolbar_location="above",
)

# Color scheme
strains = ["MG", "3.19", "IW", "WT", "UV5"]
colors = {strain: color for strain, color in zip(strains, bokeh.palettes.Category10_5)}

# Pull out only entries for zero antibiotic concentration (and not blanks)
inds = (dfA["conc"] == 0.0) & (dfA["strain"] != "blank")
dfA_0abx = dfA.loc[inds, :]

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

# Groupby and iterate, adding to plot
for strain, sub_df in dfA_0abx.groupby("strain"):
    # Lines for a given strain
    lines = []

    # Get each well for the strain and add a line to the plot
    for well, subsub_df in sub_df.groupby("well"):
        lines.append(
            p.line(subsub_df["time (hr)"], subsub_df["OD600"], color=colors[strain])
        )

    # Add the lines to the legend
    legend_items.append((strain, 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)

We could also plot the growth for all concentrations of tetracycline for the MG1655 strain.

In [11]:
p = bokeh.plotting.figure(
    frame_width=350,
    frame_height=200,
    x_axis_label="time (hr)",
    y_axis_label="OD600",
    # y_axis_type="log",
    toolbar_location="above",
)

# Color scheme
concs = np.sort(dfA['conc'].unique())
colors = {conc: color for conc, color in zip(concs, bokeh.palettes.Blues9)}

# Pull out only entries for MG1655
dfA_MG = dfA.loc[dfA['strain'] == 'MG', :]

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

# Groupby and iterate, adding to plot
for conc, sub_df in dfA_MG.groupby("conc"):
    # Lines for a given strain
    lines = []

    # Get each well for the strain and add a line to the plot
    for well, subsub_df in sub_df.groupby("well"):
        lines.append(
            p.line(subsub_df["time (hr)"], subsub_df["OD600"], color=colors[conc])
        )

    # Add the lines to the legend
    legend_items.append((str(conc), 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)

Apparently, above an antibiotic concentration of 0.9, the cells do not grow.

Obtaining growth rates¶

We can use the groupby operations to perform curve fits as well. We need to modify our fit_growth() function from the E. coli growth module for use here to simply return the growth rate (we do not care about the initial absorbance).

In [12]:
def exp_growth(t, A0, lam):
    return A0 * np.exp(lam * t)


def fit_growth(df, t_start, t_end):
    """Obtain estimates for A0 and λ for a given sub-trace"""
    # If no time window, return zero
    if t_start == t_end:
        return 0.0
        
    # Parameter guesses
    A0_guess = 0.01
    lam_guess = 1.0  # 1/hr

    # Pull out data
    sub_df = df.loc[(df["time (hr)"] >= t_start) & (df["time (hr)"] <= t_end), :]
    t = sub_df["time (hr)"].values
    A = sub_df['OD600'].values

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

    # Just return growth rate 
    return popt[1]

Now that we have our curve fitting function in hand, we can, for example, get growth rates for each strain in the absence of antibiotic (an important calculation; these are the λ0λ0 values). It turns out that Pandas GroupBy objects are really slick for doing this automatically using the apply() method. We pass in the function we want to call, and then specify other arguments to the function.

Looking at the plot, the exponential growth phase seems to be between 6 and 10 hours.

In [13]:
# Pull out only entries for zero antibiotic concentration (and not blanks)
inds = (dfA["conc"] == 0.0) & (dfA["strain"] != "blank")
dfA_0abx = dfA.loc[inds, :]

# Include include_groups=False to silence deprecation warning
# reset_index() to convert to dataframe
growth_rates_0abx = (
    dfA_0abx.groupby(["strain", "well"])
    .apply(fit_growth, t_start=6, t_end=10, include_groups=False)
    .reset_index(name='growth rate (1/hr)')
)

# Take a look
growth_rates_0abx
Out[13]:
strain well growth rate (1/hr)
0 3.19 B1 0.506579
1 3.19 B11 0.439572
2 3.19 B3 0.565789
3 3.19 B5 0.545540
4 3.19 B7 0.574230
5 3.19 B9 0.586322
6 IW C1 0.531716
7 IW C11 0.447853
8 IW C3 0.533252
9 IW C5 0.537041
10 IW C7 0.535596
11 IW C9 0.479817
12 MG A1 0.512562
13 MG A11 0.515497
14 MG A3 0.487880
15 MG A5 0.497014
16 MG A7 0.471504
17 MG A9 0.491036
18 UV5 E1 0.527472
19 UV5 E11 0.516805
20 UV5 E3 0.514496
21 UV5 E5 0.502841
22 UV5 E7 0.499688
23 UV5 E9 0.463137
24 WT D1 0.509833
25 WT D11 0.458630
26 WT D3 0.499100
27 WT D5 0.479537
28 WT D7 0.488544
29 WT D9 0.492815

We now have growth rates for each curve without antibiotics! We can make a plot of these to make sure there are no outliers.

In [14]:
bokeh.io.show(iqplot.strip(growth_rates_0abx, q='growth rate (1/hr)', cats='strain'))

The IW and 3.19 strains both have two growth rates that are low, but they are only off by about 20%. We can take λ0λ0 to be the median of our measurements (the median being immune to outliers) to get our λ0λ0 values.

In [15]:
lam0 = (
    growth_rates_0abx.groupby("strain")["growth rate (1/hr)"]
    .apply(np.median)
)

# Take a look
lam0
Out[15]:
strain
3.19    0.555665
IW      0.532484
MG      0.494025
UV5     0.508669
WT      0.490680
Name: growth rate (1/hr), dtype: float64

We can similarly obtain growth rates for all concentrations of antibiotics in the MG1655 strain. We need to refer to the plot to give time windows. For tetracycline concentration above 1 µM, we saw no growth, so we do not need to fit those, and we can put [0, 0] for the time window. We have already updated the fitting function to return zero for the growth rate if there is a time window of zero width.

In [16]:
# Pull out only entries MG1655 with nonzero ABX concentration
dfA_MG = dfA.loc[(dfA["strain"] == "MG") & (dfA['conc'] > 0), :]

# Time windows based on plots
time_windows = {
    0.21: [6, 10.5],
    0.9: [14, 17],
    3.9: [0, 0],
    16.9: [0, 0],
    73.15: [0, 0],
    208.33: [0, 0],
}

# Compute curve fits (have to loop this time because we need time windows)
# Instantiate dictionary with results
growth_rates_MG = {'conc': [], 'well': [], 'growth rate (1/hr)': []}

# Loop through groupby object to do curve fits
for (conc, well), sub_df in dfA_MG.groupby(['conc', 'well']):
    # Update concentration and well
    growth_rates_MG['conc'].append(conc)
    growth_rates_MG['well'].append(well)

    # Obtain growth rate
    lam = fit_growth(sub_df, t_start=time_windows[conc][0], t_end=time_windows[conc][1])
    growth_rates_MG['growth rate (1/hr)'].append(lam)

# Convert to data frame
growth_rates_MG = pd.DataFrame(growth_rates_MG)

# Take a look
growth_rates_MG
Out[16]:
conc well growth rate (1/hr)
0 0.21 C2 0.383576
1 0.21 F1 0.428159
2 0.90 C4 0.242548
3 0.90 F3 0.180286
4 3.90 C6 0.000000
5 3.90 F5 0.000000
6 16.90 C8 0.000000
7 16.90 F7 0.000000
8 73.15 C10 0.000000
9 73.15 F9 0.000000
10 208.33 C12 0.000000
11 208.33 F11 0.000000

We can then compute the growth rate as the average.

In [17]:
lam_MG = growth_rates_MG.groupby('conc')['growth rate (1/hr)'].mean().reset_index()

# Take a look
lam_MG
Out[17]:
conc growth rate (1/hr)
0 0.21 0.405868
1 0.90 0.211417
2 3.90 0.000000
3 16.90 0.000000
4 73.15 0.000000
5 208.33 0.000000

Finally, to get the relative growth rate, we divide by λ0λ0.

In [18]:
lam_MG['relative growth rate'] = lam_MG['growth rate (1/hr)'] / lam0['MG']

# Take a look
lam_MG
Out[18]:
conc growth rate (1/hr) relative growth rate
0 0.21 0.405868 0.821553
1 0.90 0.211417 0.427947
2 3.90 0.000000 0.000000
3 16.90 0.000000 0.000000
4 73.15 0.000000 0.000000
5 208.33 0.000000 0.000000

We can do similar analysis for all of the other strains to get our growth rates.

Theoretical model for growth rate as a function of abx concentration¶

In the handout associated with the experiment, we worked out a theoretical model for growth rate as a function of external antibiotic concentration aexaex. To solve for the growth rate, we have to find the solution to the dimensionless equation

0=(K+~qmaxex,0−1)~λ3+(2−(1−K)(~Pin+~Pout)−(1−κ)(~qmaxex,0+K))~λ2−(1+κ(~qmaxex,0+K)−(2−(1−κ)K)~Pout−(1−(1−2K)κ)~Pin~aex)~λ+(1+κK)(κ~Pin~aex−~Pout),(1)0=(K+q~ex,0max−1)λ~3+(2−(1−K)(P~in+P~out)−(1−κ)(q~ex,0max+K))λ~2−(1+κ(q~ex,0max+K)−(2−(1−κ)K)P~out−(1−(1−2K)κ)P~ina~ex)λ~(1)+(1+κK)(κP~ina~ex−P~out),

as derived in the handout. Upon finding the solution, we can get the dimensional growth rate by multiplying by λ0λ0.

By playing with parameters, the above cubic equation may have zero, one, two, or three roots on the interval 0≤λ≤10≤λ≤1. Though I will not derive it here, we can show that the following are true.

  • Zero roots: In this situation there is no growth and bacteria die.
  • One root: The value of λλ corresponding to this root represents a stable growth rate.
  • Two roots: The greater of the two roots is stable and the lesser is unstable. There can be a population of cells growing at a rate given by the greater of the two roots and another population that is not growing.
  • Three roots: The minimal and maximal roots are stable; the middle one is unstable. There is a population of cells growing at the faster rate and one growing at a slower rate.

In the case of two or three roots, the population growing at the faster rate will overwhelm the slow growers and will be the population we can see growing in the experiment. Therefore, our task is to find the largest root of the above cubic equation that is less than one. If no such root exists, the growth rate is zero.

Fortunately, Numpy has a built in function for finding roots of polynomials, np.polynomial.Polynomial.roots(). We can use this to find all three roots of the above cubic equation. The functions below accomplish this. Note that the default values for parameters known from the literature are included in the growth_rate() function.

In [19]:
def solve_growth_rate_dimensionless(
    aex, kappa, K, qmax, Pin, Pout, return_all_roots=False
):
    """Solve for the growth rate for a single antibiotic
    concentation in dimensionless units. All parameters
    are dimensionless. If `return_all_roots` is True,
    returns all physically viable steady state growth rates.
    Otherwise, only the single stable growth rate is returned.
    """
    # No antibiotic: max growth rate
    if aex == 0.0:
        lam = np.array([1.0])
    else:
        # Coefficients for cubic polynomial
        a3 = K + qmax - 1
        a2 = 2 - (1 - K) * (Pin * aex + Pout) - (1 - kappa) * (qmax + K)
        a1 = (
            -1
            - kappa * (qmax + K)
            + (2 - (1 - kappa) * K) * Pout
            + (1 - (1 - 2 * K) * kappa) * Pin * aex
        )
        a0 = (1 + kappa * K) * (kappa * Pin * aex - Pout)

        # Set up the cubic polynomial
        cubic = np.polynomial.Polynomial([a0, a1, a2, a3])

        # Solve the cubic
        lam = np.sort([r for r in cubic.roots() if np.isreal(r) and 0 <= r <= 1])

    # No roots, return 0
    if len(lam) == 0:
        return 0.0

    if return_all_roots:
        return lam if len(lam) > 1 else lam[0]
    else:
        return lam[-1]


def growth_rate(
    aex,            # µM
    qmax,           # µM/hr
    lam0,           # 1/hr
    rmin=19.3,      # µM
    kappa_t=0.061,  # 1/µM
    Pin=2.85,       # 1/hr
    Pout=2.85,      # 1/hr
    Kd=1.0,         # µM
    KM=10.0,        # µM
):
    """Solve for the growth rate for antibiotic concentration, or
    array of antibiotic concentrations, aex. Assumes all
    concentration units are µM and all time units are hours.
    """
    # Nondimensionalize
    kappa = kappa_t * rmin / lam0
    K = KM / Kd
    qmax = qmax / Kd / lam0
    Pin = Pin / lam0
    Pout = Pout / lam0
    aex = aex / Kd

    if np.isscalar(aex):
        result = solve_growth_rate_dimensionless(
            aex, kappa, K, qmax, Pin, Pout, return_all_roots=False
        )
    else:
        result = [
            solve_growth_rate_dimensionless(
                aex_val, kappa, K, qmax, Pin, Pout, return_all_roots=False
            )
            for aex_val in aex
        ]
        result = np.array(result)

    # Return dimensional growth rate
    return result * lam0

As an example, we can compute the growth rate for qmaxex,0=500qex,0max=500 with λ0=0.5λ0=0.5.

In [20]:
aex = np.linspace(0, 2, 2000)
lam = growth_rate(aex, 500, 0.5)

p = bokeh.plotting.figure(
    frame_width=350,
    frame_height=200,
    x_axis_label='aex (µM)',
    y_axis_label='λ  (1/hr)',
    x_range=[0, 2],
)

p.line(aex, lam, line_width=2)

bokeh.io.show(p)

The growth_rate() function is useful when doing a regression to find estimates for qmaxex,0qex,0max for each stain using the λ/λ0λ/λ0 versus aexaex curves you obtain in this experiment. The function you will use in scipy.optimize.curve_fit() is shown below.

In [21]:
def fit_function(aex, qmax):
    """lam0 has to be externally defined as the abx-free growth rate."""
    return growth_rate(aex, qmax, lam0)