
Tooluniverse Peptide Target Deorphanization
- 66 installs
- 1.6k repo stars
- Updated August 4, 2026
- mims-harvard/tooluniverse
Helps with ai & agent building tasks.
About
tooluniverse-peptide-target-deorphanization is a Claude Code skill in the AI & Agent Building category.
- tooluniverse-peptide-target-deorphanization
- AI & Agent Building
- AI-coding skill
Tooluniverse Peptide Target Deorphanization by the numbers
- 66 all-time installs (skills.sh)
- +5 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #6,006 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mims-harvard/tooluniverse --skill tooluniverse-peptide-target-deorphanizationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 66 |
|---|---|
| repo stars | ★ 1.6k |
| Last updated | August 4, 2026 |
| Repository | mims-harvard/tooluniverse ↗ |
What it does
Helps with ai & agent building tasks.
Files
Peptide Target Deorphanization
Deorphanize a peptide: given a peptide sequence plus an observed phenotype (and often a hypothesized target the peptide does NOT actually bind), find its likely real protein target(s) — using ToolUniverse's keyless characterization, homology, target-family, phenotype, cross-species, and (optional, key-gated) co-folding tools.
The target can be anything, not just a GPCR. A target-class router (GPCR ligand / ion-channel toxin / protease target / cytokine or growth-factor receptor / integrin ligand / antimicrobial / unknown) classifies the peptide up front and adapts the enumeration strategy. GPCRs are the best-trodden case (and the validated control), but the pipeline's spine — homology, motif, phenotype, cross-species, co-fold — is target-class-agnostic, and family enumeration uses HGNC gene-family (general) + InterPro (general) + GPCRdb (GPCR-only cross-check).
Core reasoning: LOOK UP, DON'T GUESS
The failure mode this skill defends against is guessing a target from the peptide's name or its assumed mechanism. A peptide can be phenotypically active yet not bind the hypothesized target — because it hits a paralog, a different family member, or the same receptor in a different species whose binding interface has diverged. So:
1. Never assert a target from memory. Every candidate must come from a tool result (homology hit, family enumeration, phenotype association, or structural co-fold), with the tool name and accession recorded. 2. Anchor on PHENOTYPE × STRUCTURE/SEQUENCE plausibility, not on the peptide's reputed mechanism. The real target is the intersection of (a) what the sequence/motif/structure says it could bind and (b) what the phenotype says is biologically relevant. A name-level guess ("it's a GLP-1 analog so it's GLP1R") is exactly what produces off-target errors. 3. Reconcile across species. "Binds in species A but not B" is usually interface sequence divergence, not a different target. Always pull the ortholog set and align the candidate receptor's ligand-binding interface across the assay species before concluding the peptide "doesn't work." 4. A non-binding result against the hypothesized target is a clue, not a dead end. It promotes the paralogs and phenotype-shared receptors to the top of the candidate list.
This skill is built on validated, mostly keyless tools (BLAST, ELM/PROSITE, GPCRdb/HGNC/GtoPdb, OpenTargets, EnsemblCompara/Alliance). The single key-gated step is the optional structural confirmation by co-folding (NVIDIA NIM), used only to rank an already-narrowed shortlist.
---
Automated pipeline (scripts) — the fast path
Two runnable scripts in scripts/ execute the whole pipeline so you don't have to chain the phase calls by hand. Both load ToolUniverse via the SDK and run from the repo root.
deorphanize_peptide.py — keyless candidate generation + ranking (Phases 1–4)
No API key. For each peptide it characterizes it (PepCalc/ProtParam), flags non-canonical/cyclic residues, scans PROSITE + ELM signatures, flags protease/degradation liability, classifies the target class (GPCR / channel / protease / cytokine-receptor / integrin / …) and enumerates the candidate target family accordingly, anchors on phenotype (OpenTargets), and — for the top candidates — resolves the ortholog protein sequences and aligns the binding interface across human / assay-species / source-species (the mechanistic "binds in A, not B" step) and suggests a ClusPro-ready PDB structure. Prints a ranked candidate shortlist with evidence tiers.
python3 scripts/deorphanize_peptide.py \
--sequence <PEPTIDE_SEQ> \ # OR --fasta peptides.fasta for BATCH mode
--hypothesized-target <GENE> \ # optional; seeds family enumeration (e.g. GLP1R). OMIT for SEEDLESS mode
--phenotype "<disease name>" \ # optional, REPEATABLE; OpenTargets anchor (use the DISEASE node, not a symptom) — pass several to union plausible phenotypes
--assay-species mus_musculus \ # species of the NEGATIVE binding assay
--source-species <organism> \ # optional; species where binding WAS observed -> 3-way interface alignment
[--no-blast] [--out result.json]Modes:
- Seeded (
--hypothesized-target GLP1R) — enumerate that gene's family as the candidate panel (cleanest). Even seeded, the sequence-derived candidates (below) are always unioned in, so a wrong hypothesized seed cannot blind the search to the real target's family — exactly the deorphanization premise. - Seedless (omit it) — derive candidate targets from PROSITE and BLAST-homolog keywords × the target-class nouns (e.g.
receptor/channel/protease, chosen by the router) via UniProt; degrades to phenotype-only if that resolver is transiently down. No longer receptor-only. - Multi-phenotype —
--phenotypeis repeatable; pass every plausible disease and the anchor is the union (max score per target). Best when you don't know the single right phenotype. - Batch (
--fasta) — one record per FASTA entry, sharing--phenotype/--assay-species.
Extra signals it always reports:
- Target class — the router's call (gpcr_ligand / ion_channel_toxin / protease_inhibitor_or_substrate / cytokine_or_growth_factor / integrin_ligand / guanylyl_cyclase_ligand / antimicrobial / unknown) with the evidence that triggered it and the seedless nouns it selected. This is what makes the skill general rather than GPCR-only.
- DPP4 / protease liability — a peptide can be assay-negative because it is cleaved, not because it fails to bind. Native GLP-1 (
A@P2) is DPP4-LABILE; exendin-4 (G@P2) is resistant. A labile flag triggers a "re-test with a DPP4 inhibitor or protease-resistant analog" note — a key alternative explanation for "works in vitro, not in the mouse assay." - ELM LIG motifs (ranked by rarity) + the Pfam binding domain each engages — low-confidence context for peptides without a named PROSITE family.
- Non-canonical / cyclic flag — any residue outside the 20 standard L-amino acids is surfaced, because BLAST/PROSITE/ProtParam silently assume a canonical linear peptide and will mischaracterize a non-ribosomal/cyclic peptide (common for unicellular-organism natural products). Look such peptides up by name with
Norine_get_peptideand pass--cyclictocofold_screen.py. - Cross-species interface alignment (top ≤3 candidates) — resolves each candidate's human + assay-species (+ optional source-species) ortholog sequence (UniProt) and aligns them (
EBI_msa_align), reporting per-pair % identity and substitution count. A low human-vs-assay identity flags the ortholog whose binding interface most plausibly diverged — the mechanistic answer to "binds in A, not B". If the source organism is a protist absent from UniProt, it reportsinsufficientand tells you to supply the partner sequence by hand. - ClusPro-ready PDB (top ≤3 candidates) —
PDBeSIFTS_get_best_structuresresolves a representative solved PDB id you can feed straight toClusPro_submit_peptide_docking.
Validated on the control (--sequence HGEGTFTSDLSKQMEEEAVRLFIEWLKNGGPSSGAPPPS --hypothesized-target GLP1R --phenotype "type 2 diabetes mellitus"): recovers the class-B panel {GCGR, GHRHR, GIPR, GLP1R, GLP2R, SCTR}, flags GLP1R as hypothesized (tested negative), and promotes GIPR to Tier 1 (family + phenotype, score 0.674) as the leading real-target hypothesis — exactly the deorphanization re-ranking, produced with zero API keys.
cofold_screen.py — structural confirmation (Phase 5, key-gated)
Co-folds the peptide against each shortlisted receptor and ranks by interface confidence (ipTM). Requires NVIDIA_API_KEY; without it, runs a DRY RUN that still resolves every receptor sequence (GPCRdb → UniProt fallback) and prints the co-fold plan, so you can verify inputs before paying for GPU time.
python3 scripts/cofold_screen.py --peptide <SEQ> --candidates GIPR GCGR GLP2R \
[--backend boltz2|alphafold2_multimer|openfold3] [--assay-species mus_musculus] [--out cofold.json]Use the scripts for the fast path. When you need to run, debug, or extend a single step by hand, the full per-phase manual reference — every tool call the scripts automate, with exact parameter names, gotchas, fallback chains, runtime notes, and two fully worked examples — lives in `references/phases.md`. Read it when a script step fails, when you want to drive a phase manually, or when you extend the pipeline to a new tool.
---
The pipeline at a glance
Six phases; the scripts automate 1–4 (and the Phase-5 dry run). Full detail + exact tool calls + gotchas are in `references/phases.md` — read it before driving any phase by hand.
| Phase | What it does | Key tools |
|---|---|---|
| 0 Verify | Confirm every tool loads; substitute fallbacks | tooluniverse.cli run … |
| 1 Characterize + motif + classify | Properties, non-canonical/cyclic flag, PROSITE/ELM signature → ligand family, target-class router (GPCR/channel/protease/cytokine/integrin/…) | PepCalc/ProtParam, ScanProsite→PROSITE_get_entry, ELM_*, ESMFold |
| 2 Candidate generation | 4 independent routes — homology, motif→domain, target-family enumeration (class-aware), phenotype anchor → union | BLAST/EBI_msa_align, HGNC+InterPro+GPCRdb+GtoPdb, OpenTargets |
| 3 Cross-species | Resolve "binds in A not B": align the ortholog interface across human / assay / source species | EnsemblCompara, Alliance, UniProt, EBI_msa_align |
| 4 Narrow + rank | Score on sequence × phenotype × pharmacology × cross-species → shortlist ≤15 | (intersection logic) |
| 5 Structural confirm (optional, key-gated) | Co-fold top candidates, rank by interface ipTM; or academic-free ClusPro docking | NvidiaNIM_boltz2/…, ClusPro_submit_peptide_docking |
| 6 Report | Ranked shortlist with evidence tiers + wet-lab plan | (inline report) |
Core intersection rule: the real target is where phenotype-plausible (Route 2D) meets sequence/structure-plausible (Routes 2A–2C). A name-level guess ("it's a GLP-1 analog so it's GLP1R") is exactly what produces off-target errors — never assert a target the union of routes didn't surface.
---
Output format — ranked shortlist report (return inline, no extra files)
1. Peptide characterization — length, MW, pI, GRAVY, instability, PROSITE/ELM signature, non-canonical/cyclic flag, fold confidence (if run). 2. Ranked candidate table — one row per candidate target: gene + accession · target class · evidence tier · routes that surfaced it · OpenTargets phenotype score · known-pharmacology note (GtoPdb peptide ligands?) · cross-species status (ortholog_one2one? interface % identity in the assay species?). 3. Cross-species reconciliation note — for the lead, the human-vs-assay(-vs-source) interface comparison and what it predicts for "binds A not B." 4. Recommended wet-lab validation — binding/competition + a class-appropriate functional assay against the top ≤3 candidates and their assay-species orthologs (cAMP/β-arrestin for GPCRs; electrophysiology for channels; enzymatic/inhibition for proteases; reporter for cytokine receptors), with the GtoPdb-listed family antagonist as control where one exists.
Evidence tiers — always state which evidence is keyless/validated vs key-gated (co-fold not run), and flag any candidate that is a negative against the originally hypothesized target so the reader sees the re-ranking:
- Tier 1 (strong): ≥2 independent sequence/structure routes AND present in the phenotype anchor AND (if Phase 5 run) top interface ipTM.
- Tier 2 (moderate): 1 sequence/structure route + phenotype support, OR ≥2 sequence routes without phenotype.
- Tier 3 (weak/hypothesis): single-route only (deep paralog, or a phenotype-only hit not corroborated by sequence/structure).
---
Validation & test set
Validated on the exendin-4 → GLP1R control: recovers the class-B panel {GCGR, GHRHR, GIPR, GLP1R, GLP2R, SCTR}, flags GLP1R as the (negative) hypothesized target, and promotes GIPR to Tier 1 — the deorphanization re-ranking, produced with zero API keys. The full step-by-step (control and the real "binds in the source organism, not in mouse" case) is in references/phases.md.
Reproducible test prompts + checkable assertions are in `evals/evals.json` (the exendin-4 control, the source-organism/mouse-negative case, and a non-GPCR seedless case). To check the skill still behaves, run a prompt through it and verify the output against that eval's assertions.
{
"skill_name": "tooluniverse-peptide-target-deorphanization",
"notes": "Reproducible test prompts for the peptide target-deorphanization skill. Run each prompt through a Claude that has this skill loaded; check the output against `assertions`. Eval 1 is the validated control (objective answer), eval 2 is the motivating real case (process + re-ranking), eval 3 exercises seedless mode + the non-canonical path. The full skill-creator eval harness (run_loop.py, generate_review.py) lives in the skill-creator plugin, not this repo; these prompts + assertions are the durable test set.",
"evals": [
{
"id": 1,
"name": "exendin4-control-seeded",
"prompt": "I have a 39-residue peptide, HGEGTFTSDLSKQMEEEAVRLFIEWLKNGGPSSGAPPPS. It's a type-2-diabetes drug that's supposed to act through GLP1R. Can you confirm what receptor family it really targets from the sequence alone — basically deorphanize it and show me your evidence, don't just take the GLP1R label at face value.",
"files": [],
"expected_output": "A ranked candidate report that recovers the class-B1 / secretin receptor family (GLP1R + paralogs GCGR, GIPR, GLP2R, SCTR, GHRHR) from the sequence, identifies the PROSITE glucagon/GIP/secretin/VIP family signature (PS00260), and lists GLP1R as a Tier-1 candidate — every candidate backed by a recorded tool result, produced with keyless tools.",
"assertions": [
"The candidate panel includes GLP1R together with its class-B paralogs (at least GCGR, GIPR, GLP2R, SCTR).",
"It identifies the glucagon/GIP/secretin/VIP family signature (PROSITE PS00260) or the equivalent class-B1 ligand family from the sequence.",
"GLP1R is reported as a Tier-1 / strong candidate (sequence + phenotype supported).",
"Every named candidate receptor is attributed to a tool result (homology hit, family enumeration, or phenotype association) rather than asserted from the peptide's name.",
"The analysis is performed with keyless tools (no API key required) or clearly marks any key-gated step (co-folding) as optional."
]
},
{
"id": 2,
"name": "source-organism-binding-mouse-negative",
"prompt": "A collaborator isolated a short peptide from a single-celled organism. It prevents insulin resistance in mice (real metabolic phenotype), and in the source organism it binds something they call 'GLP1' — but in a mouse wet-lab binding assay it does NOT bind mouse GLP1R. They assumed GLP1R was the target and it isn't. Can you use ToolUniverse to screen for what the real target might be? The peptide is HAEGTYTSDVSSYLEGQAAKEFIAWLVKGRG. Don't just guess from the name.",
"files": [],
"expected_output": "A deorphanization report that refuses to assert GLP1R from the name, enumerates the full class-B / metabolic receptor panel as explicit candidates, promotes paralogs (e.g. GIPR, GCGR) as the leading alternative real-target hypotheses on phenotype + sequence grounds, performs or describes a cross-species (human vs mouse, plus the source organism if resolvable) ortholog interface comparison to explain the mouse-negative result, flags the GLP1R negative as re-ranking evidence, and ends with a ranked shortlist + wet-lab recommendation.",
"assertions": [
"It does NOT conclude the target is GLP1R purely because the peptide was labeled a GLP1 binder; the GLP1R-negative result is treated as re-ranking evidence, not a dead end.",
"It enumerates the class-B / glucagon-secretin receptor family (GLP1R, GCGR, GIPR, GLP2R, SCTR, GHRHR) as explicit candidates.",
"It promotes at least one paralog (e.g. GIPR or GCGR) as a leading alternative real-target hypothesis with stated evidence.",
"It addresses the cross-species 'binds in A not B' question via ortholog interface comparison across human / mouse (and the source organism when resolvable), or explicitly notes when the source organism is absent from the sequence databases.",
"The final output is a ranked shortlist with evidence tiers and a concrete wet-lab validation recommendation (binding/competition or functional assay against the top candidates).",
"Every candidate is supported by a recorded tool result, not by unsourced recall."
]
},
{
"id": 3,
"name": "seedless-orphan-no-hypothesized-target",
"prompt": "We pulled a ~20-residue peptide out of a marine sponge metabolite screen. It reduces inflammation in a cell assay but we have no idea what it binds — no hypothesized target at all. Sequence is GLPVCGETCVGGTCNTPGCTCSWPVCTRN (it's cysteine-rich, possibly cyclic). What could the real protein target be?",
"files": [],
"expected_output": "A seedless-mode deorphanization: because no hypothesized target is given, it derives candidate targets from the motif/signature and the inflammation phenotype anchor rather than from a seed family, flags the peptide as cysteine-rich/possibly non-canonical or cyclic (so sequence tools may mischaracterize it), and returns a phenotype-anchored ranked candidate list — degrading gracefully to phenotype-only candidates if the motif resolver returns nothing, without inventing a target.",
"assertions": [
"It runs without a seeded hypothesized target (seedless mode) and derives candidates from the motif/signature and/or the inflammation phenotype rather than assuming a receptor family.",
"It classifies the target class as something OTHER than a GPCR (e.g. ion-channel toxin / cysteine-rich, or unknown) and does NOT restrict candidate generation to receptors only.",
"It flags the peptide as cysteine-rich / possibly cyclic or non-canonical and notes that BLAST/PROSITE/ProtParam may mischaracterize a non-linear peptide (or recommends Norine / a cyclic-aware tool).",
"It anchors candidate targets on the inflammation phenotype (e.g. via OpenTargets) and intersects with any sequence/motif evidence.",
"If the motif-based resolver returns nothing, it degrades to a phenotype-anchored candidate list and says so, rather than fabricating a specific target.",
"Every candidate target is supported by a tool result, and the report states which evidence is keyless vs key-gated."
]
},
{
"id": 4,
"name": "conotoxin-ion-channel-nongpcr-control",
"prompt": "Deorphanize this venom peptide for me: CKGKGAKCSRLMYDCCTGSCRSGKC. It's a 25-mer from a cone snail and it has strong analgesic / pain-relief activity. I want to know what protein it actually acts on — work it out from the sequence, don't just tell me it's a 'conotoxin'.",
"files": [],
"expected_output": "A deorphanization that classifies this cysteine-rich venom peptide as a NON-GPCR target class (ion-channel toxin), enumerates the relevant channel family via the general HGNC/InterPro route (not GPCRdb), anchors on the pain phenotype, and lands on a voltage-gated calcium channel (CACNA1B / Cav2.2, the real ziconotide target) or the calcium-channel family as the top candidate — with electrophysiology, not a GPCR cAMP assay, as the recommended validation.",
"assertions": [
"It classifies the target class as an ion-channel toxin / non-GPCR (e.g. from the cysteine-rich pattern and/or conotoxin homology), NOT as a GPCR ligand.",
"Candidate generation uses the general target-family route (HGNC gene-family and/or InterPro) rather than GPCRdb, and the candidate panel is a channel family (e.g. voltage-gated calcium channels), not a receptor family.",
"The top candidate is a voltage-gated calcium channel — CACNA1B (Cav2.2) or the CACNA1x family — consistent with the analgesic phenotype.",
"The recommended validation is class-appropriate (electrophysiology / channel binding), not a GPCR functional assay.",
"Every candidate is backed by a tool result, and the answer does not stop at the label 'conotoxin' without naming a protein target."
]
}
]
}
Peptide Target Deorphanization — per-phase manual reference
Detailed manual/fallback reference for the tooluniverse-peptide-target-deorphanization skill. The two scripts in ../scripts/ automate Phases 1–4 (and the Phase 5 dry-run); read this file when a script step fails, when you want to drive a phase by hand, or when you extend the pipeline to a new tool. Every tool call the scripts make is documented here with exact parameter names and gotchas.
Contents
- Phase 0 — Tool verification
- Phase 1 — Peptide characterization + motif
- Phase 2 — Multi-route candidate generation
- Phase 3 — Cross-species reconciliation
- Phase 4 — Narrowing & ranking
- Phase 5 — Structural confirmation (co-folding / ClusPro)
- Phase 6 — Ranked shortlist report
- Fallback Chains
- NVIDIA NIM / runtime notes
- Worked example A — exendin-4 → GLP1R (control)
- Worked example B — anti-insulin-resistance peptide (the real case)
- Worked example C — ω-conotoxin → ion channel (NON-GPCR target class)
---
Phase 0 — Tool verification (run first)
Confirm every tool you intend to use is loadable before building the pipeline. Mark any tool that errors here and substitute its fallback.
# repo root = cwd; SDK uses sys.path.insert(0,"src")
python3 -m tooluniverse.cli run PepCalc_peptide_properties '{"seq":"HGEGTFTSDLSKQMEEEAVRLFIEWLKNGGPSSGAPPPS"}'Verification targets (all expected KEYLESS unless noted):
- Characterization/motif:
PepCalc_peptide_properties,ProtParam_calculate,ScanProsite_scan_protein,PROSITE_get_entry,ELM_list_classes,ELM_get_interaction_domains,ELM_get_instances,ESMFold_predict_structure - Homology:
BLAST_protein_search,EBI_msa_align,AMPSphere_sequence_match - Receptor family / pharmacology:
GtoPdb_search_targets,GtoPdb_search_ligands,GtoPdb_get_interactions,GPCRdb_get_protein,GPCRdb_list_proteins,GPCRdb_get_ligands,HGNC_fetch_gene_by_symbol,HGNC_fetch_gene_family_members - Phenotype / cross-species:
OpenTargets_get_disease_id_description_by_name,OpenTargets_get_associated_targets_by_disease_efoId,EnsemblCompara_get_paralogues,EnsemblCompara_get_orthologues,Alliance_get_gene_orthologs - Target-engagement (soluble candidates, keyless):
ProteomicsDB_get_protein_meltome - ID mapping / sequences:
UniProt_search,UniProt_get_sequence_by_accession,PDBeSIFTS_get_best_structures - Key-gated (note, do not block):
NvidiaNIM_boltz2,NvidiaNIM_esmfold,NvidiaNIM_esm2_650m,NvidiaNIM_alphafold2_multimer,NvidiaNIM_openfold3— requireNVIDIA_API_KEY;ClusPro_submit_peptide_docking— requires a free academicCLUSPRO_USERNAME/CLUSPRO_API_SECRET.
If any keyless tool fails to load, record it and use the Fallback Chains below. Do not abort the pipeline because a key-gated co-folding tool is unavailable — co-folding is Phase 5 (optional confirmation only).
---
Phase 1 — Peptide characterization + motif (keyless)
Establish what the peptide is before guessing what it binds.
1a. Physicochemical properties — run BOTH (cross-check pI/MW)
python3 -m tooluniverse.cli run PepCalc_peptide_properties '{"seq":"<SEQUENCE>"}' # param is 'seq' (raw 1-letter)
python3 -m tooluniverse.cli run ProtParam_calculate '{"sequence":"<SEQUENCE>"}' # param is 'sequence' (different name!)- Gotcha: the two tools use different parameter names (
seqvssequence). - PepCalc → formula, monoisotopic + average MW, pI, extinction coefficient (source Pep-Calc.com).
- ProtParam → MW, pI, instability index (+ stable/unstable call), aliphatic index, GRAVY, +/- charged counts, full AA composition.
- Use the pI agreement across both tools as a sanity check (control: pI ~4.4, MW ~4.19 kDa, instability 12.9 = stable, GRAVY −0.69 = hydrophilic).
- Non-canonical / cyclic check: any residue outside the 20 standard L-amino acids means BLAST/PROSITE/ProtParam will mischaracterize the peptide (common for unicellular-organism natural products / non-ribosomal peptides). Look such peptides up by name with
Norine_get_peptideand treat them as cyclic in co-folding (cofold_screen.py --cyclic).
1b. Signature / family scan — the decisive family-ID step
python3 -m tooluniverse.cli run ScanProsite_scan_protein '{"seq":"<SEQUENCE>"}' # 'seq' accepts a RAW SEQUENCE or a UniProt acc
python3 -m tooluniverse.cli run PROSITE_get_entry '{"accession":"<PSxxxxx>"}' # resolve each matchset hit- ScanProsite returns a
matchsetof PROSITE accessions (PSxxxxx) with start/stop. Resolve each withPROSITE_get_entry. - A PROSITE family/domain hit names a protein family; for receptor-ligand peptides, the matching signature often names the receptor's natural ligand family → candidate receptors.
- Control: single hit PS00260 (res 1–23) → "Glucagon / GIP / secretin / VIP family signature" → names the class B1 GPCR ligand family containing GLP1R + paralogs. This is the family-level deorphanization signal.
1c. SLiM classes + motif→binding-domain map (for peptides whose signature is NOT a named PROSITE family)
python3 -m tooluniverse.cli run ELM_list_classes '{"operation":"list_classes","motif_type":"LIG","max_results":50}'
python3 -m tooluniverse.cli run ELM_get_interaction_domains '{"operation":"get_interaction_domains","query":"<motif or domain kw>"}'
python3 -m tooluniverse.cli run ELM_get_interaction_domains '{"operation":"get_interaction_domains","elm_identifier":"LIG_SH3_1"}'operationis enum-locked and required.motif_type:LIG(ligand-binding — most relevant), alsoCLV/DEG/DOC/MOD/TRG.ELM_list_classesgives each SLiM class a regex (+ probability, instance counts). Match candidate class regexes against your peptide.ELM_get_interaction_domainsis the mechanistic core: it maps each SLiM class → the Pfam domain it binds (e.g.LIG_SH3_1 → PF00018 'SH3 domain'; 409 total mappings). That Pfam domain points to candidate receptor/partner families.- Receptor-side check (use on a known candidate receptor, NOT the orphan peptide):
python3 -m tooluniverse.cli run ELM_get_instances '{"operation":"get_instances","uniprot_id":"<RECEPTOR_ACC>"}'Lists curated SLiM instances on a known protein (with PDB IDs). Coverage is sparse for membrane GPCRs — GLP1R (P43220) returns 0 instances; this is expected, not a failure. Most useful for soluble/adaptor candidate partners.
1d. Optional fold (keyless; do NOT block the pipeline on it)
python3 -m tooluniverse.cli run ESMFold_predict_structure '{"sequence":"<SEQUENCE>"}' # KEYLESS public ESM Atlas, ~30–60s- Returns
mean_plddt, per-residue pLDDT, confident fractions,pdb_text. Control: mean pLDDT 0.85. - Key-gated alternative:
NvidiaNIM_esmfold(NVIDIA_API_KEY). Use the keyless one first.
1e. Target-class router — classify before you enumerate
A peptide's real target is not necessarily a GPCR. Before Route 2C, classify the likely target class from the motif/homology text + sequence features, and let it pick the enumeration strategy and the seedless search nouns. The script does this automatically (_classify_target_class); doing it by hand is just reading the signals:
| Signal | Likely target class | Seedless nouns |
|---|---|---|
| PROSITE/BLAST text: glucagon/secretin/opioid/chemokine/neuropeptide… | gpcr_ligand | receptor |
| text: natriuretic/guanylin | guanylyl_cyclase_ligand | receptor, guanylate cyclase |
| text: interleukin/interferon/growth factor/leptin | cytokine_or_growth_factor | receptor |
| text: conotoxin/scorpion/sodium-potassium-calcium channel | ion_channel_toxin | channel, receptor |
| text: kunitz/protease inhibitor/serpin | protease_inhibitor_or_substrate | protease, peptidase |
| RGD motif in sequence, or text: integrin/disintegrin | integrin_ligand | integrin |
| text: defensin/cathelicidin/antimicrobial | antimicrobial | (membrane-acting; often no single protein target) |
| cysteine-rich short peptide, no named family | ion_channel_toxin (disulfide toxin/knottin) | channel, receptor, protease |
| nothing specific | unknown | receptor, enzyme, channel, transporter |
The class only steers enumeration (which resource, which nouns); phenotype + homology still drive the actual candidates. Record the class + the evidence that triggered it in the report.
---
Phase 2 — Multi-route candidate generation (run routes in parallel)
Generate candidate receptor/target genes by four independent routes, then take the union (and note overlaps — overlap = stronger evidence). Routes 2A–2C are sequence/structure plausibility; Route 2D is phenotype plausibility. The deorphanization candidate set is the intersection of phenotype-plausible and sequence/structure-plausible targets.
Route 2A — Homology (sequence → ligand family → receptor family)
python3 -m tooluniverse.cli run BLAST_protein_search '{"sequence":"<SEQUENCE>","database":"swissprot","expect":10.0,"hitlist_size":10}'- Params:
sequence(≥10 aa),database(swissprotcurated+fast |pdbstructures |nrcomprehensive+slow),expect,hitlist_size. - Always start with `swissprot` + small `hitlist_size` (10). Curated → hits return informative
hit_defnames (e.g. "Glucagon-like peptide 1") instead of raw accessions, and it's much faster thannr. - Runtime is the main gotcha. Remote NCBI submit+poll: measured 62 s and 181 s on the same control query (server-load dependent). Treat anything under ~8 min as normal; do not retry early. Run this route first/async while other routes proceed. Requires Biopython; no key.
- Reading it: inspect
data.alignments[*].hit_defand besthsps[*].expect/identities. Recurring family names in the hit list ARE the clue. Control: all 10 SwissProt hits were exendin/glucagon/GLP-1/GLP-2/pro-glucagon → glucagon/GLP/secretin ligand family. - Confirm family membership by explicit alignment against hand-picked candidate ligands:
python3 -m tooluniverse.cli run EBI_msa_align '{"sequences":">unknown\n<SEQ>\n>cand1\n<GLP1_SEQ>\n>cand2\n<GLUCAGON_SEQ>","method":"clustalo","sequence_type":"protein"}'Needs ≥2 sequences. Fast (EMBL-EBI). Returns aligned_fasta + guide_tree_newick. Read off the conserved receptor-binding motif (control: shared N-terminal HxxGTFTSD core).
- Origin screen (rule out antimicrobial-peptide origin):
python3 -m tooluniverse.cli run AMPSphere_sequence_match '{"query":"<SEQUENCE>"}'Exact-membership check. {"matched":false,"result":null} = clean no-match (control). A hit suggests AMP origin rather than a hormone/receptor-ligand. matched:false is informative, not a failure.
- NOTE: homology surfaces the ligand family; mapping ligand-family → receptor genes is a downstream knowledge/lookup step (do it via Route 2C).
Route 2B — Motif → binding domain (from Phase 1b/1c)
Carry the PROSITE family / ELM Pfam-domain hits from Phase 1 into Route 2C: a named ligand family or Pfam binding domain becomes the seed for receptor-family enumeration.
Route 2C — Target-family enumeration + known pharmacology (keyless)
From ONE seed target (from a homolog, a known-drug class, or the motif hit), enumerate the full candidate panel (seed + close paralogs) using independent resources that must agree. The general backbone is HGNC gene-family + InterPro (work for any target class — kinases, channels, proteases, GPCRs alike); GPCRdb is an extra cross-check that applies only when the target is a GPCR.
General family enumeration (any target class) — HGNC + InterPro:
python3 -m tooluniverse.cli run HGNC_fetch_gene_by_symbol '{"symbol":"<SEED>"}' # -> gene_group_id[], uniprot_ids[]
python3 -m tooluniverse.cli run HGNC_fetch_gene_family_members '{"gene_group_id":"<ID>"}' # STRING id; the whole family
# InterPro: general route that does NOT depend on GPCRdb (e.g. the seed is a kinase/channel/protease)
python3 -m tooluniverse.cli run InterPro_get_entries_for_protein '{"accession":"<SEED_UNIPROT>"}' # -> entries[]; take FAMILY-type (type=="family") IPR ids
python3 -m tooluniverse.cli run InterPro_get_proteins_by_domain '{"domain_id":"<IPRxxxxxx>","page_size":50,"reviewed_only":true}' # -> proteins[] (accession + tax_id; MIXED organisms)
# proteins[] carry UniProt accessions, NOT gene symbols, and mix species -> filter tax_id=="9606", then batch-map:
python3 -m tooluniverse.cli run UniProt_search '{"query":"accession:Q00975 OR accession:O00555","limit":60}' # -> results[].gene_names per accession- HGNC gene_group works for any family — its own docs example is non-GPCR (
gene_group_id '366'= the 56 'Ubiquitin specific peptidases'). This is why the script's family enumeration is not GPCR-locked. Gotcha: a gene can sit in several HGNC groups, including a domain supergroup (e.g. CACNA1B is in both 'Calcium voltage-gated channel alpha1 subunits' (10) and 'EF-hand domain containing' (~200)). The script skips any group with > ~80 members so the supergroup does not flood the panel — enumerate the bounded target family, not the domain supergroup. - InterPro is the second general cross-check (live-verified): take the seed's FAMILY-type entries (
type=="family"; skipdomain/homologous_superfamily— those explode), list each family's members viaInterPro_get_proteins_by_domain, keep only human (`tax_id=="9606"`) because InterPro mixes organisms, and batch-map the accessions → gene symbols with oneUniProt_search "accession:A OR accession:B …"query (proteins have no gene field). Bounded (≤60). When the seed is in no curated HGNC group, InterPro supplies the panel. Control:Q00975(CACNA1B/Cav2.2) →{CACNA1A…CACNA1S}calcium-channel family, with each member cross-checked HGNC+InterPro.
Known pharmacology (GtoPdb — general: GPCRs, ion channels, enzymes, transporters):
python3 -m tooluniverse.cli run GtoPdb_search_ligands '{"query":"exenatide"}' # -> ligandId 1135, name 'exendin-4'
python3 -m tooluniverse.cli run GtoPdb_search_targets '{"query":"glucagon"}' # USE A SINGLE KEYWORD
python3 -m tooluniverse.cli run GtoPdb_get_interactions '{"gene_symbol":"GLP1R"}' # query by TARGET, not ligandId- Gotchas: multi-word phrases (
"glucagon-like peptide") return count=0 — use a single keyword ("glucagon","secretin").GtoPdb_get_interactionsindexed by target:{"ligandId":1135}returns EMPTY; always passgene_symbol.
Family slug from GPCRdb (GPCR targets ONLY — skip for channels/proteases/enzymes):
python3 -m tooluniverse.cli run GPCRdb_get_protein '{"protein":"GLP1R"}' # bare gene symbol auto-resolves -> family slug, e.g. 002_001_003_003
python3 -m tooluniverse.cli run GPCRdb_list_proteins '{"family":"002_001_003"}' # tight subfamily panel
python3 -m tooluniverse.cli run GPCRdb_list_proteins '{"family":"002_001"}' # broader class B1 (adds CALCR/CRF/PTH/PAC1/VPAC)
python3 -m tooluniverse.cli run GPCRdb_get_ligands '{"protein":"GLP1R","max_results":8}' # annotated ligands w/ activities, SMILES, source- The slug is hierarchical:
002=class B1/Secretin,002_001=class B1 receptors,002_001_003=glucagon-receptor subfamily,..._003=GLP1R itself. Trim the slug one level for the broader class; trim to subfamily for the tight panel. - Gotchas: do NOT pass
"Class B1 (Secretin)"(not a recognized key → 0); do NOT add"operation":"list"(breaks it); calling with NO args returns 1002 entries all withfamily=None(cannot slug-filter that dump). Valid human-readable keys forprotein_class:"class b1","class b","secretin"(all → slug002). - Control:
002_001_003→ exactly {GHRHR, GIPR, GLP1R, GLP2R, glucagon receptor, SCTR}.
Cross-check via gene-family graph (HGNC):
python3 -m tooluniverse.cli run HGNC_fetch_gene_by_symbol '{"symbol":"GLP1R"}' # -> gene_group_id:[269] 'Glucagon receptor family'
python3 -m tooluniverse.cli run HGNC_fetch_gene_family_members '{"gene_group_id":"269"}' # -> {GCGR, GHRHR, GIPR, GLP1R, GLP2R, SCTR}- Gotcha:
gene_group_idMUST be a STRING ("269"); passing int269raises ToolValidationError despite the description saying it accepts an integer. - The two panels (GPCRdb slug ∩ HGNC group) should agree one-to-one. Disagreement = re-check the seed.
Route 2D — Phenotype anchor (OpenTargets; keyless)
Build a phenotype-relevant human target set to intersect with the sequence/structure candidates.
python3 -m tooluniverse.cli run OpenTargets_get_disease_id_description_by_name '{"diseaseName":"type 2 diabetes mellitus"}' # -> hits[0].id = MONDO_0005148
python3 -m tooluniverse.cli run OpenTargets_get_associated_targets_by_disease_efoId '{"efoId":"MONDO_0005148"}' # -> rows[]: target.approvedSymbol, target.id (Ensembl), score- Pick the DISEASE node, not a downstream symptom. Control:
"type 2 diabetes mellitus"→ MONDO_0005148 → top-25 associated targets contain GLP1R (0.767) AND GIPR (recovers the true target family). By contrast"insulin resistance"→ HP_0000855, whose target set does NOT contain the secretin-family receptors (correct biology — insulin resistance is downstream of incretin signaling). Lesson: anchor on the disease the peptide's pathway treats, not the symptom. - Each row has
target.approvedSymbol,target.id(Ensembl),score. Intersect `approvedSymbol`s with the Route-2A/2C candidate list. Overlap = phenotype-supported, sequence-plausible target = top candidates. - For an unknown peptide, run for each plausible disease/tissue phenotype implicated by the bioassay readout or source organism's physiology, and take the union.
---
Phase 3 — Cross-species reconciliation (keyless)
This is where "binds in species A but not B" gets resolved. Pull the candidate receptor's paralog and ortholog sets, then compare the ligand-binding interface across the assay species.
Automated:deorphanize_peptide.pydoes the interface comparison for the top ≤3 candidates — it resolves the human + assay-species (+ optional--source-species) ortholog sequences via UniProt and aligns them withEBI_msa_align, printing per-pair % identity and substitution counts (x-species GIPR: human_vs_assay 92.1% id (34 subs)). The manual calls below are the reference for running, extending, or debugging that step, and for the paralog disambiguation (which the script leaves to the HGNC family panel).
Paralogs (disambiguate which family member is the real target):
python3 -m tooluniverse.cli run EnsemblCompara_get_paralogues '{"gene":"GLP1R","species":"homo_sapiens"}'- Returns
data[]of {source_gene, paralogue_gene (ENSG), paralogue_protein (ENSP), paralogy_type, taxonomy_level}. Output is Ensembl IDs only — map ENSG→symbol downstream (usetooluniverse:translate-idor an HGNC/Ensembl resolver). - Control: recovered GCGR/GLP2R/SCTR; GIPR was a deeper paralog not in the immediate list but recovered by the Route-2D phenotype anchor → use the UNION of paralog + phenotype evidence.
Orthologs (the core of "binds in A not B"):
python3 -m tooluniverse.cli run EnsemblCompara_get_orthologues '{"gene":"GLP1R","species":"homo_sapiens"}'
# optionally restrict: add "target_species":"mus_musculus"- Returns {target_species, target_gene, target_protein (ENSP), homology_type, taxonomy_level, method}. Control: 133 orthologs / 122 species; mouse + rat are
ortholog_one2one. - Pull the human vs assay-species `target_protein` (ENSP) and align them at the peptide-binding interface (feed both sequences to
EBI_msa_align). Interface substitutions explain species-specific binding — this is the mechanistic answer to "active in human assay, not mouse."
Confidence cross-check (Alliance):
python3 -m tooluniverse.cli run Alliance_get_gene_orthologs '{"gene_id":"HGNC:4324","stringency":"all","limit":50}'- `gene_id` MUST be a prefixed Alliance ID (
HGNC:4324= GLP1R), not a bare symbol — resolve the symbol to HGNC first (HGNC_fetch_gene_by_symbol). - Adds per-ortholog
methods[]count (confidence),stringencytier,has_disease_annotations/has_expression_annotationsflags. Control: mouse Glp1r (MGI:99571) + rat Glp1r (RGD:2703), each via 10 methods. Usestringency:"stringent"(default) to trim;"all"for distant homologs.
Gotcha: EnsemblCompara_get_gene_tree returned an empty tree (members=[], tree_id=null) for GLP1R despite status success — do not rely on gene_tree for clade grouping; use get_paralogues + the phenotype anchor instead.
---
Phase 4 — Narrowing & ranking
Reduce the union candidate set to a ranked shortlist (target ≤15 before any co-folding). Score each candidate on:
1. Sequence/structure plausibility — appears in BLAST hits / PROSITE family / ELM Pfam-domain map / GPCRdb-HGNC family panel. (Strongest: present in ≥2 of these.) 2. Phenotype plausibility — appears in the OpenTargets associated-target set for the relevant disease (Route 2D). Record the score. 3. Known pharmacology cross-check — does GtoPdb already list peptide ligands for this receptor (GtoPdb_get_interactions {"gene_symbol":...})? Existing peptide ligands of the same family raise prior plausibility. 4. Cross-species consistency — is the receptor conserved (clean ortholog_one2one) in the assay species, and does the interface match? A receptor that is absent or interface-diverged in the assay species explains a negative binding result and stays on the list rather than being dropped. 5. Target-engagement context (soluble candidates only) — ProteomicsDB_get_protein_meltome {"gene_symbol":...} returns the candidate's thermal proteome profiling (TPP) melting curves. Presence in the meltome means it is a soluble protein with a measurable Tm; if the lab can run CETSA/TPP, a peptide-induced Tm shift directly confirms target engagement (the strongest experimental cross-check). NOTE: membrane GPCRs are absent from the meltome (returns 0 curves) — this signal applies to soluble candidates (kinases, enzymes), not the class-B GPCR panel.
Ranking rule: sort by (sequence/structure plausibility tiers) then (phenotype score), then by how many independent family resources agree (HGNC + InterPro + GPCRdb), and explicitly promote paralogs / phenotype-shared receptors when the hypothesized target tested negative. The non-binding result against the hypothesized target is itself evidence that re-ranks the panel.
Multi-class note (live-verified across GPCR, ion-channel, RTK, cytokine-receptor and protease seeds): some HGNC groups are loose/broad — a protease seed (MMP9, CTSK) can pull a 35–46-gene panel spanning several protease sub-families, whereas an RTK (EGFR → {EGFR, ERBB2/3/4}) or channel (CACNA1B → the 10 CACNA1x) gives a tight one. For the loose cases the HGNC∩InterPro intersection is the high-confidence tight core (e.g. the 11 matrix metallopeptidases within MMP9's 46-gene panel), so a member corroborated by both resources outranks an HGNC-only loose-group member. The supergroup cap (skip HGNC groups >80, e.g. 'CD molecules' 394, 'Ig-like domain containing' 101, 'EF-hand' ~200) keeps these panels from exploding while preserving legitimate large families (interleukin receptors ~41).
Keep the shortlist to ≤15 (ideally ≤8) before Phase 5 — co-folding is slow and key-gated.
---
Phase 5 — STRUCTURAL CONFIRMATION via co-folding (OPTIONAL, key-gated)
This phase is the only key-gated step and is OPTIONAL. Everything above already produces a defensible ranked shortlist. Use co-folding to confirm/rank the top candidates structurally — never as a gate on producing a report. Requires NVIDIA_API_KEY. Narrow to ≤15 (ideally ≤8) candidates first — each co-fold is slow.For each shortlisted receptor, co-fold the peptide with the receptor's ectodomain/full sequence and rank by interface confidence (ipTM / interface pLDDT):
NvidiaNIM_boltz2— peptide–protein complex prediction (preferred for ranking).NvidiaNIM_alphafold2_multimer— classic multimer co-fold; rank by interface ipTM.NvidiaNIM_openfold3— alternative co-fold backend.
Inputs are the peptide sequence + each candidate receptor protein sequence (pull receptor sequences from GPCRdb_get_protein sequence field, or the ortholog target_protein ENSP for the assay species). Load the exact schema with ToolSearch before calling (select:NvidiaNIM_boltz2, etc.) — these are deferred/key-gated and will error if called without their schema.
Interpretation: the candidate with the highest interface ipTM / interface pLDDT at the peptide-binding pocket is the top structurally-supported target. Run the co-fold for both the human and assay-species ortholog of the leading candidate to confirm the cross-species binding difference structurally (a drop in interface ipTM for the mouse ortholog mechanistically explains "binds human, not mouse").
If NVIDIA_API_KEY is unset, skip Phase 5 and report the shortlist with its Phase 1–4 evidence, clearly flagging that structural confirmation was not performed.
Academic-free alternative (no NVIDIA key): ClusPro peptide docking
If you have a free academic ClusPro account (set CLUSPRO_USERNAME + CLUSPRO_API_SECRET) and the candidate receptor has a solved PDB structure, submit a native peptide–protein docking job instead of co-folding:
ClusPro_submit_peptide_docking {"receptor_pdb_id": "<4-letter PDB>", "peptide_sequence": "<SEQ>", "peptide_motif": "<motif, optional>"}
- Which PDB?
deorphanize_peptide.pyalready prints aClusPro-ready PDB for <GENE>: <PDBID>line for each top candidate (resolved keyless viaPDBeSIFTS_get_best_structuresfrom the candidate's UniProt accession). Use that id directly, or runPDBeSIFTS_get_best_structures {"uniprot_accession":"<acc>"}yourself to pick a higher-coverage/resolution entry.
It returns a ClusPro job id — docking is asynchronous, so retrieve clustered poses + scores from your ClusPro results page later (hours). Best for short peptides (≤~30 residues) against a receptor with a solved structure. Use this as the academic-free structural path when no NVIDIA key is available; the co-folding backends above remain preferred for direct interface-ipTM ranking and for receptors that have only a sequence (no PDB). For a cyclic/non-ribosomal peptide, prefer cofold_screen.py --backend boltz2 --cyclic (ClusPro peptide mode assumes a linear peptide).
---
Phase 6 — Ranked shortlist report (evidence tiers + wet-lab recommendation)
Produce a report (no extra files — return it inline) with:
1. Peptide characterization summary — length, MW, pI, GRAVY, instability, PROSITE/ELM signature(s), non-canonical/cyclic flag, fold confidence (if run). 2. Ranked candidate target table, one row per receptor, columns:
- Gene symbol + accession (UniProt / Ensembl / HGNC).
- Evidence tier (see below).
- Routes that surfaced it (BLAST family / PROSITE / GPCRdb+HGNC family / OpenTargets phenotype / paralog).
- OpenTargets phenotype score (if any).
- Known-pharmacology note (GtoPdb peptide ligands present?).
- Cross-species status (conserved one2one? interface % identity in assay species?).
3. Cross-species reconciliation note — for the lead candidate, the human-vs-assay-species (-vs-source-species) interface comparison and what it predicts for the observed "binds A not B." 4. Recommended wet-lab validation — e.g. binding/competition assay against the top ≤3 candidates (and their assay-species orthologs), radioligand-displacement using the known family antagonist (GtoPdb-listed), or cAMP/β-arrestin functional assay for class-B GPCR candidates.
Evidence tiers:
- Tier 1 (strong): surfaced by ≥2 independent sequence/structure routes AND present in the phenotype anchor AND (if Phase 5 run) top interface ipTM.
- Tier 2 (moderate): surfaced by 1 sequence/structure route AND phenotype-supported, OR by ≥2 sequence routes without phenotype support.
- Tier 3 (weak / hypothesis): single-route only (e.g. a deep paralog or a phenotype-only hit not corroborated by sequence/structure).
Always state which evidence is keyless/validated vs key-gated (co-fold not run), and flag any candidate that is a negative against the originally hypothesized target so the reader understands the deorphanization re-ranking.
---
Fallback Chains
- Physicochemical:
PepCalc_peptide_properties⇄ProtParam_calculate(run both; if one fails, the other still gives pI/MW). ProtParam additionally gives instability/GRAVY/charge. - Signature/family ID:
ScanProsite_scan_protein→PROSITE_get_entry. If no PROSITE hit, fall to ELM:ELM_list_classes(regex match) →ELM_get_interaction_domains(→ Pfam binding domain). - Homology:
BLAST_protein_search(swissprot) → if down/too slow,EBI_msa_alignagainst a hand-picked candidate-ligand panel (lightweight family-membership test).AMPSphere_sequence_matchis only an exact-match AMP screen, not a homology search. - Receptor-family enumeration:
GPCRdb_get_protein→GPCRdb_list_proteins(slug) andHGNC_fetch_gene_by_symbol→HGNC_fetch_gene_family_members(group id, STRING). Use both; intersect.GtoPdb_search_targets/GtoPdb_get_interactionsadd known-pharmacology grounding. - Phenotype anchor:
OpenTargets_get_disease_id_description_by_name→OpenTargets_get_associated_targets_by_disease_efoId. DisGeNET (DisGeNET_get_disease_genes,DisGeNET_get_gda) is KEY-GATED (DISGENET_API_KEY) — skip unless configured.CTD_get_gene_diseasesis structurally unavailable (RENCI CTD mirror has no gene→disease edges) — do not use for the reverse anchor. - Cross-species:
EnsemblCompara_get_orthologues/get_paraloguesandAlliance_get_gene_orthologs(prefixed gene_id). Do not useEnsemblCompara_get_gene_tree(returns empty tree for GLP1R). - ID mapping (ENSG→symbol): EnsemblCompara/OpenTargets return Ensembl IDs; resolve with
tooluniverse:translate-idbefore intersecting with symbol-based panels. - Ortholog sequences (for the interface alignment):
UniProt_search {"query":"gene:<SYM>","organism":"<common name>"}→UniProt_get_sequence_by_accession. The script does this automatically; the GPCRdb entry-name path is unreliable across species (its suffixes are common names like_mouse, not themus_musculustoken). Gotcha: UniProt_searchorganismtakes a common name ("human","mouse"), NOT a taxid —"9606"errors. When UniProt is transiently down (RemoteDisconnected), seedless derivation and the InterPro accession→symbol map yield nothing; the run degrades to HGNC family + phenotype (still functional). - Structure (optional): keyless
ESMFold_predict_structurefor the monomer; key-gatedNvidiaNIM_boltz2/NvidiaNIM_alphafold2_multimer/NvidiaNIM_openfold3for the complex co-fold confirmation, or academic-freeClusPro_submit_peptide_docking(needs a solved PDB;PDBeSIFTS_get_best_structuresresolves one). If no key at all, report without Phase 5.
---
NVIDIA NIM / runtime notes
- Co-folding is key-gated AND slow.
NvidiaNIM_boltz2,NvidiaNIM_alphafold2_multimer,NvidiaNIM_openfold3,NvidiaNIM_esmfold,NvidiaNIM_esm2_650mall requireNVIDIA_API_KEY. Configure viatooluniverse:setup-keysif you want Phase 5. - Co-fold argument shapes (the script builds these — documented here for manual calls): boltz2 →
{"polymers":[{"molecule_type":"protein","sequence":<pep>},{...receptor}]}(add"cyclic":trueto the peptide polymer for a cyclic peptide); alphafold2_multimer →{"sequences":[<pep>,<receptor>]}; openfold3 →{"inputs":[{"input_id":"complex","molecules":[{"type":"protein","sequence":<pep>},{"type":"protein","sequence":<receptor>}]}]}(ONE input, both chains inmolecules— two separate inputs would be two monomer predictions, not a co-fold). - Narrow to ≤15 candidates (ideally ≤8) before any co-fold. Each peptide–receptor co-fold is minutes-scale; co-folding a 25-receptor panel is wasteful. Phases 1–4 exist precisely to shrink the panel first.
- BLAST is keyless but variable-runtime (62 s–181 s observed on the control; documented up to 30 min on
nr). Run it first/async; never abort before ~8 min. - ESMFold (keyless) ~30–60 s for a ~40-mer; fine to run inline. Use it as cheap monomer evidence, not as a complex predictor.
- Schemas for the NIM/co-fold tools are deferred — call
ToolSearchwithselect:NvidiaNIM_boltz2(etc.) to load the schema before invoking, or they error with InputValidationError.
---
Worked example A — exendin-4 → GLP1R (control; recovers the class-B family)
Input: HGEGTFTSDLSKQMEEEAVRLFIEWLKNGGPSSGAPPPS (exendin-4, 39 aa, Heloderma venom). Hypothesized/known target: GLP1R.
1. Characterization (Phase 1): PepCalc + ProtParam → pI ~4.4 (agree), MW ~4.19 kDa, stable (instability 12.9), GRAVY −0.69. ESMFold mean pLDDT 0.85 (confident 39-mer). 2. Motif (Phase 1b): ScanProsite_scan_protein → single hit PS00260 (res 1–23); PROSITE_get_entry(PS00260) → "Glucagon / GIP / secretin / VIP family signature." → class B1 GPCR ligand family. 3. Homology (Route 2A): BLAST_protein_search (swissprot) → all 10 hits exendin/glucagon/GLP-1/GLP-2/pro-glucagon (self-hit Exendin-4 E=3.5e-22, 39/39). EBI_msa_align vs human GLP-1 + glucagon → shared N-terminal HxxGTFTSD core. AMPSphere_sequence_match → clean no-match (not an AMP). 4. Receptor family (Route 2C): GtoPdb glucagon→{GLP-1R, GLP-2R, glucagon R}, secretin→SCTR; exenatide→ligand 1135 'exendin-4'. GPCRdb GLP1R→slug 002_001_003_003; 002_001_003→{GHRHR, GIPR, GLP1R, GLP2R, glucagon R, SCTR}. HGNC GLP1R→group 269 'Glucagon receptor family'→{GCGR, GHRHR, GIPR, GLP1R, GLP2R, SCTR}. GPCRdb and HGNC panels agree. 5. Phenotype (Route 2D): type 2 diabetes mellitus→MONDO_0005148→associated targets include GLP1R (0.767) and GIPR. (insulin resistance→HP_0000855 does NOT contain the family — symptom node, correct biology.) 6. Cross-species (Phase 3): EnsemblCompara paralogues recover GCGR/GLP2R/SCTR (GIPR via phenotype); orthologues → 133/122 species, mouse+rat ortholog_one2one; Alliance confirms mouse/rat Glp1r each via 10 methods. 7. Result: intersection of all routes = GLP1R + GCGR/GIPR/GLP2R/SCTR (class B1 / secretin family). The true target GLP1R is Tier 1 (≥2 sequence routes + PROSITE family + phenotype score 0.767). Optional Phase 5 co-fold would rank GLP1R top by interface ipTM. The correct receptor family is recovered with zero target knowledge and (except optional co-fold) zero API keys.
---
Worked example B — anti-insulin-resistance peptide that does NOT bind GLP1R in mouse
Scenario (the real user case): A peptide produces an anti-insulin-resistance / metabolic phenotype, was hypothesized to act via GLP1R, but in a mouse binding/functional assay it does not bind GLP1R. Goal: surface the alternative class-B / metabolic receptors that could be the real target — without guessing.
How this skill drives it (no target assumed from the name):
1. Characterize + motif (Phase 1): PepCalc/ProtParam for properties; ScanProsite_scan_protein → resolve every PSxxxxx with PROSITE_get_entry. If it again hits the glucagon/GIP/secretin/VIP signature (PS00260), the peptide is a class-B1 ligand-family member — which says "a secretin-family receptor," not specifically GLP1R. If no PROSITE family, fall to ELM regex → Pfam binding domain. 2. Homology (Route 2A): BLAST swissprot — if the top hits skew toward GIP / glucagon / GLP-2 / secretin rather than GLP-1, that re-weights the candidate panel toward GIPR/GCGR/GLP2R/SCTR. EBI_msa_align against GLP-1 vs GIP vs glucagon shows which family member the peptide's binding motif most resembles. 3. Receptor family (Route 2C): seed from whichever family member homology favored; GPCRdb slug + HGNC group 269 enumerate the full class-B1/glucagon-receptor panel {GLP1R, GCGR, GIPR, GLP2R, SCTR, GHRHR}. All of these are now explicit candidates — the hypothesized GLP1R is just one of them. 4. Phenotype anchor (Route 2D): type 2 diabetes mellitus → MONDO_0005148 → the associated-target set contains GLP1R AND GIPR (and other metabolic targets). Because the phenotype is metabolic, GIPR is strongly phenotype-supported even though the peptide failed against GLP1R. Intersect with the family panel → GIPR (and GCGR) rise as the leading alternative targets. 5. Cross-species reconciliation (Phase 3) — the crux of "doesn't bind in mouse": pull EnsemblCompara_get_orthologues for each surviving candidate (GLP1R, GIPR, GCGR…) restricted to mus_musculus, get the mouse target_protein (ENSP), and EBI_msa_align human vs mouse at the ligand-binding interface (the script automates this and also takes --source-species to add the organism where binding WAS observed, for a 3-way comparison). Two possible mechanistic answers, both produced by tools, not guessed:
- The peptide's real target is a paralog (e.g. GIPR/GCGR) that the GLP1R assay never tested — promoted by phenotype + homology re-weighting.
- OR the target is a family member but the mouse ortholog's interface has diverged from human at the binding pocket (interface substitutions in the alignment), explaining a human-active / mouse-negative result. Confirm with Alliance ortholog confidence (
methods[]).
6. Narrow + (optional) co-fold (Phases 4–5): rank the panel by sequence/structure + phenotype + pharmacology + cross-species consistency. If NVIDIA_API_KEY is set, co-fold the peptide with GLP1R, GIPR, GCGR (human and mouse) via NvidiaNIM_boltz2 and rank by interface ipTM — a high human-GIPR / low mouse-GLP1R interface score would structurally confirm both "real target = GIPR" and "GLP1R interface diverged in mouse." 7. Report (Phase 6): ranked shortlist with the GLP1R negative flagged, GIPR/GCGR promoted to Tier 1–2 with their phenotype scores and cross-species interface notes, and a wet-lab recommendation: binding/competition + cAMP assays against GIPR and GCGR (human + mouse orthologs), using the GtoPdb-listed family antagonists as controls.
Takeaway: the negative GLP1R result is not a dead end — by anchoring on PHENOTYPE × SEQUENCE/STRUCTURE plausibility and reconciling across species, the skill surfaces the paralog / interface-diverged alternatives (GIPR, GCGR, …) as the testable real-target hypotheses, every one of them backed by a recorded tool result rather than a name-level guess.
---
Worked example C — ω-conotoxin → ion channel (NON-GPCR; demonstrates generality)
Input: CKGKGAKCSRLMYDCCTGSCRSGKC (ω-conotoxin MVIIA / ziconotide, 25 aa, Conus magus venom). Phenotype: analgesia / severe chronic pain. The target is an ion channel, not a GPCR — this case exists to show the pipeline is not GPCR-only.
1. Characterize + classify (Phase 1 + 1e): PepCalc/ProtParam → small, basic, 6 cysteines / 25 aa. The target-class router sees the cysteine-rich short peptide (and, if BLAST has run, "omega-conotoxin" in the hit names) → `ion_channel_toxin`, seedless nouns channel, receptor, protease. Crucially it does NOT default to "receptor"-only enumeration. 2. Homology (Route 2A): BLAST_protein_search (swissprot) → hits are conotoxins / channel-blocking toxins; AMPSphere_sequence_match → not an AMP. 3. Target-family enumeration (Route 2C, GENERAL path — no GPCRdb):
- Seedless derives keywords (
omega,conotoxin,calcium) × nouns (channel) →UniProt_search "calcium channel"→ seeds CACNA1B (Cav2.2, the real target) and its relatives. HGNC_fetch_gene_by_symbol CACNA1B→ gene group "Calcium voltage-gated channel alpha1 subunits" →HGNC_fetch_gene_family_members→ {CACNA1A, CACNA1B, CACNA1C, CACNA1D, CACNA1E, …}. GPCRdb returns nothing (correct — not a GPCR); InterPro cross-checks the same calcium-channel family. The "two general resources agree" principle holds with HGNC + InterPro instead of HGNC + GPCRdb.
4. Phenotype anchor (Route 2D): neuropathic pain / chronic pain → OpenTargets associated targets include CACNA1B (and CACNA2D1, the gabapentinoid target) → intersect with the channel family → CACNA1B is phenotype-supported. 5. Cross-species (Phase 3): ortholog interface alignment of CACNA1B across human/assay species — conotoxin selectivity is famously species- and subtype-specific, so interface divergence is the expected lever for any "binds in A not B". 6. Narrow + report (Phases 4–6): ranked shortlist led by CACNA1B (N-type Cav2.2), Tier 1 (channel family + pain phenotype), with electrophysiology (not cAMP) as the class-appropriate validation assay.
Takeaway: with the target-class router selecting channel/HGNC/InterPro enumeration instead of GPCRdb, the same pipeline recovers a non-GPCR ion-channel target — confirming the skill covers the broad class "peptide → any protein target", not only GPCR ligands.
#!/usr/bin/env python3
"""Phase 5 — batch peptide:receptor co-folding screen (structural confirmation).
Takes a peptide sequence and a shortlist of candidate receptor gene symbols
(e.g. the Tier-1/2 output of ``deorphanize_peptide.py``), fetches each receptor's
sequence, and co-folds the peptide with each receptor via NVIDIA NIM, ranking by
interface confidence (ipTM / interface pLDDT). The highest-interface candidate is
the top structurally-supported target.
Requires ``NVIDIA_API_KEY`` for the actual co-fold. Without it, the script runs a
DRY RUN: it still resolves every receptor sequence and prints the co-fold plan, so
you can verify inputs before paying for GPU time. Keep the shortlist small (<=8) —
each co-fold is minutes-scale.
Examples:
# dry run (no key needed) — verifies inputs + prints the plan
python3 cofold_screen.py --peptide HGEGTF...PPPS --candidates GIPR GCGR GLP2R
# real screen (NVIDIA_API_KEY set), also co-fold the mouse ortholog of the lead
python3 cofold_screen.py --peptide HGEGTF...PPPS --candidates GIPR GCGR \
--backend boltz2 --assay-species mus_musculus
"""
import argparse
import json
import os
import sys
from typing import Any, Dict, List, Optional
def _load_tu():
try:
from tooluniverse import ToolUniverse
except ImportError:
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "src"))
from tooluniverse import ToolUniverse
tu = ToolUniverse()
tu.load_tools()
return tu
_BACKENDS = {
"boltz2": "NvidiaNIM_boltz2",
"alphafold2_multimer": "NvidiaNIM_alphafold2_multimer",
"openfold3": "NvidiaNIM_openfold3",
}
# UniProt organism filter accepts common names; map the usual species tokens.
_ORGANISM_COMMON = {
"homo_sapiens": "human", "mus_musculus": "mouse", "rattus_norvegicus": "rat",
"danio_rerio": "zebrafish",
}
class CoFolder:
def __init__(self, tu):
self.tu = tu
def run(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
try:
out = self.tu.run({"name": name, "arguments": args})
except Exception as exc:
return {"status": "error", "error": f"{type(exc).__name__}: {exc}"}
return out if isinstance(out, dict) else {"status": "success", "data": out}
@staticmethod
def _data(resp):
return resp.get("data") if isinstance(resp, dict) and resp.get("status") == "success" else None
def _uniprot_accession(self, symbol: str) -> Optional[str]:
g = self._data(self.run("HGNC_fetch_gene_by_symbol", {"symbol": symbol}))
ids = (g or {}).get("uniprot_ids") if isinstance(g, dict) else None
return ids[0] if ids else None
def _sequence_for_accession(self, accession: Optional[str]) -> Optional[str]:
if not accession:
return None
seq = self._data(self.run("UniProt_get_sequence_by_accession", {"accession": accession}))
if isinstance(seq, str):
return seq.strip() or None
if isinstance(seq, dict):
return seq.get("sequence") or seq.get("value")
return None
def receptor_sequence(self, symbol: str) -> Optional[str]:
"""Resolve a receptor's protein sequence (GPCRdb first, UniProt fallback).
GPCRdb keys some receptors under alias entry-names (e.g. glucagon receptor
is 'glr_human', not 'gcgr_human'), so fall back to the HGNC->UniProt
accession->sequence path, which is canonical for any human protein.
"""
gp = self._data(self.run("GPCRdb_get_protein", {"protein": symbol}))
if isinstance(gp, dict) and gp.get("sequence"):
return gp["sequence"]
return self._sequence_for_accession(self._uniprot_accession(symbol))
def ortholog_sequence(self, symbol: str, species: str) -> Optional[str]:
"""Fetch the assay-species ortholog sequence (UniProt gene+organism search).
Canonical for any species; replaces the GPCRdb entry-name guess, whose
species suffixes are common names ('_mouse'), not the 'mus_musculus' token.
"""
organism = _ORGANISM_COMMON.get(species.lower(), species.replace("_", " "))
res = self._data(self.run("UniProt_search", {"query": f"gene:{symbol}", "organism": organism, "limit": 1}))
rows = res.get("results") if isinstance(res, dict) else None
acc = rows[0].get("accession") if rows else None
return self._sequence_for_accession(acc)
@staticmethod
def _interface_score(result: Any) -> Optional[float]:
"""Pull an interface-confidence number out of a co-fold result.
Field names vary by backend/version; try the common ones in priority
order (ipTM is the standard interface metric).
"""
if not isinstance(result, dict):
return None
for path in (("iptm",), ("ipTM",), ("interface_ptm",), ("confidence", "iptm"),
("metrics", "iptm"), ("ranking_confidence",), ("ptm",), ("plddt",)):
node: Any = result
ok = True
for k in path:
if isinstance(node, dict) and k in node:
node = node[k]
else:
ok = False
break
if ok and isinstance(node, (int, float)):
return float(node)
return None
def cofold(self, backend_tool: str, peptide: str, receptor: str, cyclic: bool = False) -> Dict[str, Any]:
if backend_tool == "NvidiaNIM_boltz2":
pep: Dict[str, Any] = {"id": "A", "molecule_type": "protein", "sequence": peptide}
if cyclic:
pep["cyclic"] = True # boltz2 natively supports head-to-tail cyclic peptides
args = {"polymers": [pep, {"id": "B", "molecule_type": "protein", "sequence": receptor}]}
elif backend_tool == "NvidiaNIM_alphafold2_multimer":
args = {"sequences": [peptide, receptor]} # array of chains -> one complex
else: # openfold3: ONE input whose `molecules` array holds both chains (a co-fold)
args = {"inputs": [{"input_id": "complex", "molecules": [
{"type": "protein", "sequence": peptide},
{"type": "protein", "sequence": receptor},
]}]}
return self.run(backend_tool, args)
def main() -> int:
ap = argparse.ArgumentParser(description="Phase 5 batch peptide:receptor co-folding screen.")
ap.add_argument("--peptide", required=True, help="Peptide sequence.")
ap.add_argument("--candidates", nargs="+", required=True, help="Candidate receptor gene symbols (keep <=8).")
ap.add_argument("--backend", choices=list(_BACKENDS), default="boltz2")
ap.add_argument("--assay-species", default=None, help="If set, also co-fold the lead candidate's ortholog in this species (e.g. mus_musculus) to test cross-species binding.")
ap.add_argument("--cyclic", action="store_true", help="Treat the peptide as head-to-tail cyclic (boltz2 backend only).")
ap.add_argument("--out", default=None, help="Optional JSON output path.")
args = ap.parse_args()
has_key = bool(os.environ.get("NVIDIA_API_KEY"))
backend_tool = _BACKENDS[args.backend]
cf = CoFolder(_load_tu())
peptide = args.peptide.strip().upper()
print("\n" + "=" * 72)
print(f"CO-FOLD SCREEN | backend={args.backend} ({backend_tool}) | key={'set' if has_key else 'MISSING -> DRY RUN'}")
print("=" * 72)
plan: List[Dict[str, Any]] = []
for sym in args.candidates:
seq = cf.receptor_sequence(sym)
plan.append({"candidate": sym, "receptor_len": len(seq) if seq else None, "resolved": bool(seq), "_seq": seq})
print(f" {sym:<8} receptor sequence: {'resolved (%d aa)' % len(seq) if seq else 'NOT FOUND (skip)'}")
if not has_key:
print("-" * 72)
print("DRY RUN: NVIDIA_API_KEY not set. Inputs above are verified; no co-fold run.")
print("Set NVIDIA_API_KEY (free at build.nvidia.com; or `tooluniverse:setup-keys`) to score.")
print(f"Would co-fold the peptide ({len(peptide)} aa) against {sum(1 for p in plan if p['resolved'])} resolved receptor(s).")
if args.out:
json.dump({"peptide": peptide, "plan": [{k: v for k, v in p.items() if k != '_seq'} for p in plan]}, open(args.out, "w"), indent=2)
return 0
print("-" * 72)
results: List[Dict[str, Any]] = []
for p in plan:
if not p["resolved"]:
continue
sym = p["candidate"]
print(f" co-folding peptide : {sym} ...", flush=True)
resp = cf.cofold(backend_tool, peptide, p["_seq"], cyclic=args.cyclic)
score = cf._interface_score(cf._data(resp))
results.append({"candidate": sym, "interface_score": score, "status": resp.get("status"),
"error": resp.get("error")})
print(f" -> interface score: {score if score is not None else 'unparsed (see raw)'} [{resp.get('status')}]")
results.sort(key=lambda r: (r["interface_score"] is None, -(r["interface_score"] or 0)))
# optional cross-species check on the lead
cross = None
if args.assay_species and results and results[0]["interface_score"] is not None:
lead = results[0]["candidate"]
oseq = cf.ortholog_sequence(lead, args.assay_species)
if oseq:
print(f" cross-species: co-folding peptide : {lead} ({args.assay_species}) ...", flush=True)
r = cf.cofold(backend_tool, peptide, oseq, cyclic=args.cyclic)
cross = {"candidate": lead, "species": args.assay_species, "interface_score": cf._interface_score(cf._data(r))}
print(f" -> {args.assay_species} interface score: {cross['interface_score']}")
print("=" * 72)
print("RANKED BY INTERFACE CONFIDENCE (higher = stronger predicted binding):")
for i, r in enumerate(results, 1):
print(f" {i}. {r['candidate']:<8} {r['interface_score']}")
if cross:
print(f"\nCross-species: {cross['candidate']} human vs {cross['species']} interface — "
f"a drop in the ortholog score mechanistically explains a species-specific negative.")
if args.out:
json.dump({"peptide": peptide, "backend": backend_tool, "results": results, "cross_species": cross},
open(args.out, "w"), indent=2)
print(f"\nFull JSON -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Keyless peptide target-deorphanization pipeline (Phases 1-4).
Given a peptide sequence (and, ideally, the hypothesized target gene that the
peptide does NOT actually bind, plus the phenotype it produces), enumerate and
rank the candidate real protein targets using only keyless ToolUniverse tools:
characterization -> motif/signature (PROSITE + ELM regex) -> homology
-> receptor-family panel (seeded, or SEEDLESS) -> phenotype anchor
-> protease/degradation liability -> cross-species -> ranked shortlist
No API key is required. The optional structural confirmation step (co-folding)
lives in ``cofold_screen.py`` and needs NVIDIA_API_KEY.
Single peptide (the exendin-4 -> GLP1R control):
python3 deorphanize_peptide.py \
--sequence HGEGTFTSDLSKQMEEEAVRLFIEWLKNGGPSSGAPPPS \
--hypothesized-target GLP1R \
--phenotype "type 2 diabetes mellitus" --assay-species mus_musculus
Seedless (no hypothesized target -> derive candidate receptors from the motif):
python3 deorphanize_peptide.py --sequence <SEQ> --phenotype "type 2 diabetes mellitus"
Batch (one record per FASTA entry; shared --phenotype/--assay-species):
python3 deorphanize_peptide.py --fasta peptides.fasta --phenotype "type 2 diabetes mellitus"
"""
import argparse
import json
import re
import sys
from typing import Any, Dict, List, Optional
def _load_tu():
"""Load ToolUniverse, tolerating both installed-package and in-repo runs."""
try:
from tooluniverse import ToolUniverse
except ImportError:
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "src"))
from tooluniverse import ToolUniverse
tu = ToolUniverse()
tu.load_tools()
return tu
def _as_list(node: Any, *keys: str) -> List[Any]:
"""Return a list from ``node`` (already a list, or under one of ``keys``)."""
if isinstance(node, list):
return node
if isinstance(node, dict):
for k in keys:
if isinstance(node.get(k), list):
return node[k]
return []
# UniProt accepts organism common names; map the usual assay/source species.
_ORGANISM_COMMON = {
"homo_sapiens": "human",
"mus_musculus": "mouse",
"rattus_norvegicus": "rat",
"danio_rerio": "zebrafish",
"drosophila_melanogaster": "fruit fly",
"caenorhabditis_elegans": "Caenorhabditis elegans",
"saccharomyces_cerevisiae": "yeast",
}
_CANONICAL_AA = set("ACDEFGHIKLMNPQRSTVWY")
def _organism_query(species: str) -> str:
"""Species token -> a UniProt organism filter (common name when known)."""
return _ORGANISM_COMMON.get(species.lower(), species.replace("_", " "))
def _noncanonical(seq: str) -> Dict[str, Any]:
"""Flag residues outside the 20 standard AAs (BLAST/PROSITE assume canonical)."""
extra = sorted({c for c in seq.upper() if c not in _CANONICAL_AA})
return {
"noncanonical_residues": extra,
"is_canonical_linear": not extra,
"note": (
"non-standard residues present -> BLAST/PROSITE/ProtParam assume canonical "
"linear L-amino acids and may mischaracterize this peptide. If it is a "
"non-ribosomal/cyclic peptide, look it up by name with Norine_get_peptide "
"and pass --cyclic to cofold_screen.py."
if extra
else "all residues are standard L-amino acids."
),
}
def _pairwise_identity(aligned: Dict[str, str], a: str, b: str) -> Optional[Dict[str, Any]]:
"""Percent identity of two aligned sequences over non-gap shared columns."""
sa, sb = aligned.get(a), aligned.get(b)
if not sa or not sb or len(sa) != len(sb):
return None
cols = [(x, y) for x, y in zip(sa, sb) if x != "-" and y != "-"]
if not cols:
return None
same = sum(1 for x, y in cols if x.upper() == y.upper())
return {
"percent_identity": round(100.0 * same / len(cols), 1),
"n_substitutions": len(cols) - same,
"aligned_columns": len(cols),
}
def _parse_fasta_str(fasta: str) -> Dict[str, str]:
"""Parse an aligned/plain FASTA string into {name: sequence}."""
out: Dict[str, str] = {}
name: Optional[str] = None
buf: List[str] = []
for line in (fasta or "").splitlines():
if line.startswith(">"):
if name:
out[name] = "".join(buf)
name = line[1:].split()[0] if line[1:].split() else line[1:]
buf = []
elif line.strip():
buf.append(line.strip())
if name:
out[name] = "".join(buf)
return out
# Target-class router: a peptide's real target need not be a GPCR. Classifying the
# likely target class lets candidate generation ADAPT its enumeration strategy
# (and pick the right seedless search nouns) instead of assuming "receptor". Each
# entry: (class, keyword triggers in PROSITE/BLAST text, seedless search nouns).
_TARGET_CLASS_RULES = [
("gpcr_ligand",
("glucagon", "secretin", "incretin", "gip", "vip ", "pacap", "tachykinin",
"bombesin", "melanocortin", "somatostatin", "angiotensin", "bradykinin",
"orexin", "gnrh", "opioid", "neuropeptide", "calcitonin", "parathyroid"),
("receptor",)),
("guanylyl_cyclase_ligand",
("natriuretic", "guanylin", "uroguanylin"),
("receptor", "guanylate cyclase")),
("cytokine_or_growth_factor",
("interleukin", "interferon", "chemokine", "tumor necrosis", "growth factor",
"erythropoietin", "leptin", "cytokine"),
("receptor",)),
("rtk_ligand",
("insulin", "epidermal growth factor", "fibroblast growth factor", "ephrin"),
("receptor",)),
("ion_channel_toxin",
("conotoxin", "scorpion toxin", "spider toxin", "sea anemone", "channel blocker",
"potassium channel", "sodium channel", "calcium channel"),
("channel", "receptor")),
("protease_inhibitor_or_substrate",
("protease inhibitor", "serine protease inhibitor", "kunitz", "bowman-birk",
"peptidase inhibitor", "serpin"),
("protease", "peptidase")),
("integrin_ligand",
("integrin", "disintegrin", "fibronectin"),
("integrin",)),
("antimicrobial",
("antimicrobial", "defensin", "cathelicidin", "bacteriocin"),
()), # AMPs often act on the membrane, not a single protein target
]
_UNKNOWN_CLASS_NOUNS = ("receptor", "enzyme", "channel", "transporter")
# An HGNC gene group larger than this is a domain SUPERGROUP (e.g. "EF-hand domain
# containing" ~190 genes), not a ligand-target family — enumerating it floods the
# panel. Real target families (channels, secretin GPCRs, NPRs, protease subfamilies)
# are well under this; bound them like the InterPro route.
_HGNC_GROUP_CAP = 80
# A seed protein can sit in several InterPro FAMILY entries; enumerate only the
# first few to bound the number of InterPro_get_proteins_by_domain calls.
_INTERPRO_MAX_FAMILIES = 2
def _classify_target_class(
signatures: List[Dict[str, str]],
homology_defs: List[str],
seq: str,
amp_matched: bool = False,
) -> Dict[str, Any]:
"""Heuristically classify the peptide's likely target class from its motif/
homology text + sequence features, so candidate generation can branch.
Transparent and evidence-bearing: returns the matched class, the text that
triggered it, and the seedless search nouns to use. Never authoritative — it
only *steers* enumeration; phenotype + homology still drive the candidates.
"""
hay = " ".join(
[(s.get("description") or "") + " " + (s.get("name") or "") for s in signatures]
+ list(homology_defs or [])
).lower()
def _result(cls, nouns, evidence):
return {"target_class": cls, "seedless_nouns": list(nouns), "evidence": evidence}
# Most specific signals first: an explicit RGD motif -> integrins.
if "RGD" in seq.upper():
return _result("integrin_ligand", ("integrin",), ["RGD motif in sequence"])
# Keyword-driven classes from the motif/homology text.
for cls, triggers, nouns in _TARGET_CLASS_RULES:
hit = next((t for t in triggers if t in hay), None)
if hit:
return _result(cls, nouns, [f"text match: '{hit.strip()}'"])
# Sequence-feature fallback: a short, cysteine-rich peptide with no named
# family is most often a disulfide-stabilised toxin/knottin (ion channels,
# proteases) rather than a linear hormone.
cys = seq.upper().count("C")
if seq and cys >= 4 and cys / len(seq) >= 0.15 and len(seq) <= 60:
return _result("ion_channel_toxin", ("channel", "receptor", "protease"),
[f"cysteine-rich ({cys} Cys / {len(seq)} aa) -> disulfide-stabilised toxin"])
if amp_matched:
return _result("antimicrobial", (), ["AMPSphere exact match"])
return _result("unknown", _UNKNOWN_CLASS_NOUNS, ["no class-specific signal; using broad search"])
class Pipeline:
def __init__(self, tu, verbose: bool = True):
self.tu = tu
self.verbose = verbose
def run(self, name: str, args: Dict[str, Any], attempts: int = 2) -> Dict[str, Any]:
last = {"status": "error", "error": "no attempt"}
for _ in range(max(1, attempts)):
try:
out = self.tu.run({"name": name, "arguments": args})
except Exception as exc: # never let one tool kill the pipeline
last = {"status": "error", "error": f"{type(exc).__name__}: {exc}"}
continue
resp = out if isinstance(out, dict) else {"status": "success", "data": out}
if resp.get("status") == "success":
return resp
# retry only transient upstream connection drops; surface other errors
err = str(resp.get("error", "")).lower()
if "aborted" not in err and "disconnect" not in err:
return resp
last = resp
return last
@staticmethod
def _data(resp: Dict[str, Any]) -> Any:
return resp.get("data") if isinstance(resp, dict) and resp.get("status") == "success" else None
def log(self, msg: str) -> None:
if self.verbose:
print(f" {msg}", file=sys.stderr)
# ---- Phase 1: characterization -------------------------------------
def characterize(self, seq: str) -> Dict[str, Any]:
props: Dict[str, Any] = {}
pc = self._data(self.run("PepCalc_peptide_properties", {"seq": seq}))
if isinstance(pc, dict):
props["length"] = pc.get("seqLength")
props["mw_monoisotopic"] = pc.get("molecularWeight")
props["mw_average"] = pc.get("molecularWeightAverage")
props["pI_pepcalc"] = pc.get("isoelectricPoint")
props["formula"] = pc.get("formula")
pp = self._data(self.run("ProtParam_calculate", {"sequence": seq}))
if isinstance(pp, dict):
props["pI_protparam"] = pp.get("isoelectric_point")
props["gravy"] = pp.get("gravy")
props["instability_index"] = pp.get("instability_index")
props["mw_protparam"] = pp.get("molecular_weight_da")
return props
# ---- Phase 2a: motif / PROSITE signature ---------------------------
def motif_families(self, seq: str) -> List[Dict[str, str]]:
out: List[Dict[str, str]] = []
scan = self._data(self.run("ScanProsite_scan_protein", {"seq": seq}))
for m in scan.get("matchset", []) if isinstance(scan, dict) else []:
ac = m.get("signature_ac")
if not ac:
continue
entry = self._data(self.run("PROSITE_get_entry", {"accession": ac}))
entry = entry if isinstance(entry, dict) else {}
out.append({"accession": ac, "description": entry.get("description", ""), "name": entry.get("entry_name", "")})
return out
# ---- Phase 2a': ELM short-linear-motif regex match (enrichment) -----
def elm_motif_match(self, seq: str, top: int = 6) -> List[Dict[str, Any]]:
"""Match the peptide against ELM ligand (LIG) motif regexes.
LIG regexes are short and low-specificity, so results are ranked by
rarity (ELM ``probability``, smaller = rarer) and each match is annotated
with the Pfam domain it engages. Treat as low-confidence context, useful
mainly for peptides whose PROSITE signature is not a named family.
"""
lig = self._data(self.run("ELM_list_classes", {"operation": "list_classes", "motif_type": "LIG", "max_results": 300}))
hits: List[Dict[str, Any]] = []
for c in _as_list(lig, "classes", "elm_classes"):
rgx = c.get("regex")
try:
if rgx and re.search(rgx, seq):
hits.append({"elm": c.get("elm_identifier"), "probability": c.get("probability"), "site": c.get("functional_site_name")})
except re.error:
continue
hits.sort(key=lambda h: h["probability"] if h["probability"] is not None else 1.0)
hits = hits[:top]
for h in hits:
dom = self._data(self.run("ELM_get_interaction_domains", {"operation": "get_interaction_domains", "elm_identifier": h["elm"]}))
h["binding_domains"] = [
{"pfam": m.get("pfam_accession"), "name": m.get("interaction_domain_name")}
for m in _as_list(dom, "mappings", "interaction_domains", "data")[:3]
]
return hits
# ---- Phase 2b: homology (slow; optional) ---------------------------
def homology_hits(self, seq: str, hitlist: int = 10) -> List[str]:
data = self._data(self.run("BLAST_protein_search", {"sequence": seq, "database": "swissprot", "expect": 10.0, "hitlist_size": hitlist}))
defs: List[str] = []
if isinstance(data, dict):
for aln in data.get("alignments") or data.get("hits") or []:
d = aln.get("hit_def") or aln.get("description") or aln.get("definition")
if d:
defs.append(d)
return defs
# ---- Phase 2c: target-family panel ---------------------------------
def _accessions_to_symbols(self, accessions: List[str]) -> List[str]:
"""Batch-map UniProt accessions -> gene symbols in ONE UniProt query.
InterPro returns members as UniProt accessions (no gene field), so resolve
them in a single 'accession:A OR accession:B ...' search rather than N calls.
"""
accessions = accessions[:60]
if not accessions:
return []
q = " OR ".join(f"accession:{a}" for a in accessions)
res = self._data(self.run("UniProt_search", {"query": q, "limit": 60}))
syms: set = set()
for r in _as_list(res, "results"):
for g in r.get("gene_names") or []:
if g:
syms.add(str(g).upper())
return sorted(syms)
def interpro_family_members(self, accession: Optional[str], cap: int = 60) -> List[str]:
"""Enumerate a target's protein family via InterPro — a GENERAL route that
works for any target class (kinases, channels, proteases, GPCRs alike),
unlike GPCRdb. From the seed's UniProt accession, take its InterPro FAMILY
entries, list each family's HUMAN members (InterPro mixes organisms, so
filter tax_id 9606), and map those accessions to gene symbols. Bounded so a
broad superfamily does not explode the panel."""
if not accession:
return []
entries = self._data(self.run("InterPro_get_entries_for_protein", {"accession": accession}))
ipr_ids: List[str] = []
for e in _as_list(entries, "entries", "results", "data"):
if not isinstance(e, dict):
continue
etype = str(e.get("type") or e.get("entry_type") or "").lower()
ipr = e.get("accession") or e.get("interpro_accession") or e.get("id")
if ipr and str(ipr).upper().startswith("IPR") and etype == "family":
ipr_ids.append(str(ipr))
human_accs: set = set()
for ipr in ipr_ids[:_INTERPRO_MAX_FAMILIES]:
prots = self._data(self.run("InterPro_get_proteins_by_domain",
{"domain_id": ipr, "page_size": 50, "reviewed_only": True}))
fam = [
p.get("accession")
for p in _as_list(prots, "proteins", "results", "data")
if isinstance(p, dict) and str(p.get("tax_id")) == "9606" and p.get("accession")
]
if 1 < len(fam) <= cap: # skip a singleton or a too-broad superfamily
human_accs.update(fam)
if not human_accs or len(human_accs) > cap:
return []
return self._accessions_to_symbols(sorted(human_accs))
def family_panel(self, seed_symbol: str) -> Dict[str, Any]:
"""Enumerate the seed gene's family (seed + paralogs). HGNC gene-family is
the general backbone (any target class); GPCRdb cross-checks GPCRs; InterPro
cross-checks (or, for a target with no HGNC group, provides) the family."""
panel: Dict[str, Dict[str, Any]] = {}
meta: Dict[str, Any] = {"seed": seed_symbol}
gene = self._data(self.run("HGNC_fetch_gene_by_symbol", {"symbol": seed_symbol}))
gene = gene if isinstance(gene, dict) else {}
meta["hgnc_id"] = gene.get("hgnc_id")
uniprot = (gene.get("uniprot_ids") or [None])[0]
meta["uniprot"] = uniprot
meta["gene_group"] = gene.get("gene_group")
for gid in gene.get("gene_group_id") or []:
members = self._data(self.run("HGNC_fetch_gene_family_members", {"gene_group_id": str(gid)})) or []
if len(members) > _HGNC_GROUP_CAP:
# a domain supergroup (e.g. EF-hand), not a target family — skip it
meta.setdefault("skipped_broad_groups", []).append({"gene_group_id": str(gid), "size": len(members)})
continue
for mem in members:
if mem.get("symbol"):
panel.setdefault(mem["symbol"], {"sources": set()})["sources"].add("HGNC")
# HGNC family group is authoritative when present; GPCRdb then only
# ANNOTATES those symbols (its entry-names carry aliases like 'glr' for
# GCGR, so do not let GPCRdb introduce non-HGNC symbols once HGNC succeeded).
hgnc_authoritative = bool(panel)
gp = self._data(self.run("GPCRdb_get_protein", {"protein": seed_symbol}))
slug = gp.get("family") if isinstance(gp, dict) else None
if slug:
meta["gpcrdb_slug"] = slug
subfam = "_".join(slug.split("_")[:3]) # trim to subfamily level
listed = self._data(self.run("GPCRdb_list_proteins", {"family": subfam})) or {}
for p in listed.get("proteins", []):
en = p.get("entry_name") or ""
if not en.endswith("_human"):
continue
sym = en[: -len("_human")].upper()
if hgnc_authoritative and sym not in panel:
continue # skip GPCRdb alias not in the authoritative HGNC panel
panel.setdefault(sym, {"sources": set()})["sources"].add("GPCRdb")
# InterPro general cross-check / fallback. When HGNC grouped the family it
# only annotates those members; when there is NO HGNC group (a target not
# in a curated family) it supplies the panel — the route that generalizes
# beyond GPCRs/curated groups.
seed_up = seed_symbol.upper()
for sym in self.interpro_family_members(uniprot):
if hgnc_authoritative and sym not in panel and sym != seed_up:
continue
panel.setdefault(sym, {"sources": set()})["sources"].add("InterPro")
for v in panel.values():
v["sources"] = sorted(v["sources"])
return {"panel": panel, "meta": meta}
# ---- Phase 2c (seedless): derive seed targets from motif + homology -----
@staticmethod
def _family_keywords(signatures: List[Dict[str, str]], homology_defs: Optional[List[str]] = None) -> List[str]:
"""Family keywords from PROSITE descriptions AND BLAST hit names — so a
peptide with NO named PROSITE family can still seed from its homologs."""
stop = {"family", "signature", "domain", "receptor", "protein", "type", "like",
"precursor", "isoform", "fragment", "chain", "human", "putative"}
texts = [s.get("description") or "" for s in signatures] + list(homology_defs or [])
out: List[str] = []
for text in texts:
for tok in re.split(r"[\s/,.;()\-]+", text.lower()):
if len(tok) >= 4 and tok.isalpha() and tok not in stop and tok not in out:
out.append(tok)
return out[:8]
def seedless_seeds(
self,
signatures: List[Dict[str, str]],
nouns: Optional[List[str]] = None,
homology_defs: Optional[List[str]] = None,
max_seeds: int = 4,
) -> Dict[str, Any]:
"""Derive candidate seed gene symbols when no hypothesized target is given.
For each family keyword × target-class noun (e.g. 'receptor', 'channel',
'protease'), UniProt_search("<kw> <noun>", human) and keep the gene symbols
whose protein_name actually contains one of the nouns (dropping ligand /
precursor hits). The nouns come from the target-class router, so this is no
longer receptor-only — an ion-channel toxin seeds channels, a protease-
targeting peptide seeds proteases, etc. These seeds feed family_panel().
"""
nouns = [n.lower() for n in (nouns or ["receptor"])]
kws = self._family_keywords(signatures, homology_defs)
seeds: Dict[str, str] = {}
for kw in kws:
for noun in nouns:
# organism takes a COMMON NAME ('human'), not a taxid; '9606' errors.
res = self._data(self.run("UniProt_search", {"query": f"{kw} {noun}", "organism": "human", "limit": 5}))
for r in _as_list(res, "results"):
pname = (r.get("protein_name") or "").lower()
if not any(n in pname for n in nouns):
continue # keep target-class members, drop ligand/precursor hits
for g in r.get("gene_names") or []:
seeds.setdefault(g, f"{kw} {noun}")
if len(seeds) >= max_seeds:
break
if len(seeds) >= max_seeds:
break
if len(seeds) >= max_seeds:
break
return {"keywords": kws, "nouns": nouns, "seeds": seeds}
# ---- Phase 2e: protease / degradation liability --------------------
_DPP4_P2 = {"A", "P"}
def protease_liability(self, seq: str) -> Dict[str, Any]:
"""Flag degradation liabilities (a peptide may be inactive in an assay
because it is *cleaved*, not because it fails to bind)."""
p2 = seq[1] if len(seq) > 1 else ""
labile = p2 in self._DPP4_P2
out: Dict[str, Any] = {
"dpp4": {
"p2_residue": p2,
"labile": labile,
"note": (f"position-2 {p2} -> DPP4-labile (rapid N-terminal truncation, like native GLP-1)"
if labile else f"position-2 {p2} -> DPP4-resistant (like exendin-4)"),
}
}
clv = self._data(self.run("ELM_list_classes", {"operation": "list_classes", "motif_type": "CLV", "max_results": 100}))
sites: List[Dict[str, Any]] = []
for c in _as_list(clv, "classes", "elm_classes"):
rgx = c.get("regex")
try:
m = re.search(rgx, seq) if rgx else None
except re.error:
m = None
if m:
sites.append({"elm": c.get("elm_identifier"), "site": c.get("functional_site_name"), "span": [m.start() + 1, m.end()]})
out["cleavage_motifs"] = sites
return out
# ---- Phase 2d: phenotype anchor ------------------------------------
def phenotype_targets(self, disease_name: str) -> Dict[str, float]:
srch = self._data(self.run("OpenTargets_get_disease_id_description_by_name", {"diseaseName": disease_name}))
# response shape: data['search']['hits'][0]['id']
hits = ((srch or {}).get("search") or {}).get("hits") if isinstance(srch, dict) else None
efo = hits[0].get("id") if hits else None
if not efo:
return {}
tg = self._data(self.run("OpenTargets_get_associated_targets_by_disease_efoId", {"efoId": efo}))
# response shape: data['disease']['associatedTargets']['rows'][*]{target.approvedSymbol, score}
rows = ((((tg or {}).get("disease") or {}).get("associatedTargets") or {}).get("rows")) if isinstance(tg, dict) else None
scores: Dict[str, float] = {}
for r in rows or []:
sym = (r.get("target") or {}).get("approvedSymbol")
if sym:
scores[sym] = r.get("score")
scores["__efo__"] = efo # carry the resolved id for the report
return scores
def phenotype_union(self, names: List[str]) -> Dict[str, Any]:
"""Union the OpenTargets anchor across SEVERAL phenotypes, keeping the max
score per target. For an unknown peptide you rarely know the single right
disease, so anchoring on every plausible phenotype (and taking the union)
is more robust than betting on one."""
scores: Dict[str, float] = {}
efos: List[str] = []
for name in names or []:
pt = self.phenotype_targets(name)
efo = pt.pop("__efo__", None)
if efo:
efos.append(f"{name} -> {efo}")
for sym, sc in pt.items():
if sc is None:
continue
if sym not in scores or (scores[sym] or 0) < sc:
scores[sym] = sc
return {"scores": scores, "efo": "; ".join(efos)}
# ---- Phase 3: cross-species ----------------------------------------
def ortholog_status(self, hgnc_id: Optional[str], assay_species: str) -> Dict[str, Any]:
if not hgnc_id:
return {}
resp = self._data(self.run("Alliance_get_gene_orthologs", {"gene_id": hgnc_id, "stringency": "all", "limit": 60}))
rows = _as_list(resp, "orthologs", "data")
target = assay_species.replace("_", " ").lower().split()[-1]
for o in rows:
sp = str(o.get("species") or o.get("target_species") or o.get("organism") or "").lower()
if target in sp: # e.g. 'musculus' in 'Mus musculus'
m = o.get("methods")
count = len(m) if isinstance(m, list) else (m or o.get("method_count"))
return {"present": True, "best_method_count": count}
return {"present": bool(rows), "note": f"ortholog list returned but no {assay_species} match parsed"}
# ---- Phase 3': ortholog sequences + binding-interface divergence -----
def _human_sequence(self, symbol: str) -> Dict[str, Optional[str]]:
gene = self._data(self.run("HGNC_fetch_gene_by_symbol", {"symbol": symbol}))
acc = (gene.get("uniprot_ids") or [None])[0] if isinstance(gene, dict) else None
return {"accession": acc, "sequence": self._sequence_for(acc)}
def _species_sequence(self, symbol: str, species: str) -> Dict[str, Optional[str]]:
"""Resolve the ortholog's protein sequence in ``species`` via UniProt search."""
res = self._data(self.run("UniProt_search", {"query": f"gene:{symbol}", "organism": _organism_query(species), "limit": 1}))
rows = _as_list(res, "results")
acc = rows[0].get("accession") if rows else None
return {"accession": acc, "sequence": self._sequence_for(acc)}
def _sequence_for(self, accession: Optional[str]) -> Optional[str]:
if not accession:
return None
seq = self._data(self.run("UniProt_get_sequence_by_accession", {"accession": accession}))
if isinstance(seq, str):
return seq.strip() or None
if isinstance(seq, dict):
return seq.get("sequence") or seq.get("value")
return None
def cross_species_alignment(self, symbol: str, assay_species: str, source_species: Optional[str]) -> Dict[str, Any]:
"""Align the human ortholog vs the assay (and optional source) species and
report sequence divergence — the mechanistic core of "binds in A, not B".
Keyless full-length identity is a proxy for interface divergence (we cannot
pinpoint the binding pocket without a structure); a low human-vs-assay
identity flags the ortholog whose interface most plausibly diverged.
"""
chains: Dict[str, str] = {}
accs: Dict[str, Optional[str]] = {}
human = self._human_sequence(symbol)
if human["sequence"]:
chains["human"] = human["sequence"]
accs["human"] = human["accession"]
species = {"assay": assay_species}
if source_species:
species["source"] = source_species
for role, sp in species.items():
got = self._species_sequence(symbol, sp)
if got["sequence"]:
chains[role] = got["sequence"]
accs[role] = got["accession"]
if len(chains) < 2:
return {"status": "insufficient", "resolved": list(chains), "accessions": accs,
"note": "need >=2 ortholog sequences to align; some species not in UniProt "
"(source organism may be a protist absent from vertebrate-centric DBs). "
"Provide the source binding-partner sequence directly to align by hand."}
fasta = "".join(f">{role}\n{seq}\n" for role, seq in chains.items())
aln = self._data(self.run("EBI_msa_align", {"sequences": fasta, "method": "clustalo", "sequence_type": "protein"}))
aln = aln if isinstance(aln, dict) else {}
aligned_fasta = aln.get("aligned_fasta") or aln.get("alignment") or aln.get("fasta")
aligned = _parse_fasta_str(aligned_fasta) if aligned_fasta else {}
pairs: List[Dict[str, Any]] = []
for other in ("assay", "source"):
if "human" in aligned and other in aligned:
pid = _pairwise_identity(aligned, "human", other)
if pid:
pairs.append({"pair": f"human_vs_{other}", "species": species.get(other), **pid})
return {"status": "ok" if pairs else "aligned_unparsed", "accessions": accs, "pairs": pairs,
"note": "lower human-vs-assay identity = the ortholog whose binding interface most "
"plausibly diverged, a mechanistic explanation for a species-specific negative."}
def representative_pdb(self, symbol: str, accession: Optional[str]) -> Optional[Dict[str, Any]]:
"""Best PDB structure for the candidate (keyless) — feeds ClusPro docking."""
if not accession:
gene = self._data(self.run("HGNC_fetch_gene_by_symbol", {"symbol": symbol}))
accession = (gene.get("uniprot_ids") or [None])[0] if isinstance(gene, dict) else None
if not accession:
return None
best = self._data(self.run("PDBeSIFTS_get_best_structures", {"uniprot_accession": accession}))
rows = _as_list(best, "structures", "best_structures", "data")
if isinstance(best, dict) and not rows and isinstance(best.get(accession), list):
rows = best[accession] # SIFTS keys results under the accession
if not rows:
return None
top = rows[0]
return {"pdb_id": top.get("pdb_id") or top.get("pdbId"), "uniprot": accession,
"chain": top.get("chain_id") or top.get("chainId")}
def tier(in_panel: bool, pheno_score: Optional[float], is_hypoth: bool) -> str:
if is_hypoth:
return "HYPOTHESIZED (tested negative)"
if in_panel and pheno_score is not None:
return "Tier 1 (family + phenotype)"
if in_panel:
return "Tier 2 (family only)"
if pheno_score is not None:
return "Tier 3 (phenotype only)"
return "Tier 3 (weak)"
def _rank_key(r: Dict[str, Any]):
rank = {"Tier 1 (family + phenotype)": 0, "Tier 2 (family only)": 1, "Tier 3 (phenotype only)": 2}.get(r["tier"], 3)
if r["is_hypothesized_target"]:
rank = 4
# Within a tier, break ties by phenotype score, then by how many independent
# family resources agree (HGNC + InterPro + GPCRdb). A 2-source-corroborated
# member is more likely the true tight family than an HGNC-only loose-group
# member — this floats the cross-checked core above noisy broad-group panels.
return (rank, -(r["phenotype_score"] or 0), -len(r.get("family_sources") or []))
def _build_panel(pipe: Pipeline, args, result: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
"""Seed family (if a hypothesized target is given) UNION the sequence-derived
candidates (motif + homology, class-aware nouns). Running the sequence-derived
derivation in BOTH modes is deliberate: the hypothesized seed may be WRONG (the
premise of deorphanization), so the real target can sit in a DIFFERENT family;
the sequence-derived panel keeps that family in play instead of being blinded by
the seed. Degrades gracefully when the UniProt resolver is down (seeds empty ->
seed family / phenotype carry the panel)."""
panel: Dict[str, Dict[str, Any]] = {}
if args.hypothesized_target:
fp = pipe.family_panel(args.hypothesized_target)
panel = fp["panel"]
result["family_meta"] = fp["meta"]
pipe.log(f"[{result.get('label')}] sequence-derived candidates ({result['target_class']['target_class']})")
sl = pipe.seedless_seeds(
result["signatures"],
nouns=result["target_class"]["seedless_nouns"],
homology_defs=result.get("homology_hits") or [],
)
result["seedless"] = sl
for seed in sl["seeds"]:
for sym, info in pipe.family_panel(seed)["panel"].items():
existing = panel.get(sym, {}).get("sources", [])
panel[sym] = {"sources": sorted(set(existing) | set(info["sources"]))}
return panel
def analyze_one(pipe: Pipeline, label: str, seq: str, args, pheno: Dict[str, float], efo: Optional[str]) -> Dict[str, Any]:
result: Dict[str, Any] = {"label": label, "sequence": seq, "hypothesized_target": args.hypothesized_target}
pipe.log(f"[{label}] characterization + motif")
result["properties"] = pipe.characterize(seq)
result["peptide_form"] = _noncanonical(seq)
result["signatures"] = pipe.motif_families(seq)
result["elm_motifs"] = pipe.elm_motif_match(seq)
result["protease_liability"] = pipe.protease_liability(seq)
if not args.no_blast:
pipe.log(f"[{label}] BLAST homology (slow)")
result["homology_hits"] = pipe.homology_hits(seq)
# Target-class router: classify the likely target class so candidate
# generation adapts (a peptide's real target need not be a GPCR).
result["target_class"] = _classify_target_class(
result["signatures"], result.get("homology_hits") or [], seq
)
panel = _build_panel(pipe, args, result)
candidates = set(panel) | (set(pheno) & set(panel))
if not candidates and pheno:
candidates = set(list(pheno)[:15]) # phenotype-only fallback
rows: List[Dict[str, Any]] = []
for sym in sorted(candidates):
in_panel = sym in panel
pscore = pheno.get(sym)
is_hypoth = bool(args.hypothesized_target and sym.upper() == args.hypothesized_target.upper())
rows.append({
"gene": sym, "tier": tier(in_panel, pscore, is_hypoth), "in_family_panel": in_panel,
"family_sources": panel.get(sym, {}).get("sources", []), "phenotype_score": pscore,
"is_hypothesized_target": is_hypoth,
})
rows.sort(key=_rank_key)
leads_done = 0
for r in rows[:5]:
if r["is_hypothesized_target"]:
continue
g = pipe._data(pipe.run("HGNC_fetch_gene_by_symbol", {"symbol": r["gene"]}))
acc = (g.get("uniprot_ids") or [None])[0] if isinstance(g, dict) else None
r["cross_species"] = pipe.ortholog_status(g.get("hgnc_id") if isinstance(g, dict) else None, args.assay_species)
# For the top few real-target hypotheses, do the work the skill promised:
# resolve ortholog sequences and align the binding interface across species,
# and suggest a ClusPro-ready PDB structure.
if leads_done < 3:
pipe.log(f"[{label}] cross-species interface alignment: {r['gene']}")
r["interface_alignment"] = pipe.cross_species_alignment(r["gene"], args.assay_species, args.source_species)
r["representative_pdb"] = pipe.representative_pdb(r["gene"], acc)
leads_done += 1
result["ranked_candidates"] = rows
result["phenotype_efo"] = efo
_print_summary(args, result, panel, rows)
return result
def _print_summary(args, result, panel, rows) -> None:
seq = result["sequence"]
print("\n" + "=" * 72)
print(f"PEPTIDE DEORPHANIZATION | {result['label']} | {seq[:34]}{'...' if len(seq) > 34 else ''}")
print("=" * 72)
p = result["properties"]
print(f"len={p.get('length')} MW~{p.get('mw_average')} pI~{p.get('pI_protparam')} GRAVY={p.get('gravy')}")
form = result.get("peptide_form", {})
if form.get("noncanonical_residues"):
print(f"NON-CANONICAL residues {form['noncanonical_residues']} -> sequence tools may "
"mischaracterize; if NRP/cyclic use Norine_get_peptide + cofold --cyclic")
tc = result.get("target_class")
if tc:
print(f"target class: {tc['target_class']} ({'; '.join(tc.get('evidence', []))})"
+ (f" -> seedless nouns: {tc['seedless_nouns']}" if tc.get("seedless_nouns") else ""))
for s in result.get("signatures") or []:
print(f"signature: {s['accession']} {s['description']}")
elm = result.get("elm_motifs") or []
if elm:
doms = ", ".join(d["name"] for h in elm[:3] for d in h.get("binding_domains", []) if d.get("name"))
print(f"ELM LIG motifs (rarest): {', '.join(h['elm'] for h in elm[:3])}" + (f" -> domains: {doms}" if doms else ""))
pl = result.get("protease_liability", {})
dpp4 = pl.get("dpp4", {})
print(f"protease: DPP4 {dpp4.get('p2_residue')}@P2 -> {'LABILE' if dpp4.get('labile') else 'resistant'}; "
f"cleavage motifs: {len(pl.get('cleavage_motifs', []))}")
if args.hypothesized_target:
print(f"seed family of {args.hypothesized_target}: {result.get('family_meta', {}).get('gene_group')}")
sl = result.get("seedless") or {}
if sl.get("seeds"):
print(f"sequence-derived (keywords {sl['keywords']} x nouns {sl.get('nouns')}) -> seeds {dict(sl['seeds'])}")
elif sl and not args.hypothesized_target:
print(f"sequence-derived: keywords {sl.get('keywords')} x nouns {sl.get('nouns')} -> resolver returned nothing "
"(UniProt transient, or target not named like one of the nouns). Showing phenotype-anchored "
"candidates only; pass --hypothesized-target for clean family enumeration.")
print(f"panel ({len(panel)}): {sorted(panel)[:20]}{' ...' if len(panel) > 20 else ''}")
if args.phenotype:
print(f"phenotype anchor(s): {', '.join(args.phenotype)} -> {result.get('phenotype_efo')}")
print("-" * 72)
print(f"{'GENE':<10}{'TIER':<32}{'PHENO':<8}{'FAMILY':<14}{'X-SPECIES'}")
for r in rows:
xs = r.get("cross_species", {})
xs_s = ("present" if xs.get("present") else "") if xs else ""
ps = f"{r['phenotype_score']:.3f}" if r["phenotype_score"] is not None else "-"
print(f"{r['gene']:<10}{r['tier']:<32}{ps:<8}{','.join(r['family_sources']) or '-':<14}{xs_s}")
# Lead-candidate cross-species interface divergence + ClusPro-ready PDB
for r in rows:
ia = r.get("interface_alignment")
if ia and ia.get("pairs"):
spans = "; ".join(f"{x['pair']} {x['percent_identity']}% id ({x['n_substitutions']} subs)" for x in ia["pairs"])
print(f" x-species {r['gene']}: {spans}")
elif ia and ia.get("status") == "insufficient":
print(f" x-species {r['gene']}: {ia['note']}")
pdb = r.get("representative_pdb")
if pdb and pdb.get("pdb_id"):
print(f" ClusPro-ready PDB for {r['gene']}: {pdb['pdb_id']} (chain {pdb.get('chain') or '?'}, {pdb['uniprot']})")
print("=" * 72)
if dpp4.get("labile"):
print("NOTE: peptide is DPP4-labile -> an assay-negative result may be DEGRADATION, not")
print("non-binding. Re-test with a DPP4 inhibitor or a protease-resistant analog.")
if args.hypothesized_target:
print(f"NOTE: {args.hypothesized_target} was the hypothesized target (assay-negative). The Tier-1/2")
print("alternatives above are the testable real-target hypotheses to validate next.")
def _parse_fasta(path: str) -> Dict[str, str]:
seqs: Dict[str, str] = {}
name: Optional[str] = None
buf: List[str] = []
with open(path) as fh:
for line in fh:
line = line.strip()
if line.startswith(">"):
if name:
seqs[name] = "".join(buf)
name = (line[1:].split() or [f"seq{len(seqs) + 1}"])[0]
buf = []
elif line:
buf.append(line)
if name:
seqs[name] = "".join(buf)
return seqs
def main() -> int:
ap = argparse.ArgumentParser(description="Keyless peptide target deorphanization (Phases 1-4).")
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--sequence", help="Single peptide amino-acid sequence (1-letter).")
src.add_argument("--fasta", help="FASTA file of peptides for BATCH mode (one record each).")
ap.add_argument("--hypothesized-target", default=None, help="Gene symbol the peptide was assumed to hit (seeds family enumeration). Omit for SEEDLESS mode.")
ap.add_argument("--phenotype", action="append", default=None, metavar="DISEASE",
help="Disease name for the OpenTargets anchor, e.g. 'type 2 diabetes mellitus'. Repeatable: pass --phenotype several times to anchor on every plausible phenotype and union the target sets (best for an unknown peptide).")
ap.add_argument("--assay-species", default="mus_musculus", help="Species of the negative binding assay (cross-species reconciliation).")
ap.add_argument("--source-species", default=None, help="Species where binding WAS observed (e.g. the source organism). Adds a 3-way human/assay/source interface alignment. Protists are often absent from UniProt — supply the partner sequence by hand if unresolved.")
ap.add_argument("--no-blast", action="store_true", help="Skip the slow BLAST homology route.")
ap.add_argument("--out", default=None, help="Optional path to write the full JSON result.")
args = ap.parse_args()
pipe = Pipeline(_load_tu())
peptides = _parse_fasta(args.fasta) if args.fasta else {"peptide": args.sequence.strip().upper()}
pheno: Dict[str, float] = {}
efo: Optional[str] = None
if args.phenotype:
pipe.log(f"phenotype anchor (OpenTargets) — {len(args.phenotype)} phenotype(s), shared across peptides")
pu = pipe.phenotype_union(args.phenotype)
pheno, efo = pu["scores"], pu["efo"]
all_results = {label: analyze_one(pipe, label, seq.upper(), args, pheno, efo) for label, seq in peptides.items()}
if args.out:
with open(args.out, "w") as f:
json.dump(all_results if args.fasta else next(iter(all_results.values())), f, indent=2, default=str)
print(f"\nFull JSON -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())