pykingenie
analysisFit BLI and SPR sensorgrams to kinetic and equilibrium binding models with pykingenie — reads Octet .frd traces and their .fmf sample plate, or arrays from any instrument, and reports k_off, KD and a derived k_on, with the check for a pinned KD.
pykingenie — binding kinetics from BLI and SPR sensorgrams
Turns a biosensor trace into k_on, k_off and KD. Use it to go from an Octet or
Biacore run to a number you can put in a figure, and — more often the useful part — to
find out whether the experiment actually determined that number.
Nothing to obtain. pykingenie is MIT, on PyPI, pure Python over numpy/scipy/pandas,
CPU only. No account, no API key, no licence to accept, no GPU. It is a library, not a
command-line tool, so every example below is Python.
Scope: affinity and rate constants from surface-based (BLI/SPR) and in-solution experiments. It does not design the experiment, pick concentrations, or reference-subtract for you.
Install
pip install 'pykingenie==1.0.9'
python -c "from importlib.metadata import version; print(version('pykingenie'))"
Pin the version. The project is young and single-maintainer, and the fitting entry points have moved between releases.
Check the version with importlib.metadata, not pykingenie.__version__ — at 1.0.9 the
module attribute still reads 1.0.0 and will mislead a version guard.
What it reads
| Source | Support |
|---|---|
Octet .frd (traces, one file per sensor) |
Native, OctetExperiment.read_sensor_data |
Octet .fmf (sample plate — the concentrations) |
Native, OctetExperiment.read_sample_plate_info |
Gator (ExperimentStep.ini + Setting.ini + per-channel CSV) |
Native, GatorExperiment.read_all_gator_data |
| KinGenie simulated CSV | Native, KinGenieCsv.read_csv |
Biacore .blr |
Not read. Export text from the instrument and pass arrays to KineticsFitter directly |
The split between the two Octet files is the thing to internalise: .frd carries the
traces and no analyte concentration at all. Concentrations live in the .fmf sample
plate. Read only the .frd files and sample_conc stays None, sample_plate_loaded
stays False, and you cannot fit anything that needs a concentration axis.
import urllib.request
from pykingenie.octet import OctetExperiment
BASE = "https://raw.githubusercontent.com/osvalB/pykingenie/main/test_files/"
urllib.request.urlretrieve(BASE + "230309_002.frd", "230309_002.frd")
bli = OctetExperiment("one_sensor")
bli.read_sensor_data("230309_002.frd")
md = bli.exp_info[0]
print("sensor :", bli.sensor_names, md["SensorType"])
print("instrument :", md["InstrumentType"], md["ExperimentType"])
print("steps :", bli.no_steps, list(bli.df_steps["Type"].unique()))
print("conc :", bli.sample_conc, "| plate loaded:", bli.sample_plate_loaded)
df_steps is the step table — #Step, Name, Type, Start, Column_location,
Loading_location. Step types are BASELINE, LOADING, ASSOC, DISASSOC and
KREGENERATION. Traces are nested per sensor then per step, so bli.xs[sensor][step]
is a time vector and bli.ys[sensor][step] the matching signal.
Add the sample plate and the concentration axis appears:
import urllib.request
from pykingenie.octet import OctetExperiment
BASE = "https://raw.githubusercontent.com/osvalB/pykingenie/main/test_files/"
for n in range(1, 9):
urllib.request.urlretrieve(BASE + f"230309_00{n}.frd", f"230309_00{n}.frd")
urllib.request.urlretrieve(BASE + "230309_ExpMethod.fmf", "230309_ExpMethod.fmf")
bli = OctetExperiment("run230309")
bli.read_sensor_data([f"230309_00{n}.frd" for n in range(1, 9)])
bli.read_sample_plate_info("230309_ExpMethod.fmf") # must contain 'ExpMethod.fmf'
bli.generate_ligand_conc_df()
print(bli.ligand_conc_df.head(8).to_string(index=False))
print("analyte sets:", sorted(set(bli.ligand_conc_df["SampleID"])))
read_sample_plate_info finds the plate by filename: it looks for ExpMethod.fmf in
the names you pass. Rename the file and it silently loads nothing.
The two-stage fit, and why the order is not optional
A kinetic fit needs a starting guess for KD, and pykingenie takes it from an
equilibrium fit of the steady-state signal. So the sequence is always:
get_steady_state()— median of the last points of each association curve, grouped by Smax ID.fit_steady_state(model='one_site')— setsKd_ss.fit_one_site_assoc_and_disso()— the kinetic fit.
Skip stages 1–2 and stage 3 raises ValueError: Kd_ss is not set. Please run fit_steady_state() first. That is the library telling you the truth, not a bug.
smax_id says which curves share one sensor surface. A dilution series run on a
single loaded sensor shares one Smax, so smax_id=[0, 0, 0, ...] is the physically
correct model and gives the better-determined fit. Give every curve its own id and each
gets a free Smax; at sub-saturating concentrations that is under-determined and the
steady-state KD can come out an order of magnitude wrong.
import numpy as np
from pykingenie import (KineticsFitter, one_site_association_analytical,
one_site_dissociation_analytical)
KD, KOFF, SMAX = 0.050, 1.2e-2, 1.8 # KD µM, k_off 1/s, Smax nm
concs = [0.4, 0.2, 0.1, 0.05, 0.025] # µM
ta, td = np.arange(0, 300, 1.0), np.arange(0, 600, 1.0)
rng = np.random.default_rng(0)
assoc, disso = [], []
for c in concs:
a = one_site_association_analytical(ta, 0.0, SMAX, KOFF, KD, c)
assoc.append(a + rng.normal(0, 0.004, ta.size))
disso.append(one_site_dissociation_analytical(td, a[-1], KOFF)
+ rng.normal(0, 0.004, td.size))
# One loaded surface -> one shared Smax. Do NOT also pass name_lst (see Traps).
fit = KineticsFitter(time_assoc_lst=[ta] * 5, association_signal_lst=assoc,
lig_conc_lst=concs, time_diss_lst=[td] * 5,
dissociation_signal_lst=disso, smax_id=[0] * 5)
fit.get_steady_state()
fit.fit_steady_state(model="one_site")
kd_ss = fit.Kd_ss # capture it now; the kinetic fit clears the table
fit.fit_one_site_assoc_and_disso(shared_smax=True)
row = fit.fit_params_kinetics.iloc[0]
err = fit.fit_params_kinetics_error.iloc[0]
print("KD steady state : %.4f µM" % kd_ss)
print("KD kinetic : %.4f µM (true %.4f)" % (row["Kd [µM]"], KD))
print("k_off : %.5f 1/s (true %.5f)" % (row["k_off [1/s]"], KOFF))
print("k_on derived : %.4f 1/µM/s" % row["(Derived) k_on [1/µM/s]"])
print("relative errors : KD %.3f%% k_off %.3f%%" % (err["Kd [µM]"], err["k_off [1/s]"]))
Units, and the fact that k_on is never fitted
The model is parameterised on KD and k_off. k_on is then computed as
k_off / KD and reported in the column literally named (Derived) k_on [1/µM/s]. It
carries no independent uncertainty — the error table has columns for KD, k_off and
Smax only.
| Quantity | pykingenie unit | To SI |
|---|---|---|
KD |
µM | × 1e3 → nM |
k_off |
1/s | already SI |
k_on |
1/µM/s | × 1e6 → 1/M/s |
Reporting a µM number as though it were nM is a factor of a thousand, and the column
headers are the only place the unit is stated. rel_error = error / fit * 100, so the
error table is in percent, not fractions.
Read the bound before you believe the KD
This is the check that matters most, and it is not optional. The kinetic fit's lower
bound on KD is min(Kd_ss / 1000, *concentrations). When the dilution series sits far
above the true KD, every curve is saturated, the steady-state signal cannot constrain
affinity, and the optimiser walks KD down to that bound and stops. The fit then
reports a KD — and the formal relative error still looks respectable, because the
covariance is computed about the point where the optimiser halted.
So compare the fitted KD against low_bounds[0]. Equal means not measured.
import urllib.request, numpy as np
from pykingenie.octet import OctetExperiment
from pykingenie.main import KineticsAnalyzer
BASE = "https://raw.githubusercontent.com/osvalB/pykingenie/main/test_files/"
for n in range(1, 9):
urllib.request.urlretrieve(BASE + f"230309_00{n}.frd", f"230309_00{n}.frd")
urllib.request.urlretrieve(BASE + "230309_ExpMethod.fmf", "230309_ExpMethod.fmf")
bli = OctetExperiment("run230309")
bli.read_sensor_data([f"230309_00{n}.frd" for n in range(1, 9)])
bli.read_sample_plate_info("230309_ExpMethod.fmf")
bli.generate_ligand_conc_df()
an = KineticsAnalyzer()
an.add_experiment(bli, "run230309")
an.merge_ligand_conc_df()
merged = an.combined_ligand_conc_df
conc_col = next(c for c in merged.columns if "Analyte]" in c) # '[Analyte] (μM)'
sub = merged[(merged["SampleID"] == "wt - reg")
& (merged["Replicate"] == 1)
& (merged[conc_col] > 0)].copy() # drop the 0 µM blank
print("curves:", len(sub), "concs µM:", sorted(sub[conc_col]))
an.generate_fittings(sub) # NB: mutates `sub` in place
an.submit_steady_state_fitting(fitting_model="one_to_one")
f = an.fittings["wt - reg"]
kd_ss = f.Kd_ss
an.submit_kinetics_fitting(fitting_model="one_to_one", shared_smax=True)
an.get_fitting_results() # returns None; fills the attribute below
res = an.fit_params_kinetics_all.iloc[0]
pinned = np.isclose(res["Kd [µM]"], f.low_bounds[0], rtol=1e-6)
print("KD steady state : %.4g µM" % kd_ss)
print("KD kinetic : %.4g µM" % res["Kd [µM]"])
print("lower bound : %.4g µM -> pinned: %s" % (f.low_bounds[0], pinned))
print("k_off : %.3g 1/s" % res["k_off [1/s]"])
print("formal KD error : %.1f%%" % f.fit_params_kinetics_error.iloc[0]["Kd [µM]"])
On run 230309 that prints a pinned KD for all six analyte sets, at a formal error
between 4 and 5 percent. The honest read of that experiment is that k_off is
measured — dissociation is slow, around 1e-4 1/s — and KD is not, because the series
(1 nM to 130 nM) never approaches it. Reporting the pinned number as an affinity would be
reporting the lower bound of a search.
What to do instead, in order of preference: extend the dilution series downward until the
steady-state signal bends; fit k_on and k_off from the kinetics alone and quote
KD = k_off / k_on with its propagated error; or quote k_off and say KD was below the
range probed.
Fitting data from any other instrument
KineticsFitter takes plain arrays, so anything you can export works — Biacore text,
a CSV, a reference-subtracted trace you built yourself. One entry per curve in each list:
import numpy as np
from pykingenie import KineticsFitter
# two curves, 60 s association then 120 s dissociation, 1 Hz
ta, td = np.arange(0, 60, 1.0), np.arange(0, 120, 1.0)
assoc = [0.9 * (1 - np.exp(-0.05 * ta)), 0.45 * (1 - np.exp(-0.035 * ta))]
disso = [a[-1] * np.exp(-0.01 * td) for a in assoc]
fit = KineticsFitter(time_assoc_lst=[ta, ta],
association_signal_lst=assoc,
lig_conc_lst=[0.2, 0.05],
time_diss_lst=[td, td],
dissociation_signal_lst=disso,
smax_id=[0, 0])
fit.get_steady_state()
print("steady-state signals per Smax ID:", fit.signal_ss)
print("Smax guess (shared):", [round(s, 3) for s in fit.smax_guesses_shared])
Pass is_single_cycle=True for single-cycle kinetics, where titration steps run
back-to-back on one sensor without regeneration.
Other models reachable from KineticsAnalyzer.submit_kinetics_fitting via
fitting_model: one_to_one, one_to_one_mtl (mass transport limited), one_to_one_if
(induced fit), two_to_one, and heterogeneous_ligand. Use fitting_region to fit
association, dissociation or both.
Traps
Each of these was reproduced on 1.0.9.
name_lstplus a groupedsmax_idbreaks the steady-state fit. The result frame puts oneSmaxper group alongside oneNameper curve, and pandas refuses withValueError: All arrays must be of the same length. Omitname_lstwhen you shareSmax, and label the curves yourself afterwards.- The kinetic fit clears
fit_params_ss. Afterfit_one_site_assoc_and_dissoit isNone. ReadKd_ssor the steady-state table before you run the kinetics. get_fitting_results()returnsNonedespite its docstring. The frame is left onKineticsAnalyzer.fit_params_kinetics_all.generate_fittings(df)mutates the DataFrame you hand it — resets the index and renames[Analyte] (μM)toConcentration_micromolar. Pass a.copy()if you still need the original.- Two different mu characters. Result columns use the micro sign U+00B5 (
Kd [µM]), while the merged concentration column uses Greek mu U+03BC ([Analyte] (μM)). Indexing by a hand-typed string is a coin flip; select it with a substring match, as the example above does. pykingenie.__version__reports1.0.0at 1.0.9. Useimportlib.metadata.version.scipyprintsOptimizeWarning: Covariance of the parameters could not be estimatedon some fits. It means the error table for that fit is not usable; the parameters may still be fine.
Try it
A self-contained check that this skill still works. Public data, no account, no key.
Data — one real sensor trace and the sample plate from Octet run 230309, part of the
project's own MIT-licensed test corpus:
https://raw.githubusercontent.com/osvalB/pykingenie/main/test_files/230309_002.frd
https://raw.githubusercontent.com/osvalB/pykingenie/main/test_files/230309_ExpMethod.fmf
Real OctetRED384 output, 650 kB and 68 kB, no licence to accept beyond the repository's MIT terms. Last confirmed reachable 2026-10-08. The invariants below are structural — the parser's shape and the recovery of known rate constants. The instrument strings and the B1 concentration are observed values from this run, so a mismatch there is drift to investigate rather than a failure.
import urllib.request, numpy as np
from pykingenie.octet import OctetExperiment
from pykingenie import (KineticsFitter, one_site_association_analytical,
one_site_dissociation_analytical)
BASE = "https://raw.githubusercontent.com/osvalB/pykingenie/main/test_files/"
for f in ("230309_002.frd", "230309_ExpMethod.fmf"):
urllib.request.urlretrieve(BASE + f, f)
# --- the reader, and the .frd/.fmf split -----------------------------------
bli = OctetExperiment("tryit")
bli.read_sensor_data("230309_002.frd")
types = list(bli.df_steps["Type"].unique())
assoc = bli.df_steps.index[bli.df_steps["Type"] == "ASSOC"]
print("sensor :", bli.sensor_names, "|", bli.exp_info[0]["InstrumentType"])
print("steps :", bli.no_steps, "| ASSOC steps:", len(assoc))
print("conc in .frd:", bli.sample_conc)
assert bli.no_sensors == 1 and bli.sensor_names == ["B1"]
assert {"BASELINE", "LOADING", "ASSOC", "DISASSOC"} <= set(types)
assert bli.sample_conc is None and bli.sample_plate_loaded is False
assert len(bli.xs[0]) == bli.no_steps == 115
assert len(bli.xs[0][assoc[0]]) == 1500
bli.read_sample_plate_info("230309_ExpMethod.fmf")
bli.generate_ligand_conc_df()
conc_col = next(c for c in bli.ligand_conc_df.columns if "oncentration" in c)
b1 = sorted(set(bli.ligand_conc_df[conc_col]))
print("plate loaded:", bli.sample_plate_loaded, "| B1 concs µM:", b1)
assert bli.sample_plate_loaded is True
assert any(abs(c - 0.0692) < 1e-6 for c in b1) # observed, run 230309
# --- the numerical core, against known truth -------------------------------
KD, KOFF, SMAX = 0.050, 1.2e-2, 1.8
concs = [0.4, 0.2, 0.1, 0.05, 0.025]
ta, td = np.arange(0, 300, 1.0), np.arange(0, 600, 1.0)
rng = np.random.default_rng(0)
A, D = [], []
for c in concs:
a = one_site_association_analytical(ta, 0.0, SMAX, KOFF, KD, c)
A.append(a + rng.normal(0, 0.004, ta.size))
D.append(one_site_dissociation_analytical(td, a[-1], KOFF)
+ rng.normal(0, 0.004, td.size))
fit = KineticsFitter(time_assoc_lst=[ta] * 5, association_signal_lst=A,
lig_conc_lst=concs, time_diss_lst=[td] * 5,
dissociation_signal_lst=D, smax_id=[0] * 5)
fit.get_steady_state()
fit.fit_steady_state(model="one_site")
kd_ss = fit.Kd_ss
fit.fit_one_site_assoc_and_disso(shared_smax=True)
r = fit.fit_params_kinetics.iloc[0]
kd, koff, kon = r["Kd [µM]"], r["k_off [1/s]"], r["(Derived) k_on [1/µM/s]"]
print("KD : %.4f µM (true %.4f) = %.1f nM" % (kd, KD, kd * 1e3))
print("koff: %.5f 1/s (true %.5f)" % (koff, KOFF))
print("kon : %.4f 1/µM/s = %.2e 1/M/s" % (kon, kon * 1e6))
assert np.isclose(kon, koff / kd) # k_on is derived, never fitted
assert abs(kd - KD) / KD < 0.05
assert abs(koff - KOFF) / KOFF < 0.05
assert len(fit.fit_params_kinetics) == 1 # shared Smax -> one row
assert not np.isclose(kd, fit.low_bounds[0], rtol=1e-6) # a real fit is not at its bound
assert fit.fit_params_ss is None # cleared by the kinetic fit
# --- error path: the steady-state seed is mandatory ------------------------
try:
KineticsFitter([ta], [A[0]], [concs[0]]).fit_one_site_assoc_and_disso()
raise AssertionError("expected ValueError")
except ValueError as e:
print("unseeded fit:", e)
print("OK")
Expect KD within 5 percent of 0.0500 µM and k_off within 5 percent of 0.01200 1/s;
on 1.0.9 the run printed KD 0.0500 µM = 50.0 nM, koff 0.01199, kon 0.2399 1/µM/s
= 2.40e+05 1/M/s.
Limits
- Young project, one maintainer. Pin the version and re-run this page's checks after any upgrade; the fitting entry points have moved before.
- No Biacore
.blrreader. Export from the instrument and pass arrays. - Reference subtraction, baseline alignment and inter-step drift correction are the experimenter's job. A blank sensor at 0 µM is in the plate for that purpose — the worked example drops it rather than correcting with it.
- Errors come from the covariance of the fit. They describe the fit, not the experiment, and they say nothing about whether the concentration range determined the parameter. The bound check above is the thing that does.