Sensor DNA Titration

Analyze time-series data from platereader experiments.

Sensor DNA Titration

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/1BZ9z8nwe9Fla6StBErmYg-xxrzUP8LhS/view?usp=share_link",  # Replace with your data file link.
            "filename": "raw_data.txt",
        },
        {
            "url": "https://drive.google.com/file/d/1w3VMTSl3TASXHwFqyztAMoGXTRVQ3tzy/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 6 blocks:
0: (6059, 40) kinetic read with reads: GFP-G65:485/20,528/20 (Fluorescence) (Plate 'Plate 1')
1: (6059, 40) kinetic read with reads: GFP-G35:485/20,528/20 (Fluorescence) (Plate 'Plate 1')
2: (996, 38) endpoint read with reads: Max V [GFP-G65:485/20,528/20] (Fluorescence), R-Squared [GFP-G65:485/20,528/20] (Fluorescence), t at Max V [GFP-G65:485/20,528/20] (Fluorescence), Lagtime [GFP-G65:485/20,528/20] (Fluorescence), Max V [GFP-G35:485/20,528/20] (Fluorescence), R-Squared [GFP-G35:485/20,528/20] (Fluorescence), t at Max V [GFP-G35:485/20,528/20] (Fluorescence), Lagtime [GFP-G35:485/20,528/20] (Fluorescence), Count (None), Mean (None), Std Dev (None), CV (%) (None) (Plate 'Plate 1')
3: (6059, 40) kinetic read with reads: GFP-G65:485/20,528/20 (Fluorescence) (Plate 'Plate 1')
4: (6059, 40) kinetic read with reads: GFP-G35:485/20,528/20 (Fluorescence) (Plate 'Plate 1')
5: (996, 38) endpoint read with reads: Max V [GFP-G65:485/20,528/20] (Fluorescence), R-Squared [GFP-G65:485/20,528/20] (Fluorescence), t at Max V [GFP-G65:485/20,528/20] (Fluorescence), Lagtime [GFP-G65:485/20,528/20] (Fluorescence), Max V [GFP-G35:485/20,528/20] (Fluorescence), R-Squared [GFP-G35:485/20,528/20] (Fluorescence), t at Max V [GFP-G35:485/20,528/20] (Fluorescence), Lagtime [GFP-G35:485/20,528/20] (Fluorescence), Count (None), Mean (None), Std Dev (None), CV (%) (None) (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 = 1
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

data.plot(style='Type')
<seaborn.axisgrid.FacetGrid at 0x7ab445ea74d0>
<Figure size 799.5x500 with 1 Axes>
data_EsaO = data.select(name=["EsaO-mNG + 0.1nM DNA", "EsaO-mNG + 0.5nM DNA",
                              "EsaO-mNG + 1nM DNA", "EsaO-mNG + 3nM DNA", "EsaO-mNG + 6nM DNA", "EsaO-mNG + 9nM DNA"])
data_EsaO_AHSL = data.select(name=["EsaO-mNG + 0.1nM DNA + AHSL", "EsaO-mNG + 0.5nM DNA + AHSL",
                                   "EsaO-mNG + 1nM DNA + AHSL", "EsaO-mNG + 3nM DNA + AHSL", "EsaO-mNG + 6nM DNA + AHSL", "EsaO-mNG + 9nM DNA + AHSL"])

df_1 = data_EsaO.data
df_2 = data_EsaO_AHSL.data


# convert timedelta -> float hours
for df in (df_1, df_2):
    df['Time_hrs'] = df['Time'].dt.total_seconds() / 3600

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10,5), sharex=True)
fig.suptitle('EsaR/EsaO')

sns.lineplot(data=df_1, x='Time_hrs', y='Data', hue='Name', ax=ax1)
sns.lineplot(data=df_2, x='Time_hrs', y='Data', hue='Name', ax=ax2)

ax1.set(title='- AHSL')
ax2.set(title='+ AHSL')
fig.supxlabel('Time (hrs)')

for ax in (ax1, ax2):
    ax.legend(frameon=False)

sns.despine(fig)
fig.tight_layout()
<Figure size 1000x500 with 2 Axes>
data_EsaO_01 = data.select(name=["EsaO-mNG + 0.1nM DNA", "EsaO-mNG + 0.1nM DNA + AHSL"])
data_EsaO2_01 = data.select(name=["[EsaO]2-mNG + 0.1nM DNA", "[EsaO]2-mNG + 0.1nM DNA + AHSL"])

