codex/size-exclusion-chromatography-theoretical-calibration.py

size-exclusion-chromatography-theoretical-calibration.py
"""Original stipulated SEC model, not measured data; Python 3.14.

V0=5 mL, Vi=8 mL; Kd=clip(1-log4(Rh/nm),0,1).
Standard-family M/(g/mol)=10000*(Rh/nm)**2. Peak sigma=0.28 mL.
"""
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

V0, VI = 5.0, 8.0
VT = V0 + VI
SIGMA = 0.28


def elution_volume(radius_nm):
    radius_nm = np.asarray(radius_nm)
    return V0 + VI * np.clip(1 - np.log(radius_nm) / np.log(4), 0, 1)


def standard_mass(radius_nm):
    return 10000 * np.asarray(radius_nm) ** 2


def gaussian(volume, center):
    return np.exp(-0.5 * ((volume - center) / SIGMA) ** 2) / (SIGMA * np.sqrt(2 * np.pi))


def main():
    plt.rcParams.update({"font.size": 10, "font.family": "DejaVu Sans"})
    fig, (ax, curve) = plt.subplots(1, 2, figsize=(10.8, 4.8), facecolor="white")
    colors = ["#c34831", "#247ba0", "#278453"]
    radii = np.array([2 * np.sqrt(2), 2, np.sqrt(2)])
    masses = standard_mass(radii)
    centers = elution_volume(radii)
    v = np.linspace(3.7, 14.3, 3000)
    for r, m, ve, color in zip(radii, masses, centers, colors):
        ax.plot(v, gaussian(v, ve), color=color, lw=2)
        ax.text(ve, 1.54, f"{m/1000:.0f} kg/mol\n{r:.2f} nm", ha="center", color=color, fontsize=10)
    ax.set(xlim=(3.7, 14.3), ylim=(0, 1.87), xlabel="Elution volume (mL)",
           ylabel="Model detector signal (arbitrary units)", title="Equal-area theoretical peaks")
    ax.set_yticks([0, 0.5, 1, 1.5])
    for volume, label in [(V0, r"$V_0$"), (VT, r"$V_t$")]:
        ax.axvline(volume, color="#555555", ls="--", lw=1)
        ax.text(volume, 1.75, label, ha="center", color="#444444")
    r = np.geomspace(0.55, 7.3, 1200)
    curve.plot(elution_volume(r), r, color="#3d4356", lw=2.5)
    curve.scatter(centers, radii, c=colors, s=45, zorder=4)
    curve.set(xlim=(3.7, 14.3), ylim=(0.55, 7.3), yscale="log", xlabel="Elution volume (mL)",
              ylabel=r"Hydrodynamic radius $R_h$ (nm)", title="Stipulated size calibration")
    curve.set_yticks([1, 2, 4, 7], labels=["1", "2", "4", "7"])
    curve.minorticks_off()
    mass_axis = curve.secondary_yaxis("right", functions=(lambda r: standard_mass(r) / 1000,
                                                         lambda m: np.sqrt(m / 10)))
    mass_axis.set_ylabel("Standard-family mass (kg/mol)")
    mass_axis.set_yticks([10, 40, 160, 490], labels=["10", "40", "160", "490"])
    mass_axis.minorticks_off()
    curve.annotate("excluded:\nsizes coelute", (5, 5.6), (7.0, 5.6), fontsize=9,
                   arrowprops={"arrowstyle": "->", "color": "#555555"})
    curve.annotate("fully included:\nsizes coelute", (13, 0.73), (8.0, 0.73), fontsize=9,
                   arrowprops={"arrowstyle": "->", "color": "#555555"})
    for axis in (ax, curve):
        axis.grid(alpha=0.15)
        axis.spines[["top"]].set_visible(False)
    fig.suptitle("SEC model: size orders elution; mass requires a matching standard family", weight="bold", y=0.98)
    fig.text(0.5, 0.025, "Not measured data • chosen peak width 0.28 mL • equal response factors • no unique size at the plateaus",
             ha="center", fontsize=9)
    fig.subplots_adjust(left=0.075, right=0.905, bottom=0.19, top=0.82, wspace=0.36)
    fig.savefig(Path(__file__).with_suffix(".png").name, dpi=140, facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()