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/predictor.py ADDED
@@ -0,0 +1,215 @@
1
+ """PhaSePred predictor: compute features and score proteins from FASTA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from phasepred.data import models_root
12
+
13
+ # Default artifact (single product): Product A — paper split recomputed.
14
+ DEFAULT_MODELS_DIR = models_root()
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Feature computation
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ def compute_all_features(
23
+ records: list[dict[str, str]],
24
+ *,
25
+ include_human: bool = False,
26
+ verbose: bool = True,
27
+ ) -> pd.DataFrame:
28
+ """Compute all PhaSePred features for a list of FASTA records.
29
+
30
+ Each record is {"accession": str, "sequence": str}.
31
+ Returns a DataFrame with feature columns ready for prediction.
32
+ """
33
+ from catgranule import CatGranuleInputError, score_sequence
34
+
35
+ from phasepred import tools
36
+ from phasepred.features import compute_native_features as _native
37
+
38
+ t_start = time.time()
39
+
40
+ # --- Native features (pure Python, fast) ---
41
+ native_rows = []
42
+ for r in records:
43
+ seq = str(r["sequence"]).upper()
44
+ try:
45
+ feats = _native(seq)
46
+ native_rows.append(
47
+ {
48
+ "UniprotEntry": r["accession"],
49
+ "length": feats["length"],
50
+ "Hydropathy": feats["Hydropathy"],
51
+ "FCR": feats["FCR"],
52
+ }
53
+ )
54
+ except Exception:
55
+ native_rows.append(
56
+ {
57
+ "UniprotEntry": r["accession"],
58
+ "length": float("nan"),
59
+ "Hydropathy": float("nan"),
60
+ "FCR": float("nan"),
61
+ }
62
+ )
63
+
64
+ feature_df = pd.DataFrame(native_rows)
65
+ if verbose:
66
+ print(f" Native features: {len(feature_df)} in {time.time() - t_start:.1f}s")
67
+
68
+ # --- catGRANULE (via the catgranule package, legacy-unaudited weights) ---
69
+ t0 = time.time()
70
+ cat_scores: dict[str, float] = {}
71
+ for r in records:
72
+ try:
73
+ cat_scores[r["accession"]] = float(score_sequence(str(r["sequence"]))["single"])
74
+ except CatGranuleInputError:
75
+ cat_scores[r["accession"]] = float("nan")
76
+ feature_df["catGRANULE"] = feature_df["UniprotEntry"].map(cat_scores)
77
+ if verbose:
78
+ n = feature_df["catGRANULE"].notna().sum()
79
+ print(f" catGRANULE: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
80
+
81
+ # --- ESpritz IDR ---
82
+ t0 = time.time()
83
+ espritz_scores = tools.run_espritz(records)
84
+ feature_df["IDR"] = feature_df["UniprotEntry"].map(espritz_scores)
85
+ if verbose:
86
+ n = feature_df["IDR"].notna().sum()
87
+ print(f" ESpritz IDR: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
88
+
89
+ # --- SEG LCR ---
90
+ t0 = time.time()
91
+ seg_scores = tools.run_seg(records)
92
+ feature_df["LCR"] = feature_df["UniprotEntry"].map(seg_scores)
93
+ if verbose:
94
+ n = feature_df["LCR"].notna().sum()
95
+ print(f" SEG LCR: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
96
+
97
+ # --- PScore (parallel) ---
98
+ t0 = time.time()
99
+ pscore_scores = tools.run_pscore(records)
100
+ feature_df["PScore"] = feature_df["UniprotEntry"].map(pscore_scores)
101
+ if verbose:
102
+ n = feature_df["PScore"].notna().sum()
103
+ print(f" PScore: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
104
+
105
+ # --- PLAAC (parallel) ---
106
+ t0 = time.time()
107
+ plaac_scores = tools.run_plaac(records)
108
+ feature_df["PLAAC"] = feature_df["UniprotEntry"].map(plaac_scores)
109
+ if verbose:
110
+ n = feature_df["PLAAC"].notna().sum()
111
+ print(f" PLAAC: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
112
+
113
+ # --- DeepCoil (on-demand subprocess into isolated conda env; binary 0/1) ---
114
+ t0 = time.time()
115
+ dc_scores = tools.run_deepcoil(records)
116
+ feature_df["DeepCoil"] = feature_df["UniprotEntry"].map(dc_scores)
117
+ if verbose:
118
+ n = feature_df["DeepCoil"].notna().sum()
119
+ print(f" DeepCoil: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
120
+
121
+ # --- Human-specific features ---
122
+ if include_human:
123
+ t0 = time.time()
124
+ phos_scores = tools.compute_phos_freq(records)
125
+ feature_df["Phos freq"] = feature_df["UniprotEntry"].map(phos_scores)
126
+ dp_scores = tools.lookup_deepphase([r["accession"] for r in records])
127
+ feature_df["DeepPhase"] = feature_df["UniprotEntry"].map(dp_scores)
128
+ if verbose:
129
+ n_p = feature_df["Phos freq"].notna().sum()
130
+ n_d = feature_df["DeepPhase"].notna().sum()
131
+ print(f" Phos freq + DeepPhase: {n_p}, {n_d} in {time.time() - t0:.1f}s")
132
+
133
+ if verbose:
134
+ total_t = time.time() - t_start
135
+ print(f" Total feature computation: {total_t:.0f}s")
136
+
137
+ return feature_df
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Model loading and prediction
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def load_ensemble(model_dir: Path) -> list:
146
+ """Load a 10-model XGBoost ensemble from a directory of joblib files."""
147
+ import joblib
148
+
149
+ model_files = sorted(model_dir.glob("8f_model_*.joblib"))
150
+ if not model_files:
151
+ raise FileNotFoundError(f"No 8f_model_*.joblib files found in {model_dir}")
152
+ return [joblib.load(str(f)) for f in model_files]
153
+
154
+
155
+ def load_single_model(model_path: Path):
156
+ """Load a single model artifact (joblib file)."""
157
+ import joblib
158
+
159
+ if not model_path.exists():
160
+ raise FileNotFoundError(f"Model not found: {model_path}")
161
+ return joblib.load(str(model_path))
162
+
163
+
164
+ def predict_scores(
165
+ models: list,
166
+ features: pd.DataFrame,
167
+ feature_columns: list[str],
168
+ ) -> pd.DataFrame:
169
+ """Predict scores using a loaded ensemble (or single model).
170
+
171
+ If the model was trained on a subset of columns (e.g. paper baseline
172
+ with only 3 features), use only those columns from the feature table.
173
+
174
+ Returns DataFrame with columns: UniprotEntry, score
175
+ """
176
+ # Check if the model artifact specifies its own feature columns
177
+ model_columns = None
178
+ if isinstance(models, list) and models:
179
+ first = models[0]
180
+ if hasattr(first, "feature_columns"):
181
+ model_columns = list(first.feature_columns)
182
+ elif hasattr(models, "feature_columns"):
183
+ model_columns = list(models.feature_columns)
184
+
185
+ effective_columns = model_columns or feature_columns
186
+ avail_cols = [c for c in effective_columns if c in features.columns]
187
+ missing = set(effective_columns) - set(avail_cols)
188
+ if missing:
189
+ for col in missing:
190
+ features[col] = float("nan")
191
+
192
+ X = features[avail_cols]
193
+
194
+ # models may be a single object or a list
195
+ model_list = models if isinstance(models, list) else [models]
196
+
197
+ if len(model_list) > 1:
198
+ scores = np.zeros(len(X))
199
+ for model in model_list:
200
+ scores += _model_predict_proba(model, X)[:, 1]
201
+ scores /= len(model_list)
202
+ else:
203
+ scores = _model_predict_proba(model_list[0], X)[:, 1]
204
+
205
+ result = features[["UniprotEntry"]].copy()
206
+ result["score"] = scores
207
+ return result
208
+
209
+
210
+ def _model_predict_proba(model, X: pd.DataFrame) -> np.ndarray:
211
+ """Call predict_proba, handling both sklearn Pipeline and ModelArtifact."""
212
+ if hasattr(model, "estimator"):
213
+ # ModelArtifact wrapper
214
+ return model.estimator.predict_proba(X)
215
+ return model.predict_proba(X)
phasepred/sequences.py ADDED
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from dataclasses import asdict, dataclass, field
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any, Protocol
10
+
11
+ import pandas as pd
12
+ import requests
13
+ from Bio import SeqIO
14
+
15
+ UNIPROT_FASTA_BASE_URL = "https://rest.uniprot.org/uniprotkb"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ProteinSequence:
20
+ accession: str
21
+ sequence: str
22
+ source: str
23
+ header: str
24
+ retrieved_at: str | None = None
25
+ endpoint: str | None = None
26
+ sequence_hash: str = field(init=False)
27
+ length: int = field(init=False)
28
+
29
+ def __post_init__(self) -> None:
30
+ normalized = self.sequence.replace(" ", "").replace("\n", "").upper()
31
+ if not normalized:
32
+ raise ValueError(f"Protein sequence for {self.accession!r} is empty")
33
+ object.__setattr__(self, "sequence", normalized)
34
+ object.__setattr__(self, "length", len(normalized))
35
+ object.__setattr__(
36
+ self,
37
+ "sequence_hash",
38
+ hashlib.sha256(normalized.encode("utf-8")).hexdigest(),
39
+ )
40
+
41
+ def to_json(self) -> dict[str, object]:
42
+ payload = asdict(self)
43
+ payload.pop("sequence_hash", None)
44
+ payload.pop("length", None)
45
+ payload["sequence_hash"] = self.sequence_hash
46
+ payload["length"] = self.length
47
+ return payload
48
+
49
+ @classmethod
50
+ def from_json(cls, payload: dict[str, Any]) -> ProteinSequence:
51
+ retrieved_at = payload.get("retrieved_at")
52
+ endpoint = payload.get("endpoint")
53
+ return cls(
54
+ accession=str(payload["accession"]),
55
+ sequence=str(payload["sequence"]),
56
+ source=str(payload["source"]),
57
+ header=str(payload.get("header") or ""),
58
+ retrieved_at=str(retrieved_at) if retrieved_at else None,
59
+ endpoint=str(endpoint) if endpoint else None,
60
+ )
61
+
62
+
63
+ class SequenceFetcher(Protocol):
64
+ def fetch_fasta(self, accession: str) -> ProteinSequence: ...
65
+
66
+
67
+ class SequenceCache:
68
+ def __init__(self, path: str | Path) -> None:
69
+ self.path = Path(path)
70
+
71
+ def get(self, accession: str) -> ProteinSequence | None:
72
+ accession = accession.strip()
73
+ for record in self._read_all().values():
74
+ if record.accession == accession:
75
+ return record
76
+ return None
77
+
78
+ def put(self, record: ProteinSequence) -> None:
79
+ records = self._read_all()
80
+ records[record.accession] = record
81
+ self.write_all(records)
82
+
83
+ def write_all(self, records: dict[str, ProteinSequence]) -> None:
84
+ self.path.parent.mkdir(parents=True, exist_ok=True)
85
+ with self.path.open("w", encoding="utf-8") as handle:
86
+ for item in records.values():
87
+ handle.write(json.dumps(item.to_json(), sort_keys=True) + "\n")
88
+
89
+ def _read_all(self) -> dict[str, ProteinSequence]:
90
+ if not self.path.exists():
91
+ return {}
92
+ records: dict[str, ProteinSequence] = {}
93
+ with self.path.open("r", encoding="utf-8") as handle:
94
+ for line in handle:
95
+ stripped = line.strip()
96
+ if not stripped:
97
+ continue
98
+ record = ProteinSequence.from_json(json.loads(stripped))
99
+ records[record.accession] = record
100
+ return records
101
+
102
+
103
+ class UniProtClient:
104
+ def __init__(
105
+ self,
106
+ *,
107
+ base_url: str = UNIPROT_FASTA_BASE_URL,
108
+ session: requests.Session | None = None,
109
+ timeout: float = 30.0,
110
+ ) -> None:
111
+ self.base_url = base_url.rstrip("/")
112
+ self.session = session or requests.Session()
113
+ self.timeout = timeout
114
+
115
+ def fetch_fasta(self, accession: str) -> ProteinSequence:
116
+ accession = accession.strip()
117
+ endpoint = f"{self.base_url}/{accession}.fasta"
118
+ response = self.session.get(
119
+ endpoint,
120
+ headers={"Accept": "text/x-fasta"},
121
+ timeout=self.timeout,
122
+ )
123
+ response.raise_for_status()
124
+ records = _records_from_fasta_text(response.text, source="uniprot", endpoint=endpoint)
125
+ if not records:
126
+ raise ValueError(f"UniProt returned no FASTA record for {accession}")
127
+ record = records[0]
128
+ return ProteinSequence(
129
+ accession=accession,
130
+ sequence=record.sequence,
131
+ source="uniprot",
132
+ header=record.header,
133
+ retrieved_at=datetime.now(UTC).isoformat(),
134
+ endpoint=endpoint,
135
+ )
136
+
137
+
138
+ def read_fasta_records(path: str | Path) -> list[ProteinSequence]:
139
+ text = Path(path).read_text(encoding="utf-8")
140
+ return _records_from_fasta_text(text, source="fasta", endpoint=None)
141
+
142
+
143
+ def resolve_accessions(
144
+ accessions: list[str],
145
+ *,
146
+ cache_path: str | Path,
147
+ client: SequenceFetcher | None = None,
148
+ allow_network: bool = True,
149
+ ) -> list[ProteinSequence]:
150
+ cache = SequenceCache(cache_path)
151
+ fetcher = client or UniProtClient()
152
+ records: list[ProteinSequence] = []
153
+ for accession in accessions:
154
+ normalized = accession.strip()
155
+ if not normalized:
156
+ continue
157
+ cached = cache.get(normalized)
158
+ if cached is not None:
159
+ records.append(cached)
160
+ continue
161
+ if not allow_network:
162
+ raise ValueError(f"Sequence for {normalized} is not cached and network is disabled")
163
+ fetched = fetcher.fetch_fasta(normalized)
164
+ cache.put(fetched)
165
+ records.append(fetched)
166
+ return records
167
+
168
+
169
+ def sequence_records_to_frame(records: list[ProteinSequence]) -> pd.DataFrame:
170
+ return pd.DataFrame(
171
+ [
172
+ {
173
+ "UniprotEntry": record.accession,
174
+ "sequence": record.sequence,
175
+ "source": record.source,
176
+ "header": record.header,
177
+ "retrieved_at": record.retrieved_at,
178
+ "endpoint": record.endpoint,
179
+ "length": record.length,
180
+ "sequence_hash": record.sequence_hash,
181
+ }
182
+ for record in records
183
+ ]
184
+ )
185
+
186
+
187
+ def _records_from_fasta_text(
188
+ text: str,
189
+ *,
190
+ source: str,
191
+ endpoint: str | None,
192
+ ) -> list[ProteinSequence]:
193
+ from io import StringIO
194
+
195
+ records: list[ProteinSequence] = []
196
+ for seq_record in SeqIO.parse(StringIO(text), "fasta"):
197
+ header = f">{seq_record.description}"
198
+ accession = _accession_from_header(seq_record.id)
199
+ records.append(
200
+ ProteinSequence(
201
+ accession=accession,
202
+ sequence=str(seq_record.seq),
203
+ source=source,
204
+ header=header,
205
+ endpoint=endpoint,
206
+ )
207
+ )
208
+ if not records:
209
+ raise ValueError("No FASTA records found")
210
+ return records
211
+
212
+
213
+ def _accession_from_header(identifier: str) -> str:
214
+ parts = identifier.split("|")
215
+ if len(parts) >= 2 and parts[0] in {"sp", "tr"}:
216
+ return parts[1]
217
+ match = re.match(r"([A-Za-z0-9_.-]+)", identifier)
218
+ if not match:
219
+ raise ValueError(f"Cannot parse FASTA accession from header {identifier!r}")
220
+ return match.group(1)
@@ -0,0 +1,218 @@
1
+ """Per-tool feature decomposition (the ``tool-features`` CLI surface).
2
+
3
+ Each PhaSePred feature is produced by an upstream tool (or a native/table
4
+ computation). This module computes them *individually* so a caller can
5
+ decompose a protein's PS-related profile tool-by-tool, matching the web
6
+ ``tools.<x>`` payloads:
7
+
8
+ * summary value per tool (the ``single``/``NLLR``/``FCR`` column),
9
+ * optional per-residue arrays (``--residue-json``), for the tools whose local
10
+ contracts expose them.
11
+
12
+ Tool name vocabulary is aligned with the web API (PLAN §6-S7): ``espritz,
13
+ seg, pscore, plaac, catgranule, deepcoil, deepphase, phos, hydropathy,
14
+ charge``. A single tool failing degrades to ``NaN`` for that tool only — the
15
+ whole run never aborts (same degrade semantics as ``predict``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import cast
22
+
23
+ import pandas as pd
24
+
25
+ from phasepred import tools
26
+ from phasepred.features import CHARGED_RESIDUES, compute_native_features
27
+
28
+ # Tool vocabulary (web API order).
29
+ TOOL_NAMES = [
30
+ "espritz",
31
+ "seg",
32
+ "pscore",
33
+ "plaac",
34
+ "catgranule",
35
+ "deepcoil",
36
+ "deepphase",
37
+ "phos",
38
+ "hydropathy",
39
+ "charge",
40
+ ]
41
+
42
+ # Tools whose local contract exposes per-residue arrays for --residue-json.
43
+ _RESIDUE_TOOLS = {"espritz", "seg", "catgranule", "deepcoil", "charge"}
44
+
45
+
46
+ def _catgranule(records: list[dict[str, str]]) -> tuple[dict[str, float], dict[str, dict]]:
47
+ from catgranule import score_sequence
48
+
49
+ singles: dict[str, float] = {}
50
+ residues: dict[str, dict] = {}
51
+ for r in records:
52
+ seq = str(r["sequence"]).upper()
53
+ try:
54
+ scored = score_sequence(seq)
55
+ singles[r["accession"]] = float(scored["single"])
56
+ residues[r["accession"]] = {"residue": [float(x) for x in scored["residue"]]}
57
+ except Exception:
58
+ singles[r["accession"]] = float("nan")
59
+ residues[r["accession"]] = {"residue": []}
60
+ return singles, residues
61
+
62
+
63
+ def _charge_residues(records: list[dict[str, str]]) -> dict[str, dict]:
64
+ out: dict[str, dict] = {}
65
+ for r in records:
66
+ seq = str(r["sequence"]).upper()
67
+ label = [1.0 if c in CHARGED_RESIDUES else 0.0 for c in seq]
68
+ pos_pos = [i for i, c in enumerate(seq) if c in {"K", "R"}]
69
+ pos_neg = [i for i, c in enumerate(seq) if c in {"D", "E"}]
70
+ out[r["accession"]] = {"label": label, "POS_pos": pos_pos, "POS_neg": pos_neg}
71
+ return out
72
+
73
+
74
+ def compute_tool_features(
75
+ records: list[dict[str, str]],
76
+ tool_names: list[str],
77
+ *,
78
+ residue: bool = False,
79
+ ) -> tuple[pd.DataFrame, dict[str, dict[str, dict]]]:
80
+ """Compute per-tool summary values (and optional residue arrays).
81
+
82
+ Returns ``(summary_df, residue_map)``. ``summary_df`` has ``UniprotEntry``
83
+ plus one column per requested tool (NaN on degrade). ``residue_map`` maps
84
+ ``accession -> tool -> field -> list`` (empty when ``residue`` is False).
85
+ """
86
+ requested = [t for t in tool_names if t in TOOL_NAMES]
87
+ accs = [r["accession"] for r in records]
88
+ summary: dict[str, list[object]] = {t: [float("nan")] * len(accs) for t in requested}
89
+ residue_map: dict[str, dict[str, dict]] = {a: {} for a in accs}
90
+
91
+ # Native (hydropathy, charge) — pure Python, no tools.
92
+ native: dict[str, dict[str, float]] = {a: {} for a in accs}
93
+ for r in records:
94
+ try:
95
+ native[r["accession"]] = compute_native_features(str(r["sequence"]).upper())
96
+ except Exception:
97
+ native[r["accession"]] = {}
98
+
99
+ if "hydropathy" in requested:
100
+ for i, r in enumerate(records):
101
+ summary["hydropathy"][i] = native[r["accession"]].get("Hydropathy", float("nan"))
102
+ if "charge" in requested:
103
+ for i, r in enumerate(records):
104
+ summary["charge"][i] = native[r["accession"]].get("FCR", float("nan"))
105
+ if residue:
106
+ for a, ch_payload in _charge_residues(records).items():
107
+ residue_map[a]["charge"] = ch_payload
108
+
109
+ # catgranule (package) — pure Python.
110
+ if "catgranule" in requested:
111
+ singles, residues = _catgranule(records)
112
+ for i, r in enumerate(records):
113
+ summary["catgranule"][i] = singles[r["accession"]]
114
+ if residue:
115
+ for a, cg_payload in residues.items():
116
+ residue_map[a]["catgranule"] = cg_payload
117
+
118
+ # ESpritz (IDR) — perl tool; residue arrays derivable.
119
+ if "espritz" in requested:
120
+ esp_resid: dict[str, dict[str, object]] = (
121
+ tools.espritz_residues(records) if residue else {}
122
+ )
123
+ if not residue:
124
+ for i, acc in enumerate(accs):
125
+ summary["espritz"][i] = tools.run_espritz(records).get(acc, float("nan"))
126
+ else:
127
+ for i, r in enumerate(records):
128
+ esp_payload = esp_resid.get(r["accession"])
129
+ if esp_payload is None:
130
+ summary["espritz"][i] = float("nan")
131
+ continue
132
+ esp_label = cast(list[str], esp_payload["label"])
133
+ summary["espritz"][i] = (
134
+ sum(1 for s in esp_label if s == "D") / len(esp_label)
135
+ if esp_label
136
+ else float("nan")
137
+ )
138
+ residue_map[r["accession"]]["espritz"] = {
139
+ "label": esp_label,
140
+ "residue": cast(list[float], esp_payload["residue"]),
141
+ }
142
+
143
+ # SEG (LCR) — native binary; residue mask derivable.
144
+ if "seg" in requested:
145
+ seg_resid: dict[str, dict[str, object]] = tools.seg_residues(records) if residue else {}
146
+ if not residue:
147
+ for i, acc in enumerate(accs):
148
+ summary["seg"][i] = tools.run_seg(records).get(acc, float("nan"))
149
+ else:
150
+ for i, r in enumerate(records):
151
+ seg_payload = seg_resid.get(r["accession"])
152
+ if seg_payload is None:
153
+ summary["seg"][i] = float("nan")
154
+ continue
155
+ seg_label = cast(list[float], seg_payload["label"])
156
+ summary["seg"][i] = sum(seg_label) / len(seg_label) if seg_label else float("nan")
157
+ residue_map[r["accession"]]["seg"] = {"label": seg_label}
158
+
159
+ # DeepCoil (v2022 binary) — external env; residue arrays derivable.
160
+ if "deepcoil" in requested:
161
+ dc_resid: dict[str, dict[str, object]] = (
162
+ tools.deepcoil_residues(records) if residue else {}
163
+ )
164
+ if not residue:
165
+ for i, acc in enumerate(accs):
166
+ summary["deepcoil"][i] = tools.run_deepcoil(records).get(acc, float("nan"))
167
+ else:
168
+ for i, r in enumerate(records):
169
+ dc_payload = dc_resid.get(r["accession"])
170
+ if dc_payload is None:
171
+ summary["deepcoil"][i] = float("nan")
172
+ continue
173
+ raw_cc = cast(list[float], dc_payload["coiled-coil"])
174
+ summary["deepcoil"][i] = (
175
+ float(1.0 if raw_cc and max(raw_cc) >= tools.DEEPCOIL_THRESHOLD else 0.0)
176
+ if raw_cc
177
+ else float("nan")
178
+ )
179
+ residue_map[r["accession"]]["deepcoil"] = {
180
+ "coiled-coil": raw_cc,
181
+ "sharpen": cast(list[float], dc_payload["sharpen"]),
182
+ }
183
+
184
+ # PScore / PLAAC — single only (residue not exposed by local contract).
185
+ if "pscore" in requested:
186
+ vals = tools.run_pscore(records)
187
+ for i, r in enumerate(records):
188
+ summary["pscore"][i] = vals.get(r["accession"], float("nan"))
189
+ if "plaac" in requested:
190
+ vals = tools.run_plaac(records)
191
+ for i, r in enumerate(records):
192
+ summary["plaac"][i] = vals.get(r["accession"], float("nan"))
193
+
194
+ # Lookups (DeepPhase, PhosphoSitePlus).
195
+ if "deepphase" in requested:
196
+ vals = tools.lookup_deepphase(accs)
197
+ for i, acc in enumerate(accs):
198
+ summary["deepphase"][i] = vals.get(acc, float("nan"))
199
+ if "phos" in requested:
200
+ vals = tools.compute_phos_freq(records)
201
+ for i, r in enumerate(records):
202
+ summary["phos"][i] = vals.get(r["accession"], float("nan"))
203
+
204
+ frame = pd.DataFrame({"UniprotEntry": accs})
205
+ for t in requested:
206
+ frame[t] = summary[t]
207
+ return frame, residue_map
208
+
209
+
210
+ def write_residue_json(residue_map: dict[str, dict[str, dict]], out_dir: Path) -> None:
211
+ """Write one JSON file per accession: ``{tool: {field: [...]}}``."""
212
+ import json
213
+
214
+ out_dir.mkdir(parents=True, exist_ok=True)
215
+ for acc, tools_map in residue_map.items():
216
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in acc)
217
+ path = out_dir / f"{safe}.json"
218
+ path.write_text(json.dumps(tools_map, indent=2), encoding="utf-8")