EsaR Template Competition

Analyze time-series data from platereader experiments.

EsaR Template Competition

try:
    import cdk
    print("CDK already installed, skipping installation and restart.")
except ImportError:
    !pip install nucleus-cdk==0.6.2 -q > /dev/null 2>&1
    print("\nInstallation complete. Restarting runtime to refresh dependencies...")
    import os
    os._exit(0)
CDK already installed, skipping installation and restart.

import pandas as pd
pd.set_option('display.max_columns', None)
import seaborn as sns
import matplotlib.pyplot as plt

# Import the cdk platereader module
from cdk.instruments import platereader as pr

from cdk import logging
log = logging.setup_logging(logging.ERROR)

Load Data

%%writefile drive_utils.py
import os
import re

import gdown


def get_drive_file_id(url):
    """Extract the file ID from a Google Drive share URL."""
    match = re.search(r'/d/([a-zA-Z0-9_-]+)', url)
    if match:
        return match.group(1)
    return None


def download_from_drive(files_info, destination_dir):
    """
    Download files from Google Drive.

    Args:
        files_info (list): List of dicts with 'url' and 'filename'.
        destination_dir (str): Local path to save files.
    Returns:
        dict: Mapping of filenames to local paths.
    """
    os.makedirs(destination_dir, exist_ok=True)

    paths = {}
    for item in files_info:
        url = item['url']
        filename = item['filename']
        dest = os.path.join(destination_dir, filename)
        file_id = get_drive_file_id(url)

        if file_id:
            gdown.download(id=file_id, output=dest, quiet=True)
        else:
            gdown.download(url, output=dest, quiet=True)

        paths[filename] = dest
    return paths
Writing drive_utils.py
# Set use_drive = True to download data_file and platemap_file from Google Drive instead of
# using local files. Each Drive file must have its permissions set to "Anyone with a link" can "view".
use_drive = True

if use_drive:
    from drive_utils import download_from_drive

    files_to_download = [
        {
            "url": "https://drive.google.com/file/d/1IAiFH6N9sMag7jcdwwvWGr_mTqOz8mAZ/view?usp=share_link",  # Replace with your data file link.
            "filename": "raw_data.txt",
        },
        {
            "url": "https://drive.google.com/file/d/1RrYeBHgGtMEkSOTjsFj5hGUZhyayg7aJ/view?usp=share_link",  # Replace with your platemap file link.
            "filename": "platemap.csv",
        },
    ]
    downloaded_paths = download_from_drive(files_to_download, destination_dir="data")
    data_file = downloaded_paths["raw_data.txt"]
    platemap_file = downloaded_paths["platemap.csv"]
else:
    data_file = "sample-data/20251111-122213-cytation5-pure-timecourse-gfp-MFG-98-tRNA-QC.txt"
    platemap_file = "sample-data/platemap.csv"

print(f"Data file: {data_file}\nPlatemap file: {platemap_file}")
Data file: data/raw_data.txt
Platemap file: data/platemap.csv
# Load data
result = pr.load_platereader_data(
    data_file=data_file,
    platemap_file=platemap_file,
    platereader="biotek" # options: "biotek"
)

The output is a list of PlateReaderResult objects; if you did more than one read on the plate reader, the results will be in separate objects.

print(result)
PlateReaderResult with the following 2 blocks:
0: (876, 40) kinetic read with reads: GFP-G70:485,528 (Fluorescence) (Plate 'Plate 1')
1: (876, 40) kinetic read with reads: GFP-Gext:485,528 (Fluorescence) (Plate 'Plate 1')

Here there were two reads with different gains but the same excitation/emission spectrum, on one plate. If we want just the GFP-Gext read, this corresponds to block index 1, so we can extract that block by indexing:

desired_index = 0
data = result[desired_index]

You can see the underlying data with data.view() (which returns a Pandas DataFrame):

data.view().head()
Loading...

Plot Raw Curves

Normalized DataFrame:
Loading...
<Figure size 800x500 with 1 Axes>
data.plot(style='Type')
<seaborn.axisgrid.FacetGrid at 0x7a83d189cf50>
<Figure size 848.125x500 with 1 Axes>
data_01nM = data.select(name=["EsaO-mNG (0.1 nM) - AHSL", "EsaO-mNG (0.1 nM) + AHSL"])


df_01nM = data_01nM.data


df_01nM['Time_hrs'] = df_01nM['Time'].dt.total_seconds() / 3600

