binderqc 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.
- binderqc/__init__.py +7 -0
- binderqc/cli.py +79 -0
- binderqc/core.py +515 -0
- binderqc/paths.py +17 -0
- binderqc-0.1.0.dist-info/METADATA +150 -0
- binderqc-0.1.0.dist-info/RECORD +9 -0
- binderqc-0.1.0.dist-info/WHEEL +4 -0
- binderqc-0.1.0.dist-info/entry_points.txt +2 -0
- binderqc-0.1.0.dist-info/licenses/LICENSE +21 -0
binderqc/__init__.py
ADDED
binderqc/cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Command-line entry point: `binderqc`.
|
|
2
|
+
|
|
3
|
+
binderqc --binder-chains B --target-chains A \
|
|
4
|
+
--out tag_metrics.csv path/to/preds/*.cif a_directory/
|
|
5
|
+
|
|
6
|
+
Inputs may be files, globs, or directories (recursively scanned for *.pdb/*.cif).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from .core import score_structure
|
|
14
|
+
from .paths import gather_paths
|
|
15
|
+
|
|
16
|
+
_DESCRIPTION = """\
|
|
17
|
+
Pick the terminus to tag on a designed protein binder.
|
|
18
|
+
|
|
19
|
+
For each binder chain in a predicted binder-target complex it reports, per
|
|
20
|
+
terminus, the relative SASA (exposure), the CA-CA distance to the paratope, the
|
|
21
|
+
orientation (does the chain point back at the interface?) and the Cys-SG SASA,
|
|
22
|
+
then recommends the terminus farther from the interface and flags buried,
|
|
23
|
+
ambiguous or interface-facing cases. Just geometry: no folding, no GPU, no network.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv=None):
|
|
28
|
+
ap = argparse.ArgumentParser(
|
|
29
|
+
prog="binderqc",
|
|
30
|
+
description=_DESCRIPTION,
|
|
31
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
32
|
+
)
|
|
33
|
+
ap.add_argument("inputs", nargs="+", help="PDB/CIF files, globs, or directories")
|
|
34
|
+
ap.add_argument("--binder-chains", default="", help="comma-separated; default = auto-guess")
|
|
35
|
+
ap.add_argument("--target-chains", default="", help="comma-separated; default = all non-binder chains")
|
|
36
|
+
ap.add_argument("--interface-cutoff", type=float, default=5.0,
|
|
37
|
+
help="heavy-atom dist (A) to call a binder residue interface (default 5.0)")
|
|
38
|
+
ap.add_argument("--exposure-cutoff", type=float, default=0.25,
|
|
39
|
+
help="relSASA below which a terminus is buried (default 0.25)")
|
|
40
|
+
ap.add_argument("--out", default="binderqc.csv", help="output CSV path")
|
|
41
|
+
ap.add_argument("--fasta", default="",
|
|
42
|
+
help="also write QC-passing binders (no quality warnings) to this FASTA path")
|
|
43
|
+
args = ap.parse_args(argv)
|
|
44
|
+
|
|
45
|
+
import pandas as pd # imported here so `--help` works without pandas
|
|
46
|
+
|
|
47
|
+
paths = gather_paths(args.inputs)
|
|
48
|
+
if not paths:
|
|
49
|
+
sys.exit("No .pdb/.cif files found in inputs.")
|
|
50
|
+
binder_chains = [c for c in args.binder_chains.split(",") if c]
|
|
51
|
+
target_chains = [c for c in args.target_chains.split(",") if c]
|
|
52
|
+
|
|
53
|
+
rows = []
|
|
54
|
+
for p in paths:
|
|
55
|
+
try:
|
|
56
|
+
rows.extend(score_structure(p, binder_chains, target_chains,
|
|
57
|
+
args.interface_cutoff, args.exposure_cutoff))
|
|
58
|
+
except Exception as e: # noqa: BLE001 - keep the batch going, record the failure
|
|
59
|
+
rows.append({"pdb": p, "error": str(e)})
|
|
60
|
+
|
|
61
|
+
df = pd.DataFrame(rows)
|
|
62
|
+
df.to_csv(args.out, index=False)
|
|
63
|
+
shown = df.drop(columns=["binder_sequence"], errors="ignore") # too wide to print
|
|
64
|
+
with pd.option_context("display.max_columns", None, "display.width", 220):
|
|
65
|
+
print(shown.to_string(index=False))
|
|
66
|
+
print(f"\nWrote {len(df)} rows -> {args.out}")
|
|
67
|
+
|
|
68
|
+
if args.fasta:
|
|
69
|
+
clean = [r for r in rows
|
|
70
|
+
if not r.get("error") and r.get("qc_pass") and r.get("binder_sequence")]
|
|
71
|
+
with open(args.fasta, "w") as fh:
|
|
72
|
+
for r in clean:
|
|
73
|
+
fh.write(f">{os.path.splitext(r['pdb'])[0]}|{r['binder_chain']}\n{r['binder_sequence']}\n")
|
|
74
|
+
scored = sum(1 for r in rows if not r.get("error"))
|
|
75
|
+
print(f"Wrote {len(clean)}/{scored} QC-passing binders -> {args.fasta}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|
binderqc/core.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""Where to put a tag on a designed binder, plus a few QC numbers, read straight
|
|
2
|
+
off a predicted binder-target complex.
|
|
3
|
+
|
|
4
|
+
For each binder chain we work out which terminus (N or C) is the safer place to
|
|
5
|
+
hang a purification/immobilization/conjugation tag. A good tag site is exposed
|
|
6
|
+
(so the tag doesn't wreck the fold) and sits well away from the binding interface
|
|
7
|
+
(so tethering there doesn't block the paratope once the binder is on a chip or
|
|
8
|
+
displayed). So three numbers per terminus:
|
|
9
|
+
|
|
10
|
+
- relative SASA of the terminal residue (how exposed it is; Tien 2013 max-ASA)
|
|
11
|
+
- distance from the terminal CA to the nearest paratope CA
|
|
12
|
+
- orientation: does the chain point back toward the paratope (>0) or away (<0)
|
|
13
|
+
|
|
14
|
+
recommended_tag is just the terminus farther from the interface, and we raise a
|
|
15
|
+
warning if that terminus turns out to be buried, roughly tied with the other, or
|
|
16
|
+
pointing back at the target. We also report the SG SASA of a terminal cysteine so
|
|
17
|
+
you can eyeball whether it's a usable conjugation handle.
|
|
18
|
+
|
|
19
|
+
Everything here is geometry/sequence off a single structure - no folding, no GPU,
|
|
20
|
+
no network.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import biotite.structure as struc
|
|
28
|
+
import biotite.structure.io as strucio
|
|
29
|
+
|
|
30
|
+
_THREE_TO_ONE = {
|
|
31
|
+
"ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C",
|
|
32
|
+
"GLU": "E", "GLN": "Q", "GLY": "G", "HIS": "H", "ILE": "I",
|
|
33
|
+
"LEU": "L", "LYS": "K", "MET": "M", "PHE": "F", "PRO": "P",
|
|
34
|
+
"SER": "S", "THR": "T", "TRP": "W", "TYR": "Y", "VAL": "V",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Tien et al. 2013 theoretical max ASA per residue, used to turn a residue's
|
|
38
|
+
# total SASA into a 0-1 exposure.
|
|
39
|
+
_REF_MAX_ASA = {
|
|
40
|
+
"ALA": 129.0, "ARG": 274.0, "ASN": 195.0, "ASP": 193.0, "CYS": 167.0,
|
|
41
|
+
"GLU": 223.0, "GLN": 225.0, "GLY": 104.0, "HIS": 224.0, "ILE": 197.0,
|
|
42
|
+
"LEU": 201.0, "LYS": 236.0, "MET": 224.0, "PHE": 240.0, "PRO": 159.0,
|
|
43
|
+
"SER": 155.0, "THR": 172.0, "TRP": 285.0, "TYR": 263.0, "VAL": 174.0,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# When the binder chain isn't given we guess it as the shortest chain in this
|
|
47
|
+
# residue window. It's only a fallback - the printed guess and the "largest
|
|
48
|
+
# chain" warning are what actually keep you honest.
|
|
49
|
+
_AUTO_MIN_LEN = 20
|
|
50
|
+
_AUTO_MAX_LEN = 250
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_protein(path):
|
|
54
|
+
"""Load a structure, first model only, amino acids only."""
|
|
55
|
+
array = strucio.load_structure(path)
|
|
56
|
+
if isinstance(array, struc.AtomArrayStack):
|
|
57
|
+
array = array[0]
|
|
58
|
+
return array[struc.filter_amino_acids(array)]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _chain_lengths(array):
|
|
62
|
+
starts = struc.get_residue_starts(array)
|
|
63
|
+
lengths = {}
|
|
64
|
+
for s in starts:
|
|
65
|
+
c = str(array.chain_id[s])
|
|
66
|
+
lengths[c] = lengths.get(c, 0) + 1
|
|
67
|
+
return lengths
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _guess_binder_chains(chain_lens):
|
|
71
|
+
candidates = {c: n for c, n in chain_lens.items() if _AUTO_MIN_LEN <= n <= _AUTO_MAX_LEN}
|
|
72
|
+
if not candidates:
|
|
73
|
+
return []
|
|
74
|
+
return [min(candidates, key=candidates.get)]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _chain_res_ids(array, chain_id):
|
|
78
|
+
"""res_ids in chain order (N to C), not by numbering."""
|
|
79
|
+
starts = struc.get_residue_starts(array)
|
|
80
|
+
return [int(array.res_id[s]) for s in starts if array.chain_id[s] == chain_id]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _residue_relsasa(array, atom_sasa):
|
|
84
|
+
"""{(chain, res_id): relative SASA}. SASA is over the whole complex, so a
|
|
85
|
+
residue buried at the interface reads as buried."""
|
|
86
|
+
res_sasa = struc.apply_residue_wise(array, atom_sasa, np.sum)
|
|
87
|
+
res_starts = struc.get_residue_starts(array)
|
|
88
|
+
rel = {}
|
|
89
|
+
for res_total, start in zip(res_sasa, res_starts):
|
|
90
|
+
ref = _REF_MAX_ASA.get(array.res_name[start], 0.0)
|
|
91
|
+
rel[(array.chain_id[start], int(array.res_id[start]))] = (
|
|
92
|
+
(res_total / ref) if ref > 0 else float("nan")
|
|
93
|
+
)
|
|
94
|
+
return rel
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _sg_sasa(array, atom_sasa, chain_id, res_id):
|
|
98
|
+
"""SASA of a Cys SG atom (conjugatability). NaN if there's no SG here."""
|
|
99
|
+
mask = (array.chain_id == chain_id) & (array.res_id == res_id) & (array.atom_name == "SG")
|
|
100
|
+
return float(atom_sasa[mask][0]) if mask.any() else float("nan")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _binder_bsa(array, atom_sasa, binder_chain):
|
|
104
|
+
"""Area the binder buries on binding = its SASA alone minus its SASA in the
|
|
105
|
+
complex. A tiny interface is the clearest tell of a junk binder. No PLIP,
|
|
106
|
+
just biotite SASA."""
|
|
107
|
+
mask = array.chain_id == binder_chain
|
|
108
|
+
if not mask.any():
|
|
109
|
+
return float("nan")
|
|
110
|
+
iso = np.nan_to_num(struc.sasa(array[mask]), nan=0.0)
|
|
111
|
+
buried = np.clip(iso - atom_sasa[mask], 0.0, None).sum()
|
|
112
|
+
return float(buried)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _ca_coord(array, chain_id, res_id):
|
|
116
|
+
mask = (array.chain_id == chain_id) & (array.res_id == res_id) & (array.atom_name == "CA")
|
|
117
|
+
return array.coord[mask][0] if mask.any() else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _interface_residue_ids(array, binder_chain, target_chains, cutoff):
|
|
121
|
+
"""Binder residues with any heavy atom within cutoff of the target."""
|
|
122
|
+
binder_mask = array.chain_id == binder_chain
|
|
123
|
+
target_mask = np.isin(array.chain_id, list(target_chains))
|
|
124
|
+
if not binder_mask.any() or not target_mask.any():
|
|
125
|
+
return set()
|
|
126
|
+
b_coord, t_coord = array.coord[binder_mask], array.coord[target_mask]
|
|
127
|
+
b_resid = array.res_id[binder_mask]
|
|
128
|
+
interface = set()
|
|
129
|
+
# chunk the binder atoms so the pairwise distance matrix stays small on big targets
|
|
130
|
+
for i in range(0, len(b_coord), 2048):
|
|
131
|
+
chunk = b_coord[i : i + 2048]
|
|
132
|
+
d = np.linalg.norm(chunk[:, None, :] - t_coord[None, :, :], axis=-1)
|
|
133
|
+
hit = d.min(axis=1) <= cutoff
|
|
134
|
+
interface.update(int(r) for r in b_resid[i : i + 2048][hit])
|
|
135
|
+
return interface
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _paratope_centroid(array, binder_chain, interface_ids):
|
|
139
|
+
cas = [c for c in (_ca_coord(array, binder_chain, r) for r in interface_ids) if c is not None]
|
|
140
|
+
return np.mean(cas, axis=0) if cas else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _terminus_orientation(term_ca, adj_ca, paratope_centroid):
|
|
144
|
+
"""Cosine between the way the chain extends past the terminus and the
|
|
145
|
+
direction to the paratope. A tag grows outward along (term_ca - adj_ca); if
|
|
146
|
+
that points at the paratope (>0) the tag heads straight for the target.
|
|
147
|
+
Positive is bad even when the terminus is far away; NaN if we can't tell."""
|
|
148
|
+
if term_ca is None or adj_ca is None or paratope_centroid is None:
|
|
149
|
+
return float("nan")
|
|
150
|
+
ext = term_ca - adj_ca
|
|
151
|
+
to_iface = paratope_centroid - term_ca
|
|
152
|
+
n_ext, n_if = np.linalg.norm(ext), np.linalg.norm(to_iface)
|
|
153
|
+
if n_ext == 0 or n_if == 0:
|
|
154
|
+
return float("nan")
|
|
155
|
+
return float(np.dot(ext, to_iface) / (n_ext * n_if))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _min_dist_to_interface(array, binder_chain, term_res_id, interface_ids):
|
|
159
|
+
if not interface_ids:
|
|
160
|
+
return float("nan")
|
|
161
|
+
term_ca = _ca_coord(array, binder_chain, term_res_id)
|
|
162
|
+
if term_ca is None:
|
|
163
|
+
return float("nan")
|
|
164
|
+
iface_cas = np.array([c for c in (_ca_coord(array, binder_chain, r) for r in interface_ids) if c is not None])
|
|
165
|
+
if len(iface_cas) == 0:
|
|
166
|
+
return float("nan")
|
|
167
|
+
return float(np.linalg.norm(iface_cas - term_ca, axis=1).min())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _binder_sequence(array, chain_id):
|
|
171
|
+
"""One-letter sequence in chain order; anything non-standard becomes X."""
|
|
172
|
+
starts = struc.get_residue_starts(array)
|
|
173
|
+
return "".join(
|
|
174
|
+
_THREE_TO_ONE.get(str(array.res_name[s]), "X")
|
|
175
|
+
for s in starts if array.chain_id[s] == chain_id
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _sequence_liabilities(seq):
|
|
180
|
+
"""Common developability red flags, straight from the sequence. Motifs and
|
|
181
|
+
thresholds are the ones in Adaptyv's protein-qc skill (MIT). These are things
|
|
182
|
+
to look at, not automatic rejections."""
|
|
183
|
+
flags = []
|
|
184
|
+
n_cys = seq.count("C")
|
|
185
|
+
if n_cys % 2 == 1:
|
|
186
|
+
flags.append(f"odd Cys count ({n_cys}): possible unpaired thiol")
|
|
187
|
+
if re.search(r"N[^P][ST]", seq):
|
|
188
|
+
flags.append("N-glycosylation sequon (NxS/T)")
|
|
189
|
+
if re.search(r"N[GST]", seq):
|
|
190
|
+
flags.append("deamidation/isomerization motif (NG/NS/NT)")
|
|
191
|
+
if re.search(r"[KR]{3,}", seq):
|
|
192
|
+
flags.append("polybasic run (>=3 K/R): proteolysis")
|
|
193
|
+
if re.search(r"[AILMFWV]{6,}", seq):
|
|
194
|
+
flags.append("hydrophobic run (>=6): aggregation")
|
|
195
|
+
return flags
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# Kyte & Doolittle 1982 hydropathy.
|
|
199
|
+
_KD = {
|
|
200
|
+
"A": 1.8, "R": -4.5, "N": -3.5, "D": -3.5, "C": 2.5, "Q": -3.5, "E": -3.5,
|
|
201
|
+
"G": -0.4, "H": -3.2, "I": 4.5, "L": 3.8, "K": -3.9, "M": 1.9, "F": 2.8,
|
|
202
|
+
"P": -1.6, "S": -0.8, "T": -0.7, "W": -0.9, "Y": -1.3, "V": 4.2,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# pKa for side chains and termini (a common simple set) - used for charge and pI.
|
|
206
|
+
_PKA_POS = {"K": 10.5, "R": 12.5, "H": 6.0}
|
|
207
|
+
_PKA_NEG = {"D": 3.9, "E": 4.1, "C": 8.5, "Y": 10.1}
|
|
208
|
+
_PKA_NTERM, _PKA_CTERM = 9.0, 3.1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _gravy(seq):
|
|
212
|
+
"""Mean Kyte-Doolittle hydropathy; higher means more hydrophobic."""
|
|
213
|
+
vals = [_KD[a] for a in seq if a in _KD]
|
|
214
|
+
return float(np.mean(vals)) if vals else float("nan")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Average residue masses (Da), the usual Expasy values.
|
|
218
|
+
_RES_MASS = {
|
|
219
|
+
"A": 71.0788, "R": 156.1875, "N": 114.1038, "D": 115.0886, "C": 103.1388,
|
|
220
|
+
"E": 129.1155, "Q": 128.1307, "G": 57.0519, "H": 137.1411, "I": 113.1594,
|
|
221
|
+
"L": 113.1594, "K": 128.1741, "M": 131.1926, "F": 147.1766, "P": 97.1167,
|
|
222
|
+
"S": 87.0782, "T": 101.1051, "W": 186.2132, "Y": 163.1760, "V": 99.1326,
|
|
223
|
+
}
|
|
224
|
+
_WATER = 18.01528
|
|
225
|
+
_HYDROPHOBIC = set("AILMFWV")
|
|
226
|
+
_AROMATIC = set("FWY")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _protparam(seq):
|
|
230
|
+
"""The two ProtParam numbers you actually reach for at the bench: molecular
|
|
231
|
+
weight and the reduced 280 nm extinction coefficient (Pace 1995), i.e. what
|
|
232
|
+
you need to express a construct and read its concentration."""
|
|
233
|
+
if not seq:
|
|
234
|
+
return {"mw": float("nan"), "ext_coeff_280": float("nan")}
|
|
235
|
+
mw = sum(_RES_MASS.get(a, 0.0) for a in seq) + _WATER
|
|
236
|
+
ext = 5500 * seq.count("W") + 1490 * seq.count("Y") # reduced (no cystines)
|
|
237
|
+
return {"mw": mw, "ext_coeff_280": float(ext)}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _epitope_composition(array, target_iface):
|
|
241
|
+
"""Hydrophobic fraction and aromatic count of the epitope residues - the
|
|
242
|
+
anchor chemistry a binder has to grip. This is about residue identity, so it
|
|
243
|
+
complements epitope_planarity, which is about shape."""
|
|
244
|
+
if not target_iface:
|
|
245
|
+
return float("nan"), 0
|
|
246
|
+
types = []
|
|
247
|
+
for ch, rid in target_iface:
|
|
248
|
+
m = (array.chain_id == ch) & (array.res_id == rid)
|
|
249
|
+
if m.any():
|
|
250
|
+
types.append(_THREE_TO_ONE.get(str(array.res_name[m][0]), "X"))
|
|
251
|
+
if not types:
|
|
252
|
+
return float("nan"), 0
|
|
253
|
+
hyd = sum(1 for t in types if t in _HYDROPHOBIC) / len(types)
|
|
254
|
+
arom = sum(1 for t in types if t in _AROMATIC)
|
|
255
|
+
return float(hyd), int(arom)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _charge_at_ph(seq, ph):
|
|
259
|
+
"""Net charge at a given pH (Henderson-Hasselbalch)."""
|
|
260
|
+
pos = 1.0 / (1.0 + 10 ** (ph - _PKA_NTERM))
|
|
261
|
+
for a, pk in _PKA_POS.items():
|
|
262
|
+
pos += seq.count(a) / (1.0 + 10 ** (ph - pk))
|
|
263
|
+
neg = 1.0 / (1.0 + 10 ** (_PKA_CTERM - ph))
|
|
264
|
+
for a, pk in _PKA_NEG.items():
|
|
265
|
+
neg += seq.count(a) / (1.0 + 10 ** (pk - ph))
|
|
266
|
+
return pos - neg
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _net_charge(seq, ph=7.4):
|
|
270
|
+
return _charge_at_ph(seq, ph) if seq else float("nan")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _isoelectric_point(seq):
|
|
274
|
+
"""pI by bisection. It leans on the pKa set above, so treat it as a guide."""
|
|
275
|
+
if not seq:
|
|
276
|
+
return float("nan")
|
|
277
|
+
lo, hi = 0.0, 14.0
|
|
278
|
+
for _ in range(60):
|
|
279
|
+
mid = (lo + hi) / 2.0
|
|
280
|
+
if _charge_at_ph(seq, mid) > 0:
|
|
281
|
+
lo = mid
|
|
282
|
+
else:
|
|
283
|
+
hi = mid
|
|
284
|
+
return (lo + hi) / 2.0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _sequence_metrics(seq):
|
|
288
|
+
"""Sequence-derived row fields (developability + expression), pre-rounded."""
|
|
289
|
+
pp = _protparam(seq)
|
|
290
|
+
gravy = _gravy(seq)
|
|
291
|
+
return {
|
|
292
|
+
"mw": round(pp["mw"], 1) if np.isfinite(pp["mw"]) else float("nan"),
|
|
293
|
+
"gravy": round(gravy, 3) if np.isfinite(gravy) else float("nan"),
|
|
294
|
+
"net_charge_ph74": round(_net_charge(seq), 2) if seq else float("nan"),
|
|
295
|
+
"pi": round(_isoelectric_point(seq), 2) if seq else float("nan"),
|
|
296
|
+
"ext_coeff_280": pp["ext_coeff_280"],
|
|
297
|
+
"sequence_liabilities": "; ".join(_sequence_liabilities(seq)),
|
|
298
|
+
"binder_sequence": seq,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _principal_axis(coords):
|
|
303
|
+
"""Unit vector along the long axis (direction of most spread)."""
|
|
304
|
+
centered = coords - coords.mean(axis=0)
|
|
305
|
+
_, _, vh = np.linalg.svd(centered, full_matrices=False)
|
|
306
|
+
return vh[0]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _target_interface_res(array, binder_chain, target_chains, cutoff):
|
|
310
|
+
"""(chain, res_id) of target residues in contact with the binder."""
|
|
311
|
+
binder_mask = array.chain_id == binder_chain
|
|
312
|
+
target_mask = np.isin(array.chain_id, list(target_chains))
|
|
313
|
+
if not binder_mask.any() or not target_mask.any():
|
|
314
|
+
return []
|
|
315
|
+
b_coord = array.coord[binder_mask]
|
|
316
|
+
t_coord, t_chain, t_resid = array.coord[target_mask], array.chain_id[target_mask], array.res_id[target_mask]
|
|
317
|
+
hits = set()
|
|
318
|
+
for i in range(0, len(t_coord), 2048):
|
|
319
|
+
chunk = t_coord[i : i + 2048]
|
|
320
|
+
d = np.linalg.norm(chunk[:, None, :] - b_coord[None, :, :], axis=-1)
|
|
321
|
+
hit = d.min(axis=1) <= cutoff
|
|
322
|
+
for ch, rr, h in zip(t_chain[i : i + 2048], t_resid[i : i + 2048], hit):
|
|
323
|
+
if h:
|
|
324
|
+
hits.add((str(ch), int(rr)))
|
|
325
|
+
return sorted(hits)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _approach_angle(binder_ca, paratope_centroid, epitope_centroid):
|
|
329
|
+
"""Angle (0-90 deg) between the binder's long axis and the paratope->epitope
|
|
330
|
+
axis. Near 0 the binder points end-on into the target; near 90 it lies across
|
|
331
|
+
the surface. STCRpy measures a docking angle for TCRs against a fixed MHC
|
|
332
|
+
frame; this is the same idea without needing a reference frame, so it works
|
|
333
|
+
for any binder. NaN if we can't compute it."""
|
|
334
|
+
if len(binder_ca) < 2 or paratope_centroid is None or epitope_centroid is None:
|
|
335
|
+
return float("nan")
|
|
336
|
+
axis = _principal_axis(binder_ca)
|
|
337
|
+
binding = epitope_centroid - paratope_centroid
|
|
338
|
+
n_bind = np.linalg.norm(binding)
|
|
339
|
+
if n_bind == 0:
|
|
340
|
+
return float("nan")
|
|
341
|
+
cos = abs(float(np.dot(axis, binding / n_bind))) # axis has no direction
|
|
342
|
+
return float(np.degrees(np.arccos(min(cos, 1.0))))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _planarity_rmsd(coords):
|
|
346
|
+
"""RMSD of points to their best-fit plane. Small = a flat epitope patch
|
|
347
|
+
(little to grip); bigger = concave/knobby. NaN below 3 points."""
|
|
348
|
+
if len(coords) < 3:
|
|
349
|
+
return float("nan")
|
|
350
|
+
centered = coords - coords.mean(axis=0)
|
|
351
|
+
_, _, vh = np.linalg.svd(centered, full_matrices=False)
|
|
352
|
+
normal = vh[-1]
|
|
353
|
+
return float(np.sqrt(np.mean((centered @ normal) ** 2)))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _score_binder_chain(array, atom_sasa, name, binder_chain, target_chains, chain_lens,
|
|
357
|
+
relsasa, interface_cutoff, exposure_cutoff):
|
|
358
|
+
ordered_ids = _chain_res_ids(array, binder_chain)
|
|
359
|
+
nterm_id, cterm_id = (ordered_ids[0], ordered_ids[-1]) if ordered_ids else (None, None)
|
|
360
|
+
# one residue in from each end, for the chain-extension direction
|
|
361
|
+
n_adj_id = ordered_ids[1] if len(ordered_ids) >= 2 else None
|
|
362
|
+
c_adj_id = ordered_ids[-2] if len(ordered_ids) >= 2 else None
|
|
363
|
+
|
|
364
|
+
def _resname(rid):
|
|
365
|
+
m = (array.chain_id == binder_chain) & (array.res_id == rid)
|
|
366
|
+
return str(array.res_name[m][0]) if m.any() else ""
|
|
367
|
+
|
|
368
|
+
interface_ids = _interface_residue_ids(array, binder_chain, target_chains, interface_cutoff)
|
|
369
|
+
binder_bsa = _binder_bsa(array, atom_sasa, binder_chain)
|
|
370
|
+
n_rel = relsasa.get((binder_chain, nterm_id), float("nan"))
|
|
371
|
+
c_rel = relsasa.get((binder_chain, cterm_id), float("nan"))
|
|
372
|
+
n_dist = _min_dist_to_interface(array, binder_chain, nterm_id, interface_ids)
|
|
373
|
+
c_dist = _min_dist_to_interface(array, binder_chain, cterm_id, interface_ids)
|
|
374
|
+
|
|
375
|
+
paratope = _paratope_centroid(array, binder_chain, interface_ids)
|
|
376
|
+
n_orient = _terminus_orientation(_ca_coord(array, binder_chain, nterm_id),
|
|
377
|
+
_ca_coord(array, binder_chain, n_adj_id), paratope)
|
|
378
|
+
c_orient = _terminus_orientation(_ca_coord(array, binder_chain, cterm_id),
|
|
379
|
+
_ca_coord(array, binder_chain, c_adj_id), paratope)
|
|
380
|
+
|
|
381
|
+
# only meaningful if the terminal residue is actually a cysteine
|
|
382
|
+
n_sg = _sg_sasa(array, atom_sasa, binder_chain, nterm_id) if _resname(nterm_id) == "CYS" else float("nan")
|
|
383
|
+
c_sg = _sg_sasa(array, atom_sasa, binder_chain, cterm_id) if _resname(cterm_id) == "CYS" else float("nan")
|
|
384
|
+
|
|
385
|
+
# pose + grippability, both computed straight from coordinates
|
|
386
|
+
binder_ca = array.coord[(array.chain_id == binder_chain) & (array.atom_name == "CA")]
|
|
387
|
+
target_iface = _target_interface_res(array, binder_chain, target_chains, interface_cutoff)
|
|
388
|
+
epitope_ca = np.array([c for c in (_ca_coord(array, ch, r) for ch, r in target_iface) if c is not None])
|
|
389
|
+
epitope_centroid = epitope_ca.mean(axis=0) if len(epitope_ca) else None
|
|
390
|
+
approach = _approach_angle(binder_ca, paratope, epitope_centroid)
|
|
391
|
+
planarity = _planarity_rmsd(epitope_ca)
|
|
392
|
+
epi_hyd_frac, epi_aromatic_n = _epitope_composition(array, target_iface)
|
|
393
|
+
|
|
394
|
+
# sequence-side developability + expression
|
|
395
|
+
binder_seq = _binder_sequence(array, binder_chain)
|
|
396
|
+
seqm = _sequence_metrics(binder_seq)
|
|
397
|
+
|
|
398
|
+
# quality warnings say the binder/interface itself looks bad; tag-site
|
|
399
|
+
# warnings are only about where to put a tag. qc_pass ignores the latter.
|
|
400
|
+
quality, tagsite = [], []
|
|
401
|
+
if np.isfinite(planarity) and planarity < 1.0:
|
|
402
|
+
quality.append(f"flat epitope (planarity RMSD={planarity:.2f} A): low grippability")
|
|
403
|
+
if np.isfinite(epi_hyd_frac) and epi_hyd_frac < 0.2 and epi_aromatic_n == 0:
|
|
404
|
+
quality.append("polar epitope (few hydrophobic/aromatic anchors): hard to grip")
|
|
405
|
+
if np.isfinite(seqm["gravy"]) and seqm["gravy"] > 0.4:
|
|
406
|
+
quality.append(f"hydrophobic (GRAVY={seqm['gravy']:.2f}): solubility/aggregation risk")
|
|
407
|
+
if chain_lens.get(binder_chain) == max(chain_lens.values()):
|
|
408
|
+
quality.append("binder is the largest chain, so binder/target may be flipped")
|
|
409
|
+
if np.isfinite(binder_bsa) and binder_bsa < 300.0:
|
|
410
|
+
quality.append(f"small interface (binder BSA={binder_bsa:.0f} A^2): possibly weak/spurious")
|
|
411
|
+
|
|
412
|
+
if not interface_ids:
|
|
413
|
+
recommended = "N/A"
|
|
414
|
+
quality.append("no interface residues found (check target chains / cutoff)")
|
|
415
|
+
else:
|
|
416
|
+
recommended = "C" if (np.nan_to_num(c_dist) >= np.nan_to_num(n_dist)) else "N"
|
|
417
|
+
rec_dist, other_dist = (c_dist, n_dist) if recommended == "C" else (n_dist, c_dist)
|
|
418
|
+
rec_rel = c_rel if recommended == "C" else n_rel
|
|
419
|
+
rec_orient = c_orient if recommended == "C" else n_orient
|
|
420
|
+
if np.isfinite(rec_rel) and rec_rel < exposure_cutoff:
|
|
421
|
+
tagsite.append(f"recommended {recommended}-term is buried (relSASA={rec_rel:.2f})")
|
|
422
|
+
if np.isfinite(rec_dist) and np.isfinite(other_dist) and abs(rec_dist - other_dist) < 5.0:
|
|
423
|
+
tagsite.append("both termini ~equidistant from interface (ambiguous)")
|
|
424
|
+
if np.isfinite(rec_dist) and rec_dist < 8.0:
|
|
425
|
+
tagsite.append(f"recommended terminus is close to interface ({rec_dist:.1f} A)")
|
|
426
|
+
if np.isfinite(rec_orient) and rec_orient > 0.5:
|
|
427
|
+
tagsite.append(f"recommended {recommended}-term points toward interface (orientation={rec_orient:.2f})")
|
|
428
|
+
|
|
429
|
+
warnings = quality + tagsite
|
|
430
|
+
qc_pass = not quality
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
"pdb": name,
|
|
434
|
+
"binder_chain": binder_chain,
|
|
435
|
+
"target_chains": ",".join(target_chains),
|
|
436
|
+
"binder_len": chain_lens.get(binder_chain, 0),
|
|
437
|
+
"n_interface_res": len(interface_ids),
|
|
438
|
+
"binder_bsa": round(binder_bsa, 1) if np.isfinite(binder_bsa) else float("nan"),
|
|
439
|
+
"approach_angle": round(approach, 1) if np.isfinite(approach) else float("nan"),
|
|
440
|
+
"epitope_planarity": round(planarity, 2) if np.isfinite(planarity) else float("nan"),
|
|
441
|
+
"epitope_hydrophobic_frac": round(epi_hyd_frac, 2) if np.isfinite(epi_hyd_frac) else float("nan"),
|
|
442
|
+
"epitope_aromatic_n": epi_aromatic_n,
|
|
443
|
+
"nterm_resnum": nterm_id,
|
|
444
|
+
"nterm_resname": _resname(nterm_id),
|
|
445
|
+
"nterm_relsasa": round(n_rel, 3) if np.isfinite(n_rel) else float("nan"),
|
|
446
|
+
"nterm_dist_to_interface": round(n_dist, 2) if np.isfinite(n_dist) else float("nan"),
|
|
447
|
+
"nterm_orientation": round(n_orient, 2) if np.isfinite(n_orient) else float("nan"),
|
|
448
|
+
"nterm_sg_sasa": round(n_sg, 2) if np.isfinite(n_sg) else float("nan"),
|
|
449
|
+
"cterm_resnum": cterm_id,
|
|
450
|
+
"cterm_resname": _resname(cterm_id),
|
|
451
|
+
"cterm_relsasa": round(c_rel, 3) if np.isfinite(c_rel) else float("nan"),
|
|
452
|
+
"cterm_dist_to_interface": round(c_dist, 2) if np.isfinite(c_dist) else float("nan"),
|
|
453
|
+
"cterm_orientation": round(c_orient, 2) if np.isfinite(c_orient) else float("nan"),
|
|
454
|
+
"cterm_sg_sasa": round(c_sg, 2) if np.isfinite(c_sg) else float("nan"),
|
|
455
|
+
"recommended_tag": recommended,
|
|
456
|
+
"mw": seqm["mw"],
|
|
457
|
+
"gravy": seqm["gravy"],
|
|
458
|
+
"net_charge_ph74": seqm["net_charge_ph74"],
|
|
459
|
+
"pi": seqm["pi"],
|
|
460
|
+
"ext_coeff_280": seqm["ext_coeff_280"],
|
|
461
|
+
"sequence_liabilities": seqm["sequence_liabilities"],
|
|
462
|
+
"warnings": "; ".join(warnings),
|
|
463
|
+
"qc_pass": qc_pass,
|
|
464
|
+
"binder_sequence": seqm["binder_sequence"],
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def score_structure(path: str, binder_chains: "list[str] | None" = None,
|
|
469
|
+
target_chains: "list[str] | None" = None,
|
|
470
|
+
interface_cutoff: float = 5.0, exposure_cutoff: float = 0.25,
|
|
471
|
+
verbose: bool = True) -> "list[dict]":
|
|
472
|
+
"""Score every binder chain in one structure file.
|
|
473
|
+
|
|
474
|
+
binder_chains / target_chains are lists of chain ids. Leave binder_chains
|
|
475
|
+
empty to auto-guess it (shortest chain in the length window, printed when
|
|
476
|
+
verbose); leave target_chains empty to use every other chain as target.
|
|
477
|
+
interface_cutoff is the heavy-atom contact distance in A; exposure_cutoff is
|
|
478
|
+
the relSASA below which a terminus counts as buried.
|
|
479
|
+
|
|
480
|
+
Returns one dict per binder chain, or a single {"pdb", "error"} dict if the
|
|
481
|
+
requested chains aren't there.
|
|
482
|
+
"""
|
|
483
|
+
binder_chains = list(binder_chains) if binder_chains else []
|
|
484
|
+
target_chains = list(target_chains) if target_chains else []
|
|
485
|
+
|
|
486
|
+
array = _load_protein(path)
|
|
487
|
+
name = os.path.basename(path)
|
|
488
|
+
chain_lens = _chain_lengths(array)
|
|
489
|
+
present = set(chain_lens)
|
|
490
|
+
|
|
491
|
+
if binder_chains:
|
|
492
|
+
binders = [c for c in binder_chains if c in present]
|
|
493
|
+
if len(binders) < len(binder_chains):
|
|
494
|
+
missing = sorted(set(binder_chains) - present)
|
|
495
|
+
return [{"pdb": name, "error": f"binder chains {missing} not in {sorted(present)}"}]
|
|
496
|
+
else:
|
|
497
|
+
binders = _guess_binder_chains(chain_lens)
|
|
498
|
+
if not binders:
|
|
499
|
+
return [{"pdb": name, "error": f"could not auto-guess a binder chain among {chain_lens}"}]
|
|
500
|
+
if verbose:
|
|
501
|
+
print(f"[auto] {name}: binder guess = {binders[0]} (chain lengths {chain_lens})")
|
|
502
|
+
|
|
503
|
+
# SASA of the whole complex once, reused for relSASA and the SG lookups
|
|
504
|
+
atom_sasa = np.nan_to_num(struc.sasa(array), nan=0.0)
|
|
505
|
+
relsasa = _residue_relsasa(array, atom_sasa)
|
|
506
|
+
rows = []
|
|
507
|
+
for bc in binders:
|
|
508
|
+
if target_chains:
|
|
509
|
+
targets = [c for c in target_chains if c in present]
|
|
510
|
+
else:
|
|
511
|
+
targets = sorted(present - set(binders))
|
|
512
|
+
rows.append(_score_binder_chain(
|
|
513
|
+
array, atom_sasa, name, bc, targets, chain_lens, relsasa, interface_cutoff, exposure_cutoff
|
|
514
|
+
))
|
|
515
|
+
return rows
|
binderqc/paths.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Expanding input paths. Stdlib only, so importing this to collect the work
|
|
2
|
+
doesn't drag in numpy/biotite."""
|
|
3
|
+
|
|
4
|
+
import glob
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def gather_paths(inputs):
|
|
9
|
+
"""Expand files, globs, and directories into a sorted unique list of .pdb/.cif paths."""
|
|
10
|
+
paths = []
|
|
11
|
+
for item in inputs:
|
|
12
|
+
if os.path.isdir(item):
|
|
13
|
+
for root, _, files in os.walk(item):
|
|
14
|
+
paths.extend(os.path.join(root, f) for f in files if f.lower().endswith((".pdb", ".cif")))
|
|
15
|
+
else:
|
|
16
|
+
paths.extend(glob.glob(item))
|
|
17
|
+
return sorted(set(paths))
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: binderqc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Quality-control and tag-site scoring for designed protein binders, from a predicted complex
|
|
5
|
+
Project-URL: Homepage, https://github.com/ssiddhantsharma/binderqc
|
|
6
|
+
Author: Siddhant Sharma
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 Siddhant Sharma
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Keywords: binder,developability,protein-design,qc,sasa,structural-biology
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Programming Language :: Python :: 3
|
|
32
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
33
|
+
Requires-Python: >=3.10
|
|
34
|
+
Requires-Dist: biotite>=1.0
|
|
35
|
+
Requires-Dist: numpy>=1.24
|
|
36
|
+
Requires-Dist: pandas>=2.0
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: pytest; extra == 'test'
|
|
39
|
+
Provides-Extra: validation
|
|
40
|
+
Requires-Dist: matplotlib; extra == 'validation'
|
|
41
|
+
Requires-Dist: scipy; extra == 'validation'
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
|
|
44
|
+
<h1 align="center">binderqc</h1>
|
|
45
|
+
|
|
46
|
+
<p align="center">
|
|
47
|
+
Quality control and tag-site scoring for designed protein binders — geometry and
|
|
48
|
+
sequence only, one CSV row per binder, straight from a predicted complex.
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
<p align="center">
|
|
52
|
+
<a href="https://github.com/ssiddhantsharma/binderqc/actions/workflows/ci.yml"><img src="https://github.com/ssiddhantsharma/binderqc/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
53
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
54
|
+
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+"></a>
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
<p align="center">
|
|
58
|
+
<img src="docs/schematic.png" width="860"
|
|
59
|
+
alt="From a predicted binder–target complex, binderqc reports interface, pose, grippability, tag site, and developability">
|
|
60
|
+
</p>
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install -e . # or: uv pip install -e .
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Python 3.10+. Pulls in `biotite`, `numpy`, and `pandas`.
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
binderqc --binder-chains A --target-chains B --out out.csv complex.cif some_dir/
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from binderqc import score_structure
|
|
78
|
+
rows = score_structure("complex.pdb", binder_chains=["A"], target_chains=["B"])
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Inputs are PDB/CIF files, globs, or directories. Leave `--binder-chains` off to
|
|
82
|
+
guess the binder as the shortest chain (20–250 aa, printed for each file);
|
|
83
|
+
`--target-chains` defaults to the remaining chains.
|
|
84
|
+
|
|
85
|
+
| flag | default | meaning |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `--binder-chains` | auto-guess | comma-separated binder chain ids |
|
|
88
|
+
| `--target-chains` | all non-binder | comma-separated target chain ids |
|
|
89
|
+
| `--interface-cutoff` | `5.0` | heavy-atom contact distance (Å) |
|
|
90
|
+
| `--exposure-cutoff` | `0.25` | relSASA below which a terminus is buried |
|
|
91
|
+
| `--out` | `binderqc.csv` | output CSV path |
|
|
92
|
+
| `--fasta` | off | also write the QC-passing binders to this FASTA |
|
|
93
|
+
|
|
94
|
+
Example output for the bundled LCB1 minibinder (a few of the columns):
|
|
95
|
+
|
|
96
|
+
| recommended_tag | binder_bsa | epitope_planarity | epitope_aromatic_n | pi | qc_pass |
|
|
97
|
+
|---|---|---|---|---|---|
|
|
98
|
+
| C | 1021.4 | 3.21 | 11 | 4.17 | True |
|
|
99
|
+
|
|
100
|
+
Its `warnings` field reads *both termini ~equidistant from interface (ambiguous)* —
|
|
101
|
+
a tag-site advisory, so `qc_pass` stays `True`.
|
|
102
|
+
|
|
103
|
+
## What it reports
|
|
104
|
+
|
|
105
|
+
Per binder chain:
|
|
106
|
+
|
|
107
|
+
- **Interface** — buried surface area and interface residue count.
|
|
108
|
+
- **Pose** — approach angle (end-on vs. lying across the surface).
|
|
109
|
+
- **Grippability** — epitope planarity, hydrophobic fraction, aromatic anchors.
|
|
110
|
+
- **Tag site** — recommended terminus (N/C) and the numbers behind it: relative
|
|
111
|
+
SASA, CA–CA distance to the paratope, orientation, and a terminal cysteine's SG SASA.
|
|
112
|
+
- **Developability** — sequence liabilities, GRAVY, net charge, pI, MW, ε₂₈₀.
|
|
113
|
+
|
|
114
|
+
A `warnings` column flags problems (small, flat, or anchorless interfaces;
|
|
115
|
+
buried, ambiguous, or interface-facing tag sites; hydrophobic sequences).
|
|
116
|
+
`qc_pass` is true when there are no *quality* warnings — tag-site advisories like
|
|
117
|
+
an ambiguous terminus don't count — and `--fasta` dumps exactly those binders.
|
|
118
|
+
|
|
119
|
+
<details>
|
|
120
|
+
<summary>Full column list</summary>
|
|
121
|
+
|
|
122
|
+
`pdb, binder_chain, target_chains, binder_len, n_interface_res, binder_bsa,
|
|
123
|
+
approach_angle, epitope_planarity, epitope_hydrophobic_frac, epitope_aromatic_n,
|
|
124
|
+
nterm_resnum, nterm_resname, nterm_relsasa, nterm_dist_to_interface,
|
|
125
|
+
nterm_orientation, nterm_sg_sasa, cterm_resnum, cterm_resname, cterm_relsasa,
|
|
126
|
+
cterm_dist_to_interface, cterm_orientation, cterm_sg_sasa, recommended_tag, mw,
|
|
127
|
+
gravy, net_charge_ph74, pi, ext_coeff_280, sequence_liabilities, warnings,
|
|
128
|
+
qc_pass, binder_sequence`
|
|
129
|
+
</details>
|
|
130
|
+
|
|
131
|
+
## Tests
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install -e ".[test]"
|
|
135
|
+
pytest
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Runs against a bundled example, PDB 7JZU (the LCB1 minibinder on the SARS-CoV-2
|
|
139
|
+
RBD). `tests/pisa_correctness.py` is a separate script (not part of the unit
|
|
140
|
+
tests) that downloads 18 public complexes from RCSB and PDBePISA and checks the
|
|
141
|
+
interface area against PISA (r ≈ 1.0, ~1% median error):
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pip install -e ".[validation]"
|
|
145
|
+
python tests/pisa_correctness.py
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
binderqc/__init__.py,sha256=Kt8M_IY3IRSazwpOetXZpfMDeTJYtYpBq-Ow6K6gU5s,236
|
|
2
|
+
binderqc/cli.py,sha256=0mihYtX4VMtfhm832OSW2DN9fSk0ZuSuKEccnf9dwdg,3364
|
|
3
|
+
binderqc/core.py,sha256=X9PBNm54SD9gfQBSrJlcgCU9xTgzvhPqRTDK_UeihUk,23056
|
|
4
|
+
binderqc/paths.py,sha256=F9gAz9k6gp1enu56Ghwc8vU93wvlMYFNLU4BJNtcWBo,568
|
|
5
|
+
binderqc-0.1.0.dist-info/METADATA,sha256=De0Lz_gyO60FxXJUdrMQDxg_TBO_UShlqalkwvgIzPo,6178
|
|
6
|
+
binderqc-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
binderqc-0.1.0.dist-info/entry_points.txt,sha256=4zfpzd1ke9fPsiPJqCA9pFjTSoD1YJ8palLKBfva_Go,47
|
|
8
|
+
binderqc-0.1.0.dist-info/licenses/LICENSE,sha256=SPi7dcUUfNW-dsfYg3lHQOICi7FYgxz4tkpDAx6ShmU,1072
|
|
9
|
+
binderqc-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Siddhant Sharma
|
|
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.
|