moltenprot
analysisFit protein thermal unfolding curves — DSF, nanoDSF, CD — with the standalone MoltenProt CLI for an apparent free energy of unfolding, not just a melting temperature. Use to rank constructs, buffers or ligands whose melts are too close to separate.
MoltenProt — rank stability, not melting temperature
A thermal shift assay gives you a curve per well. The usual readout is one number off that curve, the melting temperature, and most of the time it is enough. When it is not — two constructs melting within a tenth of a degree of each other, a buffer screen where the top hits all land on the same plateau — a melting temperature has nothing left to say and the ranking becomes noise.
MoltenProt fits a thermodynamic model to the whole curve instead of locating its midpoint. That yields an unfolding enthalpy, and from it an apparent free energy of unfolding extrapolated to 25 °C. Two wells with the same melting temperature can differ several-fold in that free energy, and the worked example below is a real pair out of a 96-well plate that differ by 0.08 °C in melting temperature and 24.6 kJ/mol in apparent stability.
It reads plate data as exported — nanoDSF instrument spreadsheets, plain CSV from anything else, and wavelength-resolved CD or fluorescence spectra — and writes CSV tables you can rank directly.
The word "apparent" is doing real work. The free energy this reports is a model-dependent extrapolation from a non-equilibrium measurement, not a calorimetric measurement. It is a better ranking statistic than a melting temperature for samples of the same protein under different conditions. It is not a substitute for isothermal titration calorimetry or differential scanning calorimetry, and comparing it between unrelated proteins is not meaningful.
What you need, before anything runs
Nothing to buy, register for, or accept. No API key, no account, no GPU — the fit is CPU-only and a 96-well plate takes seconds.
One wrinkle at the install step: MoltenProt is not on PyPI. pip install moltenprot fails. You install it from its git repository or from a wheel attached to
a GitHub release. The standalone application is GPL-3.0, which permits commercial
use, and it is what this skill documents.
Be deliberate about which MoltenProt you are using. The same fitting is also offered through a separately hosted web service run by a third party, and a hosted service carries its own terms of use independent of the GPL on this code. If you are considering the web version, read its terms yourself — this skill makes no claim about them, and the command-line tool below needs no such check.
Install
python3 -m venv mp-venv
source mp-venv/bin/activate
pip install "moltenprot[multiproc]@git+https://github.com/kvr2007/moltenprot.git@master"
moltenprot --help | head -3
[multiproc] adds joblib, which is what -j needs to fit wells in parallel.
There is also a [gui] extra that pulls in Qt; skip it for scripted use. The
dependency set is otherwise just pandas, numpy, scipy, matplotlib and the two Excel
readers, all from PyPI.
To confirm the install beyond the help text, python -m moltenprot.test runs the
project's own test suite. Expect a single failure in its command-line test when
moltenprot is not on PATH — that test shells out by name, so it fails inside a
non-activated virtual environment while the other tests pass.
Three input shapes, and the one you have decides your flags
curl -sO https://raw.githubusercontent.com/kvr2007/moltenprot/master/moltenprot/demo_data/Ratio96.csv
curl -sO https://raw.githubusercontent.com/kvr2007/moltenprot/master/moltenprot/demo_data/Ratio_F330_F350_Scattering48.xlsx
curl -sO https://raw.githubusercontent.com/kvr2007/moltenprot/master/moltenprot/demo_data/CD_spectrum.csv
# An instrument spreadsheet carries several readouts. Ask which, before you fit.
moltenprot -i Ratio_F330_F350_Scattering48.xlsx --print_readouts
That prints Ratio 330nm 350nm Scattering deltaF for this file. Each readout is
fitted separately and gets its own results table, so excluding the ones you do not
want is the difference between one fit and five.
- Plain CSV — first column temperature, one column per sample. Name the signal
with
--readoutso the output files are labelled;-d Cor-d Ksets the temperature scale of the input. Use--sepand--decif your export is semicolon-and-comma rather than comma-and-point. - Instrument XLSX — readouts and any plate annotations are read out of the file.
Add
--rawif you exported the raw rather than the processed sheet,--panta-rheifor a Panta export,--refoldif the run included a refolding ramp. - Spectrum CSV —
--spectrumreinterprets the columns as wavelengths rather than samples, so each wavelength is fitted as its own unfolding curve. This is the path for CD and for wavelength-resolved fluorescence.
Running a plate
moltenprot -i Ratio96.csv -o out96 --readout Ratio -j 4
ls out96/Ratio96/
Output lands in <output>/<input-basename>/ and, per readout, you get
<readout>_results.csv (one row per well, the fitted parameters), a matching
_results_stdev.csv of standard errors, the preprocessed and fitted curves, and
MP_session.json, which is the whole session and is what to keep if you want to
reopen the analysis rather than re-fit it.
-r pdf|xlsx|html adds a report, --genpics writes per-well plots, --heatmaps all
writes plate heatmaps. None of them are needed to get the numbers.
"Fatal" does not mean a non-zero exit code
This is the trap that matters most for anything automated, and it is the one a reader of the documentation would not find.
MoltenProt prints Fatal: and exits 0 for a missing input file, for an output
folder that already exists, and for a readout name it does not recognise. Only an
argument-parsing failure, or an unhandled Python exception, gives you a non-zero
status.
moltenprot -i no_such_file.csv -o e1 ; echo "missing input -> rc=$?"
moltenprot -i Ratio96.csv -o out96 --readout Ratio ; echo "folder exists -> rc=$?"
moltenprot -i Ratio_F330_F350_Scattering48.xlsx -o e2 --exclude_readout Nonsense ; echo "bad readout -> rc=$?"
moltenprot -o e3 ; echo "no input at all -> rc=$?"
The first three print rc=0. So do not gate on the exit status. Check that the
results file you expect actually exists:
test -s out96/Ratio96/Ratio_results.csv || { echo "fit produced no results"; exit 1; }
Two related notes. The existing-folder refusal is why -f exists; it is a guard
against silently overwriting a previous fit, not an error to work around blindly.
And a malformed --dec does not come back as a clean message — it surfaces as a
TypeError from inside numpy's polyfit, because the columns parsed as strings.
A traceback mentioning polyfit almost always means the separator flags are wrong
for the file.
Reading the results table
Three things about this table will bite you if you assume otherwise.
import pandas as pd
r = pd.read_csv("out96/Ratio96/Ratio_results.csv", index_col=0)
print("shape:", r.shape)
print("columns:", list(r.columns))
# 1. Temperatures are in KELVIN, whatever scale the input used.
r["Tm_C"] = r["Tm_fit"] - 273.15
r["T_onset_C"] = r["T_onset"] - 273.15
print("\nTm range, K:", round(r.Tm_fit.min(), 2), "-", round(r.Tm_fit.max(), 2))
print("Tm range, C:", round(r.Tm_C.min(), 2), "-", round(r.Tm_C.max(), 2))
# 2. Rows are sorted by the model's own ranking statistic, NOT in plate order.
print("\nfirst five rows:", list(r.index[:5]))
print("sorted by dG_std descending:", r.dG_std.is_monotonic_decreasing)
# 3. Energies are in J/mol, not kJ/mol.
print("\ndG_std range, kJ/mol:", round(r.dG_std.min()/1000, 1), "-", round(r.dG_std.max()/1000, 1))
print(r[["Tm_C", "T_onset_C", "dG_std", "BS_factor"]].head(5).round(3).to_string())
Because of point 2, never take a well identity from a row position. Index by the well label. The sort key is a property of the model, and the order genuinely moves when you change fitting options — the same plate refitted with a different heat capacity reorders the table while every fitted value per well stays put.
The columns that matter, for the default two-state equilibrium model:
| column | what it is |
|---|---|
Tm_fit |
melting temperature, K |
dHm_fit |
van't Hoff unfolding enthalpy at Tm_fit, J/mol |
T_onset |
temperature at which 1% is unfolded, K — derived from Tm_fit and dHm_fit |
dG_std |
apparent free energy of unfolding at 298.15 K, J/mol — the ranking statistic |
S |
standard error of the fit; lower is a better fit |
BS_factor |
dimensionless signal window; higher is better, and this is what to filter on |
*_init |
the pre-fit starting estimates, not results — ignore them unless a fit failed |
Two of those are derived in closed form, which means you can check them rather than trust them:
import numpy as np, pandas as pd
r = pd.read_csv("out96/Ratio96/Ratio_results.csv", index_col=0)
T_std, R = 298.15, 8.314
# dG_std at dCp = 0 is the Becktel-Schellman extrapolation from Tm and dHm.
recomputed = r.dHm_fit * (1 - T_std / r.Tm_fit)
print("max |recomputed - reported| dG_std, J/mol:",
float((recomputed - r.dG_std).abs().max()))
# T_onset is Tm shifted to the temperature where the unfolded fraction is 1%.
onset = 1 / (1 / r.Tm_fit - R / r.dHm_fit * np.log(0.01 / 0.99))
print("max |recomputed - reported| T_onset, K:",
float((onset - r.T_onset).abs().max()))
Both come back at numerical zero. That is also the proof of the units: the identity
only closes with Tm_fit in kelvin and the energies in J/mol.
The heat capacity change is zero by default, and that inflates every dG_std
MoltenProt tells you this on every run — One or more dCp values are set to 0; this results in an overestimate of dG_std — and it is easy to read past.
The extrapolation from the melting temperature down to 25 °C needs the heat capacity
change of unfolding. With it left at zero the extrapolation is linear in temperature
and overshoots. Supply a value in J/mol/K with --dCp if you have one, or per sample
through a layout file.
moltenprot -i Ratio96.csv -o outdcp --readout Ratio --dCp 8000 -j 4
import pandas as pd
a = pd.read_csv("out96/Ratio96/Ratio_results.csv", index_col=0)
b = pd.read_csv("outdcp/Ratio96/Ratio_results.csv", index_col=0).reindex(a.index)
print("melting temperatures unchanged:", bool((a.Tm_fit - b.Tm_fit).abs().max() < 1e-9))
print("dG_std lower everywhere with dCp > 0:", bool((b.dG_std < a.dG_std).all()))
print("mean reduction: %.0f J/mol (%.1f%%)"
% ((a.dG_std - b.dG_std).mean(),
100 * ((a.dG_std - b.dG_std) / a.dG_std).mean()))
print("rank agreement, Spearman:", round(a.dG_std.corr(b.dG_std, method="spearman"), 4))
print("top five, dCp=0 :", list(a.index[:5]))
print("top five, dCp=8000:", list(b.sort_values('dG_std', ascending=False).index[:5]))
The practical reading of this, on the demo plate: the choice moves every value by
tens of percent but barely moves the ordering. So a relative ranking within one
plate survives leaving dCp at zero; an absolute free energy does not, and should
not be quoted from a default-settings run. It also does not touch the melting
temperatures at all, which is the clue that it affects only the extrapolation.
Filter on the signal window before you believe a ranking
A fit converges on a bad well as readily as on a good one. BS_factor is the
dimensionless size of the signal change relative to the scatter, and it is the
column to screen on — not S, which is scale-dependent.
import pandas as pd
r = pd.read_csv("out96/Ratio96/Ratio_results.csv", index_col=0)
print("BS_factor range: %.3f - %.3f" % (r.BS_factor.min(), r.BS_factor.max()))
print("wells below 0.8:", int((r.BS_factor < 0.8).sum()), "of", len(r))
keep = r[r.BS_factor >= 0.8]
print("\ntop three by dG_std, after filtering:")
print(keep.nlargest(3, "dG_std")[["Tm_fit", "dG_std", "BS_factor"]].round(2).to_string())
print("\nhighest melting temperature of all:")
print(r.nlargest(1, "Tm_fit")[["Tm_fit", "dG_std", "BS_factor"]].round(2).to_string())
0.8 is a starting point, not a standard — look at the distribution for your own assay and plot the curves for anything you exclude. The point of the last two blocks together is the one the tool exists for: on this plate the well with the highest melting temperature is not the one with the highest apparent stability.
Read those two outputs against each other. On the demo plate the hottest well is
also one of the five the filter drops — its signal window is 0.60, so part of why it
looks hot is that its curve is poorly resolved. That is the honest version of the
claim, and it survives the filter: among the wells that pass, the highest melting
temperature and the highest dG_std are still different wells.
Other input shapes
# An instrument export, fitting only the two readouts worth fitting.
moltenprot -i Ratio_F330_F350_Scattering48.xlsx -o outx \
--exclude_readout 330nm 350nm deltaF -j 4
ls outx/Ratio_F330_F350_Scattering48/
# A CD melt, one fit per wavelength.
moltenprot -i CD_spectrum.csv -o outcd --spectrum --readout CD -j 4
head -1 outcd/CD_spectrum/CD_results.csv
Note what comes back from the scattering readout. Aggregation is irreversible, so it
is fitted with an empirical model by default rather than the equilibrium one, and
that table has no dG_std column at all — it reports T_onset_fit, Tm_fit and
a combined T_eucl instead. The available columns follow from the model, so check
the header rather than assuming. --model_sct overrides the model used for
scattering; --model sets it for everything else.
The models are selected by name: santoro1988 (two-state equilibrium, the default,
ranks on dG_std), santoro1988i (three-state equilibrium, ranks on
dG_comb_std), santoro1988d and santoro1988di (empirical two- and three-state,
rank on a combined temperature T_eucl), irrev and lumry_eyring (kinetic, rank
on a rate constant), and skip to preprocess without fitting. Only the two
equilibrium models produce a free energy — if dG_std is the reason you reached
for this tool, do not change --model away from them without expecting the column
to vanish.
The Python API, when you want the fit in memory
The CLI writes files. For a pipeline that would only read them straight back in, drive it directly instead.
import moltenprot.core as mp
session = mp.parse_plain_csv("Ratio96.csv", readout="Ratio")
session.SetAnalysisOptions(model="santoro1988", dCp=0, savgol=10, baseline_fit=10)
session.PrepareAndAnalyseAll(n_jobs=4)
results = session.datasets["Ratio"].plate_results
print("readouts:", list(session.datasets))
print("shape:", results.shape)
print(results[["Tm_fit", "dG_std", "BS_factor"]].head(3).round(3).to_string())
print("standard temperature:", mp.T_std, "K | gas constant:", mp.R, "J/mol/K")
The parser you want depends on the input, mirroring the three shapes above:
parse_plain_csv, parse_prom_xlsx, parse_spectrum_csv. Each returns a session
holding one fit object per readout, keyed by readout name in .datasets — so the
results live at session.datasets["<readout>"].plate_results and not on the session
itself. WriteOutputAll writes the same files the CLI does.
Expect RuntimeWarning: overflow encountered in exp on the way through. The fitter
evaluates the model well outside its sensible range while searching, numpy says so,
and it does not indicate a bad fit — judge that from BS_factor and S.
Try it
A cold check that this skill still holds, from one public file and nothing else. No account, no key, no GPU. Needs network access twice, to install and to fetch.
Data — Ratio96.csv, the 96-well nanoDSF demo plate distributed in the
MoltenProt repository (1137 temperature points, 96 wells, a 330/350 nm fluorescence
ratio). It ships inside the GPL-3.0 source tree, so anyone who can get the tool can
get the data. Last confirmed reachable 2026-10-06.
https://raw.githubusercontent.com/kvr2007/moltenprot/master/moltenprot/demo_data/Ratio96.csv
The example routes through all three traps: a fatal error that exits 0, kelvin temperatures, and a results table whose row order is a ranking rather than the plate.
Run
python3 -m venv mp-check && . mp-check/bin/activate
pip install -q "moltenprot[multiproc]@git+https://github.com/kvr2007/moltenprot.git@master"
curl -sO https://raw.githubusercontent.com/kvr2007/moltenprot/master/moltenprot/demo_data/Ratio96.csv
# The trap, first: a fatal error reports success.
moltenprot -i absent.csv -o nowhere ; echo "exit code after a fatal error: $?"
moltenprot -i Ratio96.csv -o out --readout Ratio -j 4
moltenprot -i Ratio96.csv -o out_dcp --readout Ratio --dCp 8000 -j 4
test -s out/Ratio96/Ratio_results.csv && echo "results written"
import numpy as np, pandas as pd
r = pd.read_csv("out/Ratio96/Ratio_results.csv", index_col=0)
b = pd.read_csv("out_dcp/Ratio96/Ratio_results.csv", index_col=0).reindex(r.index)
print("wells fitted:", len(r), "| no failed fits:", int(r.Tm_fit.isna().sum()) == 0)
# INVARIANT: temperatures are kelvin. The closed-form identities only hold in kelvin.
T_std, R = 298.15, 8.314
print("max |recomputed - reported| dG_std, J/mol: %.2e"
% float((r.dHm_fit * (1 - T_std / r.Tm_fit) - r.dG_std).abs().max()))
print("max |recomputed - reported| T_onset, K: %.2e"
% float((1 / (1 / r.Tm_fit - R / r.dHm_fit * np.log(0.01 / 0.99)) - r.T_onset).abs().max()))
# INVARIANT: the table is ranked by dG_std, not laid out by plate.
print("ranked by dG_std:", r.dG_std.is_monotonic_decreasing,
"| plate order:", list(r.index[:3]) == ["A1", "A2", "A3"])
# INVARIANT: dCp only moves the extrapolation, never the melt itself.
print("Tm unchanged by --dCp:", bool((r.Tm_fit - b.Tm_fit).abs().max() < 1e-9),
"| dG_std lower everywhere:", bool((b.dG_std < r.dG_std).all()))
# THE POINT: two wells, one melting temperature, very different stability.
r["Tm_C"] = r.Tm_fit - 273.15
pair = r.loc[["G7", "G5"], ["Tm_C", "dG_std", "BS_factor"]]
print("\n" + pair.round(3).to_string())
print("dTm = %.3f C ; d(dG_std) = %.1f kJ/mol"
% (abs(pair.Tm_C.diff().iloc[1]), abs(pair.dG_std.diff().iloc[1]) / 1000))
# OBSERVED: hottest melt is not the most stable well.
print("\nhighest Tm :", r.Tm_C.idxmax(), "at %.2f C," % r.Tm_C.max(),
"dG_std %.1f kJ/mol" % (r.dG_std[r.Tm_C.idxmax()] / 1000))
print("highest dG_std:", r.dG_std.idxmax(), "at %.2f C," % r.Tm_C[r.dG_std.idxmax()],
"dG_std %.1f kJ/mol" % (r.dG_std.max() / 1000))
print("Spearman, Tm vs dG_std:", round(r.Tm_C.corr(r.dG_std, method="spearman"), 3))
Expect
Invariants — a mismatch here means this skill is wrong:
exit code after a fatal error: 0, printed after aFatal:line saying the file does not exist. If this ever becomes non-zero, the skill is out of date in the reader's favour and the exit-code section can be simplified.- 96 wells fitted, none failed.
- Both closed-form identities close to numerical zero (order 1e-10 J/mol and 1e-13 K). This is simultaneously the units check.
ranked by dG_std: Trueandplate order: False.--dCpleaves everyTm_fitbit-identical and lowers everydG_std.- Wells G7 and G5 sit within 0.1 °C of each other and differ by more
than 20 kJ/mol in
dG_std, both withBS_factorabove 0.8 so neither is a bad fit. This is the claim the tool is for. - The well with the highest melting temperature is not the well with the highest
dG_std.
Observed on 2026-10-06 with moltenprot 0.4.1b0 at git master commit 20d9382, pandas 2.3.3, numpy 2.4.6, scipy 1.17.1, Python 3.11.17 — a change in these is drift to investigate, not a failure:
- G7 is 65.04 °C / 60.4 kJ/mol and G5 is 64.95 °C / 35.9 kJ/mol, a gap of 24.6 kJ/mol at a melting-temperature difference of 0.082 °C.
- Melting temperatures span 320.93–340.96 K (47.78–67.81 °C);
dG_stdspans 22.9–68.3 kJ/mol. - The hottest melt is E7 at 67.81 °C with 38.2 kJ/mol; the highest
dG_stdis H7 at 65.80 °C with 68.3 kJ/mol. Spearman between the two rankings is 0.783 — correlated, and not a substitute for one another. - E7's
BS_factoris 0.602, so it is one of the five wells a 0.8 filter drops. Among the wells that pass that filter the hottest is F1 at 66.31 °C with 62.5 kJ/mol, still not the highestdG_std. --dCp 8000lowersdG_stdby a mean of 17397 J/mol (37.7%), with Spearman 0.9495 against the default run.- 5 of 96 wells fall below
BS_factor0.8. - The fit takes a few seconds with
-j 4, and prints thedCpwarning plusRuntimeWarning: overflow encountered in expfrom the fitter.
Sources
- Repository and
COPYING(GPL-3.0): https://github.com/kvr2007/moltenprot - Releases, including prebuilt wheels and desktop binaries: https://github.com/kvr2007/moltenprot/releases
- Kotov V, Mlynek G, Vesper O, et al. In-depth interrogation of protein thermal unfolding data with MoltenProt. Protein Science (2021). doi:10.1002/pro.3986
- Becktel WJ, Schellman JA. Protein stability curves. Biopolymers (1987).
doi:10.1002/bip.360261104 — the extrapolation
dG_stdis computed with.