df_1 = data_EsaO_01.data
df_2 = data_EsaO2_01.data


# convert timedelta -> float hours
for df in (df_1, df_2):
    df['Time_hrs'] = df['Time'].dt.total_seconds() / 3600

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10,5), sharey=True)
fig.suptitle('7 µL pre-expressed EsaR / 0.1 nM DNA / 5 µM AHSL', x=0.55)

sns.lineplot(data=df_1, x='Time_hrs', y='Data', hue='Name', ax=ax1)
sns.lineplot(data=df_2, x='Time_hrs', y='Data', hue='Name', ax=ax2)

ax1.set(title='Single operator', ylabel='', xlabel='')
ax2.set(title='Dual operator', ylabel='', xlabel='')
fig.supxlabel('Time (hrs)', x=0.55)
fig.supylabel('RFU')



for ax in (ax1, ax2):
    ax.legend(frameon=False)

ax1.legend(loc="lower center", frameon=False)



sns.despine(fig)
fig.tight_layout()
<Figure size 1000x500 with 2 Axes>
data_EsaO_05 = data.select(name=["EsaO-mNG + 0.5nM DNA", "EsaO-mNG + 0.5nM DNA + AHSL"])
data_EsaO2_05 = data.select(name=["[EsaO]2-mNG + 0.5nM DNA", "[EsaO]2-mNG + 0.5nM DNA + AHSL"])

df_3 = data_EsaO_05.data
df_4 = data_EsaO2_05.data


# convert timedelta -> float hours
for df in (df_3, df_4):
    df['Time_hrs'] = df['Time'].dt.total_seconds() / 3600

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10,5), sharey=True)
fig.suptitle('7 µL pre-expressed EsaR / 0.5 nM DNA / 5 µM AHSL', x=0.55)

sns.lineplot(data=df_3, x='Time_hrs', y='Data', hue='Name', units='Well', estimator=None, ax=ax1)
sns.lineplot(data=df_4, x='Time_hrs', y='Data', hue='Name', units='Well', estimator=None, ax=ax2)

ax1.set(title='Single operator', ylabel='', xlabel='')
ax2.set(title='Dual operator', ylabel='', xlabel='')
fig.supxlabel('Time (hrs)', x=0.55)
fig.supylabel('RFU')

for ax in (ax1, ax2):
    ax.legend(frameon=False)

    ax1.legend(loc="lower center", frameon=False, bbox_to_anchor=(0.65, 0))
sns.despine(fig)
fig.tight_layout()
<Figure size 1000x500 with 2 Axes>
data_EsaO_1 = data.select(name=["EsaO-mNG + 1nM DNA", "EsaO-mNG + 1nM DNA + AHSL"])
data_EsaO2_1 = data.select(name=["[EsaO]2-mNG + 1nM DNA", "[EsaO]2-mNG + 1nM DNA + AHSL"])

df_5 = data_EsaO_1.data
df_6 = data_EsaO2_1.data


# convert timedelta -> float hours
for df in (df_5, df_6):
    df['Time_hrs'] = df['Time'].dt.total_seconds() / 3600

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10,5), sharey=True)
fig.suptitle('7 µL pre-expressed EsaR / 1 nM DNA / 5 µM AHSL', x=0.55)

sns.lineplot(data=df_5, x='Time_hrs', y='Data', hue='Name', ax=ax1)
sns.lineplot(data=df_6, x='Time_hrs', y='Data', hue='Name', ax=ax2)

ax1.set(title='Single operator', ylabel='', xlabel='')
ax2.set(title='Dual operator', ylabel='', xlabel='')
fig.supxlabel('Time (hrs)', x=0.55)
fig.supylabel('RFU')

for ax in (ax1, ax2):
    ax.legend(frameon=False)

sns.despine(fig)
fig.tight_layout()
<Figure size 1000x500 with 2 Axes>
data_EsaO_3 = data.select(name=["EsaO-mNG + 3nM DNA", "EsaO-mNG + 3nM DNA + AHSL"])
data_EsaO2_3 = data.select(name=["[EsaO]2-mNG + 3nM DNA", "[EsaO]2-mNG + 3nM DNA + AHSL"])

df_7 = data_EsaO_3.data
df_8 = data_EsaO2_3.data


# convert timedelta -> float hours
for df in (df_7, df_8):
    df['Time_hrs'] = df['Time'].dt.total_seconds() / 3600

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10,5), sharey=True)
fig.suptitle('7 µL pre-expressed EsaR / 3 nM DNA / 5 µM AHSL', x=0.55)

