DNA Titration, Spent EsaR

Analyze time-series data from platereader experiments.

DNA Titration, Spent EsaR

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://drive.google.com/file/d/1oB_drvnZlumdGA3YeNzLriyyOfpD0Tf2/view?usp=share_link",  # Replace with your data file link.
            "filename": "raw_data.txt",
        },
        {
            "url": "https://drive.google.com/file/d/14mr1h7jZeJneQKCLnzcGVZnuyMuyPIkv/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 3 blocks:
0: (2117, 40) kinetic read with reads: GFP-G50:485/20,528/20 (Fluorescence) (Plate 'Plate 1')
1: (2117, 40) kinetic read with reads: GFP-G35:485/20,528/20 (Fluorescence) (Plate 'Plate 1')
2: (348, 38) endpoint read with reads: Max V [GFP-G50:485/20,528/20] (Fluorescence), R-Squared [GFP-G50:485/20,528/20] (Fluorescence), t at Max V [GFP-G50:485/20,528/20] (Fluorescence), Lagtime [GFP-G50: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...
data.plot(style='Type')
<seaborn.axisgrid.FacetGrid at 0x796d7c816cf0>
<Figure size 799.5x500 with 1 Axes>

Plot Raw Curves

data_EsaO2_01 = data.select(name=["[EsaO]2-mNG + 0.1nM DNA", "[EsaO]2-mNG + 0.1nM DNA + AHSL"])
data_EsaO2_05 = data.select(name=["[EsaO]2-mNG + 0.5nM DNA", "[EsaO]2-mNG + 0.5nM DNA + AHSL"])
data_EsaO2_1 = data.select(name=["[EsaO]2-mNG + 1nM DNA", "[EsaO]2-mNG + 1nM DNA + AHSL"])

df_1 = data_EsaO2_01.data
df_2 = data_EsaO2_05.data
df_3 = data_EsaO2_1.data


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

fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(13,8), sharey=True)
fig.suptitle('~ 576 nM EsaR / 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)
sns.lineplot(data=df_3, x='Time_hrs', y='Data', hue='Name', ax=ax3, units='Well', estimator=None)

ax1.set(title='0.1 nM', ylabel='', xlabel='')
ax2.set(title='0.5 nM', ylabel='', xlabel='')
ax3.set(title='1 nM', ylabel='', xlabel='')

fig.supxlabel('Time (hrs)', x=0.55)
fig.supylabel('Fluorescence units')

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

ax1.legend(loc='upper center', bbox_to_anchor=(0.64, 0.1), frameon=False)
ax2.legend(loc='upper center', bbox_to_anchor=(0.64, 0.1), frameon=False)


sns.despine(fig)
fig.tight_layout()
<Figure size 1300x800 with 3 Axes>
sns.lineplot(data=df_1, x='Time_hrs', y='Data', hue='Name')

plt.title("0.1 nM ")
plt.xlabel("Time hrs")
plt.ylabel("Fluorescence units")
plt.legend(frameon=False)
sns.despine()


<Figure size 640x480 with 1 Axes>
sns.lineplot(data=df_2, x='Time_hrs', y='Data', hue='Name')

plt.title("0.5 nM ")
plt.xlabel("Time hrs")
plt.ylabel("Fluorescence units")
plt.legend(frameon=False)
sns.despine()
<Figure size 640x480 with 1 Axes>
sns.lineplot(data=df_3, x='Time_hrs', y='Data', hue='Name', units="Well", estimator = None)

plt.title("1 nM ")
plt.xlabel("Time hrs")
plt.ylabel("Fluorescence units")
plt.legend(frameon=False)
sns.despine()
<Figure size 640x480 with 1 Axes>
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]

fc_01 = steady_state_fold_change(df_1, "[EsaO]2-mNG + 0.1nM DNA", "[EsaO]2-mNG + 0.1nM DNA + AHSL")
fc_05 = steady_state_fold_change(df_2, "[EsaO]2-mNG + 0.5nM DNA", "[EsaO]2-mNG + 0.5nM DNA + AHSL")
fc_1  = steady_state_fold_change(df_3, "[EsaO]2-mNG + 1nM DNA",   "[EsaO]2-mNG + 1nM DNA + AHSL")

# --- tidy into one summary table ---
fold_summary = pd.DataFrame({
    'DNA_conc_nM': [0.1, 0.5, 1],
    'Fold_change': [fc_01, fc_05, fc_1]
})
print(fold_summary)
   DNA_conc_nM  Fold_change
0          0.1     1.871863
1          0.5     1.589792
2          1.0     1.162061
/tmp/ipykernel_1683/2813574122.py:2: 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_1683/2813574122.py:2: 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_1683/2813574122.py:2: 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', ax=ax, color='tab:orange')

ax.set(xlabel='DNA concentration (nM)', ylabel='Fold change (+AHSL / −AHSL)')
ax.set_title('Steady-state AHSL fold induction — [EsaO]2')
ax.axhline(1, color='grey', linestyle='--', linewidth=1)

sns.despine(fig)
fig.tight_layout()
plt.show()
<Figure size 600x500 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(['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>

Side-by-Side Comparison with Exported Data

Load and compare your previously exported normalized dataset side-by-side with the current normalized plate reader data.

# Get the normalized view of the current data
df_current_norm = data.normalize('Fluorescein 0.5uM').view().copy()

# Filter out standards to keep only the experimental samples
df_current_samples_only = df_current_norm[df_current_norm['Type'] != 'Standard']

# Export to a CSV file
output_filename = 'normalized_current_run.csv'
df_current_samples_only.to_csv(output_filename, index=False)
print(f"Successfully exported normalized samples to {output_filename}")
Successfully exported normalized samples to normalized_current_run.csv


#!/usr/bin/env python3
"""
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 (mathtext $\mathbf{...}$ makes that part bold)
#   include : substring(s) a sample Name must contain to be plotted (None = all)
#   exclude : substring(s) that drop a sample Name (None = nothing dropped)
#   extra   : exact sample Name(s) to add on top of `include` (e.g. the "mNG" control)
PANELS = [
    dict(file="normalized_current_run.csv",
         title=r"Pre-expressed EsaR $\mathbf{(30\ ^{\circ}C,\ 17\ hours)}$",
         include=["[EsaO]2-mNG"], exclude=None, extra=["mNG"]),
    dict(file="normalized_samples_only.csv",
         title=r"Pre-expressed EsaR $\mathbf{(37^{\circ}C,\ 3\ hours)}$",
         include=["[EsaO]2-mNG"], exclude=None, extra=["mNG"]),
]

Y_LABEL = "Normalized RFU (0.5 µM Fluorescein)"
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
COLOR_OVERRIDES = {"mNG": "black"}   # fix colours for specific hue values

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 = 10

# Fonts — set to match the RFU figure (matplotlib defaults: DejaVu Sans, 10 pt base)
FONT_FAMILY = "DejaVu Sans"
TICK_FONTSIZE = 10
TITLE_FONTSIZE = 12          # panel titles
LABEL_FONTSIZE = 10          # x / y axis labels
OUTFILE = "panels_seaborn.png"
DPI = 200

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


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

    # Filter sample names
    keep = pd.Series(True, index=df.index)
    if cfg.get("include"):
        keep = df["Name"].apply(lambda n: any(s in n for s in cfg["include"]))
    if cfg.get("exclude"):
        keep &= ~df["Name"].apply(lambda n: any(s in n for s in cfg["exclude"]))
    if cfg.get("extra"):
        keep |= df["Name"].isin(cfg["extra"])
    df = df[keep].copy()

    # 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")
    # seaborn's style switches the font list to Arial-first; force the publication font
    plt.rcParams.update({
        "font.family": "sans-serif",
        "font.sans-serif": [FONT_FAMILY],
        "font.size": TICK_FONTSIZE,
        "xtick.labelsize": TICK_FONTSIZE,
        "ytick.labelsize": TICK_FONTSIZE,
        "mathtext.fontset": "dejavusans",
        # nothing bold by default: only the \mathbf{...} part of the panel titles
        "font.weight": "normal",
        "axes.titleweight": "normal",
        "axes.labelweight": "normal",
        "figure.titleweight": "normal",
        "figure.labelweight": "normal",
    })
    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
    if HUE:
        others = [h for h in hue_order if h not in COLOR_OVERRIDES]
        colors = dict(zip(others, sns.color_palette(PALETTE, len(others))))
        colors.update({h: c for h, c in COLOR_OVERRIDES.items() if h in hue_order})
    else:
        colors = 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"], fontsize=TITLE_FONTSIZE, fontweight="normal")
        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, fontsize=LABEL_FONTSIZE, fontweight="normal")
    fig.supxlabel(X_LABEL, fontsize=LABEL_FONTSIZE, fontweight="normal")
    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, fontsize=LABEL_FONTSIZE, fontweight="normal",
                      y=0.03 + 0.045 * -(-len(seen) // LEGEND_NCOL))
    else:
        fig.tight_layout()
    fig.savefig(OUTFILE, dpi=DPI, bbox_inches="tight")

if __name__ == "__main__":
    main()
<Figure size 1100x480 with 2 Axes>


#!/usr/bin/env python3
"""
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 (mathtext $\mathbf{...}$ makes that part bold)
#   include : substring(s) a sample Name must contain to be plotted (None = all)
#   exclude : substring(s) that drop a sample Name (None = nothing dropped)
#   extra   : exact sample Name(s) to add on top of `include` (e.g. the "mNG" control)
PANELS = [
    dict(file="normalized_samples_only.csv",
         title=r"Pre-expressed EsaR $\mathbf{(37^{\circ}C,\ 3\ hours)}$",
         include=["[EsaO]2-mNG + 1nM DNA"], exclude=None, extra=["mNG"]),
    dict(file="normalized_current_run.csv",
         title=r"Pre-expressed EsaR $\mathbf{(30\ ^{\circ}C,\ 17\ hours)}$",
         include=["[EsaO]2-mNG + 1nM DNA"], exclude=None, extra=["mNG"]),
]

Y_LABEL = "Normalized RFU (0.5 µM Fluorescein)"
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
COLOR_OVERRIDES = {"mNG": "black"}   # fix colours for specific hue values

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 = 10

# Fonts — set to match the RFU figure (matplotlib defaults: DejaVu Sans, 10 pt base)
FONT_FAMILY = "DejaVu Sans"
TICK_FONTSIZE = 10
TITLE_FONTSIZE = 12          # panel titles
LABEL_FONTSIZE = 10          # x / y axis labels
OUTFILE = "panels_seaborn.png"
DPI = 200

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


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

    # Filter sample names
    keep = pd.Series(True, index=df.index)
    if cfg.get("include"):
        keep = df["Name"].apply(lambda n: any(s in n for s in cfg["include"]))
    if cfg.get("exclude"):
        keep &= ~df["Name"].apply(lambda n: any(s in n for s in cfg["exclude"]))
    if cfg.get("extra"):
        keep |= df["Name"].isin(cfg["extra"])
    df = df[keep].copy()

    # 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")
    # seaborn's style switches the font list to Arial-first; force the publication font
    plt.rcParams.update({
        "font.family": "sans-serif",
        "font.sans-serif": [FONT_FAMILY],
        "font.size": TICK_FONTSIZE,
        "xtick.labelsize": TICK_FONTSIZE,
        "ytick.labelsize": TICK_FONTSIZE,
        "mathtext.fontset": "dejavusans",
        # nothing bold by default: only the \mathbf{...} part of the panel titles
        "font.weight": "normal",
        "axes.titleweight": "normal",
        "axes.labelweight": "normal",
        "figure.titleweight": "normal",
        "figure.labelweight": "normal",
    })
    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
    if HUE:
        others = [h for h in hue_order if h not in COLOR_OVERRIDES]
        colors = dict(zip(others, sns.color_palette(PALETTE, len(others))))
        colors.update({h: c for h, c in COLOR_OVERRIDES.items() if h in hue_order})
    else:
        colors = 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"], fontsize=TITLE_FONTSIZE, fontweight="normal")
        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, fontsize=LABEL_FONTSIZE, fontweight="normal")
    fig.supxlabel(X_LABEL, fontsize=LABEL_FONTSIZE, fontweight="normal")
    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, fontsize=LABEL_FONTSIZE, fontweight="normal",
                      y=0.03 + 0.045 * -(-len(seen) // LEGEND_NCOL))
    else:
        fig.tight_layout()
    fig.savefig(OUTFILE, dpi=DPI, bbox_inches="tight")

if __name__ == "__main__":
    main()
<Figure size 1100x480 with 2 Axes>


#!/usr/bin/env python3
"""
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 (mathtext $\mathbf{...}$ makes that part bold)
#   include : substring(s) a sample Name must contain to be plotted (None = all)
#   exclude : substring(s) that drop a sample Name (None = nothing dropped)
#   extra   : exact sample Name(s) to add on top of `include` (e.g. the "mNG" control)
PANELS = [
    dict(file="normalized_samples_only.csv",
         title=r"Pre-expressed EsaR $\mathbf{(37^{\circ}C,\ 3\ hours)}$",
         include=["[EsaO]2-mNG + 1nM DNA"], exclude=None, extra=["mNG"]),
    dict(file="normalized_current_run.csv",
         title=r"Pre-expressed EsaR $\mathbf{(30\ ^{\circ}C,\ 17\ hours)}$",
         include=["[EsaO]2-mNG + 1nM DNA"], exclude=None, extra=["mNG"]),
]

Y_LABEL = "Normalized RFU (0.5 µM Fluorescein)"
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"

# Batch correction to the mNG (-EsaR) control. Each file is multiplied by
# (common mNG level / that file's own mNG level), so the y-axis stays in
# normalized fluorescence units but PURE batch-to-batch offsets cancel.
NORMALISE_TO_REF = True
REFERENCE = "mNG"        # control sample Name in the CSVs
N_LAST_READS = 4         # mNG level = mean of each well's last N reads (steady state)

# 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
COLOR_OVERRIDES = {"mNG": "black"}   # fix colours for specific hue values

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 = 10

# Fonts — set to match the RFU figure (matplotlib defaults: DejaVu Sans, 10 pt base)
FONT_FAMILY = "DejaVu Sans"
TICK_FONTSIZE = 10
TITLE_FONTSIZE = 12          # panel titles
LABEL_FONTSIZE = 10          # x / y axis labels
OUTFILE = "panels_seaborn.png"
DPI = 200

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


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

    # Filter sample names
    keep = pd.Series(True, index=df.index)
    if cfg.get("include"):
        keep = df["Name"].apply(lambda n: any(s in n for s in cfg["include"]))
    if cfg.get("exclude"):
        keep &= ~df["Name"].apply(lambda n: any(s in n for s in cfg["exclude"]))
    if cfg.get("extra"):
        keep |= df["Name"].isin(cfg["extra"])
    df = df[keep].copy()

    # 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")
    # seaborn's style switches the font list to Arial-first; force the publication font
    plt.rcParams.update({
        "font.family": "sans-serif",
        "font.sans-serif": [FONT_FAMILY],
        "font.size": TICK_FONTSIZE,
        "xtick.labelsize": TICK_FONTSIZE,
        "ytick.labelsize": TICK_FONTSIZE,
        "mathtext.fontset": "dejavusans",
        # nothing bold by default: only the \mathbf{...} part of the panel titles
        "font.weight": "normal",
        "axes.titleweight": "normal",
        "axes.labelweight": "normal",
        "figure.titleweight": "normal",
        "figure.labelweight": "normal",
    })
    data = [load_panel(p) for p in PANELS]

    if NORMALISE_TO_REF:
        # steady-state mNG level per file: last N reads per well, then mean over wells
        ref_means = []
        for df in data:
            ref = df[df["Name"] == REFERENCE].sort_values("hours_raw")
            ref_means.append(ref.groupby("Well").tail(N_LAST_READS)["y"].mean())
        common_ref = sum(ref_means) / len(ref_means)   # all files rescaled to this level
        for df, ref_mean in zip(data, ref_means):
            df["y"] = df["y"] * (common_ref / ref_mean)

    # 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
    if HUE:
        others = [h for h in hue_order if h not in COLOR_OVERRIDES]
        colors = dict(zip(others, sns.color_palette(PALETTE, len(others))))
        colors.update({h: c for h, c in COLOR_OVERRIDES.items() if h in hue_order})
    else:
        colors = 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"], fontsize=TITLE_FONTSIZE, fontweight="normal")
        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, fontsize=LABEL_FONTSIZE, fontweight="normal")
    fig.supxlabel(X_LABEL, fontsize=LABEL_FONTSIZE, fontweight="normal")
    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, fontsize=LABEL_FONTSIZE, fontweight="normal",
                      y=0.03 + 0.045 * -(-len(seen) // LEGEND_NCOL))
    else:
        fig.tight_layout()
    fig.savefig(OUTFILE, dpi=DPI, bbox_inches="tight")

if __name__ == "__main__":
    main()
<Figure size 1100x480 with 2 Axes>
#!/usr/bin/env python3
"""
Side-by-side plate-reader kinetics with a shared y-axis (seaborn),
batch-corrected to the mNG (-EsaR) control. The mNG control is used for the
correction but is not drawn.

Edit the CONFIG block below, then run:
    python plot_panels_normalised.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 (mathtext $\mathbf{...}$ makes that part bold)
#   include : substring(s) a sample Name must contain to be plotted (None = all)
#   exclude : substring(s) that drop a sample Name (None = nothing dropped)
#   extra   : exact sample Name(s) loaded on top of `include`. Keep "mNG" here:
#             it is needed to compute the batch correction, then removed before plotting.
PANELS = [
    dict(file="normalized_samples_only.csv",
         title=r"Pre-expressed EsaR $\mathbf{(37^{\circ}C,\ 3\ hours)}$",
         include=["[EsaO]2-mNG"], exclude=None, extra=["mNG"]),
    dict(file="normalized_current_run.csv",
         title=r"Pre-expressed EsaR $\mathbf{(30\ ^{\circ}C,\ 17\ hours)}$",
         include=["[EsaO]2-mNG"], exclude=None, extra=["mNG"]),
]

SUPTITLE = "Pre-expressed EsaR comparison (Batch-corrected to - EsaR control signal)"
Y_LABEL = "Normalized RFU\n(0.5 µM Fluorescein)"
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"

# Batch correction to the mNG (-EsaR) control. Each file is multiplied by
# (common mNG level / that file's own mNG level), so the y-axis stays in
# normalized fluorescence units but PURE batch-to-batch offsets cancel.
REFERENCE = "mNG"        # control sample Name in the CSVs (corrected to, not plotted)
N_LAST_READS = 4         # mNG level = mean of each well's last N reads (steady state)

# 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 = 10

# Fonts — set to match the RFU figure (matplotlib defaults: DejaVu Sans, 10 pt base)
FONT_FAMILY = "DejaVu Sans"
TICK_FONTSIZE = 10
TITLE_FONTSIZE = 12          # panel titles
LABEL_FONTSIZE = 14          # x / y axis labels
SUPTITLE_FONTSIZE = 16
Y_LABELPAD = 14              # distance (pt) between the y label and the tick labels
OUTFILE = "panels_seaborn_mNG_normalised.png"
DPI = 200

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


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

    # Filter sample names
    keep = pd.Series(True, index=df.index)
    if cfg.get("include"):
        keep = df["Name"].apply(lambda n: any(s in n for s in cfg["include"]))
    if cfg.get("exclude"):
        keep &= ~df["Name"].apply(lambda n: any(s in n for s in cfg["exclude"]))
    if cfg.get("extra"):
        keep |= df["Name"].isin(cfg["extra"])
    df = df[keep].copy()

    # 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 normalise_to_reference(data):
    """Rescale y so every file's mNG (-EsaR) steady-state level matches the average level."""
    ref_means = []
    for df in data:
        ref = df[df["Name"] == REFERENCE].sort_values("hours_raw")
        ref_means.append(ref.groupby("Well").tail(N_LAST_READS)["y"].mean())
    common_ref = sum(ref_means) / len(ref_means)

    out = []
    for df, ref_mean in zip(data, ref_means):
        df = df.copy()
        df["y"] = df["y"] * (common_ref / ref_mean)
        out.append(df)
    return out


def main():
    sns.set_style("ticks")
    # seaborn's style switches the font list to Arial-first; force the publication font
    plt.rcParams.update({
        "font.family": "sans-serif",
        "font.sans-serif": [FONT_FAMILY],
        "font.size": TICK_FONTSIZE,
        "xtick.labelsize": TICK_FONTSIZE,
        "ytick.labelsize": TICK_FONTSIZE,
        "mathtext.fontset": "dejavusans",
        # nothing bold by default: only the \mathbf{...} part of the panel titles
        "font.weight": "normal",
        "axes.titleweight": "normal",
        "axes.labelweight": "normal",
        "figure.titleweight": "normal",
        "figure.labelweight": "normal",
    })

    # Load (mNG included), batch-correct, then drop the mNG control before plotting
    data = [load_panel(p) for p in PANELS]
    data = normalise_to_reference(data)
    data = [df[df["Name"] != REFERENCE].copy() for df in data]

    # 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"], fontsize=TITLE_FONTSIZE, fontweight="normal")
        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, fontsize=LABEL_FONTSIZE, fontweight="normal", labelpad=Y_LABELPAD)
    fig.supxlabel(X_LABEL, fontsize=LABEL_FONTSIZE, fontweight="normal")
    fig.suptitle(SUPTITLE, fontsize=SUPTITLE_FONTSIZE, fontweight="normal")
    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, fontsize=LABEL_FONTSIZE, fontweight="normal",
                      y=0.03 + 0.045 * -(-len(seen) // LEGEND_NCOL))
    else:
        fig.tight_layout()
    fig.savefig(OUTFILE, dpi=DPI, bbox_inches="tight")
    plt.show()


if __name__ == "__main__":
    main()
<Figure size 1100x480 with 2 Axes>
#!/usr/bin/env python3
"""
Steady-state AHSL fold induction (+AHSL / −AHSL) per DNA concentration,
for each EsaR preparation, plotted as grouped bars.

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

# =============================================================================
# CONFIG
# =============================================================================
DATASETS = {
    "Pre-expressed EsaR (30°C, 17 hours)": "normalized_current_run.csv",
    "Pre-expressed EsaR (37°C, 3 hours)": "normalized_samples_only.csv",
}
CONSTRUCT = "[EsaO]2-mNG"
DNA_CONCS = ["0.1", "0.5", "1"]   # as written in the sample names, e.g. "0.1nM"
N_LAST = 10                       # rows used for the steady-state mean (see note below)
Y_COL = "Data_normalized"         # fold change is identical with raw data (constant scale per file)

TITLE = f"Steady-state AHSL fold induction"
X_LABEL = "DNA concentration (nM)"
Y_LABEL = "RFU Fold change (+AHSL / −AHSL)"
PALETTE = ["tab:orange", "tab:blue"]
FIGSIZE = (6, 5)
OUTFILE = "fold_change.png"
# =============================================================================


def steady_state_fold_change(df, base_name, ahsl_name, n_last=N_LAST):
    # Note: nlargest pools rows from all replicate wells of a Name, so with 3 wells
    # n_last=10 covers roughly the last 3–4 reads (~15–20 min), not 10 time points.
    tail = df.groupby("Name").apply(
        lambda g: g.nlargest(n_last, "Time_hrs")[Y_COL].mean(), include_groups=False
    )
    return tail[ahsl_name] / tail[base_name]


rows = []
for label, path in DATASETS.items():
    df = pd.read_csv(path)
    df["Time_hrs"] = pd.to_timedelta(df["Time"]).dt.total_seconds() / 3600
    for conc in DNA_CONCS:
        base = f"{CONSTRUCT} + {conc}nM DNA"
        fc = steady_state_fold_change(df, base, base + " + AHSL")
        rows.append({"EsaR prep": label, "DNA_conc_nM": float(conc), "Fold_change": fc})

fold_summary = pd.DataFrame(rows)
#print(fold_summary.pivot(index="DNA_conc_nM", columns="EsaR prep", values="Fold_change").round(2))

fig, ax = plt.subplots(figsize=FIGSIZE)
sns.barplot(data=fold_summary, x="DNA_conc_nM", y="Fold_change", hue="EsaR prep",
            palette=PALETTE, ax=ax)
ax.set(xlabel=X_LABEL, ylabel=Y_LABEL)
ax.set_title(TITLE)
ax.axhline(1, color="grey", linestyle="--", linewidth=1)
ax.set_ylim(1, 2.25)
ax.legend(title=None, frameon=False, loc="upper right")

sns.despine(fig)
fig.tight_layout()
fig.savefig(OUTFILE, dpi=200, bbox_inches="tight")
plt.show()
<Figure size 600x500 with 1 Axes>
preps = list(DATASETS.keys())   # one panel per EsaR prep, in CONFIG order

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

# --- Panel 1: first EsaR prep ---
subset_1 = fold_summary[fold_summary["EsaR prep"] == preps[0]]
sns.barplot(data=subset_1, x="DNA_conc_nM", y="Fold_change", color=PALETTE[0], ax=ax1)
ax1.set(title=preps[0], xlabel="", ylabel="")
ax1.axhline(1, color="grey", linestyle="--", linewidth=1)

# --- Panel 2: second EsaR prep ---
subset_2 = fold_summary[fold_summary["EsaR prep"] == preps[1]]
sns.barplot(data=subset_2, x="DNA_conc_nM", y="Fold_change", color=PALETTE[1], ax=ax2)
ax2.set(title=preps[1], xlabel="", ylabel="")
ax2.axhline(1, color="grey", linestyle="--", linewidth=1)

ax1.set_ylim(1, 2.25)   # sharey=True applies this to both panels

fig.supxlabel(X_LABEL)
fig.supylabel(Y_LABEL)

sns.despine(fig)
fig.tight_layout()
fig.savefig(OUTFILE, dpi=200, bbox_inches="tight")
plt.show()
<Figure size 1000x500 with 2 Axes>
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

# =============================================================================
# CONFIG
# =============================================================================
DATASETS = {
    "Pre-expressed EsaR (30°C, 17 hours)": "normalized_current_run.csv",
    "Pre-expressed EsaR (37°C, 3 hours)": "normalized_samples_only.csv",
}
CONSTRUCT = "[EsaO]2-mNG"
DNA_CONCS = ["0.1", "0.5", "1"]
REFERENCE = "mNG"
N_LAST_READS = 4
Y_COL = "Data_normalized"

PALETTE = ["tab:orange", "tab:blue"]

FONT_FAMILY = "DejaVu Sans"
BASE_FONTSIZE = 10
TITLE_FONTSIZE = 12
LABEL_FONTSIZE = 12

TITLE = "Steady-state yield (batch-corrected to mNG control)"
X_LABEL = "DNA concentration (nM)"
Y_LABEL = "Normalized fluorescence"
ERRORBAR = "sd"
SHOW_POINTS = True
FIGSIZE = (6, 5)
OUTFILE = "steady_state_yield_batch_corrected.png"
DPI = 200
# =============================================================================

plt.rcParams.update({
    "font.family": "sans-serif",
    "font.sans-serif": [FONT_FAMILY],
    "font.size": BASE_FONTSIZE,
    "axes.titlesize": TITLE_FONTSIZE,
    "axes.labelsize": LABEL_FONTSIZE,
    "legend.fontsize": BASE_FONTSIZE,
})
PREP_COLORS = dict(zip(DATASETS, PALETTE))


def steady_state_yield_per_well(path, n_reads=N_LAST_READS):
    df = pd.read_csv(path)
    df["Time_hrs"] = pd.to_timedelta(df["Time"]).dt.total_seconds() / 3600
    return (df.sort_values("Time_hrs").groupby(["Name", "Well"])
              .tail(n_reads).groupby(["Name", "Well"])[Y_COL].mean().reset_index())


# ----------------------------------------------------------------------------
# Batch-correction factors from the mNG (-EsaR) control
# ----------------------------------------------------------------------------
wells_by_prep = {label: steady_state_yield_per_well(path) for label, path in DATASETS.items()}
ref_means = pd.Series({label: w.loc[w["Name"] == REFERENCE, Y_COL].mean()
                       for label, w in wells_by_prep.items()})
common_ref = ref_means.mean()                 # all files are rescaled to this mNG level
scale = common_ref / ref_means                # multiplicative factor per file

print("mNG reference mean per file:\n", ref_means.round(3).to_string())
print(f"Common mNG level: {common_ref:.3f}")
print("Scale factors:\n", scale.round(3).to_string())

# ----------------------------------------------------------------------------
# Compute corrected per-well values
# ----------------------------------------------------------------------------
rows = []
ref_rows = []
for label, wells in wells_by_prep.items():
    for v in wells.loc[wells["Name"] == REFERENCE, Y_COL]:
        ref_rows.append({"EsaR prep": label, "Raw": v, "Corrected": v * scale[label]})

    for conc in DNA_CONCS:
        name = f"{CONSTRUCT} + {conc}nM DNA + AHSL"
        for v in wells.loc[wells["Name"] == name, Y_COL]:
            rows.append({"EsaR prep": label, "DNA_conc_nM": float(conc),
                         "Yield": v * scale[label]})

yield_wells = pd.DataFrame(rows)
ref_wells = pd.DataFrame(ref_rows)

# ----------------------------------------------------------------------------
# Figure 1: batch-corrected yield
# ----------------------------------------------------------------------------
fig, ax = plt.subplots(figsize=FIGSIZE)
kw = dict(data=yield_wells, x="DNA_conc_nM", y="Yield", hue="EsaR prep",
          hue_order=list(DATASETS), ax=ax)
sns.barplot(**kw, palette=PREP_COLORS, errorbar=ERRORBAR, capsize=0.15,
            err_kws={"linewidth": 1})
if SHOW_POINTS:
    sns.stripplot(**kw, palette={p: "black" for p in DATASETS},
                  dodge=True, size=4, legend=False)

ax.axhline(common_ref, color="grey", linestyle="--", linewidth=1)   # mNG (-EsaR) level
ax.set(xlabel=X_LABEL, ylabel=Y_LABEL)
ax.set_title(TITLE)
ax.legend(title=None, frameon=False)

sns.despine(fig)
fig.tight_layout()
fig.savefig(OUTFILE, dpi=DPI, bbox_inches="tight")
plt.show()

# ----------------------------------------------------------------------------
# Figure 2: mNG control before vs after batch correction
# ----------------------------------------------------------------------------
fig2, (axA, axB) = plt.subplots(1, 2, figsize=(7, 4.5), sharey=True)
fig2.suptitle("mNG (−EsaR) control: batch-correction check")

for ax_, col, title in [(axA, "Raw", "Before"), (axB, "Corrected", "After")]:
    sns.barplot(data=ref_wells, x="EsaR prep", y=col, hue="EsaR prep",
                order=list(DATASETS), hue_order=list(DATASETS), palette=PREP_COLORS,
                errorbar=ERRORBAR, capsize=0.15, err_kws={"linewidth": 1},
                legend=False, ax=ax_)
    sns.stripplot(data=ref_wells, x="EsaR prep", y=col, order=list(DATASETS),
                  color="black", size=4, ax=ax_)
    ax_.set(title=title, xlabel="", ylabel="")
    ax_.set_xticks([])

axA.set_ylabel(Y_LABEL)
axB.axhline(common_ref, color="grey", linestyle="--", linewidth=1)

handles = [plt.Rectangle((0, 0), 1, 1, color=PREP_COLORS[p]) for p in DATASETS]
fig2.legend(handles, list(DATASETS), loc="lower center", ncol=1,
            frameon=False, bbox_to_anchor=(0.5, -0.08))

sns.despine(fig2)
fig2.tight_layout()
fig2.savefig("mNG_control_check.png", dpi=DPI, bbox_inches="tight")
plt.show()
mNG reference mean per file:
 Pre-expressed EsaR (30°C, 17 hours)   0.87
Pre-expressed EsaR (37°C, 3 hours)    1.11
Common mNG level: 0.994
Scale factors:
 Pre-expressed EsaR (30°C, 17 hours)   1.14
Pre-expressed EsaR (37°C, 3 hours)    0.89
<Figure size 600x500 with 1 Axes>
<Figure size 700x450 with 2 Axes>
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

# =============================================================================
# CONFIG
# =============================================================================
DATASETS = {
    "Pre-expressed EsaR (30°C, 17 hours)": "normalized_current_run.csv",
    "Pre-expressed EsaR (37°C, 3 hours)": "normalized_samples_only.csv",
}
CONSTRUCT = "[EsaO]2-mNG"
DNA_CONCS = ["0.1", "0.5", "1"]
REFERENCE = "mNG"
N_LAST_READS = 4
Y_COL = "Data_normalized"

PALETTE = ["tab:orange", "tab:blue"]

FONT_FAMILY = "DejaVu Sans"
BASE_FONTSIZE = 10
TITLE_FONTSIZE = 12
LABEL_FONTSIZE = 12

TITLE = "End-point Fluorescence (batch-corrected)"
X_LABEL = "DNA concentration (nM)"
Y_LABEL = "Normalized RFU"
ERRORBAR = "sd"
SHOW_POINTS = True
FIGSIZE = (6, 5)
OUTFILE = "steady_state_yield_batch_corrected.png"
DPI = 200
# =============================================================================

plt.rcParams.update({
    "font.family": "sans-serif",
    "font.sans-serif": [FONT_FAMILY],
    "font.size": BASE_FONTSIZE,
    "axes.titlesize": TITLE_FONTSIZE,
    "axes.labelsize": LABEL_FONTSIZE,
    "legend.fontsize": BASE_FONTSIZE,
})
PREP_COLORS = dict(zip(DATASETS, PALETTE))


def steady_state_yield_per_well(path, n_reads=N_LAST_READS):
    df = pd.read_csv(path)
    df["Time_hrs"] = pd.to_timedelta(df["Time"]).dt.total_seconds() / 3600
    return (df.sort_values("Time_hrs").groupby(["Name", "Well"])
              .tail(n_reads).groupby(["Name", "Well"])[Y_COL].mean().reset_index())


# ----------------------------------------------------------------------------
# Batch-correction factors from the mNG (-EsaR) control
# ----------------------------------------------------------------------------
wells_by_prep = {label: steady_state_yield_per_well(path) for label, path in DATASETS.items()}
ref_means = pd.Series({label: w.loc[w["Name"] == REFERENCE, Y_COL].mean()
                       for label, w in wells_by_prep.items()})
scale = ref_means.mean() / ref_means   # rescale every file to the average mNG level

# ----------------------------------------------------------------------------
# Corrected per-well values
# ----------------------------------------------------------------------------
rows = []
for label, wells in wells_by_prep.items():
    for conc in DNA_CONCS:
        name = f"{CONSTRUCT} + {conc}nM DNA + AHSL"
        for v in wells.loc[wells["Name"] == name, Y_COL]:
            rows.append({"EsaR prep": label, "DNA_conc_nM": float(conc),
                         "Yield": v * scale[label]})

yield_wells = pd.DataFrame(rows)

# ----------------------------------------------------------------------------
# Plot
# ----------------------------------------------------------------------------
fig, ax = plt.subplots(figsize=FIGSIZE)
kw = dict(data=yield_wells, x="DNA_conc_nM", y="Yield", hue="EsaR prep",
          hue_order=list(DATASETS), ax=ax)
sns.barplot(**kw, palette=PREP_COLORS, errorbar=ERRORBAR, capsize=0.15,
            err_kws={"linewidth": 1})
if SHOW_POINTS:
    sns.stripplot(**kw, palette={p: "black" for p in DATASETS},
                  dodge=True, size=4, legend=False)

ax.set(xlabel=X_LABEL, ylabel=Y_LABEL)
ax.set_title(TITLE)
ax.legend(title=None, frameon=False)

sns.despine(fig)
fig.tight_layout()
fig.savefig(OUTFILE, dpi=DPI, bbox_inches="tight")
plt.show()
<Figure size 600x500 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