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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hxprobe = hxprobe.cli:main
@@ -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