sns.lineplot(data=df_7, x='Time_hrs', y='Data', hue='Name', ax=ax1)
sns.lineplot(data=df_8, x='Time_hrs', y='Data', hue='Name', ax=ax2)

ax1.set(title='Single operator', ylabel='', xlabel='')
ax2.set(title='Dual operator', ylabel='', xlabel='')
fig.supxlabel('Time (hrs)', x=0.55)
fig.supylabel('RFU')

for ax in (ax1, ax2):
    ax.legend(frameon=False)

sns.despine(fig)
fig.tight_layout()
<Figure size 1000x500 with 2 Axes>

# --- steady-state fold change function (from earlier) ---
def steady_state_fold_change(df, base_name, ahsl_name, n_last=10):
    tail = df.groupby('Name').apply(lambda g: g.nlargest(n_last, 'Time_hrs')['Data'].mean())
    return tail[ahsl_name] / tail[base_name]

# --- compute for all six ---
fc_EsaO_01  = steady_state_fold_change(df_1, "EsaO-mNG + 0.1nM DNA",       "EsaO-mNG + 0.1nM DNA + AHSL")
fc_EsaO2_01 = steady_state_fold_change(df_2, "[EsaO]2-mNG + 0.1nM DNA",    "[EsaO]2-mNG + 0.1nM DNA + AHSL")
fc_EsaO_05  = steady_state_fold_change(df_3, "EsaO-mNG + 0.5nM DNA",       "EsaO-mNG + 0.5nM DNA + AHSL")
fc_EsaO2_05 = steady_state_fold_change(df_4, "[EsaO]2-mNG + 0.5nM DNA",    "[EsaO]2-mNG + 0.5nM DNA + AHSL")
fc_EsaO_1   = steady_state_fold_change(df_5, "EsaO-mNG + 1nM DNA",         "EsaO-mNG + 1nM DNA + AHSL")
fc_EsaO2_1  = steady_state_fold_change(df_6, "[EsaO]2-mNG + 1nM DNA",      "[EsaO]2-mNG + 1nM DNA + AHSL")
fc_EsaO_3   = steady_state_fold_change(df_7, "EsaO-mNG + 3nM DNA",         "EsaO-mNG + 3nM DNA + AHSL")
fc_EsaO2_3  = steady_state_fold_change(df_8, "[EsaO]2-mNG + 3nM DNA",      "[EsaO]2-mNG + 3nM DNA + AHSL")


# --- tidy into one summary table ---
fold_summary = pd.DataFrame({
    'DNA_conc_nM': [0.1, 0.1, 0.5, 0.5, 1, 1, 3, 3],
    'Construct':   ['EsaO', '[EsaO]2', 'EsaO', '[EsaO]2', 'EsaO', '[EsaO]2', 'EsaO', '[EsaO]2'],
    'Fold_change': [fc_EsaO_01, fc_EsaO2_01, fc_EsaO_05, fc_EsaO2_05, fc_EsaO_1, fc_EsaO2_1, fc_EsaO_3, fc_EsaO2_3]
})
print(fold_summary)
   DNA_conc_nM Construct  Fold_change
0          0.1      EsaO     1.546366
1          0.1   [EsaO]2     2.046698
2          0.5      EsaO     1.045033
3          0.5   [EsaO]2     1.614508
4          1.0      EsaO     1.147014
5          1.0   [EsaO]2     1.142774
6          3.0      EsaO     0.939829
7          3.0   [EsaO]2     0.922712
/tmp/ipykernel_5379/6956029.py:3: DeprecationWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
  tail = df.groupby('Name').apply(lambda g: g.nlargest(n_last, 'Time_hrs')['Data'].mean())
/tmp/ipykernel_5379/6956029.py:3: DeprecationWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
  tail = df.groupby('Name').apply(lambda g: g.nlargest(n_last, 'Time_hrs')['Data'].mean())
/tmp/ipykernel_5379/6956029.py:3: DeprecationWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
  tail = df.groupby('Name').apply(lambda g: g.nlargest(n_last, 'Time_hrs')['Data'].mean())
/tmp/ipykernel_5379/6956029.py:3: DeprecationWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
  tail = df.groupby('Name').apply(lambda g: g.nlargest(n_last, 'Time_hrs')['Data'].mean())
/tmp/ipykernel_5379/6956029.py:3: DeprecationWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
  tail = df.groupby('Name').apply(lambda g: g.nlargest(n_last, 'Time_hrs')['Data'].mean())
