Samad14 commited on
Commit
f2d99d8
·
verified ·
1 Parent(s): 1189d33

Upload folder using huggingface_hub

Browse files
Dockerfile CHANGED
@@ -7,6 +7,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
7
  libxml2 libxslt1.1 samtools && \
8
  rm -rf /var/lib/apt/lists/*
9
 
 
 
 
 
 
 
 
 
 
 
10
  # fpocket (pocket detection) — not packaged in Debian repos; build from source
11
  # NOTE: tags carry no "v" prefix (4.2.3, not v4.2.3) — a wrong prefix kills the build
12
  RUN git clone --depth 1 --branch 4.2.3 https://github.com/Discngine/fpocket /tmp/fpocket && \
 
7
  libxml2 libxslt1.1 samtools && \
8
  rm -rf /var/lib/apt/lists/*
9
 
10
+ # Gnina (CNN-based rescoring) — pre-built binary; --no_gpu for CPU-only environments
11
+ RUN wget -q "https://github.com/gnina/gnina/releases/download/v1.3.2/gnina" -O /usr/local/bin/gnina && \
12
+ chmod +x /usr/local/bin/gnina || echo "gnina download skipped (optional)"
13
+
14
+ # MAFFT (local MSA) — portable Linux package, faster and more reliable than remote EBI endpoints
15
+ RUN wget -q "https://mafft.cbrc.jp/alignment/software/mafft-7.526-linux.tgz" -O /tmp/mafft.tgz && \
16
+ tar xzf /tmp/mafft.tgz -C /opt && \
17
+ ln -sf /opt/mafft-linux64/mafft.bat /usr/local/bin/mafft && \
18
+ rm /tmp/mafft.tgz || echo "mafft download skipped (optional)"
19
+
20
  # fpocket (pocket detection) — not packaged in Debian repos; build from source
21
  # NOTE: tags carry no "v" prefix (4.2.3, not v4.2.3) — a wrong prefix kills the build
22
  RUN git clone --depth 1 --branch 4.2.3 https://github.com/Discngine/fpocket /tmp/fpocket && \
Dockerfile.worker CHANGED
@@ -1,13 +1,38 @@
1
- FROM python:3.11-slim
2
 
3
  RUN apt-get update && apt-get install -y --no-install-recommends \
4
- build-essential gcc wget ca-certificates openbabel libgomp1 samtools && \
 
 
5
  rm -rf /var/lib/apt/lists/*
6
 
7
  # AutoDock Vina
8
  RUN wget -q "https://github.com/ccsb-scripps/AutoDock-Vina/releases/download/v1.2.7/vina_1.2.7_linux_x86_64" -O /usr/local/bin/vina && \
9
  chmod +x /usr/local/bin/vina
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  # minimap2 (for sequencing)
12
  RUN wget -q https://github.com/lh3/minimap2/releases/download/v2.28/minimap2-2.28_x64-linux.tar.bz2 -O /tmp/minimap2.tar.bz2 && \
13
  tar xjf /tmp/minimap2.tar.bz2 -C /tmp --no-same-owner && \
 
1
+ FROM python:3.11-slim-bookworm
2
 
3
  RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ build-essential gcc g++ wget ca-certificates git libnetcdf-dev \
5
+ openbabel libgl1 libgomp1 libopenblas-dev \
6
+ libxml2 libxslt1.1 samtools && \
7
  rm -rf /var/lib/apt/lists/*
8
 
9
  # AutoDock Vina
10
  RUN wget -q "https://github.com/ccsb-scripps/AutoDock-Vina/releases/download/v1.2.7/vina_1.2.7_linux_x86_64" -O /usr/local/bin/vina && \
11
  chmod +x /usr/local/bin/vina
12
 
13
+ # Gnina (CNN-based rescoring) — CPU fallback via --no_gpu
14
+ RUN wget -q "https://github.com/gnina/gnina/releases/download/v1.3.2/gnina" -O /usr/local/bin/gnina && \
15
+ chmod +x /usr/local/bin/gnina || echo "gnina download skipped (optional)"
16
+
17
+ # MAFFT (local MSA)
18
+ RUN wget -q "https://mafft.cbrc.jp/alignment/software/mafft-7.526-linux.tgz" -O /tmp/mafft.tgz && \
19
+ tar xzf /tmp/mafft.tgz -C /opt && \
20
+ ln -sf /opt/mafft-linux64/mafft.bat /usr/local/bin/mafft && \
21
+ rm /tmp/mafft.tgz || echo "mafft download skipped (optional)"
22
+
23
+ # fpocket (pocket detection) — build from source
24
+ RUN git clone --depth 1 --branch 4.2.3 https://github.com/Discngine/fpocket /tmp/fpocket && \
25
+ make -C /tmp/fpocket && make -C /tmp/fpocket install && \
26
+ rm -rf /tmp/fpocket
27
+
28
+ # PhyML (phylogenetics)
29
+ RUN wget -qO /tmp/phyml.tar.bz2 \
30
+ https://anaconda.org/bioconda/phyml/3.3.20220408/download/linux-64/phyml-3.3.20220408-h9bc3f66_3.tar.bz2 && \
31
+ tar xjf /tmp/phyml.tar.bz2 -C /tmp --no-same-owner && \
32
+ cp /tmp/bin/phyml /usr/local/bin/phyml && \
33
+ chmod +x /usr/local/bin/phyml && \
34
+ rm -rf /tmp/phyml.tar.bz2 /tmp/bin
35
+
36
  # minimap2 (for sequencing)
37
  RUN wget -q https://github.com/lh3/minimap2/releases/download/v2.28/minimap2-2.28_x64-linux.tar.bz2 -O /tmp/minimap2.tar.bz2 && \
38
  tar xjf /tmp/minimap2.tar.bz2 -C /tmp --no-same-owner && \
app/routers/docking.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  import math
5
  import re
6
  from fastapi import APIRouter, HTTPException, Depends, Request
@@ -13,6 +14,7 @@ from app.services.auth import require_user_id
13
  from app.services.ssrf import validate_url
14
  router = APIRouter(prefix="/api/docking", tags=["Docking"])
15
  _TABLE = "docking_jobs"
 
16
 
17
 
18
  # ---------------------------------------------------------------------------
@@ -230,7 +232,18 @@ def _run_docking_sync(job_id: str, payload: dict):
230
  protein_pdb, vina_result.get("result_sdf", "")
231
  )
232
 
233
- # 8. Ligand essential data (from SMILES, RDKit)
 
 
 
 
 
 
 
 
 
 
 
234
  ligand_properties = _ligand_properties(smiles)
235
 
236
  result_obj = {
@@ -256,6 +269,7 @@ def _run_docking_sync(job_id: str, payload: dict):
256
  "grid_source": grid_source,
257
  "interactions": interactions,
258
  "pose_interactions": pose_interactions,
 
259
  "ligand_pdb": vina_result.get("ligand_pdb", ""),
260
  "result_sdf": vina_result.get("result_sdf", ""),
261
  "receptor_pdb": protein_pdb,
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import logging
5
  import math
6
  import re
7
  from fastapi import APIRouter, HTTPException, Depends, Request
 
14
  from app.services.ssrf import validate_url
15
  router = APIRouter(prefix="/api/docking", tags=["Docking"])
16
  _TABLE = "docking_jobs"
17
+ logger = logging.getLogger(__name__)
18
 
19
 
20
  # ---------------------------------------------------------------------------
 
232
  protein_pdb, vina_result.get("result_sdf", "")
233
  )
234
 
235
+ # 8. CNN rescoring with Gnina (optional, graceful fallback)
236
+ cnn_rescoring = None
237
+ try:
238
+ from app.tools.gnina_rescore import rescore_with_gnina
239
+ cnn_rescoring = rescore_with_gnina(
240
+ receptor_pdb=protein_pdb,
241
+ vina_output_pdbqt=vina_result.get("result_sdf", ""),
242
+ )
243
+ except Exception as e:
244
+ logger.info("Gnina rescoring skipped: %s", e)
245
+
246
+ # 9. Ligand essential data (from SMILES, RDKit)
247
  ligand_properties = _ligand_properties(smiles)
248
 
249
  result_obj = {
 
269
  "grid_source": grid_source,
270
  "interactions": interactions,
271
  "pose_interactions": pose_interactions,
272
+ "cnn_rescoring": cnn_rescoring,
273
  "ligand_pdb": vina_result.get("ligand_pdb", ""),
274
  "result_sdf": vina_result.get("result_sdf", ""),
275
  "receptor_pdb": protein_pdb,
app/routers/interactions.py CHANGED
@@ -7,10 +7,17 @@ router = APIRouter(prefix="/api/interactions", tags=["interactions"])
7
 
8
  STRING_NET = "https://string-db.org/api/json/interaction_partners"
9
 
 
 
 
 
 
 
10
  def _sanitize_for_url(s: str) -> str:
11
  """Strip control characters that break URL construction."""
12
  return re.sub(r'[\x00-\x1f\x7f-\x9f]', '', s)
13
 
 
14
  class Interaction(BaseModel):
15
  partner_gene: str
16
  partner_protein: str
@@ -22,21 +29,38 @@ class Interaction(BaseModel):
22
  escore: float
23
  dscore: float
24
  tscore: float
 
 
 
 
25
 
26
  @router.get("/{gene_name}")
27
  async def get_interactions(
28
  gene_name: str,
29
  species: int = Query(default=9606, description="NCBI taxon ID; 9606=human"),
30
  limit: int = Query(default=15, ge=1, le=50),
 
 
 
 
31
  ):
32
  gene_name = _sanitize_for_url(gene_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  async with httpx.AsyncClient(timeout=20) as client:
34
- r = await client.get(STRING_NET, params={
35
- "identifiers": gene_name,
36
- "species": species,
37
- "limit": limit,
38
- "caller_identity": "bio-nexus-platform",
39
- })
40
  if r.status_code != 200:
41
  raise HTTPException(502, f"STRING-DB returned {r.status_code}")
42
  data = r.json()
@@ -44,20 +68,39 @@ async def get_interactions(
44
  if not data:
45
  raise HTTPException(404, f"No interactions found for {gene_name}")
46
 
47
- interactions = [
48
- Interaction(
49
- partner_gene = item.get("preferredName_B", ""),
50
- partner_protein = item.get("stringId_B", ""),
51
- combined_score = item.get("score", 0),
52
- nscore = item.get("nscore", 0),
53
- fscore = item.get("fscore", 0),
54
- pscore = item.get("pscore", 0),
55
- ascore = item.get("ascore", 0),
56
- escore = item.get("escore", 0),
57
- dscore = item.get("dscore", 0),
58
- tscore = item.get("tscore", 0),
59
- )
60
- for item in data
61
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  interactions.sort(key=lambda x: x.combined_score, reverse=True)
63
- return {"gene": gene_name, "species": species, "interactions": interactions}
 
 
 
 
 
 
7
 
8
  STRING_NET = "https://string-db.org/api/json/interaction_partners"
9
 
10
+ # Network types supported by STRING-DB
11
+ # "functional" = all evidence channels (default, same as combined)
12
+ # "physical" = only direct physical binding (experimental + database)
13
+ NETWORK_TYPES = {"functional", "physical"}
14
+
15
+
16
  def _sanitize_for_url(s: str) -> str:
17
  """Strip control characters that break URL construction."""
18
  return re.sub(r'[\x00-\x1f\x7f-\x9f]', '', s)
19
 
20
+
21
  class Interaction(BaseModel):
22
  partner_gene: str
23
  partner_protein: str
 
29
  escore: float
30
  dscore: float
31
  tscore: float
32
+ # Network-type specific scores
33
+ physical_score: float | None = None
34
+ functional_score: float | None = None
35
+
36
 
37
  @router.get("/{gene_name}")
38
  async def get_interactions(
39
  gene_name: str,
40
  species: int = Query(default=9606, description="NCBI taxon ID; 9606=human"),
41
  limit: int = Query(default=15, ge=1, le=50),
42
+ network_type: str = Query(
43
+ default="functional",
44
+ description="'functional' (all evidence) or 'physical' (direct binding only)",
45
+ ),
46
  ):
47
  gene_name = _sanitize_for_url(gene_name)
48
+ network_type = network_type.lower()
49
+ if network_type not in NETWORK_TYPES:
50
+ raise HTTPException(400, f"Invalid network_type '{network_type}'. Use 'functional' or 'physical'.")
51
+
52
+ params = {
53
+ "identifiers": gene_name,
54
+ "species": species,
55
+ "limit": limit,
56
+ "caller_identity": "bio-nexus-platform",
57
+ }
58
+ # STRING API supports network_type parameter
59
+ if network_type == "physical":
60
+ params["network_type"] = "physical"
61
+
62
  async with httpx.AsyncClient(timeout=20) as client:
63
+ r = await client.get(STRING_NET, params=params)
 
 
 
 
 
64
  if r.status_code != 200:
65
  raise HTTPException(502, f"STRING-DB returned {r.status_code}")
66
  data = r.json()
 
68
  if not data:
69
  raise HTTPException(404, f"No interactions found for {gene_name}")
70
 
71
+ interactions = []
72
+ for item in data:
73
+ # Compute network-type specific confidence scores
74
+ escore = item.get("escore", 0)
75
+ dscore = item.get("dscore", 0)
76
+ pscore_combined = item.get("pscore", 0)
77
+ ascore = item.get("ascore", 0)
78
+ tscore = item.get("tscore", 0)
79
+
80
+ # Physical: experimental + database evidence only
81
+ physical_score = round(min(1.0, (escore + dscore) / 2), 4) if (escore or dscore) else None
82
+ # Functional: all channels weighted
83
+ functional_score = round(item.get("score", 0), 4)
84
+
85
+ interactions.append(Interaction(
86
+ partner_gene=item.get("preferredName_B", ""),
87
+ partner_protein=item.get("stringId_B", ""),
88
+ combined_score=item.get("score", 0),
89
+ nscore=item.get("nscore", 0),
90
+ fscore=item.get("fscore", 0),
91
+ pscore=item.get("pscore", 0),
92
+ ascore=item.get("ascore", 0),
93
+ escore=item.get("escore", 0),
94
+ dscore=item.get("dscore", 0),
95
+ tscore=item.get("tscore", 0),
96
+ physical_score=physical_score,
97
+ functional_score=functional_score,
98
+ ))
99
+
100
  interactions.sort(key=lambda x: x.combined_score, reverse=True)
101
+ return {
102
+ "gene": gene_name,
103
+ "species": species,
104
+ "network_type": network_type,
105
+ "interactions": interactions,
106
+ }
app/routers/pathways.py CHANGED
@@ -184,3 +184,16 @@ async def pathway_enrichment(req: EnrichmentRequest):
184
  if result is None:
185
  raise HTTPException(status_code=502, detail="Enrichment analysis failed")
186
  return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  if result is None:
185
  raise HTTPException(status_code=502, detail="Enrichment analysis failed")
186
  return result
187
+
188
+
189
+ class CrossValidationRequest(BaseModel):
190
+ identifiers: list[str] = Field(..., min_length=1, description="List of gene or protein identifiers")
191
+ organism: str = Field(default="hsapiens", description="g:Profiler organism code")
192
+
193
+
194
+ @router.post("/enrichment/cross-validate")
195
+ async def pathway_enrichment_cross_validate(req: CrossValidationRequest):
196
+ """Run both Reactome and g:Profiler enrichment, returning concordant pathways."""
197
+ from app.services.pathway_enrichment import run_cross_validated_enrichment
198
+ result = await run_cross_validated_enrichment(req.identifiers, req.organism)
199
+ return result
app/routers/pipeline_v2.py CHANGED
@@ -880,22 +880,35 @@ async def _run_msa(query_sequence: str, blast_hits: list, alignment_mode: str =
880
  seq_type = detect_sequence_type(query_sequence) or "protein"
881
  stype = "protein" if seq_type == "protein" else "dna"
882
  try:
883
- result = await run_ebi_msa_best_effort(
884
- sequence=fasta_str,
885
- stype=stype,
886
- email=email,
887
  )
888
- method = result["method"]
889
- aln_fasta = result["aln_fasta"]
890
- phylotree = result["phylotree"]
891
- except Exception as e:
892
- logger.warning("EBI MSA unavailable (%s) — using in-process fallback", e)
893
- from app.tools.msa_fallback import progressive_msa
894
- fallback = await asyncio.get_running_loop().run_in_executor(
895
- None, progressive_msa, sequences, stype
896
- )
897
- aln_fasta, phylotree = fallback
898
- method = "in-process fallback"
 
 
 
 
 
 
 
 
 
 
 
 
 
899
 
900
  payload = {
901
  "aln_fasta": aln_fasta,
 
880
  seq_type = detect_sequence_type(query_sequence) or "protein"
881
  stype = "protein" if seq_type == "protein" else "dna"
882
  try:
883
+ # Try local MAFFT first (fast, no network dependency)
884
+ from app.tools.mafft_local import run_local_mafft
885
+ local_result = await asyncio.get_running_loop().run_in_executor(
886
+ None, run_local_mafft, fasta_str, "auto", 1, 300,
887
  )
888
+ if local_result and local_result.get("aln_fasta"):
889
+ method = "mafft-local"
890
+ aln_fasta = local_result["aln_fasta"]
891
+ phylotree = ""
892
+ else:
893
+ raise ValueError("local MAFFT unavailable or returned empty")
894
+ except Exception:
895
+ try:
896
+ result = await run_ebi_msa_best_effort(
897
+ sequence=fasta_str,
898
+ stype=stype,
899
+ email=email,
900
+ )
901
+ method = result["method"]
902
+ aln_fasta = result["aln_fasta"]
903
+ phylotree = result["phylotree"]
904
+ except Exception as e:
905
+ logger.warning("EBI MSA unavailable (%s) — using in-process fallback", e)
906
+ from app.tools.msa_fallback import progressive_msa
907
+ fallback = await asyncio.get_running_loop().run_in_executor(
908
+ None, progressive_msa, sequences, stype
909
+ )
910
+ aln_fasta, phylotree = fallback
911
+ method = "in-process fallback"
912
 
913
  payload = {
914
  "aln_fasta": aln_fasta,
app/services/pathway_enrichment.py CHANGED
@@ -8,6 +8,7 @@ from app.services.cache import cache_get, cache_set
8
  logger = logging.getLogger(__name__)
9
 
10
  ANALYSIS_BASE = "https://reactome.org/AnalysisService"
 
11
 
12
 
13
  async def run_enrichment(identifiers: list[str]) -> dict | None:
@@ -84,3 +85,141 @@ async def run_enrichment(identifiers: list[str]) -> dict | None:
84
  except Exception as e:
85
  logger.warning(f"Pathway enrichment failed: {e}")
86
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  logger = logging.getLogger(__name__)
9
 
10
  ANALYSIS_BASE = "https://reactome.org/AnalysisService"
11
+ GPROFILER_BASE = "https://biit.cs.ut.ee/gprofiler/api/gost/profile/"
12
 
13
 
14
  async def run_enrichment(identifiers: list[str]) -> dict | None:
 
85
  except Exception as e:
86
  logger.warning(f"Pathway enrichment failed: {e}")
87
  return None
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # g:Profiler cross-validation enrichment
92
+ # ---------------------------------------------------------------------------
93
+
94
+ async def run_gprofiler_enrichment(
95
+ identifiers: list[str],
96
+ organism: str = "hsapiens",
97
+ sources: list[str] | None = None,
98
+ ) -> dict | None:
99
+ """Run enrichment analysis via g:Profiler as cross-validation.
100
+
101
+ g:Profiler queries multiple annotation databases (GO:BP, GO:MF, GO:CC,
102
+ KEGG, Reactome, WikiPathways) and applies proper multiple-testing
103
+ correction. Use this to validate Reactome-only enrichment results.
104
+
105
+ Args:
106
+ identifiers: gene/protein identifiers (symbol, UniProt, Ensembl, etc.)
107
+ organism: g:Profiler organism code (default: hsapiens)
108
+ sources: annotation sources to query. None = all available.
109
+
110
+ Returns:
111
+ {"results": [...], "source": "g:Profiler"} or None on failure.
112
+ """
113
+ cache_key = f"gprofiler:{hashlib.sha256(json.dumps(sorted(identifiers)).encode()).hexdigest()[:16]}"
114
+ cached = cache_get(cache_key)
115
+ if cached is not None:
116
+ try:
117
+ return json.loads(cached)
118
+ except (json.JSONDecodeError, TypeError):
119
+ pass
120
+
121
+ payload = {
122
+ "organism": organism,
123
+ "query": identifiers,
124
+ "significance_threshold_method": "g_SCS",
125
+ "user_threshold": 0.05,
126
+ "no_evidences": False,
127
+ }
128
+ if sources:
129
+ payload["sources"] = sources
130
+
131
+ try:
132
+ async with httpx.AsyncClient(timeout=30) as client:
133
+ resp = await client.post(
134
+ GPROFILER_BASE,
135
+ json=payload,
136
+ headers={"Content-Type": "application/json"},
137
+ )
138
+ if resp.status_code != 200:
139
+ logger.warning("g:Profiler returned %d", resp.status_code)
140
+ return None
141
+
142
+ data = resp.json()
143
+ results = []
144
+ for item in data.get("result", []):
145
+ if item.get("significant"):
146
+ results.append({
147
+ "source": item.get("source", ""),
148
+ "term_id": item.get("native", ""),
149
+ "term_name": item.get("name", ""),
150
+ "p_value": item.get("p_value", 1.0),
151
+ "fdr": item.get("p_value_intersections", 1.0),
152
+ "intersection_size": item.get("intersection_size", 0),
153
+ "term_size": item.get("term_size", 0),
154
+ "query_size": item.get("query_size", 0),
155
+ "effective_domain_size": item.get("effective_domain_size", 0),
156
+ "source_order": item.get("source_order", 0),
157
+ })
158
+
159
+ results.sort(key=lambda r: r["p_value"])
160
+
161
+ result = {
162
+ "results": results,
163
+ "count": len(results),
164
+ "organism": organism,
165
+ "source": "g:Profiler",
166
+ }
167
+ try:
168
+ cache_set(cache_key, json.dumps(result), ttl=86400)
169
+ except (TypeError, ValueError):
170
+ pass
171
+ return result
172
+ except Exception as e:
173
+ logger.warning("g:Profiler enrichment failed: %s", e)
174
+ return None
175
+
176
+
177
+ async def run_cross_validated_enrichment(
178
+ identifiers: list[str],
179
+ organism: str = "hsapiens",
180
+ ) -> dict:
181
+ """Run both Reactome and g:Profiler enrichment, merging results.
182
+
183
+ Returns:
184
+ {
185
+ "reactome": {...} | null,
186
+ "gprofiler": {...} | null,
187
+ "concordant_terms": [...], # pathways found by both methods
188
+ }
189
+ """
190
+ reactome_result = await run_enrichment(identifiers)
191
+ gprofiler_result = await run_gprofiler_enrichment(identifiers, organism)
192
+
193
+ # Find concordant pathways (present in both Reactome and g:Profiler)
194
+ concordant = []
195
+ if reactome_result and gprofiler_result:
196
+ reactome_names = {
197
+ p["name"].lower() for p in reactome_result.get("pathways", [])
198
+ }
199
+ gprofiler_names = {
200
+ r["term_name"].lower() for r in gprofiler_result.get("results", [])
201
+ }
202
+ shared = reactome_names & gprofiler_names
203
+ if shared:
204
+ # Collect details from both sources
205
+ for name_lower in shared:
206
+ r_pathway = next(
207
+ (p for p in reactome_result.get("pathways", [])
208
+ if p["name"].lower() == name_lower), None
209
+ )
210
+ g_term = next(
211
+ (r for r in gprofiler_result.get("results", [])
212
+ if r["term_name"].lower() == name_lower), None
213
+ )
214
+ concordant.append({
215
+ "name": r_pathway["name"] if r_pathway else name_lower,
216
+ "reactome_fdr": r_pathway.get("entitiesFDR") if r_pathway else None,
217
+ "gprofiler_pvalue": g_term.get("p_value") if g_term else None,
218
+ "source": "both",
219
+ })
220
+
221
+ return {
222
+ "reactome": reactome_result,
223
+ "gprofiler": gprofiler_result,
224
+ "concordant_terms": concordant,
225
+ }
app/tools/castp.py CHANGED
@@ -1,8 +1,21 @@
1
- """CASTp pocket/cavity analysis via Biopython SASA + local detection."""
 
 
 
 
 
 
 
 
 
2
 
3
  import asyncio
4
  import logging
5
  import math
 
 
 
 
6
  from typing import Any
7
 
8
  import httpx
@@ -10,7 +23,12 @@ import httpx
10
  logger = logging.getLogger(__name__)
11
 
12
  CASTPFOLD_BASE = "https://cfold.bme.uic.edu/castpfold"
 
 
13
 
 
 
 
14
 
15
  async def analyze_pockets_pdb_id(pdb_id: str, probe_radius: float = 1.4) -> dict:
16
  pdb_text = await _fetch_pdb(pdb_id)
@@ -28,8 +46,207 @@ async def _fetch_pdb(pdb_id: str) -> str:
28
  return resp.text
29
 
30
 
31
- def _analyze_pockets_sync(pdb_text: str, pdb_id: str, probe_radius: float) -> dict:
32
- """Compute per-residue SASA and detect pockets via clustering (CPU-bound)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  import io
34
  from Bio.PDB import PDBParser, SASA
35
 
@@ -63,26 +280,17 @@ def _analyze_pockets_sync(pdb_text: str, pdb_id: str, probe_radius: float) -> di
63
  })
64
  coords.append((float(ca[0]), float(ca[1]), float(ca[2])))
65
 
66
- pockets = _detect_pockets_fast(residues_sasa, coords, probe_radius)
67
 
68
  return {
69
  "pdb_id": pdb_id,
70
  "probe_radius": probe_radius,
71
  "total_residues": len(residues_sasa),
72
  "pockets": pockets,
73
- "residues": residues_sasa,
74
  }
75
 
76
 
77
- async def _analyze_pockets(pdb_text: str, pdb_id: str, probe_radius: float) -> dict:
78
- import functools
79
- loop = asyncio.get_running_loop()
80
- return await loop.run_in_executor(
81
- None, functools.partial(_analyze_pockets_sync, pdb_text, pdb_id, probe_radius)
82
- )
83
-
84
-
85
- def _detect_pockets_fast(residues: list[dict], coords: list[tuple], probe_radius: float) -> list[dict]:
86
  """Detect pockets using scipy KDTree for O(n log n) neighbor lookups."""
87
  if not residues:
88
  return []
@@ -101,7 +309,6 @@ def _detect_pockets_fast(residues: list[dict], coords: list[tuple], probe_radius
101
  pairs = tree.query_pairs(r=cutoff, output_type='ndarray')
102
 
103
  adj: dict[int, list[int]] = {i: [] for i in range(n)}
104
- orig_idx = {i: exposed[i][0] for i in range(n)}
105
  for a, b in pairs:
106
  adj[a].append(b)
107
  adj[b].append(a)
@@ -151,14 +358,16 @@ def _detect_pockets_fast(residues: list[dict], coords: list[tuple], probe_radius
151
  volume = (4.0 / 3.0) * math.pi * (max_dist + probe_radius) ** 3
152
  avg_sasa = sum(r["sasa"] for r in cluster_residues) / len(cluster_residues)
153
 
 
 
 
 
154
  pockets.append({
155
  "id": idx + 1,
156
  "area_sa": round(avg_sasa * len(cluster_residues), 1),
157
  "volume_sa": round(volume, 1),
158
  "num_residues": len(cluster_residues),
159
- "residues": [
160
- f"{r['chain']}{r['resnum']}{r['residue']}" for r in cluster_residues
161
- ],
162
  "centroid": [round(c, 2) for c in centroid],
163
  "radius": round(max_dist + probe_radius, 2),
164
  })
 
1
+ """Pocket/cavity analysis — uses fpocket (real tool) with Biopython SASA fallback.
2
+
3
+ Priority chain:
4
+ 1. fpocket local binary (installed in Docker) — produces druggability scores,
5
+ volumes, areas, and residue lists per pocket.
6
+ 2. Biopython ShrakeRupley SASA + KDTree clustering — lightweight fallback
7
+ when fpocket is unavailable.
8
+
9
+ Both produce the same output contract so callers don't need to change.
10
+ """
11
 
12
  import asyncio
13
  import logging
14
  import math
15
+ import re
16
+ import subprocess
17
+ import tempfile
18
+ from pathlib import Path
19
  from typing import Any
20
 
21
  import httpx
 
23
  logger = logging.getLogger(__name__)
24
 
25
  CASTPFOLD_BASE = "https://cfold.bme.uic.edu/castpfold"
26
+ FPOCKET_BIN: str = "/usr/local/bin/fpocket"
27
+
28
 
29
+ # ---------------------------------------------------------------------------
30
+ # Public API
31
+ # ---------------------------------------------------------------------------
32
 
33
  async def analyze_pockets_pdb_id(pdb_id: str, probe_radius: float = 1.4) -> dict:
34
  pdb_text = await _fetch_pdb(pdb_id)
 
46
  return resp.text
47
 
48
 
49
+ async def _analyze_pockets(pdb_text: str, pdb_id: str, probe_radius: float) -> dict:
50
+ # 1. Try fpocket (real tool)
51
+ import shutil
52
+ fpocket = FPOCKET_BIN if Path(FPOCKET_BIN).exists() else (shutil.which("fpocket") or "")
53
+ if fpocket and Path(fpocket).exists():
54
+ loop = asyncio.get_running_loop()
55
+ result = await loop.run_in_executor(
56
+ None, _run_fpocket_analysis, fpocket, pdb_text, pdb_id, probe_radius,
57
+ )
58
+ if result["pockets"]:
59
+ return result
60
+ logger.info("fpocket found no pockets for %s, falling back to SASA heuristic", pdb_id)
61
+
62
+ # 2. Fallback: Biopython SASA + KDTree clustering
63
+ loop = asyncio.get_running_loop()
64
+ return await loop.run_in_executor(
65
+ None, _analyze_pockets_sasa_sync, pdb_text, pdb_id, probe_radius,
66
+ )
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # fpocket analysis
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def _run_fpocket_analysis(
74
+ fpocket_bin: str,
75
+ pdb_text: str,
76
+ pdb_id: str,
77
+ probe_radius: float,
78
+ ) -> dict:
79
+ """Run fpocket and parse results into the standard output contract."""
80
+ with tempfile.TemporaryDirectory() as tmpdir:
81
+ in_path = Path(tmpdir) / "input.pdb"
82
+ in_path.write_text(pdb_text)
83
+
84
+ try:
85
+ subprocess.run(
86
+ [fpocket_bin, "-f", str(in_path), "-r", str(probe_radius)],
87
+ capture_output=True, text=True, timeout=60,
88
+ )
89
+ except subprocess.TimeoutExpired:
90
+ logger.warning("fpocket timed out for %s", pdb_id)
91
+ return _empty_result(pdb_id, probe_radius, 0)
92
+ except Exception as e:
93
+ logger.warning("fpocket failed for %s: %s", pdb_id, e)
94
+ return _empty_result(pdb_id, probe_radius, 0)
95
+
96
+ fpocket_out = Path(tmpdir) / "input_out"
97
+ return _parse_fpocket(fpocket_out, pdb_id, probe_radius)
98
+
99
+
100
+ def _parse_fpocket(out_dir: Path, pdb_id: str, probe_radius: float) -> dict:
101
+ """Parse fpocket output into standard pocket list."""
102
+ info_file = out_dir / "info" / "infos.txt"
103
+ if not info_file.exists():
104
+ return _empty_result(pdb_id, probe_radius, 0)
105
+
106
+ pockets: list[dict] = []
107
+ current: dict[str, Any] = {}
108
+
109
+ try:
110
+ text = info_file.read_text()
111
+ for line in text.splitlines():
112
+ line = line.strip()
113
+ if line.startswith("Pocket"):
114
+ if current:
115
+ pockets.append(current)
116
+ m = re.search(r"Pocket\s+(\d+)", line)
117
+ current = {
118
+ "id": int(m.group(1)) if m else len(pockets) + 1,
119
+ "druggability_score": 0.0,
120
+ "volume": 0.0,
121
+ "area_sa": 0.0,
122
+ "score": 0.0,
123
+ "num_residues": 0,
124
+ "centroid": [0.0, 0.0, 0.0],
125
+ "radius": 0.0,
126
+ }
127
+ elif "Druggability Score" in line:
128
+ m = re.search(r":\s*([\d.]+)", line)
129
+ if m:
130
+ current["druggability_score"] = float(m.group(1))
131
+ elif "Volume" in line:
132
+ m = re.search(r":\s*([\d.]+)", line)
133
+ if m:
134
+ current["volume"] = float(m.group(1))
135
+ elif "Area" in line:
136
+ m = re.search(r":\s*([\d.]+)", line)
137
+ if m:
138
+ current["area_sa"] = float(m.group(1))
139
+ elif "Score" in line and "Drug" not in line:
140
+ m = re.search(r":\s*([\d.]+)", line)
141
+ if m:
142
+ current["score"] = float(m.group(1))
143
+ elif "Number of residues" in line:
144
+ m = re.search(r":\s*(\d+)", line)
145
+ if m:
146
+ current["num_residues"] = int(m.group(1))
147
+
148
+ if current:
149
+ pockets.append(current)
150
+ except Exception as e:
151
+ logger.warning("Failed to parse fpocket infos.txt: %s", e)
152
+
153
+ # Parse pocket PDB files for centroid and residue list
154
+ pockets_dir = out_dir / "pockets"
155
+ for pocket in pockets:
156
+ pocket_id = pocket["id"]
157
+ pocket_pdb = pockets_dir / f"pocket{pocket_id}_atm.pdb"
158
+ if pocket_pdb.exists():
159
+ _enrich_pocket_from_pdb(pocket, pocket_pdb)
160
+
161
+ # Compute radii from centroid + farthest residue
162
+ for pocket in pockets:
163
+ if pocket["centroid"] != [0.0, 0.0, 0.0] and pocket.get("residues"):
164
+ # Radius approximated from volume: V = (4/3)πr³ → r = (3V/4π)^(1/3)
165
+ vol = pocket["volume"]
166
+ if vol > 0:
167
+ pocket["radius"] = round((3 * vol / (4 * math.pi)) ** (1/3), 2)
168
+
169
+ total_residues = sum(p["num_residues"] for p in pockets) if pockets else 0
170
+
171
+ return {
172
+ "pdb_id": pdb_id,
173
+ "probe_radius": probe_radius,
174
+ "total_residues": total_residues,
175
+ "pockets": [
176
+ {
177
+ "id": p["id"],
178
+ "area_sa": round(p["area_sa"], 1),
179
+ "volume_sa": round(p["volume"], 1),
180
+ "num_residues": p["num_residues"],
181
+ "residues": p.get("residues", []),
182
+ "centroid": [round(c, 2) for c in p["centroid"]],
183
+ "radius": round(p["radius"], 2),
184
+ }
185
+ for p in pockets
186
+ ],
187
+ }
188
+
189
+
190
+ def _enrich_pocket_from_pdb(pocket: dict, pdb_path: Path) -> None:
191
+ """Extract centroid and residue list from fpocket's pocket PDB file."""
192
+ xs, ys, zs = [], [], []
193
+ residues: list[str] = []
194
+ seen_res: set[str] = set()
195
+
196
+ try:
197
+ for line in pdb_path.read_text().splitlines():
198
+ if not (line.startswith("ATOM") or line.startswith("HETATM")):
199
+ continue
200
+ try:
201
+ x = float(line[30:38])
202
+ y = float(line[38:46])
203
+ z = float(line[46:54])
204
+ xs.append(x)
205
+ ys.append(y)
206
+ zs.append(z)
207
+ except (ValueError, IndexError):
208
+ continue
209
+
210
+ # Extract residue identifier
211
+ try:
212
+ chain = line[21].strip() or "A"
213
+ resname = line[17:20].strip()
214
+ resseq = line[22:26].strip()
215
+ res_key = f"{chain}{resseq}{resname}"
216
+ if res_key not in seen_res:
217
+ seen_res.add(res_key)
218
+ residues.append(res_key)
219
+ except IndexError:
220
+ pass
221
+ except Exception:
222
+ pass
223
+
224
+ if xs:
225
+ pocket["centroid"] = [
226
+ sum(xs) / len(xs),
227
+ sum(ys) / len(ys),
228
+ sum(zs) / len(zs),
229
+ ]
230
+ if residues:
231
+ pocket["residues"] = residues
232
+ pocket["num_residues"] = len(residues)
233
+
234
+
235
+ def _empty_result(pdb_id: str, probe_radius: float, total_residues: int) -> dict:
236
+ return {
237
+ "pdb_id": pdb_id,
238
+ "probe_radius": probe_radius,
239
+ "total_residues": total_residues,
240
+ "pockets": [],
241
+ }
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # SASA fallback (Biopython ShrakeRupley + KDTree clustering)
246
+ # ---------------------------------------------------------------------------
247
+
248
+ def _analyze_pockets_sasa_sync(pdb_text: str, pdb_id: str, probe_radius: float) -> dict:
249
+ """Compute per-residue SASA and detect pockets via clustering."""
250
  import io
251
  from Bio.PDB import PDBParser, SASA
252
 
 
280
  })
281
  coords.append((float(ca[0]), float(ca[1]), float(ca[2])))
282
 
283
+ pockets = _detect_pockets_sasa(residues_sasa, coords, probe_radius)
284
 
285
  return {
286
  "pdb_id": pdb_id,
287
  "probe_radius": probe_radius,
288
  "total_residues": len(residues_sasa),
289
  "pockets": pockets,
 
290
  }
291
 
292
 
293
+ def _detect_pockets_sasa(residues: list[dict], coords: list[tuple], probe_radius: float) -> list[dict]:
 
 
 
 
 
 
 
 
294
  """Detect pockets using scipy KDTree for O(n log n) neighbor lookups."""
295
  if not residues:
296
  return []
 
309
  pairs = tree.query_pairs(r=cutoff, output_type='ndarray')
310
 
311
  adj: dict[int, list[int]] = {i: [] for i in range(n)}
 
312
  for a, b in pairs:
313
  adj[a].append(b)
314
  adj[b].append(a)
 
358
  volume = (4.0 / 3.0) * math.pi * (max_dist + probe_radius) ** 3
359
  avg_sasa = sum(r["sasa"] for r in cluster_residues) / len(cluster_residues)
360
 
361
+ residues_list = [
362
+ f"{r['chain']}{r['resnum']}{r['residue']}" for r in cluster_residues
363
+ ]
364
+
365
  pockets.append({
366
  "id": idx + 1,
367
  "area_sa": round(avg_sasa * len(cluster_residues), 1),
368
  "volume_sa": round(volume, 1),
369
  "num_residues": len(cluster_residues),
370
+ "residues": residues_list,
 
 
371
  "centroid": [round(c, 2) for c in centroid],
372
  "radius": round(max_dist + probe_radius, 2),
373
  })
app/tools/function_predict.py CHANGED
@@ -1,42 +1,51 @@
1
- """Protein function prediction using a simplified GCN-like approach.
 
2
 
3
- This is a lightweight approximation inspired by DeepFRI. For production use,
4
- bake the full DeepFRI weights into the Docker image (see Dockerfile additions).
 
 
 
 
 
 
5
 
6
  Outputs:
7
  - GO term predictions with confidence scores (MF / BP / CC)
8
  - Per-residue importance scores (saliency map)
9
  - Per-residue amino acid composition
10
-
11
- Note: EC number prediction is not implemented in the heuristic model.
12
  """
13
 
14
  from __future__ import annotations
15
 
16
  import json
17
  import logging
 
18
  import urllib.request
19
 
20
  logger = logging.getLogger(__name__)
21
 
 
 
 
 
22
 
23
- def _fetch_pdb_sequence(pdb_id: str) -> str:
24
- """Fetch the amino acid sequence for a PDB entry from RCSB.
25
 
26
- Callers validate pdb_id against ^[A-Za-z0-9]{4}$ (see FunctionPredictRequest).
27
- """
28
- url = f"https://data.rcsb.org/rest/v1/core/polymer_entity/{pdb_id}/1"
 
 
 
 
29
  try:
30
- # pdb_id is 4-char alphanumeric, enforced at the router
31
  data = json.loads(urllib.request.urlopen(url, timeout=15).read()) # nosemgrep
32
  return data.get("entity_poly", {}).get("pdbx_seq_one_letter_code_can", "")
33
  except Exception:
34
  pass
35
 
36
- # Fallback: fetch FASTA
37
  try:
38
- url = f"https://www.rcsb.org/fasta/entry/{pdb_id}"
39
- # pdb_id is 4-char alphanumeric, enforced at the router
40
  text = urllib.request.urlopen(url, timeout=15).read().decode() # nosemgrep
41
  lines = [l for l in text.splitlines() if not l.startswith(">")]
42
  return "".join(lines).replace("\n", "")
@@ -44,23 +53,180 @@ def _fetch_pdb_sequence(pdb_id: str) -> str:
44
  raise RuntimeError(f"Could not fetch sequence for {pdb_id}: {e}")
45
 
46
 
47
- def _predict_from_sequence(sequence: str, pdb_id: str) -> dict:
48
- """Lightweight function prediction based on sequence composition.
 
 
 
 
49
 
50
- This is a heuristic approximation. Replace with proper GCN inference
51
- when DeepFRI weights are baked into the Docker image.
52
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  seq_upper = sequence.upper()
54
  seq_len = len(seq_upper)
55
  aa_comp = {}
56
  for aa in seq_upper:
57
  aa_comp[aa] = aa_comp.get(aa, 0) + 1
58
 
59
- # Predict GO terms based on amino acid composition patterns
60
  predicted_go = []
61
- confidence_base = 0.5
62
 
63
- # Simple composition-based predictions
64
  hydrophobic_fraction = sum(aa_comp.get(a, 0) for a in "AILMFWV") / max(seq_len, 1)
65
  charged_fraction = sum(aa_comp.get(a, 0) for a in "DEKRH") / max(seq_len, 1)
66
 
@@ -70,6 +236,7 @@ def _predict_from_sequence(sequence: str, pdb_id: str) -> dict:
70
  "name": "membrane",
71
  "namespace": "CC",
72
  "confidence": round(min(0.6 + hydrophobic_fraction * 0.3, 0.95), 3),
 
73
  })
74
  if charged_fraction > 0.25:
75
  predicted_go.append({
@@ -77,6 +244,7 @@ def _predict_from_sequence(sequence: str, pdb_id: str) -> dict:
77
  "name": "protein binding",
78
  "namespace": "MF",
79
  "confidence": round(min(0.55 + charged_fraction * 0.2, 0.9), 3),
 
80
  })
81
  if hydrophobic_fraction > 0.4 and charged_fraction > 0.15:
82
  predicted_go.append({
@@ -84,21 +252,25 @@ def _predict_from_sequence(sequence: str, pdb_id: str) -> dict:
84
  "name": "signal transduction",
85
  "namespace": "BP",
86
  "confidence": round(min(0.5 + hydrophobic_fraction * 0.2, 0.85), 3),
 
87
  })
88
 
89
- # Always include a general prediction
90
  predicted_go.append({
91
  "go_id": "GO:0003674",
92
  "name": "molecular_function",
93
  "namespace": "MF",
94
  "confidence": 0.99,
 
95
  })
96
 
97
- # Per-residue importance (saliency approximation)
98
- # Higher importance at charged/polar residues on the surface
 
 
 
 
99
  saliency = []
100
- for i, aa in enumerate(seq_upper):
101
- score = 0.1
102
  if aa in "DEKRH":
103
  score = 0.6
104
  elif aa in "STNQ":
@@ -108,28 +280,68 @@ def _predict_from_sequence(sequence: str, pdb_id: str) -> dict:
108
  else:
109
  score = 0.15
110
  saliency.append(round(score, 3))
 
 
111
 
112
- # Actual per-residue composition (fractions), sorted most abundant first
113
- composition = {
 
 
 
 
 
114
  "aa": "ACDEFGHIKLMNPQRSTVWY",
115
  "fractions": {aa: round(aa_comp.get(aa, 0) / max(seq_len, 1), 4) for aa in "ACDEFGHIKLMNPQRSTVWY"},
116
  }
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  return {
119
  "pdb_id": pdb_id.upper(),
120
  "sequence_length": seq_len,
121
- "go_terms": predicted_go,
122
  "ec_numbers": [],
 
 
 
 
 
123
  "saliency": saliency,
124
  "composition": composition,
125
- "method": "heuristic_composition",
126
- "note": "Predictions based on amino acid composition. EC number prediction is not implemented in this heuristic model; for research-grade predictions use the full DeepFRI model.",
127
  }
128
-
129
-
130
- def predict_function(pdb_id: str) -> dict:
131
- """Main entry point: predict protein function from structure."""
132
- sequence = _fetch_pdb_sequence(pdb_id)
133
- if not sequence:
134
- raise RuntimeError(f"No sequence available for PDB {pdb_id}")
135
- return _predict_from_sequence(sequence, pdb_id)
 
1
+ """Protein function prediction combining InterPro2GO domain mapping with
2
+ heuristic composition-based fallback.
3
 
4
+ Primary method: run InterProScan5 on the sequence, then map each domain hit
5
+ to GO terms via the InterPro API (which embeds InterPro2GO transitive
6
+ annotations). This yields experimentally validated GO terms derived from
7
+ curated domain-to-function mappings.
8
+
9
+ Fallback: when InterProScan5 is unreachable or the sequence is too short,
10
+ fall back to amino-acid-composition heuristics (low confidence, kept for
11
+ completeness).
12
 
13
  Outputs:
14
  - GO term predictions with confidence scores (MF / BP / CC)
15
  - Per-residue importance scores (saliency map)
16
  - Per-residue amino acid composition
 
 
17
  """
18
 
19
  from __future__ import annotations
20
 
21
  import json
22
  import logging
23
+ import math
24
  import urllib.request
25
 
26
  logger = logging.getLogger(__name__)
27
 
28
+ _INTERPRO_SEARCH_URL = "https://www.ebi.ac.uk/interpro/service/rest/iprscan5/run"
29
+ _INTERPRO_ENTRY_API = "https://www.ebi.ac.uk/interpro/api/entry/interpro/{accession}/?format=json"
30
+ _RCSB_SEQUENCE_API = "https://data.rcsb.org/rest/v1/core/polymer_entity/{pdb_id}/1"
31
+ _RCSB_FASTA_API = "https://www.rcsb.org/fasta/entry/{pdb_id}"
32
 
 
 
33
 
34
+ # ---------------------------------------------------------------------------
35
+ # Sequence fetching
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def _fetch_pdb_sequence(pdb_id: str) -> str:
39
+ """Fetch the amino acid sequence for a PDB entry from RCSB."""
40
+ url = _RCSB_SEQUENCE_API.format(pdb_id=pdb_id)
41
  try:
 
42
  data = json.loads(urllib.request.urlopen(url, timeout=15).read()) # nosemgrep
43
  return data.get("entity_poly", {}).get("pdbx_seq_one_letter_code_can", "")
44
  except Exception:
45
  pass
46
 
 
47
  try:
48
+ url = _RCSB_FASTA_API.format(pdb_id=pdb_id)
 
49
  text = urllib.request.urlopen(url, timeout=15).read().decode() # nosemgrep
50
  lines = [l for l in text.splitlines() if not l.startswith(">")]
51
  return "".join(lines).replace("\n", "")
 
53
  raise RuntimeError(f"Could not fetch sequence for {pdb_id}: {e}")
54
 
55
 
56
+ # ---------------------------------------------------------------------------
57
+ # InterProScan5 sequence search
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def _run_interproscan(sequence: str, timeout: int = 120) -> list[dict]:
61
+ """Submit a sequence to InterProScan5 REST and poll for results.
62
 
63
+ Returns a list of domain hits: [{"accession", "name", "database",
64
+ "start", "end", "score"}, ...]. Empty list on any failure.
65
  """
66
+ import time
67
+
68
+ body = json.dumps({
69
+ "sequences": [{"sequence": sequence}],
70
+ "type": "PROTEIN",
71
+ })
72
+
73
+ req = urllib.request.Request(
74
+ _INTERPRO_SEARCH_URL,
75
+ data=body.encode(),
76
+ headers={"Content-Type": "application/json"},
77
+ method="POST",
78
+ )
79
+
80
+ try:
81
+ resp = urllib.request.urlopen(req, timeout=30) # nosemgrep
82
+ job_data = json.loads(resp.read())
83
+ job_id = job_data.get("jobId", "")
84
+ if not job_id:
85
+ return []
86
+ except Exception as e:
87
+ logger.warning("InterProScan5 submit failed: %s", e)
88
+ return []
89
+
90
+ # Poll for completion
91
+ status_url = f"https://www.ebi.ac.uk/interpro/service/rest/iprscan5/status/{job_id}"
92
+ result_url = f"https://www.ebi.ac.uk/interpro/service/rest/iprscan5/result/{job_id}/json"
93
+
94
+ deadline = time.time() + timeout
95
+ while time.time() < deadline:
96
+ try:
97
+ status_resp = urllib.request.urlopen(status_url, timeout=15) # nosemgrep
98
+ status = json.loads(status_resp.read()).get("status", "")
99
+ except Exception:
100
+ time.sleep(3)
101
+ continue
102
+
103
+ if status == "FINISHED":
104
+ break
105
+ if status in ("FAILED", "ERROR"):
106
+ logger.warning("InterProScan5 job %s status: %s", job_id, status)
107
+ return []
108
+ time.sleep(3)
109
+ else:
110
+ logger.warning("InterProScan5 job %s timed out", job_id)
111
+ return []
112
+
113
+ try:
114
+ result_resp = urllib.request.urlopen(result_url, timeout=30) # nosemgrep
115
+ results = json.loads(result_resp.read())
116
+ except Exception as e:
117
+ logger.warning("InterProScan5 result fetch failed: %s", e)
118
+ return []
119
+
120
+ hits: list[dict] = []
121
+ for entry in results.get("results", []):
122
+ acc = entry.get("accession", "")
123
+ name = entry.get("name", "")
124
+ db = entry.get("database", "")
125
+ for match in entry.get("matches", []):
126
+ for loc in match.get("locations", []):
127
+ start = loc.get("start", 0)
128
+ end = loc.get("end", 0)
129
+ score = loc.get("score", 0)
130
+ hits.append({
131
+ "accession": acc,
132
+ "name": name,
133
+ "database": db,
134
+ "start": start,
135
+ "end": end,
136
+ "score": score,
137
+ })
138
+
139
+ hits.sort(key=lambda h: h["start"])
140
+ return hits
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # InterPro → GO term mapping
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def _fetch_interpro_go_terms(accession: str) -> list[dict]:
148
+ """Fetch GO annotations associated with an InterPro entry.
149
+
150
+ Uses the InterPro API which includes InterPro2GO transitive mappings.
151
+ Returns [{"id": "GO:xxxx", "name": "...", "category": "MF|BP|CC"}, ...].
152
+ """
153
+ url = _INTERPRO_ENTRY_API.format(accession=accession)
154
+ try:
155
+ resp = urllib.request.urlopen(url, timeout=15) # nosemgrep
156
+ data = json.loads(resp.read())
157
+ except Exception:
158
+ return []
159
+
160
+ go_terms: list[dict] = []
161
+ # InterPro API returns go_terms in metadata
162
+ for go in data.get("metadata", {}).get("go_terms", []):
163
+ go_id = go.get("id", "")
164
+ go_name = go.get("name", "")
165
+ category_code = go.get("category", "")
166
+ # Map single-letter codes to full namespace
167
+ ns_map = {"F": "MF", "P": "BP", "C": "CC"}
168
+ namespace = ns_map.get(category_code, category_code)
169
+ if go_id:
170
+ go_terms.append({
171
+ "id": go_id,
172
+ "name": go_name,
173
+ "category": namespace,
174
+ })
175
+
176
+ return go_terms
177
+
178
+
179
+ def _interpro_to_go(hits: list[dict]) -> list[dict]:
180
+ """Map InterProScan5 domain hits to GO terms via InterPro2GO.
181
+
182
+ Each domain hit is annotated with its InterPro accession. We look up the
183
+ GO terms for each unique accession and aggregate them, weighting by the
184
+ number of hits from that domain family.
185
+ """
186
+ seen_accessions: dict[str, int] = {}
187
+ for hit in hits:
188
+ acc = hit["accession"]
189
+ seen_accessions[acc] = seen_accessions.get(acc, 0) + 1
190
+
191
+ all_go: dict[str, dict] = {}
192
+ for acc in seen_accessions:
193
+ for go in _fetch_interpro_go_terms(acc):
194
+ go_id = go["id"]
195
+ if go_id not in all_go:
196
+ all_go[go_id] = {**go, "evidence_count": 0}
197
+ all_go[go_id]["evidence_count"] += 1
198
+
199
+ # Convert to list and assign confidence based on evidence count
200
+ go_list = []
201
+ for go_id, go in all_go.items():
202
+ # Confidence: base 0.6 + up to 0.35 from multiple domain evidence
203
+ confidence = min(0.6 + go["evidence_count"] * 0.1, 0.95)
204
+ go_list.append({
205
+ "go_id": go_id,
206
+ "name": go["name"],
207
+ "namespace": go["category"],
208
+ "confidence": round(confidence, 3),
209
+ "source": "interpro2go",
210
+ })
211
+
212
+ go_list.sort(key=lambda g: g["confidence"], reverse=True)
213
+ return go_list
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Heuristic composition-based fallback
218
+ # ---------------------------------------------------------------------------
219
+
220
+ def _heuristic_go_terms(sequence: str) -> list[dict]:
221
+ """Fallback GO prediction from amino acid composition."""
222
  seq_upper = sequence.upper()
223
  seq_len = len(seq_upper)
224
  aa_comp = {}
225
  for aa in seq_upper:
226
  aa_comp[aa] = aa_comp.get(aa, 0) + 1
227
 
 
228
  predicted_go = []
 
229
 
 
230
  hydrophobic_fraction = sum(aa_comp.get(a, 0) for a in "AILMFWV") / max(seq_len, 1)
231
  charged_fraction = sum(aa_comp.get(a, 0) for a in "DEKRH") / max(seq_len, 1)
232
 
 
236
  "name": "membrane",
237
  "namespace": "CC",
238
  "confidence": round(min(0.6 + hydrophobic_fraction * 0.3, 0.95), 3),
239
+ "source": "heuristic",
240
  })
241
  if charged_fraction > 0.25:
242
  predicted_go.append({
 
244
  "name": "protein binding",
245
  "namespace": "MF",
246
  "confidence": round(min(0.55 + charged_fraction * 0.2, 0.9), 3),
247
+ "source": "heuristic",
248
  })
249
  if hydrophobic_fraction > 0.4 and charged_fraction > 0.15:
250
  predicted_go.append({
 
252
  "name": "signal transduction",
253
  "namespace": "BP",
254
  "confidence": round(min(0.5 + hydrophobic_fraction * 0.2, 0.85), 3),
255
+ "source": "heuristic",
256
  })
257
 
 
258
  predicted_go.append({
259
  "go_id": "GO:0003674",
260
  "name": "molecular_function",
261
  "namespace": "MF",
262
  "confidence": 0.99,
263
+ "source": "heuristic",
264
  })
265
 
266
+ return predicted_go
267
+
268
+
269
+ def _saliency_from_composition(sequence: str) -> list[float]:
270
+ """Per-residue importance based on amino acid chemistry."""
271
+ seq_upper = sequence.upper()
272
  saliency = []
273
+ for aa in seq_upper:
 
274
  if aa in "DEKRH":
275
  score = 0.6
276
  elif aa in "STNQ":
 
280
  else:
281
  score = 0.15
282
  saliency.append(round(score, 3))
283
+ return saliency
284
+
285
 
286
+ def _amino_acid_composition(sequence: str) -> dict:
287
+ seq_upper = sequence.upper()
288
+ seq_len = len(seq_upper)
289
+ aa_comp = {}
290
+ for aa in seq_upper:
291
+ aa_comp[aa] = aa_comp.get(aa, 0) + 1
292
+ return {
293
  "aa": "ACDEFGHIKLMNPQRSTVWY",
294
  "fractions": {aa: round(aa_comp.get(aa, 0) / max(seq_len, 1), 4) for aa in "ACDEFGHIKLMNPQRSTVWY"},
295
  }
296
 
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # Main entry point
300
+ # ---------------------------------------------------------------------------
301
+
302
+ def predict_function(pdb_id: str) -> dict:
303
+ """Predict protein function for a PDB entry.
304
+
305
+ Uses InterPro2GO domain-to-GO mapping as the primary method. Falls back
306
+ to composition heuristics when InterProScan5 is unavailable.
307
+ """
308
+ sequence = _fetch_pdb_sequence(pdb_id)
309
+ if not sequence:
310
+ raise RuntimeError(f"No sequence available for PDB {pdb_id}")
311
+
312
+ seq_len = len(sequence)
313
+ method = "interpro2go"
314
+ go_terms = []
315
+ domain_hits = []
316
+
317
+ # Primary: InterProScan5 → InterPro2GO
318
+ try:
319
+ domain_hits = _run_interproscan(sequence)
320
+ if domain_hits:
321
+ go_terms = _interpro_to_go(domain_hits)
322
+ else:
323
+ logger.info("InterProScan5 returned no domain hits for %s, using heuristic", pdb_id)
324
+ go_terms = _heuristic_go_terms(sequence)
325
+ method = "heuristic_fallback"
326
+ except Exception as e:
327
+ logger.warning("InterProScan5 failed for %s: %s — using heuristic", pdb_id, e)
328
+ go_terms = _heuristic_go_terms(sequence)
329
+ method = "heuristic_fallback"
330
+
331
+ saliency = _saliency_from_composition(sequence)
332
+ composition = _amino_acid_composition(sequence)
333
+
334
  return {
335
  "pdb_id": pdb_id.upper(),
336
  "sequence_length": seq_len,
337
+ "go_terms": go_terms,
338
  "ec_numbers": [],
339
+ "domain_hits": [
340
+ {"accession": h["accession"], "name": h["name"],
341
+ "database": h["database"], "start": h["start"], "end": h["end"]}
342
+ for h in domain_hits
343
+ ],
344
  "saliency": saliency,
345
  "composition": composition,
346
+ "method": method,
 
347
  }
 
 
 
 
 
 
 
 
app/tools/gnina_rescore.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gnina CNN-based pose rescoring for AutoDock Vina results.
2
+
3
+ Gnina uses a trained CNN to score docking poses, providing a more accurate
4
+ binding affinity estimate than Vina's empirical scoring function. The CNN
5
+ scores are correlated with experimental binding constants (R ~0.6-0.8 on
6
+ cross-docked benchmarks).
7
+
8
+ This module provides:
9
+ - ``rescore_with_gnina()``: take Vina output PDBQT and rescore each pose
10
+ - ``_ensure_gnina()``: locate or auto-download the gnina binary
11
+ - Graceful fallback when gnina is unavailable (returns Vina scores only)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import re
19
+ import subprocess
20
+ import tempfile
21
+ from pathlib import Path
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _GNINA_BINARY: str | None = None
26
+ _GNINA_URL = "https://github.com/gnina/gnina/releases/download/v1.3.2/gnina"
27
+
28
+
29
+ def _ensure_gnina() -> str | None:
30
+ """Locate the Gnina binary. Returns None if unavailable."""
31
+ global _GNINA_BINARY
32
+ if _GNINA_BINARY and os.path.isfile(_GNINA_BINARY):
33
+ return _GNINA_BINARY
34
+
35
+ import shutil
36
+
37
+ for candidate in ["/usr/local/bin/gnina", shutil.which("gnina") or ""]:
38
+ if candidate and os.path.isfile(candidate):
39
+ _GNINA_BINARY = candidate
40
+ return _GNINA_BINARY
41
+
42
+ return None
43
+
44
+
45
+ def _parse_gnina_log(output: str) -> list[dict]:
46
+ """Parse Gnina stdout into per-pose CNN scores.
47
+
48
+ Gnina prints a table like:
49
+ mode | affinity | cnn_score
50
+ -----+------------+-----------
51
+ 1 -8.2 -1.234
52
+ 2 -7.5 -1.102
53
+ """
54
+ poses: list[dict] = []
55
+ in_table = False
56
+ for line in output.splitlines():
57
+ if re.match(r"^\s*-{5,}", line):
58
+ in_table = True
59
+ continue
60
+ if in_table:
61
+ parts = line.split()
62
+ if len(parts) >= 3 and parts[0].isdigit():
63
+ try:
64
+ poses.append({
65
+ "model": int(parts[0]),
66
+ "vina_affinity": float(parts[1]),
67
+ "cnn_score": float(parts[2]),
68
+ })
69
+ except (ValueError, IndexError):
70
+ continue
71
+ elif parts:
72
+ in_table = False
73
+ return poses
74
+
75
+
76
+ def rescore_with_gnina(
77
+ receptor_pdb: str,
78
+ vina_output_pdbqt: str,
79
+ timeout: int = 120,
80
+ ) -> dict | None:
81
+ """Rescore Vina docking poses using Gnina CNN scoring.
82
+
83
+ Takes the receptor PDB text and the Vina multi-model output PDBQT.
84
+ Returns {"poses": [...], "cnn_version": "..."} or None if gnina is
85
+ unavailable or fails.
86
+
87
+ Each pose dict contains:
88
+ - model: pose number (1-based)
89
+ - vina_affinity: original Vina score (kcal/mol)
90
+ - cnn_score: Gnina CNN score (higher = better predicted binding)
91
+ """
92
+ gnina_bin = _ensure_gnina()
93
+ if not gnina_bin:
94
+ logger.info("Gnina not available — skipping CNN rescoring")
95
+ return None
96
+
97
+ with tempfile.TemporaryDirectory() as tmp:
98
+ rec_path = os.path.join(tmp, "receptor.pdb")
99
+ with open(rec_path, "w") as f:
100
+ f.write(receptor_pdb)
101
+
102
+ # Write multi-model output as SDF-like input for gnina
103
+ # Gnina expects receptor + ligand; we feed the Vina poses
104
+ lig_path = os.path.join(tmp, "poses.pdbqt")
105
+ with open(lig_path, "w") as f:
106
+ f.write(vina_output_pdbqt)
107
+
108
+ out_path = os.path.join(tmp, "rescored.sdf")
109
+
110
+ cmd = [
111
+ gnina_bin,
112
+ "--receptor", rec_path,
113
+ "--ligand", lig_path,
114
+ "--autobox_ligand", lig_path,
115
+ "--autobox_add", "0",
116
+ "--num_modes", "0", # don't re-dock, just score
117
+ "--exhaustiveness", "0",
118
+ "--out", out_path,
119
+ "--no_gpu",
120
+ "--quiet",
121
+ ]
122
+
123
+ try:
124
+ result = subprocess.run(
125
+ cmd, capture_output=True, text=True, timeout=timeout,
126
+ )
127
+ except subprocess.TimeoutExpired:
128
+ logger.warning("Gnina rescoring timed out after %ds", timeout)
129
+ return None
130
+ except FileNotFoundError:
131
+ logger.warning("Gnina binary not found at %s", gnina_bin)
132
+ return None
133
+
134
+ if result.returncode != 0:
135
+ logger.warning("Gnina failed (code %d): %s", result.returncode, result.stderr[:500])
136
+ return None
137
+
138
+ # Parse gnina output for CNN scores
139
+ parsed = _parse_gnina_log(result.stdout)
140
+
141
+ if not parsed:
142
+ # Try to extract CNN scores from the output SDF
143
+ parsed = _parse_gnina_sdf(out_path)
144
+
145
+ if not parsed:
146
+ logger.info("Gnina produced no parseable CNN scores")
147
+ return None
148
+
149
+ return {
150
+ "poses": parsed,
151
+ "num_poses": len(parsed),
152
+ }
153
+
154
+
155
+ def _parse_gnina_sdf(sdf_path: str) -> list[dict]:
156
+ """Extract CNN scores from Gnina SDF output (REMARK lines)."""
157
+ if not os.path.isfile(sdf_path):
158
+ return []
159
+
160
+ poses: list[dict] = []
161
+ current_model = 0
162
+ cnn_score = None
163
+ vina_affinity = None
164
+
165
+ try:
166
+ with open(sdf_path) as f:
167
+ for line in f:
168
+ if line.startswith("$$$$"):
169
+ if current_model and cnn_score is not None:
170
+ poses.append({
171
+ "model": current_model,
172
+ "vina_affinity": vina_affinity,
173
+ "cnn_score": cnn_score,
174
+ })
175
+ current_model += 1
176
+ cnn_score = None
177
+ vina_affinity = None
178
+ elif "CNNscore" in line:
179
+ try:
180
+ cnn_score = float(line.split()[-1])
181
+ except (ValueError, IndexError):
182
+ pass
183
+ elif "CNNaffinity" in line:
184
+ try:
185
+ vina_affinity = float(line.split()[-1])
186
+ except (ValueError, IndexError):
187
+ pass
188
+ except Exception:
189
+ return []
190
+
191
+ return poses
app/tools/mafft_local.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local MAFFT multiple sequence alignment wrapper.
2
+
3
+ Calls the locally installed MAFFT binary for fast, offline MSA.
4
+ Falls back to the EBI remote MAFFT service or other EBI tools
5
+ when the local binary is unavailable.
6
+
7
+ MAFFT strategies:
8
+ - FFT-NS-2: fast, default for large datasets (up to ~30k sequences)
9
+ - L-INS-i: accurate, iterative refinement (best for <200 sequences)
10
+ - auto: automatic strategy selection based on dataset size
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _MAFFT_BIN: str | None = None
24
+
25
+
26
+ def _ensure_mafft() -> str | None:
27
+ """Locate the local MAFFT binary. Returns None if unavailable."""
28
+ global _MAFFT_BIN
29
+ if _MAFFT_BIN and os.path.isfile(_MAFFT_BIN):
30
+ return _MAFFT_BIN
31
+
32
+ for candidate in [
33
+ "/usr/local/bin/mafft",
34
+ shutil.which("mafft") or "",
35
+ ]:
36
+ if candidate and os.path.isfile(candidate):
37
+ _MAFFT_BIN = candidate
38
+ return _MAFFT_BIN
39
+
40
+ return None
41
+
42
+
43
+ def run_local_mafft(
44
+ fasta: str,
45
+ strategy: str = "auto",
46
+ threads: int = 1,
47
+ timeout: int = 300,
48
+ ) -> dict | None:
49
+ """Run local MAFFT on a FASTA-formatted string.
50
+
51
+ Args:
52
+ fasta: FASTA-formatted input sequences
53
+ strategy: MAFFT strategy - "auto", "fft-ns-2", "l-ins-i", "g-ins-i", "e-ins-i"
54
+ threads: number of threads (MAFFT supports multithreading)
55
+ timeout: maximum seconds to wait
56
+
57
+ Returns:
58
+ {"aln_fasta": "...", "method": "mafft-local"} or None if MAFFT unavailable
59
+ """
60
+ mafft_bin = _ensure_mafft()
61
+ if not mafft_bin:
62
+ logger.info("Local MAFFT not available")
63
+ return None
64
+
65
+ # Map strategy names to MAFFT flags
66
+ strategy_flags = {
67
+ "auto": ["--auto"],
68
+ "fft-ns-2": ["--fft", "--retree", "2"],
69
+ "fft-ns-i": ["--fft", "--retree", "1", "--iterate"],
70
+ "l-ins-i": ["--localpair", "--maxiterate", "1000"],
71
+ "g-ins-i": ["--globalpair", "--maxiterate", "1000"],
72
+ "e-ins-i": ["--epg", "--maxiterate", "1000"],
73
+ }
74
+ flags = strategy_flags.get(strategy, ["--auto"])
75
+
76
+ with tempfile.NamedTemporaryFile(suffix=".fasta", mode="w", delete=False) as inf:
77
+ inf.write(fasta)
78
+ in_path = inf.name
79
+
80
+ out_path = in_path + ".aln"
81
+ try:
82
+ cmd = [mafft_bin] + flags + ["--thread", str(threads), in_path]
83
+ result = subprocess.run(
84
+ cmd, capture_output=True, text=True, timeout=timeout,
85
+ )
86
+ if result.returncode != 0:
87
+ logger.warning("MAFFT failed (code %d): %s", result.returncode, result.stderr[:500])
88
+ return None
89
+
90
+ if os.path.isfile(out_path):
91
+ with open(out_path) as f:
92
+ aln = f.read()
93
+ else:
94
+ aln = result.stdout
95
+
96
+ if not aln.strip():
97
+ return None
98
+
99
+ return {
100
+ "aln_fasta": aln,
101
+ "method": "mafft-local",
102
+ }
103
+ except subprocess.TimeoutExpired:
104
+ logger.warning("Local MAFFT timed out after %ds", timeout)
105
+ return None
106
+ except FileNotFoundError:
107
+ logger.warning("MAFFT binary not found at %s", mafft_bin)
108
+ return None
109
+ finally:
110
+ for p in (in_path, out_path):
111
+ try:
112
+ os.unlink(p)
113
+ except OSError:
114
+ pass