diff --git a/prototypes/__init__.py b/prototypes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prototypes/simboard_bridge/README.md b/prototypes/simboard_bridge/README.md new file mode 100644 index 0000000..c174972 --- /dev/null +++ b/prototypes/simboard_bridge/README.md @@ -0,0 +1,100 @@ +# SimBoard ↔ uxarray-mcp bridge (prototype) + +Two thin tools that let an MCP agent chain +**SimBoard (find runs)** → **uxarray-mcp (analyze them)** in one conversation. + +This lives under `prototypes/` and is intentionally **not** wired into the +registry (`registry.py`) yet. It's a proof of concept to share with Tom Vo +(SimBoard lead) and Rob Jacob before committing to the integration. + +## What the demo looks like + +```text +User: "Find the latest completed Chrysalis run and compute its face areas." + +Agent → simboard_list_simulations(machine="chrysalis", status="completed", limit=5) +SimBoard returns the matching simulations with execution IDs and metadata. + +Agent → simboard_get_paths(sim_id="bae7fd01-...") +SimBoard returns: + { + "execution_id": "10011002.260610-072848", + "machine": "chrysalis", + "grid_name": "ne30pg2_r05_IcoswISC30E3r5", + "artifacts": { + "output": "/lcrc/group/e3sm/ac.golaz/.../run", + "archive": "/lcrc/group/e3sm/ac.golaz/.../archive/...", + } + } + +Agent → run_analysis(operation="calculate_area", + grid_path="/lcrc/group/.../grid.nc", + use_remote=True, endpoint="chrysalis") +uxarray-mcp computes on Chrysalis (ANL/LCRC) via Globus Compute. +``` + +Two providers, one conversation, no glue code on either side. + +The SimBoard REST API contract this bridge targets (the `-api` subdomain and +`/api/v1` prefix) is documented upstream in +[E3SM-Project/simboard#246](https://github.com/E3SM-Project/simboard/pull/246). + +## Layout + +- `client.py` — typed Python client against `simboard-dev-api.e3sm.org`. + Uses `urllib` (stdlib) — no new dep. +- `tools.py` — two MCP-shaped functions (`simboard_list_simulations`, + `simboard_get_paths`) that the server would expose. +- `example.py` — self-contained, **offline** demo of the full + discovery → analysis chain (mocked SimBoard + local HEALPix grid). + No network, no HPC, no data files needed. +- `smoke.py` — end-to-end smoke test against the live dev deployment. + +## Running + +Offline example (runs anywhere): + +```bash +uv run python -m prototypes.simboard_bridge.example +``` + +Live smoke test (needs network access to the SimBoard dev API): + +```bash +uv run python -m prototypes.simboard_bridge.smoke +``` + +## Endpoints used (all GET, no auth needed today) + +| Tool | SimBoard endpoint | +|---|---| +| `simboard_list_simulations` | `GET /api/v1/cases` (and `/api/v1/simulations`) | +| `simboard_get_paths` | `GET /api/v1/simulations/{sim_id}` | + +OpenAPI schema: +Frontend: + +## Status / next steps + +- [x] Confirmed live dev API at `simboard-dev-api.e3sm.org`. +- [x] Verified case + simulation list endpoints return rich metadata. +- [x] Verified simulation-detail returns archive/output/run_script paths + with machine identity (Perlmutter/NERSC). +- [ ] Decide on auth model for production (today the dev API is open; + production will need either OAuth token or service-account creds). +- [ ] Path translation: SimBoard returns NERSC paths; we'd route to the + `nersc`-named Globus Compute endpoint via uxarray-mcp's existing + `path_prefix` mechanism. No new code, just config. +- [ ] If we move past prototype: graduate `tools.py` to + `src/uxarray_mcp/tools/simboard.py`, add unit tests, decide whether + to expose as front-door tools or under `analyze_dataset` enrichment. + +## Why this stays out of the main server (for now) + +Until Tom blesses the integration, this is speculative work against a dev +deployment. Putting it in `src/` would advertise it as supported. The +prototype is enough to: + +1. Demo the workflow to Rob/Tom. +2. Validate the API surface is what we need. +3. Quantify the work it would take to ship. diff --git a/prototypes/simboard_bridge/__init__.py b/prototypes/simboard_bridge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prototypes/simboard_bridge/client.py b/prototypes/simboard_bridge/client.py new file mode 100644 index 0000000..e7da715 --- /dev/null +++ b/prototypes/simboard_bridge/client.py @@ -0,0 +1,84 @@ +"""Minimal HTTP client for the SimBoard REST API. + +Stdlib only — no new dependency. Reads JSON from +https://simboard-dev-api.e3sm.org/api/v1/ by default; override via the +SIMBOARD_API_BASE env var when the production deployment lands. + +This is a *prototype* client. It does not handle pagination cursors, +retries, auth tokens, or anything else a production client would need. +""" + +from __future__ import annotations + +import json +import os +import ssl +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +DEFAULT_BASE = "https://simboard-dev-api.e3sm.org/api/v1" + + +class SimBoardClient: + """Thin wrapper around the SimBoard REST API.""" + + def __init__( + self, + base_url: str | None = None, + token: str | None = None, + timeout_s: float = 15.0, + ) -> None: + self.base_url = ( + base_url or os.environ.get("SIMBOARD_API_BASE", DEFAULT_BASE) + ).rstrip("/") + self.token = token or os.environ.get("SIMBOARD_API_TOKEN") + self.timeout_s = timeout_s + self._ctx = ssl.create_default_context() + + # ---- low-level ---- + + def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: + url = f"{self.base_url}{path}" + if params: + clean = {k: v for k, v in params.items() if v is not None} + if clean: + url = f"{url}?{urllib.parse.urlencode(clean, doseq=True)}" + headers = {"User-Agent": "uxarray-mcp-simboard-bridge/0.1"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen( + req, context=self._ctx, timeout=self.timeout_s + ) as r: + return json.load(r) + except urllib.error.HTTPError as e: + body = e.read(500).decode(errors="ignore") if e.fp else "" + raise RuntimeError( + f"SimBoard GET {url} failed: HTTP {e.code} — {body[:200]}" + ) from e + except urllib.error.URLError as e: + raise RuntimeError(f"SimBoard GET {url} unreachable: {e.reason}") from e + + # ---- typed endpoints ---- + + def health(self) -> dict[str, Any]: + return self._get("/health") + + def list_cases(self) -> list[dict[str, Any]]: + return self._get("/cases") + + def list_simulations( + self, + case_name: str | None = None, + case_group: str | None = None, + ) -> list[dict[str, Any]]: + return self._get( + "/simulations", + params={"case_name": case_name, "case_group": case_group}, + ) + + def get_simulation(self, sim_id: str) -> dict[str, Any]: + return self._get(f"/simulations/{sim_id}") diff --git a/prototypes/simboard_bridge/example.py b/prototypes/simboard_bridge/example.py new file mode 100644 index 0000000..b9d74e0 --- /dev/null +++ b/prototypes/simboard_bridge/example.py @@ -0,0 +1,200 @@ +"""Offline, runnable example: SimBoard discovery → uxarray-mcp analysis. + +This showcases the full "find a run, then analyze it" workflow that the +SimBoard bridge enables, using the SimBoard REST API contract documented in +E3SM-Project/simboard#246 (the ``-api`` subdomain + ``/api/v1`` prefix). + +Unlike ``smoke.py`` (which hits the live dev deployment and would hand off to +an HPC endpoint), this example is **fully self-contained**: + +* SimBoard responses are mocked with a tiny in-process fake client, so no + network access is required and the documented JSON shapes are visible inline. +* The final analysis runs **locally** on a synthetic HEALPix grid + (``healpix:2``), so no data files and no Globus Compute endpoint are needed. + +The point is to demonstrate the conversational chain and the exact handoff +into ``uxarray-mcp.run_analysis`` without any external dependencies. + +Run it:: + + uv run python -m prototypes.simboard_bridge.example +""" + +from __future__ import annotations + +import json +from typing import Any + +from prototypes.simboard_bridge.tools import ( + simboard_get_paths, + simboard_list_simulations, +) + +# --------------------------------------------------------------------------- +# A minimal fake SimBoard client. +# +# It returns the same JSON shapes the real REST API returns (see the OpenAPI +# schema at https://simboard-dev-api.e3sm.org/openapi.json). Only the two +# endpoints the bridge uses are implemented: ``/cases`` and +# ``/simulations/{id}``. +# --------------------------------------------------------------------------- + +_FAKE_CASES: list[dict[str, Any]] = [ + { + "name": "v3.LR.piControl", + "machineNames": ["chrysalis"], + "hpcUsernames": ["golaz"], + "simulations": [ + { + "id": "bae7fd01-0000-4000-8000-000000000001", + "executionId": "10011002.260610-072848", + "status": "completed", + "simulationStartDate": "0001-01-01", + "simulationEndDate": "0050-12-31", + } + ], + }, + { + "name": "v3.HR.historical", + "machineNames": ["perlmutter"], + "hpcUsernames": ["whannah"], + "simulations": [ + { + "id": "bae7fd01-0000-4000-8000-000000000002", + "executionId": "54258454.260101-000000", + "status": "running", + "simulationStartDate": "1850-01-01", + "simulationEndDate": "2014-12-31", + } + ], + }, +] + +_FAKE_SIM_DETAILS: dict[str, dict[str, Any]] = { + "bae7fd01-0000-4000-8000-000000000001": { + "executionId": "10011002.260610-072848", + "caseName": "v3.LR.piControl", + "gridName": "ne30pg2_r05_IcoswISC30E3r5", + "gridResolution": "ne30", + "compset": "WCYCL1850", + "machine": { + "name": "chrysalis", + "site": "ANL-LCRC", + "scheduler": "slurm", + }, + "groupedArtifacts": { + "output": [ + {"uri": "/lcrc/group/e3sm/ac.golaz/v3.LR.piControl/run"}, + ], + "archive": [ + {"uri": "/lcrc/group/e3sm/ac.golaz/v3.LR.piControl/archive/atm"}, + ], + "run_script": [ + {"uri": "/home/ac.golaz/E3SM/v3.LR.piControl.run"}, + ], + }, + "links": [ + {"label": "diagnostics", "url": "https://web.lcrc.anl.gov/.../e3sm_diags"}, + ], + } +} + + +class FakeSimBoardClient: + """In-process stand-in for ``SimBoardClient`` — returns canned JSON.""" + + base_url = "https://simboard-dev-api.e3sm.org/api/v1 (mocked)" + + def list_cases(self) -> list[dict[str, Any]]: + return _FAKE_CASES + + def get_simulation(self, sim_id: str) -> dict[str, Any]: + return _FAKE_SIM_DETAILS[sim_id] + + +# --------------------------------------------------------------------------- +# The workflow. +# --------------------------------------------------------------------------- + + +def main() -> int: + client = FakeSimBoardClient() + + print("=" * 70) + print("SimBoard -> uxarray-mcp (offline example)") + print("=" * 70) + print(f"SimBoard REST API: {client.base_url}\n") + print( + 'User: "Find the latest completed Chrysalis run and check its\n' + ' mesh + face areas."\n' + ) + + # ---- Step 1: discover a simulation ----------------------------------- + print("[1] simboard_list_simulations(machine='chrysalis', status='completed')") + listing = simboard_list_simulations( + machine="chrysalis", + status="completed", + limit=5, + client=client, # type: ignore[arg-type] + ) + print(f" -> {listing['count']} match(es)") + for sim in listing["simulations"]: + print( + f" {sim['execution_id']} " + f"case={sim['case_name']} status={sim['status']}" + ) + + pick = listing["simulations"][0] + print(f"\n Agent picks: sim_id={pick['sim_id']}\n") + + # ---- Step 2: resolve paths + machine --------------------------------- + print(f"[2] simboard_get_paths(sim_id='{pick['sim_id']}')") + paths = simboard_get_paths(pick["sim_id"], client=client) # type: ignore[arg-type] + print( + " -> grid={grid_name!r} compset={compset!r}\n" + " machine={machine_hint!r} site={machine_site!r} " + "scheduler={machine_scheduler!r}".format(**paths) + ) + for kind, uris in (paths.get("artifacts") or {}).items(): + for uri in uris: + print(f" [{kind}] {uri}") + + machine = paths["machine_hint"] + print( + f"\n In production the agent maps machine {machine!r} -> the " + "Globus Compute\n endpoint registered as 'chrysalis' and calls " + "run_analysis(use_remote=True).\n Path-prefix routing handles " + "/lcrc/group/... automatically.\n" + ) + + # ---- Step 3: run the analysis ---------------------------------------- + # For this offline demo we analyze a synthetic HEALPix grid locally + # instead of the (unreachable) Chrysalis path, so the example runs anywhere. + from uxarray_mcp.tools.frontdoor import run_analysis + + demo_grid = "healpix:2" + print(f"[3] run_analysis(operation='inspect_mesh', grid_path={demo_grid!r})") + print(" (offline demo grid; production would use the resolved Chrysalis path)") + mesh = run_analysis(operation="inspect_mesh", grid_path=demo_grid) + print( + f" -> format={mesh['format']} n_face={mesh['n_face']} " + f"n_node={mesh['n_node']}" + ) + + print(f"\n[4] run_analysis(operation='calculate_area', grid_path={demo_grid!r})") + area = run_analysis(operation="calculate_area", grid_path=demo_grid) + print( + f" -> total_area={area['total_area']:.4e} {area['area_units']} " + f"mean_area={area['mean_area']:.4e}" + ) + + print("\n" + "=" * 70) + print("Done. Two providers (SimBoard + uxarray-mcp), one conversation.") + print("=" * 70) + print("\nProvenance from the final area result:") + print(json.dumps(area.get("_provenance", {}), indent=2, default=str)[:800]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/prototypes/simboard_bridge/smoke.py b/prototypes/simboard_bridge/smoke.py new file mode 100644 index 0000000..295e0fa --- /dev/null +++ b/prototypes/simboard_bridge/smoke.py @@ -0,0 +1,90 @@ +"""End-to-end smoke test for the SimBoard bridge prototype. + +Runs against the live dev deployment. Walks the conversational chain: + + list_simulations() → pick the newest Perlmutter completed run + get_paths(sim_id) → resolve archive/output paths + machine + (would hand off to uxarray-mcp.run_analysis here) + +Prints a compact transcript suitable for pasting into Slack / a slide. +""" + +from __future__ import annotations + +import json +import sys + +from prototypes.simboard_bridge.client import SimBoardClient +from prototypes.simboard_bridge.tools import ( + simboard_get_paths, + simboard_list_simulations, +) + + +def main() -> int: + client = SimBoardClient() + print(f"# SimBoard bridge smoke — {client.base_url}\n") + + # ---- 1. Health ---- + print("[1] health check") + h = client.health() + print(f" -> {h}\n") + + # ---- 2. List Perlmutter, completed ---- + print( + "[2] simboard_list_simulations(machine='perlmutter', status='completed', limit=3)" + ) + lst = simboard_list_simulations( + machine="perlmutter", status="completed", limit=3, client=client + ) + print(f" -> {lst['count']} simulations") + for s in lst["simulations"]: + print( + f" {s['execution_id']} user={s['hpc_usernames']} " + f"case={s['case_name'][:60]}" + ) + + if not lst["simulations"]: + print("\nFAIL — no simulations returned. Aborting.") + return 1 + pick = lst["simulations"][0] + print(f"\n Picking the first one: sim_id={pick['sim_id']}\n") + + # ---- 3. Get paths ---- + print(f"[3] simboard_get_paths(sim_id='{pick['sim_id']}')") + paths = simboard_get_paths(pick["sim_id"], client=client) + print( + " -> machine_hint={machine_hint!r} site={machine_site!r} " + "grid={grid_name!r} compset={compset!r}".format(**paths) + ) + print(" -> artifacts by kind:") + for kind, uris in (paths.get("artifacts") or {}).items(): + print(f" [{kind}] {len(uris)} uri(s)") + for u in uris[:2]: + print(f" {u}") + + # ---- 4. What we'd hand off to uxarray-mcp ---- + print("\n[4] Hand-off plan to uxarray-mcp.run_analysis:") + output_paths = (paths.get("artifacts") or {}).get("output") or [] + if output_paths: + print(" Would call (pseudo):") + print( + f" run_analysis(operation='inspect_mesh',\n" + f" grid_path='',\n" + f" use_remote=True, endpoint='nersc')" + ) + print( + " Path-prefix routing on the uxarray-mcp side handles the\n" + " /pscratch/sd/... → NERSC endpoint mapping; no new code needed." + ) + else: + print(" (no output artifact for this sim — would need a different one)") + + print("\nPASS") + print("\nFull get_paths response (for reference):") + print(json.dumps(paths, indent=2, default=str)[:1500]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/prototypes/simboard_bridge/tools.py b/prototypes/simboard_bridge/tools.py new file mode 100644 index 0000000..f29158d --- /dev/null +++ b/prototypes/simboard_bridge/tools.py @@ -0,0 +1,176 @@ +"""Prototype MCP-shaped tools that bridge SimBoard → uxarray-mcp. + +If/when graduated, these would live at ``src/uxarray_mcp/tools/simboard.py``. +For now they sit under ``prototypes/`` to keep them out of the supported +server surface until Tom Vo blesses the integration. + +The two tools, in plain English: + +- ``simboard_list_simulations`` — "find me runs matching X". Returns a + ranked list of simulations with enough metadata for an AI to pick one. +- ``simboard_get_paths`` — "given this simulation id, where are its files + and what machine do I run on?". Returns the data needed to hand off to + ``uxarray-mcp.run_analysis``. + +Filtering today is **client-side** because SimBoard's current list APIs +only take ``case_name``/``case_group``. As their API grows we'll push +filters down server-side and remove the post-filter pass below. +""" + +from __future__ import annotations + +from typing import Any + +from prototypes.simboard_bridge.client import SimBoardClient + + +def simboard_list_simulations( + grid_name: str | None = None, + machine: str | None = None, + hpc_username: str | None = None, + compset: str | None = None, + status: str | None = "completed", + limit: int = 10, + client: SimBoardClient | None = None, +) -> dict[str, Any]: + """Find E3SM simulations in the SimBoard catalog matching the given filters. + + Use this when the user asks "find runs that...", "what simulations are + available for...", or otherwise wants to *discover* a simulation before + analyzing it. The returned ``simulations`` list contains enough metadata + (grid_name, machine, status, execution_id) for the AI to pick one, then + call ``simboard_get_paths`` to get file locations. + + Args: + grid_name: substring match against the mesh/grid name + (e.g. ``"oRRS18to6"``, ``"ne30pg2"``). Case-insensitive. + machine: HPC machine name (e.g. ``"perlmutter"``, ``"chrysalis"``). + hpc_username: HPC username that owns the run. + compset: substring match against the E3SM compset (e.g. ``"F2010"``). + status: filter by status (default ``"completed"``; + pass ``None`` to include all). + limit: max number of simulations to return. + client: injected SimBoardClient for testing. + + Returns: + A dict with ``count``, ``simulations`` (list of summary dicts), and + ``_provenance`` (source URL + filter echoes). + """ + client = client or SimBoardClient() + cases = client.list_cases() + + out: list[dict[str, Any]] = [] + for case in cases: + case_machines = [m.lower() for m in (case.get("machineNames") or [])] + case_users = [u.lower() for u in (case.get("hpcUsernames") or [])] + if machine and machine.lower() not in case_machines: + continue + if hpc_username and hpc_username.lower() not in case_users: + continue + for sim in case.get("simulations") or []: + if status and (sim.get("status") or "").lower() != status.lower(): + continue + row = { + "sim_id": sim.get("id"), + "execution_id": sim.get("executionId"), + "case_name": case.get("name"), + "machine_names": case.get("machineNames"), + "hpc_usernames": case.get("hpcUsernames"), + "status": sim.get("status"), + "simulation_start_date": sim.get("simulationStartDate"), + "simulation_end_date": sim.get("simulationEndDate"), + } + out.append(row) + + # grid_name and compset live on the sim-detail object, not on the case + # list. Filter by enriching the top N candidates on demand to avoid + # hammering the API. + if grid_name or compset: + enriched: list[dict[str, Any]] = [] + for row in out: + sim = client.get_simulation(row["sim_id"]) + if ( + grid_name + and grid_name.lower() not in (sim.get("gridName") or "").lower() + ): + continue + if compset and compset.lower() not in (sim.get("compset") or "").lower(): + continue + row["grid_name"] = sim.get("gridName") + row["compset"] = sim.get("compset") + enriched.append(row) + if len(enriched) >= limit: + break + out = enriched + else: + out = out[:limit] + + return { + "count": len(out), + "simulations": out, + "_provenance": { + "tool": "simboard_list_simulations", + "source_api": client.base_url, + "filters": { + "grid_name": grid_name, + "machine": machine, + "hpc_username": hpc_username, + "compset": compset, + "status": status, + "limit": limit, + }, + }, + } + + +def simboard_get_paths( + sim_id: str, + client: SimBoardClient | None = None, +) -> dict[str, Any]: + """Resolve a SimBoard simulation id to its archive/output paths and host machine. + + Use this after ``simboard_list_simulations`` (or any time you have a + simulation id) to get the file locations and machine identity needed + to hand off to ``uxarray-mcp.run_analysis(use_remote=True, endpoint=...)``. + + The returned ``machine_hint`` is the SimBoard machine name; the caller + is responsible for mapping it to a Globus Compute endpoint configured + in uxarray-mcp (e.g. ``"perlmutter"`` → ``"nersc"`` if registered). + + Args: + sim_id: simulation UUID (from ``simboard_list_simulations``). + client: injected SimBoardClient for testing. + + Returns: + Dict with keys ``execution_id``, ``case_name``, ``grid_name``, + ``compset``, ``machine_hint``, ``machine_site``, ``artifacts`` + (mapped by kind: ``output``, ``archive``, ``run_script``, ...), + ``links``, and ``_provenance``. + """ + client = client or SimBoardClient() + sim = client.get_simulation(sim_id) + + grouped = sim.get("groupedArtifacts") or {} + artifacts_by_kind: dict[str, list[str]] = {} + for kind, items in grouped.items(): + artifacts_by_kind[kind] = [a.get("uri") for a in (items or []) if a.get("uri")] + + machine = sim.get("machine") or {} + return { + "sim_id": sim_id, + "execution_id": sim.get("executionId"), + "case_name": sim.get("caseName"), + "grid_name": sim.get("gridName"), + "grid_resolution": sim.get("gridResolution"), + "compset": sim.get("compset"), + "machine_hint": machine.get("name"), + "machine_site": machine.get("site"), + "machine_scheduler": machine.get("scheduler"), + "artifacts": artifacts_by_kind, + "links": sim.get("links") or [], + "_provenance": { + "tool": "simboard_get_paths", + "source_api": client.base_url, + "sim_id": sim_id, + }, + }