sns.lineplot(df_01nM, x="Time_hrs", y='Data', hue="Name")

plt.xlabel("Time (hrs)")
plt.ylabel("RFU")

plt.title("0.1 nM [Esao]2-mNG DNA titration w/ EsaR pre-incubated 37˚C, 1 hr")

plt.legend(frameon=False)


sns.despine()
plt.tight_layout()
plt.show()
<Figure size 640x480 with 1 Axes>
data_01nM_EsaR = data.select(name=["EsaO-mNG (0.1 nM) + EsaR (8 nM) + AHSL", "EsaO-mNG (0.1 nM) + EsaR (8 nM) - AHSL"])


df_01nM_EsaR = data_01nM_EsaR.data


df_01nM_EsaR['Time_hrs'] = df_01nM_EsaR['Time'].dt.total_seconds() / 3600

sns.lineplot(df_01nM_EsaR, x="Time_hrs", y='Data', hue="Name")

plt.xlabel("Time (hrs)")
plt.ylabel("RFU")

plt.title("0.1 nM [Esao]2-mNG DNA + EsaR DNA (8 nM) titration w/ EsaR pre-incubated 37˚C, 1 hr")

plt.legend(frameon=False)


sns.despine()
plt.tight_layout()
plt.show()
<Figure size 640x480 with 1 Axes>
# --- text sizes (raise these to make everything larger) ---
plt.rcParams.update({
    "font.size": 14,           # base size
    "axes.labelsize": 16,      # x / y axis labels
    "axes.titlesize": 16,      # panel titles
    "xtick.labelsize": 14,
    "ytick.labelsize": 14,
    "legend.fontsize": 13,
    "lines.linewidth": 2.5,    # thicker traces to match
})

data_01nM = data.select(name=["EsaO-mNG (0.1 nM) - AHSL", "EsaO-mNG (0.1 nM) + AHSL"])
data_01nM_EsaR = data.select(name=["EsaO-mNG (0.1 nM) + EsaR (8 nM) + AHSL", "EsaO-mNG (0.1 nM) + EsaR (8 nM) - AHSL"])

df_01nM = data_01nM.data
df_01nM_EsaR = data_01nM_EsaR.data

df_01nM['Time_hrs'] = df_01nM['Time'].dt.total_seconds() / 3600
df_01nM_EsaR['Time_hrs'] = df_01nM_EsaR['Time'].dt.total_seconds() / 3600

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6), sharey=True)

# --- Left: no EsaR ---
sns.lineplot(df_01nM, x="Time_hrs", y='Data', hue="Name", ax=ax1,
             hue_order=["EsaO-mNG (0.1 nM) - AHSL", "EsaO-mNG (0.1 nM) + AHSL"])
ax1.set_title("0.1 nM [Esao]2-mNG DNA")
ax1.set(xlabel="Time (hrs)", ylabel="RFU")
ax1.legend(frameon=False)

# --- Right: + EsaR ---
sns.lineplot(df_01nM_EsaR, x="Time_hrs", y='Data', hue="Name", ax=ax2,
             hue_order=["EsaO-mNG (0.1 nM) + EsaR (8 nM) - AHSL", "EsaO-mNG (0.1 nM) + EsaR (8 nM) + AHSL"])
ax2.set_title("0.1 nM [Esao]2-mNG DNA + EsaR DNA (8 nM)")
ax2.set(xlabel="Time (hrs)", ylabel="")
ax2.legend(frameon=False)

sns.despine()
plt.tight_layout()
plt.show()
<Figure size 1400x600 with 2 Axes>
df_01nM_EsaR.head()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
/tmp/ipykernel_975/4254841565.py in <cell line: 0>()
----> 1 df_01nM_EsaR.head()

NameError: name 'df_01nM_EsaR' is not defined
def steady_state_fold_change(df, base_name, ahsl_name, n_last=10):
    last_n = (df.sort_values('Time_hrs')
                .groupby(['Name', 'Well'])
                .tail(n_last))
    ss = last_n.groupby('Name')['Data'].mean()
    return ss[ahsl_name] / ss[base_name]

# --- compute ---
fc_no_EsaR = steady_state_fold_change(
    df_01nM,
    "EsaO-mNG (0.1 nM) - AHSL",
    "EsaO-mNG (0.1 nM) + AHSL",
)