/tmp/ipykernel_5379/6956029.py:3: DeprecationWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
  tail = df.groupby('Name').apply(lambda g: g.nlargest(n_last, 'Time_hrs')['Data'].mean())
/tmp/ipykernel_5379/6956029.py:3: DeprecationWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
  tail = df.groupby('Name').apply(lambda g: g.nlargest(n_last, 'Time_hrs')['Data'].mean())
/tmp/ipykernel_5379/6956029.py:3: DeprecationWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
  tail = df.groupby('Name').apply(lambda g: g.nlargest(n_last, 'Time_hrs')['Data'].mean())


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

sns.barplot(data=fold_summary, x='DNA_conc_nM', y='Fold_change', hue='Construct',
            ax=ax, palette=['tab:blue', 'tab:orange'])

ax.set(xlabel='DNA concentration (nM)', ylabel='Fold change (+AHSL / −AHSL)')
ax.set_title('Steady-state AHSL fold induction')
ax.legend(frameon=False, title='')
ax.set_ylim(1, 2.5)

sns.despine(fig)
fig.tight_layout()
plt.show()
<Figure size 600x500 with 1 Axes>
# rename constructs (order follows order of appearance in fold_summary)
old_names = fold_summary['Construct'].unique()
#print(old_names)   # check the order matches the new names below
new_names = ['EsaO-mNG', '[EsaO]2-mNG']
fold_summary['Construct'] = fold_summary['Construct'].map(dict(zip(old_names, new_names)))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10,5), sharey=True)

# --- Panel 1: EsaO-mNG ---
subset_1 = fold_summary[fold_summary['Construct'] == 'EsaO-mNG']
sns.barplot(data=subset_1, x='DNA_conc_nM', y='Fold_change', ax=ax1, color='tab:blue')
ax1.set(title='EsaO-mNG', xlabel='', ylabel='')

# --- Panel 2: [EsaO]2-mNG ---
subset_2 = fold_summary[fold_summary['Construct'] == '[EsaO]2-mNG']
sns.barplot(data=subset_2, x='DNA_conc_nM', y='Fold_change', ax=ax2, color='tab:orange')
ax2.set(title='[EsaO]2-mNG', xlabel='', ylabel='')

fig.supxlabel('DNA concentration (nM)')
fig.supylabel('RFU fold change (+AHSL / −AHSL)')

ax1.set_ylim(1, 2.5)

sns.despine(fig)
fig.tight_layout()
plt.show()
<Figure size 1000x500 with 2 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(['Fluorescein 0uM', 'Fluorescein 0.5uM', 'Fluorescein 1uM', 'Fluorescein 2uM'], dtype=object)
data = data.normalize('Fluorescein 0.5uM')

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 799.5x500 with 1 Axes>

Replot Normalized Data (Excluding > 1 nM DNA)

# Identify names that have <= 1nM DNA (or specifically exclude 3nM, 6nM, and 9nM conditions)
all_names = data.view()['Name'].unique()
filtered_names = [name for name in all_names if not any(x in name for x in ['3nM DNA', '6nM DNA', '9nM DNA'])]

# Select only the filtered conditions
data_filtered = data.select(name=filtered_names)

# Replot the normalized curves
g_filtered = data_filtered.plot(style='Type', exclude_types=['Standard'])
<Figure size 799.5x500 with 1 Axes>

Export Normalized Data for Comparison

# Save the filtered, normalized view to a CSV file
data_filtered.view().to_csv('normalized_data_under_1nM.csv', index=False)
print('Data successfully exported to normalized_data_under_1nM.csv')
Data successfully exported to normalized_data_under_1nM.csv
from google.colab import files
files.download('normalized_data_under_1nM.csv')
Loading...
Loading...
# Trigger download of the cleaned file
from google.colab import files
files.download('normalized_samples_only.csv')
Loading...
Loading...
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

# 1. Load the cleaned CSV
plot_df = pd.read_csv('normalized_samples_only.csv')

# 2. Convert Time column from string back to timedelta, then to hours for easy plotting
plot_df['Time'] = pd.to_timedelta(plot_df['Time'])
plot_df['Time_hrs'] = plot_df['Time'].dt.total_seconds() / 3600.0

# 3. Create the verification plot
plt.figure(figsize=(10, 6))
sns.lineplot(
    data=plot_df,
    x='Time_hrs',
    y='Data',  # This now contains the normalized decimal values!
    hue='Name'
)

