Source code for tooluniverse.pdbtm_tool
# pdbtm_tool.py
"""
PDBTM (Protein Data Bank of Transmembrane Proteins) tool for ToolUniverse.
PDBTM classifies transmembrane topology directly from solved PDB
structures: for each chain, whether it is alpha-helical, beta-barrel, or
not membrane-embedded, and how many transmembrane segments it has.
ToolUniverse already has OPM (membrane placement geometry and transfer
energy) and TopDB (curated per-region topology from experimental evidence).
PDBTM adds the third, independent view: per-chain classification computed
directly from structure coordinates, useful for cross-checking multi-chain
complexes chain by chain (e.g. which subunit of a photosynthetic reaction
centre is membrane-embedded and which is not).
API: https://pdbtm.unitmp.org/api/v1
No authentication required.
"""
from typing import Dict, Any, List
import requests
from .base_tool import BaseTool
from .tool_registry import register_tool
PDBTM_BASE_URL = "https://pdbtm.unitmp.org/api/v1"
SEGMENTS_PER_CHAIN = 200
def _topology_segments(chain, site_labels):
"""Collapse the per-residue site assignment into labelled ranges.
PDBTM assigns every residue to one of eight regions -- Side1, Side2,
Transmembrane Beta strand, Membrane Inside and so on -- and the entry
carries the legend that turns a site_id into that name. The wrapper
reported `num_tm_segments` and none of it, so a topology tool answered
"16" to the question of which residues cross the membrane. For 2por's
chain A that is 103 residues of transmembrane beta strand, 55 on Side1,
108 on Side2 and 35 inside the membrane, every one of them curated.
"""
segments = []
for res in chain.get("residues") or []:
site = (res.get("site_data") or [{}])[0]
label = site_labels.get(site.get("site_id_ref"))
confidence = site.get("confidence_classification")
position = res.get("pdb_res_label")
last = segments[-1] if segments else None
if last and last["region"] == label and last["confidence"] == confidence:
last["end_residue"] = position
last["residue_count"] += 1
last["amino_acids"].append(res.get("aa_type"))
continue
segments.append(
{
"region": label,
"confidence": confidence,
"start_residue": position,
"end_residue": position,
"residue_count": 1,
"amino_acids": [res.get("aa_type")],
}
)
return segments
[docs]
@register_tool("PDBTMTool")
class PDBTMTool(BaseTool):
"""
Tool for retrieving structure-derived transmembrane classification from
PDBTM.
Supports fetching one PDB entry's per-chain topology: whether each
chain is alpha-helical, beta-barrel, or not membrane-embedded, and its
transmembrane segment count.
No authentication required.
"""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 45)
self.operation = tool_config.get("fields", {}).get(
"operation", "get_topology"
)
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Execute the PDBTM lookup."""
try:
if self.operation == "get_topology":
return self._get_topology(arguments)
return {
"status": "error",
"error": f"Unknown operation: {self.operation}",
}
except requests.exceptions.Timeout:
return {
"status": "error",
"error": f"PDBTM request timed out after {self.timeout}s",
}
except requests.exceptions.ConnectionError:
return {
"status": "error",
"error": "Failed to connect to PDBTM. Check network.",
}
except requests.exceptions.HTTPError as e:
code = e.response.status_code if e.response is not None else "unknown"
return {"status": "error", "error": f"PDBTM returned HTTP {code}"}
except ValueError:
return {"status": "error", "error": "PDBTM returned a non-JSON response"}
except Exception as e:
return {"status": "error", "error": f"Error querying PDBTM: {str(e)}"}
[docs]
def _get_topology(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch per-chain transmembrane classification for one PDB entry."""
pdb_id = (arguments.get("pdb_id") or "").strip().lower()
if not pdb_id:
return {
"status": "error",
"error": "pdb_id is required, e.g. '1prc' (photosynthetic "
"reaction centre) or '2por' (porin).",
}
response = requests.get(
f"{PDBTM_BASE_URL}/entry/{pdb_id}.json", timeout=self.timeout
)
if response.status_code != 200:
return {
"status": "error",
"error": f"No PDBTM entry for '{pdb_id}'. PDBTM only covers "
"PDB structures it has classified as membrane proteins.",
}
payload = response.json()
if not isinstance(payload, dict) or not payload.get("pdb_id"):
return {
"status": "error",
"error": f"No PDBTM entry for '{pdb_id}'.",
}
site_labels = {
site.get("site_id"): site.get("label")
for site in (payload.get("sites") or [])
if isinstance(site, dict)
}
chains: List[Dict[str, Any]] = []
for chain in payload.get("chains") or []:
annotations = chain.get("additional_chain_annotations") or {}
tm_type = annotations.get("type")
num_tm = annotations.get("num_tm")
segments = _topology_segments(chain, site_labels)
region_counts = {}
for seg in segments:
key = seg["region"] or "unlabelled"
region_counts[key] = region_counts.get(key, 0) + seg["residue_count"]
chains.append(
{
"chain_label": chain.get("chain_label"),
"tm_type": tm_type,
"num_tm_segments": int(num_tm) if num_tm is not None else None,
"is_membrane_embedded": tm_type not in (None, "non_tm"),
"topology_segments": segments[:SEGMENTS_PER_CHAIN],
"topology_segments_total": len(segments),
"topology_segments_truncated": (
len(segments) > SEGMENTS_PER_CHAIN
),
"residue_count": sum(x["residue_count"] for x in segments),
"residues_by_region": region_counts,
}
)
membrane = (payload.get("additional_entry_annotations") or {}).get(
"membrane"
) or {}
return {
"status": "success",
"data": {
"pdb_id": payload.get("pdb_id"),
"release_date": payload.get("release_date"),
"chains": chains,
"membrane_radius_angstrom": membrane.get("radius"),
# The region legend the per-residue assignment refers to.
"regions": [
{"site_id": k, "label": v} for k, v in sorted(site_labels.items())
],
# The membrane-positioned coordinates themselves, and the
# PDBTM page. A structure database that returns no way to
# fetch the structure is a dead end.
"model_coordinates_url": payload.get("model_coordinates_url"),
"entry_url": payload.get("resource_entry_url"),
"includes_het_groups": payload.get("includes_het_groups"),
# How the assignment was made, as an ECO term.
"evidence_codes": payload.get("evidence_code_ontology") or [],
"pdbtm_version": payload.get("resource_version"),
"software_version": payload.get("software_version"),
},
"metadata": {
"pdb_id": pdb_id,
"chain_count": len(chains),
"note": "tm_type is 'alpha', 'beta', or 'non_tm'. Compare "
"with OPM_search_structures (geometry/energetics) or "
"TopDB_get_topology (curated per-region evidence).",
"source": "PDBTM (Protein Data Bank of Transmembrane Proteins)",
},
}