Python

No client library to install: the API is plain HTTP and JSON. This page uses httpx and polars; the same works with requests and pandas.

pip install httpx polars
export PTARMIGAN_API_KEY=...   # from https://ptarmigan.app/api-keys

The examples read the key from that environment variable. Don't write it into a script or notebook you might share; if it may have leaked, revoke it on the API keys page and create a new one.

Send a descriptive User-Agent too. Cloudflare can block HTTP-library defaults before a request reaches the API; see Errors.

Getting-started notebook

Download the getting-started notebook (.ipynb) and open it in Jupyter, VS Code or Colab. It walks through the same steps as the script below, one cell at a time: balance, a target and a library, an estimate, a screen, the top hits, the full ranking with a plot, and one compound residue by residue. It is already set up for https://ptarmigan.app.

A complete script

Screens the smallest ready library against EGFR and prints the top ten. The call helper turns API errors into exceptions and retries what is worth retrying; because the screen is queued with one Idempotency-Key, a retry never pays for a second screen.

import io
import os
import time
import uuid
from urllib.parse import quote

import httpx
import polars as pl

BASE = "https://ptarmigan.app"
USER_AGENT = "acme-screen-python/1.0"
client = httpx.Client(
    base_url=BASE,
    headers={
        "Authorization": "Bearer " + os.environ["PTARMIGAN_API_KEY"],
        "User-Agent": USER_AGENT,
    },
    timeout=60,
)


class ApiError(Exception):
    pass


def call(method, path, **kwargs):
    """One API request. Retries a dropped connection, and a 502, 503 or 504
    (after its Retry-After); raises ApiError with the API's code otherwise."""
    for attempt in range(5):
        try:
            response = client.request(method, path, **kwargs)
        except httpx.TransportError:
            time.sleep(2**attempt)
            continue
        if response.status_code in (502, 503, 504):
            time.sleep(int(response.headers.get("Retry-After", "5")))
            continue
        if response.is_error:
            try:
                error = response.json()
                detail = f"{error['code']}: {error['message']}"
            except (ValueError, KeyError, TypeError):
                # Not the API's own error: a proxy's page, for one.
                detail = response.text[:200]
            raise ApiError(f"{response.status_code} {detail}")
        return response.json()
    raise ApiError(f"{method} {path} kept failing; try again later")


def items(path, **params):
    """Read every page, keeping search filters unchanged."""
    while True:
        page = call("GET", path, params=params)
        yield from page["data"]
        if page["next_after"] is None:
            return
        params["after"] = page["next_after"]


target = next(items("/api/v1/proteins", search="EGFR"))
libraries = items("/api/v1/compound-collections")
library = min((c for c in libraries if c["status"] == "ready"), key=lambda c: c["size"])
body = {"protein_id": target["id"], "collection_id": library["id"]}

estimate = call("POST", "/api/v1/screens/estimate", json=body)
print(f"{estimate['compounds']:,} compounds for ${estimate['cost_usd']}")
if not estimate["sufficient"]:
    raise SystemExit(f"Add funds at {BASE}/billing")

key = str(uuid.uuid4())  # one key per screen; every retry reuses it
screen = call("POST", "/api/v1/screens", json=body, headers={"Idempotency-Key": key})

delay = 2
while screen["status"] in ("queued", "running"):
    time.sleep(delay)
    delay = min(delay * 2, 30)
    screen = call("GET", f"/api/v1/screens/{screen['id']}")
if screen["status"] != "completed":
    raise SystemExit(f"The screen {screen['status']}; nothing was charged.")

link = call(
    "GET", f"/api/v1/screens/{screen['id']}/download", params={"format": "parquet"}
)
hits = pl.read_parquet(
    io.BytesIO(httpx.get(link["url"], headers={"User-Agent": USER_AGENT}).content)
)
print(hits.head(10).select("rank", "compound_id", "name", "engagement"))

Working with the results

The download has one row per compound, already sorted by rank. With polars:

hits.filter(pl.col("engagement") > 0.5)  # strong predictions
hits.filter(pl.col("name").str.contains("nib"))  # by name
hits.write_csv("ranking.csv")

With pandas, read the same file with pandas.read_parquet. To read only the top of a ranking without downloading it, page through GET /api/v1/screens/{screen_id}/hits (see REST with curl).

One compound, residue by residue

detail = call(
    "GET",
    f"/api/v1/screens/{screen['id']}/compounds/{quote(hits['compound_id'][0], safe='')}",
    params={"include_residues": "true"},
)
detail["residue_engagement"]  # one value per residue; None where not scored

quote keeps an id containing #, ? or / in one piece.

See Reading the results for what each value means, and Errors for every code ApiError can carry.