Download scripts/query_toxcast.py from Qianhui19/iHARP: direct link, hf CLI and curl.
- Browser
- Download file 10.1 kB
-
https://huggingface.co/Qianhui19/iHARP/resolve/main/scripts/query_toxcast.py
- Command line
-
hf download hf://Qianhui19/iHARP/scripts/query_toxcast.py
-
curl -L -o query_toxcast.py https://huggingface.co/Qianhui19/iHARP/resolve/main/scripts/query_toxcast.py
10.1 kB
| #!/usr/bin/env python3 | |
| """Match and query AC50/HitCall details from the ToxCast_AC50 folder. | |
| Data source: the ToxCast_AC50 folder (each CSV = all-compound AC50 for one endpoint). | |
| Column structure: DTXSID, PREFERRED NAME, CASRN, MOLECULAR FORMULA, MONOISOTOPIC MASS, | |
| ToxCast Active, ToxCast Total, % ToxCast Active, HIT CALL, TOP, | |
| SCALED TOP, AC50, LOGAC50 | |
| Purpose: the invitrodb MCP can only return an endpoint name list, not AC50/HitCall; | |
| this script goes back to the ToxCast folder and matches the AC50/HitCall details | |
| by compound name or CASRN. | |
| Usage: | |
| python query_toxcast.py --query "Bisphenol A" | |
| python query_toxcast.py --query "80-05-7" | |
| python query_toxcast.py --query "Bisphenol A" --toxcast-dir /path/to/ToxCast_AC50 | |
| python query_toxcast.py --query "80-05-7" --output /tmp/bpa_toxcast.csv | |
| External data dependency: | |
| - The ToxCast_AC50 folder is bundled with this skill under assets/ToxCast_AC50. | |
| - When --toxcast-dir is not passed, auto-detection order: assets/ToxCast_AC50 | |
| -> environment variable TOXCAST_DIR -> project-root ToxCast_AC50; error if not found. | |
| Notes: | |
| - standard library only, no third-party dependencies. | |
| - prefer reusing the skill-root `.cache/toxcast_index.sqlite` cache (if present and fresh); | |
| otherwise scan the folder and rebuild. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import glob | |
| import json | |
| import os | |
| import re | |
| import sqlite3 | |
| from pathlib import Path | |
| def normalize_name(value: object) -> str: | |
| """Matching key kept consistent with chemical_annotator5.normalize_name.""" | |
| text = "" if value is None else str(value) | |
| text = text.strip().strip('"').strip("'").lower() | |
| text = re.sub(r"\s+", " ", text) | |
| text = text.replace("–", "-").replace("—", "-") | |
| text = re.sub(r"\s*([,;:/()\[\]{}+-])\s*", r"\1", text) | |
| return text | |
| def is_probable_cas(value: str) -> bool: | |
| return bool(re.fullmatch(r"\d{2,7}-\d{2}-\d", value.strip())) | |
| def skill_root() -> Path: | |
| """This script lives in skill/iharp/scripts/; go up two levels to the skill root.""" | |
| return Path(__file__).resolve().parents[1] | |
| def project_root() -> Path: | |
| """Go up three levels to the project root (skill/iharp/scripts/ -> project root).""" | |
| return Path(__file__).resolve().parents[3] | |
| def probe_toxcast_dir() -> Path | None: | |
| """Auto-detect the ToxCast folder, by priority: | |
| 1. skill-bundled data: assets/ToxCast_AC50 | |
| 2. environment variable TOXCAST_DIR | |
| 3. project-root ToxCast_AC50 (legacy layout compatibility) | |
| """ | |
| bundled = skill_root() / "assets" / "ToxCast_AC50" | |
| if bundled.is_dir(): | |
| return bundled | |
| env = os.environ.get("TOXCAST_DIR") | |
| if env and Path(env).is_dir(): | |
| return Path(env) | |
| candidate = project_root() / "ToxCast_AC50" | |
| if candidate.is_dir(): | |
| return candidate | |
| return None | |
| def cache_path_for(toxcast_dir: Path) -> Path: | |
| """Cache is placed under the skill root .cache directory (separate from bundled data).""" | |
| return skill_root() / ".cache" / "toxcast_index.sqlite" | |
| def csv_signature(toxcast_dir: Path) -> str: | |
| """Use total file size + newest mtime as the folder signature to decide whether the cache is stale.""" | |
| total_size = 0 | |
| newest = 0 | |
| for path in glob.glob(str(toxcast_dir / "*.csv")): | |
| stat = os.stat(path) | |
| total_size += stat.st_size | |
| newest = max(newest, int(stat.st_mtime)) | |
| return f"{total_size}:{newest}" | |
| def scan_folder(toxcast_dir: Path) -> list[dict[str, str]]: | |
| """Scan the folder directly and return a list of detail rows.""" | |
| rows: list[dict[str, str]] = [] | |
| for path in sorted(glob.glob(str(toxcast_dir / "*.csv"))): | |
| endpoint = os.path.splitext(os.path.basename(path))[0] | |
| with open(path, newline="", encoding="utf-8-sig", errors="replace") as f: | |
| reader = csv.DictReader(f) | |
| for row in reader: | |
| name = (row.get("PREFERRED NAME") or row.get("Preferred Name") or "").strip() | |
| casrn = (row.get("CASRN") or row.get("CAS") or "").strip() | |
| if not name and not casrn: | |
| continue | |
| rows.append( | |
| { | |
| "name_key": normalize_name(name), | |
| "casrn": casrn, | |
| "compound_name": name, | |
| "endpoint": endpoint, | |
| "hit_call": (row.get("HIT CALL") or "").strip(), | |
| "ac50": (row.get("AC50") or "").strip(), | |
| } | |
| ) | |
| return rows | |
| def load_index(toxcast_dir: Path) -> list[dict[str, str]]: | |
| """Prefer reusing the sqlite cache; scan and rebuild when missing or stale.""" | |
| cache_path = cache_path_for(toxcast_dir) | |
| sig = csv_signature(toxcast_dir) | |
| use_cache = False | |
| if cache_path.exists(): | |
| try: | |
| conn = sqlite3.connect(cache_path) | |
| cur = conn.cursor() | |
| cur.execute("SELECT value FROM meta WHERE key='sig'") | |
| row = cur.fetchone() | |
| if row is not None and row[0] == sig: | |
| use_cache = True | |
| conn.close() | |
| except sqlite3.Error: | |
| use_cache = False | |
| if use_cache: | |
| conn = sqlite3.connect(cache_path) | |
| try: | |
| cur = conn.cursor() | |
| cur.execute( | |
| "SELECT name_key, casrn, compound_name, endpoint, hit_call, ac50 FROM toxcast" | |
| ) | |
| rows = [ | |
| { | |
| "name_key": r[0], | |
| "casrn": r[1], | |
| "compound_name": r[2], | |
| "endpoint": r[3], | |
| "hit_call": r[4] or "", | |
| "ac50": r[5] or "", | |
| } | |
| for r in cur.fetchall() | |
| ] | |
| return rows | |
| finally: | |
| conn.close() | |
| # scan the folder and write back the cache | |
| rows = scan_folder(toxcast_dir) | |
| try: | |
| cache_path.parent.mkdir(parents=True, exist_ok=True) | |
| conn = sqlite3.connect(cache_path) | |
| cur = conn.cursor() | |
| cur.execute( | |
| "CREATE TABLE IF NOT EXISTS toxcast " | |
| "(name_key TEXT, casrn TEXT, compound_name TEXT, endpoint TEXT, hit_call TEXT, ac50 TEXT)" | |
| ) | |
| cur.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)") | |
| cur.execute("DELETE FROM toxcast") | |
| cur.executemany( | |
| "INSERT INTO toxcast VALUES (?, ?, ?, ?, ?, ?)", | |
| [ | |
| (r["name_key"], r["casrn"], r["compound_name"], r["endpoint"], r["hit_call"], r["ac50"]) | |
| for r in rows | |
| ], | |
| ) | |
| cur.execute("DELETE FROM meta") | |
| cur.execute("INSERT INTO meta(key, value) VALUES('sig', ?)", (sig,)) | |
| conn.commit() | |
| conn.close() | |
| except sqlite3.Error: | |
| pass # cache write failure does not affect the main flow | |
| return rows | |
| def query_details(rows: list[dict[str, str]], query: str) -> list[dict[str, str]]: | |
| """Match by CAS or name, return de-duplicated details sorted by endpoint.""" | |
| q = query.strip() | |
| if is_probable_cas(q): | |
| matched = [r for r in rows if r["casrn"] == q] | |
| else: | |
| key = normalize_name(q) | |
| matched = [r for r in rows if r["name_key"] == key] | |
| if not matched: | |
| return [] | |
| seen: set[tuple[str, str]] = set() | |
| details: list[dict[str, str]] = [] | |
| for r in sorted(matched, key=lambda x: (x["endpoint"], x["ac50"])): | |
| key = (r["endpoint"], r["ac50"]) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| details.append( | |
| { | |
| "CompoundName": r["compound_name"], | |
| "CASRN": r["casrn"], | |
| "Endpoint": r["endpoint"], | |
| "AC50": r["ac50"], | |
| "HitCall": r["hit_call"], | |
| } | |
| ) | |
| return details | |
| def main() -> int: | |
| parser = argparse.ArgumentParser(description="Match and query AC50/HitCall from the ToxCast folder") | |
| parser.add_argument("--query", required=True, help="compound name or CASRN") | |
| parser.add_argument( | |
| "--toxcast-dir", | |
| default=None, | |
| help="ToxCast_AC50 folder path; auto-detect when omitted (project root or env TOXCAST_DIR)", | |
| ) | |
| parser.add_argument("--output", help="optional detail CSV output path") | |
| args = parser.parse_args() | |
| toxcast_dir = Path(args.toxcast_dir) if args.toxcast_dir else probe_toxcast_dir() | |
| if toxcast_dir is None or not toxcast_dir.is_dir(): | |
| print( | |
| json.dumps( | |
| { | |
| "ok": False, | |
| "error": ( | |
| "ToxCast_AC50 folder not found." | |
| "Specify it with --toxcast-dir, or set the TOXCAST_DIR environment variable." | |
| ), | |
| }, | |
| ensure_ascii=False, | |
| ) | |
| ) | |
| return 2 | |
| rows = load_index(toxcast_dir) | |
| details = query_details(rows, args.query) | |
| if not details: | |
| print(json.dumps({"ok": True, "query": args.query, "match_count": 0, "details": []}, ensure_ascii=False)) | |
| return 0 | |
| active_count = sum(1 for d in details if d["HitCall"].strip().lower() == "active") | |
| summary = { | |
| "ok": True, | |
| "query": args.query, | |
| "compound_name": details[0]["CompoundName"], | |
| "casrn": details[0]["CASRN"], | |
| "endpoint_count": len(details), | |
| "active_count": active_count, | |
| "details": details, | |
| } | |
| if args.output: | |
| out = Path(args.output) | |
| out.parent.mkdir(parents=True, exist_ok=True) | |
| cols = ["CompoundName", "CASRN", "Endpoint", "AC50", "HitCall"] | |
| with out.open("w", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=cols) | |
| writer.writeheader() | |
| for d in details: | |
| writer.writerow({c: d[c] for c in cols}) | |
| summary["output"] = str(out) | |
| print(json.dumps(summary, ensure_ascii=False, indent=2)) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |