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 ADDED
@@ -0,0 +1,7 @@
1
+ """Score binder termini for tag/conjugation suitability from predicted complexes."""
2
+
3
+ from .core import score_structure
4
+ from .paths import gather_paths
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["score_structure", "gather_paths", "__version__"]
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ binderqc = binderqc.cli:main
@@ -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.