fc_EsaR = steady_state_fold_change(
    df_01nM_EsaR,
    "EsaO-mNG (0.1 nM) + EsaR (8 nM) - AHSL",
    "EsaO-mNG (0.1 nM) + EsaR (8 nM) + AHSL",
)

# --- tidy into one summary table ---
fold_summary = pd.DataFrame({
    'DNA_conc_nM': [0.1, 0.1],
    'Construct':   ['EsaO-mNG (0.1 nM)', 'EsaO-mNG (0.1 nM) + EsaR (8 nM)'],
    'Fold_change': [fc_no_EsaR, fc_EsaR],
})
print(fold_summary)
   DNA_conc_nM                        Construct  Fold_change
0          0.1                EsaO-mNG (0.1 nM)     2.799152
1          0.1  EsaO-mNG (0.1 nM) + EsaR (8 nM)     3.757270
fold_summary['Condition'] = ['− EsaR DNA', '+ EsaR DNA (8 nM)']

fig, ax = plt.subplots(figsize=(5, 5))

sns.barplot(data=fold_summary, x='Condition', y='Fold_change',
            color='tab:orange', ax=ax)

ax.set(xlabel='', ylabel='Fold change (+AHSL / −AHSL)')
ax.set_title('Steady-state AHSL fold induction\n0.1 nM [EsaO]2-mNG DNA')
ax.set_ylim(1, 4)

sns.despine(fig)
fig.tight_layout()
plt.show()
<Figure size 500x500 with 1 Axes>
N_LAST_READS = 4   # steady state = mean of each well's last 4 reads

def steady_state_per_well(df, name):
    sub = df[df['Name'] == name].sort_values('Time_hrs')
    return sub.groupby('Well').tail(N_LAST_READS).groupby('Well')['Data'].mean()

def fold_change_wells(df, base_name, ahsl_name, condition):
    base_mean = steady_state_per_well(df, base_name).mean()   # mean of the -AHSL wells
    ahsl = steady_state_per_well(df, ahsl_name)               # one value per +AHSL well
    return pd.DataFrame({'Condition': condition, 'Fold_change': ahsl / base_mean})

fold_no_EsaR = fold_change_wells(
    df_01nM,
    "EsaO-mNG (0.1 nM) - AHSL",
    "EsaO-mNG (0.1 nM) + AHSL",
    '− EsaR DNA',
)

fold_EsaR = fold_change_wells(
    df_01nM_EsaR,
    "EsaO-mNG (0.1 nM) + EsaR (8 nM) - AHSL",
    "EsaO-mNG (0.1 nM) + EsaR (8 nM) + AHSL",
    '+ EsaR DNA (8 nM)',
)

fold_wells = pd.concat([fold_no_EsaR, fold_EsaR], ignore_index=True)

fig, ax = plt.subplots(figsize=(5, 5))

sns.barplot(data=fold_wells, x='Condition', y='Fold_change', color='tab:orange',
            errorbar='sd', capsize=0.15, err_kws={'linewidth': 1}, ax=ax)
sns.stripplot(data=fold_wells, x='Condition', y='Fold_change', color='black',
              size=4, ax=ax)

ax.set(xlabel='', ylabel='RFU Fold change (+AHSL / −AHSL)')
ax.set_title('EsaR template competition')
ax.set_ylim(1, 4)

sns.despine(fig)
fig.tight_layout()
plt.show()
<Figure size 500x500 with 1 Axes>
import numpy as np
import matplotlib.pyplot as plt

# Replicate measurements
no_ahsl = np.array([3670, 3370, 3186])
ahsl_5uM = np.array([18149, 19992, 21139])

labels = ["− AHSL", "+ AHSL"]
colors = ["tab:blue", "tab:orange"]   # − AHSL blue, + AHSL orange (as in the other plots)
groups = [no_ahsl, ahsl_5uM]          # renamed from `data` so it can't overwrite your plate reader object
means = [g.mean() for g in groups]
sds = [g.std(ddof=1) for g in groups]   # sample standard deviation

fig, ax = plt.subplots(figsize=(4, 5))
x = np.arange(len(labels))
ax.bar(x, means, yerr=sds, capsize=6, width=0.6, color=colors,
       error_kw={"linewidth": 1})

# Overlay individual replicate points
rng = np.random.default_rng(0)
for i, g in enumerate(groups):
    jitter = rng.uniform(-0.1, 0.1, size=len(g))
    ax.scatter(x[i] + jitter, g, color="black", s=16, zorder=3)

