Setup

Analyze time-series data from platereader experiments.

Setup

try:
    import cdk
    print("CDK already installed, skipping installation and restart.")
except ImportError:
    !pip install nucleus-cdk -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
Overwriting 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://data.nucleus.engineering/platereader/devstudio/20260930-164151-synergy2-luv-gel-OJ.txt",  # Replace with your data file link.
            "filename": "luvgel01_data.txt",

        },
         {
            "url": "https://data.nucleus.engineering/platereader/devstudio/20260930-164151-synergy2-luv-gel-OJ-Day2.txt",  # Replace with your data file link.
            "filename": "luvgel02_data.txt",

        },
         {
            "url": "https://data.nucleus.engineering/platereader/devstudio/20260930-164151-synergy2-luv-gel-OJ-Day2-afterPLA.txt",  # Replace with your data file link.
            "filename": "luvgel02PLA_data.txt",

        },

        {
            "url": "https://drive.google.com/file/d/1FaAVsLXBhn7geS1sKbb9aXfyPDG0_XDb/view?usp=sharing",  # Replace with your platemap file link.
            "filename": "platemap.csv",
        },
    ]
    downloaded_paths = download_from_drive(files_to_download, destination_dir="data")
    data_files = [downloaded_paths[key] for key in downloaded_paths.keys() if key != "platemap.csv"]
    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 files: {data_files}\nPlatemap file: {platemap_file}")
Data files: ['data/luvgel01_data.txt', 'data/luvgel02_data.txt', 'data/luvgel02PLA_data.txt']
Platemap file: data/platemap.csv
pd.read_csv(platemap_file).columns
Index(['Date', 'Experiment Name', 'Well', 'Name', 'Type'], dtype='object')
# Load data
results = {
    data_file: pr.load_platereader_data(
      data_file=data_file,
      platemap_file=platemap_file,
      platereader="biotek" # options: "biotek"
  ) for data_file in data_files
}

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(results)
{'data/luvgel01_data.txt': PlateReaderResult with the following 1 blocks:
0: (10, 22) endpoint read with reads: CPRG/CPR:412 (Absorbance), CPRG/CPR:570 (Absorbance) (Plate 'Plate 1'), 'data/luvgel02_data.txt': PlateReaderResult with the following 1 blocks:
0: (10, 22) endpoint read with reads: CPRG:412 (Absorbance), CPRG:570 (Absorbance) (Plate 'Plate 1'), 'data/luvgel02PLA_data.txt': PlateReaderResult with the following 1 blocks:
0: (14, 22) endpoint read with reads: CPRG/CPR:412 (Absorbance), CPRG/CPR:570 (Absorbance) (Plate 'Plate 1')}
res = results["data/luvgel01_data.txt"]
res[0].view().head()
Loading...

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 = {
    k: v[desired_index]
    for k, v in results.items()
}
data
{'data/luvgel01_data.txt': (10, 22) endpoint read with reads: CPRG/CPR:412 (Absorbance), CPRG/CPR:570 (Absorbance), 'data/luvgel02_data.txt': (10, 22) endpoint read with reads: CPRG:412 (Absorbance), CPRG:570 (Absorbance), 'data/luvgel02PLA_data.txt': (14, 22) endpoint read with reads: CPRG/CPR:412 (Absorbance), CPRG/CPR:570 (Absorbance)}

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

data['data/luvgel01_data.txt'].view().head()
Loading...

Plot Raw Curves

g = data['data/luvgel01_data.txt'].plot()
g.ax.set_xticklabels(g.ax.get_xticklabels(), rotation=45, ha='right')
/tmp/ipykernel_2732/3343472465.py:2: UserWarning: set_ticklabels() should only be used with a fixed number of ticks, i.e. after set_ticks() or using a FixedLocator.
  g.ax.set_xticklabels(g.ax.get_xticklabels(), rotation=45, ha='right')
[Text(0, 0, 'LUV+buffer'), Text(1, 0, 'LUV+PEG_gel'), Text(2, 0, 'LUV+PEG_pregel'), Text(3, 0, 'LUV+ULGA'), Text(4, 0, 'LUV+LGA')]
<Figure size 500x500 with 1 Axes>
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

# Consolidate all data into a single DataFrame
all_data_frames = []
for key, result_obj in data.items():
    df = result_obj.view().copy()
    df['Experiment'] = key
    all_data_frames.append(df)

combined_df = pd.concat(all_data_frames, ignore_index=True)

# Standardize 'Read' values so colours are consistent across subplots
combined_df['Read'] = combined_df['Read'].replace({
    'CPRG:412': 'CPRG/CPR:412',
    'CPRG:570': 'CPRG/CPR:570'
})

g = sns.catplot(
    data=combined_df,
    x='Name',
    y='Data',
    hue='Read',
    col='Experiment',
    kind='bar',
    col_wrap=2,
    height=6, aspect=1.6,
    sharex=False,      # stops seaborn hiding x labels/ticks on upper rows
    sharey=True,
    dodge=True
)

g.set_titles("{col_name}")

# Set labels on every subplot, including the top row
for ax in g.axes.flat:
    ax.set_xlabel("Types of gels", labelpad=8)
    ax.set_ylabel("Absorbance (AU)")
    ax.tick_params(labelbottom=True)
    plt.setp(ax.get_xticklabels(), rotation=45, ha='right', fontsize=9)

