phasepred 0.9.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.
Files changed (64) hide show
  1. phasepred/__init__.py +3 -0
  2. phasepred/cli.py +1053 -0
  3. phasepred/data/models/PdPS/8f_model_0.joblib +0 -0
  4. phasepred/data/models/PdPS/8f_model_1.joblib +0 -0
  5. phasepred/data/models/PdPS/8f_model_2.joblib +0 -0
  6. phasepred/data/models/PdPS/8f_model_3.joblib +0 -0
  7. phasepred/data/models/PdPS/8f_model_4.joblib +0 -0
  8. phasepred/data/models/PdPS/8f_model_5.joblib +0 -0
  9. phasepred/data/models/PdPS/8f_model_6.joblib +0 -0
  10. phasepred/data/models/PdPS/8f_model_7.joblib +0 -0
  11. phasepred/data/models/PdPS/8f_model_8.joblib +0 -0
  12. phasepred/data/models/PdPS/8f_model_9.joblib +0 -0
  13. phasepred/data/models/PdPS/manifest.json +25 -0
  14. phasepred/data/models/SaPS/8f_model_0.joblib +0 -0
  15. phasepred/data/models/SaPS/8f_model_1.joblib +0 -0
  16. phasepred/data/models/SaPS/8f_model_2.joblib +0 -0
  17. phasepred/data/models/SaPS/8f_model_3.joblib +0 -0
  18. phasepred/data/models/SaPS/8f_model_4.joblib +0 -0
  19. phasepred/data/models/SaPS/8f_model_5.joblib +0 -0
  20. phasepred/data/models/SaPS/8f_model_6.joblib +0 -0
  21. phasepred/data/models/SaPS/8f_model_7.joblib +0 -0
  22. phasepred/data/models/SaPS/8f_model_8.joblib +0 -0
  23. phasepred/data/models/SaPS/8f_model_9.joblib +0 -0
  24. phasepred/data/models/SaPS/manifest.json +25 -0
  25. phasepred/data/models/hPdPS/8f_model_0.joblib +0 -0
  26. phasepred/data/models/hPdPS/8f_model_1.joblib +0 -0
  27. phasepred/data/models/hPdPS/8f_model_2.joblib +0 -0
  28. phasepred/data/models/hPdPS/8f_model_3.joblib +0 -0
  29. phasepred/data/models/hPdPS/8f_model_4.joblib +0 -0
  30. phasepred/data/models/hPdPS/8f_model_5.joblib +0 -0
  31. phasepred/data/models/hPdPS/8f_model_6.joblib +0 -0
  32. phasepred/data/models/hPdPS/8f_model_7.joblib +0 -0
  33. phasepred/data/models/hPdPS/8f_model_8.joblib +0 -0
  34. phasepred/data/models/hPdPS/8f_model_9.joblib +0 -0
  35. phasepred/data/models/hPdPS/manifest.json +27 -0
  36. phasepred/data/models/hSaPS/8f_model_0.joblib +0 -0
  37. phasepred/data/models/hSaPS/8f_model_1.joblib +0 -0
  38. phasepred/data/models/hSaPS/8f_model_2.joblib +0 -0
  39. phasepred/data/models/hSaPS/8f_model_3.joblib +0 -0
  40. phasepred/data/models/hSaPS/8f_model_4.joblib +0 -0
  41. phasepred/data/models/hSaPS/8f_model_5.joblib +0 -0
  42. phasepred/data/models/hSaPS/8f_model_6.joblib +0 -0
  43. phasepred/data/models/hSaPS/8f_model_7.joblib +0 -0
  44. phasepred/data/models/hSaPS/8f_model_8.joblib +0 -0
  45. phasepred/data/models/hSaPS/8f_model_9.joblib +0 -0
  46. phasepred/data/models/hSaPS/manifest.json +27 -0
  47. phasepred/data.py +65 -0
  48. phasepred/espritz.py +192 -0
  49. phasepred/features.py +158 -0
  50. phasepred/legacy_features.py +224 -0
  51. phasepred/models.py +141 -0
  52. phasepred/paper.py +160 -0
  53. phasepred/predictor.py +215 -0
  54. phasepred/sequences.py +220 -0
  55. phasepred/tool_features.py +218 -0
  56. phasepred/tool_paths.py +429 -0
  57. phasepred/tools.py +602 -0
  58. phasepred/updated_data.py +349 -0
  59. phasepred-0.9.0.dist-info/METADATA +283 -0
  60. phasepred-0.9.0.dist-info/RECORD +64 -0
  61. phasepred-0.9.0.dist-info/WHEEL +5 -0
  62. phasepred-0.9.0.dist-info/entry_points.txt +2 -0
  63. phasepred-0.9.0.dist-info/licenses/LICENSE +35 -0
  64. phasepred-0.9.0.dist-info/top_level.txt +1 -0
