Source code for tooluniverse.ewas_atlas_tool
# ewas_atlas_tool.py
"""
EWAS Atlas tools for ToolUniverse.
EWAS Atlas (NGDC/CNCB, Beijing) is a curated knowledgebase of
epigenome-wide association studies: which CpG probes were reported for which
trait, in which study, cohort and tissue. It overlaps with the EWAS Catalog
(MRC-IEU) but is curated and served independently, so it still answers when
the Catalog is down (ewascatalog.org answered HTTP 503 site-wide on
2026-10-05, 10-08 and 10-09).
API: https://ngdc.cncb.ac.cn/ewas/rest (documented at /ewas/api).
No authentication required. Behaviour verified live on 2026-10-09:
* Every response is ``{"code": 0|1, "msg": ..., "data": ...}``; ``code: 1``
with ``data: null`` means "not found" for study and publication lookups.
* An unknown probe ID, or a chromosome written as "X"/"chrX", makes the
server close the connection without a response instead of answering.
Chromosomes are numbers: X is 23, Y is 24.
* Coordinates are GRCh37/hg19.
* ``/pos`` answers a 1 Mb window in about 20 s (~550 probes); a 5 Mb window
hits the server's own ~60 s limit and comes back truncated, so the window
is capped at 1 Mb here.
* Study and publication records spell their association list
``assocaitionList``; both spellings are read.
"""
from collections import Counter
from typing import Any, Dict, List, Optional
import requests
from .base_tool import BaseTool
from .tool_registry import register_tool
EWAS_ATLAS_URL = "https://ngdc.cncb.ac.cn/ewas/rest"
MAX_REGION_BP = 1_000_000
_CHROMOSOME_NUMBERS = {"X": 23, "Y": 24}
def _limit(value: Any, default: int, maximum: int) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
return default
return min(value, maximum)
def _chromosome(value: Any) -> Optional[int]:
"""'chr5' / '5' / 5 -> 5, 'X' -> 23, 'Y' -> 24; None if unrecognised."""
text = str(value if value is not None else "").strip().upper()
if text.startswith("CHR"):
text = text[3:]
if text in _CHROMOSOME_NUMBERS:
return _CHROMOSOME_NUMBERS[text]
if text.isdigit() and 1 <= int(text) <= 24:
return int(text)
return None
def _association(raw: Dict[str, Any]) -> Dict[str, Any]:
return {
"study_id": raw.get("studyId"),
"trait": raw.get("trait"),
"correlation": raw.get("correlation"),
"rank": raw.get("rank"),
"pmid": str(raw["pmid"]) if raw.get("pmid") is not None else None,
}
def _probe(raw: Dict[str, Any], max_associations: int) -> Dict[str, Any]:
"""Flatten one probe record and bound its association list."""
associations = [
_association(a) for a in raw.get("associationList") or [] if isinstance(a, dict)
]
genes = []
for transcript in raw.get("relatedTranscription") or []:
name = transcript.get("geneName") if isinstance(transcript, dict) else None
if name and name not in genes:
genes.append(name)
return {
"probe_id": raw.get("probeId"),
"chromosome": raw.get("chrHg19"),
"position_hg19": raw.get("posHg19"),
"cpg_island": raw.get("cpgIsland"),
"genes": genes,
"association_count": len(associations),
"trait_count": len({a["trait"] for a in associations if a["trait"]}),
"associations": associations[:max_associations],
}
def _top_traits(raw_probes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
counts = Counter(
a.get("trait")
for raw in raw_probes
for a in raw.get("associationList") or []
if isinstance(a, dict) and a.get("trait")
)
return [{"trait": t, "associations": n} for t, n in counts.most_common(15)]
def _study(raw: Dict[str, Any], max_associations: int) -> Dict[str, Any]:
associations = raw.get("assocaitionList") or raw.get("associationList") or []
rows = [
{
"probe_id": a.get("probeId"),
"correlation": a.get("correlation"),
"rank": a.get("rank"),
"p_value": a.get("pvalue") or None,
}
for a in associations
if isinstance(a, dict)
]
cohorts = [
{
"stage": c.get("stage"),
"cohort": c.get("fullName") or c.get("cohortName") or None,
"sample_size": c.get("sampleSize"),
"tissue": c.get("tissue"),
"platform": c.get("platform"),
"description": c.get("description") or None,
"mean_age": c.get("meanAge"),
"male_fraction": c.get("malePercentage"),
}
for c in raw.get("cohortList") or []
if isinstance(c, dict)
]
return {
"study_id": raw.get("studyId"),
"reported_trait": raw.get("reportedTrait"),
"case_group": raw.get("caseGroup") or None,
"control_group": raw.get("controlGroup") or None,
"description": raw.get("otherDescription") or None,
"association_count": len(rows),
"associations": rows[:max_associations],
"cohorts": cohorts,
}
[docs]
@register_tool("EWASAtlasTool")
class EWASAtlasTool(BaseTool):
"""Look up EWAS Atlas probes, genes, regions, studies and publications."""
[docs]
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 90)
self.operation = tool_config.get("fields", {}).get("operation", "get_probe")
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
handler = {
"get_probe": self._get_probe,
"search_by_gene": self._search_by_gene,
"search_by_region": self._search_by_region,
"get_study": self._get_study,
"get_publication": self._get_publication,
}.get(self.operation)
if handler is None:
return {"status": "error", "error": f"Unknown operation: {self.operation}"}
try:
return handler(arguments or {})
except requests.exceptions.Timeout:
return {
"status": "error",
"error": f"EWAS Atlas did not answer within {self.timeout}s.",
"retryable": True,
}
except requests.exceptions.ConnectionError:
if self.operation == "get_probe":
return {
"status": "error",
"error": "EWAS Atlas closed the connection. It does this for "
"probe IDs it does not hold (e.g. non-Illumina IDs), so check "
"the ID; otherwise retry later.",
}
return {
"status": "error",
"error": "Failed to connect to EWAS Atlas.",
"retryable": True,
}
except requests.exceptions.HTTPError as e:
code = e.response.status_code if e.response is not None else "unknown"
return {"status": "error", "error": f"EWAS Atlas returned HTTP {code}"}
except ValueError:
return {
"status": "error",
"error": "EWAS Atlas returned an incomplete or non-JSON response.",
"retryable": True,
}
[docs]
def _fetch(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
response = requests.get(
f"{EWAS_ATLAS_URL}/{path}", params=params, timeout=self.timeout
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("unexpected payload")
return payload
[docs]
@staticmethod
def _required(arguments: Dict[str, Any], name: str, example: str):
value = str(arguments.get(name) or "").strip()
if not value:
return None, {
"status": "error",
"error": f"{name} is required, e.g. '{example}'.",
}
return value, None
[docs]
def _get_probe(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
probe_id, error = self._required(arguments, "probe_id", "cg05575921")
if error:
return error
payload = self._fetch("probe", {"probeId": probe_id})
raw = payload.get("data")
if payload.get("code") != 0 or not isinstance(raw, dict):
return {
"status": "error",
"error": f"EWAS Atlas has no probe '{probe_id}': {payload.get('msg')}",
}
probe = _probe(raw, _limit(arguments.get("limit"), 50, 500))
return {
"status": "success",
"data": probe,
"metadata": {
"source": "EWAS Atlas (NGDC)",
"returned_associations": len(probe["associations"]),
"note": "Coordinates are GRCh37/hg19. 'rank' is the probe's rank "
"within that study's reported results.",
},
}
[docs]
def _probe_list(self, raw_probes, arguments, query):
max_probes = _limit(arguments.get("limit"), 50, 500)
per_probe = _limit(arguments.get("max_associations_per_probe"), 10, 200)
raw_probes = [p for p in raw_probes if isinstance(p, dict)]
raw_probes.sort(key=lambda p: -len(p.get("associationList") or []))
probes = [_probe(p, per_probe) for p in raw_probes[:max_probes]]
return {
"status": "success",
"data": probes,
"metadata": {
**query,
"source": "EWAS Atlas (NGDC)",
"total_probes": len(raw_probes),
"returned_probes": len(probes),
"total_associations": sum(
len(p.get("associationList") or []) for p in raw_probes
),
"top_traits": _top_traits(raw_probes),
"note": "Probes are ordered by number of reported associations. "
"Each probe lists at most max_associations_per_probe of them; "
"association_count is the full number. Coordinates are "
"GRCh37/hg19.",
},
}
[docs]
def _search_by_gene(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
gene, error = self._required(arguments, "gene_symbol", "AHRR")
if error:
return error
payload = self._fetch("gene", {"geneSymbol": gene})
data = payload.get("data") if payload.get("code") == 0 else None
raw_probes = (data or {}).get("probeList") or []
if not raw_probes:
return {
"status": "success",
"data": [],
"metadata": {
"gene_symbol": gene,
"source": "EWAS Atlas (NGDC)",
"total_probes": 0,
"note": "No EWAS Atlas probes are annotated to this gene. "
"Check the official HGNC symbol.",
},
}
return self._probe_list(raw_probes, arguments, {"gene_symbol": gene})
[docs]
def _search_by_region(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
chromosome = _chromosome(arguments.get("chromosome"))
if chromosome is None:
return {
"status": "error",
"error": "chromosome must be 1-22, X or Y (e.g. '5' or 'chr5').",
}
start, end = arguments.get("start"), arguments.get("end")
if (
isinstance(start, bool)
or isinstance(end, bool)
or not isinstance(start, int)
or not isinstance(end, int)
or start < 1
or end < start
):
return {
"status": "error",
"error": "start and end are required integers with 1 <= start <= end "
"(GRCh37/hg19).",
}
if end - start > MAX_REGION_BP:
return {
"status": "error",
"error": f"The window is {end - start:,} bp; EWAS Atlas times out "
f"above about {MAX_REGION_BP:,} bp. Split the region.",
}
payload = self._fetch("pos", {"chr": chromosome, "start": start, "end": end})
raw_probes = payload.get("data") if payload.get("code") == 0 else None
query = {"chromosome": chromosome, "start": start, "end": end}
if not raw_probes:
return {
"status": "success",
"data": [],
"metadata": {
**query,
"source": "EWAS Atlas (NGDC)",
"total_probes": 0,
"note": "No EWAS Atlas probes in this window. Coordinates "
"are GRCh37/hg19.",
},
}
return self._probe_list(raw_probes, arguments, query)
[docs]
def _get_study(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
study_id, error = self._required(arguments, "study_id", "ES00033")
if error:
return error
payload = self._fetch("study", {"studyId": study_id})
raw = payload.get("data")
if payload.get("code") != 0 or not isinstance(raw, dict):
return {
"status": "error",
"error": f"EWAS Atlas has no study '{study_id}': {payload.get('msg')}",
}
study = _study(raw, _limit(arguments.get("limit"), 100, 1000))
return {
"status": "success",
"data": study,
"metadata": {
"source": "EWAS Atlas (NGDC)",
"returned_associations": len(study["associations"]),
},
}
[docs]
def _get_publication(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
pmid, error = self._required(arguments, "pmid", "29535343")
if error:
return error
payload = self._fetch("publication", {"pmid": pmid})
raw = payload.get("data")
if payload.get("code") != 0 or not isinstance(raw, dict):
return {
"status": "error",
"error": f"EWAS Atlas has no publication with PMID {pmid}: "
f"{payload.get('msg')}",
}
per_study = _limit(arguments.get("limit"), 50, 1000)
studies = [
_study(s, per_study)
for s in raw.get("studyList") or []
if isinstance(s, dict)
]
return {
"status": "success",
"data": {
"pmid": str(raw.get("pmid")) if raw.get("pmid") is not None else pmid,
"title": raw.get("title"),
"doi": raw.get("doi") or None,
"publication_date": raw.get("publicationDate"),
"studies": studies,
},
"metadata": {
"source": "EWAS Atlas (NGDC)",
"study_count": len(studies),
},
}