plt.title('Verification Plot: Cleaned Dataset (Samples Only, <1nM DNA, Normalized Values)')
plt.xlabel('Time (hours)')
plt.ylabel('Normalized Signal (Default \'Data\')')
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', frameon=False)
sns.despine()
plt.tight_layout()
plt.show()
<Figure size 1000x600 with 1 Axes>

"""
Side-by-side plate-reader kinetics with a shared y-axis (seaborn).

Edit the CONFIG block below, then run:
    python plot_panels_seaborn.py
"""
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

# =============================================================================
# CONFIG — edit these
# =============================================================================

# One dict per panel, left -> right.
#   file    : CSV path
#   title   : panel title
#   include : substring(s) a sample Name must contain to be plotted (None = all)
#   exclude : substring(s) that drop a sample Name (None = nothing dropped)
PANELS = [
    dict(file="normalized_current_run.csv", title="Current run",
         include=["[EsaO]2-mNG"], exclude=None),
    dict(file="normalized_samples_only.csv", title="Samples only",
         include=["[EsaO]2-mNG"], exclude=None),
]

SUPTITLE = "[EsaO]2-mNG DNA template titration ± 5 µM AHSL"
Y_LABEL = "Normalized fluorescence"
X_LABEL = "Time (hrs)"

# What to plot on y:
#   "normalized" -> Data_normalized (fluorescein-calibrated; comparable across days)
#   "raw"        -> raw counts (uses Data_raw if present, otherwise Data)
Y_DATA = "normalized"

# Line grouping. Any column of the CSV works, e.g. "Name", "DNA Template (nM)", "AHSL".
HUE = "Name"          # colour
STYLE = None          # line dash (e.g. "AHSL"); None = all solid
PALETTE = "tab10"     # seaborn palette name, or a list of colours

ERRORBAR = "sd"       # "sd", ("ci", 95), "se", or None for no band
SHARE_Y = True
Y_LIM = None          # e.g. (0, 4500); None = auto
X_LIM = None          # e.g. (0, 6)

FIGSIZE = (11, 4.8)
# Legend placement:
#   "shared" -> one legend under the figure (best when there are many lines)
#   "each"   -> a legend inside every panel at LEGEND_LOC (like the reference plot)
LEGEND_MODE = "shared"
LEGEND_LOC = "lower right"   # used when LEGEND_MODE = "each"
LEGEND_NCOL = 3              # used when LEGEND_MODE = "shared"
LEGEND_FONTSIZE = 9
OUTFILE = "panels_seaborn.png"
DPI = 200

# =============================================================================


def load_panel(cfg):
    df = pd.read_csv(cfg["file"])

    # Filter sample names
    if cfg.get("include"):
        df = df[df["Name"].apply(lambda n: any(s in n for s in cfg["include"]))]
    if cfg.get("exclude"):
        df = df[~df["Name"].apply(lambda n: any(s in n for s in cfg["exclude"]))]

    # y column
    if Y_DATA == "raw":
        df["y"] = df["Data_raw"] if "Data_raw" in df.columns else df["Data"]
    else:
        df["y"] = df["Data_normalized"]

    # Time in hours, aligned across replicate wells (they are read seconds apart)
    df["hours_raw"] = pd.to_timedelta(df["Time"]).dt.total_seconds() / 3600
    df["read_idx"] = df.groupby("Well").cumcount()
    df["hours"] = df.groupby("read_idx")["hours_raw"].transform("mean")

    # Handy grouping column
    df["AHSL"] = df["[AHSL] (uM)"].fillna(0).gt(0).map({True: "+ AHSL", False: "– AHSL"})
    return df