phasepred/tools.py ADDED
@@ -0,0 +1,602 @@
1
+ """External feature-tool runners.
2
+
3
+ Module boundary (S2): ``features`` holds pure computation, while this
4
+ module owns the *four-step* lifecycle for every external feature tool —
5
+ **locate** (resolve the tool-package entry), **execute** (subprocess or
6
+ lookup), **parse** (translate raw output into per-accession values), and
7
+ **degrade** (return ``{}`` so the caller stamps NaN).
8
+
9
+ Since S3 the *locate* step goes through the manifest registry in
10
+ ``phasepred.tool_paths``: each tool is a self-contained package under
11
+ ``tools/<TOOL>/`` (``run`` entrypoint + ``manifest.toml`` contract) and the
12
+ registry resolves ``PHASEPRED_<TOOL>_DIR`` → ``tools/<TOOL>/`` → PATH.
13
+ The package ``run`` scripts dispatch exactly the same underlying commands
14
+ as the pre-S3 wrappers, so output values are unchanged.
15
+
16
+ The universal public surface is ``run_<tool>(records) -> dict[str, float]``
17
+ with ``records`` a list of ``{"accession": str, "sequence": str}`` dicts.
18
+ A missing/broken tool degrades to ``{}`` — never raises at inference time
19
+ (path resolution problems still raise ``PhaSePredToolNotFound`` so
20
+ ``predict``'s preflight can fail fast).
21
+
22
+ Per-feature v2022 definitions (``docs/FEATURES.md``):
23
+ - DeepCoil: binarized ``1.0 if max(raw_cc) >= 0.82 else 0.0``.
24
+ - ESpritz: DisProt model ``D``, ``sw 0`` (5% FPR), IDR = fraction of ``D``
25
+ state residues parsed from the two-column residue lines only.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ import shutil
32
+ import subprocess
33
+ import tempfile
34
+ import warnings
35
+ from multiprocessing import Pool
36
+ from pathlib import Path
37
+
38
+ import pandas as pd
39
+
40
+ from phasepred.data import data_path
41
+ from phasepred.tool_paths import (
42
+ find_tool_entry,
43
+ find_tool_entry_or_none,
44
+ )
45
+
46
+
47
+ class PhaSePredMissingDataWarning(UserWarning):
48
+ """Emitted when optional data (DeepPhase/PhosphoSitePlus) is missing.
49
+
50
+ The lookup runners are the only emitters; ``predictor`` re-exports it so
51
+ ``predict`` can register it with the ``warnings`` machinery.
52
+ """
53
+
54
+
55
+ DEEPHASE_TABLE = data_path("data", "raw", "external", "deepphase", "extracted", "tableS3.xlsx")
56
+ PHOSPHOSITE_PATH = data_path(
57
+ "data", "raw", "external", "phosphositeplus", "Phosphorylation_site_dataset.gz"
58
+ )
59
+
60
+ ESPRITZ_STATE_RE = re.compile(r"^(?P<state>[DO])\s+(?P<score>\S+)$")
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # ESpritz (IDR)
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def _safe_stem(accession: str) -> str:
68
+ return "".join(c if c.isalnum() or c in "-_" else "_" for c in accession)
69
+
70
+
71
+ def run_espritz(
72
+ records: list[dict[str, str]],
73
+ *,
74
+ model: str = "D",
75
+ sw: int = 0,
76
+ ) -> dict[str, float]:
77
+ """Run ESpritz DisProt 5% FPR on all records (batch in one directory).
78
+
79
+ Returns the per-accession IDR fraction (``count(D) / L``) computed from
80
+ the two-column residue lines only — banner/license lines are ignored.
81
+ """
82
+ wrapper = find_tool_entry("espritz")
83
+ workdir = Path(tempfile.mkdtemp(prefix="espritz_pred_"))
84
+ try:
85
+ for r in records:
86
+ fasta = workdir / f"{_safe_stem(r['accession'])}.fasta"
87
+ fasta.write_text(
88
+ f">{r['accession']}\n{str(r['sequence']).upper()}\n", encoding="utf-8"
89
+ )
90
+ result = subprocess.run(
91
+ [str(wrapper), str(workdir), model, str(sw)],
92
+ capture_output=True, text=True, timeout=300,
93
+ )
94
+ if result.returncode != 0:
95
+ return {}
96
+
97
+ scores: dict[str, float] = {}
98
+ for r in records:
99
+ out = workdir / f"{_safe_stem(r['accession'])}.espritz"
100
+ if not out.exists():
101
+ continue
102
+ states = _parse_espritz_states(out)
103
+ if states:
104
+ scores[r["accession"]] = sum(s == "D" for s in states) / len(states)
105
+ return scores
106
+ finally:
107
+ shutil.rmtree(workdir, ignore_errors=True)
108
+
109
+
110
+ def _parse_espritz_states(out_path: Path) -> list[str]:
111
+ """Extract ordered residue states from a ``.espritz`` output file.
112
+
113
+ Only two-column residue lines whose first token is D or O are counted;
114
+ license/banner/sequence lines are skipped (v2022 IDR parsing rule).
115
+ """
116
+ states: list[str] = []
117
+ for line in out_path.read_text(encoding="utf-8", errors="replace").splitlines():
118
+ match = ESPRITZ_STATE_RE.match(line.strip())
119
+ if match is not None:
120
+ states.append(match.group("state"))
121
+ return states
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # SEG (LCR)
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def run_seg(records: list[dict[str, str]]) -> dict[str, float]:
130
+ """Run SEG with the default parameters (``12 2.2 2.5 -x``)."""
131
+ seg_bin = find_tool_entry("seg")
132
+
133
+ fasta_text = "".join(
134
+ f">{r['accession']}\n{str(r['sequence']).upper()}\n" for r in records
135
+ )
136
+ with tempfile.NamedTemporaryFile(
137
+ mode="w", suffix=".fasta", delete=False, prefix="seg_pred_"
138
+ ) as fh:
139
+ fh.write(fasta_text)
140
+ fasta_path = Path(fh.name)
141
+
142
+ try:
143
+ result = subprocess.run(
144
+ [str(seg_bin), str(fasta_path), "12", "2.2", "2.5", "-x"],
145
+ capture_output=True, text=True, timeout=120,
146
+ )
147
+ if result.returncode != 0:
148
+ return {}
149
+
150
+ masked: dict[str, str] = {}
151
+ current_id = ""
152
+ current_seq: list[str] = []
153
+ for line in result.stdout.split("\n"):
154
+ line = line.strip()
155
+ if line.startswith(">"):
156
+ if current_id:
157
+ masked[current_id] = "".join(current_seq)
158
+ current_id = line[1:].split()[0]
159
+ current_seq = []
160
+ elif line:
161
+ current_seq.append(line)
162
+ if current_id:
163
+ masked[current_id] = "".join(current_seq)
164
+
165
+ scores: dict[str, float] = {}
166
+ for r in records:
167
+ seq = masked.get(r["accession"], "")
168
+ if seq:
169
+ lc = sum(1 for c in seq if c == "x" or c.islower())
170
+ scores[r["accession"]] = lc / len(seq)
171
+ return scores
172
+ finally:
173
+ fasta_path.unlink(missing_ok=True)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # PScore
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ def _pscore_chunk(args: tuple[int, list[dict[str, str]]]) -> tuple[int, str]:
182
+ chunk_idx, recs = args
183
+ fasta_text = "".join(
184
+ f">{r['accession']}\n{str(r['sequence']).upper()}\n" for r in recs
185
+ )
186
+ with tempfile.TemporaryDirectory(prefix=f"pscore_pred_{chunk_idx}_") as tmp:
187
+ workdir = Path(tmp)
188
+ fasta = workdir / "input.fasta"
189
+ output = workdir / "pscore.tsv"
190
+ fasta.write_text(fasta_text, encoding="utf-8")
191
+ subprocess.run(
192
+ [str(find_tool_entry("pscore")),
193
+ str(fasta), "-output", str(output), "-overwrite", "-mute"],
194
+ check=True, capture_output=True, text=True, timeout=300,
195
+ )
196
+ return (chunk_idx, output.read_text(encoding="utf-8"))
197
+
198
+
199
+ def run_pscore(
200
+ records: list[dict[str, str]],
201
+ *,
202
+ n_workers: int = 8,
203
+ ) -> dict[str, float]:
204
+ """Run PScore in parallel chunks; single values from ``PScore:`` lines."""
205
+ find_tool_entry("pscore")
206
+
207
+ chunk_size = max(1, len(records) // n_workers)
208
+ chunks: list[tuple[int, list[dict[str, str]]]] = []
209
+ for i in range(0, len(records), chunk_size):
210
+ chunks.append((len(chunks), records[i : i + chunk_size]))
211
+
212
+ scores: dict[str, float] = {}
213
+ with Pool(processes=min(n_workers, len(chunks))) as pool:
214
+ for _, output in pool.imap_unordered(_pscore_chunk, chunks):
215
+ for line in output.strip().split("\n"):
216
+ if not line.startswith("PScore:"):
217
+ continue
218
+ parts = line.strip().split()
219
+ if len(parts) >= 2:
220
+ try:
221
+ value = float(parts[1])
222
+ acc = parts[-1].lstrip(">")
223
+ scores[acc] = value
224
+ except (ValueError, IndexError):
225
+ continue
226
+ return scores
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # PLAAC (NLLR)
231
+ # ---------------------------------------------------------------------------
232
+
233
+
234
+ def _plaac_chunk(args: tuple[int, list[dict[str, str]]]) -> tuple[int, str]:
235
+ chunk_idx, recs = args
236
+ fasta_text = "".join(
237
+ f">{r['accession']}\n{str(r['sequence']).upper()}\n" for r in recs
238
+ )
239
+ with tempfile.TemporaryDirectory(prefix=f"plaac_pred_{chunk_idx}_") as tmp:
240
+ fasta = Path(tmp) / "input.fasta"
241
+ fasta.write_text(fasta_text, encoding="utf-8")
242
+ result = subprocess.run(
243
+ [str(find_tool_entry("plaac")), "-i", str(fasta)],
244
+ cwd=str(data_path()),
245
+ check=True, capture_output=True, text=True, timeout=300,
246
+ )
247
+ return (chunk_idx, result.stdout)
248
+
249
+
250
+ def run_plaac(
251
+ records: list[dict[str, str]],
252
+ *,
253
+ n_workers: int = 4,
254
+ ) -> dict[str, float]:
255
+ """Run PLAAC in parallel chunks; take the ``NLLR`` column value."""
256
+ find_tool_entry("plaac")
257
+
258
+ chunk_size = max(1, len(records) // n_workers)
259
+ chunks: list[tuple[int, list[dict[str, str]]]] = []
260
+ for i in range(0, len(records), chunk_size):
261
+ chunks.append((len(chunks), records[i : i + chunk_size]))
262
+
263
+ scores: dict[str, float] = {}
264
+ with Pool(processes=min(n_workers, len(chunks))) as pool:
265
+ for _, output in pool.imap_unordered(_plaac_chunk, chunks):
266
+ header: list[str] | None = None
267
+ for line in output.strip().split("\n"):
268
+ if line.startswith("#") or not line.strip():
269
+ continue
270
+ parts = line.rstrip("\n").split("\t")
271
+ if header is None:
272
+ if parts and parts[0] == "SEQid":
273
+ header = parts
274
+ continue
275
+ if len(parts) != len(header):
276
+ continue
277
+ record = dict(zip(header, parts, strict=False))
278
+ try:
279
+ scores[str(record["SEQid"])] = float(record["NLLR"])
280
+ except (KeyError, ValueError):
281
+ continue
282
+ return scores
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # DeepCoil (v2022 binary definition)
287
+ # ---------------------------------------------------------------------------
288
+
289
+
290
+ def _safe_id(accession: str) -> str:
291
+ """DeepCoil sanitizes output filenames; mirror its rule."""
292
+ return "".join(ch for ch in str(accession) if ch.isalnum() or ch == "_")
293
+
294
+
295
+ DEEPCOIL_THRESHOLD = 0.82
296
+
297
+
298
+ def _binarize_deepcoil_frame(frame: pd.DataFrame) -> float:
299
+ """v2022 DeepCoil feature: ``1.0 if max(raw_cc) >= 0.82 else 0.0``."""
300
+ if frame.empty:
301
+ raise ValueError("empty DeepCoil output")
302
+ return float(1.0 if frame["raw_cc"].max() >= DEEPCOIL_THRESHOLD else 0.0)
303
+
304
+
305
+ def run_deepcoil(records: list[dict[str, str]]) -> dict[str, float]:
306
+ """Run DeepCoil on demand for the input sequences.
307
+
308
+ Aggregates the per-residue ``raw_cc`` array into the v2022 *binary*
309
+ coiled-coil indicator (threshold 0.82). Returns ``{}`` on any failure
310
+ (wrapper not installed, env broken, subprocess timeout) so the caller
311
+ degrades the column to NaN.
312
+ """
313
+ if not records:
314
+ return {}
315
+
316
+ runner = find_tool_entry_or_none("deepcoil")
317
+ if runner is None:
318
+ return {} # contract package missing → degrade to NaN
319
+
320
+ scores: dict[str, float] = {}
321
+ with tempfile.TemporaryDirectory(prefix="deepcoil_predict_") as tmp:
322
+ workdir = Path(tmp)
323
+ fasta = workdir / "input.fasta"
324
+ out_dir = workdir / "out"
325
+ out_dir.mkdir()
326
+
327
+ lines: list[str] = []
328
+ id_map: dict[str, str] = {} # safe_id → original accession
329
+ for r in records:
330
+ acc = str(r["accession"])
331
+ safe = _safe_id(acc)
332
+ id_map[safe] = acc
333
+ seq = re.sub(r"\s+", "", str(r["sequence"]).upper())
334
+ lines.append(f">{safe}")
335
+ lines.extend(seq[i : i + 80] for i in range(0, len(seq), 80))
336
+ fasta.write_text("\n".join(lines) + "\n", encoding="utf-8")
337
+
338
+ try:
339
+ subprocess.run(
340
+ [
341
+ str(runner),
342
+ "-i", str(fasta),
343
+ "-out_path", str(out_dir),
344
+ "-n_cpu", "4",
345
+ ],
346
+ check=True,
347
+ capture_output=True,
348
+ text=True,
349
+ timeout=max(300, 30 * len(records)),
350
+ )
351
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
352
+ return {}
353
+
354
+ for safe, orig in id_map.items():
355
+ out_file = out_dir / f"{safe}.out"
356
+ if not out_file.exists():
357
+ continue
358
+ try:
359
+ frame = pd.read_csv(out_file, sep="\t")
360
+ scores[orig] = _binarize_deepcoil_frame(frame)
361
+ except Exception:
362
+ continue
363
+ return scores
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Residue-level extractors (tool-features subcommand)
368
+ #
369
+ # These mirror the *per-residue* arrays of the web ``tools.<x>`` payloads for
370
+ # the tools whose local contracts expose them. Single values stay on the
371
+ # ``run_<tool>`` surface; here we return dict[accession, dict] with the raw
372
+ # residue lists so the CLI can derive both the summary and the JSON.
373
+ # ---------------------------------------------------------------------------
374
+
375
+
376
+ def _parse_espritz_residues(out_path: Path) -> tuple[list[str], list[float]]:
377
+ states: list[str] = []
378
+ scores: list[float] = []
379
+ for line in out_path.read_text(encoding="utf-8", errors="replace").splitlines():
380
+ match = ESPRITZ_STATE_RE.match(line.strip())
381
+ if match is not None:
382
+ states.append(match.group("state"))
383
+ try:
384
+ scores.append(float(match.group("score")))
385
+ except ValueError:
386
+ scores.append(float("nan"))
387
+ return states, scores
388
+
389
+
390
+ def espritz_residues(
391
+ records: list[dict[str, str]],
392
+ *,
393
+ model: str = "D",
394
+ sw: int = 0,
395
+ ) -> dict[str, dict[str, object]]:
396
+ """Per-residue ESpritz ``label`` (D/O) + ``residue`` (score) arrays."""
397
+ wrapper = find_tool_entry("espritz")
398
+ workdir = Path(tempfile.mkdtemp(prefix="espritz_res_"))
399
+ try:
400
+ for r in records:
401
+ fasta = workdir / f"{_safe_stem(r['accession'])}.fasta"
402
+ fasta.write_text(
403
+ f">{r['accession']}\n{str(r['sequence']).upper()}\n", encoding="utf-8"
404
+ )
405
+ result = subprocess.run(
406
+ [str(wrapper), str(workdir), model, str(sw)],
407
+ capture_output=True, text=True, timeout=300,
408
+ )
409
+ if result.returncode != 0:
410
+ return {}
411
+ out: dict[str, dict[str, object]] = {}
412
+ for r in records:
413
+ path = workdir / f"{_safe_stem(r['accession'])}.espritz"
414
+ if not path.exists():
415
+ continue
416
+ states, scores = _parse_espritz_residues(path)
417
+ out[r["accession"]] = {"label": states, "residue": scores}
418
+ return out
419
+ finally:
420
+ shutil.rmtree(workdir, ignore_errors=True)
421
+
422
+
423
+ def _seg_masked_map(seg_bin: Path, fasta_path: Path) -> dict[str, str]:
424
+ result = subprocess.run(
425
+ [str(seg_bin), str(fasta_path), "12", "2.2", "2.5", "-x"],
426
+ capture_output=True, text=True, timeout=120,
427
+ )
428
+ if result.returncode != 0:
429
+ return {}
430
+ masked: dict[str, str] = {}
431
+ current_id = ""
432
+ current_seq: list[str] = []
433
+ for line in result.stdout.split("\n"):
434
+ line = line.strip()
435
+ if line.startswith(">"):
436
+ if current_id:
437
+ masked[current_id] = "".join(current_seq)
438
+ current_id = line[1:].split()[0]
439
+ current_seq = []
440
+ elif line:
441
+ current_seq.append(line)
442
+ if current_id:
443
+ masked[current_id] = "".join(current_seq)
444
+ return masked
445
+
446
+
447
+ def seg_residues(records: list[dict[str, str]]) -> dict[str, dict[str, object]]:
448
+ """Per-residue SEG low-complexity ``label`` mask (1 = masked)."""
449
+ seg_bin = find_tool_entry("seg")
450
+ fasta_text = "".join(
451
+ f">{r['accession']}\n{str(r['sequence']).upper()}\n" for r in records
452
+ )
453
+ with tempfile.NamedTemporaryFile(
454
+ mode="w", suffix=".fasta", delete=False, prefix="seg_res_"
455
+ ) as fh:
456
+ fh.write(fasta_text)
457
+ fasta_path = Path(fh.name)
458
+ try:
459
+ masked = _seg_masked_map(seg_bin, fasta_path)
460
+ out: dict[str, dict[str, object]] = {}
461
+ for r in records:
462
+ seq = masked.get(r["accession"], "")
463
+ label = [1.0 if c == "x" or c.islower() else 0.0 for c in seq]
464
+ out[r["accession"]] = {"label": label}
465
+ return out
466
+ finally:
467
+ fasta_path.unlink(missing_ok=True)
468
+
469
+
470
+ def deepcoil_residues(records: list[dict[str, str]]) -> dict[str, dict[str, object]]:
471
+ """Per-residue DeepCoil ``coiled-coil`` (raw_cc) + ``sharpen`` (cc) arrays."""
472
+ if not records:
473
+ return {}
474
+ runner = find_tool_entry_or_none("deepcoil")
475
+ if runner is None:
476
+ return {}
477
+ out: dict[str, dict[str, object]] = {}
478
+ with tempfile.TemporaryDirectory(prefix="deepcoil_res_") as tmp:
479
+ workdir = Path(tmp)
480
+ fasta = workdir / "input.fasta"
481
+ out_dir = workdir / "out"
482
+ out_dir.mkdir()
483
+ lines: list[str] = []
484
+ id_map: dict[str, str] = {}
485
+ for r in records:
486
+ acc = str(r["accession"])
487
+ safe = _safe_id(acc)
488
+ id_map[safe] = acc
489
+ seq = re.sub(r"\s+", "", str(r["sequence"]).upper())
490
+ lines.append(f">{safe}")
491
+ lines.extend(seq[i : i + 80] for i in range(0, len(seq), 80))
492
+ fasta.write_text("\n".join(lines) + "\n", encoding="utf-8")
493
+ try:
494
+ subprocess.run(
495
+ [str(runner), "-i", str(fasta), "-out_path", str(out_dir), "-n_cpu", "4"],
496
+ check=True, capture_output=True, text=True,
497
+ timeout=max(300, 30 * len(records)),
498
+ )
499
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
500
+ return {}
501
+ for safe, orig in id_map.items():
502
+ fpath = out_dir / f"{safe}.out"
503
+ if not fpath.exists():
504
+ continue
505
+ try:
506
+ frame = pd.read_csv(fpath, sep="\t")
507
+ out[orig] = {
508
+ "coiled-coil": frame["raw_cc"].astype(float).tolist(),
509
+ "sharpen": frame["cc"].astype(float).tolist(),
510
+ }
511
+ except Exception:
512
+ continue
513
+ return out
514
+
515
+
516
+ # ---------------------------------------------------------------------------
517
+ # Lookups (DeepPhase table, PhosphoSitePlus)
518
+ # ---------------------------------------------------------------------------
519
+ def lookup_deepphase(accessions: list[str]) -> dict[str, float]:
520
+ """Look up DeepPhase scores from the DeepPhase supplement table S3.
521
+
522
+ DeepPhase has no inference tool — only the reference table. Accessions
523
+ not in the table return nothing (the caller imputes with a warning).
524
+ """
525
+ if not DEEPHASE_TABLE.exists():
526
+ warnings.warn(
527
+ (
528
+ f"DeepPhase table missing at {DEEPHASE_TABLE}. "
529
+ "Human-mode predictions (hSaPS / hPdPS) will use median "
530
+ "imputation for the DeepPhase column. See the README "
531
+ "section 'Optional human-feature data' for the download."
532
+ ),
533
+ PhaSePredMissingDataWarning,
534
+ stacklevel=2,
535
+ )
536
+ return {}
537
+ dp = pd.read_excel(str(DEEPHASE_TABLE), sheet_name="tableS3")
538
+ dp_map: dict[str, float] = {}
539
+ for _, row in dp.iterrows():
540
+ swiss = str(row.get("Swiss-Prot ID", "")).strip()
541
+ if swiss and swiss != "nan":
542
+ try:
543
+ dp_map[swiss] = float(row["DeepPhase_score"])
544
+ except (ValueError, KeyError):
545
+ continue
546
+ hits = {a: dp_map[a] for a in accessions if a in dp_map}
547
+ missing = [a for a in accessions if a not in dp_map]
548
+ if missing:
549
+ preview = ", ".join(missing[:5]) + ("…" if len(missing) > 5 else "")
550
+ warnings.warn(
551
+ (
552
+ f"DeepPhase has no entry for {len(missing)}/{len(accessions)} "
553
+ f"requested protein(s): {preview}. Median imputation will be "
554
+ "used, so hSaPS/hPdPS scores for these proteins should be "
555
+ "treated as approximate."
556
+ ),
557
+ PhaSePredMissingDataWarning,
558
+ stacklevel=2,
559
+ )
560
+ return hits
561
+
562
+
563
+ def compute_phos_freq(records: list[dict[str, str]]) -> dict[str, float]:
564
+ """Compute phosphorylation-site frequency from PhosphoSitePlus.
565
+
566
+ Returns per-accession site count / sequence length for the human set.
567
+ """
568
+ if not PHOSPHOSITE_PATH.exists():
569
+ warnings.warn(
570
+ (
571
+ f"PhosphoSitePlus dataset missing at {PHOSPHOSITE_PATH}. "
572
+ "Human-mode predictions (hSaPS / hPdPS) will use median "
573
+ "imputation for the 'Phos freq' column. See the README "
574
+ "section 'Optional human-feature data' for the registration "
575
+ "and download steps."
576
+ ),
577
+ PhaSePredMissingDataWarning,
578
+ stacklevel=2,
579
+ )
580
+ return {}
581
+ from phasepred.legacy_features import compute_phosphosite_frequencies
582
+
583
+ seq_rows: list[dict[str, object]] = [
584
+ {"UniprotEntry": r["accession"], "sequence": r["sequence"]} for r in records
585
+ ]
586
+ features = compute_phosphosite_frequencies(seq_rows, PHOSPHOSITE_PATH)
587
+ hits = {f.accession: f.value for f in features}
588
+ requested = {r["accession"] for r in records}
589
+ missing = sorted(requested - set(hits.keys()))
590
+ if missing:
591
+ preview = ", ".join(missing[:5]) + ("…" if len(missing) > 5 else "")
592
+ warnings.warn(
593
+ (
594
+ f"PhosphoSitePlus has no entry for {len(missing)}/{len(requested)} "
595
+ f"requested protein(s): {preview}. Median imputation will be "
596
+ "used for 'Phos freq', so hSaPS/hPdPS scores for these "
597
+ "proteins should be treated as approximate."
598
+ ),
599
+ PhaSePredMissingDataWarning,
600
+ stacklevel=2,
601
+ )
602
+ return hits