# Add vertical space between rows so the top row's x labels aren't covered
g.fig.subplots_adjust(hspace=0.75, wspace=0.15, top=0.92)
g.fig.suptitle('Facet Plot of Plate Reader Results by Experiment', y=0.98)

plt.show()
<Figure size 2071.5x1200 with 3 Axes>
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

# 1. Consolidate all data
all_data_frames = []
for key, result_obj in data.items():
    df = result_obj.view().copy()
    df['Experiment'] = key
    all_data_frames.append(df)

combined_df = pd.concat(all_data_frames, ignore_index=True)

# Standardize 'Read' labels
combined_df['Read'] = combined_df['Read'].replace({
    'CPRG:412': 'CPRG/CPR:412',
    'CPRG:570': 'CPRG/CPR:570'
})

# 2. Pivot the DataFrame to separate the 412 and 570 readings into columns
pivoted_df = combined_df.pivot_table(
    index=['Date', 'Experiment', 'Well', 'Name', 'Type', 'Reader Type', 'Plate ID'],
    columns='Read',
    values='Data'
).reset_index()

# Rename columns for clarity
pivoted_df.columns.name = None
pivoted_df = pivoted_df.rename(columns={
    'CPRG/CPR:412': 'CPRG_412',
    'CPRG/CPR:570': 'CPR_570'
})

# 3. Calculate the ratio: CPR (570) / CPRG (412)
pivoted_df['CPR_CPRG_Ratio'] = pivoted_df['CPR_570'] / pivoted_df['CPRG_412']

# 4. Create the facet plot
g = sns.catplot(
    data=pivoted_df,
    x='Name',
    y='CPR_CPRG_Ratio',
    col='Experiment',
    kind='bar',
    col_wrap=2,
    height=6, aspect=1.6,
    sharex=False,
    sharey=False,
    color='teal'
)

g.set_titles("{col_name}")

# Set labels on every subplot
for ax in g.axes.flat:
    ax.set_xlabel("Types of gels", labelpad=8)
    ax.set_ylabel("CPR (570) / CPRG (412) Ratio")
    ax.tick_params(labelbottom=True)
    plt.setp(ax.get_xticklabels(), rotation=45, ha='right', fontsize=9)

g.fig.subplots_adjust(hspace=0.75, wspace=0.15, top=0.92)
g.fig.suptitle('CPR (570) to CPRG (412) Absorbance Ratio by Experiment', y=0.98)

plt.show()
<Figure size 1931.11x1200 with 3 Axes>

Absorbance Change: luvgel02 to luvgel02PLA

We will extract the data for both luvgel02_data.txt and luvgel02PLA_data.txt, match the wells/sample names and read wavelengths, calculate the difference in absorbance (Day 2 After PLA minus Day 2 Before PLA), and plot the result.

import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

# Extract and clean the two datasets of interest
df_02 = data['data/luvgel02_data.txt'].view().copy()
df_02PLA = data['data/luvgel02PLA_data.txt'].view().copy()

# Standardize the 'Read' labels for exact matching
for df_temp in [df_02, df_02PLA]:
    df_temp['Read'] = df_temp['Read'].replace({
        'CPRG:412': 'CPRG/CPR:412',
        'CPRG:570': 'CPRG/CPR:570'
    })

# Pivot or group to align matching samples (Name) and measurement wavelengths (Read)
# We average replicates if any exist for a given Name and Read combination
grp_02 = df_02.groupby(['Name', 'Read'])['Data'].mean().reset_index()
grp_02PLA = df_02PLA.groupby(['Name', 'Read'])['Data'].mean().reset_index()

# Merge the two datasets on Name and Read
merged_diff = pd.merge(
    grp_02,
    grp_02PLA,
    on=['Name', 'Read'],
    suffixes=('_before', '_after')
)

# Calculate the absolute magnitude of change in absorbance (no negative values)
merged_diff['Absorbance Change (AU)'] = (merged_diff['Data_after'] - merged_diff['Data_before']).abs()

# Plot the absolute change in absorbance
plt.figure(figsize=(10, 6))
sns.barplot(
    data=merged_diff,
    x='Name',
    y='Absorbance Change (AU)',
    hue='Read',
    palette='muted'
)

plt.title('Absolute Absorbance Change after PLA Treatment (luvgel02 to luvgel02PLA)', fontsize=14, pad=15)
plt.xlabel('Types of gels', fontsize=11, labelpad=10)
plt.ylabel('Absolute Change in Absorbance (AU)', fontsize=11, labelpad=10)
plt.xticks(rotation=45, ha='right', fontsize=9)
plt.axhline(0, color='grey', linestyle='--', linewidth=0.8)
plt.tight_layout()
plt.show()

# Display the structured calculation table
display(merged_diff[['Name', 'Read', 'Data_before', 'Data_after', 'Absorbance Change (AU)']])
<Figure size 1000x600 with 1 Axes>
Loading...

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()
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)

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()
kinetics.summary

Visualize Fits

g = kinetics.plot(col="Well")
# g = kinetics.plot() # call this to visualize across replicates

Summary Plots

g = kinetics.plot_summary()

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