def main():
    sns.set_style("ticks")
    data = [load_panel(p) for p in PANELS]

    # Consistent colours across panels: one colour per hue value, shared by all panels
    hue_order = sorted(pd.concat(d[HUE] for d in data).unique(), key=str) if HUE else None
    colors = dict(zip(hue_order, sns.color_palette(PALETTE, len(hue_order)))) if HUE else None

    fig, axes = plt.subplots(1, len(PANELS), figsize=FIGSIZE, sharey=SHARE_Y, squeeze=False)
    axes = axes[0]

    for ax, cfg, df in zip(axes, PANELS, data):
        sns.lineplot(
            data=df, x="hours", y="y",
            hue=HUE, hue_order=[h for h in hue_order if h in set(df[HUE])] if HUE else None,
            palette=colors, style=STYLE,
            errorbar=ERRORBAR, ax=ax,
        )
        ax.set_title(cfg["title"])
        ax.set_xlabel("")
        ax.set_ylabel("")
        if X_LIM:
            ax.set_xlim(X_LIM)
        if ax.get_legend():
            if LEGEND_MODE == "each":
                sns.move_legend(ax, LEGEND_LOC, frameon=False, fontsize=LEGEND_FONTSIZE, title=None)
            else:
                ax.get_legend().remove()

    if Y_LIM:
        axes[0].set_ylim(Y_LIM)
    axes[0].set_ylabel(Y_LABEL)
    fig.supxlabel(X_LABEL)
    fig.suptitle(SUPTITLE)
    sns.despine(fig)

    if LEGEND_MODE == "shared" and (HUE or STYLE):
        # Collect unique entries across all panels
        seen = {}
        for ax in axes:
            for h, l in zip(*ax.get_legend_handles_labels()):
                seen.setdefault(l, h)
        fig.legend(seen.values(), seen.keys(), loc="lower center", ncol=LEGEND_NCOL,
                   frameon=False, fontsize=LEGEND_FONTSIZE, bbox_to_anchor=(0.5, -0.02))
        fig.tight_layout(rect=(0, 0.04 + 0.045 * -(-len(seen) // LEGEND_NCOL), 1, 1))
        fig.supxlabel(X_LABEL, y=0.03 + 0.045 * -(-len(seen) // LEGEND_NCOL))
    else:
        fig.tight_layout()
    fig.savefig(OUTFILE, dpi=DPI, bbox_inches="tight")
    print(f"Saved {OUTFILE}")


if __name__ == "__main__":
    main()
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
/tmp/ipykernel_1098/4150799761.py in <cell line: 0>()
    136 
    137 if __name__ == "__main__":
--> 138     main()

/tmp/ipykernel_1098/4150799761.py in main()
     85 def main():
     86     sns.set_style("ticks")
---> 87     data = [load_panel(p) for p in PANELS]
     88 
     89     # Consistent colours across panels: one colour per hue value, shared by all panels

/tmp/ipykernel_1098/4150799761.py in load_panel(cfg)
     59 
     60 def load_panel(cfg):
---> 61     df = pd.read_csv(cfg["file"])
     62 
     63     # Filter sample names

/usr/local/lib/python3.13/dist-packages/pandas/io/parsers/readers.py in read_csv(filepath_or_buffer, sep, delimiter, header, names, index_col, usecols, dtype, engine, converters, true_values, false_values, skipinitialspace, skiprows, skipfooter, nrows, na_values, keep_default_na, na_filter, verbose, skip_blank_lines, parse_dates, infer_datetime_format, keep_date_col, date_parser, date_format, dayfirst, cache_dates, iterator, chunksize, compression, thousands, decimal, lineterminator, quotechar, quoting, doublequote, escapechar, comment, encoding, encoding_errors, dialect, on_bad_lines, delim_whitespace, low_memory, memory_map, float_precision, storage_options, dtype_backend)
   1024     kwds.update(kwds_defaults)
   1025 
-> 1026     return _read(filepath_or_buffer, kwds)
   1027 
   1028 

/usr/local/lib/python3.13/dist-packages/pandas/io/parsers/readers.py in _read(filepath_or_buffer, kwds)
    618 
    619     # Create the parser.
--> 620     parser = TextFileReader(filepath_or_buffer, **kwds)
    621 
    622     if chunksize or iterator:

/usr/local/lib/python3.13/dist-packages/pandas/io/parsers/readers.py in __init__(self, f, engine, **kwds)
   1618 
   1619         self.handles: IOHandles | None = None
-> 1620         self._engine = self._make_engine(f, self.engine)
   1621 
   1622     def close(self) -> None:

/usr/local/lib/python3.13/dist-packages/pandas/io/parsers/readers.py in _make_engine(self, f, engine)
   1878                 if "b" not in mode:
   1879                     mode += "b"
-> 1880             self.handles = get_handle(
   1881                 f,
   1882                 mode,

/usr/local/lib/python3.13/dist-packages/pandas/io/common.py in get_handle(path_or_buf, mode, encoding, compression, memory_map, is_text, errors, storage_options)
    871         if ioargs.encoding and "b" not in ioargs.mode:
    872             # Encoding
--> 873             handle = open(
    874                 handle,
    875                 ioargs.mode,

FileNotFoundError: [Errno 2] No such file or directory: 'normalized_current_run.csv'

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