hxprobe 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hxprobe/__init__.py +29 -0
- hxprobe/cli.py +101 -0
- hxprobe/data/ubiquitin_dgopen.csv +42 -0
- hxprobe/data/ubiquitin_ensemble.pdb.gz +0 -0
- hxprobe/diff.py +68 -0
- hxprobe/ensemble.py +60 -0
- hxprobe/operator.py +252 -0
- hxprobe/probe.py +165 -0
- hxprobe/protonate.py +70 -0
- hxprobe-0.1.0.dist-info/METADATA +165 -0
- hxprobe-0.1.0.dist-info/RECORD +15 -0
- hxprobe-0.1.0.dist-info/WHEEL +5 -0
- hxprobe-0.1.0.dist-info/entry_points.txt +2 -0
- hxprobe-0.1.0.dist-info/licenses/LICENSE +21 -0
- hxprobe-0.1.0.dist-info/top_level.txt +1 -0
hxprobe/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""hxprobe -- the 50-conformer probe.
|
|
2
|
+
|
|
3
|
+
Read residue-resolved hydrogen-exchange opening free energies out of a
|
|
4
|
+
conformational ensemble with a white-box, two-parameter physical operator.
|
|
5
|
+
"""
|
|
6
|
+
from .operator import (BETA_C, BETA_H, CUT_NC_NM, CUT_NH_NM, IJ_NC, IJ_NH,
|
|
7
|
+
R_KCAL, T_REF, ProtectionResult, compute, nc_nh_frame)
|
|
8
|
+
from .ensemble import load_ensemble, optionally_protonate
|
|
9
|
+
from .probe import (convergence, example_ensemble_path, global_unfolding,
|
|
10
|
+
load_experimental, score_ensemble, spearman)
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"score_ensemble",
|
|
17
|
+
"convergence",
|
|
18
|
+
"global_unfolding",
|
|
19
|
+
"compute",
|
|
20
|
+
"nc_nh_frame",
|
|
21
|
+
"ProtectionResult",
|
|
22
|
+
"load_ensemble",
|
|
23
|
+
"optionally_protonate",
|
|
24
|
+
"example_ensemble_path",
|
|
25
|
+
"load_experimental",
|
|
26
|
+
"spearman",
|
|
27
|
+
"BETA_C", "BETA_H", "CUT_NC_NM", "CUT_NH_NM", "IJ_NC", "IJ_NH",
|
|
28
|
+
"R_KCAL", "T_REF",
|
|
29
|
+
]
|
hxprobe/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Command-line interface for hxprobe."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _add_common(p):
|
|
9
|
+
p.add_argument("ensemble", help="multi-model PDB (.pdb/.pdb.gz) or trajectory file")
|
|
10
|
+
p.add_argument("--top", default=None, help="topology file (for trajectory inputs)")
|
|
11
|
+
p.add_argument("--protonate", default="auto",
|
|
12
|
+
choices=["auto", "none", "pdbfixer"],
|
|
13
|
+
help="how to obtain backbone amide hydrogens (default: auto)")
|
|
14
|
+
p.add_argument("--betaC", type=float, default=None, help="contact coefficient")
|
|
15
|
+
p.add_argument("--betaH", type=float, default=None, help="H-bond coefficient")
|
|
16
|
+
p.add_argument("--temperature", type=float, default=None, help="temperature (K)")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _operator_kw(args):
|
|
20
|
+
from . import BETA_C, BETA_H, T_REF
|
|
21
|
+
return dict(
|
|
22
|
+
protonate=args.protonate,
|
|
23
|
+
betaC=BETA_C if args.betaC is None else args.betaC,
|
|
24
|
+
betaH=BETA_H if args.betaH is None else args.betaH,
|
|
25
|
+
temperature=T_REF if args.temperature is None else args.temperature,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cmd_score(args):
|
|
30
|
+
from . import score_ensemble
|
|
31
|
+
res = score_ensemble(args.ensemble, top=args.top, **_operator_kw(args))
|
|
32
|
+
df = res.to_dataframe()
|
|
33
|
+
if args.out:
|
|
34
|
+
res.to_csv(args.out)
|
|
35
|
+
print(f"wrote {len(res)} residues to {args.out}")
|
|
36
|
+
else:
|
|
37
|
+
print(df.to_string(index=False))
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cmd_converge(args):
|
|
42
|
+
from . import convergence
|
|
43
|
+
out = convergence(args.ensemble, top=args.top, **_operator_kw(args))
|
|
44
|
+
try:
|
|
45
|
+
print(out.to_string(index=False))
|
|
46
|
+
except AttributeError:
|
|
47
|
+
for row in out:
|
|
48
|
+
print(row)
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cmd_example(args):
|
|
53
|
+
from . import (convergence, example_ensemble_path, load_experimental,
|
|
54
|
+
score_ensemble, spearman)
|
|
55
|
+
path = example_ensemble_path()
|
|
56
|
+
exp = load_experimental()
|
|
57
|
+
print(f"bundled example: 50-conformer leakage-free ubiquitin ensemble\n {path}")
|
|
58
|
+
res = score_ensemble(path, protonate="none") # already protonated
|
|
59
|
+
ref = [exp.get(int(rs)) for rs in res.resSeq]
|
|
60
|
+
rho = spearman(res.lnPF, [r if r is not None else float("nan") for r in ref])
|
|
61
|
+
n_overlap = sum(1 for r in ref if r is not None)
|
|
62
|
+
print(f"\nper-residue ln PF vs experimental dG_open (native-state HX):")
|
|
63
|
+
print(f" Spearman rho = {rho:+.3f} over {n_overlap} measured residues")
|
|
64
|
+
print(f"\nconvergence with ensemble size:")
|
|
65
|
+
conv = convergence(path, protonate="none", reference=exp)
|
|
66
|
+
try:
|
|
67
|
+
print(conv.to_string(index=False))
|
|
68
|
+
except AttributeError:
|
|
69
|
+
for row in conv:
|
|
70
|
+
print(" ", row)
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_parser():
|
|
75
|
+
p = argparse.ArgumentParser(
|
|
76
|
+
prog="hxprobe",
|
|
77
|
+
description="The 50-conformer probe: residue-resolved hydrogen-exchange "
|
|
78
|
+
"opening free energies from conformational ensembles.")
|
|
79
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
80
|
+
|
|
81
|
+
s = sub.add_parser("score", help="per-residue opening free energies for an ensemble")
|
|
82
|
+
_add_common(s)
|
|
83
|
+
s.add_argument("--out", default=None, help="write a CSV instead of printing")
|
|
84
|
+
s.set_defaults(func=_cmd_score)
|
|
85
|
+
|
|
86
|
+
c = sub.add_parser("converge", help="convergence of the readout with ensemble size")
|
|
87
|
+
_add_common(c)
|
|
88
|
+
c.set_defaults(func=_cmd_converge)
|
|
89
|
+
|
|
90
|
+
e = sub.add_parser("example", help="run the bundled ubiquitin example")
|
|
91
|
+
e.set_defaults(func=_cmd_example)
|
|
92
|
+
return p
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv=None):
|
|
96
|
+
args = build_parser().parse_args(argv)
|
|
97
|
+
return args.func(args)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
sys.exit(main())
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
resi,dGopen_kcal,grade,resn
|
|
2
|
+
2,1.831,B,
|
|
3
|
+
3,6.335,B,
|
|
4
|
+
4,6.307,B,
|
|
5
|
+
5,5.897,B,
|
|
6
|
+
6,4.929,B,
|
|
7
|
+
7,6.108,B,
|
|
8
|
+
12,2.969,B,
|
|
9
|
+
13,5.202,B,
|
|
10
|
+
15,2.526,B,
|
|
11
|
+
16,3.436,B,
|
|
12
|
+
17,5.565,B,
|
|
13
|
+
22,2.171,B,
|
|
14
|
+
23,5.771,B,
|
|
15
|
+
25,4.292,B,
|
|
16
|
+
26,7.699,B,
|
|
17
|
+
28,5.513,B,
|
|
18
|
+
29,5.897,B,
|
|
19
|
+
30,7.169,B,
|
|
20
|
+
31,4.703,B,
|
|
21
|
+
32,2.015,B,
|
|
22
|
+
36,2.837,B,
|
|
23
|
+
40,2.679,B,
|
|
24
|
+
41,4.093,B,
|
|
25
|
+
42,4.503,B,
|
|
26
|
+
44,6.307,B,
|
|
27
|
+
45,4.333,B,
|
|
28
|
+
48,4.586,B,
|
|
29
|
+
49,2.482,B,
|
|
30
|
+
50,3.855,B,
|
|
31
|
+
55,4.818,B,
|
|
32
|
+
56,6.045,B,
|
|
33
|
+
57,3.436,B,
|
|
34
|
+
58,2.969,B,
|
|
35
|
+
59,5.612,B,
|
|
36
|
+
60,2.426,B,
|
|
37
|
+
61,4.611,B,
|
|
38
|
+
65,4.201,B,
|
|
39
|
+
67,3.93,B,
|
|
40
|
+
68,4.407,B,
|
|
41
|
+
69,5.339,B,
|
|
42
|
+
70,4.724,B,
|
|
Binary file
|
hxprobe/diff.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Differentiable Best--Vendruscolo operator (optional, requires PyTorch).
|
|
2
|
+
|
|
3
|
+
The hard contact and hydrogen-bond counts are replaced by smooth sigmoidal
|
|
4
|
+
switching functions controlled by a temperature ``tau``; the discrete operator
|
|
5
|
+
is recovered as ``tau -> 0``. Because protection then becomes a differentiable
|
|
6
|
+
function of atomic coordinates, the residue-level readout can in principle
|
|
7
|
+
provide gradients to steer a generator toward the rare openings it
|
|
8
|
+
under-populates.
|
|
9
|
+
|
|
10
|
+
Install with ``pip install hxprobe[diff]``.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .operator import BETA_C, BETA_H, CUT_NC_NM, CUT_NH_NM, IJ_NC, IJ_NH, R_KCAL, T_REF
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def soft_nc_nh(xyz, amideN_idx, amideH_idx, heavy_idx, O_idx, resid,
|
|
18
|
+
cut_Nc=CUT_NC_NM, cut_Nh=CUT_NH_NM, ij_Nc=IJ_NC, ij_Nh=IJ_NH,
|
|
19
|
+
tau=0.02):
|
|
20
|
+
"""Differentiable per-residue (N_C, N_H) for one conformer.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
xyz : torch.Tensor ``[n_atoms, 3]`` (nanometres, requires_grad as needed)
|
|
25
|
+
amideN_idx, amideH_idx : list[int]
|
|
26
|
+
Per-residue amide N / amide H atom indices (``-1`` if absent).
|
|
27
|
+
heavy_idx, O_idx : list[int]
|
|
28
|
+
Heavy-atom and backbone-carbonyl-oxygen atom indices.
|
|
29
|
+
resid : sequence[int]
|
|
30
|
+
Residue index of every atom (used for the sequence-separation mask).
|
|
31
|
+
tau : float
|
|
32
|
+
Switching temperature; smaller is sharper (recovers the hard operator).
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
(NC, NH) : torch.Tensor, torch.Tensor each ``[n_res]``
|
|
37
|
+
"""
|
|
38
|
+
import torch
|
|
39
|
+
|
|
40
|
+
dev = xyz.device
|
|
41
|
+
R = len(amideN_idx)
|
|
42
|
+
NC = torch.zeros(R, device=dev)
|
|
43
|
+
NH = torch.zeros(R, device=dev)
|
|
44
|
+
heavy = torch.as_tensor(heavy_idx, device=dev, dtype=torch.long)
|
|
45
|
+
hres = torch.as_tensor([resid[i] for i in heavy_idx], device=dev)
|
|
46
|
+
Os = torch.as_tensor(O_idx, device=dev, dtype=torch.long)
|
|
47
|
+
Ores = torch.as_tensor([resid[i] for i in O_idx], device=dev)
|
|
48
|
+
for r in range(R):
|
|
49
|
+
ni = amideN_idx[r]
|
|
50
|
+
if ni < 0:
|
|
51
|
+
continue
|
|
52
|
+
ri = resid[ni]
|
|
53
|
+
d = torch.norm(xyz[heavy] - xyz[ni], dim=1)
|
|
54
|
+
mask_c = (torch.abs(hres - ri) >= ij_Nc).float()
|
|
55
|
+
NC[r] = torch.sum(torch.sigmoid((cut_Nc - d) / tau) * mask_c)
|
|
56
|
+
hi = amideH_idx[r]
|
|
57
|
+
if hi >= 0:
|
|
58
|
+
dh = torch.norm(xyz[Os] - xyz[hi], dim=1)
|
|
59
|
+
mask_h = (torch.abs(Ores - ri) >= ij_Nh).float()
|
|
60
|
+
NH[r] = torch.sum(torch.sigmoid((cut_Nh - dh) / tau) * mask_h)
|
|
61
|
+
return NC, NH
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def soft_lnpf(xyz, amideN_idx, amideH_idx, heavy_idx, O_idx, resid,
|
|
65
|
+
betaC=BETA_C, betaH=BETA_H, **kw):
|
|
66
|
+
"""Differentiable per-residue ln PF for one conformer."""
|
|
67
|
+
NC, NH = soft_nc_nh(xyz, amideN_idx, amideH_idx, heavy_idx, O_idx, resid, **kw)
|
|
68
|
+
return betaC * NC + betaH * NH
|
hxprobe/ensemble.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Loading conformational ensembles.
|
|
2
|
+
|
|
3
|
+
Thin wrappers over MDTraj that accept the common ways an ensemble is stored:
|
|
4
|
+
a multi-model PDB (optionally gzipped), or a trajectory file plus a topology.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_ensemble(path: str, top: Optional[str] = None):
|
|
12
|
+
"""Load a conformational ensemble as an ``mdtraj.Trajectory``.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
path : str
|
|
17
|
+
A multi-model PDB (``.pdb`` / ``.pdb.gz``) or a trajectory file
|
|
18
|
+
(``.xtc``, ``.dcd``, ``.h5`` ...).
|
|
19
|
+
top : str, optional
|
|
20
|
+
Topology file (e.g. a ``.pdb``); required for trajectory formats that
|
|
21
|
+
do not embed topology.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
mdtraj.Trajectory
|
|
26
|
+
"""
|
|
27
|
+
import mdtraj as md
|
|
28
|
+
|
|
29
|
+
if top is not None:
|
|
30
|
+
return md.load(path, top=top)
|
|
31
|
+
return md.load(path)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def optionally_protonate(traj, method: str = "auto"):
|
|
35
|
+
"""Return an ensemble guaranteed to be scorable for the H-bond term.
|
|
36
|
+
|
|
37
|
+
``method``:
|
|
38
|
+
|
|
39
|
+
* ``"none"`` -- score as-is (geometric amide-H placement is used inside
|
|
40
|
+
the operator when explicit hydrogens are absent).
|
|
41
|
+
* ``"pdbfixer"`` -- repair missing heavy atoms and add real hydrogens with
|
|
42
|
+
PDBFixer/OpenMM (the ``hxprobe[fix]`` extra). Most faithful for raw
|
|
43
|
+
crystal or heavy-atom generated structures.
|
|
44
|
+
* ``"auto"`` (default) -- use PDBFixer if it is installed and hydrogens are
|
|
45
|
+
missing, otherwise fall back to ``"none"``.
|
|
46
|
+
"""
|
|
47
|
+
from .protonate import has_explicit_hydrogens, pdbfixer_protonate
|
|
48
|
+
|
|
49
|
+
if method == "none":
|
|
50
|
+
return traj
|
|
51
|
+
if has_explicit_hydrogens(traj):
|
|
52
|
+
return traj
|
|
53
|
+
if method == "pdbfixer":
|
|
54
|
+
return pdbfixer_protonate(traj)
|
|
55
|
+
if method == "auto":
|
|
56
|
+
try:
|
|
57
|
+
return pdbfixer_protonate(traj)
|
|
58
|
+
except Exception:
|
|
59
|
+
return traj
|
|
60
|
+
raise ValueError(f"unknown protonation method: {method!r}")
|
hxprobe/operator.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""The white-box Best--Vendruscolo forward operator.
|
|
2
|
+
|
|
3
|
+
For each backbone amide *i* the log protection factor is the ensemble average
|
|
4
|
+
|
|
5
|
+
ln PF_i = beta_C * <N_C,i> + beta_H * <N_H,i>
|
|
6
|
+
|
|
7
|
+
where ``N_C`` is the number of heavy atoms within ``cut_Nc`` of the amide
|
|
8
|
+
nitrogen (sequence separation >= ``ij_Nc``) and ``N_H`` is the number of
|
|
9
|
+
backbone carbonyl oxygens within ``cut_Nh`` of the amide hydrogen (sequence
|
|
10
|
+
separation >= ``ij_Nh``). Under the EX2 regime the protection factor converts
|
|
11
|
+
to a per-residue opening free energy
|
|
12
|
+
|
|
13
|
+
dG_open,i = RT * ln PF_i.
|
|
14
|
+
|
|
15
|
+
The two coefficients are fixed to their classical Best--Vendruscolo values
|
|
16
|
+
(0.35 and 2.0) and are *not* fitted to stability data, so any residue-level
|
|
17
|
+
signal the operator recovers originates in the ensemble itself.
|
|
18
|
+
|
|
19
|
+
This is a faithful, dependency-light re-implementation of the operator used in
|
|
20
|
+
the accompanying study; geometry is computed with MDTraj and NumPy only.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
# Best--Vendruscolo classical coefficients (never fitted to stability labels).
|
|
30
|
+
BETA_C = 0.35
|
|
31
|
+
BETA_H = 2.0
|
|
32
|
+
# Geometric cut-offs in nanometres (6.5 Angstrom contacts, 2.6 Angstrom H-bond).
|
|
33
|
+
CUT_NC_NM = 0.65
|
|
34
|
+
CUT_NH_NM = 0.26
|
|
35
|
+
IJ_NC = 3
|
|
36
|
+
IJ_NH = 2
|
|
37
|
+
# Gas constant (kcal / mol / K) and reference temperature.
|
|
38
|
+
R_KCAL = 0.0019872041
|
|
39
|
+
T_REF = 298.15
|
|
40
|
+
|
|
41
|
+
_THREE_TO_ONE = {
|
|
42
|
+
"ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C", "GLN": "Q",
|
|
43
|
+
"GLU": "E", "GLY": "G", "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K",
|
|
44
|
+
"MET": "M", "PHE": "F", "PRO": "P", "SER": "S", "THR": "T", "TRP": "W",
|
|
45
|
+
"TYR": "Y", "VAL": "V",
|
|
46
|
+
}
|
|
47
|
+
_AMIDE_H_NAMES = ("H", "HN", "H1", "HT1")
|
|
48
|
+
_N_H_BOND_NM = 0.101 # ~1.01 Angstrom N-H bond length, for geometric placement.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ProtectionResult:
|
|
53
|
+
"""Per-residue protection factors and opening free energies for an ensemble."""
|
|
54
|
+
|
|
55
|
+
resSeq: np.ndarray # residue numbers (author/topology numbering)
|
|
56
|
+
resn: np.ndarray # one-letter residue names
|
|
57
|
+
NC_mean: np.ndarray # ensemble-averaged heavy-atom contacts
|
|
58
|
+
NH_mean: np.ndarray # ensemble-averaged amide hydrogen bonds
|
|
59
|
+
lnPF: np.ndarray # ln protection factor
|
|
60
|
+
dGopen_kcal: np.ndarray # opening free energy (kcal/mol)
|
|
61
|
+
NC: np.ndarray = field(repr=False) # per-conformer contacts [n_res, n_frames]
|
|
62
|
+
NH: np.ndarray = field(repr=False) # per-conformer H-bonds [n_res, n_frames]
|
|
63
|
+
weights: np.ndarray = field(repr=False)
|
|
64
|
+
temperature: float = T_REF
|
|
65
|
+
n_frames: int = 0
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def log10PF(self) -> np.ndarray:
|
|
69
|
+
return self.lnPF / np.log(10.0)
|
|
70
|
+
|
|
71
|
+
def to_dataframe(self):
|
|
72
|
+
import pandas as pd
|
|
73
|
+
return pd.DataFrame({
|
|
74
|
+
"resSeq": self.resSeq,
|
|
75
|
+
"resn": self.resn,
|
|
76
|
+
"NC_mean": self.NC_mean,
|
|
77
|
+
"NH_mean": self.NH_mean,
|
|
78
|
+
"lnPF": self.lnPF,
|
|
79
|
+
"log10PF": self.log10PF,
|
|
80
|
+
"dGopen_kcal": self.dGopen_kcal,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
def to_csv(self, path: str) -> None:
|
|
84
|
+
self.to_dataframe().to_csv(path, index=False)
|
|
85
|
+
|
|
86
|
+
def __len__(self) -> int:
|
|
87
|
+
return len(self.resSeq)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _largest_protein_chain(traj):
|
|
91
|
+
"""Atom-slice ``traj`` down to the chain with the most standard residues."""
|
|
92
|
+
top = traj.topology
|
|
93
|
+
best, best_n = None, -1
|
|
94
|
+
for ch in top.chains:
|
|
95
|
+
n = sum(1 for r in ch.residues if r.name in _THREE_TO_ONE)
|
|
96
|
+
if n > best_n:
|
|
97
|
+
best, best_n = ch, n
|
|
98
|
+
if best is None:
|
|
99
|
+
return traj
|
|
100
|
+
return traj.atom_slice([a.index for a in best.atoms])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _geometric_amide_h(xyz, top):
|
|
104
|
+
"""Geometric backbone amide-H position per residue, in the residue plane.
|
|
105
|
+
|
|
106
|
+
Used only when explicit hydrogens are absent. H is placed 1.01 A from N
|
|
107
|
+
along the external bisector of the C(prev)-N-CA angle, the standard sp2
|
|
108
|
+
amide placement. Returns ``{residue.index: h_xyz_nm}``.
|
|
109
|
+
"""
|
|
110
|
+
out = {}
|
|
111
|
+
residues = list(top.residues)
|
|
112
|
+
for r in residues:
|
|
113
|
+
if r.name == "PRO" or r.name not in _THREE_TO_ONE:
|
|
114
|
+
continue
|
|
115
|
+
atoms = {a.name: a.index for a in r.atoms}
|
|
116
|
+
if "N" not in atoms or "CA" not in atoms:
|
|
117
|
+
continue
|
|
118
|
+
prev_c = None
|
|
119
|
+
if r.index > 0:
|
|
120
|
+
pr = residues[r.index - 1]
|
|
121
|
+
if pr.chain.index == r.chain.index:
|
|
122
|
+
prev_c = next((a.index for a in pr.atoms if a.name == "C"), None)
|
|
123
|
+
if prev_c is None:
|
|
124
|
+
continue
|
|
125
|
+
n = xyz[atoms["N"]]
|
|
126
|
+
u_ca = xyz[atoms["CA"]] - n
|
|
127
|
+
u_c = xyz[prev_c] - n
|
|
128
|
+
nu_ca = np.linalg.norm(u_ca)
|
|
129
|
+
nu_c = np.linalg.norm(u_c)
|
|
130
|
+
if nu_ca < 1e-6 or nu_c < 1e-6:
|
|
131
|
+
continue
|
|
132
|
+
bis = u_ca / nu_ca + u_c / nu_c
|
|
133
|
+
nb = np.linalg.norm(bis)
|
|
134
|
+
if nb < 1e-6:
|
|
135
|
+
continue
|
|
136
|
+
out[r.index] = n - _N_H_BOND_NM * (bis / nb)
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def nc_nh_frame(traj_single, cut_Nc=CUT_NC_NM, cut_Nh=CUT_NH_NM,
|
|
141
|
+
ij_Nc=IJ_NC, ij_Nh=IJ_NH, place_h_if_missing=True):
|
|
142
|
+
"""Per-residue (N_C, N_H) for a single-frame MDTraj trajectory.
|
|
143
|
+
|
|
144
|
+
Returns ``{resSeq: (one_letter, N_C, N_H)}``. Prolines and non-standard
|
|
145
|
+
residues are skipped (no exchangeable backbone amide).
|
|
146
|
+
"""
|
|
147
|
+
top = traj_single.topology
|
|
148
|
+
xyz = traj_single.xyz[0]
|
|
149
|
+
heavy = np.array([a.index for a in top.atoms
|
|
150
|
+
if a.element is not None and a.element.symbol != "H"])
|
|
151
|
+
hv_res = np.array([top.atom(i).residue.index for i in heavy])
|
|
152
|
+
o_idx = np.array([a.index for a in top.atoms if a.name == "O"])
|
|
153
|
+
o_res = np.array([top.atom(i).residue.index for i in o_idx])
|
|
154
|
+
|
|
155
|
+
has_explicit_h = any(a.name in _AMIDE_H_NAMES and a.element is not None
|
|
156
|
+
and a.element.symbol == "H" for a in top.atoms)
|
|
157
|
+
geo_h = {} if has_explicit_h else (
|
|
158
|
+
_geometric_amide_h(xyz, top) if place_h_if_missing else {})
|
|
159
|
+
|
|
160
|
+
out = {}
|
|
161
|
+
for res in top.residues:
|
|
162
|
+
if res.name == "PRO" or res.name not in _THREE_TO_ONE:
|
|
163
|
+
continue
|
|
164
|
+
ns = [a for a in res.atoms if a.name == "N"]
|
|
165
|
+
if not ns:
|
|
166
|
+
continue
|
|
167
|
+
ri = res.index
|
|
168
|
+
npos = xyz[ns[0].index]
|
|
169
|
+
if heavy.size:
|
|
170
|
+
d = np.linalg.norm(xyz[heavy] - npos, axis=1)
|
|
171
|
+
nc = int(((d < cut_Nc) & (np.abs(hv_res - ri) >= ij_Nc)).sum())
|
|
172
|
+
else:
|
|
173
|
+
nc = 0
|
|
174
|
+
|
|
175
|
+
h_pos = None
|
|
176
|
+
hs = [a for a in res.atoms if a.name in _AMIDE_H_NAMES]
|
|
177
|
+
if hs:
|
|
178
|
+
h_pos = xyz[hs[0].index]
|
|
179
|
+
elif ri in geo_h:
|
|
180
|
+
h_pos = geo_h[ri]
|
|
181
|
+
|
|
182
|
+
nh = 0
|
|
183
|
+
if h_pos is not None and o_idx.size:
|
|
184
|
+
dh = np.linalg.norm(xyz[o_idx] - h_pos, axis=1)
|
|
185
|
+
nh = int(((dh < cut_Nh) & (np.abs(o_res - ri) >= ij_Nh)).sum())
|
|
186
|
+
|
|
187
|
+
out[res.resSeq] = (_THREE_TO_ONE[res.name], nc, nh)
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def compute(traj, weights=None, betaC=BETA_C, betaH=BETA_H,
|
|
192
|
+
cut_Nc=CUT_NC_NM, cut_Nh=CUT_NH_NM, ij_Nc=IJ_NC, ij_Nh=IJ_NH,
|
|
193
|
+
temperature=T_REF, select_largest_chain=True,
|
|
194
|
+
place_h_if_missing=True) -> ProtectionResult:
|
|
195
|
+
"""Score a conformational ensemble into per-residue opening free energies.
|
|
196
|
+
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
traj : mdtraj.Trajectory
|
|
200
|
+
The conformational ensemble (one or more frames).
|
|
201
|
+
weights : array-like, optional
|
|
202
|
+
Per-conformer Boltzmann weights (defaults to uniform).
|
|
203
|
+
betaC, betaH : float
|
|
204
|
+
Operator coefficients (default to the classical 0.35 / 2.0).
|
|
205
|
+
temperature : float
|
|
206
|
+
Temperature (K) for the RT * ln PF conversion.
|
|
207
|
+
"""
|
|
208
|
+
if select_largest_chain:
|
|
209
|
+
traj = _largest_protein_chain(traj)
|
|
210
|
+
F = traj.n_frames
|
|
211
|
+
if weights is None:
|
|
212
|
+
w = np.ones(F) / F
|
|
213
|
+
else:
|
|
214
|
+
w = np.asarray(weights, float)
|
|
215
|
+
w = w / w.sum()
|
|
216
|
+
|
|
217
|
+
perframe = [nc_nh_frame(traj[k], cut_Nc, cut_Nh, ij_Nc, ij_Nh,
|
|
218
|
+
place_h_if_missing) for k in range(F)]
|
|
219
|
+
all_res = sorted(set().union(*[set(d) for d in perframe])) if perframe else []
|
|
220
|
+
|
|
221
|
+
resSeq, resn = [], []
|
|
222
|
+
NCm, NHm = [], []
|
|
223
|
+
for rs in all_res:
|
|
224
|
+
ncv = np.array([perframe[k][rs][1] if rs in perframe[k] else np.nan
|
|
225
|
+
for k in range(F)], float)
|
|
226
|
+
nhv = np.array([perframe[k][rs][2] if rs in perframe[k] else np.nan
|
|
227
|
+
for k in range(F)], float)
|
|
228
|
+
name = next(perframe[k][rs][0] for k in range(F) if rs in perframe[k])
|
|
229
|
+
resSeq.append(rs)
|
|
230
|
+
resn.append(name)
|
|
231
|
+
NCm.append(ncv)
|
|
232
|
+
NHm.append(nhv)
|
|
233
|
+
|
|
234
|
+
NC = np.array(NCm) if NCm else np.zeros((0, F))
|
|
235
|
+
NH = np.array(NHm) if NHm else np.zeros((0, F))
|
|
236
|
+
|
|
237
|
+
# weighted ensemble average over the frames in which each residue is present
|
|
238
|
+
nc_mean = np.zeros(len(all_res))
|
|
239
|
+
nh_mean = np.zeros(len(all_res))
|
|
240
|
+
for i in range(len(all_res)):
|
|
241
|
+
m = ~np.isnan(NC[i])
|
|
242
|
+
ww = w[m] / w[m].sum() if m.any() else w
|
|
243
|
+
nc_mean[i] = np.sum(ww * NC[i][m]) if m.any() else np.nan
|
|
244
|
+
nh_mean[i] = np.sum(ww * NH[i][m]) if m.any() else np.nan
|
|
245
|
+
|
|
246
|
+
lnPF = betaC * nc_mean + betaH * nh_mean
|
|
247
|
+
dG = R_KCAL * temperature * lnPF
|
|
248
|
+
return ProtectionResult(
|
|
249
|
+
resSeq=np.array(resSeq), resn=np.array(resn),
|
|
250
|
+
NC_mean=nc_mean, NH_mean=nh_mean, lnPF=lnPF, dGopen_kcal=dG,
|
|
251
|
+
NC=NC, NH=NH, weights=w, temperature=temperature, n_frames=F,
|
|
252
|
+
)
|
hxprobe/probe.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""High-level interface: the 50-conformer probe.
|
|
2
|
+
|
|
3
|
+
* :func:`score_ensemble` -- per-residue opening free energies for an ensemble.
|
|
4
|
+
* :func:`convergence` -- how the readout converges with ensemble size.
|
|
5
|
+
* :func:`global_unfolding` -- the most-open-conformer global stability proxy.
|
|
6
|
+
* :func:`example_ensemble_path` / :func:`load_experimental` -- bundled data.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from typing import Optional, Sequence, Union
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .operator import (BETA_C, BETA_H, R_KCAL, T_REF, ProtectionResult,
|
|
16
|
+
_largest_protein_chain, compute)
|
|
17
|
+
from .ensemble import load_ensemble, optionally_protonate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --------------------------------------------------------------------------- #
|
|
21
|
+
# numpy-only Spearman (avoids a SciPy dependency)
|
|
22
|
+
# --------------------------------------------------------------------------- #
|
|
23
|
+
def _rankdata(x: np.ndarray) -> np.ndarray:
|
|
24
|
+
x = np.asarray(x, float)
|
|
25
|
+
order = np.argsort(x, kind="mergesort")
|
|
26
|
+
ranks = np.empty(len(x), float)
|
|
27
|
+
ranks[order] = np.arange(1, len(x) + 1, dtype=float)
|
|
28
|
+
sx = x[order]
|
|
29
|
+
i = 0
|
|
30
|
+
n = len(x)
|
|
31
|
+
while i < n:
|
|
32
|
+
j = i
|
|
33
|
+
while j + 1 < n and sx[j + 1] == sx[i]:
|
|
34
|
+
j += 1
|
|
35
|
+
if j > i:
|
|
36
|
+
ranks[order[i:j + 1]] = (i + 1 + j + 1) / 2.0
|
|
37
|
+
i = j + 1
|
|
38
|
+
return ranks
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def spearman(a: Sequence[float], b: Sequence[float]) -> float:
|
|
42
|
+
"""Spearman rank correlation (NumPy only)."""
|
|
43
|
+
a = np.asarray(a, float)
|
|
44
|
+
b = np.asarray(b, float)
|
|
45
|
+
m = np.isfinite(a) & np.isfinite(b)
|
|
46
|
+
if m.sum() < 3:
|
|
47
|
+
return float("nan")
|
|
48
|
+
ra, rb = _rankdata(a[m]), _rankdata(b[m])
|
|
49
|
+
return float(np.corrcoef(ra, rb)[0, 1])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _as_traj(ensemble, top=None):
|
|
53
|
+
if isinstance(ensemble, str):
|
|
54
|
+
return load_ensemble(ensemble, top=top)
|
|
55
|
+
return ensemble
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def score_ensemble(ensemble, top: Optional[str] = None, protonate: str = "auto",
|
|
59
|
+
betaC: float = BETA_C, betaH: float = BETA_H,
|
|
60
|
+
temperature: float = T_REF, **kw) -> ProtectionResult:
|
|
61
|
+
"""Score a conformational ensemble into per-residue opening free energies.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
ensemble : str or mdtraj.Trajectory
|
|
66
|
+
Path to a multi-model PDB / trajectory, or a loaded trajectory.
|
|
67
|
+
top : str, optional
|
|
68
|
+
Topology file when ``ensemble`` is a trajectory path.
|
|
69
|
+
protonate : {"auto", "none", "pdbfixer"}
|
|
70
|
+
How to obtain backbone amide hydrogens for the H-bond term. ``"auto"``
|
|
71
|
+
uses explicit hydrogens if present, else PDBFixer if installed, else a
|
|
72
|
+
geometric placement. See :func:`hxprobe.ensemble.optionally_protonate`.
|
|
73
|
+
"""
|
|
74
|
+
traj = _as_traj(ensemble, top)
|
|
75
|
+
traj = _largest_protein_chain(traj)
|
|
76
|
+
traj = optionally_protonate(traj, method=protonate)
|
|
77
|
+
return compute(traj, betaC=betaC, betaH=betaH, temperature=temperature,
|
|
78
|
+
select_largest_chain=False, **kw)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _lnpf_from_first_n(res: ProtectionResult, n: int, betaC: float, betaH: float):
|
|
82
|
+
nc = np.nanmean(res.NC[:, :n], axis=1)
|
|
83
|
+
nh = np.nanmean(res.NH[:, :n], axis=1)
|
|
84
|
+
return betaC * nc + betaH * nh
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def convergence(ensemble, ns: Optional[Sequence[int]] = None,
|
|
88
|
+
reference: Optional[dict] = None, top: Optional[str] = None,
|
|
89
|
+
protonate: str = "auto", betaC: float = BETA_C,
|
|
90
|
+
betaH: float = BETA_H, **kw):
|
|
91
|
+
"""Convergence of the readout with ensemble size.
|
|
92
|
+
|
|
93
|
+
Returns a list of dicts (and, if pandas is available, a DataFrame) with, for
|
|
94
|
+
each ``n``: the Spearman correlation of the ``n``-conformer ln PF against
|
|
95
|
+
the full-ensemble ln PF (self-convergence) and, if ``reference`` is given,
|
|
96
|
+
against the experimental opening free energies.
|
|
97
|
+
|
|
98
|
+
``reference`` maps ``resSeq -> experimental dG_open`` (see
|
|
99
|
+
:func:`load_experimental`).
|
|
100
|
+
"""
|
|
101
|
+
res = score_ensemble(ensemble, top=top, protonate=protonate,
|
|
102
|
+
betaC=betaC, betaH=betaH, **kw)
|
|
103
|
+
F = res.n_frames
|
|
104
|
+
if ns is None:
|
|
105
|
+
ns = [n for n in (5, 10, 25, 50, 100, 200) if n <= F] or [F]
|
|
106
|
+
if F not in ns:
|
|
107
|
+
ns = list(ns) + [F]
|
|
108
|
+
full = res.lnPF
|
|
109
|
+
ref_vec = None
|
|
110
|
+
if reference is not None:
|
|
111
|
+
ref_vec = np.array([reference.get(int(rs), np.nan) for rs in res.resSeq], float)
|
|
112
|
+
|
|
113
|
+
rows = []
|
|
114
|
+
for n in ns:
|
|
115
|
+
n = int(min(n, F))
|
|
116
|
+
lnpf_n = _lnpf_from_first_n(res, n, betaC, betaH)
|
|
117
|
+
row = {"n": n, "self_spearman": spearman(lnpf_n, full)}
|
|
118
|
+
if ref_vec is not None:
|
|
119
|
+
row["ref_spearman"] = spearman(lnpf_n, ref_vec)
|
|
120
|
+
rows.append(row)
|
|
121
|
+
try:
|
|
122
|
+
import pandas as pd
|
|
123
|
+
return pd.DataFrame(rows)
|
|
124
|
+
except Exception:
|
|
125
|
+
return rows
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def global_unfolding(ensemble, top: Optional[str] = None, protonate: str = "auto",
|
|
129
|
+
betaC: float = BETA_C, betaH: float = BETA_H,
|
|
130
|
+
temperature: float = T_REF, **kw) -> float:
|
|
131
|
+
"""Most-open-conformer protection: a global fold-stability proxy.
|
|
132
|
+
|
|
133
|
+
For each conformer the mean log-protection over residues is computed; the
|
|
134
|
+
observable is ``RT * min_c <ln PF>_residues``, i.e. the protection of the
|
|
135
|
+
single most-open conformer, taken as a proxy for the unfolded state.
|
|
136
|
+
Larger values track higher global stability (dG_fold).
|
|
137
|
+
"""
|
|
138
|
+
res = score_ensemble(ensemble, top=top, protonate=protonate,
|
|
139
|
+
betaC=betaC, betaH=betaH, temperature=temperature, **kw)
|
|
140
|
+
per_conf_lnpf = betaC * res.NC + betaH * res.NH # [n_res, n_frames]
|
|
141
|
+
g = np.nanmean(per_conf_lnpf, axis=0) # per-conformer mean
|
|
142
|
+
return float(R_KCAL * temperature * np.nanmin(g))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --------------------------------------------------------------------------- #
|
|
146
|
+
# bundled example data (a 50-conformer leakage-free ubiquitin ensemble)
|
|
147
|
+
# --------------------------------------------------------------------------- #
|
|
148
|
+
def example_ensemble_path() -> str:
|
|
149
|
+
"""Path to the bundled 50-conformer ubiquitin ensemble (multi-model PDB)."""
|
|
150
|
+
return str(resources.files("hxprobe").joinpath("data/ubiquitin_ensemble.pdb.gz"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_experimental(path: Optional[str] = None) -> dict:
|
|
154
|
+
"""Load experimental per-residue opening free energies as ``{resSeq: dG}``.
|
|
155
|
+
|
|
156
|
+
With no argument, returns the bundled ubiquitin native-state HX dataset.
|
|
157
|
+
"""
|
|
158
|
+
if path is None:
|
|
159
|
+
path = str(resources.files("hxprobe").joinpath("data/ubiquitin_dgopen.csv"))
|
|
160
|
+
import csv
|
|
161
|
+
out = {}
|
|
162
|
+
with open(path) as fh:
|
|
163
|
+
for row in csv.DictReader(fh):
|
|
164
|
+
out[int(row["resi"])] = float(row["dGopen_kcal"])
|
|
165
|
+
return out
|
hxprobe/protonate.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Protonation / repair backends.
|
|
2
|
+
|
|
3
|
+
The contact term ``N_C`` needs no hydrogens, but the hydrogen-bond term
|
|
4
|
+
``N_H`` needs a backbone amide hydrogen. Three options are available:
|
|
5
|
+
|
|
6
|
+
* explicit hydrogens already in the ensemble -- used directly;
|
|
7
|
+
* geometric placement inside the operator (NumPy only, no extra deps);
|
|
8
|
+
* PDBFixer/OpenMM repair + real hydrogen addition (``hxprobe[fix]``), which
|
|
9
|
+
also rebuilds missing heavy atoms and is the most faithful for raw crystal
|
|
10
|
+
or heavy-atom generated structures.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
_AMIDE_H_NAMES = ("H", "HN", "H1", "HT1")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def has_explicit_hydrogens(traj) -> bool:
|
|
18
|
+
"""True if the topology contains backbone amide hydrogens."""
|
|
19
|
+
for a in traj.topology.atoms:
|
|
20
|
+
if a.name in _AMIDE_H_NAMES and a.element is not None and a.element.symbol == "H":
|
|
21
|
+
return True
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fix_one_frame(traj1, add_missing_atoms=True, ph=7.0):
|
|
26
|
+
import os
|
|
27
|
+
import tempfile
|
|
28
|
+
import mdtraj as md
|
|
29
|
+
from pdbfixer import PDBFixer
|
|
30
|
+
from openmm.app import PDBFile
|
|
31
|
+
|
|
32
|
+
shm = "/dev/shm" if os.path.isdir("/dev/shm") else None
|
|
33
|
+
tmp = tempfile.mktemp(suffix=".pdb", dir=shm)
|
|
34
|
+
traj1.save_pdb(tmp)
|
|
35
|
+
fixer = PDBFixer(filename=tmp)
|
|
36
|
+
fixer.findMissingResidues()
|
|
37
|
+
fixer.missingResidues = {} # do not model whole missing residues
|
|
38
|
+
fixer.findNonstandardResidues()
|
|
39
|
+
fixer.findMissingAtoms()
|
|
40
|
+
if not add_missing_atoms:
|
|
41
|
+
fixer.missingAtoms = {}
|
|
42
|
+
fixer.missingTerminals = {}
|
|
43
|
+
fixer.addMissingAtoms()
|
|
44
|
+
fixer.addMissingHydrogens(ph)
|
|
45
|
+
out = tempfile.mktemp(suffix=".pdb", dir=shm)
|
|
46
|
+
with open(out, "w") as fh:
|
|
47
|
+
PDBFile.writeFile(fixer.topology, fixer.positions, fh)
|
|
48
|
+
fixed = md.load(out)
|
|
49
|
+
os.remove(tmp)
|
|
50
|
+
os.remove(out)
|
|
51
|
+
return fixed
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pdbfixer_protonate(traj, add_missing_atoms=True, ph=7.0):
|
|
55
|
+
"""Repair + protonate every frame with PDBFixer; return a single trajectory.
|
|
56
|
+
|
|
57
|
+
Requires the ``hxprobe[fix]`` extra (``pdbfixer``, ``openmm``). Frames whose
|
|
58
|
+
repaired atom count differs from the modal count are dropped so the result
|
|
59
|
+
can be stacked into one trajectory.
|
|
60
|
+
"""
|
|
61
|
+
import numpy as np
|
|
62
|
+
import mdtraj as md
|
|
63
|
+
|
|
64
|
+
fixed = [_fix_one_frame(traj[k], add_missing_atoms, ph)
|
|
65
|
+
for k in range(traj.n_frames)]
|
|
66
|
+
counts = [f.n_atoms for f in fixed]
|
|
67
|
+
modal = max(set(counts), key=counts.count)
|
|
68
|
+
keep = [f for f in fixed if f.n_atoms == modal]
|
|
69
|
+
xyz = np.concatenate([f.xyz for f in keep], axis=0)
|
|
70
|
+
return md.Trajectory(xyz, keep[0].topology)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hxprobe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The 50-conformer probe: residue-resolved hydrogen-exchange opening free energies from conformational ensembles
|
|
5
|
+
Author: hxprobe authors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/woshuizhaol/hxprobe
|
|
8
|
+
Project-URL: Source, https://github.com/woshuizhaol/hxprobe
|
|
9
|
+
Project-URL: Issues, https://github.com/woshuizhaol/hxprobe/issues
|
|
10
|
+
Keywords: hydrogen-deuterium exchange,protection factor,conformational ensemble,generative models,protein dynamics,free energy,structural biology
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: numpy>=1.21
|
|
25
|
+
Requires-Dist: pandas>=1.3
|
|
26
|
+
Requires-Dist: mdtraj>=1.9
|
|
27
|
+
Provides-Extra: fix
|
|
28
|
+
Requires-Dist: pdbfixer>=1.8; extra == "fix"
|
|
29
|
+
Requires-Dist: openmm>=7.6; extra == "fix"
|
|
30
|
+
Provides-Extra: diff
|
|
31
|
+
Requires-Dist: torch>=1.10; extra == "diff"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
34
|
+
Requires-Dist: build; extra == "dev"
|
|
35
|
+
Requires-Dist: twine; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# hxprobe — the 50-conformer probe
|
|
39
|
+
|
|
40
|
+
**Read residue-resolved hydrogen-exchange opening free energies out of a conformational ensemble.**
|
|
41
|
+
|
|
42
|
+
`hxprobe` turns a conformational ensemble (from a generative model, molecular
|
|
43
|
+
dynamics, or any source) into per-residue protection factors and opening free
|
|
44
|
+
energies (ΔG_open, in kcal/mol) using a white-box, two-parameter physical
|
|
45
|
+
operator. It is an inexpensive, physically interpretable probe of how well an
|
|
46
|
+
ensemble reproduces the *near-equilibrium local opening* that hydrogen–deuterium
|
|
47
|
+
exchange measures — and it converges within roughly **50 conformers**, which is
|
|
48
|
+
where the name comes from.
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
ln PF_i = β_C · ⟨N_C,i⟩ + β_H · ⟨N_H,i⟩ (ensemble average)
|
|
52
|
+
ΔG_open,i = RT · ln PF_i (EX2 regime)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`N_C` counts heavy atoms near each backbone amide nitrogen and `N_H` counts the
|
|
56
|
+
amide's backbone hydrogen bonds, averaged over the ensemble. The two
|
|
57
|
+
coefficients are **fixed to their classical Best–Vendruscolo values** (0.35 and
|
|
58
|
+
2.0) and are *not* fitted to stability data, so any residue-level signal the
|
|
59
|
+
probe recovers comes from the ensemble, not from a tuned scoring function.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install hxprobe
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This pulls in `numpy`, `pandas`, and `mdtraj`. Two optional extras:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install "hxprobe[fix]" # PDBFixer/OpenMM: repair + protonate raw structures
|
|
71
|
+
pip install "hxprobe[diff]" # PyTorch: differentiable operator for steering
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Quickstart
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import hxprobe
|
|
78
|
+
|
|
79
|
+
# Score the bundled 50-conformer ubiquitin ensemble (already protonated).
|
|
80
|
+
res = hxprobe.score_ensemble(hxprobe.example_ensemble_path(), protonate="none")
|
|
81
|
+
print(res.to_dataframe().head()) # resSeq, resn, NC_mean, NH_mean, lnPF, dGopen_kcal
|
|
82
|
+
|
|
83
|
+
# Compare to experimental native-state HX opening free energies.
|
|
84
|
+
exp = hxprobe.load_experimental() # {resSeq: dG_open}
|
|
85
|
+
ref = [exp.get(int(r), float("nan")) for r in res.resSeq]
|
|
86
|
+
print("Spearman vs experiment:", round(hxprobe.spearman(res.lnPF, ref), 3))
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Score *your own* ensemble — a multi-model PDB, or a trajectory plus topology:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
res = hxprobe.score_ensemble("my_ensemble.pdb") # multi-model PDB
|
|
93
|
+
res = hxprobe.score_ensemble("traj.xtc", top="topology.pdb") # trajectory + topology
|
|
94
|
+
res.to_csv("opening_free_energies.csv")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
If your structures are raw heavy-atom coordinates without hydrogens, the H-bond
|
|
98
|
+
term is obtained either by a geometric amide-H placement (default, no extra
|
|
99
|
+
dependencies) or, more faithfully, with PDBFixer:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
res = hxprobe.score_ensemble("raw_heavy_atom.pdb", protonate="pdbfixer") # needs hxprobe[fix]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Command line
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
hxprobe example # run the bundled ubiquitin demo
|
|
109
|
+
hxprobe score my_ensemble.pdb # print the per-residue table
|
|
110
|
+
hxprobe score traj.xtc --top top.pdb --out dG.csv
|
|
111
|
+
hxprobe converge my_ensemble.pdb # show convergence with ensemble size
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## What you get back
|
|
115
|
+
|
|
116
|
+
`score_ensemble` returns a `ProtectionResult` with NumPy arrays and a
|
|
117
|
+
`.to_dataframe()` / `.to_csv()` helper:
|
|
118
|
+
|
|
119
|
+
| field | meaning |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `resSeq`, `resn` | residue number and one-letter code |
|
|
122
|
+
| `NC_mean`, `NH_mean` | ensemble-averaged contacts / hydrogen bonds |
|
|
123
|
+
| `lnPF`, `log10PF` | log protection factor |
|
|
124
|
+
| `dGopen_kcal` | opening free energy ΔG_open (kcal/mol) |
|
|
125
|
+
|
|
126
|
+
Two further entry points:
|
|
127
|
+
|
|
128
|
+
* **`convergence(ensemble)`** — Spearman correlation of the `n`-conformer
|
|
129
|
+
readout against the full-ensemble readout (and, optionally, against an
|
|
130
|
+
experimental reference), showing the plateau near ~50 conformers.
|
|
131
|
+
* **`global_unfolding(ensemble)`** — `RT · min_c ⟨ln PF⟩_residues`, the
|
|
132
|
+
protection of the most-open conformer, a bounded proxy for global fold
|
|
133
|
+
stability (the unfolded-state limit of the ensemble).
|
|
134
|
+
|
|
135
|
+
## How it works
|
|
136
|
+
|
|
137
|
+
For each backbone amide (prolines and non-standard residues are skipped):
|
|
138
|
+
|
|
139
|
+
* **`N_C`** — heavy atoms within **6.5 Å** of the amide nitrogen, sequence
|
|
140
|
+
separation `|i − j| ≥ 3`, hydrogens excluded.
|
|
141
|
+
* **`N_H`** — backbone carbonyl oxygens within **2.6 Å** of the amide hydrogen,
|
|
142
|
+
sequence separation `|i − j| ≥ 2`.
|
|
143
|
+
|
|
144
|
+
Counts are computed per conformer and **averaged over the ensemble before** the
|
|
145
|
+
linear combination is formed, so a residue that is buried in most conformers but
|
|
146
|
+
exposed in a rare open state receives the reduced mean contact count its
|
|
147
|
+
protection reflects. The contact term dominates, so the readout is robust even
|
|
148
|
+
when hydrogens are placed geometrically rather than with a full protonation step.
|
|
149
|
+
|
|
150
|
+
## Reproducing the bundled example
|
|
151
|
+
|
|
152
|
+
`hxprobe example` scores a 50-conformer leakage-free ubiquitin ensemble and
|
|
153
|
+
recovers the experimental native-state opening free energies at Spearman
|
|
154
|
+
ρ ≈ 0.58, with the correlation plateauing by ~25–50 conformers — the behaviour
|
|
155
|
+
that motivates the probe.
|
|
156
|
+
|
|
157
|
+
## Citing
|
|
158
|
+
|
|
159
|
+
If you use `hxprobe`, please cite the accompanying study on residue-resolved
|
|
160
|
+
hydrogen-exchange free energies as a benchmark for generative conformational
|
|
161
|
+
ensembles. (Reference to be added on publication.)
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
hxprobe/__init__.py,sha256=3eGKz1beI297aF91CGuJovNlJOFNAIZXgmLenGQggD8,929
|
|
2
|
+
hxprobe/cli.py,sha256=QPo_Z8CLKI6b4Gb8epBK6rAhECEgrEO5IEQQBrRWRMI,3568
|
|
3
|
+
hxprobe/diff.py,sha256=LEXvAX1SHNDQrDS0W0e-I9JW6cwh_BJ0Va7fpyTmoIw,2737
|
|
4
|
+
hxprobe/ensemble.py,sha256=P7xKxjbSHRD6gPKDIE3ocJfUS1VqN5UEsCfqA_LlO0w,1908
|
|
5
|
+
hxprobe/operator.py,sha256=-xBF-H3aKamPgAFKx46_pCUypq7xQeWjj-o5_vRR56A,9298
|
|
6
|
+
hxprobe/probe.py,sha256=5CxjViokZVGLH1hZq5JdXRIc5OGzv6_bUxxNwi1pYSA,6575
|
|
7
|
+
hxprobe/protonate.py,sha256=ahNldxZP_92Vt44vaGiB5jN-doN6LVyhKMw97pu8xNk,2533
|
|
8
|
+
hxprobe/data/ubiquitin_dgopen.csv,sha256=8S53ApPbBjwqiEc4jjJZvhIQBvaO7ByvifOZ6YCnowo,513
|
|
9
|
+
hxprobe/data/ubiquitin_ensemble.pdb.gz,sha256=p1Q0iIK-2_gwz5ydbs3-_RkW1lT28vmAbb0Dn2TaKsA,992264
|
|
10
|
+
hxprobe-0.1.0.dist-info/licenses/LICENSE,sha256=EMVzXBEI7U0YNZRIGU2A73z5wMbPNz4AOBPNHEnMrw0,1072
|
|
11
|
+
hxprobe-0.1.0.dist-info/METADATA,sha256=9tbagX6n0HhKrIkXyC26h_3ZOzjeek-xMx2d0MwxICs,6622
|
|
12
|
+
hxprobe-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
hxprobe-0.1.0.dist-info/entry_points.txt,sha256=nCLOTyzpNlgf2UoTEdGFfhJhc1Ad7YPDuEVs5Nj0gb4,45
|
|
14
|
+
hxprobe-0.1.0.dist-info/top_level.txt,sha256=zk2Sk2LzTFrZMHcer6AVGPoGOWKK2wxm22H1vdrcZv0,8
|
|
15
|
+
hxprobe-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hxprobe authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hxprobe
|