# Compute fold change (+ AHSL relative to − AHSL)
fc = means[1] / means[0]
#print(f"Fold change (+ AHSL / − AHSL): {fc:.2f}")

ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.set_ylabel("RFU (Background corrected)")
ax.spines[["top", "right"]].set_visible(False)
ax.set_title("0.1 nM [Esao]2-mNG DNA ± 5 µM AHSL (O/N,25°C)", fontsize=13)

plt.tight_layout()
plt.show()
<Figure size 400x500 with 1 Axes>

We can see that the standard (here, HPTS) is stable and can be safely used for normalization.

Normalize Data

Now, use data.normalize('<standard name>') to normalize your data. This will calculate the average over a time window at the end of the experiment (by default, 1 hour), then divide all data by the mean of that window-average across all wells with the same Name (defined by your platemap).

# To check the exact name of your standard
platemap = data.platemap
standards = platemap[platemap['Type']=='Standard']
standards['Name'].unique()
array(['0 uM Fluorescein', '0.5 uM Fluorescein', '1 uM Fluorescein', '2 uM Fluorescein'], dtype=object)
data = data.normalize('10 uM HPTS')

Now replot your curves to see them normalized:

g = data.plot(style='Type', exclude_types=['Standard'])
# g.savefig('after-normalization.png',dpi=300)
<Figure size 735.75x500 with 1 Axes>

If you want to change the time window over which you calculate the average, you can use the window argument:

# data = data.normalize('10 uM HPTS', window=pd.Timedelta("2.5h"))

Kinetic Analysis

See the DevNote on kinetic analysis for more details.

Metrics extracted:

  • Maximum velocity: Maximum rate of fluorescence increase (slope at inflection point)

  • Lag time: Time to reach the exponential phase

  • Steady-state: Final fluorescence level

  • Time-to-completion: Time it takes to reach 95% of asymptote

  • Drift: Rate of signal decay or increase after steady-state

  • R²: Goodness of fit; “Good Fit” is True if R2≥0.95R^2 \geq 0.95

# Perform kinetic analysis using sigmoid_drift model
kinetics = data.fit_kinetics()
[07/16/26 17:29:20] INFO     Calculating Kinetics                analysis.py:223
kinetics.summary
Loading...

Visualize Fits

g = kinetics.plot(col="Well")
# g = kinetics.plot() # call this to visualize across replicates
<Figure size 1800x2400 with 18 Axes>

Summary Plots

g = kinetics.plot_summary()
<Figure size 6000x1500 with 4 Axes>

Key Metrics Explained

1. Steady-State Level (Steady State, Data)

  • The final fluorescence value reached by the reaction

  • Represents the total amount of protein produced

  • Higher values indicate greater expression yield

2. Maximum Velocity (Velocity, Max)

  • The steepest slope of the fluorescence curve (at the inflection point)

  • Units: RFU per second

  • Reflects the peak rate of protein synthesis

  • Sensitive to enzyme activity, substrate availability, and reaction conditions

3. Lag Time (Lag, Time)

  • Time before exponential fluorescence increase begins

  • May reflect time for ribosome assembly or initial translation steps

  • Shorter lag times suggest faster reaction initiation

4. Drift (Fit, drift)

  • Rate of fluorescence change after reaching steady-state

  • Positive drift: continued synthesis or aggregation

  • Negative drift: photobleaching, protein degradation, or quenching

  • Units: RFU per second

5. R² Value (Fit, R^2)

  • Goodness of fit (0 to 1, higher is better)

  • R² > 0.98 indicates excellent fit

  • Poor fits may indicate noisy data, overflow errors, or non-sigmoid kinetics


Tips and Troubleshooting

  • Overflow errors: Wells with OVRFLW or NaN values are automatically excluded from fitting

  • Poor fits (low R²): Inspect raw curves for anomalies (bubbles, evaporation, pipetting errors)

  • Drift: Sometimes seen in kinetics curves; use sigmoid_drift model

  • Multiple replicates: Always include technical replicates and report error bars

  • Comparing conditions: Normalize or blank data consistently across all samples


Next Steps

  • Export kinetics results: pr.export_kinetics(kinetics, 'results.csv')

  • Statistical analysis: Use scipy.stats or statsmodels for ANOVA/t-tests

  • Parameter optimization: Vary Mg²⁺, K⁺, or other conditions to maximize Vmax or steady-state

  • Mechanistic modeling: Fit ODE models to extract biological rate constants