From a568adc81d19f4cab8e86e4ca95b4f45f104dab8 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 11 Sep 2026 14:28:46 -0500 Subject: [PATCH 1/9] Let the globus CLI own Globus Transfer's credentials The SDK backend requested a data_access scope for the local collection, which Globus Connect Personal collections do not have, so every transfer failed once a local collection was configured. The CLI already tracks which collections need which scopes, so this holds no auth code at all and `uxarray-mcp transfer setup` walks the seven prerequisites instead. --- CHANGELOG.md | 39 +- docs/data-transfer.md | 139 ++++++ docs/index.rst | 1 + docs/remote-hpc.md | 4 +- pyproject.toml | 12 +- src/uxarray_mcp/cli.py | 36 ++ src/uxarray_mcp/remote/gcp.py | 194 ++++++++ src/uxarray_mcp/remote/transfer.py | 259 ++++++++-- src/uxarray_mcp/tools/execution_control.py | 41 +- src/uxarray_mcp/transfer_setup.py | 545 +++++++++++++++++++++ tests/test_globus_transfer.py | 256 ++++++++++ tests/test_transfer_setup.py | 226 +++++++++ tests/test_transfer_tools.py | 2 +- uv.lock | 37 +- 14 files changed, 1715 insertions(+), 76 deletions(-) create mode 100644 docs/data-transfer.md create mode 100644 src/uxarray_mcp/remote/gcp.py create mode 100644 src/uxarray_mcp/transfer_setup.py create mode 100644 tests/test_transfer_setup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 27ecf88..e45aa46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,9 @@ built against; see `docs/release.md`. Versions through `0.3.1` were SemVer. half-made task. `validate_hpc_setup` (behind `doctor`) gains a transfer check: it passes when nothing is configured, since transfers are opt-in the way HPC is, and fails on a configured transfer that cannot work -- no write - root, no SDK, no consent, or a write root the collection will not list. The + root, no `globus` CLI, no login or consent, or a write root the collection + will not list, and it reports whether this machine's Globus Connect Personal + share is writable, since that is invisible from the Globus side. The write root is readable as well as writable, which the doctor probe found the hard way: somewhere you may put a file is somewhere you may look at one. - Files can now move between this machine and an HPC collection. Compute has @@ -47,6 +49,41 @@ built against; see `docs/release.md`. Versions through `0.3.1` were SemVer. count stated. The service builds its submission payload as a plain dict after an explicit `get_submission_id()`, which keeps the wire shape in one place and lets all 44 tests run against a fake client with no credentials in CI. +- The client behind that service runs the `globus` command-line client rather + than linking `globus-sdk`, so this package holds no authentication code and + stores no tokens. Written against the SDK first, and every one of the three + defects hand-verification found was in the ~40 lines of auth: it requested a + `data_access` scope for the *local* collection, which Globus Connect Personal + collections do not have, so adding a local collection ID made every transfer + fail permanently; it modelled one credential failure, so a collection refusing + a session identity died with a raw 40-line `TransferAPIError` instead of + saying which identity to link; and it directed users to a `transfer-login` + command that did not exist. Which collections need which scopes, and what a + session policy will accept, are rules the CLI already implements and a + two-person project would otherwise have to track. Failures now carry the CLI's + own words -- the same text the Globus documentation and a facility support + ticket already use -- and the three that need a browser are told apart from + the ones that do not, because sending someone to a browser to fix a missing + directory wastes the trip. +- `uxarray-mcp transfer setup` is the command that error message now names. It + walks the seven things that must be true before a file can move -- the CLI, a + login, Globus Connect Personal running with a writable share, both collection + UUIDs, consent for the remote collection, and the config block -- reports + which already hold, and offers to fix the rest; `--check` reports without + asking or changing anything. Facility collection UUIDs for NCAR, ALCF Polaris, + ALCF Aurora and NERSC are a table rather than a name search, which returns a + page of look-alikes. The share check is the reason for the command: Globus + Connect Personal shares `$HOME` read-only by default and says nothing about + it, so uploads work, downloads fail on the destination write hours later with + `PERMISSION_DENIED`, and it reads like a network problem. Checked on macOS + (the `org.globusonline.Globus-Connect` preferences domain) as well as Linux + (`~/.globusonline/lta/config-paths`). +- Relative local paths resolve against `local_root` when one is configured, + matching what the remote side has always done with `remote_write_root`. They + previously resolved against the process working directory, which for a server + started by an MCP client is wherever that client was launched from: a + different directory per client, invisible to the caller, and never the one + they meant. ### Changed - Releases now follow upstream instead of the calendar. The workflow polled on diff --git a/docs/data-transfer.md b/docs/data-transfer.md new file mode 100644 index 0000000..364796d --- /dev/null +++ b/docs/data-transfer.md @@ -0,0 +1,139 @@ +# Moving Files — Globus Transfer + +Globus Compute and Globus Transfer are two services. Compute runs code where +the data already is; Transfer copies files between machines. They have separate +logins and separate consents, so **being able to run a job on a cluster does not +let you copy a file to it**, and a working `uxarray-mcp doctor` says nothing +about whether a transfer will succeed. + +Most people never need this page. Remote analysis is the point of the HPC +support — you leave the data where it is and get a number or a plot back. Set +transfers up when you actually have a file to move: a small input to stage in, +or a result to bring home. + +## What you get + +Four tools, once it is configured: + +| tool | does | +|---|---| +| `transfer_ls` | list a directory on the cluster | +| `transfer_put` | upload a file from this machine | +| `transfer_get` | download a file to this machine | +| `transfer_status` | poll a task to `SUCCEEDED` or `FAILED` | + +Transfers are asynchronous. `transfer_put` and `transfer_get` return a task ID +immediately; `transfer_status` is how you find out what happened. + +## Setup + +```bash +uv tool install --python 3.12 --extra transfer uxarray-mcp +uxarray-mcp transfer setup --endpoint NAME +``` + +`NAME` is the endpoint you already registered with `endpoints add` (see +[remote-hpc.md](remote-hpc.md)). + +The command walks seven prerequisites, says which already hold, and offers to +fix the rest. Add `--check` to report without changing or asking anything: + +```text + [ok ] globus CLI -- /opt/homebrew/bin/globus + [TODO] globus login -- not logged in + fix: globus login + [ok ] facility collection -- NCAR GLADE d33b3614-6d04-11e5-ba46-22000b92c6ec + [ok ] Globus Connect Personal -- running + [TODO] /Users/you writable -- /Users/you is shared READ-ONLY, so downloads here will be refused + fix: Globus Connect Personal -> Preferences -> Access, select /Users/you, + tick Writable, then restart it + [ok ] this machine's collection -- 58bfc3d1-... + [ok ] config -- ucar-uxarray-yac in ~/.config/uxarray-mcp/config.yaml +``` + +### This package stores no credentials + +Every authentication step is handed to the [`globus` command-line +client](https://docs.globus.org/cli/), which already owns your tokens, your +consents and your session. That is deliberate. Globus auth has scopes that exist +on one kind of collection and not another, and identity policies that only +surface after a transfer has been submitted; those rules change, and the CLI +already tracks them. Nothing here holds a token, and `uxarray-mcp transfer +setup` runs `globus login` rather than reimplementing it. + +The consequence worth knowing: the login is shared with anything else you use +the `globus` CLI for, and `globus logout` breaks transfers here too. + +### Both ends need a collection + +A transfer is between two *collections*, and a collection UUID is not an +endpoint UUID — a Globus Compute endpoint ID says nothing about which collection +serves the filesystem it runs on. + +The facility end is looked up from a table for NCAR, ALCF Polaris, ALCF Aurora +and NERSC; anywhere else, the command asks and you paste the UUID from +**Collections** in the [Globus web app](https://app.globus.org). Searching by +name is a trap: facilities publish a guest collection per project and users +publish their own, so "GLADE" matches a page of look-alikes. + +Your end is [Globus Connect +Personal](https://www.globus.org/globus-connect-personal), which makes your +laptop a collection. `transfer setup` reads its UUID out of +`~/.globusonline/lta/client-id.txt` so you do not have to find it. + +### The write bit + +Globus Connect Personal's default share is your home directory **read-only**, +and nothing warns you. Uploads work, because they only read here. Downloads fail +on the destination write, hours later, with `PERMISSION_DENIED` — which reads +like a network problem and is not. `transfer setup` checks this on both macOS +and Linux and is the reason the command exists. + +## Paths + +`remote_write_root` is a containment boundary, not a default directory. A path +outside it is refused, not relocated into it. `remote_read_root` widens what may +be read without widening what may be written — the usual case is a project tree +you can read and one scratch subtree you can write. + +Relative paths resolve against those roots on the remote side and against +`local_root` on this side. Absolute paths are checked against the roots and +refused if they fall outside, so `transfer_ls('/etc/passwd')` comes back as a +refusal rather than a listing. + +The config block `transfer setup` writes: + +```yaml +hpc: + endpoints: + ucar-uxarray-yac: + endpoint_id: ... + globus_transfer: + remote_collection_id: d33b3614-6d04-11e5-ba46-22000b92c6ec + local_collection_id: 58bfc3d1-... + remote_write_root: /glade/derecho/scratch/you + remote_read_root: /glade + local_root: /Users/you +``` + +Some collections expose a subtree as their own `/`. `collection_roots` lists the +filesystem prefixes to strip, so `/lus/eagle/projects/x/run.nc` is sent as +`/x/run.nc`. Paths under no configured root are passed through unchanged: a +wrong translation is worse than none, because Globus rejects a path it does not +recognise but happily reads a rewritten one that names the wrong real file. + +## When it breaks + +Run `uxarray-mcp doctor --endpoint NAME`. Its `transfer` check reports the +collections, the roots, and whether your local share is writable. + +Three failures look unrelated and are the same problem — no tokens, no consent +for a collection's `data_access` scope, and a session identity the collection +refuses. All three need a browser, which an MCP server does not have, so they +surface as one error telling you to run `uxarray-mcp transfer setup` in a +terminal. + +A task that reaches `nice_status: TIMEOUT` on `STOR` is usually neither: Globus +negotiates data channels on ports separate from the control connection, and a +corporate VPN commonly blocks them. Retry off the VPN before looking anywhere +else. diff --git a/docs/index.rst b/docs/index.rst index 520ff51..aa48b9d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,6 +24,7 @@ HTTP clients) from a single install. :caption: Running on HPC (optional) remote-hpc + data-transfer operating-an-endpoint improv ucar diff --git a/docs/remote-hpc.md b/docs/remote-hpc.md index 46162d0..21988f3 100644 --- a/docs/remote-hpc.md +++ b/docs/remote-hpc.md @@ -265,7 +265,9 @@ uxarray-mcp diagnose-endpoint --endpoint NAME --action validate Use **remote** when: - The data lives on the HPC filesystem and is large (GB+). -- You'd otherwise need to Globus Transfer files to your laptop first. +- You'd otherwise need to Globus Transfer files to your laptop first. (If you do + need to move files, that is a separate service with a separate login — see + [data-transfer.md](data-transfer.md).) - The analysis benefits from cluster CPU/memory. Use **local** when: diff --git a/pyproject.toml b/pyproject.toml index 9afeb30..d8a14c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,13 +71,13 @@ hpc = [ "academy-py>=0.3.1", "globus-compute-sdk>=4.5.0", ] -# Moving files is separate from running code on them: an install that only -# needs remote compute should not pull a second Globus SDK, and a transfer -# client is useless without the Compute login it reuses, so this extra is -# additive to `hpc` rather than a replacement for it. +# Moving files is separate from running code on them, and is done by running +# the `globus` command-line client rather than by linking a second SDK. The CLI +# owns the tokens, the consents and the session, so this package holds no auth +# code at all -- which is the only reason a two-person project can offer data +# movement without also signing up to track Globus's scope rules. transfer = [ - "globus-compute-sdk>=4.5.0", - "globus-sdk>=3.40.0", + "globus-cli>=3.30", ] docs = [ "sphinx>=7.0", diff --git a/src/uxarray_mcp/cli.py b/src/uxarray_mcp/cli.py index 4f3bf70..1bfbef1 100644 --- a/src/uxarray_mcp/cli.py +++ b/src/uxarray_mcp/cli.py @@ -6,6 +6,7 @@ - ``setup`` — write a minimal user config to ``~/.config/uxarray-mcp/config.yaml`` - ``doctor`` — validate local Globus auth, endpoint health, and optional remote probes - ``endpoints`` — manage named Globus Compute endpoints in the user config +- ``transfer setup`` — get Globus Transfer working: CLI, login, consent, config - ``install-claude`` — print or write the Claude Desktop ``mcpServers`` block The CLI is registered via the ``uxarray-mcp`` entry point in pyproject.toml. @@ -29,6 +30,7 @@ discover_config_search_paths, load_config, ) +from uxarray_mcp.transfer_setup import cmd_transfer_setup def _read_user_config(path: Path) -> dict[str, Any]: @@ -381,6 +383,40 @@ def build_parser() -> argparse.ArgumentParser: ep_remove.add_argument("name") ep_remove.set_defaults(func=cmd_endpoints_remove) + # Globus Transfer is its own service with its own login, so it gets its own + # noun rather than another flag on `setup`, which configures compute. + transfer = sub.add_parser( + "transfer", help="Set up Globus Transfer (file movement)." + ) + tr_sub = transfer.add_subparsers(dest="transfer_command", required=True) + + tr_setup = tr_sub.add_parser( + "setup", + help="Check and fix everything a transfer needs.", + description=( + "Walk the seven things that must be true before a file can move: " + "the globus CLI, a login, Globus Connect Personal running with a " + "writable share, both collection UUIDs, consent for the remote " + "collection, and the config block. Offers to fix each." + ), + ) + tr_setup.add_argument( + "--endpoint", + default=None, + help="Named endpoint to configure, as used by `endpoints add`.", + ) + tr_setup.add_argument( + "--check", + action="store_true", + help="Report only; change nothing and ask nothing.", + ) + tr_setup.add_argument( + "--yes", + action="store_true", + help="Take every default without asking. Skips anything needing a browser.", + ) + tr_setup.set_defaults(func=cmd_transfer_setup) + doctor = sub.add_parser( "doctor", help="Check that the install is healthy (local-only or HPC).", diff --git a/src/uxarray_mcp/remote/gcp.py b/src/uxarray_mcp/remote/gcp.py new file mode 100644 index 0000000..8ef6908 --- /dev/null +++ b/src/uxarray_mcp/remote/gcp.py @@ -0,0 +1,194 @@ +"""What Globus Connect Personal is doing on this machine, read from its own files. + +Globus Connect Personal is what makes a laptop one end of a transfer. It is a +separate program with its own configuration, and three of its states are +invisible from the Globus side of the wire while being the whole reason a +transfer fails: + +* it is not running, so the collection exists and answers nothing; +* the directory being transferred is not shared, so the path is not there; +* the directory is shared **read-only**, so a download fails on the write and + an upload from it succeeds -- one direction works and the other does not, + which reads like a network problem and is not. + +The third is the one that costs an afternoon. The default share on a fresh +install is the user's home directory with the write bit clear, and nothing +warns about it until a transfer has already been submitted, run, and failed +with ``PERMISSION_DENIED`` on the destination. + +Everything here reads; nothing here changes Globus Connect Personal's +configuration. Where a value cannot be established -- an unfamiliar platform, a +file that is not there -- the answer is ``None``, meaning "unknown", never +``False``. A confident wrong "no" would send someone to fix the wrong thing. +""" + +from __future__ import annotations + +import plistlib +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +# Injected in tests so none of this needs a real Globus Connect Personal, and +# `None` means "use the real one" rather than "do nothing". +Runner = Callable[..., Any] | None + +__all__ = [ + "GcpShare", + "MACOS_DEFAULTS_DOMAIN", + "collection_id", + "config_paths_file", + "is_running", + "share_for", + "shares", +] + +MACOS_DEFAULTS_DOMAIN = "org.globusonline.Globus-Connect" +_LTA_DIR = Path.home() / ".globusonline" / "lta" + + +@dataclass(frozen=True) +class GcpShare: + """One directory Globus Connect Personal exposes, and on what terms.""" + + path: str + readable: bool + writable: bool + + +def collection_id() -> str | None: + """This machine's collection UUID, as Globus Connect Personal recorded it. + + Reading the file beats asking the user to find it in the web app, where + their own collections sit in a list with everyone else's. + """ + try: + value = (_LTA_DIR / "client-id.txt").read_text(encoding="utf-8").strip() + except OSError: + return None + return value or None + + +def config_paths_file() -> Path: + """Where the Linux build keeps its share list.""" + return _LTA_DIR / "config-paths" + + +def _shares_from_config_paths() -> list[GcpShare] | None: + """Parse ``~/.globusonline/lta/config-paths`` -- ``,,``.""" + try: + text = config_paths_file().read_text(encoding="utf-8") + except OSError: + return None + found: list[GcpShare] = [] + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = [part.strip() for part in line.split(",")] + path = str(Path(parts[0]).expanduser()) + # A malformed or truncated line is read the permissive way the program + # itself reads it, rather than being dropped: a share we fail to see is + # a warning we give about a directory that is actually fine. + writable = parts[2] == "1" if len(parts) > 2 else True + found.append(GcpShare(path=path, readable=True, writable=writable)) + return found + + +def _shares_from_macos_defaults(runner: Runner = None) -> list[GcpShare] | None: + """Parse ``GC_RESTRICTED_PATHS`` out of the macOS preferences domain. + + ``defaults export`` is used rather than ``defaults read`` because it emits + an XML property list, which ``plistlib`` parses exactly; ``defaults read`` + prints an ad-hoc format that has to be guessed at. + + Each entry maps a directory to a ``(read, write)`` pair of flags. + """ + run = runner or subprocess.run + try: + proc = run( + ["defaults", "export", MACOS_DEFAULTS_DOMAIN, "-"], + capture_output=True, + timeout=20, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0 or not proc.stdout: + return None + raw = proc.stdout + if isinstance(raw, str): + raw = raw.encode("utf-8") + try: + parsed = plistlib.loads(raw) + except Exception: + return None + restricted = parsed.get("GC_RESTRICTED_PATHS") + if not isinstance(restricted, dict): + return None + found: list[GcpShare] = [] + for path, flags in restricted.items(): + pair = list(flags) if isinstance(flags, (list, tuple)) else [flags] + readable = bool(pair[0]) if pair else False + writable = bool(pair[1]) if len(pair) > 1 else False + found.append( + GcpShare( + path=str(Path(str(path)).expanduser()), + readable=readable, + writable=writable, + ) + ) + return found + + +def shares(runner: Runner = None) -> list[GcpShare] | None: + """Every directory this machine exposes, or ``None`` if it cannot be read.""" + if sys.platform == "darwin": + found = _shares_from_macos_defaults(runner) + # A macOS install that also has the Linux-style file is unusual but + # cheap to honour, and beats reporting nothing. + return found if found is not None else _shares_from_config_paths() + return _shares_from_config_paths() + + +def share_for(path: str, runner: Runner = None) -> GcpShare | None: + """The share covering ``path``, longest match first, or ``None``. + + Longest match, because a nested share is the one whose terms apply: a + read-only ``$HOME`` with a writable project directory inside it means the + project directory is writable, and answering with ``$HOME`` would warn + about a problem that is not there. + """ + available = shares(runner) + if not available: + return None + target = Path(path).expanduser() + covering = [ + share + for share in available + if target == Path(share.path) or Path(share.path) in target.parents + ] + if not covering: + return None + return max(covering, key=lambda share: len(share.path)) + + +def is_running(runner: Runner = None) -> bool | None: + """Whether the background service is up, or ``None`` if it cannot be told. + + Checked by looking for the process, which works the same whether the user + started the menu-bar app or the headless ``globusconnectpersonal -start``. + """ + run = runner or subprocess.run + try: + proc = run( + ["pgrep", "-f", "globusconnectpersonal|Globus Connect Personal"], + capture_output=True, + timeout=20, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + return proc.returncode == 0 diff --git a/src/uxarray_mcp/remote/transfer.py b/src/uxarray_mcp/remote/transfer.py index 550c3fa..becbb9c 100644 --- a/src/uxarray_mcp/remote/transfer.py +++ b/src/uxarray_mcp/remote/transfer.py @@ -22,15 +22,27 @@ * a local symlink resolves to somewhere else entirely, so the local side is checked after ``realpath``, not before. -The service builds its own request payloads rather than using -``globus_sdk.TransferData``. That keeps the wire shape visible in one place and -lets the tests drive a fake client with no ``globus-sdk`` installed at all, -which is the only way this is testable without credentials in CI. +The service builds its own request payloads rather than assembling calls inline. +That keeps the wire shape visible in one place and lets the tests drive a fake +client with nothing Globus installed at all, which is the only way this is +testable without credentials in CI. + +Nothing here authenticates. The four calls this module makes are handed to the +``globus`` command-line client, which already owns the user's tokens, consents +and sessions; see ``CliTransferClient``. Holding no auth code is the point, not +an omission -- Globus auth has scopes that exist on one kind of collection and +not another, and session policies a local consent check cannot see, and every +one of those rules is a rule the CLI already implements and we would otherwise +have to track. """ from __future__ import annotations +import json import os +import shutil +import subprocess +import sys from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Any, Protocol @@ -38,6 +50,7 @@ from uxarray_mcp.remote.config import GlobusTransferProfile __all__ = [ + "CliTransferClient", "PathOutsideRoot", "TransferError", "TransferNotConfigured", @@ -45,6 +58,7 @@ "TransferService", "bounded_preview", "collapse", + "find_globus_cli", "is_within", "join_under", "resolve_local", @@ -61,7 +75,13 @@ class TransferNotConfigured(TransferError): class TransferLoginRequired(TransferError): - """Globus has no usable token for Transfer on this machine.""" + """Globus will not act for this user until they log in again. + + Covers the three failures that look unrelated and are not: no tokens, no + consent for a collection's ``data_access`` scope, and a session identity a + collection's policy refuses. Each needs a browser, so each needs a + terminal, which is the one thing an MCP server does not have. + """ class PathOutsideRoot(TransferError): @@ -135,8 +155,17 @@ def resolve_local(path: str | os.PathLike[str], root: str | None = None) -> Path made before resolution and fails one made after. Resolution is non-strict, so a download destination that does not exist yet still resolves -- what exists is followed, the rest is appended. + + A relative path resolves against ``root`` when there is one, matching what + the remote side already does with ``remote_write_root``. The alternative is + the process working directory, which for a server started by an MCP client + is wherever that client happened to be launched from -- a different + directory per client, invisible to the caller, and never the one they meant. """ - resolved = Path(path).expanduser().resolve() + given = Path(path).expanduser() + if root is not None and not given.is_absolute(): + given = Path(root).expanduser() / given + resolved = given.resolve() if root is None: return resolved root_resolved = Path(root).expanduser().resolve() @@ -431,56 +460,178 @@ def _value_of(response: Any, key: str) -> Any: return getattr(response, key, None) -def default_transfer_client( - profile: GlobusTransferProfile, -) -> TransferClientLike: - """Build a real client from the login Globus Compute already made. - - Same native client as `globus-compute-sdk`, same token storage, so a user - who has run a remote tool has already logged in and only needs to consent - to the Transfer scope. That consent is an interactive browser flow, and an - MCP server has no terminal to run it in, so a missing token raises with the - command to run rather than blocking on a prompt nobody will see. - - Mapped collections need a per-collection ``data_access`` scope on top of - the base Transfer scope; both configured collections are declared before - the login state is checked, so a user who consented to one and not the - other is told to log in again rather than failing later on a path. +CLI_TIMEOUT_SECONDS = 120 + +# Text the CLI prints when the problem is who you are rather than what you +# asked for. Globus has several such failures and they read nothing alike: no +# tokens at all, tokens without consent for a collection's ``data_access`` +# scope, and a session whose identity the collection's own policy rejects. All +# three are fixed by logging in again, in a terminal, so all three are one +# exception here -- and the CLI's own words go along with it, because those are +# the words in the Globus documentation and in any support ticket that follows. +_LOGIN_MARKERS = ( + "MissingLoginError", + "globus login", + "globus session", + "ConsentRequired", + "consent_required", + "session_required", + "AuthenticationFailed", + "PermissionDenied", +) + + +def find_globus_cli() -> str: + """Locate the ``globus`` executable, or say how to get one. + + ``shutil.which`` alone is not enough. A server started by a desktop MCP + client inherits the launcher's environment rather than a login shell's, so + a CLI that works when the user types it can be missing here; the two + directories pip actually drops console scripts into are checked by hand + before giving up. """ - try: - import globus_sdk - from globus_compute_sdk.sdk.auth.globus_app import get_globus_app - except ImportError as exc: # pragma: no cover - exercised by install shape - raise TransferError( - "Globus Transfer needs the transfer extra: " - "pip install 'uxarray-mcp[transfer]'." - ) from exc - - app = get_globus_app() - client = globus_sdk.TransferClient(app=app) - for collection_id in ( - profile.remote_collection_id, - profile.local_collection_id, + found = shutil.which("globus") + if found: + return found + for candidate in ( + Path(sys.prefix) / "bin" / "globus", + Path.home() / ".local" / "bin" / "globus", ): - if collection_id: - client.add_app_data_access_scope(collection_id) - if app.login_required(): - raise TransferLoginRequired( - "Globus has no Transfer consent for these collections on this " - "machine. Run `uxarray-mcp transfer-login` in a terminal, which " - "opens the browser flow an MCP server cannot." - ) - return client + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + raise TransferError( + "The globus command-line client is not installed, or is not on this " + "process's PATH. Install it with `pip install globus-cli`, then run " + "`uxarray-mcp transfer setup`." + ) + + +class CliTransferClient: + """The four Transfer calls, made by running the ``globus`` CLI. + The CLI holds the user's tokens, consents and session, so this class holds + none: there is no login flow here, no token store, no scope arithmetic, and + nothing that expires. A failure comes back as the CLI's own stderr, which + is what the user will paste into a search box or a support ticket anyway. -def transfer_service_for(profile: Any) -> TransferService: - """Build a service from an endpoint profile, or say why there is none.""" - transfer_profile = getattr(profile, "globus_transfer", None) - if transfer_profile is None: - name = getattr(profile, "name", "this endpoint") - raise TransferNotConfigured( - f"{name} has no globus_transfer block in config.yaml, so it moves " - f"no files. Add remote_collection_id and remote_write_root to " - f"enable transfers for it." + ``runner`` is injected so the whole thing can be tested against a fake + without a binary, a network, or credentials. + """ + + def __init__( + self, + executable: str | None = None, + *, + timeout_seconds: int = CLI_TIMEOUT_SECONDS, + runner: Any = None, + ) -> None: + self._runner = runner or subprocess.run + self._executable = executable or find_globus_cli() + self._timeout = timeout_seconds + + # -- running it ---------------------------------------------------- + + def _run(self, *argv: str) -> str: + printable = "globus " + " ".join(argv) + try: + proc = self._runner( + [self._executable, *argv], + capture_output=True, + text=True, + timeout=self._timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise TransferError( + f"`{printable}` did not finish within {self._timeout} seconds." + ) from exc + if proc.returncode != 0: + message = (proc.stderr or proc.stdout or "").strip()[-4000:] + detail = f"`{printable}` failed:\n{message}" if message else printable + if any(marker in message for marker in _LOGIN_MARKERS): + raise TransferLoginRequired( + f"{detail}\n\nThis is a Globus login, consent or identity " + f"problem, which only a terminal can fix. Run " + f"`uxarray-mcp transfer setup` and follow what it asks." + ) + raise TransferError(detail) + return proc.stdout + + def _run_json(self, *argv: str) -> Any: + raw = self._run(*argv) + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise TransferError( + f"`globus {' '.join(argv)}` did not return JSON:\n{raw.strip()[:4000]}" + ) from exc + + def whoami(self) -> str: + """The logged-in identity, or ``TransferLoginRequired``.""" + return self._run("whoami").strip() + + # -- the protocol -------------------------------------------------- + + def operation_ls(self, collection_id: str, **kwargs: Any) -> Any: + path = kwargs.get("path") or "/" + return self._run_json( + "ls", "--long", "--format", "json", f"{collection_id}:{path}" ) - return TransferService(transfer_profile) + + def get_submission_id(self) -> Any: + """A sentinel: ``globus transfer`` mints and consumes its own. + + The id exists so a retried submission cannot run twice, and the CLI + already handles that end to end. Returning a placeholder keeps + ``TransferService.submit`` written against one shape. + """ + return {"value": "globus-cli"} + + def submit_transfer(self, data: Any) -> Any: + items = data.get("DATA") or [] + if len(items) != 1: + raise TransferError( + f"The CLI backend submits one path pair at a time; this " + f"payload has {len(items)}. Use `globus transfer --batch` for " + f"more." + ) + item = items[0] + argv = ["transfer"] + if item.get("recursive"): + argv.append("--recursive") + argv += [ + f"{data['source_endpoint']}:{item['source_path']}", + f"{data['destination_endpoint']}:{item['destination_path']}", + "--notify", + "off", + "--format", + "json", + ] + # Checksum verification is the CLI's default, so only its absence is + # worth saying out loud. + if data.get("verify_checksum") is False: + argv.append("--no-verify-checksum") + if data.get("label"): + argv += ["--label", str(data["label"])] + return self._run_json(*argv) + + def get_task(self, task_id: str) -> Any: + return self._run_json("task", "show", "--format", "json", task_id) + + +def default_transfer_client( + profile: GlobusTransferProfile, +) -> TransferClientLike: + """Hand the work to the ``globus`` CLI, after checking it can do it. + + ``profile`` is unused, and that is the change: the previous client read the + collection UUIDs off it to request a ``data_access`` scope for each. Only + Globus Connect Server v5 collections have that scope -- a Globus Connect + Personal collection does not, so asking for one on the local end left the + login permanently incomplete and every transfer refused. Which collections + need which scopes is the CLI's problem now. + """ + del profile # the CLI resolves collections and scopes for itself + client = CliTransferClient() + client.whoami() + return client diff --git a/src/uxarray_mcp/tools/execution_control.py b/src/uxarray_mcp/tools/execution_control.py index f781792..58ba64e 100644 --- a/src/uxarray_mcp/tools/execution_control.py +++ b/src/uxarray_mcp/tools/execution_control.py @@ -62,6 +62,33 @@ def _make_check( return result +def _local_share_details(transfer_profile: Any) -> Dict[str, Any]: + """What Globus Connect Personal is willing to do with this machine's files. + + Reported rather than failed on, because it only matters in one direction: a + read-only share uploads fine and refuses every download, and which of those + the user needs is not something the doctor knows. Saying so here is enough + to turn a PERMISSION_DENIED on the destination into an obvious cause. + """ + if not getattr(transfer_profile, "local_collection_id", None): + return {} + from uxarray_mcp.remote import gcp + + local_root = getattr(transfer_profile, "local_root", None) or str(Path.home()) + share = gcp.share_for(local_root) + if share is None: + return { + "local_path": local_root, + "local_share": None, + "local_writable": None, + } + return { + "local_path": local_root, + "local_share": share.path, + "local_writable": share.writable, + } + + def _transfer_check( base_config: Any, endpoint: str | None, run_probe: bool ) -> Dict[str, Any]: @@ -70,8 +97,8 @@ def _transfer_check( Passing when nothing is configured is deliberate: transfers are opt-in the way HPC itself is, and a doctor that goes red for a feature the user never asked for teaches people to ignore it. What is worth failing on is a - configured transfer that cannot work -- SDK absent, no consent, or a write - root the collection will not show. + configured transfer that cannot work -- no globus CLI, no login or consent, + or a write root the collection will not show. The reachability probe rides on ``run_remote_probe`` because it is a real network call, and it lists the write root rather than transferring @@ -105,6 +132,7 @@ def _transfer_check( "remote_read_root": transfer_profile.remote_read_root, "collection_roots": list(transfer_profile.collection_roots), } + details.update(_local_share_details(transfer_profile)) if not transfer_profile.remote_write_root: return _make_check( @@ -125,12 +153,13 @@ def _transfer_check( return _make_check( "transfer", False, - "Globus Transfer is configured but no client could be built.", + "Globus Transfer is configured but the globus CLI could not act.", details={**details, **_exception_details(exc)}, guidance=( - "Install the transfer extra (`uv sync --extra transfer`) and " - "complete the Globus login in a terminal; an MCP server cannot " - "open a browser consent flow." + "Run `uxarray-mcp transfer setup`, which installs the globus " + "CLI if it is missing, logs in, and consents to this " + "collection. Those steps need a terminal; an MCP server cannot " + "open a browser." ), ) diff --git a/src/uxarray_mcp/transfer_setup.py b/src/uxarray_mcp/transfer_setup.py new file mode 100644 index 0000000..50cbafb --- /dev/null +++ b/src/uxarray_mcp/transfer_setup.py @@ -0,0 +1,545 @@ +"""``uxarray-mcp transfer setup`` -- get Globus data movement working, once. + +Seven things have to be true before a file can move between this machine and a +cluster, and Globus reports the failure of each one differently and none of +them plainly. Getting from nothing to a working transfer by hand means finding +out about them in the worst order: the collection UUID that is in a file nobody +mentions, the consent scope that exists for one kind of collection and not +another, the share on this machine whose write bit is clear by default, and the +identity policy that only speaks up after a transfer has been submitted. + +So this asks all seven up front, says which are already true, and offers to fix +the rest. It is deliberately close to a shell script: prompts, one line of +output per step, and no state of its own. Anything it changes it names first. + +What it does *not* do is authenticate. Every credential step is handed to the +``globus`` command-line client, running attached to this terminal so its +browser flow works. Nothing here stores a token. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + +from uxarray_mcp.remote import gcp + +__all__ = [ + "KNOWN_COLLECTIONS", + "KnownCollection", + "TransferSetup", + "cmd_transfer_setup", +] + + +@dataclass(frozen=True) +class KnownCollection: + """A facility collection whose UUID is a fact, not a per-user setting.""" + + label: str + collection_id: str + collection_roots: tuple[str, ...] = () + default_read_root: str | None = None + + +# Searching for these is a poor substitute for stating them. Facilities publish +# a guest collection per project, and users publish their own, so a name search +# for "Chrysalis" or "GLADE" returns a page of look-alikes with no way to tell +# which one serves the filesystem the compute endpoint runs on. Each entry below +# was confirmed against a live listing. +KNOWN_COLLECTIONS: dict[str, KnownCollection] = { + "ncar": KnownCollection( + label="NCAR GLADE", + collection_id="d33b3614-6d04-11e5-ba46-22000b92c6ec", + # GLADE names files by their real filesystem path, so nothing to translate. + collection_roots=(), + default_read_root="/glade", + ), + "polaris": KnownCollection( + label="ALCF Polaris (alcf#dtn_eagle)", + collection_id="05d2c76a-e867-4f67-aa57-76edeb0beda0", + collection_roots=("/eagle", "/lus/eagle/projects"), + ), + "aurora": KnownCollection( + label="ALCF Aurora (ALCF Flare)", + collection_id="f39a7a0f-5bfc-46ce-9615-ba9f8592814f", + collection_roots=("/flare", "/lus/flare/projects"), + ), + "perlmutter": KnownCollection( + label="NERSC DTN", + collection_id="9d6d994a-6d04-11e5-ba46-22000b92c6ec", + ), +} + +# Endpoint names people actually use, mapped to the table above. An endpoint +# whose name matches nothing here just gets asked about. +_NAME_HINTS: tuple[tuple[str, str], ...] = ( + ("ucar", "ncar"), + ("ncar", "ncar"), + ("glade", "ncar"), + ("derecho", "ncar"), + ("casper", "ncar"), + ("polaris", "polaris"), + ("eagle", "polaris"), + ("aurora", "aurora"), + ("flare", "aurora"), + ("perlmutter", "perlmutter"), + ("nersc", "perlmutter"), +) + +_CONSENT_SCOPE = ( + "urn:globus:auth:scope:transfer.api.globus.org:all" + "[*https://auth.globus.org/scopes/{collection_id}/data_access]" +) + + +def known_collection_for(endpoint_name: str | None) -> KnownCollection | None: + """Guess the facility collection from an endpoint's name.""" + if not endpoint_name: + return None + lowered = endpoint_name.lower() + for hint, key in _NAME_HINTS: + if hint in lowered: + return KNOWN_COLLECTIONS[key] + return None + + +@dataclass +class StepResult: + """One line of the report: what was checked and how it came out.""" + + title: str + ok: bool + detail: str = "" + fix: str = "" + + def render(self) -> str: + mark = "ok " if self.ok else "TODO" + line = f" [{mark}] {self.title}" + if self.detail: + line += f" -- {self.detail}" + if not self.ok and self.fix: + line += f"\n fix: {self.fix}" + return line + + +@dataclass +class TransferSetup: + """The seven checks, and the offers to fix them. + + ``runner``, ``prompt`` and ``emit`` are injected so every path through this + can be tested with no binary, no network and no terminal. + """ + + endpoint: str | None = None + check_only: bool = False + assume_yes: bool = False + runner: Callable[..., Any] = subprocess.run + prompt: Callable[[str], str] = input + emit: Callable[[str], None] = print + results: list[StepResult] = field(default_factory=list) + + # -- primitives ---------------------------------------------------- + + def _record(self, title: str, ok: bool, detail: str = "", fix: str = "") -> bool: + result = StepResult(title=title, ok=ok, detail=detail, fix=fix) + self.results.append(result) + self.emit(result.render()) + return ok + + def _confirm(self, question: str) -> bool: + """Ask before changing anything. ``--check`` never changes anything.""" + if self.check_only: + return False + if self.assume_yes: + return True + try: + answer = self.prompt(f" {question} [Y/n] ").strip().lower() + except EOFError: + return False + return answer in ("", "y", "yes") + + def _ask(self, question: str, default: str = "") -> str: + if self.check_only or self.assume_yes: + return default + try: + answer = self.prompt(f" {question}").strip() + except EOFError: + return default + return answer or default + + def _capture(self, argv: list[str], timeout: int = 120) -> tuple[int, str, str]: + try: + proc = self.runner( + argv, capture_output=True, text=True, timeout=timeout, check=False + ) + except (OSError, subprocess.SubprocessError) as exc: + return 1, "", str(exc) + return proc.returncode, (proc.stdout or "").strip(), (proc.stderr or "").strip() + + def _interactive(self, argv: list[str]) -> int: + """Run a command attached to this terminal. + + Login and consent print a URL and wait for a pasted code. Capturing + their output would hide the URL and hang on the prompt, so these are the + one kind of command that must not be captured. + """ + self.emit(f" running: {' '.join(argv)}") + try: + proc = self.runner(argv, check=False) + except (OSError, subprocess.SubprocessError) as exc: + self.emit(f" failed: {exc}") + return 1 + return int(getattr(proc, "returncode", 1)) + + # -- 1. the CLI ---------------------------------------------------- + + def find_cli(self) -> str | None: + from uxarray_mcp.remote.transfer import TransferError, find_globus_cli + + try: + return find_globus_cli() + except TransferError: + return None + + def step_cli(self) -> str | None: + found = self.find_cli() + if found: + self._record("globus CLI", True, found) + return found + self._record( + "globus CLI", + False, + "not installed", + "pip install globus-cli", + ) + if not self._confirm("install globus-cli now?"): + return None + if self._interactive([sys.executable, "-m", "pip", "install", "globus-cli"]): + self.emit(" install failed; install it by hand and re-run.") + return None + found = self.find_cli() + self.emit( + f" installed: {found}" if found else " still not found" + ) + return found + + # -- 2. login ------------------------------------------------------ + + def step_login(self, cli: str) -> bool: + code, out, _ = self._capture([cli, "whoami"], timeout=60) + if code == 0 and out: + return self._record("globus login", True, out) + self._record("globus login", False, "not logged in", "globus login") + if not self._confirm("log in now? A browser opens; paste the code back."): + return False + if self._interactive([cli, "login"]): + return False + code, out, _ = self._capture([cli, "whoami"], timeout=60) + if code == 0 and out: + self.emit(f" logged in as {out}") + return True + return False + + # -- 3. this machine's Globus Connect Personal ---------------------- + + def step_gcp(self, local_root: str | None) -> None: + running = gcp.is_running() + if running is None: + self._record( + "Globus Connect Personal", False, "cannot tell if it is running" + ) + elif running: + self._record("Globus Connect Personal", True, "running") + else: + self._record( + "Globus Connect Personal", + False, + "not running -- transfers to and from this machine will fail", + "start it from the menu bar, or `globusconnectpersonal -start`", + ) + self._step_gcp_share(local_root) + + def _step_gcp_share(self, local_root: str | None) -> None: + """The write bit. The default share is read-only and nothing says so.""" + target = local_root or str(Path.home()) + share = gcp.share_for(target) + if share is None: + self._record( + f"{target} shared", + False, + "not in Globus Connect Personal's shared paths", + "Preferences -> Access -> + , add it and tick Writable", + ) + return + if share.writable: + self._record(f"{target} writable", True, f"covered by {share.path}") + return + self._record( + f"{target} writable", + False, + f"{share.path} is shared READ-ONLY, so downloads here will be refused", + self._share_fix(share.path), + ) + + @staticmethod + def _share_fix(share_path: str) -> str: + if sys.platform == "darwin": + # Flipping this in the preferences plist by hand does not take + # effect until the app rewrites it, and the app is what owns the + # file -- so this is the supported route, and the only honest one. + return ( + "Globus Connect Personal -> Preferences -> Access, " + f"select {share_path}, tick Writable, then restart it" + ) + return ( + f"add a writable entry to {gcp.config_paths_file()} " + f"(e.g. `{share_path}/,0,1`), then restart globusconnectpersonal" + ) + + # -- 4. which collection is this machine ---------------------------- + + def step_local_collection(self, cli: str) -> str | None: + found = gcp.collection_id() + if found: + self._record("this machine's collection", True, found) + return found + code, out, _ = self._capture( + [ + cli, + "endpoint", + "search", + "--filter-scope", + "my-endpoints", + "--format", + "unix", + "--jmespath", + "DATA[].[id,display_name]", + ] + ) + if code == 0 and out: + self.emit(" collections you own:") + for line in out.splitlines(): + self.emit(f" {line}") + entered = self._ask("this machine's collection UUID (blank to skip): ") + if entered: + self._record("this machine's collection", True, entered) + return entered + self._record( + "this machine's collection", + False, + "unknown", + "install Globus Connect Personal, or paste its UUID", + ) + return None + + # -- 5. the facility's collection ----------------------------------- + + def step_remote_collection(self) -> KnownCollection | None: + known = known_collection_for(self.endpoint) + if known: + self._record( + "facility collection", True, f"{known.label} {known.collection_id}" + ) + return known + entered = self._ask( + f"collection UUID for {self.endpoint or 'this endpoint'} " + f"(find it under Collections in the Globus web app): " + ) + if entered: + self._record("facility collection", True, entered) + return KnownCollection( + label=self.endpoint or "endpoint", collection_id=entered + ) + self._record( + "facility collection", + False, + "unknown", + "look it up under Collections at app.globus.org and re-run", + ) + return None + + # -- 6. consent ------------------------------------------------------ + + def step_consent(self, cli: str, collection_id: str, probe_path: str) -> bool: + """Prove the consent by using it, then ask for it if that failed. + + A listing is the probe because it is the cheapest thing that exercises + the whole chain -- token, consent, the collection's own identity policy, + and the path -- and moves nothing. + """ + code, _, err = self._capture([cli, "ls", f"{collection_id}:{probe_path}"]) + if code == 0: + return self._record("consent", True, f"listed {probe_path}") + scope = _CONSENT_SCOPE.format(collection_id=collection_id) + self._record( + "consent", False, _first_line(err), f"globus session consent '{scope}'" + ) + if not self._confirm("request consent now? A browser opens."): + return False + # The local collection is deliberately absent from this. Only Globus + # Connect Server v5 collections have a data_access scope; asking for one + # on a Globus Connect Personal collection fails with UNKNOWN_SCOPE_ERROR + # and leaves the login looking permanently incomplete. + if self._interactive([cli, "session", "consent", scope]): + return False + code, _, err = self._capture([cli, "ls", f"{collection_id}:{probe_path}"]) + if code == 0: + self.emit(f" consent granted; {probe_path} lists") + return True + self.emit(f" still refused: {_first_line(err)}") + return False + + # -- report ---------------------------------------------------------- + + def summary(self) -> tuple[int, int]: + done = sum(1 for r in self.results if r.ok) + return done, len(self.results) + + # -- 7. write it down ------------------------------------------------- + + def step_config( + self, + remote: KnownCollection, + local_collection: str | None, + write_root: str | None, + local_root: str | None, + ) -> bool: + """Put the answers in the user config so nobody has to find them twice.""" + from uxarray_mcp.cli import ( + _ensure_hpc_block, + _read_user_config, + _user_write_target, + _write_user_config, + ) + + if not self.endpoint: + self._record( + "config", False, "no endpoint named", "re-run with --endpoint NAME" + ) + return False + block: dict[str, Any] = {"remote_collection_id": remote.collection_id} + if local_collection: + block["local_collection_id"] = local_collection + if write_root: + block["remote_write_root"] = write_root + if remote.default_read_root: + block["remote_read_root"] = remote.default_read_root + if remote.collection_roots: + block["collection_roots"] = list(remote.collection_roots) + if local_root: + block["local_root"] = local_root + + target = _user_write_target() + data = _read_user_config(target) + hpc = _ensure_hpc_block(data) + endpoints = hpc["endpoints"] + existing = endpoints.get(self.endpoint) + if not isinstance(existing, dict): + self._record( + "config", + False, + f"{self.endpoint} is not in {target}", + f"uxarray-mcp endpoints add {self.endpoint} ", + ) + return False + current = existing.get("globus_transfer") + if current == block: + return self._record("config", True, f"already set in {target}") + # A --check run never asked for the write root, so it cannot have built + # a block equal to a good one. Judging what is there on its own terms is + # the only thing that is not a false alarm. + if self.check_only: + configured = isinstance(current, dict) and current.get( + "remote_collection_id" + ) + return self._record( + "config", + bool(configured), + f"{self.endpoint} in {target}" + if configured + else f"{self.endpoint} has no globus_transfer block", + "" if configured else "run this again without --check", + ) + for key, value in block.items(): + self.emit(f" {key}: {value}") + if not self._confirm(f"write this to {target}?"): + self._record( + "config", False, "not written", f"add the block above to {target}" + ) + return False + existing["globus_transfer"] = block + _write_user_config(target, data) + return self._record("config", True, f"written to {target}") + + # -- the whole thing -------------------------------------------------- + + def run(self) -> int: + """Every step in order. Returns a process exit code.""" + self.emit( + "Globus Transfer moves files. It is a separate service from Globus\n" + "Compute, with separate logins and separate consents -- being able to\n" + "run a job on a machine does not let you copy a file to it.\n" + ) + cli = self.step_cli() + if not cli: + return self._finish() + logged_in = self.step_login(cli) + # Not logged in stops the fixing but not the reporting: the Globus + # Connect Personal checks below read local files and are the ones most + # likely to be quietly wrong, so a `--check` run should still show them. + if not logged_in and not self.check_only: + return self._finish() + + remote = self.step_remote_collection() + write_root = None + if remote and logged_in: + default_root = remote.default_read_root or "" + write_root = self._ask( + f"writable directory on {remote.label} " + f"(e.g. /glade/derecho/scratch/$USER){f' [{default_root}]' if default_root else ''}: ", + default_root, + ) + probe = write_root or "/" + self.step_consent(cli, remote.collection_id, probe) + + local_root = self._ask( + f"directory on this machine transfers may touch [{Path.home()}]: ", + str(Path.home()), + ) + self.step_gcp(local_root) + local_collection = self.step_local_collection(cli) + + if remote: + self.step_config(remote, local_collection, write_root or None, local_root) + return self._finish() + + def _finish(self) -> int: + done, total = self.summary() + self.emit(f"\n{done}/{total} checks pass.") + if done == total: + self.emit("Transfers should work. Try `transfer_ls` from the MCP client.") + return 0 + self.emit("Fix the TODO lines above, then run this again.") + return 1 + + +def cmd_transfer_setup(args: argparse.Namespace) -> int: + """``uxarray-mcp transfer setup`` -- see :class:`TransferSetup`.""" + setup = TransferSetup( + endpoint=getattr(args, "endpoint", None), + check_only=bool(getattr(args, "check", False)), + assume_yes=bool(getattr(args, "yes", False)), + ) + return setup.run() + + +def _first_line(text: str) -> str: + for line in (text or "").splitlines(): + if line.strip(): + return line.strip()[:200] + return "failed" diff --git a/tests/test_globus_transfer.py b/tests/test_globus_transfer.py index 2d87c75..80a25b4 100644 --- a/tests/test_globus_transfer.py +++ b/tests/test_globus_transfer.py @@ -14,17 +14,24 @@ from __future__ import annotations import os +import shutil +import subprocess +import sys +from pathlib import Path import pytest from uxarray_mcp.remote.config import GlobusTransferProfile from uxarray_mcp.remote.transfer import ( + CliTransferClient, PathOutsideRoot, TransferError, + TransferLoginRequired, TransferNotConfigured, TransferService, bounded_preview, collapse, + find_globus_cli, is_within, join_under, resolve_local, @@ -370,3 +377,252 @@ def test_status_reports_what_the_task_says(self): assert status["status"] == "SUCCEEDED" assert status["files_transferred"] == 3 assert status["fatal_error"] is None + + +class FakeProcess: + """What ``subprocess.run`` returns, with nothing else attached.""" + + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class FakeRunner: + """Answers ``globus`` invocations from a table keyed on the first argument.""" + + def __init__(self, replies): + self.replies = replies + self.calls: list[list[str]] = [] + + def __call__(self, argv, **kwargs): + self.calls.append(list(argv)) + for key, reply in self.replies.items(): + if key in argv: + return reply + return FakeProcess(1, stderr=f"no fake reply for {argv}") + + +def _cli(replies): + return CliTransferClient("/fake/globus", runner=FakeRunner(replies)) + + +class TestTheCliIsAskedForMachineReadableOutput: + """Every call parses stdout, so every call must have asked for JSON. + + A missing ``--format json`` does not fail; it returns the human table, + which parses as nothing and surfaces as a bad-output error a long way from + the flag that caused it. + """ + + def test_a_listing_names_the_collection_and_path_as_one_argument(self): + client = _cli({"ls": FakeProcess(stdout='{"DATA": [{"name": "a.nc"}]}')}) + result = client.operation_ls("coll-1", path="/scratch") + assert result == {"DATA": [{"name": "a.nc"}]} + argv = client._runner.calls[0] + assert argv[:2] == ["/fake/globus", "ls"] + assert "coll-1:/scratch" in argv + assert "--format" in argv and "json" in argv + + def test_a_listing_with_no_path_asks_for_the_collection_root(self): + client = _cli({"ls": FakeProcess(stdout="{}")}) + client.operation_ls("coll-1") + assert "coll-1:/" in client._runner.calls[0] + + def test_a_task_lookup_asks_for_json(self): + client = _cli({"task": FakeProcess(stdout='{"status": "SUCCEEDED"}')}) + assert client.get_task("t-1") == {"status": "SUCCEEDED"} + argv = client._runner.calls[0] + assert argv[1:3] == ["task", "show"] + assert argv[-1] == "t-1" + + +class TestASubmissionBecomesOneTransferCommand: + """The payload is the wire shape; the CLI takes flags. Something translates.""" + + def test_the_two_endpoints_and_paths_become_two_colon_arguments(self): + client = _cli({"transfer": FakeProcess(stdout='{"task_id": "t-9"}')}) + result = client.submit_transfer( + { + "source_endpoint": "src", + "destination_endpoint": "dst", + "DATA": [{"source_path": "/a/x.nc", "destination_path": "/b/x.nc"}], + } + ) + assert result == {"task_id": "t-9"} + argv = client._runner.calls[0] + assert "src:/a/x.nc" in argv + assert "dst:/b/x.nc" in argv + assert argv[argv.index("--notify") + 1] == "off" + + def test_a_recursive_item_becomes_the_recursive_flag(self): + client = _cli({"transfer": FakeProcess(stdout='{"task_id": "t"}')}) + client.submit_transfer( + { + "source_endpoint": "src", + "destination_endpoint": "dst", + "DATA": [ + { + "source_path": "/a", + "destination_path": "/b", + "recursive": True, + } + ], + } + ) + assert "--recursive" in client._runner.calls[0] + + def test_checksums_are_only_mentioned_when_they_are_turned_off(self): + """Verifying is the CLI's default, so saying so again is noise. + + Saying nothing when the payload asks for verification is only correct + while that stays the default, which is why the opposite case is the one + spelled out on the command line. + """ + on = _cli({"transfer": FakeProcess(stdout="{}")}) + on.submit_transfer( + { + "source_endpoint": "s", + "destination_endpoint": "d", + "verify_checksum": True, + "DATA": [{"source_path": "/a", "destination_path": "/b"}], + } + ) + assert "--no-verify-checksum" not in on._runner.calls[0] + + off = _cli({"transfer": FakeProcess(stdout="{}")}) + off.submit_transfer( + { + "source_endpoint": "s", + "destination_endpoint": "d", + "verify_checksum": False, + "DATA": [{"source_path": "/a", "destination_path": "/b"}], + } + ) + assert "--no-verify-checksum" in off._runner.calls[0] + + def test_a_batch_is_refused_rather_than_silently_truncated(self): + """One command moves one thing. Sending the first of several is worse + than sending none, because the caller is told it succeeded.""" + client = _cli({"transfer": FakeProcess(stdout="{}")}) + with pytest.raises(TransferError): + client.submit_transfer( + { + "source_endpoint": "s", + "destination_endpoint": "d", + "DATA": [ + {"source_path": "/a", "destination_path": "/b"}, + {"source_path": "/c", "destination_path": "/d"}, + ], + } + ) + + +class TestAnAuthFailureIsToldApartFromEveryOtherFailure: + """Three unrelated-looking Globus errors all mean "go to a terminal". + + Missing tokens, a missing ``data_access`` consent, and an identity a + collection's policy refuses are reported by three different subsystems in + three different shapes, and the fix for all three is the same. Anything + else -- a path that is not there, a collection that is down -- must not be + dressed up as a login problem, because that sends the user to a browser to + fix something a browser cannot fix. + """ + + @pytest.mark.parametrize( + "stderr", + [ + "MissingLoginError: Missing login for Globus Auth.", + "The collection requires ConsentRequired for data_access", + "session_required_single_domain: ncar.edu", + ], + ) + def test_a_credential_failure_asks_for_a_terminal(self, stderr): + client = _cli({"ls": FakeProcess(1, stderr=stderr)}) + with pytest.raises(TransferLoginRequired) as caught: + client.operation_ls("coll-1") + assert "transfer setup" in str(caught.value) + + def test_a_missing_path_stays_a_plain_error(self): + client = _cli({"ls": FakeProcess(1, stderr="Directory not found: /nope")}) + with pytest.raises(TransferError) as caught: + client.operation_ls("coll-1", path="/nope") + assert not isinstance(caught.value, TransferLoginRequired) + assert "/nope" in str(caught.value) + + def test_the_failing_command_is_quoted_back(self): + """Whatever went wrong, the user can retype the line and see it too.""" + client = _cli({"ls": FakeProcess(1, stderr="boom")}) + with pytest.raises(TransferError) as caught: + client.operation_ls("coll-1", path="/x") + assert "globus ls" in str(caught.value) + assert "coll-1:/x" in str(caught.value) + + +class TestOutputThatIsNotJson: + def test_unparseable_output_names_the_command_rather_than_the_parser(self): + client = _cli({"task": FakeProcess(stdout="Task ID: t-1\nStatus: OK\n")}) + with pytest.raises(TransferError) as caught: + client.get_task("t-1") + assert "globus task show" in str(caught.value) + + def test_a_hung_command_is_given_up_on(self): + def hang(argv, **kwargs): + raise subprocess.TimeoutExpired(argv, 5) + + client = CliTransferClient("/fake/globus", timeout_seconds=5, runner=hang) + with pytest.raises(TransferError) as caught: + client.whoami() + assert "5 seconds" in str(caught.value) + + +class TestFindingTheBinary: + """An MCP server started from a GUI has no shell PATH. + + ``shutil.which`` is the whole answer in a terminal and no answer at all in + Claude Desktop, which is where most of these installs run. + """ + + def test_the_path_is_used_when_it_has_one(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/globus") + assert find_globus_cli() == "/usr/bin/globus" + + def test_the_interpreter_prefix_is_searched_when_the_path_has_none( + self, monkeypatch, tmp_path + ): + binary = tmp_path / "bin" / "globus" + binary.parent.mkdir() + binary.write_text("#!/bin/sh\n") + binary.chmod(0o755) + monkeypatch.setattr(shutil, "which", lambda name: None) + monkeypatch.setattr(sys, "prefix", str(tmp_path)) + assert find_globus_cli() == str(binary) + + def test_an_absent_binary_says_how_to_get_one(self, monkeypatch, tmp_path): + monkeypatch.setattr(shutil, "which", lambda name: None) + monkeypatch.setattr(sys, "prefix", str(tmp_path)) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + with pytest.raises(TransferError) as caught: + find_globus_cli() + assert "pip install globus-cli" in str(caught.value) + + +class TestARelativeLocalPathHasSomewhereToStart: + """The remote side resolves against its root; the local side must match. + + Resolving against the process working directory looks the same in a + terminal and is a different directory for every MCP client, none of them + the one the caller had in mind. + """ + + def test_a_relative_path_lands_under_the_local_root(self, tmp_path): + (tmp_path / "runs").mkdir() + resolved = resolve_local("runs/mesh.nc", str(tmp_path)) + assert resolved == (tmp_path / "runs" / "mesh.nc").resolve() + + def test_the_working_directory_is_only_used_when_there_is_no_root(self, tmp_path): + assert resolve_local("mesh.nc", None) == (Path.cwd() / "mesh.nc").resolve() + + def test_a_relative_path_still_cannot_climb_out_of_the_root(self, tmp_path): + with pytest.raises(PathOutsideRoot): + resolve_local("../elsewhere/mesh.nc", str(tmp_path)) diff --git a/tests/test_transfer_setup.py b/tests/test_transfer_setup.py new file mode 100644 index 0000000..1ae4b6a --- /dev/null +++ b/tests/test_transfer_setup.py @@ -0,0 +1,226 @@ +"""``transfer setup``, driven with no globus binary and no browser. + +The command exists because getting Globus Transfer working by hand means +finding out about seven prerequisites in the worst possible order. So the thing +worth testing is not that each check runs -- it is that each one reports the +*right* state, including the two that are easy to get confidently wrong: a +read-only share reported as fine, and an ordinary error reported as a login +problem. + +Nothing here launches a browser, reads the real config, or touches the network. +Every subprocess call and every prompt is injected. +""" + +from __future__ import annotations + +import sys + +import pytest + +from uxarray_mcp.remote import gcp +from uxarray_mcp.transfer_setup import ( + KNOWN_COLLECTIONS, + TransferSetup, + known_collection_for, +) + + +class FakeProcess: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class Recorder: + """Stands in for both ``subprocess.run`` and ``print``.""" + + def __init__(self, replies=None): + self.replies = replies or {} + self.calls: list[list[str]] = [] + self.lines: list[str] = [] + + def run(self, argv, **kwargs): + self.calls.append(list(argv)) + for key, reply in self.replies.items(): + if any(key in str(part) for part in argv): + return reply + return FakeProcess(1, stderr="unexpected call") + + def emit(self, line): + self.lines.append(str(line)) + + @property + def text(self) -> str: + return "\n".join(self.lines) + + +def _setup(rec, **kwargs): + kwargs.setdefault("endpoint", "ucar-uxarray-yac") + return TransferSetup(runner=rec.run, emit=rec.emit, **kwargs) + + +class TestTheFacilityCollectionIsNamedNotSearchedFor: + """A name search returns a page of look-alikes with no way to pick. + + Facilities publish a guest collection per project and users publish their + own, so "GLADE" matches many collections and only one of them serves the + filesystem the compute endpoint runs on. The table is the answer. + """ + + @pytest.mark.parametrize( + "name,expected", + [ + ("ucar-uxarray-yac", "ncar"), + ("derecho", "ncar"), + ("alcf-polaris", "polaris"), + ("aurora-flare", "aurora"), + ("nersc-perlmutter", "perlmutter"), + ], + ) + def test_an_endpoint_name_finds_its_facility(self, name, expected): + found = known_collection_for(name) + assert found is not None + assert found.collection_id == KNOWN_COLLECTIONS[expected].collection_id + + def test_an_unknown_endpoint_is_asked_about_rather_than_guessed(self): + """Chrysalis and Improv have no published collection. Inventing a UUID + for them would send a transfer to a real collection belonging to + somebody else.""" + rec = Recorder() + setup = _setup(rec, endpoint="chrysalis", check_only=True) + assert setup.step_remote_collection() is None + assert "unknown" in rec.text + + +class TestTheWriteBitOnThisMachinesShare: + """The default share is the home directory with the write bit clear. + + Nothing announces this. An upload works, because it only reads here; a + download fails on the destination write, which reads as a network problem + and is not. It is the failure that costs an afternoon, so it is checked + before anything is submitted. + """ + + def test_a_read_only_share_is_reported_as_the_problem_it_is(self, monkeypatch): + monkeypatch.setattr( + gcp, + "share_for", + lambda path, runner=None: gcp.GcpShare( + "/Users/x", readable=True, writable=False + ), + ) + rec = Recorder() + _setup(rec, check_only=True)._step_gcp_share("/Users/x/work") + assert "READ-ONLY" in rec.text + assert "TODO" in rec.text + + def test_a_writable_share_is_left_alone(self, monkeypatch): + monkeypatch.setattr( + gcp, + "share_for", + lambda path, runner=None: gcp.GcpShare( + "/Users/x", readable=True, writable=True + ), + ) + rec = Recorder() + _setup(rec, check_only=True)._step_gcp_share("/Users/x/work") + assert "READ-ONLY" not in rec.text + assert "[ok" in rec.text + + def test_a_path_nobody_shared_is_a_different_complaint(self, monkeypatch): + """Not shared and shared read-only need different fixes, so they are + different lines.""" + monkeypatch.setattr(gcp, "share_for", lambda path, runner=None: None) + rec = Recorder() + _setup(rec, check_only=True)._step_gcp_share("/tmp/elsewhere") + assert "not in Globus Connect Personal's shared paths" in rec.text + + def test_the_fix_names_this_platforms_way_of_doing_it(self, monkeypatch): + setup = TransferSetup() + monkeypatch.setattr(sys, "platform", "darwin") + assert "Preferences" in setup._share_fix("/Users/x") + monkeypatch.setattr(sys, "platform", "linux") + assert "config-paths" in setup._share_fix("/home/x") + + +class TestConsentIsProvedByUsingIt: + """Asking Globus whether a consent exists is not the same as having it. + + A collection can also refuse a session identity, which no consent check + sees. Listing a directory exercises token, consent, identity policy and + path in one call and moves nothing. + """ + + def test_a_listing_that_works_is_the_end_of_it(self): + rec = Recorder({"ls": FakeProcess(0, stdout="a.nc")}) + ok = _setup(rec).step_consent("/fake/globus", "coll-1", "/scratch") + assert ok + assert len(rec.calls) == 1 + + def test_a_refused_listing_offers_the_scope_for_that_collection(self): + rec = Recorder({"ls": FakeProcess(1, stderr="ConsentRequired")}) + ok = _setup(rec, check_only=True).step_consent("/fake/globus", "coll-1", "/x") + assert not ok + assert "globus session consent" in rec.text + assert "coll-1/data_access" in rec.text + + def test_the_local_collection_is_never_asked_for_a_scope(self): + """Only Globus Connect Server v5 collections have ``data_access``. A + Globus Connect Personal collection does not, and requesting one fails + with UNKNOWN_SCOPE_ERROR, leaving the login permanently incomplete -- + which is the defect this whole change removes.""" + rec = Recorder({"ls": FakeProcess(1, stderr="ConsentRequired")}) + setup = _setup(rec, check_only=True) + setup.step_consent("/fake/globus", "remote-coll", "/x") + consent_lines = [line for line in rec.lines if "session consent" in line] + assert consent_lines + assert all("remote-coll" in line for line in consent_lines) + + +class TestCheckModeChangesNothing: + def test_no_offer_is_taken_even_when_the_answer_would_be_yes(self): + rec = Recorder() + + def refuse(prompt): + raise AssertionError(f"--check asked a question: {prompt!r}") + + setup = TransferSetup( + endpoint="x", check_only=True, runner=rec.run, emit=rec.emit, prompt=refuse + ) + assert setup._confirm("do a thing?") is False + assert setup._ask("a value: ", "fallback") == "fallback" + + def test_a_missing_login_does_not_stop_the_local_checks( + self, monkeypatch, tmp_path + ): + """The Globus Connect Personal state is read off local files and is the + most likely thing to be quietly wrong, so a report that stopped at the + login would hide it.""" + monkeypatch.setenv("UXARRAY_MCP_CONFIG", str(tmp_path / "config.yaml")) + monkeypatch.setattr(gcp, "is_running", lambda runner=None: True) + monkeypatch.setattr( + gcp, + "share_for", + lambda path, runner=None: gcp.GcpShare("/h", readable=True, writable=False), + ) + monkeypatch.setattr(gcp, "collection_id", lambda: "local-uuid") + rec = Recorder( + { + "whoami": FakeProcess(1, stderr="MissingLoginError"), + "endpoint": FakeProcess(0, stdout=""), + } + ) + setup = _setup(rec, check_only=True) + setup.find_cli = lambda: "/fake/globus" + setup.run() + titles = [result.title for result in setup.results] + assert "globus login" in titles + assert any("writable" in title for title in titles) + assert "this machine's collection" in titles + + def test_the_exit_code_says_whether_anything_is_outstanding(self, monkeypatch): + rec = Recorder() + setup = _setup(rec, check_only=True) + setup.find_cli = lambda: None + assert setup.run() == 1 diff --git a/tests/test_transfer_tools.py b/tests/test_transfer_tools.py index c9f10c3..d494054 100644 --- a/tests/test_transfer_tools.py +++ b/tests/test_transfer_tools.py @@ -217,7 +217,7 @@ def test_a_client_that_cannot_be_built_fails_with_the_install_step( from uxarray_mcp.remote import transfer as transfer_mod def boom(profile): - raise transfer_mod.TransferError("globus-sdk is not installed") + raise transfer_mod.TransferError("globus-cli is not installed") monkeypatch.setattr(transfer_mod, "default_transfer_client", boom) check = _transfer_check(_config(_transfer_profile()), None, False) diff --git a/uv.lock b/uv.lock index 1937725..de19a0a 100644 --- a/uv.lock +++ b/uv.lock @@ -1130,6 +1130,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/90/d0323acc02966204035bb5bcaf7385d5a1f2b5359410eb6a09f2ce380f94/geoviews-1.15.1-py3-none-any.whl", hash = "sha256:f369eb94907476e9233abe77bdd6b92340872a20845df7da0341f9a4a13309c8", size = 574718, upload-time = "2026-01-13T17:34:36.562Z" }, ] +[[package]] +name = "globus-cli" +version = "3.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "globus-sdk" }, + { name = "jmespath" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/19/1833aec39218b69088b94b047aa96ce03b1b29edae0cb49ae005c946820e/globus_cli-3.43.0.tar.gz", hash = "sha256:f6926bac91ce2d84d1317fd84d9d94f3b4e6e5054102740f9bf7bb22bbf42efc", size = 216458, upload-time = "2026-08-12T15:23:45.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/78/67d4e5a7eee8d15a921b6fdada584e4d3838ecaae9e770e685ce55600e75/globus_cli-3.43.0-py3-none-any.whl", hash = "sha256:1e6c9a55ce5f02e2824b2d485a7327d49f6c41bed2a50b8443ce895f6869305a", size = 334332, upload-time = "2026-08-12T15:23:44.421Z" }, +] + [[package]] name = "globus-compute-common" version = "0.7.1" @@ -1166,16 +1182,16 @@ wheels = [ [[package]] name = "globus-sdk" -version = "4.7.0" +version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/b2/c05a7042344ebbf9f85df259374e08a0deb5437a6a816e92976f5612c122/globus_sdk-4.7.0.tar.gz", hash = "sha256:69c27e1e5035f732d29dcdb9983ed282e5bf9c9c75691399b7d51ce3aea94504", size = 281838, upload-time = "2026-05-20T16:20:29.992Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/78/9d61118c9aa81b29779ab8f2101cd9fc9f2fc00d240bf626ba6d5d305b7a/globus_sdk-4.9.0.tar.gz", hash = "sha256:41cdc8611e6d48133fc75e20162068c8ad583a63f33245bd2e2d64edd6425a71", size = 288856, upload-time = "2026-08-10T19:38:25.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/f0/67d4b279d5a19324792e29499856160d3a478e864cfec3919d23ebc88268/globus_sdk-4.7.0-py3-none-any.whl", hash = "sha256:6f2a15cff130c93ca70ddc25a8156ae636865850d9a9c9dbb7ffc365e70930e2", size = 439273, upload-time = "2026-05-20T16:20:27.683Z" }, + { url = "https://files.pythonhosted.org/packages/87/1d/99a84a391b06865bcef2653203b6a353fcd00acd65b75c42c2afafaf5c57/globus_sdk-4.9.0-py3-none-any.whl", hash = "sha256:7bbc33745b2daa0bfc080bd588be1643288ebb76949c410daae79ed29b2920b0", size = 449312, upload-time = "2026-08-10T19:38:23.472Z" }, ] [[package]] @@ -1388,6 +1404,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "joblib" version = "1.5.3" @@ -4129,8 +4154,7 @@ openapi = [ { name = "toolregistry-server", extra = ["openapi"] }, ] transfer = [ - { name = "globus-compute-sdk" }, - { name = "globus-sdk" }, + { name = "globus-cli" }, ] [package.dev-dependencies] @@ -4147,9 +4171,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "academy-py", marker = "extra == 'hpc'", specifier = ">=0.3.1" }, + { name = "globus-cli", marker = "extra == 'transfer'", specifier = ">=3.30" }, { name = "globus-compute-sdk", marker = "extra == 'hpc'", specifier = ">=4.5.0" }, - { name = "globus-compute-sdk", marker = "extra == 'transfer'", specifier = ">=4.5.0" }, - { name = "globus-sdk", marker = "extra == 'transfer'", specifier = ">=3.40.0" }, { name = "holoviews", specifier = ">=1.19.0" }, { name = "matplotlib", specifier = ">=3.9.0" }, { name = "mcp", specifier = ">=1.27,<3" }, From 947cb5e42f8f84be685ba423009a88c961dcb668 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 11 Sep 2026 15:02:51 -0500 Subject: [PATCH 2/9] Say that a Globus STOR timeout is often still running Observed a task report nice_status TIMEOUT on STOR and then finish SUCCEEDED eighteen minutes later, so the previous advice to blame the VPN and retry would have abandoned a transfer that was working. --- docs/data-transfer.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/data-transfer.md b/docs/data-transfer.md index 364796d..8417f3a 100644 --- a/docs/data-transfer.md +++ b/docs/data-transfer.md @@ -133,7 +133,11 @@ refuses. All three need a browser, which an MCP server does not have, so they surface as one error telling you to run `uxarray-mcp transfer setup` in a terminal. -A task that reaches `nice_status: TIMEOUT` on `STOR` is usually neither: Globus -negotiates data channels on ports separate from the control connection, and a -corporate VPN commonly blocks them. Retry off the VPN before looking anywhere -else. +A task that reaches `nice_status: TIMEOUT` on `STOR` is usually neither, and +usually not dead either. Globus negotiates data channels on ports separate from +the control connection, retries them on its own, and reports `TIMEOUT` while it +is still trying; a task that sat there for twenty minutes has been observed to +finish `SUCCEEDED`. Poll `transfer_status` before concluding anything. A +corporate VPN is the usual reason for the retries — it degrades the data +channel without blocking it — so if the duration is wildly out of proportion to +the file, compare one transfer off the VPN. From 5841887328d115cc6e36568ad6fcb09e0a313b87 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 11 Sep 2026 15:18:40 -0500 Subject: [PATCH 3/9] Reach YAC remapping from run_analysis and fix what a first run trips on Driving the server as a new user against the local uxarray test meshes and the UCAR endpoint turned up one missing feature and ten defects. The feature: YAC was documented and installed on the ucar-uxarray-yac worker but unreachable from any client. run_analysis now takes backend and yac_method, and method accepts conservative, nnn, dnn and average directly. One resolver in domain/remap_backend.py serves the local path; the worker payloads inline the same rules under a drift test. Coverage records the method it judged, so the not-conservative warning names the method that ran. Verified locally (YAC 3.20) and on casper: identical field means. The defects: gradient on a grid without sphere_radius was unreachable, each repair pointing at the other, and acknowledging the refusal failed the output schema (status "overridden" was not in the enum); sphere_radius now reaches gradient/curl/divergence locally and remotely, with a radius_basis block. The azimuthal r=0 ring was counted as a missed bin. The variable-plot colorbar sat over the map. analyze_dataset inlined 82 KB of base64 into a JSON object. Remote remap returned no source_coverage. Remote analyze recommended operations that refuse use_remote. lat_step gives JSON callers the (start, stop, step) tuple. mesh_geo options are exposed on plot_dataset and the plot note names them. README: extras and the plain uv sync footgun, install-claude --config-path, 33 tools, missing CLI rows, sphere_radius instead of scale_by_radius=False. --- CHANGELOG.md | 71 ++++ README.md | 80 ++-- docs/tools.md | 55 ++- src/uxarray_mcp/domain/plotting.py | 22 +- src/uxarray_mcp/domain/remap_backend.py | 110 ++++++ src/uxarray_mcp/domain/remap_coverage.py | 3 +- src/uxarray_mcp/domain/vector_calc.py | 56 ++- src/uxarray_mcp/preconditions.py | 20 +- src/uxarray_mcp/remote/agent.py | 6 + src/uxarray_mcp/remote/compute_functions.py | 384 +++++++++++++++++++- src/uxarray_mcp/tools/advanced.py | 164 +++++++-- src/uxarray_mcp/tools/frontdoor.py | 141 +++++-- src/uxarray_mcp/tools/orchestration.py | 83 +++-- src/uxarray_mcp/tools/plotting.py | 23 +- src/uxarray_mcp/tools/vector_calc.py | 12 + src/uxarray_mcp/typed_results.py | 6 +- tests/test_analyze_dataset.py | 15 +- tests/test_regular_user_fixes.py | 311 ++++++++++++++++ tests/test_remap_backend.py | 208 +++++++++++ tests/test_typed_results.py | 1 + 20 files changed, 1600 insertions(+), 171 deletions(-) create mode 100644 src/uxarray_mcp/domain/remap_backend.py create mode 100644 tests/test_regular_user_fixes.py create mode 100644 tests/test_remap_backend.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e45aa46..69e45f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ built against; see `docs/release.md`. Versions through `0.3.1` were SemVer. ## Unreleased ### Added +- YAC remapping is reachable. `docs/ucar.md` has said since the endpoint was + named `ucar-uxarray-yac` that YAC "enables conservative remapping", and the + `backend` argument existed on `remap_to_rectilinear`, but `run_analysis` + never forwarded it and the mesh-to-mesh remaps had no such argument, so the + only conservative method the server knew about was unreachable from any + client. `run_analysis` now takes `backend` and `yac_method`, and `method` + accepts YAC's `conservative`, `nnn`, `dnn` and `average` directly, since a + caller who wants a conservative remap asks for one by that name rather than + by engine. One resolver (`domain/remap_backend.py`) turns either spelling + into the same plan; the worker copies inline the same rules under a test that + compares them. Verified on the UCAR worker: `remap_variable` with + `method="conservative"` returns the same field mean to sixteen digits as the + local YAC 3.20 build. YAC missing where the remap runs fails with a message + naming both repairs. Coverage now records the method it judged, so the + not-conservative warning names inverse-distance when that is what ran; it + said "nearest-neighbor" for every method. +- `sphere_radius` reaches `gradient`, `curl` and `divergence`, locally and on + the worker. The argument existed for `calculate_area` only, so a derivative + on a grid without a `sphere_radius` attribute -- which is nearly every grid + file -- could not be scaled at all. The radius is attached to the grid for + the call and reported in a `radius_basis` block with where it came from. +- `lat_step` for the zonal operations. UXarray reads a tuple as + `(start, stop, step)` and a list as explicit latitudes, and JSON has no + tuple, so `[-90, 90, 30]` from an MCP client always meant three latitudes and + there was no way to ask for a step. `lat_step` with `lat_spec=[start, stop]` + (or alone, for the globe) builds the tuple. +- `plot_dataset(plot_type="mesh_geo")` takes `show_mesh_boundary`, + `coastlines`, `borders`, `rivers`, `lakes`, `cities` and `basemap`. The plot + note had been telling callers to "ask" for those, naming options the front + door did not expose; it now names the parameters. - `transfer_ls`, `transfer_put`, `transfer_get` and `transfer_status` expose data movement as tools, in the deferred pool; the core surface stays at 33. Three verbs rather than one `transfer(op=...)` dispatcher, because a model @@ -101,6 +131,47 @@ built against; see `docs/release.md`. Versions through `0.3.1` were SemVer. issue, and a human merge is what reaches PyPI. ### Fixed +- A gradient could not be obtained on a grid without a `sphere_radius` + attribute. The refusal for the missing attribute told the caller to pass + `scale_by_radius=False`; doing so was refused in turn with the advice to + set `scale_by_radius=True`. Both repairs now name `sphere_radius` and + `acknowledge`, the two things that actually end the loop. +- Acknowledging a refused derivative crashed the envelope. The override path + set `preconditions.status` to `"overridden"`, a value the published output + schema did not list, so every acknowledged `gradient`, `curl` or + `divergence` failed with `MCP error -32602` after the number was computed. + The enum now admits it, under a test that validates an acknowledged result + against the schema. +- Every azimuthal profile reported partial coverage. The ring at radius zero + is a point, holds no face centres and is NaN by construction, and was + counted as a bin that missed the mesh, so a correct profile carried + `PROFILE_COVERAGE_PARTIAL` and `physically_interpretable: false`. The + degenerate ring is excluded from the count and reported as such. +- The colorbar of a `variable` plot sat on top of the map. HoloViews positions + the colorbar axes for the figure size it chose; resizing to the requested + width and height left it where it was, over the right-hand third of the + data, and `tight_layout` does not move axes it did not create. Map and + colorbar are laid out by hand after the resize, locally and on the worker. +- `analyze_dataset` embedded the variable plot as 82 KB of base64 inside a + JSON object no client can render an image from, while the larger mesh plot + correctly came back as a link. A figure written to the artifact store is now + referenced by URI in the summary and the bytes are left out; they stay only + when nothing was stored. +- `remote_remap_variable` and `remote_regrid_dataset` returned no + `source_coverage`, so the same remap answered with a coverage block and a + not-conservative warning locally and with neither on the worker. The worker + now measures coverage of the target mesh the same way. +- `analyze_dataset` with `use_remote=True` recommended `subset_bbox` and + `cross_section` as next steps, both of which refuse `use_remote`. The + remote summary now suggests operations that have a remote implementation. +- README: the clone install did not mention the `hpc`/`transfer` extras or + that a later plain `uv sync` removes them (which is how the endpoint check + comes to report `No module named 'globus_compute_sdk'`); `install-claude` + was described as merging a config it only prints without `--config-path`; + the tool count said 31 where the core profile registers 33; the CLI table + omitted `openapi`, `endpoints remove` and `transfer setup`; and the + derivative note still recommended `scale_by_radius=False`, which is now + refused. `docs/tools.md` gained the remap backends and every new parameter. - A directory of SCRIP meshes was classified as nothing and advised nothing. `_GRID_HINTS` held `grid`, `mesh`, `topo`, `coord` and `geo` but not `scrip` or `esmf`, and E3SM names half its meshes with the convention diff --git a/README.md b/README.md index 62625e5..7e8cd05 100644 --- a/README.md +++ b/README.md @@ -134,16 +134,22 @@ uv tool install --python 3.12 uxarray-mcp # Or from a fresh clone (developer path) git clone https://github.com/UXARRAY/uxarray-mcp-server.git -cd uxarray-mcp-server && uv sync --python 3.12 -# or: bash SETUP.sh (does the sync + runs the local test suite in one step) +cd uxarray-mcp-server && uv sync --python 3.12 --extra hpc --extra transfer +# or: bash SETUP.sh (local-only sync + runs the local test suite in one step) ``` -> **Why `--python 3.12`?** The server uses Globus Compute to submit work to -> HPC endpoints, and Globus Compute's serializer is fragile across Python -> minor versions — a 3.13 submitter against a 3.12 endpoint worker raises -> `WorkerLost` on non-trivial payloads. HPC sites broadly ship 3.12 conda -> stacks today, so we pin the install to match. Tracking removal of this pin -> at [globus/globus-compute#2139](https://github.com/globus/globus-compute/issues/2139). +The `hpc` and `transfer` extras hold the Globus Compute and Globus Transfer +SDKs. Leave them off for a laptop-only install. Note that `uv sync` installs +*exactly* the requested set: running a plain `uv sync` later removes the +extras again, and `hpc/endpoint_status` will then report `unreachable` with +`No module named 'globus_compute_sdk'`. + +> **Why `--python 3.12`?** Only the HPC path needs it. Globus Compute's +> serializer is fragile across Python minor versions — a 3.13 submitter +> against a 3.12 endpoint worker raises `WorkerLost` on non-trivial payloads, +> and HPC sites broadly ship 3.12 conda stacks today. Local-only use works on +> 3.11–3.13. Tracking removal of this constraint at +> [globus/globus-compute#2139](https://github.com/globus/globus-compute/issues/2139). > `uv` downloads 3.12 automatically if your system doesn't have it. ### Step 2 — Write a starter config @@ -155,16 +161,32 @@ uxarray-mcp setup Creates `~/.config/uxarray-mcp/config.yaml` with sensible defaults. Local mode needs nothing more. +Three environment variables adjust where the server looks and writes: +`UXARRAY_MCP_CONFIG` (path to a config file, checked before +`~/.config/uxarray-mcp/config.yaml`), `UXARRAY_MCP_STATE_DIR` (sessions, +result handles and rendered plots; default `~/.uxarray_mcp_server`), and +`UXARRAY_MCP_VERDICT_POLICY` (`full`, `reference_only` or `off` for the +postcondition block on every result). + ### Step 3 — Connect your AI client **Claude Desktop** ```bash -uxarray-mcp install-claude # merges the mcpServers block into your config +# merges the mcpServers block into the config file you name +uxarray-mcp install-claude --config-path ~/Library/Application\ Support/Claude/claude_desktop_config.json # or uxarray-mcp install-claude --print-only # prints the JSON to paste manually ``` +Without `--config-path` the command only prints the block; it never guesses +where your Claude Desktop config lives. + +If you installed from a clone rather than `uv tool install`, the `uxarray-mcp` +binary lives in the project `.venv`. In every client config below, use +`uv --directory /path/to/uxarray-mcp-server run uxarray-mcp serve` as the +command instead of a bare `uxarray-mcp serve`. + Restart Claude Desktop. The `uxarray` server should appear in Settings → Developer. @@ -208,7 +230,7 @@ Add to `~/.config/opencode/opencode.json`: } ``` -The server registers 31 tools, which is a large tool schema to carry on every +The server registers 33 tools, which is a large tool schema to carry on every request. `"enabled": false` turns it off for sessions that are not doing mesh analysis. @@ -289,16 +311,27 @@ Intent-shaped tools, not raw UXarray bindings — all local by default: - `get_capabilities` — what can I do with this mesh? - `analyze_dataset` — deterministic first-look: inspect, validate, area, zonal mean, plots. - `run_analysis` — one operation at a time (gradient, curl, subset, remap, …). -- `plot_dataset` — mesh, geographic, variable, or zonal-mean plots. + Remaps take `method`: `nearest_neighbor`, `inverse_distance_weighted` or + `bilinear` on UXarray's own engine, or `conservative`, `nnn`, `dnn`, + `average` on [YAC](https://dkrz-sw.gitlab-pages.dkrz.de/yac/) (equivalently + `backend="yac"` with `yac_method`). Only `conservative` preserves the field + integral, and it needs YAC importable where the remap runs — build it with + `scripts/build_yac_local.sh` on a laptop or `scripts/hpc_build_yac.py` on a + worker, and put its `site-packages` on `PYTHONPATH`. +- `plot_dataset` — `plot_type` of `mesh`, `mesh_geo`, `variable`, or `zonal_mean`. - `run_workflow`, `resume_workflow`, `get_status`, `get_result`, `manage_session` — persisted sessions and multi-step workflows. -Full schema: [docs/tools.md](docs/tools.md). +Helper namespaces also appear in `tools/list`: `session/*`, `hpc/*`, +`io/list_datasets`, `contract/*` and `prompt/*`. + +Full schema and every `run_analysis` parameter: [docs/tools.md](docs/tools.md). **Protocol version.** We do not implement MCP directly; servers are built through `toolregistry-server`, which depends on the `mcp` Python SDK. As of `toolregistry-server` 0.5.0 and `toolregistry` 0.16.0 the SDK cap is lifted, so -we resolve `mcp` 2.1.1 and negotiate spec **`2026-07-28`** (stateless core, +we resolve `mcp` 1.27 or 2.x (2.1.1 at the last lock) and negotiate spec +**`2026-07-28`** (stateless core, cacheable list results, MRTR). 0.16.0 also widens the recognized content-block set to audio, `resource_link`, and embedded resources. @@ -321,10 +354,13 @@ auditable and the server actively flags common scientific pitfalls: the tool that ran, timestamp, input arguments, `execution_venue` (`local` or `hpc:`), and the UXarray/Python versions used. - **Derivative unit convention is never hidden.** `gradient`, `curl`, and - `divergence` echo `scale_by_radius` in both the result and provenance, so a - unit-sphere result can never be mistaken for a physical (per-metre) one. - Gradient and curl default to physical scaling, matching UXarray; pass - `scale_by_radius=False` explicitly for unit-sphere output. + `divergence` echo `scale_by_radius` and a `radius_basis` block (the radius + used and whether it came from the grid or the caller), so a unit-sphere + result can never be mistaken for a physical (per-metre) one. Most grid files + declare no `sphere_radius`; pass `sphere_radius=6371000` (metres) to attach + Earth's. Without it, or with `scale_by_radius=False`, the call is refused + with `outcome="input_required"` until you pass `acknowledge`. The same + `sphere_radius` argument turns `calculate_area` from steradians into m². - **Vector-calculus sanity guard.** `curl`/`divergence` warn (without blocking) when the two inputs are the same field, or when neither carries a velocity/flux-like `units` attribute — the classic "vorticity from two random @@ -346,12 +382,14 @@ auditable and the server actively flags common scientific pitfalls: | Command | Purpose | |---|---| -| `uxarray-mcp serve` | Run the MCP server (used by your AI client) | +| `uxarray-mcp serve` | Run the MCP server (used by your AI client); `--profile core\|deferred-full`, `--transport stdio\|sse\|http` — see [docs/serving.md](docs/serving.md) | +| `uxarray-mcp openapi` | Print the OpenAPI document for the HTTP transport | | `uxarray-mcp setup` | Write a starter config | | `uxarray-mcp endpoints add NAME UUID` | Register a Globus Compute endpoint | -| `uxarray-mcp endpoints list` | Show configured endpoints | +| `uxarray-mcp endpoints list` / `remove NAME` | Show or drop configured endpoints | +| `uxarray-mcp transfer setup` | Check and fix everything a Globus Transfer needs, including the browser login an MCP server cannot open | | `uxarray-mcp doctor` | Validate local + (optionally) remote setup | -| `uxarray-mcp install-claude` | Merge or print the Claude Desktop config block | +| `uxarray-mcp install-claude --config-path FILE` | Merge (or `--print-only`) the Claude Desktop config block | --- @@ -392,7 +430,7 @@ see **[SECURITY.md](SECURITY.md)**. ## Development ```bash -uv sync --extra hpc --extra docs --dev +uv sync --extra hpc --extra transfer --extra docs uv run pre-commit run --all-files uv run pytest tests/ --ignore=tests/test_remote_agent.py uv run sphinx-build -b html docs docs/_build/html diff --git a/docs/tools.md b/docs/tools.md index 7f4cc4d..c5782b8 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -59,7 +59,7 @@ Supported operations: | `gradient`, `curl`, `divergence`, `azimuthal_mean` | Vector/radial diagnostics | | `subset_bbox`, `subset_polygon`, `cross_section` | Spatial selections | | `compare_fields`, `bias`, `rmse`, `pattern_correlation` | Same-grid comparisons | -| `remap_variable`, `regrid_dataset` | UXarray-backed remapping | +| `remap_variable`, `regrid_dataset` | Remap onto another unstructured grid (UXarray or YAC engine) | | `remap_to_rectilinear` | Remap a variable onto a regular lon/lat grid | | `temporal_mean`, `anomaly` | Time-dimension summaries | | `ensemble_mean`, `ensemble_spread` | Multi-file ensemble summaries | @@ -67,15 +67,43 @@ Supported operations: Common parameters include `grid_path`, `data_path`, `variable_name`, `target_grid_path`, `data_path_a`, `data_path_b`, `data_paths`, `lon_bounds`, -`lat_bounds`, `method`, `session_id`, and `dataset_handle`. Each operation -validates the parameters it requires and returns a clear error if one is -missing. - -`gradient`, `curl`, and `divergence` echo the `scale_by_radius` convention in -their result and provenance, and all three accept `scale_by_radius` -(default `True`, matching UXarray). When `True`, results are divided by -`uxgrid.sphere_radius` for physical units; the grid must define -`sphere_radius`. Pass `False` explicitly to keep unit-sphere results. +`lat_bounds`, `method`, `backend`, `yac_method`, `remap_to`, `target_lon`, +`target_lat`, `sphere_radius`, `lat_spec`, `lat_step`, `time_index`, +`level_index`, `session_id`, and `dataset_handle`. Each operation validates the +parameters it requires and returns a clear error if one is missing. + +**Remap methods and backends.** The three remap operations take `method`. +`nearest_neighbor`, `inverse_distance_weighted` and `bilinear` run on +UXarray's own engine. `conservative`, `nnn`, `dnn` and `average` run on +[YAC](https://dkrz-sw.gitlab-pages.dkrz.de/yac/) through UXarray's +`backend="yac"`; naming one of them as `method` selects YAC on its own, and +`backend="yac"` with `yac_method` is the explicit spelling. Results report +`method` as `yac:conservative` and so on, plus `backend` and `yac_method`. +YAC must be importable where the remap executes: locally (build with +`scripts/build_yac_local.sh`, then put its `site-packages` on `PYTHONPATH`) +or on the worker (`scripts/hpc_build_yac.py`; `diagnose_endpoint(action= +"check_yac")` confirms it). A missing install fails with a message naming +both repairs rather than a bare `ModuleNotFoundError`. Only `conservative` +preserves the field integral; every other method carries +`REMAP_METHOD_NOT_CONSERVATIVE`, worded with the method actually used. One +known UXarray limitation: `remap_to_rectilinear` with the YAC backend fails +when `target_lon` spans the full 360°; use a regional target or +`backend="uxarray"` for a global one. + +**Latitude bands.** `lat_spec` follows UXarray: a number is one latitude, a +list is explicit latitudes (band edges when `conservative=True`). JSON has no +tuple, so to ask for a regular range pass `lat_step` together with +`lat_spec=[start, stop]`, or `lat_step` alone for -90..90. + +`gradient`, `curl`, and `divergence` echo `scale_by_radius` and a +`radius_basis` block (`sphere_radius` and `radius_source`: `grid`, `argument` +or `none`) in their result. When `scale_by_radius` is `True` (the default, +matching UXarray) results are divided by `uxgrid.sphere_radius` for physical +units. Most grid files declare no radius; pass `sphere_radius=6371000` +(metres) to attach Earth's for the call. Without one, or with +`scale_by_radius=False`, the `radius_scaling` precondition fails and the call +is refused until `acknowledge` is passed -- the unit-sphere number is never +returned as if it were physical. `gradient`, `curl` and `divergence` declare **refusable preconditions** (#86) rather than warning and computing anyway. Each operation states, as data, what @@ -462,7 +490,12 @@ Supported `plot_type` values: - `zonal_mean` Common parameters include `grid_path`, `data_path`, `variable_name`, `width`, -`height`, `cmap`, `vmin`, `vmax`, `title`, `session_id`, and `dataset_handle`. +`height`, `cmap`, `vmin`, `vmax`, `title`, `time_index`, `level_index`, +`lat_spec`, `lat_step`, `conservative`, `session_id`, and `dataset_handle`. +`mesh_geo` additionally takes `lon_bounds`, `lat_bounds`, `show_mesh_boundary`, +`coastlines`, `borders`, `rivers`, `lakes`, `cities` and `basemap` (`basemap` +fetches tiles over the network). The `plot_note` in a `mesh_geo` result names +these parameters when it suggests what to change next. ### `run_workflow` and `resume_workflow` diff --git a/src/uxarray_mcp/domain/plotting.py b/src/uxarray_mcp/domain/plotting.py index 6df6375..9abf635 100644 --- a/src/uxarray_mcp/domain/plotting.py +++ b/src/uxarray_mcp/domain/plotting.py @@ -451,7 +451,7 @@ def render_variable( if title is not None: fig.axes[0].set_title(title) - fig.tight_layout() + _layout_with_colorbar(fig) buf = io.BytesIO() fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight") @@ -466,6 +466,26 @@ def render_variable( return png_bytes +def _layout_with_colorbar(fig: Any) -> None: + """Place the map and its colorbar side by side after a figure resize. + + HoloViews positions the colorbar axes in figure coordinates for the + figure size it chose. Resizing to the requested width/height leaves + that axes where it was, on top of the map, and ``tight_layout`` does + not manage axes it did not create -- the colorbar sat inside the plot + area over the right-hand third of the data. Laying the two out by hand + is the only arrangement that survives the resize. + """ + axes = list(fig.axes) + if len(axes) < 2: + fig.tight_layout() + return + main, *colorbars = axes + main.set_position([0.08, 0.12, 0.76, 0.80]) + for cax in colorbars: + cax.set_position([0.87, 0.12, 0.025, 0.80]) + + def render_zonal_mean( latitudes: list[float], values: list[float], diff --git a/src/uxarray_mcp/domain/remap_backend.py b/src/uxarray_mcp/domain/remap_backend.py new file mode 100644 index 0000000..378970e --- /dev/null +++ b/src/uxarray_mcp/domain/remap_backend.py @@ -0,0 +1,110 @@ +"""Resolve which remapping engine a request means. + +UXarray exposes two engines behind one accessor. Its own implements +``nearest_neighbor``, ``inverse_distance_weighted`` and ``bilinear``; YAC, +when its Python bindings are importable, adds ``nnn``, ``dnn``, ``average`` +and ``conservative``. Conservative remapping is the one that matters for +fluxes, and it exists only on the YAC side. + +A caller can say either ``backend="yac", yac_method="conservative"`` or the +shorter ``method="conservative"``; both mean the same call. This module turns +whichever was said into one canonical triple so the local and remote paths +dispatch identically. The remote payload in ``remote/compute_functions.py`` +inlines the same rules, because the worker has no ``uxarray_mcp`` to import. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +#: UXarray's native accessor methods. +UXARRAY_METHODS = ("nearest_neighbor", "inverse_distance_weighted", "bilinear") + +#: YAC interpolation stacks reachable through UXarray's ``backend="yac"``. +YAC_METHODS = ("nnn", "dnn", "average", "conservative") + + +@dataclass(frozen=True) +class RemapPlan: + """One resolved remap request.""" + + backend: str + method: str + yac_method: str | None + + @property + def label(self) -> str: + """Name reported in results: ``nearest_neighbor`` or ``yac:conservative``.""" + return f"yac:{self.yac_method}" if self.backend == "yac" else self.method + + @property + def coverage_method(self) -> str: + """The method name the coverage helpers should judge conservation by.""" + if self.backend == "yac" and self.yac_method is not None: + return self.yac_method + return self.method + + +def resolve_remap_plan( + method: str = "nearest_neighbor", + backend: str = "uxarray", + yac_method: str | None = None, +) -> RemapPlan: + """Normalise ``method``/``backend``/``yac_method`` into one plan. + + Raises + ------ + ValueError + For an unknown backend or method, or for ``yac_method`` given with + ``backend="uxarray"``, which would otherwise be silently ignored. + """ + method_l = (method or "nearest_neighbor").strip().lower() + backend_l = (backend or "uxarray").strip().lower() + yac_l = yac_method.strip().lower() if yac_method else None + + # ``method="conservative"`` is the natural thing to ask for; route it. + if method_l in YAC_METHODS: + if yac_l and yac_l != method_l: + raise ValueError( + f"method={method!r} and yac_method={yac_method!r} disagree; " + "pass one or the other." + ) + backend_l, yac_l = "yac", method_l + + if backend_l == "yac": + yac_l = yac_l or "nnn" + if yac_l not in YAC_METHODS: + raise ValueError( + f"Unsupported yac_method {yac_method!r}. Choose from " + f"{', '.join(repr(m) for m in YAC_METHODS)}." + ) + return RemapPlan(backend="yac", method=method_l, yac_method=yac_l) + + if backend_l != "uxarray": + raise ValueError( + f"Unsupported remap backend {backend!r}. Choose 'uxarray' or 'yac'." + ) + if yac_l: + raise ValueError( + f"yac_method={yac_method!r} requires backend='yac'; with the " + "uxarray backend it would be ignored." + ) + if method_l not in UXARRAY_METHODS: + raise ValueError( + f"Unsupported remap method {method!r}. Choose from " + f"{', '.join(repr(m) for m in UXARRAY_METHODS)} (uxarray backend) " + f"or {', '.join(repr(m) for m in YAC_METHODS)} (YAC backend)." + ) + return RemapPlan(backend="uxarray", method=method_l, yac_method=None) + + +def yac_unavailable_message(venue: str) -> str: + """Explain a failed YAC import in terms of what the caller can do.""" + return ( + "backend='yac' was requested but the 'yac' Python package could not be " + f"imported on the {venue}. Build YAC with Python bindings " + "(scripts/build_yac_local.sh locally, scripts/hpc_build_yac.py on a " + "worker) and put its site-packages on PYTHONPATH, or use " + "backend='uxarray' with method='nearest_neighbor', " + "'inverse_distance_weighted' or 'bilinear'." + ) diff --git a/src/uxarray_mcp/domain/remap_coverage.py b/src/uxarray_mcp/domain/remap_coverage.py index b44a701..42edb97 100644 --- a/src/uxarray_mcp/domain/remap_coverage.py +++ b/src/uxarray_mcp/domain/remap_coverage.py @@ -78,7 +78,7 @@ def compute_target_coverage( dict Keys: ``n_target_points``, ``points_in_source``, ``coverage_fraction``, ``source_bbox``, ``test`` (``"point_in_cell"`` or ``"bounding_box"``), - ``method_is_conservative``, and ``warning_codes``. + ``method``, ``method_is_conservative``, and ``warning_codes``. """ lon = _wrap_lon(np.asarray(list(target_lon), dtype=float)) lat = np.asarray(list(target_lat), dtype=float) @@ -162,6 +162,7 @@ def _coverage_of_points( "coverage_fraction": fraction, "source_bbox": bbox, "test": test, + "method": method, "method_is_conservative": conservative, "warning_codes": warning_codes, } diff --git a/src/uxarray_mcp/domain/vector_calc.py b/src/uxarray_mcp/domain/vector_calc.py index da1de6e..5cdd787 100644 --- a/src/uxarray_mcp/domain/vector_calc.py +++ b/src/uxarray_mcp/domain/vector_calc.py @@ -176,12 +176,35 @@ def evidence(var: Any) -> dict[str, str | bool | None]: } +def _apply_sphere_radius(uxds: Any, sphere_radius: float | None) -> dict: + """Attach a caller-supplied sphere radius to the grid for this call. + + UXarray scales derivatives by ``uxgrid.sphere_radius`` only when the grid + file declares one, and most do not. Without a way to supply it, a caller + on such a grid could only get the unit-sphere number, and only by + acknowledging it as unphysical. Setting the attribute here is what the + ``sphere_radius`` argument on the front door means, and it is recorded so + the result says where the radius came from. + """ + grid = uxds.uxgrid + declared = "sphere_radius" in getattr(grid, "_ds").attrs + if sphere_radius is not None: + if sphere_radius <= 0: + raise ValueError("sphere_radius must be a positive number of metres.") + grid.sphere_radius = float(sphere_radius) + return {"sphere_radius": float(sphere_radius), "radius_source": "argument"} + if declared: + return {"sphere_radius": float(grid.sphere_radius), "radius_source": "grid"} + return {"sphere_radius": None, "radius_source": "none"} + + def compute_gradient( uxds: Any, variable_name: str, scale_by_radius: bool = True, time_index: int = 0, level_index: int = 0, + sphere_radius: float | None = None, ) -> dict: """Compute the gradient of a face-centered scalar field. @@ -206,13 +229,18 @@ def compute_gradient( Vertical-level index to select if the variable carries a level dimension (e.g. atmospheric ``lev``). Ignored if there is no level dimension. + sphere_radius : float, optional + Radius in metres to attach to the grid before differentiating, for + grids that declare none. Overrides a declared value when given. Returns ------- dict Keys: variable_name, zonal_component_name, meridional_component_name, n_face, stats (min/max/mean for each component), reduced_dims (which - time/level axes were collapsed to reach a single face-centered slice). + time/level axes were collapsed to reach a single face-centered slice), + radius_basis (the radius used and whether it came from the grid, the + caller, or nowhere). """ if variable_name not in uxds.data_vars: raise ValueError( @@ -227,6 +255,7 @@ def compute_gradient( var, reduced_dims = _reduce_to_face( var, time_index=time_index, level_index=level_index ) + radius_basis = _apply_sphere_radius(uxds, sphere_radius) import numpy as np @@ -262,6 +291,7 @@ def _stats(arr: Any) -> dict: "component_stats": components, "n_face": int(uxds.uxgrid.n_face), "scale_by_radius": bool(scale_by_radius), + "radius_basis": radius_basis, "interpretation": "zonal (∂/∂x) and meridional (∂/∂y) components of the gradient", "component_warnings": uxarray_warnings, "reduced_dims": reduced_dims, @@ -290,6 +320,7 @@ def compute_curl( scale_by_radius: bool = True, time_index: int = 0, level_index: int = 0, + sphere_radius: float | None = None, ) -> dict: """Compute the curl (relative vorticity) of a 2-D vector field (u, v). @@ -349,6 +380,7 @@ def compute_curl( u_variable, v_variable, u, v, "curl" ) component_evidence = _vector_component_evidence(u, v) + radius_basis = _apply_sphere_radius(uxds, sphere_radius) result, uxarray_warnings = _call_capturing_warnings( lambda: u.curl(v, scale_by_radius=scale_by_radius) @@ -386,6 +418,7 @@ def compute_curl( "interpretation": "relative vorticity ζ = ∂v/∂x − ∂u/∂y", "n_face": int(uxds.uxgrid.n_face), "scale_by_radius": bool(scale_by_radius), + "radius_basis": radius_basis, "stats": stats, "component_warnings": component_warnings, "component_evidence": component_evidence, @@ -413,6 +446,7 @@ def compute_divergence( scale_by_radius: bool = True, time_index: int = 0, level_index: int = 0, + sphere_radius: float | None = None, ) -> dict: """Compute the horizontal divergence of a 2-D vector field (u, v). @@ -473,6 +507,7 @@ def compute_divergence( u_variable, v_variable, u, v, "divergence" ) component_evidence = _vector_component_evidence(u, v) + radius_basis = _apply_sphere_radius(uxds, sphere_radius) result, uxarray_warnings = _call_capturing_warnings( lambda: u.divergence(v, scale_by_radius=scale_by_radius) @@ -502,6 +537,7 @@ def compute_divergence( "interpretation": "horizontal divergence ∂u/∂x + ∂v/∂y", "n_face": int(uxds.uxgrid.n_face), "scale_by_radius": bool(scale_by_radius), + "radius_basis": radius_basis, "stats": stats, "component_warnings": component_warnings, "component_evidence": component_evidence, @@ -586,6 +622,20 @@ def compute_azimuthal_mean( result, "radius", time_index=time_index, level_index=level_index ) + # The ring at radius zero is a point, not a circle: it contains no face + # centres and is NaN by construction, so it says nothing about whether + # the rings reach the mesh. Counting it made every azimuthal profile + # report partial coverage. + import numpy as _np + + _radii = _np.asarray(radii, dtype=float) + _degenerate = [i for i, r in enumerate(_radii) if r == 0.0] + _counted = [v for i, v in enumerate(values) if i not in _degenerate] + # A centre the mesh does not reach still produces a profile of the + # requested length, every ring of it NaN. + coverage = compute_profile_coverage(_counted, source=var) + coverage["degenerate_bins_excluded"] = len(_degenerate) + return { "variable_name": variable_name, "center": {"lon": center_lon, "lat": center_lat}, @@ -595,7 +645,5 @@ def compute_azimuthal_mean( "azimuthal_mean_values": values, "reduced_dims": reduced_dims, "n_face": int(uxds.uxgrid.n_face), - # A centre the mesh does not reach still produces a profile of the - # requested length, every ring of it NaN. - "profile_coverage": compute_profile_coverage(values, source=var), + "profile_coverage": coverage, } diff --git a/src/uxarray_mcp/preconditions.py b/src/uxarray_mcp/preconditions.py index c9cdc8e..34d8581 100644 --- a/src/uxarray_mcp/preconditions.py +++ b/src/uxarray_mcp/preconditions.py @@ -171,12 +171,19 @@ def _radius_scaling_check( """The condition that decides whether a derivative has physical units.""" requested = bool(scale_by_radius) if not requested: + # Both repairs below must lead somewhere that passes. Pointing this + # branch at scale_by_radius=False and the next at scale_by_radius=True + # sent callers in a circle on any grid without a sphere_radius attribute; + # the way out of both is the sphere_radius argument. return _check( "radius_scaling", False, - "scale_by_radius=False.", - "Set scale_by_radius=True so the result carries physical units " - "instead of unit-sphere units.", + "scale_by_radius=False, so the result is a per-radian quantity on " + "the unit sphere.", + "Set scale_by_radius=True so the result carries physical units. " + "If the grid declares no sphere_radius, also pass " + "sphere_radius=6371000 (metres, Earth). To keep the unit-sphere " + "number anyway, pass acknowledge.", ) if scaling_applied is False: # Requested and refused by the data, not by the caller. Naming the @@ -188,10 +195,9 @@ def _radius_scaling_check( "scale_by_radius=True was requested but the grid carries no " "'sphere_radius' attribute, so UXarray left the result on the " "unit sphere.", - "Set uxgrid.sphere_radius on the source grid (6371000.0 m for " - "Earth) so the scaling can be applied, or pass " - "scale_by_radius=False and read the result as a per-radian " - "quantity.", + "Pass sphere_radius=6371000 (metres, Earth) so the scaling can " + "be applied; the value is attached to the grid for this call. " + "To keep the unit-sphere number anyway, pass acknowledge.", ) return _check( "radius_scaling", diff --git a/src/uxarray_mcp/remote/agent.py b/src/uxarray_mcp/remote/agent.py index 5287df1..886197d 100644 --- a/src/uxarray_mcp/remote/agent.py +++ b/src/uxarray_mcp/remote/agent.py @@ -401,6 +401,7 @@ async def calculate_gradient_remote( scale_by_radius: bool = True, time_index: int = 0, level_index: int = 0, + sphere_radius: Optional[float] = None, ) -> Dict[str, Any]: """Compute spatial gradient on HPC.""" return await self._run_on_hpc( @@ -411,6 +412,7 @@ async def calculate_gradient_remote( scale_by_radius, time_index, level_index, + sphere_radius, ) @action @@ -423,6 +425,7 @@ async def calculate_curl_remote( scale_by_radius: bool = True, time_index: int = 0, level_index: int = 0, + sphere_radius: Optional[float] = None, ) -> Dict[str, Any]: """Compute relative vorticity (curl) on HPC.""" return await self._run_on_hpc( @@ -434,6 +437,7 @@ async def calculate_curl_remote( scale_by_radius, time_index, level_index, + sphere_radius, ) @action @@ -446,6 +450,7 @@ async def calculate_divergence_remote( scale_by_radius: bool = True, time_index: int = 0, level_index: int = 0, + sphere_radius: Optional[float] = None, ) -> Dict[str, Any]: """Compute horizontal divergence on HPC.""" return await self._run_on_hpc( @@ -457,6 +462,7 @@ async def calculate_divergence_remote( scale_by_radius, time_index, level_index, + sphere_radius, ) @action diff --git a/src/uxarray_mcp/remote/compute_functions.py b/src/uxarray_mcp/remote/compute_functions.py index f49dd2e..be212e2 100644 --- a/src/uxarray_mcp/remote/compute_functions.py +++ b/src/uxarray_mcp/remote/compute_functions.py @@ -877,7 +877,16 @@ def remote_plot_variable( fig.set_dpi(dpi) if title is not None: fig.axes[0].set_title(title) - fig.tight_layout() + # Mirrors domain.plotting._layout_with_colorbar: after the resize the + # HoloViews colorbar axes sits over the map and tight_layout does not + # move it, so the two are laid out by hand. + _axes = list(fig.axes) + if len(_axes) < 2: + fig.tight_layout() + else: + _axes[0].set_position([0.08, 0.12, 0.76, 0.80]) + for _cax in _axes[1:]: + _cax.set_position([0.87, 0.12, 0.025, 0.80]) buf = io.BytesIO() fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight") @@ -1596,6 +1605,7 @@ def remote_calculate_gradient( scale_by_radius: bool = True, time_index: int = 0, level_index: int = 0, + sphere_radius: Optional[float] = None, ) -> Dict[str, Any]: """Compute the spatial gradient of a face-centered scalar field on HPC.""" import inspect as _inspect @@ -1667,6 +1677,26 @@ def remote_calculate_gradient( # structured result and _provenance.warnings, not just worker stderr. import warnings as _warnings_module + # Mirrors domain.vector_calc._apply_sphere_radius: a caller-supplied radius + # is attached to the grid so UXarray can scale, and where the radius came + # from is reported. Inlined because the worker has no uxarray_mcp. + _declared = "sphere_radius" in uxds.uxgrid._ds.attrs + if sphere_radius is not None: + if float(sphere_radius) <= 0: + raise ValueError("sphere_radius must be a positive number of metres.") + uxds.uxgrid.sphere_radius = float(sphere_radius) + _radius_basis = { + "sphere_radius": float(sphere_radius), + "radius_source": "argument", + } + elif _declared: + _radius_basis = { + "sphere_radius": float(uxds.uxgrid.sphere_radius), + "radius_source": "grid", + } + else: + _radius_basis = {"sphere_radius": None, "radius_source": "none"} + applied_scale = False with _warnings_module.catch_warnings(record=True) as _caught: _warnings_module.simplefilter("always") @@ -1701,6 +1731,7 @@ def _stats(arr: Any) -> Dict[str, Any]: "component_stats": {name: _stats(grad[name]) for name in comp_names}, "n_face": int(uxds.uxgrid.n_face), "scale_by_radius": applied_scale, + "radius_basis": _radius_basis, "interpretation": "zonal (d/dx) and meridional (d/dy) components of the gradient", "component_warnings": uxarray_warnings, "reduced_dims": _reduced, @@ -1731,6 +1762,7 @@ def remote_calculate_curl( scale_by_radius: bool = True, time_index: int = 0, level_index: int = 0, + sphere_radius: Optional[float] = None, ) -> Dict[str, Any]: """Compute relative vorticity (curl) of a 2-D wind field on HPC. @@ -1834,6 +1866,26 @@ def remote_calculate_curl( # structured result and _provenance.warnings, not just worker stderr. import warnings as _warnings_module + # Mirrors domain.vector_calc._apply_sphere_radius: a caller-supplied radius + # is attached to the grid so UXarray can scale, and where the radius came + # from is reported. Inlined because the worker has no uxarray_mcp. + _declared = "sphere_radius" in uxds.uxgrid._ds.attrs + if sphere_radius is not None: + if float(sphere_radius) <= 0: + raise ValueError("sphere_radius must be a positive number of metres.") + uxds.uxgrid.sphere_radius = float(sphere_radius) + _radius_basis = { + "sphere_radius": float(sphere_radius), + "radius_source": "argument", + } + elif _declared: + _radius_basis = { + "sphere_radius": float(uxds.uxgrid.sphere_radius), + "radius_source": "grid", + } + else: + _radius_basis = {"sphere_radius": None, "radius_source": "none"} + applied_scale = False with _warnings_module.catch_warnings(record=True) as _caught: _warnings_module.simplefilter("always") @@ -1874,6 +1926,7 @@ def remote_calculate_curl( "interpretation": "relative vorticity zeta = dv/dx - du/dy", "n_face": int(uxds.uxgrid.n_face), "scale_by_radius": applied_scale, + "radius_basis": _radius_basis, "stats": stats, "component_warnings": component_warnings, "reduced_dims": _reduced, @@ -1904,6 +1957,7 @@ def remote_calculate_divergence( scale_by_radius: bool = True, time_index: int = 0, level_index: int = 0, + sphere_radius: Optional[float] = None, ) -> Dict[str, Any]: """Compute horizontal divergence of a 2-D vector field on HPC. @@ -2004,6 +2058,26 @@ def remote_calculate_divergence( import warnings as _warnings_module + # Mirrors domain.vector_calc._apply_sphere_radius: a caller-supplied radius + # is attached to the grid so UXarray can scale, and where the radius came + # from is reported. Inlined because the worker has no uxarray_mcp. + _declared = "sphere_radius" in uxds.uxgrid._ds.attrs + if sphere_radius is not None: + if float(sphere_radius) <= 0: + raise ValueError("sphere_radius must be a positive number of metres.") + uxds.uxgrid.sphere_radius = float(sphere_radius) + _radius_basis = { + "sphere_radius": float(sphere_radius), + "radius_source": "argument", + } + elif _declared: + _radius_basis = { + "sphere_radius": float(uxds.uxgrid.sphere_radius), + "radius_source": "grid", + } + else: + _radius_basis = {"sphere_radius": None, "radius_source": "none"} + applied_scale = False with _warnings_module.catch_warnings(record=True) as _caught: _warnings_module.simplefilter("always") @@ -2042,6 +2116,7 @@ def remote_calculate_divergence( "interpretation": "horizontal divergence du/dx + dv/dy", "n_face": int(uxds.uxgrid.n_face), "scale_by_radius": applied_scale, + "radius_basis": _radius_basis, "stats": stats, "component_warnings": component_warnings, "reduced_dims": _reduced, @@ -2142,7 +2217,13 @@ def remote_calculate_azimuthal_mean( # worker cannot import uxarray_mcp. Bins the caller chose need not touch # the mesh, and an all-NaN profile is shaped exactly like an answer. _np = __import__("numpy") - _profile = _np.asarray(values, dtype=float) + # The ring at radius zero is a point, not a circle, and is NaN by + # construction; it is left out of the count as in the local path. + _radii_arr = _np.asarray(radii, dtype=float) + _degenerate = [i for i, r in enumerate(_radii_arr) if r == 0.0] + _profile = _np.asarray( + [v for i, v in enumerate(values) if i not in _degenerate], dtype=float + ) _src = _np.asarray(var.values, dtype=float) _n_bins = int(_profile.size) _n_filled = int(_np.isfinite(_profile).sum()) @@ -2158,6 +2239,7 @@ def remote_calculate_azimuthal_mean( "n_bins_filled": _n_filled, "source_has_missing": _src_missing, "cause": _cause, + "degenerate_bins_excluded": len(_degenerate), } return { @@ -2271,6 +2353,8 @@ def remote_remap_variable( variable_name: str, method: str = "nearest_neighbor", remap_to: str = "faces", + backend: str = "uxarray", + yac_method: Optional[str] = None, ) -> Dict[str, Any]: """Remap a face-centered variable onto a target grid on HPC. @@ -2308,13 +2392,116 @@ def _open_grid(gp): f"Variable '{variable_name}' not found. Available: {list(uxds.data_vars)}" ) uxda = uxds[variable_name] - if not hasattr(uxda.remap, method): + # Mirrors domain.remap_backend.resolve_remap_plan; inlined because the + # worker has no uxarray_mcp. Change both together. + _YAC_METHODS = ("nnn", "dnn", "average", "conservative") + _UX_METHODS = ("nearest_neighbor", "inverse_distance_weighted", "bilinear") + _method = (method or "nearest_neighbor").strip().lower() + _backend = (backend or "uxarray").strip().lower() + _yac = yac_method.strip().lower() if yac_method else None + if _method in _YAC_METHODS: + _backend, _yac = "yac", _yac or _method + if _backend == "yac": + _yac = _yac or "nnn" + if _yac not in _YAC_METHODS: + raise ValueError( + f"Unsupported yac_method {yac_method!r}. Choose from {_YAC_METHODS}." + ) + elif _backend != "uxarray": + raise ValueError( + f"Unsupported remap backend {backend!r}. Choose 'uxarray' or 'yac'." + ) + elif _method not in _UX_METHODS: raise ValueError( - f"Unsupported remap method '{method}'. Choose from " - "'nearest_neighbor', 'inverse_distance_weighted', or 'bilinear'." + f"Unsupported remap method {method!r}. Choose from {_UX_METHODS} " + f"(uxarray backend) or {_YAC_METHODS} (YAC backend)." + ) + _label = f"yac:{_yac}" if _backend == "yac" else _method + _coverage_method = _yac if _backend == "yac" else _method + + def _remap_one(_uxda): + if _backend == "yac": + try: + return _uxda.remap.nearest_neighbor( + target_grid, remap_to=remap_to, backend="yac", yac_method=_yac + ) + except Exception as exc: + if "yac" in type(exc).__name__.lower() or "yac.core" in str(exc): + raise RuntimeError( + "backend='yac' was requested but the 'yac' Python package " + "could not be imported on the HPC worker. Build YAC with " + "scripts/hpc_build_yac.py and put its site-packages on the " + "worker's PYTHONPATH, or use backend='uxarray'." + ) from exc + raise + return getattr(_uxda.remap, _method)(target_grid, remap_to=remap_to) + + # Coverage of the target mesh by the source, measured before remapping. + # Mirrors domain.remap_coverage.compute_scattered_coverage. + def _coverage(_src_grid): + _lon_attr, _lat_attr = ( + ("node_lon", "node_lat") + if remap_to == "nodes" + else ("face_lon", "face_lat") + ) + try: + _tl = np.asarray(getattr(target_grid, _lon_attr), dtype=float) + _tla = np.asarray(getattr(target_grid, _lat_attr), dtype=float) + except (AttributeError, ValueError, TypeError): + return None + if _tl.size == 0 or _tl.shape != _tla.shape: + return None + _tl = (_tl + 180.0) % 360.0 - 180.0 + _sl = (np.asarray(_src_grid.node_lon, dtype=float) + 180.0) % 360.0 - 180.0 + _sla = np.asarray(_src_grid.node_lat, dtype=float) + _bbox = { + "lon_min": float(_sl.min()), + "lon_max": float(_sl.max()), + "lat_min": float(_sla.min()), + "lat_max": float(_sla.max()), + } + _pts = np.column_stack([_tl, _tla]) + _in = ( + (_pts[:, 0] >= _bbox["lon_min"]) + & (_pts[:, 0] <= _bbox["lon_max"]) + & (_pts[:, 1] >= _bbox["lat_min"]) + & (_pts[:, 1] <= _bbox["lat_max"]) + ) + _n = int(_pts.shape[0]) + _inside = int(_in.sum()) + _test = "bounding_box" + if _inside and _n <= 20000: + try: + _f, _counts = _src_grid.get_faces_containing_point(_pts[_in]) + _inside = int(np.count_nonzero(np.asarray(_counts) > 0)) + _test = "point_in_cell" + except Exception: + _test = "bounding_box" + _conservative = _coverage_method in ( + "conservative", + "conservative_normed", + "first_order_conservative", ) + _codes = [] + if _inside == 0: + _codes.append("REMAP_COVERAGE_ZERO") + elif _inside < _n: + _codes.append("REMAP_COVERAGE_PARTIAL") + if not _conservative: + _codes.append("REMAP_METHOD_NOT_CONSERVATIVE") + return { + "n_target_points": _n, + "points_in_source": _inside, + "coverage_fraction": (float(_inside) / _n) if _n else 0.0, + "source_bbox": _bbox, + "test": _test, + "method": _coverage_method, + "method_is_conservative": _conservative, + "warning_codes": _codes, + } - remapped = getattr(uxda.remap, method)(target_grid, remap_to=remap_to) + coverage = _coverage(source_grid) + remapped = _remap_one(uxda) vals = np.asarray(remapped.values, dtype=float) finite = vals[np.isfinite(vals)] stats = ( @@ -2328,9 +2515,11 @@ def _open_grid(gp): else {"min": None, "max": None, "mean": None, "std": None} ) - return { + out = { "variable_name": variable_name, - "method": method, + "method": _label, + "backend": _backend, + "yac_method": _yac, "remap_to": remap_to, "source_grid": { "n_face": int(source_grid.n_face), @@ -2353,6 +2542,9 @@ def _open_grid(gp): "pbs_job_id": __import__("os").environ.get("PBS_JOBID"), }, } + if coverage is not None: + out["source_coverage"] = coverage + return out def remote_regrid_dataset( @@ -2362,6 +2554,8 @@ def remote_regrid_dataset( variable_names: Optional[list] = None, method: str = "nearest_neighbor", remap_to: str = "faces", + backend: str = "uxarray", + yac_method: Optional[str] = None, ) -> Dict[str, Any]: """Remap all selected face-centered variables onto a target grid on HPC. @@ -2399,16 +2593,118 @@ def _open_grid(gp): if not variables: raise ValueError("No face-centered variables available for remapping.") - first = uxds[variables[0]] - if not hasattr(first.remap, method): + # Mirrors domain.remap_backend.resolve_remap_plan; inlined because the + # worker has no uxarray_mcp. Change both together. + _YAC_METHODS = ("nnn", "dnn", "average", "conservative") + _UX_METHODS = ("nearest_neighbor", "inverse_distance_weighted", "bilinear") + _method = (method or "nearest_neighbor").strip().lower() + _backend = (backend or "uxarray").strip().lower() + _yac = yac_method.strip().lower() if yac_method else None + if _method in _YAC_METHODS: + _backend, _yac = "yac", _yac or _method + if _backend == "yac": + _yac = _yac or "nnn" + if _yac not in _YAC_METHODS: + raise ValueError( + f"Unsupported yac_method {yac_method!r}. Choose from {_YAC_METHODS}." + ) + elif _backend != "uxarray": raise ValueError( - f"Unsupported remap method '{method}'. Choose from " - "'nearest_neighbor', 'inverse_distance_weighted', or 'bilinear'." + f"Unsupported remap backend {backend!r}. Choose 'uxarray' or 'yac'." + ) + elif _method not in _UX_METHODS: + raise ValueError( + f"Unsupported remap method {method!r}. Choose from {_UX_METHODS} " + f"(uxarray backend) or {_YAC_METHODS} (YAC backend)." + ) + _label = f"yac:{_yac}" if _backend == "yac" else _method + _coverage_method = _yac if _backend == "yac" else _method + + def _remap_one(_uxda): + if _backend == "yac": + try: + return _uxda.remap.nearest_neighbor( + target_grid, remap_to=remap_to, backend="yac", yac_method=_yac + ) + except Exception as exc: + if "yac" in type(exc).__name__.lower() or "yac.core" in str(exc): + raise RuntimeError( + "backend='yac' was requested but the 'yac' Python package " + "could not be imported on the HPC worker. Build YAC with " + "scripts/hpc_build_yac.py and put its site-packages on the " + "worker's PYTHONPATH, or use backend='uxarray'." + ) from exc + raise + return getattr(_uxda.remap, _method)(target_grid, remap_to=remap_to) + + # Coverage of the target mesh by the source, measured before remapping. + # Mirrors domain.remap_coverage.compute_scattered_coverage. + def _coverage(_src_grid): + _lon_attr, _lat_attr = ( + ("node_lon", "node_lat") + if remap_to == "nodes" + else ("face_lon", "face_lat") + ) + try: + _tl = np.asarray(getattr(target_grid, _lon_attr), dtype=float) + _tla = np.asarray(getattr(target_grid, _lat_attr), dtype=float) + except (AttributeError, ValueError, TypeError): + return None + if _tl.size == 0 or _tl.shape != _tla.shape: + return None + _tl = (_tl + 180.0) % 360.0 - 180.0 + _sl = (np.asarray(_src_grid.node_lon, dtype=float) + 180.0) % 360.0 - 180.0 + _sla = np.asarray(_src_grid.node_lat, dtype=float) + _bbox = { + "lon_min": float(_sl.min()), + "lon_max": float(_sl.max()), + "lat_min": float(_sla.min()), + "lat_max": float(_sla.max()), + } + _pts = np.column_stack([_tl, _tla]) + _in = ( + (_pts[:, 0] >= _bbox["lon_min"]) + & (_pts[:, 0] <= _bbox["lon_max"]) + & (_pts[:, 1] >= _bbox["lat_min"]) + & (_pts[:, 1] <= _bbox["lat_max"]) + ) + _n = int(_pts.shape[0]) + _inside = int(_in.sum()) + _test = "bounding_box" + if _inside and _n <= 20000: + try: + _f, _counts = _src_grid.get_faces_containing_point(_pts[_in]) + _inside = int(np.count_nonzero(np.asarray(_counts) > 0)) + _test = "point_in_cell" + except Exception: + _test = "bounding_box" + _conservative = _coverage_method in ( + "conservative", + "conservative_normed", + "first_order_conservative", ) + _codes = [] + if _inside == 0: + _codes.append("REMAP_COVERAGE_ZERO") + elif _inside < _n: + _codes.append("REMAP_COVERAGE_PARTIAL") + if not _conservative: + _codes.append("REMAP_METHOD_NOT_CONSERVATIVE") + return { + "n_target_points": _n, + "points_in_source": _inside, + "coverage_fraction": (float(_inside) / _n) if _n else 0.0, + "source_bbox": _bbox, + "test": _test, + "method": _coverage_method, + "method_is_conservative": _conservative, + "warning_codes": _codes, + } + coverage = _coverage(uxds.uxgrid) per_variable = {} for name in variables: - remapped = getattr(uxds[name].remap, method)(target_grid, remap_to=remap_to) + remapped = _remap_one(uxds[name]) vals = np.asarray(remapped.values, dtype=float) finite = vals[np.isfinite(vals)] per_variable[name] = ( @@ -2422,8 +2718,10 @@ def _open_grid(gp): else {"min": None, "max": None, "mean": None, "shape": list(vals.shape)} ) - return { - "method": method, + out = { + "method": _label, + "backend": _backend, + "yac_method": _yac, "remap_to": remap_to, "variables": list(variables), "target_grid": { @@ -2441,6 +2739,9 @@ def _open_grid(gp): "pbs_job_id": __import__("os").environ.get("PBS_JOBID"), }, } + if coverage is not None: + out["source_coverage"] = coverage + return out def remote_remap_to_rectilinear( @@ -2450,6 +2751,7 @@ def remote_remap_to_rectilinear( target_lon: list, target_lat: list, backend: str = "uxarray", + yac_method: Optional[str] = None, ) -> Dict[str, Any]: """Remap a face-centered variable onto a rectilinear lon/lat grid on HPC. @@ -2484,6 +2786,26 @@ def remote_remap_to_rectilinear( lon = list(target_lon) lat = list(target_lat) + # Mirrors domain.remap_backend.resolve_remap_plan for the rectilinear case. + _YAC_METHODS = ("nnn", "dnn", "average", "conservative") + _backend = (backend or "uxarray").strip().lower() + _yac = yac_method.strip().lower() if yac_method else None + if _yac in _YAC_METHODS: + _backend = "yac" + if _backend == "yac": + _yac = _yac or "nnn" + if _yac not in _YAC_METHODS: + raise ValueError( + f"Unsupported yac_method {yac_method!r}. Choose from {_YAC_METHODS}." + ) + elif _backend != "uxarray": + raise ValueError( + f"Unsupported remap backend {backend!r}. Choose 'uxarray' or 'yac'." + ) + elif _yac: + raise ValueError(f"yac_method={yac_method!r} requires backend='yac'.") + _coverage_method = _yac if _backend == "yac" else "nearest_neighbor" + _conservative = _coverage_method == "conservative" # Same coverage screen as the local path, inlined because the worker does # not have uxarray_mcp installed. grid_lon = (np.asarray(uxda.uxgrid.node_lon, dtype=float) + 180.0) % 360.0 - 180.0 @@ -2518,18 +2840,40 @@ def remote_remap_to_rectilinear( coverage_codes.append("REMAP_COVERAGE_ZERO") elif n_inside < n_points: coverage_codes.append("REMAP_COVERAGE_PARTIAL") - coverage_codes.append("REMAP_METHOD_NOT_CONSERVATIVE") + if not _conservative: + coverage_codes.append("REMAP_METHOD_NOT_CONSERVATIVE") coverage = { "n_target_points": n_points, "points_in_source": n_inside, "coverage_fraction": (float(n_inside) / n_points) if n_points else 0.0, "source_bbox": bbox, "test": coverage_test, - "method_is_conservative": False, + "method": _coverage_method, + "method_is_conservative": _conservative, "warning_codes": coverage_codes, } - remapped = uxda.remap.to_rectilinear(lon, lat, backend=backend) + try: + remapped = uxda.remap.to_rectilinear( + lon, lat, backend=_backend, yac_method=_yac + ) + except Exception as exc: + if _backend == "yac" and ( + "yac" in type(exc).__name__.lower() or "yac.core" in str(exc) + ): + raise RuntimeError( + "backend='yac' was requested but the 'yac' Python package could " + "not be imported on the HPC worker. Build YAC with " + "scripts/hpc_build_yac.py and put its site-packages on the " + "worker's PYTHONPATH, or use backend='uxarray'." + ) from exc + if _backend == "yac" and "Cannot reshape remapped data" in str(exc): + raise RuntimeError( + f"{exc} This is a known UXarray limitation of backend='yac' when " + "target_lon spans the full 360 degrees. Use a regional " + "target_lon, or backend='uxarray' for a global one." + ) from exc + raise vals = np.asarray(remapped.values, dtype=float) finite = vals[np.isfinite(vals)] stats = ( @@ -2544,7 +2888,9 @@ def remote_remap_to_rectilinear( return { "variable_name": variable_name, - "backend": backend, + "backend": _backend, + "method": f"yac:{_yac}" if _backend == "yac" else "nearest_neighbor", + "yac_method": _yac, "target_shape": [len(lat), len(lon)], "stats": stats, "source_coverage": coverage, diff --git a/src/uxarray_mcp/tools/advanced.py b/src/uxarray_mcp/tools/advanced.py index ec63deb..a5d24ec 100644 --- a/src/uxarray_mcp/tools/advanced.py +++ b/src/uxarray_mcp/tools/advanced.py @@ -99,13 +99,43 @@ def _coverage_warnings(coverage: dict[str, Any]) -> list[str]: "fall inside the source mesh; values outside are extrapolated." ) if "REMAP_METHOD_NOT_CONSERVATIVE" in codes: + # Name the method that was actually used; this message once said + # "nearest-neighbor" for every method, including inverse-distance. + method = coverage.get("method") or "this" messages.append( - "REMAP_METHOD_NOT_CONSERVATIVE: nearest-neighbor remapping does " - "not conserve the field integral and is unsuitable for fluxes." + f"REMAP_METHOD_NOT_CONSERVATIVE: {method} remapping does not " + "conserve the field integral and is unsuitable for fluxes. " + "Use method='conservative' (YAC backend) when the integral matters." ) return messages +def _run_remap(uxda: Any, target_grid: Any, plan: Any, remap_to: str) -> Any: + """Dispatch one remap according to a resolved plan. + + Both engines are reached through the same UXarray accessor; YAC is + selected with ``backend="yac"`` on ``nearest_neighbor``, which is the + entry point UXarray gives it, and the interpolation stack is chosen by + ``yac_method``. A missing YAC install surfaces as an ImportError-like + failure from inside UXarray; it is re-raised with the repair spelled out. + """ + from uxarray_mcp.domain.remap_backend import yac_unavailable_message + + if plan.backend == "yac": + try: + return uxda.remap.nearest_neighbor( + target_grid, + remap_to=remap_to, + backend="yac", + yac_method=plan.yac_method, + ) + except Exception as exc: # YacNotAvailableError subclasses RuntimeError + if "yac" in type(exc).__name__.lower() or "yac.core" in str(exc): + raise RuntimeError(yac_unavailable_message("local machine")) from exc + raise + return getattr(uxda.remap, plan.method)(target_grid, remap_to=remap_to) + + def _resolve_paths( *, session_id: str | None = None, @@ -928,15 +958,27 @@ def remap_variable( result_name: str | None = None, use_remote: bool = False, endpoint: str | None = None, + backend: str = "uxarray", + yac_method: str | None = None, ) -> dict[str, Any]: """Remap a face-centered variable onto a target grid. + ``method`` names a UXarray method (``nearest_neighbor``, + ``inverse_distance_weighted``, ``bilinear``) or a YAC one (``nnn``, + ``dnn``, ``average``, ``conservative``); the latter select + ``backend="yac"`` automatically. ``backend="yac"`` with ``yac_method`` + says the same thing explicitly. YAC must be importable where the remap + runs. + When ``use_remote=True`` and an HPC endpoint is configured, the remap runs on the worker (where large meshes live) and compact summary statistics are returned — the full remapped array is not shipped across the network. Falls back to local execution when the endpoint is unavailable and the paths are locally reachable. """ + from uxarray_mcp.domain.remap_backend import resolve_remap_plan + + plan = resolve_remap_plan(method=method, backend=backend, yac_method=yac_method) tracker = OperationTracker("remap_variable", session_id=session_id) resolved_grid, resolved_data = _resolve_paths( session_id=session_id, @@ -970,11 +1012,18 @@ def remap_variable( resolved_data, target_grid_path, variable_name, - method, + plan.method, remap_to, + plan.backend, + plan.yac_method, ) ) remote_result["_provenance"]["operation_id"] = tracker.operation_id + remote_coverage = remote_result.get("source_coverage") + if remote_coverage: + remote_result["_provenance"].setdefault("warnings", []).extend( + _coverage_warnings(remote_coverage) + ) tracker.succeed("Variable remap complete (remote).") return remote_result if not _path_is_locally_reachable(resolved_grid): @@ -990,19 +1039,14 @@ def remap_variable( target_grid = load_grid(target_grid_path) _, uxda, selected = _load_dataarray(resolved_grid, resolved_data, variable_name) - if not hasattr(uxda.remap, method): - raise ValueError( - f"Unsupported remap method {method!r}. Choose from " - "'nearest_neighbor', 'inverse_distance_weighted', or 'bilinear'." - ) # Measured before the remap, because the remap itself fills every target # point regardless and so cannot tell an interpolated value from an # extrapolated one afterwards. coverage = _grid_coverage( - uxda.uxgrid, target_grid, remap_to=remap_to, method=method + uxda.uxgrid, target_grid, remap_to=remap_to, method=plan.coverage_method ) - tracker.stage("remapping", f"Running {method} remap.") - remapped = getattr(uxda.remap, method)(target_grid, remap_to=remap_to) + tracker.stage("remapping", f"Running {plan.label} remap.") + remapped = _run_remap(uxda, target_grid, plan, remap_to) result_handle = _persist_dataarray_result( data=remapped, session_id=session_id, @@ -1012,7 +1056,8 @@ def remap_variable( metadata={ "source_grid": resolved_grid, "target_grid": target_grid_path, - "method": method, + "method": plan.label, + "backend": plan.backend, "remap_to": remap_to, "variable_name": selected, }, @@ -1020,7 +1065,9 @@ def remap_variable( tracker.succeed("Variable remap complete.") result: dict[str, Any] = { "variable_name": selected, - "method": method, + "method": plan.label, + "backend": plan.backend, + "yac_method": plan.yac_method, "remap_to": remap_to, "source_grid": summarize_grid(source_grid), "target_grid": summarize_grid(target_grid), @@ -1038,6 +1085,8 @@ def remap_variable( "grid_path": grid_path, "data_path": data_path, "method": method, + "backend": plan.backend, + "yac_method": plan.yac_method, "remap_to": remap_to, "session_id": session_id, "dataset_handle": dataset_handle, @@ -1060,13 +1109,21 @@ def regrid_dataset( result_name: str | None = None, use_remote: bool = False, endpoint: str | None = None, + backend: str = "uxarray", + yac_method: str | None = None, ) -> dict[str, Any]: """Remap all selected face-centered variables in a dataset onto a target grid. + ``method``/``backend``/``yac_method`` are resolved exactly as in + :func:`remap_variable`; ``method="conservative"`` selects YAC. + When ``use_remote=True`` and an HPC endpoint is configured, the regrid runs on the worker and per-variable summary statistics are returned. Falls back to local execution when the endpoint is unavailable and paths are local. """ + from uxarray_mcp.domain.remap_backend import resolve_remap_plan + + plan = resolve_remap_plan(method=method, backend=backend, yac_method=yac_method) tracker = OperationTracker("regrid_dataset", session_id=session_id) resolved_grid, resolved_data = _resolve_paths( session_id=session_id, @@ -1100,11 +1157,18 @@ def regrid_dataset( resolved_data, target_grid_path, variable_names, - method, + plan.method, remap_to, + plan.backend, + plan.yac_method, ) ) remote_result["_provenance"]["operation_id"] = tracker.operation_id + remote_coverage = remote_result.get("source_coverage") + if remote_coverage: + remote_result["_provenance"].setdefault("warnings", []).extend( + _coverage_warnings(remote_coverage) + ) tracker.succeed("Dataset regridding complete (remote).") return remote_result if not _path_is_locally_reachable(resolved_grid): @@ -1118,11 +1182,6 @@ def regrid_dataset( uxds = load_dataset(resolved_grid, resolved_data) target_grid = load_grid(target_grid_path) - if not hasattr(uxds[next(iter(uxds.data_vars))].remap, method): - raise ValueError( - f"Unsupported remap method {method!r}. Choose from " - "'nearest_neighbor', 'inverse_distance_weighted', or 'bilinear'." - ) variables = variable_names or [ name for name, var in uxds.data_vars.items() @@ -1133,12 +1192,12 @@ def regrid_dataset( # One source mesh and one target mesh for every variable in the dataset, # so coverage is a property of the pair and is measured once. coverage = _grid_coverage( - uxds.uxgrid, target_grid, remap_to=remap_to, method=method + uxds.uxgrid, target_grid, remap_to=remap_to, method=plan.coverage_method ) dataset_parts = [] for name in variables: - tracker.stage("remapping", f"Remapping variable {name}") - remapped = getattr(uxds[name].remap, method)(target_grid, remap_to=remap_to) + tracker.stage("remapping", f"Remapping variable {name} ({plan.label})") + remapped = _run_remap(uxds[name], target_grid, plan, remap_to) dataset_parts.append(remapped.to_dataset(name=name).to_xarray()) remapped_dataset = xr.merge(dataset_parts) result_handle = _persist_dataset_result( @@ -1150,13 +1209,16 @@ def regrid_dataset( metadata={ "source_grid": resolved_grid, "target_grid": target_grid_path, - "method": method, + "method": plan.label, + "backend": plan.backend, "variables": variables, }, ) tracker.succeed("Dataset regridding complete.") result: dict[str, Any] = { - "method": method, + "method": plan.label, + "backend": plan.backend, + "yac_method": plan.yac_method, "variables": variables, "target_grid": summarize_grid(target_grid), "result_handle": result_handle, @@ -1173,6 +1235,8 @@ def regrid_dataset( "data_path": data_path, "variable_names": variable_names, "method": method, + "backend": plan.backend, + "yac_method": plan.yac_method, "remap_to": remap_to, "session_id": session_id, "dataset_handle": dataset_handle, @@ -1194,6 +1258,7 @@ def remap_to_rectilinear( result_name: str | None = None, use_remote: bool = False, endpoint: str | None = None, + yac_method: str | None = None, ) -> dict[str, Any]: """Remap a face-centered variable onto a regular lon/lat (rectilinear) grid. @@ -1211,6 +1276,10 @@ def remap_to_rectilinear( Source grid and data files (or resolve from session/dataset handle). backend : str Remapping backend: ``"uxarray"`` (default) or ``"yac"``. + yac_method : str, optional + YAC interpolation stack when ``backend="yac"``: ``"nnn"`` (default), + ``"dnn"``, ``"average"`` or ``"conservative"``. Passing one of these + selects the YAC backend on its own. session_id, dataset_handle, result_name : optional Session/result-handle plumbing. @@ -1227,6 +1296,15 @@ def remap_to_rectilinear( NotImplementedError If the installed UXarray lacks ``remap.to_rectilinear``. """ + from uxarray_mcp.domain.remap_backend import ( + resolve_remap_plan, + yac_unavailable_message, + ) + + plan = resolve_remap_plan( + method=yac_method or "nearest_neighbor", backend=backend, yac_method=yac_method + ) + backend = plan.backend tracker = OperationTracker("remap_to_rectilinear", session_id=session_id) resolved_grid, resolved_data = _resolve_paths( session_id=session_id, @@ -1263,7 +1341,8 @@ def remap_to_rectilinear( variable_name, list(target_lon), list(target_lat), - backend, + plan.backend, + plan.yac_method, ) ) # Persist the small rectilinear array locally from the returned data. @@ -1326,9 +1405,31 @@ def remap_to_rectilinear( lat = list(target_lat) # Coverage is measured before remapping so a zero-coverage request is # reported even when the interpolation happily fills every target point. - coverage = compute_target_coverage(uxda.uxgrid, lon, lat, method="nearest_neighbor") - tracker.stage("remapping", f"Remapping {selected} to {len(lat)}x{len(lon)} grid.") - remapped = uxda.remap.to_rectilinear(lon, lat, backend=backend) + coverage = compute_target_coverage( + uxda.uxgrid, lon, lat, method=plan.coverage_method + ) + tracker.stage( + "remapping", + f"Remapping {selected} to {len(lat)}x{len(lon)} grid ({plan.label}).", + ) + try: + remapped = uxda.remap.to_rectilinear( + lon, lat, backend=plan.backend, yac_method=plan.yac_method + ) + except Exception as exc: + if plan.backend == "yac" and ( + "yac" in type(exc).__name__.lower() or "yac.core" in str(exc) + ): + raise RuntimeError(yac_unavailable_message("local machine")) from exc + if plan.backend == "yac" and "Cannot reshape remapped data" in str(exc): + # UXarray's YAC rectilinear path builds a cyclic target when the + # longitudes span the globe and then cannot reshape it back. + raise RuntimeError( + f"{exc} This is a known UXarray limitation of backend='yac' " + "when target_lon spans the full 360 degrees. Use a regional " + "target_lon, or backend='uxarray' for a global one." + ) from exc + raise # remapped is a plain xarray.DataArray with lat/lon axes. remapped_ds = remapped.to_dataset(name=selected) @@ -1362,7 +1463,9 @@ def remap_to_rectilinear( tracker.succeed("Rectilinear remap complete.") result: dict[str, Any] = { "variable_name": selected, - "backend": backend, + "backend": plan.backend, + "method": plan.label, + "yac_method": plan.yac_method, "target_shape": [len(lat), len(lon)], "stats": stats, "source_coverage": coverage, @@ -1378,7 +1481,8 @@ def remap_to_rectilinear( "target_lat": lat, "grid_path": grid_path, "data_path": data_path, - "backend": backend, + "backend": plan.backend, + "yac_method": plan.yac_method, "session_id": session_id, "dataset_handle": dataset_handle, }, diff --git a/src/uxarray_mcp/tools/frontdoor.py b/src/uxarray_mcp/tools/frontdoor.py index a42303d..4422b94 100644 --- a/src/uxarray_mcp/tools/frontdoor.py +++ b/src/uxarray_mcp/tools/frontdoor.py @@ -161,6 +161,40 @@ def _suggest_operations(requested: str) -> list[str]: return ranked[:3] +def _resolve_lat_spec(lat_spec: Any, lat_step: float | None) -> Any: + """Turn the JSON-reachable spelling of a latitude range into UXarray's. + + UXarray reads a tuple as ``(start, stop, step)`` and a list as explicit + latitudes (or band edges). JSON has no tuple, so ``[-90, 90, 30]`` from + an MCP client always meant three latitudes and there was no way to ask + for a step. ``lat_step`` supplies it: with ``lat_spec=[start, stop]`` (or + omitted, for the whole globe) the three become the tuple UXarray wants. + """ + if lat_step is None: + return lat_spec + if lat_step <= 0: + raise ValueError("lat_step must be a positive number of degrees.") + if lat_spec is None: + start, stop = -90.0, 90.0 + elif isinstance(lat_spec, (int, float)): + raise ValueError( + "lat_step needs a range: pass lat_spec=[start, stop] with it, " + "or omit lat_spec for -90..90." + ) + else: + bounds = list(lat_spec) + if len(bounds) != 2: + raise ValueError( + "With lat_step, lat_spec must be [start, stop]; got " + f"{len(bounds)} values. Without lat_step a list is read as " + "explicit latitudes." + ) + start, stop = float(bounds[0]), float(bounds[1]) + if stop <= start: + raise ValueError(f"lat_spec range must increase; got [{start}, {stop}].") + return (start, stop, float(lat_step)) + + def _require(value: Any, name: str, operation: str) -> Any: if value is None: raise ValueError(f"{operation!r} requires {name}.") @@ -588,6 +622,8 @@ def run_analysis( radius_step: float | None = None, method: str = "nearest_neighbor", remap_to: str = "faces", + backend: str = "uxarray", + yac_method: str | None = None, groupby: str | None = None, baseline: str = "temporal_mean", output_path: str | None = None, @@ -601,6 +637,7 @@ def run_analysis( time_index: int = 0, level_index: int = 0, lat_spec: tuple | float | list[Any] | None = None, + lat_step: float | None = None, conservative: bool = False, target_lon: list[float] | None = None, target_lat: list[float] | None = None, @@ -611,44 +648,43 @@ def run_analysis( ) -> dict[str, Any]: """Run one analysis operation by intent instead of exposing many tools. - Supported operations: - ``inspect_mesh``, ``inspect_variable``, ``validate_dataset``, + Operations: ``inspect_mesh``, ``inspect_variable``, ``validate_dataset``, ``calculate_area``, ``calculate_zonal_mean``, ``zonal_anomaly``, ``gradient``, ``curl``, ``divergence``, ``azimuthal_mean``, ``subset_bbox``, ``subset_polygon``, ``cross_section``, ``compare_fields``, ``bias``, ``rmse``, ``pattern_correlation``, ``remap_variable``, ``regrid_dataset``, ``remap_to_rectilinear``, ``temporal_mean``, - ``anomaly``, ``ensemble_mean``, ``ensemble_spread``, and ``export``. - - ``gradient`` and ``curl`` accept ``scale_by_radius`` (default True matches - UXarray and returns physical units when sphere-radius metadata exists). - ``gradient``, ``curl``, ``divergence``, ``calculate_zonal_mean``, and - ``azimuthal_mean`` - also accept ``time_index``/``level_index`` to select a single time/level - slice when the input variable(s) carry those extra dimensions (e.g. real - model output shaped ``(time, lev, n_face)``); both default to 0 and are - ignored for variables that are already face-centered only. The two are - never interchangeable: ``time_index`` reaches only time-like axes and - ``level_index`` only vertical ones, so neither can silently return the - other's slice. - ``zonal_anomaly`` accepts ``lat_spec`` and - ``conservative``. ``remap_to_rectilinear`` accepts ``target_lon`` and - ``target_lat`` (1-D coordinate arrays). ``calculate_area`` accepts - ``sphere_radius`` in metres, and refuses without one unless the grid - declares its own: UXarray integrates on the unit sphere, so the - unscaled numbers are steradians rather than areas. - - ``curl`` and ``divergence`` declare preconditions and refuse rather than - return an unphysical number: if the components are not verifiably a - vector field, the call returns ``outcome='input_required'`` with the - failed checks and the repairs that would fix them. Pass ``acknowledge`` - with the token named in that response to run it anyway; the result is - then marked ``unverified``. - - ``verdict_policy`` controls the ``postconditions`` block: ``"full"`` - (default) returns reference, residual, tolerance, and verdict; - ``"reference_only"`` returns reference and tolerance and requires the - caller to compute the comparison itself; ``"off"`` evaluates nothing. + ``anomaly``, ``ensemble_mean``, ``ensemble_spread``, ``export``. + + ``sphere_radius`` (metres; 6371000 for Earth) gives ``calculate_area``, + ``gradient``, ``curl`` and ``divergence`` a radius when the grid declares + none. Without one, areas are steradians and derivatives are per radian, + so those calls are refused rather than returned. ``scale_by_radius`` + (default True) is the UXarray flag the refusal guards. + + ``time_index``/``level_index`` select one slice of a ``(time, lev, + n_face)`` variable for the derivative and profile operations; each + reaches only its own kind of axis. + + ``lat_spec`` for the zonal operations: a number is one latitude, a list + is explicit latitudes (band edges when ``conservative``). For a regular + range pass ``lat_step`` with ``lat_spec=[start, stop]`` (or none for the + globe); JSON cannot spell UXarray's ``(start, stop, step)`` tuple. + + Remaps take ``method``: ``nearest_neighbor``, ``inverse_distance_weighted`` + or ``bilinear`` on UXarray's engine; ``nnn``, ``dnn``, ``average`` or + ``conservative`` on YAC, which must be importable where the remap runs. + ``backend="yac"`` with ``yac_method`` is the explicit spelling. Only + ``conservative`` preserves the field integral. ``remap_to_rectilinear`` + takes ``target_lon``/``target_lat`` (1-D arrays). + + Failed preconditions return ``outcome='input_required'`` with the + checks and repairs instead of a number; pass ``acknowledge`` with the + token named there to run anyway, marked ``unverified``. + + ``verdict_policy``: ``"full"`` (default) returns reference, residual, + tolerance and verdict; ``"reference_only"`` omits the verdict; ``"off"`` + evaluates nothing. """ from uxarray_mcp.tools.advanced import ( calculate_anomaly, @@ -685,6 +721,7 @@ def run_analysis( # Accept hyphen- and space-separated spellings of an operation name; # an agent that writes "zonal mean" means calculate_zonal_mean. op = "_".join(operation.strip().lower().replace("-", "_").split()) + lat_spec = _resolve_lat_spec(lat_spec, lat_step) session_id = _resolve_optional_session(session_id, dataset_handle) grid_path, data_path = _paths_from_handle( session_id, dataset_handle, grid_path, data_path @@ -757,6 +794,7 @@ def run_analysis( use_remote=use_remote, endpoint=endpoint, session_id=session_id, + sphere_radius=sphere_radius, ) if op == "curl": return calculate_curl( @@ -770,6 +808,7 @@ def run_analysis( use_remote=use_remote, endpoint=endpoint, session_id=session_id, + sphere_radius=sphere_radius, ) if op == "divergence": return calculate_divergence( @@ -783,6 +822,7 @@ def run_analysis( use_remote=use_remote, endpoint=endpoint, session_id=session_id, + sphere_radius=sphere_radius, ) if op == "azimuthal_mean": return calculate_azimuthal_mean( @@ -876,6 +916,8 @@ def run_analysis( data_path=data_path, method=method, remap_to=remap_to, + backend=backend, + yac_method=yac_method, session_id=session_id, dataset_handle=dataset_handle, result_name=result_name, @@ -890,6 +932,8 @@ def run_analysis( variable_names=[variable_name] if variable_name else None, method=method, remap_to=remap_to, + backend=backend, + yac_method=yac_method, session_id=session_id, dataset_handle=dataset_handle, result_name=result_name, @@ -897,12 +941,19 @@ def run_analysis( endpoint=endpoint, ) if op == "remap_to_rectilinear": + from uxarray_mcp.domain.remap_backend import YAC_METHODS + + # method="conservative" means the same here as for the mesh remaps. + if yac_method is None and method and method.strip().lower() in YAC_METHODS: + yac_method = method.strip().lower() return remap_to_rectilinear( variable_name=_require(variable_name, "variable_name", op), target_lon=_require(target_lon, "target_lon", op), target_lat=_require(target_lat, "target_lat", op), grid_path=grid_path, data_path=data_path, + backend=backend, + yac_method=yac_method, session_id=session_id, dataset_handle=dataset_handle, result_name=result_name, @@ -981,10 +1032,18 @@ def plot_dataset( time_index: int = 0, level_index: int = 0, lat_spec: tuple | float | list[Any] | None = None, + lat_step: float | None = None, conservative: bool = False, line_color: str = "#1f77b4", lon_bounds: list[float] | None = None, lat_bounds: list[float] | None = None, + show_mesh_boundary: bool = False, + coastlines: bool = True, + borders: bool = True, + rivers: bool = False, + lakes: bool = True, + cities: bool = False, + basemap: bool = False, use_remote: bool = False, endpoint: str | None = None, session_id: str | None = None, @@ -992,15 +1051,24 @@ def plot_dataset( ) -> list[Any]: """Render mesh, geographic mesh, variable, or zonal-mean plots. + ``plot_type`` is one of ``mesh``, ``mesh_geo``, ``variable``, + ``zonal_mean``. + ``time_index`` and ``level_index`` are separate selectors, applied only to time-like and level-like dimensions respectively. Results carry a ``reduced_dims`` entry naming every dimension that was collapsed to make the picture, because the PNG cannot say so itself. + + ``mesh_geo`` (local only) draws the cell outlines over Natural Earth + features and takes ``show_mesh_boundary``, ``coastlines``, ``borders``, + ``rivers``, ``lakes``, ``cities`` and ``basemap`` (``basemap`` needs a + network connection). The other plot types ignore these. """ from uxarray_mcp.tools.plotting import plot_mesh_geo from uxarray_mcp.tools.remote_tools import plot_mesh, plot_variable, plot_zonal_mean kind = plot_type.strip().lower().replace("-", "_") + lat_spec = _resolve_lat_spec(lat_spec, lat_step) if kind == "mesh": return plot_mesh( grid_path=grid_path, @@ -1019,6 +1087,13 @@ def plot_dataset( height=height, lon_bounds=lon_bounds, lat_bounds=lat_bounds, + coastlines=coastlines, + borders=borders, + rivers=rivers, + lakes=lakes, + show_mesh_boundary=show_mesh_boundary, + basemap=basemap, + cities=cities, session_id=session_id, dataset_handle=dataset_handle, ) diff --git a/src/uxarray_mcp/tools/orchestration.py b/src/uxarray_mcp/tools/orchestration.py index 687ee6d..c2696fb 100644 --- a/src/uxarray_mcp/tools/orchestration.py +++ b/src/uxarray_mcp/tools/orchestration.py @@ -55,21 +55,23 @@ def _png_meta(items: list[Any]) -> dict[str, Any]: image_size_bytes = len(base64.b64decode(png_b64)) except Exception: image_size_bytes = None + uri = meta.get("image_uri") or block_uri(img) + # This summary is a JSON object, not a content-block list, so a client + # cannot render base64 embedded in it; it only costs context. When the + # figure was written to the artifact store, hand back the path and drop + # the bytes. The bytes stay only when nothing was stored, so a caller + # who cannot reach the filesystem still gets a picture. + inline = png_b64 if uri is None else None out = { - "png_b64": png_b64, + "png_b64": inline, "image_size_bytes": image_size_bytes, "grid_info": meta.get("grid_info"), "variable_name": meta.get("variable_name"), "_provenance": meta.get("_provenance", {}), } - # A large figure is a resource_link rather than inline bytes, so pass - # the URI along; otherwise the summary reports no image at all for - # exactly the meshes big enough to be interesting. - if png_b64 is None: - uri = meta.get("image_uri") or block_uri(img) - if uri is not None: - out["image_uri"] = str(uri) - out["image_delivery"] = "resource_link" + if uri is not None: + out["image_uri"] = str(uri) + out["image_delivery"] = "resource_link" return out @@ -326,26 +328,53 @@ def analyze_dataset( note="render the zonal profile", ) ) - next_steps.append( - call( - "extract_cross_section", - latitude="0.0", - grid_path="grid_path", - data_path="data_path", - variable_name=literal(selected_variable), + if use_remote: + # Subsetting and cross-sections have no remote implementation and + # would be refused with use_remote=True; the profile operations do, + # so suggest those instead of a call the caller cannot make. + next_steps.append( + call( + "zonal_anomaly", + grid_path="grid_path", + data_path="data_path", + variable_name=literal(selected_variable), + use_remote="True", + note="eddy component on the worker", + ) ) - ) - next_steps.append( - call( - "subset_bbox", - lon_bounds="[-180, 180]", - lat_bounds="[-90, 90]", - grid_path="grid_path", - data_path="data_path", - variable_name=literal(selected_variable), - note="focus on a region", + next_steps.append( + call( + "remap_variable", + target_grid_path=needed("target_grid_path"), + method=literal("conservative"), + grid_path="grid_path", + data_path="data_path", + variable_name=literal(selected_variable), + use_remote="True", + note="YAC conservative remap on the worker", + ) + ) + else: + next_steps.append( + call( + "extract_cross_section", + latitude="0.0", + grid_path="grid_path", + data_path="data_path", + variable_name=literal(selected_variable), + ) + ) + next_steps.append( + call( + "subset_bbox", + lon_bounds="[-180, 180]", + lat_bounds="[-90, 90]", + grid_path="grid_path", + data_path="data_path", + variable_name=literal(selected_variable), + note="focus on a region", + ) ) - ) if not next_steps: next_steps.append( call( diff --git a/src/uxarray_mcp/tools/plotting.py b/src/uxarray_mcp/tools/plotting.py index 56fc209..28ec935 100644 --- a/src/uxarray_mcp/tools/plotting.py +++ b/src/uxarray_mcp/tools/plotting.py @@ -437,10 +437,7 @@ def _build_plot_note( "This mesh has no real geographic cutout boundary." ) elif status == "disabled": - lines.append( - "Mesh boundary not shown (off by default). " - 'Ask "show the mesh boundary" to enable it.' - ) + lines.append("Mesh boundary not shown (show_mesh_boundary=false by default).") # ── Seam faces note ─────────────────────────────────────────────────────── skipped = render_info["seam_faces_skipped"] @@ -468,23 +465,23 @@ def _build_plot_note( lines.append("Terrain basemap: NASA stock image (contextily not installed).") # ── What the user can ask for next ──────────────────────────────────────── + # Named as the parameters that select them, so a caller reaching this + # through plot_dataset can act on the note without guessing the API. suggestions = [] if lon_bounds is None: - suggestions.append('zoom to a region (e.g. "show North America")') + suggestions.append("lon_bounds/lat_bounds to zoom to a region") if "rivers" not in feats: - suggestions.append("add rivers") + suggestions.append("rivers=true") if not cities: - suggestions.append("add city labels") + suggestions.append("cities=true for city labels") if basemap == "none": - suggestions.append("add terrain background") + suggestions.append("basemap=true for a terrain background (network)") if status == "disabled": - suggestions.append("show the mesh boundary in red") + suggestions.append("show_mesh_boundary=true to trace the mesh edge in red") if "borders" in feats: - suggestions.append("remove borders") - suggestions.append("make cells more transparent") - suggestions.append("show mesh-only without geographic features") + suggestions.append("borders=false") - lines.append("You can ask to: " + "; ".join(suggestions[:5]) + ".") + lines.append("Options: " + "; ".join(suggestions[:5]) + ".") return "\n".join(lines) diff --git a/src/uxarray_mcp/tools/vector_calc.py b/src/uxarray_mcp/tools/vector_calc.py index 25bc80e..3b3c7eb 100644 --- a/src/uxarray_mcp/tools/vector_calc.py +++ b/src/uxarray_mcp/tools/vector_calc.py @@ -91,6 +91,7 @@ def calculate_gradient( use_remote: bool = False, endpoint: Optional[str] = None, session_id: Optional[str] = None, + sphere_radius: Optional[float] = None, ) -> Dict[str, Any]: """Compute the spatial gradient of a face-centered scalar field. @@ -157,6 +158,7 @@ def calculate_gradient( "data_path": data_path, "variable_name": variable_name, "scale_by_radius": scale_by_radius, + "sphere_radius": sphere_radius, "time_index": time_index, "level_index": level_index, } @@ -170,6 +172,7 @@ def _local(): scale_by_radius=scale_by_radius, time_index=time_index, level_index=level_index, + sphere_radius=sphere_radius, ), tool="calculate_gradient", inputs=inputs, @@ -190,6 +193,7 @@ def _local(): scale_by_radius, time_index, level_index, + sphere_radius, ) ), ) @@ -206,6 +210,7 @@ def calculate_curl( use_remote: bool = False, endpoint: Optional[str] = None, session_id: Optional[str] = None, + sphere_radius: Optional[float] = None, ) -> Dict[str, Any]: """Compute the curl (relative vorticity) of a 2-D wind or vector field. @@ -281,6 +286,7 @@ def calculate_curl( "u_variable": u_variable, "v_variable": v_variable, "scale_by_radius": scale_by_radius, + "sphere_radius": sphere_radius, "time_index": time_index, "level_index": level_index, } @@ -294,6 +300,7 @@ def _local(): scale_by_radius=scale_by_radius, time_index=time_index, level_index=level_index, + sphere_radius=sphere_radius, ) return attach_provenance( result, @@ -318,6 +325,7 @@ def _local(): scale_by_radius, time_index, level_index, + sphere_radius, ) ), ) @@ -334,6 +342,7 @@ def calculate_divergence( use_remote: bool = False, endpoint: Optional[str] = None, session_id: Optional[str] = None, + sphere_radius: Optional[float] = None, ) -> Dict[str, Any]: """Compute the horizontal divergence of a 2-D vector field. @@ -401,6 +410,7 @@ def calculate_divergence( "u_variable": u_variable, "v_variable": v_variable, "scale_by_radius": scale_by_radius, + "sphere_radius": sphere_radius, "time_index": time_index, "level_index": level_index, } @@ -414,6 +424,7 @@ def _local(): scale_by_radius=scale_by_radius, time_index=time_index, level_index=level_index, + sphere_radius=sphere_radius, ) return attach_provenance( result, @@ -438,6 +449,7 @@ def _local(): scale_by_radius, time_index, level_index, + sphere_radius, ) ), ) diff --git a/src/uxarray_mcp/typed_results.py b/src/uxarray_mcp/typed_results.py index f9022c4..00384e8 100644 --- a/src/uxarray_mcp/typed_results.py +++ b/src/uxarray_mcp/typed_results.py @@ -206,12 +206,14 @@ def declared_output_schemas() -> dict[str, dict[str, Any]]: "type": "object", "description": ( "Physical conditions checked before computing. Status " - "'not_evaluated' means no check ran -- distinct from 'failed'." + "'not_evaluated' means no check ran -- distinct from 'failed'. " + "'overridden' means a check failed and the caller acknowledged " + "it; the number was produced but is not interpretable." ), "properties": { "status": { "type": "string", - "enum": ["satisfied", "failed", "not_evaluated"], + "enum": ["satisfied", "failed", "overridden", "not_evaluated"], }, "checks": {"type": "array", "items": {"type": "object"}}, "failed_checks": {"type": "array", "items": {"type": "string"}}, diff --git a/tests/test_analyze_dataset.py b/tests/test_analyze_dataset.py index 2f9b2c1..c18ff55 100644 --- a/tests/test_analyze_dataset.py +++ b/tests/test_analyze_dataset.py @@ -53,12 +53,23 @@ def test_analyze_dataset_with_data_runs_full_pipeline(synthetic_mesh_with_data): def test_analyze_dataset_includes_plots_by_default(): - """Plot stages should produce base64 PNGs when include_plots=True.""" + """Plot stages run when include_plots=True and hand back a stored figure. + + The summary is a JSON object, so base64 in it cannot be rendered by a + client and only costs context; a stored figure is referenced by URI and + the bytes are left out. + """ result = analyze_dataset("healpix:2") assert result["mesh_plot"] is not None - assert result["mesh_plot"]["png_b64"] assert "plot_mesh" in result["stages_run"] + plot = result["mesh_plot"] + if plot.get("image_uri"): + assert plot["png_b64"] is None + assert plot["image_delivery"] == "resource_link" + assert plot["image_size_bytes"] + else: + assert plot["png_b64"] def test_analyze_dataset_resolves_session_dataset_handle(synthetic_mesh_with_data): diff --git a/tests/test_regular_user_fixes.py b/tests/test_regular_user_fixes.py new file mode 100644 index 0000000..93105eb --- /dev/null +++ b/tests/test_regular_user_fixes.py @@ -0,0 +1,311 @@ +"""Defects found by driving the server as a regular user, pinned so they stay fixed. + +Each class names the behaviour a first-time caller hit: + +* a gradient on any grid without a ``sphere_radius`` attribute could not be + obtained -- both repairs pointed at each other -- and acknowledging the + refusal then failed the published output schema; +* the azimuthal profile always reported partial coverage because the ring + at radius zero is empty by construction; +* there was no JSON spelling for a regular latitude range; +* ``analyze_dataset`` embedded base64 that no client could render. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import uxarray as ux +import xarray as xr + +from uxarray_mcp.content_blocks import image_block +from uxarray_mcp.domain.vector_calc import ( + compute_azimuthal_mean, + compute_curl, + compute_divergence, + compute_gradient, +) +from uxarray_mcp.preconditions import OVERRIDE_TOKEN, _radius_scaling_check +from uxarray_mcp.tools.frontdoor import _resolve_lat_spec, run_analysis +from uxarray_mcp.typed_results import output_schema_for + + +def _healpix_scalar_dataset(zoom: int = 2): + grid = ux.Grid.from_healpix(zoom=zoom) + rng = np.random.default_rng(3) + field = xr.DataArray( + rng.standard_normal(grid.n_face), dims=["n_face"], attrs={"units": "K"} + ) + u = xr.DataArray( + rng.standard_normal(grid.n_face), + dims=["n_face"], + attrs={"units": "m s-1", "standard_name": "eastward_wind"}, + ) + v = xr.DataArray( + rng.standard_normal(grid.n_face), + dims=["n_face"], + attrs={"units": "m s-1", "standard_name": "northward_wind"}, + ) + return ux.UxDataset( + { + "t": ux.UxDataArray(field, uxgrid=grid), + "u": ux.UxDataArray(u, uxgrid=grid), + "v": ux.UxDataArray(v, uxgrid=grid), + }, + uxgrid=grid, + ) + + +@pytest.fixture() +def healpix_files(tmp_path): + """A HEALPix grid spec plus a data file the front door can open.""" + ds = _healpix_scalar_dataset() + data_path = tmp_path / "field.nc" + xr.Dataset( + { + name: (["n_face"], ds[name].values, ds[name].attrs) + for name in ("t", "u", "v") + } + ).to_netcdf(data_path) + return "healpix:2", str(data_path) + + +class TestRadiusRepairsAreNotCircular: + def test_missing_attribute_repair_names_the_argument(self): + check = _radius_scaling_check(True, False) + assert not check["passed"] + assert "sphere_radius=6371000" in check["repair"] + assert "scale_by_radius=False" not in check["repair"] + + def test_unscaled_request_repair_also_names_the_argument(self): + check = _radius_scaling_check(False, None) + assert not check["passed"] + assert "sphere_radius=6371000" in check["repair"] + assert "acknowledge" in check["repair"] + + def test_applied_scaling_passes(self): + assert _radius_scaling_check(True, True)["passed"] + + +class TestSphereRadiusArgument: + def test_gradient_uses_a_supplied_radius(self): + ds = _healpix_scalar_dataset() + result = compute_gradient(ds, "t", sphere_radius=6371000.0) + assert result["radius_basis"] == { + "sphere_radius": 6371000.0, + "radius_source": "argument", + } + assert result["scientific_status"]["physical_scaling_applied"] is True + assert result["component_warnings"] == [] + + def test_gradient_without_a_radius_reports_none(self): + ds = _healpix_scalar_dataset() + result = compute_gradient(ds, "t") + assert result["radius_basis"]["radius_source"] == "none" + assert result["scientific_status"]["physical_scaling_applied"] is False + + def test_declared_grid_radius_is_reported_as_the_grid(self): + ds = _healpix_scalar_dataset() + ds.uxgrid.sphere_radius = 1000.0 + result = compute_gradient(ds, "t") + assert result["radius_basis"] == { + "sphere_radius": 1000.0, + "radius_source": "grid", + } + + def test_argument_overrides_a_declared_radius(self): + ds = _healpix_scalar_dataset() + ds.uxgrid.sphere_radius = 1000.0 + result = compute_gradient(ds, "t", sphere_radius=2000.0) + assert result["radius_basis"]["radius_source"] == "argument" + assert result["radius_basis"]["sphere_radius"] == 2000.0 + + def test_scaled_gradient_is_the_unit_sphere_one_divided_by_radius(self): + unit = compute_gradient(_healpix_scalar_dataset(), "t", scale_by_radius=False) + scaled = compute_gradient(_healpix_scalar_dataset(), "t", sphere_radius=1000.0) + for component in unit["components"]: + assert scaled["component_stats"][component]["max"] == pytest.approx( + unit["component_stats"][component]["max"] / 1000.0 + ) + + @pytest.mark.parametrize("compute", [compute_curl, compute_divergence]) + def test_curl_and_divergence_take_the_radius_too(self, compute): + ds = _healpix_scalar_dataset() + result = compute(ds, "u", "v", sphere_radius=6371000.0) + assert result["radius_basis"]["radius_source"] == "argument" + assert result["scientific_status"]["physical_scaling_applied"] is True + + def test_non_positive_radius_is_rejected(self): + with pytest.raises(ValueError, match="positive"): + compute_gradient(_healpix_scalar_dataset(), "t", sphere_radius=0.0) + + def test_front_door_gradient_with_radius_is_satisfied( + self, state_dir, healpix_files + ): + grid, data = healpix_files + result = run_analysis( + "gradient", + grid_path=grid, + data_path=data, + variable_name="t", + sphere_radius=6371000, + ) + assert result["outcome"] == "complete" + assert result["preconditions"]["status"] == "satisfied" + assert result["scientific_status"]["physically_interpretable"] is True + + def test_front_door_gradient_without_radius_is_refused_with_the_repair( + self, state_dir, healpix_files + ): + grid, data = healpix_files + result = run_analysis( + "gradient", grid_path=grid, data_path=data, variable_name="t" + ) + assert result["outcome"] == "input_required" + (repair,) = result["refusal"]["repairs"] + assert "sphere_radius=6371000" in repair + + +class TestOverriddenResultValidates: + """An acknowledged refusal must still fit the published output schema.""" + + @pytest.fixture(autouse=True) + def _needs_jsonschema(self): + pytest.importorskip("jsonschema") + + def test_schema_admits_overridden(self): + schema = output_schema_for("run_analysis") + status = schema["properties"]["preconditions"]["properties"]["status"] + assert "overridden" in status["enum"] + + def test_acknowledged_gradient_validates(self, state_dir, healpix_files): + import jsonschema + + grid, data = healpix_files + result = run_analysis( + "gradient", + grid_path=grid, + data_path=data, + variable_name="t", + scale_by_radius=False, + acknowledge=OVERRIDE_TOKEN, + ) + assert result["outcome"] == "complete" + assert result["preconditions"]["status"] == "overridden" + assert result["scientific_status"]["physically_interpretable"] is False + jsonschema.validate(result, output_schema_for("run_analysis")) + + +class TestAzimuthalZeroRing: + def test_zero_radius_ring_is_not_counted_as_a_miss(self): + ds = _healpix_scalar_dataset(zoom=3) + result = compute_azimuthal_mean( + ds, "t", center_lon=0.0, center_lat=0.0, outer_radius=30.0, radius_step=10.0 + ) + assert result["radii_deg"][0] == 0.0 + assert np.isnan(result["azimuthal_mean_values"][0]) + coverage = result["profile_coverage"] + assert coverage["degenerate_bins_excluded"] == 1 + assert coverage["n_bins"] == len(result["radii_deg"]) - 1 + assert coverage["n_bins_filled"] == coverage["n_bins"] + assert coverage["cause"] == "none" + + def test_front_door_profile_is_interpretable(self, state_dir, healpix_files): + grid, data = healpix_files + result = run_analysis( + "azimuthal_mean", + grid_path=grid, + data_path=data, + variable_name="t", + center_lon=0.0, + center_lat=0.0, + outer_radius=40.0, + radius_step=20.0, + ) + assert result["scientific_status"]["warning_codes"] == [] + assert result["scientific_status"]["physically_interpretable"] is True + + +class TestLatStep: + def test_no_step_leaves_the_spec_alone(self): + assert _resolve_lat_spec([-90, 90, 30], None) == [-90, 90, 30] + assert _resolve_lat_spec(None, None) is None + + def test_step_alone_spans_the_globe(self): + assert _resolve_lat_spec(None, 30) == (-90.0, 90.0, 30.0) + + def test_step_with_bounds_builds_the_tuple(self): + assert _resolve_lat_spec([-60, 60], 15) == (-60.0, 60.0, 15.0) + + @pytest.mark.parametrize( + "spec,step,match", + [ + ([-90, 0, 90], 30, r"\[start, stop\]"), + (30.0, 10, "needs a range"), + ([60, -60], 10, "must increase"), + (None, 0, "positive"), + ], + ) + def test_bad_combinations_are_refused_clearly(self, spec, step, match): + with pytest.raises(ValueError, match=match): + _resolve_lat_spec(spec, step) + + def test_front_door_zonal_mean_honours_lat_step(self, state_dir, healpix_files): + grid, data = healpix_files + result = run_analysis( + "calculate_zonal_mean", + grid_path=grid, + data_path=data, + variable_name="t", + lat_step=45, + ) + assert result["latitudes"] == [-90.0, -45.0, 0.0, 45.0, 90.0] + + +class TestRemapResultNamesItsMethod: + def test_result_reports_backend_and_names_its_method_in_the_warning( + self, state_dir, healpix_files + ): + grid, data = healpix_files + result = run_analysis( + "remap_variable", + grid_path=grid, + data_path=data, + variable_name="t", + target_grid_path="healpix:1", + method="nearest_neighbor", + ) + assert result["outcome"] == "complete", result + assert result["method"] == "nearest_neighbor" + assert result["backend"] == "uxarray" + assert result["yac_method"] is None + assert result["source_coverage"]["method"] == "nearest_neighbor" + warnings = result["_provenance"]["warnings"] + assert any("nearest_neighbor remapping" in w for w in warnings) + assert not any("nearest-neighbor" in w for w in warnings) + + +class TestAnalyzeDatasetDoesNotInlineStoredFigures: + def test_png_meta_prefers_the_uri(self): + import base64 + import json + + from uxarray_mcp.tools.orchestration import _png_meta + + b64 = base64.b64encode(b"\x89PNG_fake").decode() + meta = json.dumps({"image_uri": "file:///tmp/x.png", "image_size_bytes": 9}) + out = _png_meta([image_block(b64), {"type": "text", "text": meta}]) + assert out["png_b64"] is None + assert out["image_uri"] == "file:///tmp/x.png" + assert out["image_delivery"] == "resource_link" + + def test_png_meta_keeps_bytes_when_nothing_was_stored(self): + import base64 + import json + + from uxarray_mcp.tools.orchestration import _png_meta + + b64 = base64.b64encode(b"\x89PNG_fake").decode() + out = _png_meta([image_block(b64), {"type": "text", "text": json.dumps({})}]) + assert out["png_b64"] == b64 + assert "image_uri" not in out diff --git a/tests/test_remap_backend.py b/tests/test_remap_backend.py new file mode 100644 index 0000000..bcabe9e --- /dev/null +++ b/tests/test_remap_backend.py @@ -0,0 +1,208 @@ +"""Selecting the remap engine: UXarray's own or YAC, from one set of arguments. + +Before this, ``run_analysis`` had no way to reach YAC at all -- the +``backend`` argument stopped at the front door -- so conservative remapping, +the one that matters for fluxes, was documented but unreachable. These tests +pin the resolution rules and the places that must agree with them. +""" + +from __future__ import annotations + +import ast +import inspect +import textwrap + +import pytest + +from uxarray_mcp.domain.remap_backend import ( + UXARRAY_METHODS, + YAC_METHODS, + RemapPlan, + resolve_remap_plan, + yac_unavailable_message, +) +from uxarray_mcp.remote import compute_functions as cf +from uxarray_mcp.tools.advanced import _coverage_warnings + + +class TestResolveRemapPlan: + def test_default_is_uxarray_nearest_neighbour(self): + plan = resolve_remap_plan() + assert plan == RemapPlan("uxarray", "nearest_neighbor", None) + assert plan.label == "nearest_neighbor" + assert plan.coverage_method == "nearest_neighbor" + + @pytest.mark.parametrize("method", UXARRAY_METHODS) + def test_uxarray_methods_stay_on_uxarray(self, method): + plan = resolve_remap_plan(method=method) + assert plan.backend == "uxarray" + assert plan.method == method + assert plan.yac_method is None + + @pytest.mark.parametrize("method", YAC_METHODS) + def test_a_yac_method_name_selects_the_yac_backend(self, method): + """``method="conservative"`` is the natural request; it must route.""" + plan = resolve_remap_plan(method=method) + assert plan.backend == "yac" + assert plan.yac_method == method + assert plan.label == f"yac:{method}" + assert plan.coverage_method == method + + def test_explicit_backend_defaults_to_nnn(self): + plan = resolve_remap_plan(backend="yac") + assert plan.yac_method == "nnn" + + def test_explicit_backend_and_yac_method(self): + plan = resolve_remap_plan(backend="yac", yac_method="dnn") + assert plan == RemapPlan("yac", "nearest_neighbor", "dnn") + + def test_case_and_whitespace_are_forgiven(self): + plan = resolve_remap_plan(method=" Conservative ", backend=" YAC ") + assert plan.yac_method == "conservative" + + def test_conflicting_method_and_yac_method_refuse(self): + with pytest.raises(ValueError, match="disagree"): + resolve_remap_plan(method="conservative", yac_method="nnn") + + def test_yac_method_on_uxarray_backend_refuses_rather_than_ignores(self): + with pytest.raises(ValueError, match="requires backend='yac'"): + resolve_remap_plan(method="nearest_neighbor", yac_method="conservative") + + def test_unknown_method_lists_both_engines(self): + with pytest.raises(ValueError) as excinfo: + resolve_remap_plan(method="bogus") + message = str(excinfo.value) + assert "nearest_neighbor" in message + assert "conservative" in message + + def test_unknown_backend_refuses(self): + with pytest.raises(ValueError, match="Unsupported remap backend"): + resolve_remap_plan(backend="esmf") + + def test_unknown_yac_method_refuses(self): + with pytest.raises(ValueError, match="Unsupported yac_method"): + resolve_remap_plan(backend="yac", yac_method="bilinear") + + def test_only_conservative_is_conservative(self): + from uxarray_mcp.domain.remap_coverage import method_is_conservative + + for method in YAC_METHODS + UXARRAY_METHODS: + plan = resolve_remap_plan(method=method) + assert method_is_conservative(plan.coverage_method) is ( + method == "conservative" + ) + + +class TestCoverageWarningNamesTheMethod: + """The not-conservative warning once said 'nearest-neighbor' for IDW too.""" + + def _coverage(self, method): + return { + "n_target_points": 10, + "points_in_source": 10, + "method": method, + "warning_codes": ["REMAP_METHOD_NOT_CONSERVATIVE"], + } + + @pytest.mark.parametrize( + "method", ["nearest_neighbor", "inverse_distance_weighted", "nnn", "dnn"] + ) + def test_message_carries_the_method_used(self, method): + (message,) = _coverage_warnings(self._coverage(method)) + assert message.startswith(f"REMAP_METHOD_NOT_CONSERVATIVE: {method} ") + assert "conservative" in message # points at the fix + + def test_message_without_a_method_still_reads(self): + coverage = self._coverage(None) + coverage.pop("method") + (message,) = _coverage_warnings(coverage) + assert "nearest-neighbor" not in message + + +class TestYacUnavailableMessage: + def test_names_venue_and_both_repairs(self): + message = yac_unavailable_message("HPC worker") + assert "HPC worker" in message + assert "backend='uxarray'" in message + assert "PYTHONPATH" in message + + +class TestRemoteInlineMirrorsDomain: + """The worker copies the resolution rules; they must not drift apart.""" + + @pytest.mark.parametrize( + "function", + [cf.remote_remap_variable, cf.remote_regrid_dataset], + ) + def test_method_lists_match(self, function): + tree = ast.parse(textwrap.dedent(inspect.getsource(function))) + found = {} + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if isinstance(target, ast.Name) and target.id in ( + "_YAC_METHODS", + "_UX_METHODS", + ): + found[target.id] = tuple(ast.literal_eval(node.value)) + assert found["_YAC_METHODS"] == YAC_METHODS + assert found["_UX_METHODS"] == UXARRAY_METHODS + + def test_rectilinear_payload_carries_the_yac_methods(self): + tree = ast.parse( + textwrap.dedent(inspect.getsource(cf.remote_remap_to_rectilinear)) + ) + yac_lists = [ + tuple(ast.literal_eval(node.value)) + for node in ast.walk(tree) + if isinstance(node, ast.Assign) + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == "_YAC_METHODS" + ] + assert yac_lists == [YAC_METHODS] + + @pytest.mark.parametrize( + "function", + [ + cf.remote_remap_variable, + cf.remote_regrid_dataset, + cf.remote_remap_to_rectilinear, + ], + ) + def test_remote_signatures_accept_backend_and_yac_method(self, function): + params = inspect.signature(function).parameters + assert params["backend"].default == "uxarray" + assert params["yac_method"].default is None + + +class TestFrontDoorForwardsBackend: + def test_run_analysis_declares_backend_and_yac_method(self): + from uxarray_mcp.tools.frontdoor import run_analysis + + params = inspect.signature(run_analysis).parameters + assert params["backend"].default == "uxarray" + assert params["yac_method"].default is None + + def test_conservative_without_yac_fails_with_the_repair( + self, state_dir, comparison_mesh_with_data, remap_target_grid + ): + """On a machine without YAC the failure must say what to do.""" + pytest.importorskip("uxarray") + try: + import yac.core # noqa: F401 + + pytest.skip("YAC is importable here; the refusal cannot be observed") + except ImportError: + pass + from uxarray_mcp.tools.frontdoor import run_analysis + + grid_file, data_a, _ = comparison_mesh_with_data + with pytest.raises(RuntimeError, match="backend='uxarray'"): + run_analysis( + "remap_variable", + grid_path=grid_file, + data_path=data_a, + variable_name="temperature", + target_grid_path=remap_target_grid, + method="conservative", + ) diff --git a/tests/test_typed_results.py b/tests/test_typed_results.py index 1ac1500..80e9e11 100644 --- a/tests/test_typed_results.py +++ b/tests/test_typed_results.py @@ -117,6 +117,7 @@ def test_not_evaluated_is_distinct_from_failed(self): assert set(pre["properties"]["status"]["enum"]) == { "satisfied", "failed", + "overridden", "not_evaluated", } From 298dd1b2e03f4d8360e295a949cf46f3f504e67d Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 11 Sep 2026 16:03:04 -0500 Subject: [PATCH 4/9] Reference the stored figure from analyze_dataset for inline plots too Through the MCP transport the variable plot still arrived as 78 KB of base64 inside the summary: the local plot path records the stored file only under _provenance.artifacts and never set image_uri for an inline figure, so the summary saw no URI and kept the bytes. Both the plot metadata and the summary now read the URI off the artifact record. The summary dropped from 87,663 to 9,592 bytes on outCSne30. --- src/uxarray_mcp/tools/orchestration.py | 5 +++++ src/uxarray_mcp/tools/remote_tools.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/uxarray_mcp/tools/orchestration.py b/src/uxarray_mcp/tools/orchestration.py index c2696fb..e80ff72 100644 --- a/src/uxarray_mcp/tools/orchestration.py +++ b/src/uxarray_mcp/tools/orchestration.py @@ -56,6 +56,11 @@ def _png_meta(items: list[Any]) -> dict[str, Any]: except Exception: image_size_bytes = None uri = meta.get("image_uri") or block_uri(img) + if uri is None: + for artifact in (meta.get("_provenance") or {}).get("artifacts") or []: + if artifact.get("type") == "plot" and artifact.get("uri"): + uri = artifact["uri"] + break # This summary is a JSON object, not a content-block list, so a client # cannot render base64 embedded in it; it only costs context. When the # figure was written to the artifact store, hand back the path and drop diff --git a/src/uxarray_mcp/tools/remote_tools.py b/src/uxarray_mcp/tools/remote_tools.py index ffb89a8..9d3f90b 100644 --- a/src/uxarray_mcp/tools/remote_tools.py +++ b/src/uxarray_mcp/tools/remote_tools.py @@ -262,7 +262,17 @@ def _plot_result_to_mcp_contents(result: Dict[str, Any]) -> list[Any]: validation on a null payload, which is exactly what happens on the biggest meshes. """ + # An inline figure was still written to the artifact store; say where. + # Callers that compose plots into a JSON summary (analyze_dataset) can + # then reference the file instead of carrying the bytes. + if result.get("image_uri") is None: + for artifact in (result.get("_provenance") or {}).get("artifacts") or []: + if artifact.get("type") == "plot" and artifact.get("uri"): + result["image_uri"] = artifact["uri"] + break metadata = {key: value for key, value in result.items() if key != "png_b64"} + if result.get("png_b64") is not None: + metadata.setdefault("image_delivery", "inline") text = text_block(json_text(metadata)) b64 = result.get("png_b64") From 08cff6ba0d1164c977c655ca47bf4e67da7d4b37 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 11 Sep 2026 21:02:15 -0500 Subject: [PATCH 5/9] Reduce many files on the worker with plot_type="temporal_mean" Every existing plot type reads one file and draws the whole mesh, so a multi-year regional mean had no path through the server. temporal_mean, subset_bbox and anomaly all refused use_remote=True, and the advice they gave was to pass a locally-readable path -- not available when the data lives on someone else's filesystem, which is the case this endpoint exists for. remote_temporal_mean_map opens the files with open_mfdataset, applies the bounding box before the reduction so only the kept cells are carried through it, scales units, and renders a choropleth with Natural Earth geography when the worker has cartopy. It reports n_time_steps, reduced_dims, n_face_subset against n_face_total and n_nonfinite: a PNG cannot say what was averaged away, so the response has to. plot_type="variable" now refuses lon_bounds/lat_bounds rather than ignoring them. It never honored a box, but it accepted one and returned a global map with nothing saying the box had been dropped, so a regional request came back looking answered. The refusal names the plot types that do honor a box. Verified on the UCAR worker: ten annual files of 6-hourly CESM ne120 PRECT, 14,600 steps reduced to a CONUS map in 462 s, matching a direct call to the compute function to sixteen digits. --- CHANGELOG.md | 25 ++ src/uxarray_mcp/remote/agent.py | 46 +++ src/uxarray_mcp/remote/compute_functions.py | 372 ++++++++++++++++++++ src/uxarray_mcp/tools/frontdoor.py | 62 +++- src/uxarray_mcp/tools/remote_tools.py | 173 +++++++++ 5 files changed, 675 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e45f4..d4bf1ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,7 +115,32 @@ built against; see `docs/release.md`. Versions through `0.3.1` were SemVer. different directory per client, invisible to the caller, and never the one they meant. +- `plot_dataset(plot_type="temporal_mean")` reduces many files on the worker and + maps the result. Every other plot type reads one file and draws the whole + mesh, so a multi-year regional mean -- the ordinary reason to reach for an HPC + endpoint at all -- had no path through the server: `temporal_mean`, + `subset_bbox` and `anomaly` all refused `use_remote=True`, and the advice they + gave was to pass a locally-readable path, which is not available when the data + is on someone else's filesystem. `remote_temporal_mean_map` opens the files + with `open_mfdataset`, applies the bounding box *before* the reduction so only + the kept cells are carried through it, scales units, and renders a choropleth + with Natural Earth geography when the worker has cartopy. It reports + `n_time_steps`, `reduced_dims`, `n_face_subset` against `n_face_total` and + `n_nonfinite`, because a PNG cannot say what was averaged away. Verified on + the UCAR worker: ten annual files of 6-hourly CESM ne120 output, 14,600 steps + reduced to a CONUS map in 462 s, matching a direct call to sixteen digits. +- `case-studies/conus-precipitation-gdex/` documents that run end to end -- the + prompt, the result, the provenance, the timings, and setup for both a laptop + and a Casper endpoint. + ### Changed +- `plot_dataset(plot_type="variable")` now refuses `lon_bounds`/`lat_bounds` + instead of ignoring them. It draws the whole mesh and never honored a box, but + it accepted one and returned a global map with nothing in the response saying + the box had been dropped -- so a regional request came back looking answered. + The refusal names `temporal_mean` and `mesh_geo`, the plot types that do honor + a box. Callers who passed a box and accepted the global map get an error where + they used to get a picture. - Releases now follow upstream instead of the calendar. The workflow polled on the 5th of every month, but upstream skipped 2026.01 and 2026.05, shipped twice in August, and released 2026.09.0 on the 10th, so the poll was either diff --git a/src/uxarray_mcp/remote/agent.py b/src/uxarray_mcp/remote/agent.py index 886197d..f8e0298 100644 --- a/src/uxarray_mcp/remote/agent.py +++ b/src/uxarray_mcp/remote/agent.py @@ -34,6 +34,7 @@ def action(fn): # type: ignore[no-redef] remote_plot_variable, remote_plot_zonal_mean, remote_probe_path, + remote_temporal_mean_map, ) from .config import HPCConfig @@ -563,6 +564,51 @@ async def plot_variable_remote( level_index, ) + @action + async def temporal_mean_map_remote( + self, + grid_path: str, + data_paths: list, + variable_name: str, + lon_bounds: Optional[list] = None, + lat_bounds: Optional[list] = None, + level_index: int = 0, + scale_factor: float = 1.0, + units_label: Optional[str] = None, + region_name: str = "", + width: int = 900, + height: int = 520, + cmap: str = "viridis", + vmin: Optional[float] = None, + vmax: Optional[float] = None, + title: Optional[str] = None, + geography: bool = True, + use_remote: bool = False, + ) -> Dict[str, Any]: + """Average across files, cut to a box, and render -- all on the worker.""" + args = ( + grid_path, + data_paths, + variable_name, + lon_bounds, + lat_bounds, + level_index, + scale_factor, + units_label, + region_name, + width, + height, + cmap, + vmin, + vmax, + title, + geography, + ) + if use_remote and self.config.endpoint_id: + return await self._run_on_hpc(remote_temporal_mean_map, *args) + else: + return remote_temporal_mean_map(*args) + @action async def plot_zonal_mean_remote( self, diff --git a/src/uxarray_mcp/remote/compute_functions.py b/src/uxarray_mcp/remote/compute_functions.py index be212e2..167df2b 100644 --- a/src/uxarray_mcp/remote/compute_functions.py +++ b/src/uxarray_mcp/remote/compute_functions.py @@ -917,6 +917,378 @@ def remote_plot_variable( } +def remote_temporal_mean_map( + grid_path: str, + data_paths: list, + variable_name: str, + lon_bounds: Optional[list] = None, + lat_bounds: Optional[list] = None, + level_index: int = 0, + scale_factor: float = 1.0, + units_label: Optional[str] = None, + region_name: str = "", + width: int = 900, + height: int = 520, + cmap: str = "viridis", + vmin: Optional[float] = None, + vmax: Optional[float] = None, + title: Optional[str] = None, + geography: bool = True, +) -> Dict[str, Any]: + """Average a variable over time across many files, cut to a box, and draw it. + + The three steps are one function because splitting them defeats the + point. ``temporal_mean`` alone cannot reach a facility-only path, and a + mean computed on the submitter would have to pull every input file over + the wire; here the whole reduction happens on the worker and only the + PNG plus a few summary numbers come back. + + The bounding box is applied *before* the time average, so a regional + request reads the faces it asked for rather than the globe. On a mesh + where the region is a small fraction of the faces this is the + difference between a demo that finishes and one that does not. + + Parameters + ---------- + grid_path : str + Mesh file on the worker filesystem (or ``healpix:``). + data_paths : list + One or more data files to average across, in time order. A bare + string is accepted and treated as a single-element list. + variable_name : str + Face-centered variable to average. + lon_bounds, lat_bounds : list | None + ``[min, max]`` degrees. Both must be given to subset; either alone + is refused rather than half-applied. + level_index : int + Index along a vertical dimension. Never applied to a time axis. + scale_factor : float + Multiplied into the mean after averaging, for unit conversion + (CAM ``PRECT`` is m/s; 86400000.0 gives mm/day). + units_label : str | None + Units for the colorbar. Records what ``scale_factor`` converted to, + since the number alone cannot say. + region_name : str + Human-readable region label for the default title. + width, height : int + PNG size in pixels. + cmap : str + Matplotlib colormap name. + vmin, vmax : float | None + Color limits, in the units produced by ``scale_factor``. + title : str | None + Overrides the generated title. + geography : bool + Draw coastlines, national borders and state lines under the data. + The axes are plain degrees, which is what Natural Earth's geometries + are in, so they overlay without a projection. Skipped without + comment if cartopy or its data are missing on the worker; the + result says which happened. + + Returns + ------- + dict + - png_b64, image_size_bytes: the rendered map + - geography: how many coastline/border/state paths were drawn, or + why none were + - variable_name, units, scale_factor: what was drawn, in what units + - n_files, n_time_steps, time_start, time_end: what was averaged + - reduced_dims: the time dims collapsed and the level index held + - n_face_total, n_face_subset, fraction_of_mesh: subset coverage + - value_stats: min/mean/max of the mean field, for sanity checks + - grid_info: n_face, n_node, n_edge of the *subset* grid + """ + import base64 + import io + import os + + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import numpy as np + import uxarray as ux + + if isinstance(data_paths, str): + data_paths = [data_paths] + data_paths = [str(p) for p in (data_paths or [])] + if not data_paths: + raise ValueError("remote_temporal_mean_map requires at least one data path.") + missing = [p for p in data_paths if not os.path.exists(p)] + if missing: + raise FileNotFoundError( + f"{len(missing)} of {len(data_paths)} data paths are not readable " + f"on the worker; first missing: {missing[0]}" + ) + if (lon_bounds is None) != (lat_bounds is None): + raise ValueError( + "Subsetting needs both lon_bounds and lat_bounds; got only one. " + "Pass both, or neither for the whole mesh." + ) + + # open_mfdataset takes a grid *path*, not a Grid object -- handing it one + # fails with the Grid's repr as the error message. Only the two synthetic + # grid spellings need the object, and those get the dataset attached by + # hand, exactly as remote_plot_variable does. + _spec = grid_path.lower() + if _spec.startswith("healpix:") or os.path.splitext(_spec)[1] in [ + ".shp", + ".geojson", + ]: + import xarray as xr + + if _spec.startswith("healpix:"): + _grid = ux.Grid.from_healpix(int(grid_path.split(":")[1])) + else: + _grid = ux.Grid.from_file(grid_path, backend="geopandas") + uxds = ux.UxDataset( + xr.open_mfdataset(data_paths, combine="by_coords"), uxgrid=_grid + ) + else: + uxds = ux.open_mfdataset(grid_path, data_paths, combine="by_coords") + n_face_total = int(uxds.uxgrid.n_face) + + if variable_name not in uxds.data_vars: + raise ValueError( + f"Variable '{variable_name}' not found. " + f"Available: {list(uxds.data_vars.keys())}" + ) + uxda = uxds[variable_name] + + face_dims = {"n_face", "nCells"} + if not any(d in face_dims for d in uxda.dims): + raise ValueError(f"Variable '{variable_name}' is not face-centered.") + + # Same split as remote_plot_variable: a time index and a level index + # reach different axes, and mixing them silently averages the wrong one. + _LEVEL_EXACT = {"lev", "level", "levels", "plev", "z", "nvertlevels"} + _LEVEL_SUBSTR = ("lev", "depth", "height", "altitude", "isobaric") + level_sel = {} + time_dims = [] + reduced_dims: Dict[str, Any] = {} + for dim in uxda.dims: + if dim in face_dims: + continue + size = int(uxda.sizes[dim]) + name = str(dim).lower() + if "time" in name: + time_dims.append(str(dim)) + reduced_dims[str(dim)] = {"kind": "time", "how": "mean", "size": size} + continue + if size == 1: + level_sel[dim] = 0 + continue + if name in _LEVEL_EXACT or any(s in name for s in _LEVEL_SUBSTR): + level_sel[dim] = level_index + reduced_dims[str(dim)] = { + "kind": "level", + "index": level_index, + "size": size, + } + else: + level_sel[dim] = 0 + reduced_dims[str(dim)] = {"kind": "other", "index": 0, "size": size} + if not time_dims: + raise ValueError( + f"Variable '{variable_name}' has no time dimension to average; " + f"dims are {list(uxda.dims)}." + ) + if level_sel: + uxda = uxda.isel(**level_sel) + + n_time_steps = 1 + for d in time_dims: + n_time_steps *= int(uxda.sizes[d]) + time_start = time_end = None + for d in time_dims: + if d in uxda.coords: + _tv = uxda[d].values + if len(_tv): + time_start, time_end = str(_tv[0]), str(_tv[-1]) + break + + # Cut to the region before averaging: the mean then touches only the + # faces that end up in the picture. + subset_applied = False + if lon_bounds is not None and lat_bounds is not None: + uxda = uxda.subset.bounding_box( + lon_bounds=[float(v) for v in lon_bounds], + lat_bounds=[float(v) for v in lat_bounds], + ) + subset_applied = True + if int(uxda.uxgrid.n_face) == 0: + raise ValueError( + f"Bounding box lon={lon_bounds} lat={lat_bounds} selects no " + f"faces of this {n_face_total}-face mesh. Longitudes here may " + f"use a different convention (0..360 vs -180..180)." + ) + + mean_da = uxda.mean(dim=time_dims) + if hasattr(mean_da, "compute"): + mean_da = mean_da.compute() + if scale_factor != 1.0: + _scaled = mean_da * float(scale_factor) + # Arithmetic can hand back a plain xarray object; the grid has to be + # reattached or .plot.polygons has no mesh to draw on. + if not hasattr(_scaled, "uxgrid") or _scaled.uxgrid is None: + _scaled = ux.UxDataArray(_scaled, uxgrid=mean_da.uxgrid) + mean_da = _scaled + sub_grid = mean_da.uxgrid + n_face_subset = int(sub_grid.n_face) + + label = variable_name if not units_label else f"{variable_name} ({units_label})" + mean_da = mean_da.rename(label) + + vals = np.asarray(mean_da.values, dtype="float64") + finite = vals[np.isfinite(vals)] + value_stats = { + "min": float(finite.min()) if finite.size else None, + "mean": float(finite.mean()) if finite.size else None, + "max": float(finite.max()) if finite.size else None, + "n_faces": int(vals.size), + "n_nonfinite": int(vals.size - finite.size), + } + + import holoviews as hv + + hv.extension("matplotlib") + + dpi = 100 + kwargs: Dict[str, Any] = {"backend": "matplotlib", "cmap": cmap} + if vmin is not None or vmax is not None: + kwargs["clim"] = ( + float(vmin) if vmin is not None else float(np.nanmin(vals)), + float(vmax) if vmax is not None else float(np.nanmax(vals)), + ) + + element = mean_da.plot.polygons(**kwargs) + renderer = hv.Store.renderers["matplotlib"] + plot = renderer.get_plot(element) + fig = plot.state + fig.set_size_inches(width / dpi, height / dpi) + fig.set_dpi(dpi) + + if title is None: + _span = "" + if time_start and time_end: + _span = f" {time_start[:10]} to {time_end[:10]}" + _where = f" over {region_name}" if region_name else "" + title = f"Mean {label}{_where},{_span} ({n_time_steps} steps)" + fig.axes[0].set_title(title) + + # Geography, drawn as plain paths rather than through a projection: the + # polygons were plotted in degrees, and Natural Earth's geometries are in + # degrees, so the two line up without cartopy owning the axes. A map of a + # region with no coastline on it is hard to check and easy to misread. + geo_info: Dict[str, Any] = {"drawn": False} + if geography: + try: + import cartopy.feature as cfeature + from matplotlib.collections import LineCollection + + _ax = fig.axes[0] + _xlim, _ylim = _ax.get_xlim(), _ax.get_ylim() + counts = {} + for key, category, feature_name, lw, color in ( + ("coastlines", "physical", "coastline", 0.8, "#111111"), + ( + "borders", + "cultural", + "admin_0_boundary_lines_land", + 0.6, + "#333333", + ), + ( + "states", + "cultural", + "admin_1_states_provinces_lines", + 0.4, + "#555555", + ), + ): + segments = [] + for geom in cfeature.NaturalEarthFeature( + category, feature_name, "50m" + ).geometries(): + parts = getattr(geom, "geoms", None) or [geom] + for part in parts: + coords = getattr(part, "coords", None) + if coords is None: + continue + pts = list(coords) + if len(pts) > 1: + segments.append(pts) + if segments: + _ax.add_collection( + LineCollection( + segments, + linewidths=lw, + colors=color, + zorder=5, + ) + ) + counts[key] = len(segments) + # add_collection re-autoscales to the whole world; the box the + # caller asked for is the view that matters. + _ax.set_xlim(_xlim) + _ax.set_ylim(_ylim) + geo_info = {"drawn": True, **counts} + except Exception as exc: # cartopy absent, or its data not cached + geo_info = {"drawn": False, "reason": f"{type(exc).__name__}: {exc}"} + + # Mirrors remote_plot_variable: after the resize the HoloViews colorbar + # sits over the map and tight_layout will not move it. + _axes = list(fig.axes) + if len(_axes) < 2: + fig.tight_layout() + else: + _axes[0].set_position([0.08, 0.12, 0.76, 0.80]) + for _cax in _axes[1:]: + _cax.set_position([0.87, 0.12, 0.025, 0.80]) + + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight") + plt.close(fig) + buf.seek(0) + png_bytes = buf.read() + if not png_bytes: + raise ValueError("Rendered temporal mean map is empty.") + + return { + "png_b64": base64.b64encode(png_bytes).decode("utf-8"), + "image_size_bytes": len(png_bytes), + "variable_name": variable_name, + "units": units_label, + "scale_factor": float(scale_factor), + "n_files": len(data_paths), + "n_time_steps": int(n_time_steps), + "time_start": time_start, + "time_end": time_end, + "reduced_dims": reduced_dims, + "subset_applied": subset_applied, + "lon_bounds": list(lon_bounds) if lon_bounds is not None else None, + "lat_bounds": list(lat_bounds) if lat_bounds is not None else None, + "n_face_total": n_face_total, + "n_face_subset": n_face_subset, + "fraction_of_mesh": (n_face_subset / n_face_total) if n_face_total else None, + "value_stats": value_stats, + "geography": geo_info, + "grid_info": { + "n_face": n_face_subset, + "n_node": int(sub_grid.n_node), + "n_edge": int(sub_grid.n_edge), + }, + "_worker_runtime": { + "hostname": __import__("socket").gethostname(), + "python_version": __import__("platform").python_version(), + "uxarray_version": getattr(ux, "__version__", "unknown"), + "xarray_version": getattr(__import__("xarray"), "__version__", "unknown"), + "slurm_job_id": __import__("os").environ.get("SLURM_JOB_ID"), + "pbs_job_id": __import__("os").environ.get("PBS_JOBID"), + }, + } + + def remote_plot_zonal_mean( grid_path: str, data_path: str, diff --git a/src/uxarray_mcp/tools/frontdoor.py b/src/uxarray_mcp/tools/frontdoor.py index 4422b94..2644759 100644 --- a/src/uxarray_mcp/tools/frontdoor.py +++ b/src/uxarray_mcp/tools/frontdoor.py @@ -1022,7 +1022,11 @@ def plot_dataset( plot_type: str, grid_path: str | None = None, data_path: str | None = None, + data_paths: list[str] | None = None, variable_name: str | None = None, + scale_factor: float = 1.0, + units_label: str | None = None, + region_name: str = "", width: int = 800, height: int = 400, cmap: str = "viridis", @@ -1052,7 +1056,15 @@ def plot_dataset( """Render mesh, geographic mesh, variable, or zonal-mean plots. ``plot_type`` is one of ``mesh``, ``mesh_geo``, ``variable``, - ``zonal_mean``. + ``zonal_mean``, ``temporal_mean``. + + ``temporal_mean`` averages ``variable_name`` over every time step in + ``data_paths`` and draws the result, optionally cut to + ``lon_bounds``/``lat_bounds`` first and scaled by ``scale_factor`` into + ``units_label``. It is the only plot type that reads more than one data + file, and the only one whose box is honored, so it is what a decadal + regional mean on facility-only paths goes through. Longitudes follow + uxarray's -180..180 convention, not 0..360. ``time_index`` and ``level_index`` are separate selectors, applied only to time-like and level-like dimensions respectively. Results carry a @@ -1062,7 +1074,9 @@ def plot_dataset( ``mesh_geo`` (local only) draws the cell outlines over Natural Earth features and takes ``show_mesh_boundary``, ``coastlines``, ``borders``, ``rivers``, ``lakes``, ``cities`` and ``basemap`` (``basemap`` needs a - network connection). The other plot types ignore these. + network connection). ``temporal_mean`` reads ``coastlines`` as well, and + draws coastlines, borders and state lines under the field. The remaining + plot types ignore all of these. """ from uxarray_mcp.tools.plotting import plot_mesh_geo from uxarray_mcp.tools.remote_tools import plot_mesh, plot_variable, plot_zonal_mean @@ -1097,7 +1111,47 @@ def plot_dataset( session_id=session_id, dataset_handle=dataset_handle, ) + if kind in ("temporal_mean", "mean_map"): + from uxarray_mcp.tools.remote_tools import temporal_mean_map + + paths = data_paths if data_paths else ([data_path] if data_path else None) + if not paths: + raise ValueError( + "plot_type='temporal_mean' requires data_paths (the files to " + "average over), or a single data_path." + ) + return temporal_mean_map( + grid_path=grid_path, + data_paths=paths, + variable_name=variable_name, + lon_bounds=lon_bounds, + lat_bounds=lat_bounds, + level_index=level_index, + scale_factor=scale_factor, + units_label=units_label, + region_name=region_name, + width=width, + height=height, + cmap=cmap, + vmin=vmin, + vmax=vmax, + title=title, + geography=coastlines, + use_remote=use_remote, + endpoint=endpoint, + session_id=session_id, + dataset_handle=dataset_handle, + ) if kind == "variable": + # A box handed to this plot type used to be accepted and dropped, so + # a regional request came back as a global picture that looked like + # an answer. Name the plot type that honors it instead. + if lon_bounds is not None or lat_bounds is not None: + raise ValueError( + "plot_type='variable' draws the whole mesh and cannot honor " + "lon_bounds/lat_bounds. Use plot_type='temporal_mean' for a " + "regional map, or 'mesh_geo' for a regional wireframe." + ) return plot_variable( grid_path=grid_path, data_path=data_path, @@ -1133,7 +1187,9 @@ def plot_dataset( session_id=session_id, dataset_handle=dataset_handle, ) - raise ValueError("plot_type must be one of: mesh, mesh_geo, variable, zonal_mean.") + raise ValueError( + "plot_type must be one of: mesh, mesh_geo, variable, zonal_mean, temporal_mean." + ) def diagnose_endpoint( diff --git a/src/uxarray_mcp/tools/remote_tools.py b/src/uxarray_mcp/tools/remote_tools.py index 9d3f90b..62b1132 100644 --- a/src/uxarray_mcp/tools/remote_tools.py +++ b/src/uxarray_mcp/tools/remote_tools.py @@ -827,6 +827,179 @@ def _local() -> Dict[str, Any]: ) +def temporal_mean_map( + grid_path: str | None = None, + data_paths: List[str] | None = None, + variable_name: str | None = None, + lon_bounds: Optional[List[float]] = None, + lat_bounds: Optional[List[float]] = None, + level_index: int = 0, + scale_factor: float = 1.0, + units_label: Optional[str] = None, + region_name: str = "", + width: int = 900, + height: int = 520, + cmap: str = "viridis", + vmin: Optional[float] = None, + vmax: Optional[float] = None, + title: Optional[str] = None, + geography: bool = True, + use_remote: bool = False, + endpoint: str | None = None, + session_id: str | None = None, + dataset_handle: str | None = None, +) -> list[Any]: + """Average a variable over time across files, cut to a box, and map it. + + Unlike ``temporal_mean``, this runs on the HPC worker, so the inputs + may live only on the facility filesystem. The box is applied before + the average, so a regional request does not pay for the globe. + + Parameters + ---------- + grid_path : str | None + Mesh file (local or HPC filesystem). Optional with a session + dataset. + data_paths : list[str] | None + Data files to average across, in time order. A single path is + accepted. + variable_name : str + Face-centered variable to average. + lon_bounds, lat_bounds : list[float] | None + ``[min, max]`` degrees. Longitudes follow uxarray's convention, + -180..180, not 0..360. Pass both or neither. + level_index : int + Index along a vertical dimension; never applied to time. + scale_factor : float + Multiplied into the mean, for unit conversion. + units_label : str | None + Units after ``scale_factor``, for the colorbar. + region_name : str + Region label for the default title. + width, height : int + PNG size in pixels. + cmap : str + Matplotlib colormap name. + vmin, vmax : float | None + Color limits, in converted units. + title : str | None + Overrides the generated title. + geography : bool + Draw coastlines, borders and state lines under the data when the + worker has cartopy and its Natural Earth cache. + use_remote : bool + If True and HPC is configured, run on the remote endpoint. + session_id, dataset_handle : str | None + When both are given, the grid path is looked up from the + registered session dataset. + + Returns + ------- + dict + - png_b64 / image_uri, image_size_bytes: the map + - n_files, n_time_steps, time_start, time_end: what was averaged + - n_face_total, n_face_subset, fraction_of_mesh: subset coverage + - value_stats: min/mean/max of the mean field + - execution_venue: "local" or "hpc:" + """ + from uxarray_mcp.provenance import attach_provenance + + from .plotting import _resolve_plot_paths + + if isinstance(data_paths, str): + data_paths = [data_paths] + first_path: str | None = data_paths[0] if data_paths else None + resolved_grid, resolved_first = _resolve_plot_paths( + grid_path, first_path, session_id, dataset_handle + ) + resolved_paths = list(data_paths) if data_paths else [resolved_first] + if not variable_name: + raise ValueError("temporal_mean_map requires variable_name.") + + inputs = { + "grid_path": resolved_grid, + "data_paths": resolved_paths, + "variable_name": variable_name, + "lon_bounds": lon_bounds, + "lat_bounds": lat_bounds, + "level_index": level_index, + "scale_factor": scale_factor, + } + + def _local() -> Dict[str, Any]: + from uxarray_mcp.remote.compute_functions import remote_temporal_mean_map + + result = remote_temporal_mean_map( + resolved_grid, + resolved_paths, + variable_name, + lon_bounds, + lat_bounds, + level_index, + scale_factor, + units_label, + region_name, + width, + height, + cmap, + vmin, + vmax, + title, + geography, + ) + result["execution_venue"] = "local" + return attach_provenance( + result, + tool="temporal_mean_map", + inputs=inputs, + venue="local", + selected_variable=variable_name, + ) + + result = _run_with_optional_hpc( + tool_name="temporal_mean_map", + use_remote=use_remote, + endpoint=endpoint, + path_hint=resolved_grid, + session_id=session_id, + local_call=_local, + remote_call=lambda agent: _run_sync( + lambda: agent.temporal_mean_map_remote( + resolved_grid, + resolved_paths, + variable_name, + lon_bounds, + lat_bounds, + level_index, + scale_factor, + units_label, + region_name, + width, + height, + cmap, + vmin, + vmax, + title, + geography, + use_remote, + ) + ), + ) + # The remote branch records the venue only in provenance; mirror it up so + # both venues answer "where did this run" in the same place. + if result.get("execution_venue") is None: + result["execution_venue"] = (result.get("_provenance") or {}).get( + "execution_venue" + ) + return _plot_result_to_mcp_contents( + _ensure_plot_artifact( + result, + plot_type="temporal_mean_map", + variable=variable_name, + ) + ) + + def plot_zonal_mean( grid_path: str | None = None, data_path: str | None = None, From b9b423b60d65fb04525d6aeddc1e260a29954dce Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 11 Sep 2026 21:04:38 -0500 Subject: [PATCH 6/9] Let someone other than the author run the endpoint scripts The three site scripts hardcoded USERNAME="jain" at the top under a banner telling the reader to edit it, so the first run by anyone else either failed on a path they did not own or, worse, worked against somebody's directory. Usernames now default to $USER and every site detail -- conda env, endpoint name, YAC version -- takes an environment override, so nothing in the files needs editing. YAC becomes opt-in via WITH_YAC, defaulting off. It buys conservative remapping and nothing else, needs a separate build, and its absence is already reported rather than crashed on, so requiring it to bring an endpoint up was a barrier for no gain. configure refuses a profile named for YAC unless WITH_YAC=1, since a profile named ucar-uxarray-yac that came up without it would look healthy and quietly lack the one method it is named for. scripts/endpoint.sh is a site-agnostic version that assumes no facility, for people standing up a first endpoint; each site doc now leads with it and keeps the site-specific script as the tuned path. Both notes claiming the repo need not be cloned on the cluster are corrected: it must be, because the scripts live in it. What must not happen is uxarray_mcp landing on the worker's PYTHONPATH. --- docs/chrysalis.md | 82 +++++-- docs/improv.md | 66 +++-- docs/operating-an-endpoint.md | 105 ++++++-- docs/ucar.md | 171 +++++++++---- scripts/chrysalis_endpoint.sh | 12 +- scripts/endpoint.sh | 448 ++++++++++++++++++++++++++++++++++ scripts/improv_endpoint.sh | 22 +- scripts/ucar_endpoint.sh | 54 +++- 8 files changed, 842 insertions(+), 118 deletions(-) create mode 100755 scripts/endpoint.sh diff --git a/docs/chrysalis.md b/docs/chrysalis.md index 3d82a78..d0a7aa7 100644 --- a/docs/chrysalis.md +++ b/docs/chrysalis.md @@ -15,15 +15,24 @@ which hosts the E3SM next-generation mesh library. ## Key Points -- The **MCP server does not need to be cloned on Chrysalis** — remote functions - are sent as source code via `AllCodeStrategies` and only need `uxarray` + deps - in the worker environment. +- The **MCP server must be cloned on Chrysalis** — the endpoint scripts live in + it — but it must never be importable by the worker. Remote functions are sent + as source code via `AllCodeStrategies` and only need `uxarray` + deps in the + worker environment; adding `uxarray_mcp` to the worker's `PYTHONPATH` drags in + a pydantic that conflicts with the one `globus-compute-endpoint` wants. - Login nodes **kill compute processes** — always use the Slurm backend. - YAC remapping needs the Python 3.12 `uxarray-yac` environment plus YAC, MKL, MPICH, NetCDF, and local shim library paths. Use `scripts/chrysalis_endpoint.sh` instead of hand-writing those paths. +- **YAC is optional.** Without it everything works except `method="conservative"` + and the other `backend="yac"` remap methods, which report the missing library + rather than failing obscurely. - If a remote probe times out after the endpoint is `registered`, inspect the endpoint logs on Chrysalis with `scripts/chrysalis_endpoint.sh logs`. +- `scripts/endpoint.sh` is the site-agnostic version of this script — start + there if you are standing up your first endpoint on some other machine. +- Copying files is Globus Transfer, a separate service with its own login — see + [Moving Files](data-transfer.md). ## Worker Environment @@ -31,12 +40,20 @@ which hosts the E3SM next-generation mesh library. |---|---| | UXarray/YAC env | `~/.conda/envs/uxarray-yac` (Python 3.12) | | Endpoint helper venv | `~/venvs/globus-compute-py313` | -| Slurm partition | `debug` (4h walltime, 20 nodes) | +| Slurm partition | `compute` (1h walltime, 1 node per block, 2 blocks) | | Compute nodes | 251 GB RAM, 128 CPUs | | Endpoint name | `uxarray-chrysalis` | ## First-Time Setup +The script assumes four things exist that it does not create for you: the conda +environment `$HOME/.conda/envs/uxarray-yac`, the endpoint helper venv +`$HOME/venvs/globus-compute-py313`, a YAC install at `$HOME/local/yac-$YAC_VERSION` +(default `3.20.2`), and a uxarray-with-YAC checkout, which now defaults to +`/lcrc/group/e3sm/$USER/uxarray-yac-src` and is overridable with +`UXARRAY_YAC_SRC`. Only the last is genuinely optional — a `UXARRAY_YAC_SRC` +path that does not exist is simply ignored by Python. + The checked-in helper script writes the endpoint profile, YAC runtime library paths, and small BLAS/LAPACK shims needed by the current YAC build: @@ -47,26 +64,60 @@ bash scripts/chrysalis_endpoint.sh configure slurm-debug bash scripts/chrysalis_endpoint.sh check-yac ``` -The `check-yac` command runs a tiny Slurm job that imports `yac.core`, imports -UXarray's YAC helper, and remaps HEALPix zoom 2 to zoom 3. It should report +The `check-yac` command imports `yac.core`, imports UXarray's YAC helper, and +remaps HEALPix zoom 2 to zoom 3. It runs the smoke script under `srun --ntasks 1` +straight from the login shell, naming no partition or account, so it depends on +site defaults or on an allocation you already hold. It should report `yac_core_ok: true` and `remap_ok: true` before the endpoint is used by MCP. +## Script Options + +Beyond `configure`, `start`, `restart`, `check-yac` and `logs`, the script takes +`status`, which activates the environment and prints `globus-compute-endpoint +list`. `configure` takes a second mode, `single-host`, which is what you get if +you name no mode at all: a `LocalProvider` template that runs workers on the +login node — fine for a quick probe, killed by the site for anything real. + +Everything site-specific is an environment variable with a working default: + +| Variable | Default | What it moves | +|---|---|---| +| `ENDPOINT_NAME` | `uxarray-chrysalis` | Globus Compute endpoint profile name | +| `CHRYSALIS_CONDA_MODULE` | `miniforge3` | Module that puts `conda` on `PATH` | +| `VENV_GC` | `$HOME/venvs/globus-compute-py313` | Endpoint helper venv | +| `YAC_VERSION` | `3.20.2` | Selects `$HOME/local/yac-$YAC_VERSION` | +| `UXARRAY_YAC_SRC` | `/lcrc/group/e3sm/$USER/uxarray-yac-src` | uxarray checkout put on the worker's `PYTHONPATH` ahead of the installed package | +| `YAC_SMOKE_DIR` | `$HOME/.cache/uxarray-mcp` | Where `check-yac` writes its smoke script; must be on a filesystem the compute node can read, which rules out `/tmp` here | + +`worker_init` bakes the YAC prefix in at configure time, so changing +`YAC_VERSION` or `UXARRAY_YAC_SRC` means `configure ` again, then +`restart`. + ## Starting the Endpoint -Run this every time you log in: +Run this every time you log in, from a plain shell: ```bash bash scripts/chrysalis_endpoint.sh start ``` +`start` creates its own tmux session named `uxarray-endpoint`. If you are +already inside a tmux session with some other name it refuses to run, rather +than leave the endpoint owned by whatever shell happened to be attached — +detach with `Ctrl-b d` first. + The endpoint prints its UUID. Add it to your private local config on your laptop/workstation, never to the repository: ```bash # On your laptop: -uxarray-mcp endpoints add chrysalis --path-prefix /lcrc/ --set-default +uxarray-mcp endpoints add chrysalis --path-prefix /lcrc/ ``` +The remote profile name (`uxarray-chrysalis`) and the local alias (`chrysalis`) +are separate namespaces — the local one is just what you type to refer to this +endpoint, and it need not match. + Always register the `--path-prefix`. Without it this endpoint claims no paths of its own, so it only ever gets work as the fallback default — and any path another endpoint claims (Improv also mounts `/home/`) silently routes there @@ -77,7 +128,7 @@ instead, even when the file is really on Chrysalis. From your laptop after the endpoint is running: ```bash -uv run python scripts/hpc_doctor.py --endpoint chrysalis --timeout-seconds 120 +uxarray-mcp doctor --endpoint chrysalis --timeout-seconds 120 uv run --extra hpc python scripts/yac_smoke_test.py \ --endpoint chrysalis --timeout-seconds 300 ``` @@ -117,10 +168,13 @@ Verified loadable with `ux.open_grid` on a Chrysalis worker: | `IcosXISC30E3r7/mpaso.IcosXISC30E3r7.20240314.nc` | 463,013 | 3.9 GB | Do not point at a personal scratch tree such as -`/lcrc/group/e3sm/ac./polaris_1.0/...`. Those hold per-run +`/lcrc/group/e3sm/ac./polaris_1.0/...` for *mesh data*. Those hold per-run `test_` directories that are rotated and deleted, so a path that -worked last month will simply be gone. Not every `.nc` file in these -directories is a mesh — forcing and initial-condition files raise +worked last month will simply be gone. The endpoint script's own +`/lcrc/group/e3sm/$USER/uxarray-yac-src` default is not an exception to this — +it is a source checkout you make and keep, not model output somebody else +rotates, and `UXARRAY_YAC_SRC` moves it if you keep yours elsewhere. Not every +`.nc` file in these directories is a mesh — forcing and initial-condition files raise `RuntimeError: Failed to parse uxgrid information`. Probe first: ```python @@ -129,8 +183,8 @@ diagnose_endpoint(action="probe_path", file_path="", endpoint="chrysalis") ## Troubleshooting -**`ENDPOINT_NOT_ONLINE`** — the Slurm debug job timed out (4h limit). Restart -with `bash scripts/chrysalis_endpoint.sh restart`. +**`ENDPOINT_NOT_ONLINE`** — the Slurm `compute` job timed out (1h walltime). +Restart with `bash scripts/chrysalis_endpoint.sh restart`. **Worker probe timeout after `registered`** — the manager is connected, but a Slurm worker did not return. Run `bash scripts/chrysalis_endpoint.sh logs` on diff --git a/docs/improv.md b/docs/improv.md index 93fa71e..c7b0f15 100644 --- a/docs/improv.md +++ b/docs/improv.md @@ -14,20 +14,27 @@ on the LCRC GPFS filesystem (`/gpfs/fs1/`). ## Key Points -- The **MCP server does not need to be cloned on Improv** — remote functions are - sent as source code via `AllCodeStrategies`. -- The venv Python version should match the local SDK as closely as possible. - Improv has Python 3.12 at `/usr/bin/python3.12` — use it to avoid Dill - serialisation warnings from the 3.11 venv. +- The **MCP server must be cloned on Improv** — `improv_endpoint.sh` lives in it + — but it must never be importable by the worker. Remote functions are sent as + source code via `AllCodeStrategies`, so the worker venv needs only `uxarray` + and its dependencies. +- Dill serialises cleanly between matching Python minor versions, so the worker + venv should be on the same minor version as the machine you drive it from. + uxarray-mcp targets 3.12, and Improv has Python 3.12 at `/usr/bin/python3.12` + — use it, and a mismatch warning goes away. - Use canonical `/gpfs/fs1/home//...` paths, not `/home//...` aliases, when probing remote files. +- `scripts/endpoint.sh` is the site-agnostic version of this script — start + there if you are standing up your first endpoint on some other machine. +- Copying files is Globus Transfer, a separate service with its own login — see + [Moving Files](data-transfer.md). ## Worker Environment | Item | Value | |---|---| | Venv | `~/venvs/globus-compute` | -| Python | 3.11 (existing) or 3.12 (`upgrade-venv` subcommand) | +| Python | 3.12 — `upgrade-venv` refuses to build with anything else | | Scheduler | PBS Pro | | Endpoint name | `improv-uxarray` | @@ -54,8 +61,8 @@ scripts/improv_endpoint.sh start ## Upgrading to Python 3.12 (recommended) -Eliminates the Dill version mismatch warning between the local 3.13 SDK and the -3.11 worker: +Eliminates the Dill version mismatch warning you get when the worker venv is on +a different minor version than the machine driving it: ```bash # On an Improv login node: @@ -70,13 +77,30 @@ scripts/improv_endpoint.sh configure pbs-debug improv-uxarray scripts/improv_endpoint.sh restart ``` +The template it writes asks PBS for the `debug` queue, one node per block, at +most one block, and a walltime of `00:30:00`. `configure pbs-debug` also links +`qsub`, `qstat` and `qdel` from `/opt/pbs/bin` into the venv's `bin` — that +happens at configure time, not from anything in the emitted template. + +## Script Options + +The script also takes `status`, which activates the venv and prints +`globus-compute-endpoint list`. Two environment variables move the defaults: +`ENDPOINT_NAME` (the Globus Compute endpoint profile name, default +`improv-uxarray`) and `PYTHON` (the interpreter `upgrade-venv` builds with, +default `/usr/bin/python3.12`). + ## Starting the Endpoint ```bash -# In a tmux session on a login node: +# From a plain shell on a login node: scripts/improv_endpoint.sh start ``` +`start` creates its own tmux session named `uxarray-endpoint`; run it from +outside tmux so the endpoint lives in that session rather than whichever shell +you happened to be in. + To restart: `scripts/improv_endpoint.sh restart` To check: `scripts/improv_endpoint.sh status` @@ -84,25 +108,35 @@ Add the UUID to your private local config on your laptop/workstation, not to the repository: ```bash -uxarray-mcp endpoints add improv --set-default +uxarray-mcp endpoints add improv \ + --path-prefix /gpfs/fs1/ --path-prefix /home/ ``` +Both mounts have to be named: an endpoint registered without a prefix claims no +paths of its own and never wins a match. Do not add `--set-default` here — +[Remote HPC](remote-hpc.md) hands the fallback default to `ucar`, and two +endpoints cannot both be it. + ## Validation ```bash -uv run python scripts/hpc_doctor.py --endpoint improv --timeout-seconds 180 +uxarray-mcp doctor --endpoint improv --timeout-seconds 180 ``` Or with a real mesh file: ```bash -uv run python scripts/hpc_doctor.py \ +uxarray-mcp doctor \ --endpoint improv \ --sample-path /gpfs/fs1/home//uxarray/test/meshfiles/mpas/QU/480/grid.nc ``` ## Reference Mesh Files on Improv +These are UXarray's own test meshfiles, and they exist only if you have cloned +uxarray with its test data into your own `$HOME`. Nothing in this repository or +in `improv_endpoint.sh` puts them there. + ``` /gpfs/fs1/home//uxarray/test/meshfiles/mpas/QU/480/grid.nc /gpfs/fs1/home//uxarray/test/meshfiles/mpas/QU/480/data.nc @@ -118,8 +152,12 @@ differently on worker nodes. **`WorkerLost`** — Python version mismatch causing Dill failure. Run `upgrade-venv` to rebuild with Python 3.12. -**`qsub: command not found`** — scheduler binaries missing from worker PATH. The -PBS-backed config template links them via `ln -sf /opt/pbs/bin/qsub ~/venvs/...`. +**`ENDPOINT_NOT_ONLINE`** — the PBS `debug` job expired (30-minute walltime). +Restart with `scripts/improv_endpoint.sh restart`. + +**`qsub: command not found`** — scheduler binaries missing from worker PATH. +`configure pbs-debug` links `qsub`, `qstat` and `qdel` from `/opt/pbs/bin` into +`~/venvs/globus-compute/bin`; if one is missing, re-run `configure pbs-debug`. **`validate_hpc_setup` passes but real jobs fail** — worker environment lacks `uxarray` or its dependencies. Check `pip list` in the venv. diff --git a/docs/operating-an-endpoint.md b/docs/operating-an-endpoint.md index d6a3b7d..456f907 100644 --- a/docs/operating-an-endpoint.md +++ b/docs/operating-an-endpoint.md @@ -48,11 +48,47 @@ the endpoint's Globus Auth allow-list. Do this with eyes open. ## Solo personal endpoint quickstart If only **you** will submit to this endpoint, you can skip the service-account -ticket, the multi-user setup, and the function allowlist. The minimum viable -personal endpoint is six commands and one config edit. +ticket, the multi-user setup, and the function allowlist. -**Prereqs:** shell on the HPC machine, a Globus identity, your project's -Slurm account or PBS project ID, and the site's conda/module convention. +**Prereqs:** shell on the HPC machine, a Globus identity, and the site's +conda/module convention. You do not need a scheduler account for the first +pass — start on the login node, prove the round trip, then move to Slurm or +PBS. + +### With the script + +`scripts/endpoint.sh` in this repo does all of it and knows nothing about any +particular facility. Clone the repo on the HPC machine (the worker does not +import it — the scripts just live there) and: + +```bash +export CONDA_ENV=uxarray # or VENV=~/venvs/uxarray +export MODULES="conda" # whatever your site needs, in order + +./scripts/endpoint.sh install # create the env, install the worker packages +./scripts/endpoint.sh check # report what is ready; changes nothing +./scripts/endpoint.sh configure # write ~/.globus_compute/uxarray/config.yaml +./scripts/endpoint.sh start # starts inside tmux, prints the UUID on first run +``` + +`start` creates its own tmux session (`uxarray-endpoint`), so run it from a +plain shell rather than starting tmux yourself. First start opens an OAuth +flow: over ssh, paste the URL into a browser and paste the code back. + +Once that works, move the workers onto the scheduler: + +```bash +SCHEDULER=slurm ACCOUNT=myproject QUEUE=debug ./scripts/endpoint.sh configure +./scripts/endpoint.sh restart +``` + +`./scripts/endpoint.sh` with no argument lists every override — worker Python, +walltime, block counts, AMQP port, and `WORKER_INIT_EXTRA` for anything the +script should not know about (a YAC activate file, an `LD_LIBRARY_PATH`). + +### By hand + +The same thing without the script: ```bash # 1. On the HPC machine, in your account @@ -77,34 +113,44 @@ globus-compute-endpoint configure uxarray > `uxarray-mcp doctor` will surface a warning at probe time if anything is > off. -Edit `~/.globus_compute/uxarray/config.yaml` and set the scheduler block. -Minimum diff from the generated template (PBS example shown — see Step 3 -below for Slurm): +Edit `~/.globus_compute/uxarray/config.yaml`. Start with `LocalProvider`, which +runs workers on the login node: it needs no account, no queue and no working +scheduler config, so when it fails you know the failure is yours and not the +site's. This is the shape we run in production at NCAR. ```yaml display_name: uxarray engine: type: GlobusComputeEngine + max_workers_per_node: 1 provider: - type: PBSProProvider - queue: casper # or your site's queue - account: YOUR_PROJECT_ID # critical — without this, jobs reject - nodes_per_block: 1 + type: LocalProvider init_blocks: 1 min_blocks: 0 max_blocks: 1 - walltime: "01:00:00" worker_init: | unset PYTHONPATH # critical — see Step 3 for why - module load conda + source "$(conda info --base)/etc/profile.d/conda.sh" conda activate gce +idle_heartbeats_soft: 10 +idle_heartbeats_hard: 5760 ``` +Login-node workers are fine for inspection, zonal means and plots. They are not +fine for hour-long jobs — sites notice. Move to the scheduler block in +[Step 3](#step-3--configure-the-endpoint) before doing anything heavy. + A single-user endpoint already runs only what the identity that started it submits, so there is nothing to add here to lock it to you. **Do not put your Globus identity UUID in this file** — no key in it takes one. Sharing the -endpoint is what Step 6 is for, and it is a policy created in Globus Auth, -referenced by one UUID. +endpoint is what [Step 6](#step-6--auth-policy-optional) is for, and it is a +policy created in Globus Auth, referenced by one UUID. + +In particular, `authentication_policy` is typed `UUID | str | None`. Writing it +as a nested block — the shape the Globus Auth policy document itself has — +fails at startup with two pydantic errors that name `uuid_type` and +`string_type`. If you hit that, the fix is almost always to delete the block +entirely; see [Step 6](#step-6--auth-policy-optional). Then: @@ -124,8 +170,16 @@ uxarray-mcp endpoints add mine --path-prefix /glade/ # or /lcrc/, /gpfs uxarray-mcp doctor --endpoint mine ``` +`mine` is a **local alias** and has nothing to do with the profile name on the +HPC machine; every later `--endpoint` flag uses the alias. The `--path-prefix` +is what makes a path route here, so an endpoint registered without one only +ever runs work that names it explicitly. + If `doctor` reports `active`, you're done. Total time: ~30 min the first time. +To move files as well as run code, Globus Transfer is a separate service with +its own login — see [data-transfer.md](data-transfer.md). + **You should still do the full hardening eventually:** - If anyone else will ever submit (collaborator, student, agent on a shared @@ -276,6 +330,13 @@ engine: conda activate gce ``` +Both scheduler blocks are starting points, not verified configurations. The +endpoint we run at NCAR uses `LocalProvider`; the PBS block above has not been +exercised on Casper, and PBS sites commonly need a `select` line naming +`ncpus`/`mem` before a job is accepted at all. Get `LocalProvider` round- +tripping first, so that when the scheduler rejects something you are debugging +one thing. + Critical lines: - **`unset PYTHONPATH`** — prevents pydantic/dill version conflicts when @@ -311,6 +372,16 @@ Verify it imports: python -c "import uxarray; print(uxarray.__version__)" ``` +**YAC is optional and most endpoints should skip it.** It is a separate C build +and it buys exactly one thing: `method="conservative"` and the `backend="yac"` +remap methods. Everything else — inspection, validation, areas, zonal means, +gradient/curl/divergence, subsetting, cross-sections, comparison metrics, +plots, and nearest-neighbour/IDW/bilinear remapping — works without it, and the +YAC-only methods report the missing library rather than failing obscurely. If +you do want it, `scripts/hpc_build_yac.py` builds it and +`scripts/yac_smoke_test.py` proves it on the worker rather than the login node; +point `WORKER_INIT_EXTRA` (or the site script's `WITH_YAC=1`) at the result. + --- ### Step 5 — Start the endpoint @@ -464,8 +535,8 @@ a public GitHub README. Have them run: ```bash -uxarray-mcp endpoints add ucar --path-prefix /glade/ -uxarray-mcp doctor --endpoint ucar +uxarray-mcp endpoints add --path-prefix /glade/ +uxarray-mcp doctor --endpoint ``` If `doctor` reports `active`, you're done. diff --git a/docs/ucar.md b/docs/ucar.md index d2d7b2f..d612c2e 100644 --- a/docs/ucar.md +++ b/docs/ucar.md @@ -10,100 +10,171 @@ and interactive analysis of climate model output. - **Operator:** CISL/NCAR — - **Access:** NCAR allocation required — - **System page:** -- **Scheduler:** PBS Pro (via Slurm frontend on some partitions) +- **Scheduler:** PBS Pro - **Login:** `ssh @casper.ucar.edu` -- **Storage:** GLADE — `/glade/work/`, `/glade/scratch/`, `/glade/u/` +- **Storage:** GLADE — `/glade/work/`, `/glade/derecho/scratch/`, `/glade/u/` + +## Start here + +If you are standing up your own Casper endpoint for the first time, use the +site-agnostic [`scripts/endpoint.sh`](operating-an-endpoint.md) and skip the +rest of this page: + +```bash +# On a Casper login node, from a clone of this repo +export CONDA_ENV=uxarray MODULES="ncarenv/24.12 conda" +./scripts/endpoint.sh install +./scripts/endpoint.sh configure +./scripts/endpoint.sh start +``` + +That gives you every tool except conservative remapping. The rest of this page +describes `scripts/ucar_endpoint.sh`, which is the same thing with NCAR's +verified module list, an optional YAC build, and a fixed profile name. ## Key Points -- The **MCP server does not need to be cloned on Casper** — remote functions are - sent as source code via `AllCodeStrategies`. -- YAC is available via a pre-built activate script and enables conservative - remapping on the worker. -- The conda `uxarray_dev` env is the worker environment — `globus-compute-endpoint` - lives in a separate venv layered on top. +- **You do need a clone of this repo on Casper**, because the endpoint scripts + live in it. You do **not** need it importable: remote functions are sent as + source via `AllCodeStrategies`, and adding `uxarray_mcp` to the worker's + `PYTHONPATH` breaks it with a pydantic conflict. +- **YAC is optional.** Without it, everything works except + `method="conservative"` and the `backend="yac"` remap methods, which report + the missing library rather than failing obscurely. +- `globus-compute-endpoint` runs from the same conda env as the worker; there + is no separate venv on Casper. +- Worker Python must be 3.12. Globus Compute tolerates patch skew but not + minor skew, and uxarray-mcp pins 3.12. ## Worker Environment -| Item | Value | -|---|---| -| Conda env | `/glade/work//conda-envs/uxarray_dev` | -| YAC activate | `~/opt/yac-core-v3.14.0_p1/activate-yac.sh` | -| Endpoint name | `ucar-uxarray-yac` | -| Modules | `ncarenv/24.12`, `gcc/12.4.0`, `openmpi/5.0.6`, `conda` | +| Item | Value | Override | +|---|---|---| +| Conda env | `/glade/work//conda-envs/uxarray_dev` | `CONDA_ENV` | +| NCAR login | `$USER` | `NCAR_USERNAME` | +| Endpoint profile | `ucar-uxarray-yac` | `ENDPOINT_NAME` | +| Modules | `ncarenv/24.12`, `gcc/12.4.0`, `openmpi/5.0.6`, `conda` | — | +| Provider | `LocalProvider` (workers on the login node) | — | +| YAC | off | `WITH_YAC=1` | +| YAC prefix | `~/opt/yac-3.20.2` when `WITH_YAC=1` | `YAC_VERSION` | + +The script does **not** create the conda env or the YAC build. Both must exist +first. ## First-Time Setup ```bash -# 1. Load modules (add to ~/.bashrc or run manually each login) -module purge -module load ncarenv/24.12 gcc/12.4.0 openmpi/5.0.6 conda +# 1. Build the worker conda env (once) +module load conda +conda create -p /glade/work/$USER/conda-envs/uxarray_dev python=3.12 -c conda-forge -y +conda activate /glade/work/$USER/conda-envs/uxarray_dev +pip install globus-compute-endpoint uxarray xarray netCDF4 h5netcdf matplotlib -# 2. Configure the endpoint -scripts/ucar_endpoint.sh configure +# 2. Write the endpoint config +ENDPOINT_NAME=ucar-uxarray ./scripts/ucar_endpoint.sh configure -# 3. Start -scripts/ucar_endpoint.sh start +# 3. Start it +ENDPOINT_NAME=ucar-uxarray ./scripts/ucar_endpoint.sh start ``` +The profile name is `ucar-uxarray-yac` by default, and `configure` refuses that +name unless `WITH_YAC=1` — a profile named for YAC that quietly came up without +it would look healthy and be missing conservative remapping. Either pick a name +without `yac` in it, as above, or build YAC first (below). + ## Starting the Endpoint ```bash -# In a tmux session on a Casper login node: -scripts/ucar_endpoint.sh start +# From a plain shell on a Casper login node -- NOT from inside tmux +./scripts/ucar_endpoint.sh start ``` -The script loads modules, activates the conda env, checks YAC, and starts the -endpoint inside a tmux session named `uxarray-endpoint`. +The script loads modules, activates the conda env, and starts the endpoint +**inside a tmux session it creates itself**, named `uxarray-endpoint`. Do not +start tmux yourself: if you are already inside a session with a different name, +the script refuses and tells you to detach. + +- Reattach: `tmux attach -t uxarray-endpoint` +- Restart: `./scripts/ucar_endpoint.sh restart` (use this, not `start`, on a + running endpoint — `start` would take it down while reporting success) +- Status: `./scripts/ucar_endpoint.sh status` -To reattach: `tmux attach -t uxarray-endpoint` -To restart: `scripts/ucar_endpoint.sh restart` -To check: `scripts/ucar_endpoint.sh status` +The first start opens an OAuth flow. Over ssh, paste the printed URL into a +browser on your laptop and paste the code back. It then prints the endpoint +UUID. -Add the UUID to your private local config on your laptop/workstation, never to -the repository: +## Registering It + +Add the UUID to your private local config on your laptop, never to the +repository: ```bash -uxarray-mcp endpoints add ucar --path-prefix /glade/ +uxarray-mcp endpoints add ucar-casper --path-prefix /glade/ ``` -Always register the `--path-prefix`. Without it this endpoint claims no paths -of its own, so `/glade/...` work will fall through to whatever endpoint happens -to be the configured default — quite possibly at another facility. +`ucar-casper` here is a **local alias**, independent of the remote profile +name; every later `--endpoint` flag uses the alias. Always register the +`--path-prefix`. Without it this endpoint claims no paths of its own, so +`/glade/...` work falls through to whatever endpoint happens to be the +configured default — quite possibly at another facility. ## Validation ```bash -uv run python scripts/hpc_doctor.py --endpoint ucar --timeout-seconds 120 +uxarray-mcp doctor --endpoint ucar-casper --timeout-seconds 120 ``` -Or with a real GLADE path: +Or against a real GLADE path: ```bash -uv run python scripts/hpc_doctor.py \ - --endpoint ucar \ +uxarray-mcp doctor --endpoint ucar-casper \ --sample-path /glade/work//your_mesh_file.nc ``` -## YAC Remapping +## YAC Remapping (optional) -YAC is pre-built and activated by `activate-yac.sh`. To verify it works on the -worker: +Only needed for `method="conservative"` and the `backend="yac"` methods. ```bash -uv run python scripts/yac_smoke_test.py --endpoint ucar +# Build it once, into the prefix ucar_endpoint.sh looks for +uv run python scripts/hpc_build_yac.py \ + --endpoint ucar-casper --prefix '~/opt/yac-3.20.2' --yac-version v3.20.2 + +# Then rebuild the endpoint config with YAC in worker_init and restart +WITH_YAC=1 ./scripts/ucar_endpoint.sh configure +WITH_YAC=1 ./scripts/ucar_endpoint.sh restart + +# Verify on the worker, not the login node +uv run python scripts/yac_smoke_test.py --endpoint ucar-casper ``` +`worker_init` bakes the YAC prefix in at configure time, so pointing at a new +build (`YAC_VERSION=3.21 ...`) needs `configure` again, then `restart`. + +## Moving Files + +Globus Compute and Globus Transfer are separate services with separate logins. +A working endpoint does not let you copy files. See +[data-transfer.md](data-transfer.md); NCAR GLADE is in the known-collections +table, so `uxarray-mcp transfer setup --endpoint ucar-casper` finds it without +you pasting a UUID. + ## Troubleshooting **`endpoint not ready` immediately after start** — modules not loaded before -starting. The `ucar_endpoint.sh start` command loads them automatically; if -starting manually, ensure `module load ncarenv/24.12 gcc/12.4.0 openmpi/5.0.6 conda` -is run first. +starting. `ucar_endpoint.sh start` loads them; if you started +`globus-compute-endpoint` by hand, run +`module load ncarenv/24.12 gcc/12.4.0 openmpi/5.0.6 conda` first. + +**`configure` refuses with "named for YAC but WITH_YAC is 0"** — intentional. +Pass `WITH_YAC=1`, or use a profile name without `yac` in it. + +**`no YAC install at ~/opt/yac-`** — `WITH_YAC=1` with nothing built. +Build it with `scripts/hpc_build_yac.py`, or drop `WITH_YAC`. -**YAC import fails on worker** — the `activate-yac.sh` path may have changed. -Check `~/opt/` for the current YAC build and update `YAC_ACTIVATE` in -`ucar_endpoint.sh`. +**YAC import fails on the worker** — the prefix moved. Check `~/opt/` for the +build you have and pass its version as `YAC_VERSION`; the activate file is +synthesized from the prefix, so there is nothing to edit in the script. -**`validate_hpc_setup` passes but YAC tools fail** — run `yac_smoke_test.py` -to verify YAC is importable on the compute worker specifically. +**`doctor` passes but YAC tools fail** — run `yac_smoke_test.py`, which checks +importability on the compute worker specifically rather than on the login node. diff --git a/scripts/chrysalis_endpoint.sh b/scripts/chrysalis_endpoint.sh index 7972891..3362d62 100755 --- a/scripts/chrysalis_endpoint.sh +++ b/scripts/chrysalis_endpoint.sh @@ -3,9 +3,8 @@ set -euo pipefail # --------------------------------------------------------------------------- -# USER CONFIG — change these to match your account before running -# --------------------------------------------------------------------------- -USERNAME="jain" # your Chrysalis username +# Everything below has a default that works for any LCRC account. Override in +# the environment; nothing needs editing in this file. # --------------------------------------------------------------------------- ENDPOINT_NAME="${ENDPOINT_NAME:-uxarray-chrysalis}" @@ -23,7 +22,10 @@ YAC_SHIM_LIB="$HOME/local/yac-runtime-shims/lib" # time, so changing this needs `configure ` again, then `restart`. YAC_VERSION="${YAC_VERSION:-3.20.2}" YAC_LOCAL_PREFIX="$HOME/local/yac-$YAC_VERSION" -UXARRAY_YAC_SRC="/lcrc/group/e3sm/jain/uxarray-yac-src" +# A uxarray checkout carrying the YAC accessor, put on the worker's PYTHONPATH +# ahead of the installed package. Only needed while that support is unreleased; +# a path that does not exist is simply ignored by Python. +UXARRAY_YAC_SRC="${UXARRAY_YAC_SRC:-/lcrc/group/e3sm/$USER/uxarray-yac-src}" MKL_LIB="/gpfs/fs1/soft/chrysalis/spack-latest/opt/spack/linux-rhel8-x86_64/oneapi-2022.1.0/intel-oneapi-mkl-2022.1.0-iwhfz52/mkl/2022.1.0/lib/intel64" MPICH_LIB="/gpfs/fs1/soft/chrysalis/spack-latest/opt/spack/linux-rhel8-x86_64/gcc-11.3.0/mpich-4.3.2-dp2ycaq/lib" HWLOC_LIB="/gpfs/fs1/soft/chrysalis/spack-latest/opt/spack/linux-rhel8-x86_64/gcc-11.3.0/hwloc-2.12.2-5vqrpw7/lib" @@ -379,7 +381,7 @@ _logs() { sed -n '1,120p' "$ep_dir/user_environment.yaml" 2>/dev/null || true echo echo "==> Running endpoint-related processes" - ps -fu "$USER" | grep -E 'globus|parsl|process_worker|interchange|uxarray-chrysalis' | grep -v grep || true + ps -fu "$USER" | grep -E "globus|parsl|process_worker|interchange|$ENDPOINT_NAME" | grep -v grep || true echo echo "==> Latest submit scripts" find "$ep_dir" -path '*/submit_scripts/*' \ diff --git a/scripts/endpoint.sh b/scripts/endpoint.sh new file mode 100755 index 0000000..d3a21e4 --- /dev/null +++ b/scripts/endpoint.sh @@ -0,0 +1,448 @@ +#!/usr/bin/env bash +# Stand up a Globus Compute endpoint for uxarray-mcp on any HPC machine. +# +# This is the site-agnostic script: it knows nothing about YAC, about any +# facility's filesystem layout, or about anybody's username. Everything that +# differs between machines is an environment variable with a default that works +# on a plain login node. The per-site scripts in this directory (ucar_, +# chrysalis_, improv_) are this same shape with a site's verified module list +# and library paths already filled in. +# +# The MCP server repo does NOT need to be cloned on the HPC machine for remote +# analysis -- functions are serialised via AllCodeStrategies and run against +# whatever is installed in the worker environment. You only need this one file. +# Never add uxarray_mcp itself to the worker's PYTHONPATH; it drags in a +# pydantic that conflicts with the one globus-compute-endpoint wants. +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration -- override any of these in the environment +# --------------------------------------------------------------------------- +ENDPOINT_NAME="${ENDPOINT_NAME:-uxarray}" + +# Worker environment. Set VENV for a virtualenv, CONDA_ENV for a conda env +# (a name or an absolute path). CONDA_ENV wins if both are set, because a +# conda env is what most facilities' own documentation tells you to build. +CONDA_ENV="${CONDA_ENV:-}" +VENV="${VENV:-}" +PYTHON_VERSION="${PYTHON_VERSION:-3.12}" + +# Modules to load before touching the environment, space separated and in +# order, e.g. MODULES="ncarenv/24.12 conda". Empty means the site needs none. +MODULES="${MODULES:-}" +MODULE_PURGE="${MODULE_PURGE:-0}" + +# Where the work runs. "local" puts workers on the login node, which is the +# right first step everywhere: it proves the endpoint round-trips before a +# scheduler can be blamed for anything. Move to slurm or pbs once it does. +SCHEDULER="${SCHEDULER:-local}" # local | slurm | pbs +ACCOUNT="${ACCOUNT:-}" # Slurm account / PBS project -- required off local +QUEUE="${QUEUE:-}" # PBS queue or Slurm partition +WALLTIME="${WALLTIME:-01:00:00}" +NODES_PER_BLOCK="${NODES_PER_BLOCK:-1}" +MAX_BLOCKS="${MAX_BLOCKS:-1}" +MAX_WORKERS_PER_NODE="${MAX_WORKERS_PER_NODE:-1}" + +# Extra worker_init lines, appended verbatim after the environment is active. +# This is the hook a site script uses for things this script must not know +# about -- a YAC activate file, an LD_LIBRARY_PATH, a scratch export. +WORKER_INIT_EXTRA="${WORKER_INIT_EXTRA:-}" + +# Some facilities firewall AMQP's default 5671. Set AMQP_PORT=443 there. +AMQP_PORT="${AMQP_PORT:-}" + +TMUX_SESSION="${TMUX_SESSION:-uxarray-endpoint}" + +usage() { + cat <<'EOF' +Usage (run on an HPC login node): + endpoint.sh install Create the worker environment and install into it + endpoint.sh check Report what is ready and what is missing; change nothing + endpoint.sh configure Write the endpoint config (once per install) + endpoint.sh start Start the endpoint inside tmux + endpoint.sh restart Stop a running endpoint, then start it again + endpoint.sh status Show the endpoint list + endpoint.sh uuid Print this endpoint's UUID for `uxarray-mcp endpoints add` + +Environment overrides: + ENDPOINT_NAME Endpoint profile name (default: uxarray) + CONDA_ENV Conda env name or path for the worker + VENV Virtualenv path for the worker (used if CONDA_ENV unset) + PYTHON_VERSION Python for `install` (default: 3.12 -- see the note below) + MODULES Modules to load, space separated, in order + MODULE_PURGE 1 to `module purge` first (default: 0) + SCHEDULER local | slurm | pbs (default: local) + ACCOUNT Slurm account or PBS project; required unless local + QUEUE PBS queue or Slurm partition + WALLTIME Job walltime (default: 01:00:00) + NODES_PER_BLOCK Nodes per scheduler job (default: 1) + MAX_BLOCKS Concurrent scheduler jobs (default: 1) + MAX_WORKERS_PER_NODE Workers per node (default: 1) + WORKER_INIT_EXTRA Extra worker_init lines, appended verbatim + AMQP_PORT Set to 443 where the site firewalls 5671 + TMUX_SESSION tmux session name (default: uxarray-endpoint) + +Python version: Globus Compute tolerates patch skew (3.12.4 vs 3.12.10) but +not minor skew (3.12 vs 3.13). uxarray-mcp pins 3.12, so build the worker on +3.12 and the submitter side already matches. + +Examples: + # Simplest thing that works: login-node workers, conda, no scheduler + CONDA_ENV=uxarray ./endpoint.sh install + CONDA_ENV=uxarray ./endpoint.sh configure && CONDA_ENV=uxarray ./endpoint.sh start + + # Slurm, once the local one round-trips + CONDA_ENV=uxarray SCHEDULER=slurm ACCOUNT=myproj QUEUE=debug \ + ./endpoint.sh configure +EOF +} + +# --------------------------------------------------------------------------- +# Environment +# --------------------------------------------------------------------------- + +_env_kind() { + if [[ -n "$CONDA_ENV" ]]; then echo conda + elif [[ -n "$VENV" ]]; then echo venv + else echo none + fi +} + +_require_env_choice() { + if [[ "$(_env_kind)" == none ]]; then + echo "ERROR: set CONDA_ENV (name or path) or VENV (path) first." >&2 + echo " e.g. CONDA_ENV=uxarray $(basename "$0") $1" >&2 + return 1 + fi +} + +_load_modules() { + [[ -z "$MODULES" ]] && return 0 + if ! command -v module &>/dev/null; then + # `module` is a shell function, so it can be missing in a non-login shell + # even on a machine that has Lmod. Say which, or the user edits MODULES + # looking for a typo that is not there. + echo "WARNING: MODULES is set but no 'module' command in this shell." >&2 + echo " Run from a login shell (bash -l), or unset MODULES." >&2 + return 0 + fi + [[ "$MODULE_PURGE" == "1" ]] && module purge + local m + for m in $MODULES; do module load "$m"; done + return 0 +} + +_activate_env() { + case "$(_env_kind)" in + conda) + if ! command -v conda &>/dev/null; then + echo "ERROR: no conda on PATH. Add the site's conda module to MODULES." >&2 + return 1 + fi + # shellcheck disable=SC1091 + source "$(conda info --base)/etc/profile.d/conda.sh" + conda activate "$CONDA_ENV" + ;; + venv) + # shellcheck disable=SC1091 + source "$VENV/bin/activate" + ;; + *) _require_env_choice activate ;; + esac +} + +# --------------------------------------------------------------------------- +# install +# --------------------------------------------------------------------------- + +_install() { + _require_env_choice install + _load_modules + case "$(_env_kind)" in + conda) + if conda env list | awk '{print $1}' | grep -qx "$CONDA_ENV"; then + echo "==> Conda env '$CONDA_ENV' exists; installing into it" + else + echo "==> Creating conda env '$CONDA_ENV' (python $PYTHON_VERSION)" + conda create -n "$CONDA_ENV" "python=$PYTHON_VERSION" -c conda-forge -y + fi + ;; + venv) + if [[ ! -d "$VENV" ]]; then + echo "==> Creating venv $VENV" + "python$PYTHON_VERSION" -m venv "$VENV" + fi + ;; + esac + _activate_env + echo "==> Installing worker packages" + python -m pip install --upgrade pip + python -m pip install globus-compute-endpoint uxarray xarray netCDF4 h5netcdf matplotlib + echo + echo "Installed into: $(python -c 'import sys; print(sys.prefix)')" + python -c 'import sys; print("python", ".".join(map(str, sys.version_info[:3])))' + echo "Next: $(basename "$0") configure" +} + +# --------------------------------------------------------------------------- +# configure +# --------------------------------------------------------------------------- + +_worker_init() { + # Emitted as a YAML block scalar, so the caller indents every line. Order + # matters: PYTHONPATH is cleared before anything activates, or the login + # node's site-packages shadow the worker's and the import errors that + # follow name packages nobody asked for. + echo "unset PYTHONPATH" + if [[ -n "$MODULES" ]]; then + [[ "$MODULE_PURGE" == "1" ]] && echo "module purge" + local m + for m in $MODULES; do echo "module load $m"; done + fi + case "$(_env_kind)" in + conda) + # Left unexpanded on purpose: conda's base differs between the login + # node and a compute node at some sites. + echo "source \"\$(conda info --base)/etc/profile.d/conda.sh\"" + echo "conda activate $CONDA_ENV" + ;; + venv) echo "source $VENV/bin/activate" ;; + esac + [[ -n "$WORKER_INIT_EXTRA" ]] && printf '%s\n' "$WORKER_INIT_EXTRA" + return 0 +} + +_provider_block() { + case "$SCHEDULER" in + local) + cat <&2 + return 1 + ;; + esac +} + +_configure() { + _require_env_choice configure + if [[ "$SCHEDULER" != local ]]; then + if [[ -z "$ACCOUNT" ]]; then + # Without an account the scheduler rejects every job, and Globus Compute + # reports the endpoint as healthy while no worker ever appears. + echo "ERROR: SCHEDULER=$SCHEDULER needs ACCOUNT set to your project." >&2 + return 1 + fi + if [[ -z "$QUEUE" ]]; then + echo "ERROR: SCHEDULER=$SCHEDULER needs QUEUE set to the partition or queue." >&2 + return 1 + fi + fi + _load_modules + _activate_env + + local ep_dir="$HOME/.globus_compute/$ENDPOINT_NAME" + if [[ ! -d "$ep_dir" ]]; then + echo "==> Creating endpoint profile: $ENDPOINT_NAME" + globus-compute-endpoint configure "$ENDPOINT_NAME" + fi + + { + echo "display_name: $ENDPOINT_NAME" + [[ -n "$AMQP_PORT" ]] && echo "amqp_port: $AMQP_PORT" + echo "engine:" + echo " type: GlobusComputeEngine" + echo " max_workers_per_node: $MAX_WORKERS_PER_NODE" + echo " provider:" + _provider_block + echo " worker_init: |" + _worker_init | sed 's/^/ /' + # The endpoint stops itself after a long idle rather than holding a + # scheduler allocation forever; the soft count releases blocks first. + echo "idle_heartbeats_soft: 10" + echo "idle_heartbeats_hard: 5760" + } > "$ep_dir/config.yaml" + + echo "Wrote $ep_dir/config.yaml:" + echo + sed 's/^/ /' "$ep_dir/config.yaml" + echo + echo "Next: $(basename "$0") start" +} + +# --------------------------------------------------------------------------- +# check -- report only, change nothing +# --------------------------------------------------------------------------- + +_check_line() { printf ' [%s] %s\n' "$1" "$2"; } + +_check() { + local missing=0 + if [[ "$(_env_kind)" == none ]]; then + _check_line TODO "worker env -- set CONDA_ENV or VENV" + missing=1 + else + _check_line "ok " "worker env -- $(_env_kind): ${CONDA_ENV:-$VENV}" + fi + + _load_modules || true + if [[ "$(_env_kind)" != none ]] && _activate_env 2>/dev/null; then + _check_line "ok " "env activates" + if command -v globus-compute-endpoint &>/dev/null; then + _check_line "ok " "globus-compute-endpoint -- $(command -v globus-compute-endpoint)" + else + _check_line TODO "globus-compute-endpoint not installed -- run: $(basename "$0") install" + missing=1 + fi + local pyver + pyver="$(python -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || echo '?')" + if [[ "$pyver" == "3.12" ]]; then + _check_line "ok " "worker python $pyver" + else + # Minor skew breaks Dill; AllCodeStrategies hides it for simple payloads + # and then does not, which is the worst way to find out. + _check_line TODO "worker python $pyver -- uxarray-mcp pins 3.12; rebuild the env" + missing=1 + fi + if python -c 'import uxarray' 2>/dev/null; then + _check_line "ok " "uxarray importable" + else + _check_line TODO "uxarray not importable in the worker env" + missing=1 + fi + else + _check_line TODO "env does not activate -- check MODULES and CONDA_ENV/VENV" + missing=1 + fi + + if [[ -f "$HOME/.globus_compute/$ENDPOINT_NAME/config.yaml" ]]; then + _check_line "ok " "config -- ~/.globus_compute/$ENDPOINT_NAME/config.yaml" + else + _check_line TODO "no config -- run: $(basename "$0") configure" + missing=1 + fi + + if command -v tmux &>/dev/null; then + _check_line "ok " "tmux available" + else + _check_line TODO "no tmux -- the endpoint would die with your ssh session" + missing=1 + fi + + if [[ "$SCHEDULER" != local && -z "$ACCOUNT" ]]; then + _check_line TODO "SCHEDULER=$SCHEDULER but ACCOUNT is empty" + missing=1 + fi + return "$missing" +} + +# --------------------------------------------------------------------------- +# start / restart / status +# --------------------------------------------------------------------------- + +_do_start() { + _load_modules + _activate_env + # `globus-compute-endpoint start` against a profile that is already Running + # takes the live endpoint down rather than no-opping, and reports success + # while doing it. Refuse, and name the verb that works. + if globus-compute-endpoint list 2>/dev/null | grep -q "Running.*$ENDPOINT_NAME"; then + echo "ERROR: '$ENDPOINT_NAME' is already Running -- starting again would stop it." >&2 + echo " Use: $(basename "$0") restart" >&2 + return 1 + fi + echo "==> Starting endpoint: $ENDPOINT_NAME" + globus-compute-endpoint start "$ENDPOINT_NAME" +} + +_start() { + _require_env_choice start + if [[ -z "${TMUX:-}" ]]; then + echo "Launching tmux session '$TMUX_SESSION'..." + # -A: attach if it exists, create otherwise. The trailing shell keeps the + # window open when the endpoint exits, so the error is still on screen. + exec tmux new-session -A -s "$TMUX_SESSION" \ + "bash -l \"$0\" _do_start; exec bash -l" + fi + local current + current="$(tmux display-message -p '#S' 2>/dev/null || echo '')" + if [[ "$current" != "$TMUX_SESSION" ]]; then + # Running from some other session leaves the endpoint owned by whatever + # shell happened to be attached, and invites a second copy from elsewhere. + echo "ERROR: inside tmux session '$current', not '$TMUX_SESSION'." >&2 + echo " Detach with Ctrl-b d, then re-run from a plain shell." >&2 + return 1 + fi + _do_start +} + +_restart() { + _require_env_choice restart + _load_modules + _activate_env + echo "==> Stopping endpoint: $ENDPOINT_NAME" + globus-compute-endpoint stop "$ENDPOINT_NAME" 2>/dev/null || true + rm -f "$HOME/.globus_compute/$ENDPOINT_NAME/daemon.pid" + echo "==> Restarting..." + globus-compute-endpoint start "$ENDPOINT_NAME" +} + +_status() { + _load_modules + _activate_env + globus-compute-endpoint list +} + +_uuid() { + local f="$HOME/.globus_compute/$ENDPOINT_NAME/endpoint.json" + if [[ ! -f "$f" ]]; then + echo "ERROR: no $f -- the endpoint has not been started yet." >&2 + return 1 + fi + python -c "import json,sys; print(json.load(open(sys.argv[1]))['endpoint_id'])" "$f" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +case "${1:-}" in + install) _install ;; + check) _check ;; + configure) _configure ;; + start) _start ;; + _do_start) _do_start ;; # internal: invoked by tmux + restart) _restart ;; + status) _status ;; + uuid) _uuid ;; + *) usage; exit 1 ;; +esac diff --git a/scripts/improv_endpoint.sh b/scripts/improv_endpoint.sh index 0e55d23..715207a 100755 --- a/scripts/improv_endpoint.sh +++ b/scripts/improv_endpoint.sh @@ -3,9 +3,8 @@ set -euo pipefail # --------------------------------------------------------------------------- -# USER CONFIG — change these to match your account before running -# --------------------------------------------------------------------------- -USERNAME="jain" # your Improv username +# Everything below has a default that works for any Improv account. Override in +# the environment; nothing needs editing in this file. # --------------------------------------------------------------------------- ENDPOINT_NAME="${ENDPOINT_NAME:-improv-uxarray}" @@ -16,9 +15,10 @@ ENDPOINT_NAME="${ENDPOINT_NAME:-improv-uxarray}" PYTHON="${PYTHON:-/usr/bin/python3.12}" VENV="$HOME/venvs/globus-compute" TMUX_SESSION="uxarray-endpoint" -# NOTE: the MCP server repo does NOT need to be cloned on Improv. Remote -# functions are serialised via AllCodeStrategies and only require uxarray -# + numpy in the worker venv. Never add uxarray_mcp to PYTHONPATH. +# NOTE: the repo is cloned on Improv only because this script lives in it. The +# MCP server itself must never be importable by the worker: remote functions are +# serialised via AllCodeStrategies and only require uxarray + numpy in the +# worker venv. Never add uxarray_mcp to PYTHONPATH. usage() { cat <<'EOF' @@ -62,16 +62,20 @@ _check_endpoint_dir() { _configure() { local mode="${1:-}" - local project="${2:-}" - local ep_name="${2:-$ENDPOINT_NAME}" # single-host uses arg2 as ep name + local project="" + local ep_name if [[ -z "$mode" ]]; then usage; exit 1 fi - # pbs-debug uses arg2 as project, arg3 as optional ep name + # The two modes take different positionals: single-host has no project to + # charge, so the endpoint name is arg2 there and arg3 under pbs-debug. if [[ "$mode" == "pbs-debug" ]]; then + project="${2:-}" ep_name="${3:-$ENDPOINT_NAME}" + else + ep_name="${2:-$ENDPOINT_NAME}" fi ENDPOINT_NAME="$ep_name" diff --git a/scripts/ucar_endpoint.sh b/scripts/ucar_endpoint.sh index 9b66f4a..67bb84f 100755 --- a/scripts/ucar_endpoint.sh +++ b/scripts/ucar_endpoint.sh @@ -9,7 +9,13 @@ USERNAME="${NCAR_USERNAME:-$USER}" # override with NCAR_USERNAME if it differs # --------------------------------------------------------------------------- ENDPOINT_NAME="${ENDPOINT_NAME:-ucar-uxarray-yac}" -CONDA_ENV="/glade/work/$USERNAME/conda-envs/uxarray_dev" +CONDA_ENV="${CONDA_ENV:-/glade/work/$USERNAME/conda-envs/uxarray_dev}" + +# YAC buys conservative remapping and nothing else. It is a separate build that +# most people should skip: every tool except method="conservative" and the +# backend="yac" methods works without it, and a missing YAC reports itself +# rather than crashing. Opt in with WITH_YAC=1 once the build exists. +WITH_YAC="${WITH_YAC:-0}" # Override to test a freshly built YAC without editing this file: # YAC_VERSION=3.18 ucar_endpoint.sh restart # Bump the default only once that build is verified on the endpoint. 3.20.2 was @@ -20,10 +26,11 @@ YAC_VERSION="${YAC_VERSION:-3.20.2}" YAC_PREFIX="$HOME/opt/yac-$YAC_VERSION" YAC_ACTIVATE="$YAC_PREFIX/activate-yac.sh" TMUX_SESSION="uxarray-endpoint" -# NOTE: the MCP server repo does NOT need to be cloned on UCAR. Remote -# functions are serialised via AllCodeStrategies and run with only uxarray -# + numpy + matplotlib installed in the conda env. Never add uxarray_mcp -# to PYTHONPATH on the worker — it causes pydantic version conflicts. +# NOTE: the repo is cloned on Casper only because this script lives in it. The +# MCP server itself must never be importable by the worker: remote functions are +# serialised via AllCodeStrategies and run with only uxarray + numpy + +# matplotlib installed in the conda env. Never add uxarray_mcp to PYTHONPATH on +# the worker — it causes pydantic version conflicts. usage() { cat <<'EOF' @@ -35,6 +42,18 @@ Usage (run on a Casper login node): Environment overrides: ENDPOINT_NAME Globus Compute endpoint profile name (default: ucar-uxarray-yac) + NCAR_USERNAME NCAR login, if it differs from $USER + CONDA_ENV Worker conda env (default: /glade/work/$USER/conda-envs/uxarray_dev) + WITH_YAC 1 to build YAC into worker_init (default: 0 -- see below) + YAC_VERSION YAC prefix under ~/opt to use when WITH_YAC=1 (default: 3.20.2) + +YAC is optional. Without it every tool works except method="conservative" and +the backend="yac" remap methods, which report the missing library rather than +failing oddly. With WITH_YAC=1 you need a build at ~/opt/yac-$YAC_VERSION +first; see scripts/hpc_build_yac.py. + +If you have no Casper setup yet, scripts/endpoint.sh is the site-agnostic +version of this script and does not assume any of the above. EOF } @@ -63,6 +82,7 @@ _activate_env() { # shellcheck disable=SC1091 source "$(conda info --base)/etc/profile.d/conda.sh" conda activate "$CONDA_ENV" + [[ "$WITH_YAC" == "1" ]] || return 0 _ensure_yac_activate # shellcheck disable=SC1091 source "$YAC_ACTIVATE" @@ -106,6 +126,21 @@ _check_yac() { _configure() { local ep_dir="$HOME/.globus_compute/$ENDPOINT_NAME" + # worker_init bakes the YAC prefix in at configure time, so this is the one + # verb where WITH_YAC actually decides something. A profile named for YAC + # that is configured without it would come back up quietly missing + # conservative remapping, so make the choice explicit rather than default it. + local yac_line="" + if [[ "$WITH_YAC" == "1" ]]; then + _ensure_yac_activate + yac_line=$'\n source '"$YAC_ACTIVATE" + elif [[ "$ENDPOINT_NAME" == *yac* ]]; then + echo "ERROR: '$ENDPOINT_NAME' is named for YAC but WITH_YAC is 0." >&2 + echo " Rebuild it with YAC: WITH_YAC=1 $(basename "$0") configure" >&2 + echo " Or drop YAC from it: ENDPOINT_NAME=ucar-uxarray $(basename "$0") configure" >&2 + return 1 + fi + if [[ ! -d "$ep_dir" ]]; then echo "Creating endpoint profile: $ENDPOINT_NAME" globus-compute-endpoint configure "$ENDPOINT_NAME" @@ -125,8 +160,7 @@ engine: worker_init: | unset PYTHONPATH source "\$(conda info --base)/etc/profile.d/conda.sh" - conda activate $CONDA_ENV - source $YAC_ACTIVATE + conda activate $CONDA_ENV$yac_line idle_heartbeats_soft: 10 idle_heartbeats_hard: 5760 @@ -152,8 +186,10 @@ _do_start() { _load_modules echo "==> Activating conda env: $CONDA_ENV" _activate_env - echo "==> Checking YAC..." - _check_yac + if [[ "$WITH_YAC" == "1" ]]; then + echo "==> Checking YAC..." + _check_yac + fi # `globus-compute-endpoint start` against a profile that is already Running # takes the live endpoint down rather than no-opping, and reports success # while doing it. Refuse, and name the verb that actually works. Must come From 33ae6194a74a1f9b09c51b705c3936624681727d Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 11 Sep 2026 21:05:15 -0500 Subject: [PATCH 7/9] Document a 10-year CONUS mean on Casper as a worked case study case-studies/conus-precipitation-gdex/ follows one plain-English prompt through to a map: what was asked, the tool call it became, the result, the provenance block, and the numbers. Ten annual files of 6-hourly CESM ne120 PRECT from GDEX d651007, 14,600 steps, 23,510 of 777,602 cells, 462 s on casper02, ~37 GB read and 178 KB returned. Written for people who have the data and the allocation but have not run an MCP server before, so it explains what MCP is before assuming it, and carries laptop and Casper setup end to end. It records what usually gets left out: the run is I/O bound at roughly 81 MB/s and the CONUS subset saves no reading at all, since the kept cells are scattered through ncol and chunks are read whole; the reported mean is over the bounding box and includes ocean; the ensemble member is .002 because .010 is not staged in the six-hourly tier. Timings and cell counts are measured, token figures are estimates, and the text says which is which. docs/demo-gdex-conus-precip.md is the run-of-show for presenting it live, including the two config mistakes that cost the most time: an endpoint timeout of 300 s against a 462 s job, and path_prefixes missing /gdex/, which routed GDEX paths to a cluster at another facility. --- .../conus-precipitation-gdex/README.md | 615 ++++++++++++++++++ .../images/conus-precip-10yr.png | Bin 0 -> 177737 bytes docs/demo-gdex-conus-precip.md | 352 ++++++++++ 3 files changed, 967 insertions(+) create mode 100644 case-studies/conus-precipitation-gdex/README.md create mode 100644 case-studies/conus-precipitation-gdex/images/conus-precip-10yr.png create mode 100644 docs/demo-gdex-conus-precip.md diff --git a/case-studies/conus-precipitation-gdex/README.md b/case-studies/conus-precipitation-gdex/README.md new file mode 100644 index 0000000..9236a95 --- /dev/null +++ b/case-studies/conus-precipitation-gdex/README.md @@ -0,0 +1,615 @@ +# Using the uxarray MCP server on Casper: a 10-year CONUS precipitation map from 37 GB that never moved + +## The prompt + +This is the entire input. Typed into an AI assistant on a laptop. No script, no +file list built by hand, no cluster commands, no coordinates. + +> I've got a Globus Compute endpoint running on Casper at NCAR — it's called +> `ucar-uxarray-yac`. All the data is on the NCAR filesystem. Nothing is on my +> laptop, and I don't want to download any of it. +> +> Here's what I have: +> +> **Mesh** (CESM ne120 spectral element, about a quarter degree): +> `/glade/p/cesmdata/cseg/inputdata/share/scripgrids/ne120np4_pentagons_100310.nc` +> +> **Data** — 6-hourly PRECT, one file per year, 1979 through 1988, under +> `/gdex/data/d651007/b.e13.BHISTC5.ne120_t12.cesm-ihesp-hires1.0.30-1920-2005.002/atm/proc/tseries/hour_6/`, +> named `...cam.h2.PRECT.1979010100-1980010100.nc` through +> `...cam.h2.PRECT.1988010100-1989010100.nc`. +> +> Can you give me a ten-year average precipitation map over the continental US? +> PRECT comes out in m/s and I'd like it in mm/day. Run everything on Casper — +> don't pull the files over. When you're done, tell me exactly what ran where, +> so I can put it in a methods section. + +## The output + +``` +ELAPSED 462.1 s +n_files 10 +n_time_steps 14600 +time span 1979-01-01 00:00 → 1988-12-31 18:00 +n_face_subset 23510 of 777602 (3.02% of the mesh) +value_stats min 0.111 mean 2.232 max 8.584 mm/day n_nonfinite 0 +execution_venue hpc:ucar-uxarray-yac +worker casper02 +provenance.tool remote_temporal_mean_map +provenance.op_id op_1676bc621555 +``` + +![10-year mean PRECT over CONUS](images/conus-precip-10yr.png) + +*Mean PRECT, 1979–1988, CESM ne120 spectral-element mesh, subset to CONUS. +14,600 six-hourly time steps. 23,510 of 777,602 mesh cells. Computed on +casper02 at NSF NCAR. Coastlines, national and state boundaries drawn from +Natural Earth on the worker.* + +> **Read the mean correctly.** 2.232 mm/day is the average over the whole +> bounding box, which includes ocean — the deep blue offshore of the Carolinas +> is the Gulf Stream, not land. This is a box average, not a land-masked CONUS +> average. + +--- + +## A worked case study + +One paragraph of plain English produced a publication-shaped map from ten years +of 6-hourly CESM output living on the NCAR filesystem — plus a machine-readable +record of exactly which function ran, on which host, under which PBS job, over +how many time steps. + +No data was downloaded. The laptop never held more than a 178 KB PNG. + +### Contents + +- [Why this matters](#why-this-matters) +- [What MCP actually is](#what-mcp-actually-is-in-two-minutes) +- [What the assistant did with the prompt](#what-the-assistant-did-with-the-prompt) +- [Reading the result](#reading-the-result) +- [How it worked under the hood](#how-it-worked-under-the-hood) +- [Why it takes 462 seconds](#why-it-takes-462-seconds) +- [What it cost](#what-it-cost) +- [Set it up yourself](#set-it-up-yourself) +- [Methods: what was used](#methods-what-was-used) +- [Honest limitations](#honest-limitations) + +--- + +## Why this matters + +If you work with climate model output at NCAR, the shape of the problem is +familiar. The data is enormous and it lives on GLADE or GDEX. Your analysis code +lives on your laptop. Bridging those two facts costs you an afternoon: ssh in, +remember the module names, remember whether it was `conda activate` or +`module load conda` first, write the loop over annual files, get the longitude +convention wrong once, get the units wrong once, and finally produce a PNG you +then have to `scp` back. + +None of that is science. It is logistics. + +Three things make this different from "an AI wrote some code for me": + +1. **The compute went to the data.** The ~37 GB of 6-hourly output stayed on the + NCAR filesystem. A small function was shipped *to* Casper, ran there, and + sent back a picture. This is the opposite of the usual download-then-analyze + pattern, and it is the only pattern that scales. + +2. **Nothing was invented.** The assistant did not write a script and hope. It + called specific, tested functions in a server that already knows how to open + an unstructured mesh, subset it, and reduce it. When it tried to pass a + bounding box to a function that draws the whole globe, the server **refused** + rather than silently returning a global map. + +3. **The answer is auditable.** Every call returned a provenance record. You can + put `remote_temporal_mean_map`, `casper02`, 14,600 time steps and + `n_nonfinite: 0` into a methods section and someone else can check it. A + picture alone cannot tell you what was averaged away. This record can. + +That third point is the one worth arguing about. AI assistants are good at +producing plausible plots. The interesting engineering question is how you know +a plot is *right*. The answer here is not "trust the model" — it is that the +model can only call a fixed set of instrumented functions, and each one reports +what it did. + +--- + +## What MCP actually is, in two minutes + +**MCP** — Model Context Protocol — is a standard way to give an AI assistant a +set of tools it can call. That is the whole idea. + +Without MCP, an assistant that wants to compute something writes code and asks +you to run it. It cannot see your files, does not know your cluster, and has no +way to verify that what it wrote worked. You are the integration layer. + +With MCP, you run a small program — a **server** — that publishes a menu of +operations: "inspect this mesh", "compute a zonal mean", "plot this variable". +The assistant reads that menu and calls the operations by name, with arguments. +The server does the actual work and hands back structured results. + +``` + You ──type a question──▶ AI assistant + │ + │ calls a named tool with arguments + ▼ + uxarray MCP server ◀── runs on your laptop + │ + │ ships the work to where the data is + ▼ + Casper worker at NCAR ◀── runs uxarray on 37 GB + │ + ▼ + PNG + JSON record ──────▶ back to you +``` + +Three consequences worth internalizing: + +- **The assistant cannot do anything the server does not offer.** The menu is + the security boundary and the correctness boundary at once. It cannot delete + your scratch directory, because deleting files is not on the menu. +- **The server can be tested like any other software.** It has a test suite. The + functions the assistant calls are the same functions a Python user would call. +- **The results carry their own paperwork.** Because the server — not the model — + produces the result, the server can attach a provenance record the model + cannot forge. + +The **uxarray MCP server** is one such server. It publishes operations over +unstructured climate meshes (MPAS, CESM spectral-element, SCRIP, UGRID, HEALPix) +and knows how to run them either on your laptop or on an HPC endpoint via +[Globus Compute](https://www.globus.org/compute). + +--- + +## What the assistant did with the prompt + +It translated the paragraph into one tool call: + +```python +plot_dataset( + plot_type="temporal_mean", + grid_path="/glade/p/cesmdata/cseg/inputdata/share/scripgrids/ne120np4_pentagons_100310.nc", + data_paths=[...ten annual PRECT files...], + variable_name="PRECT", + lon_bounds=[-125, -67], # CONUS + lat_bounds=[24, 50], + scale_factor=86400000.0, # m/s -> mm/day + units_label="mm/day", + region_name="CONUS", + cmap="YlGnBu", + width=1000, height=560, + coastlines=True, + use_remote=True, + endpoint="ucar-uxarray-yac", +) +``` + +Three inferences it had to make, none of them stated in the prompt: + +| you said | it chose | why that is right | +|---|---|---| +| "continental US" | `lon_bounds=[-125,-67]`, `lat_bounds=[24,50]` | and crucially **negative** longitudes — uxarray normalizes to −180..180, so the naive `[235, 293]` selects nothing at all | +| "m/s, I'd like mm/day" | `scale_factor=86400000.0` | 86,400 s/day × 1000 mm/m | +| "run everything on Casper" | `use_remote=True`, `endpoint="ucar-uxarray-yac"` | the alternative — streaming 37 GB to a laptop — is not a thing that finishes | + +--- + +## Reading the result + +### The map + +Read it as a scientist would: + +- Dry Great Basin and Desert Southwest. +- Wet Pacific Northwest and a clean Sierra Nevada crest. +- Wet Gulf Coast and Southeast, with a visible Appalachian ridge signal. +- Maximum offshore over the Gulf Stream. + +That the features register correctly against the drawn coastlines is itself the +test. A longitude-convention bug would have put the Sierra in Kansas. + +### The paperwork + +Every result carries a `_provenance` block. This is the part you can cite: + +```json +"_provenance": { + "tool": "remote_temporal_mean_map", + "execution_venue": "hpc:ucar-uxarray-yac", + "remote_hostname": "casper02", + "operation_id": "op_1676bc621555", + "inputs": {"args": ["", "[<10 data paths>]", "PRECT", ...]} +} +``` + +And alongside it, the reduction record — what was averaged away: + +```json +"reduced_dims": {"time": {"kind": "time", "how": "mean", "size": 14600}}, +"subset_applied": true, +"lon_bounds": [-125.0, -67.0], +"lat_bounds": [24.0, 50.0], +"n_face_total": 777602, +"n_face_subset": 23510, +"value_stats": {"min": 0.111, "mean": 2.232, "max": 8.584, "n_nonfinite": 0} +``` + +**This is the point of the whole exercise.** A PNG cannot tell you whether it +averaged 14,600 time steps or accidentally took one. It cannot tell you whether +your bounding box selected 23,510 cells or zero. It cannot tell you whether a +level index was silently taken. The JSON says all three, in the same response, +so a wrong plot is *falsifiable* rather than merely pretty. + +--- + +## How it worked under the hood + +### The path a request takes + +1. **You type a paragraph.** The assistant sees the uxarray server's tool menu + alongside your text. +2. **The assistant picks one tool and fills in arguments.** Here, + `plot_dataset` with `plot_type="temporal_mean"`. +3. **The server routes it.** It sees `use_remote=True` and a path starting + `/gdex/`, matches that against the endpoint's configured `path_prefixes`, and + picks the Casper endpoint. +4. **The function is serialized and shipped.** Globus Compute's + `AllCodeStrategies` sends the *source* of the worker function, so the code + does not have to be pre-installed on Casper. +5. **A Casper worker runs it.** It opens the ten files with + `ux.open_mfdataset`, subsets to the CONUS bounding box, takes the time mean, + scales to mm/day, renders a choropleth, and overlays Natural Earth coastlines + from the worker's cartopy cache. +6. **A PNG and a JSON record come back.** ~180 KB total. + +### The one design decision that matters + +**The bounding box is applied *before* the reduction.** Subset first, then +average: only 23,510 cells — 3.02% of the mesh — are ever carried through the +mean over 14,600 time steps. Averaging first and cropping after would have done +33× the arithmetic for the same picture. + +### Guardrails you can see working + +- **Empty selection is an error, not an empty map.** A box that selects zero + faces raises, and the message names the −180..180 convention, because that is + the mistake almost everyone makes first. +- **A plot type that cannot honor a box refuses one.** `plot_type="variable"` + draws the whole mesh. Passing it `lon_bounds` used to silently drop them and + return a global map. It now raises and names the plot type that *does* honor a + box. That was a real bug found while building this case study. +- **Remote support is per-operation, not global.** An operation with no remote + implementation says so explicitly rather than quietly computing on your laptop + against a path it cannot read. + +### Why no cluster redeploy was needed + +The worker function for this case study did not exist when the Casper endpoint +was started. It still ran, against an unmodified endpoint, because Globus +Compute ships function source with the task. Iterating on remote analysis code +costs a rerun, not a redeploy. This is a bigger deal than it sounds — it is the +difference between "I'll try a variant" and "I'll file a ticket." + +--- + +## Why it takes 462 seconds + +Worth being blunt about, because 7.7 minutes feels slow for what looks like one +average. + +**It is I/O bound. The arithmetic is nothing; the reading is everything.** + +PRECT is stored as `(time, ncol)` float32. Per year that is +1460 × 777,602 × 4 B = **4.54 GB**. Ten years is **45.4 GB decompressed**, about +37.5 GB on disk. At 462.1 s that is roughly **81 MB/s off disk, 98 MB/s +decompressed** — a single-stream rate. + +Three reasons, in order of size: + +1. **The CONUS subset saves no reading at all.** You keep 3.02% of cells, but + those 23,510 indices are scattered through `ncol`, and netCDF chunks are read + whole. You read essentially all 45 GB to keep 1.4 GB. Subsetting before the + mean saves memory and arithmetic — not I/O. +2. **One worker, one stream.** The ten annual files are read sequentially. They + are completely independent, so this is the obvious 5–10× win available. +3. **Workers run on the login node.** The endpoint uses `LocalProvider` (see + [`docs/ucar.md`](../../docs/ucar.md)). Login-node I/O is shared and throttled; + compute nodes read faster. + +Marginal cost is **~46 s per year of data**, dead linear, which confirms I/O +bound with no meaningful per-call overhead. + +**If you need it faster:** compute the ten annual means once and average those — +the repeat becomes seconds. Or parallelize the file reads. Or move the provider +off the login node. The first is cheapest and is what you want if you are going +to look at this field more than once. + +--- + +## What it cost + +### Wall clock + +Measured against `ucar-uxarray-yac` (casper02) on 2026-09-11: + +| what | time | +|---|---| +| capability query (mesh topology, applicable operations) | ~20 s | +| **1 year**, direct call to the compute function | 29.1 s | +| **1 year**, full chain through the MCP server | 51.4 s / 77.5 s (two runs) | +| **10 years**, direct call | 464.4 s | +| **10 years**, full chain through the MCP server | 462.1 s | +| cold-worker penalty if no worker is allocated | add 1–4 min (PBS queue) | + +The MCP layer costs approximately nothing: 462.1 s through the full chain versus +464.4 s calling the compute function directly, with identical values to every +printed digit. + +### Bytes + +| direction | payload | +|---|---| +| laptop → Casper | serialized function + arguments, a few KB | +| Casper → laptop | 177,737-byte PNG + ~3 KB JSON | +| **what never moved** | **~37 GB of 6-hourly CAM output** | + +Roughly **200,000:1**. That ratio is the entire argument for moving compute to +data rather than data to compute. + +### Tokens + +AI assistants are billed per token — roughly per word in and out. Two different +costs are worth separating, because only one of them is interesting. + +| item | approximate tokens | +|---|---| +| **standing cost:** the uxarray tool menu, 31 tools, sent on *every* request | **~10,600** | +| the paragraph you typed | ~200 | +| the returned map (1000×560 image) | ~750 | +| the returned metadata + provenance JSON | ~750 | + +**The standing cost dominates.** Publishing 31 tools means ~10.6k tokens of +schema ride along with every message, whether or not you use any of them. The +per-call cost of actually doing the science is small by comparison. + +Two honest consequences: + +- This is a real argument for the server's "front door" design — `run_analysis` + and `plot_dataset` cover most of the surface, and a narrower registered tool + list would cut the standing cost several-fold. +- Turn the server off in sessions not doing mesh analysis. Most MCP clients let + you enable servers per session. + +A ten-year analysis like this one is a few cents of tokens against several +minutes of a Casper worker. The token bill is not the thing to optimize; the +schema footprint is. + +--- + +## Set it up yourself + +Two halves: something on your laptop, something on Casper. **Do the laptop half +first** — it works entirely on its own with local files, and you should confirm +it before adding a cluster to the picture. + +### Part 1 — your laptop (about 10 minutes) + +**Step 1. Install.** + +```bash +uv tool install --python 3.12 uxarray-mcp +``` + +If you don't have `uv`, install it first from +. It downloads Python +3.12 for you. + +> **Why exactly 3.12?** Only the HPC half needs it. Globus Compute's serializer +> is fragile across Python minor versions. Laptop-only use works on 3.11–3.13. + +**Step 2. Write a starter config.** + +```bash +uxarray-mcp setup +``` + +Creates `~/.config/uxarray-mcp/config.yaml`. Local-only use needs nothing more. + +**Step 3. Connect your AI assistant.** + +Claude Code: + +```bash +claude mcp add uxarray --transport stdio -- uxarray-mcp serve +``` + +Claude Desktop: + +```bash +uxarray-mcp install-claude --config-path ~/Library/Application\ Support/Claude/claude_desktop_config.json +``` + +Then restart the app. Other MCP clients: `uxarray-mcp install-claude --print-only` +prints the JSON block most of them accept. + +**Step 4. Check it.** Ask your assistant, in plain English, to inspect a mesh +file you already have locally. If it comes back with a face and node count, the +laptop half works. + +### Part 2 — a Casper endpoint (about 30 minutes, once) + +Full detail is in [`docs/ucar.md`](../../docs/ucar.md). The short version: + +**Step 1. Build a worker environment on Casper.** One time. + +```bash +ssh @casper.ucar.edu +module load conda +conda create -p /glade/work/$USER/conda-envs/uxarray_dev python=3.12 -c conda-forge -y +conda activate /glade/work/$USER/conda-envs/uxarray_dev +pip install globus-compute-endpoint uxarray xarray netCDF4 h5netcdf matplotlib cartopy +``` + +Worker Python **must** be 3.12. Include `cartopy` if you want coastlines drawn +on your maps, as in the figure above. + +**Step 2. Configure and start the endpoint.** + +```bash +git clone https://github.com/UXARRAY/uxarray-mcp-server.git +cd uxarray-mcp-server +export CONDA_ENV=uxarray_dev MODULES="ncarenv/24.12 conda" +./scripts/endpoint.sh install +./scripts/endpoint.sh configure +./scripts/endpoint.sh start +``` + +The first start opens an OAuth flow: paste the printed URL into a browser on +your laptop, paste the code back. It then prints a UUID. **Copy that UUID.** + +You need the repo cloned on Casper because the scripts live in it. You do *not* +need `uxarray_mcp` importable on the worker — remote functions are sent as +source. + +**Step 3. Register the endpoint on your laptop.** + +```bash +uxarray-mcp endpoints add ucar-casper --path-prefix /glade/ --path-prefix /gdex/ +``` + +**Register every path prefix you will read from.** This is the step people skip +and then spend an hour debugging. Without `/gdex/`, a request naming a GDEX file +matches no endpoint, falls through to whatever your default endpoint is — +possibly a cluster at another facility that cannot see the file at all. + +**Step 4. Raise the timeout if you run multi-year jobs.** In +`~/.config/uxarray-mcp/config.yaml`: + +```yaml + ucar-casper: + endpoint_id: + path_prefixes: [/glade/, /gdex/] + timeout_seconds: 2400 # the default 300 is too short for a 10-year mean +``` + +The ten-year run here takes 462 s. At the default 300 s the call raises a +timeout *after* the worker has already been doing the work. + +**Step 5. Verify.** Ask your assistant to check the endpoint status with a +worker probe. You want `status: active` and a node name. `registered` means the +manager is up but no worker is allocated — the next real call will wait in the +PBS queue. + +### Then ask it something + +Paste the paragraph from [The prompt](#the-prompt), with your own endpoint name +and file paths. Start with one year before you ask for ten. + +--- + +## Methods: what was used + +Everything needed to reproduce or check the run above. + +**Software** + +| component | version / detail | +|---|---| +| uxarray MCP server | v2026.9.0 | +| submitter Python | 3.12.10 (laptop) | +| worker Python | 3.11.12 (Casper conda env) | +| serialization | Globus Compute `AllCodeStrategies`, Dill 0.3.9 both ends | +| geography | cartopy 0.24.1 on the worker, Natural Earth cached locally there | + +The submitter/worker Python minor-version skew emits a warning +(`Environment differences detected...`) and worked in every run here. The +submitter must be 3.12; tracked at +[globus/globus-compute#2139](https://github.com/globus/globus-compute/issues/2139). + +**The call chain** + +``` +plot_dataset(plot_type="temporal_mean", ...) # tools/frontdoor.py + └─ temporal_mean_map(...) # tools/remote_tools.py + └─ agent.temporal_mean_map_remote(...) # remote/agent.py + └─ remote_temporal_mean_map(...) # remote/compute_functions.py + # ↑ runs on casper02 +``` + +`remote_temporal_mean_map` was written for this case study. On the worker it +calls `ux.open_mfdataset`, `Grid.subset.bounding_box`, an `xarray` time mean, +and HoloViews `polygons()` on the matplotlib backend, then draws Natural Earth +geometries as a `LineCollection` in plain degrees. + +**Endpoint configuration as run** + +```yaml + ucar-uxarray-yac: + endpoint_id: + path_prefixes: [/glade/, /gdex/] + timeout_seconds: 2400 +``` + +**Two bugs found and fixed while producing this** + +- `plot_dataset(plot_type="variable")` accepted `lon_bounds`/`lat_bounds` and + silently ignored them, returning a global map with nothing in the response + saying the box had been dropped. It now raises and names the plot type that + honors a box. +- The endpoint's `path_prefixes` listed only `/glade/`. GDEX paths start + `/gdex/`, matched no prefix, and routed to the configured default endpoint — + a cluster at a different facility that cannot see the files. Both prefixes are + now registered. + +**Measured versus estimated** + +Wall-clock times, byte counts, cell counts and all `value_stats` are measured +from the runs described. Token counts are estimates: the tool-schema figure is +measured from the registered tool list; image and JSON token figures are +standard approximations, not billing records. + +--- + +## Honest limitations + +Things a demo usually hides. They are here because a case study that only shows +the happy path is not evidence of anything. + +- **Ten years takes 7.7 minutes.** That is real work on real data and there is + no trick that makes it instant. Plan for it; run one year first. +- **The mean is over a box, not over land.** 2.232 mm/day includes the offshore + Atlantic and Pacific inside the bounding box. A land-masked CONUS average is a + different number. +- **The endpoint has to be up.** If the manager is down, nothing works, and the + error says so. `globus-compute-endpoint start ` on Casper. +- **Cold start hurts.** The first call after an idle period waits in the PBS + queue. Warm the worker before you need a fast answer. +- **Remote support is per-operation.** Not every operation in the server has a + remote implementation yet. Ones that don't say so explicitly instead of + quietly running locally against a path they cannot read — but you may still + hit "this one runs locally only." +- **The ensemble member.** This case study uses member `.002`. The originating + request named member #10; only `.002` and `.003` are staged in the 6-hourly + tier of `d651007`. Which member you get is a property of what is on disk, not + of the tooling — and the provenance record is what makes that checkable rather + than assumed. +- **The assistant can still be wrong about what you meant.** It inferred a CONUS + bounding box you did not specify. That inference was right, and it is recorded + in the provenance so you can check it. Read the provenance. That is what it is + for. + +--- + +## Reference + +- Dataset: UCAR GDEX `d651007` (MESACLIP), CESM iHESP high-resolution historical +- Mesh: `ne120np4_pentagons_100310.nc` — CESM ne120 spectral element, SCRIP + format. The grid file presents GLL nodes as face centers, a quirk of how SE + grids are written to SCRIP; uxarray reads 777,602 "faces" and everything + downstream stays consistent. +- Variable: `PRECT`, total precipitation rate, m/s +- Docs: [`docs/ucar.md`](../../docs/ucar.md) · + [`docs/remote-hpc.md`](../../docs/remote-hpc.md) · + [`docs/provenance.md`](../../docs/provenance.md) · + [`docs/operating-an-endpoint.md`](../../docs/operating-an-endpoint.md) diff --git a/case-studies/conus-precipitation-gdex/images/conus-precip-10yr.png b/case-studies/conus-precipitation-gdex/images/conus-precip-10yr.png new file mode 100644 index 0000000000000000000000000000000000000000..45e955f668620606cc3cfcdf15de81fec01bd2b4 GIT binary patch literal 177737 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 U1t$+pf`FnZt0q$}WfJgz0C}|1jsO4v literal 0 HcmV?d00001 diff --git a/docs/demo-gdex-conus-precip.md b/docs/demo-gdex-conus-precip.md new file mode 100644 index 0000000..a568e2c --- /dev/null +++ b/docs/demo-gdex-conus-precip.md @@ -0,0 +1,352 @@ +# Live demo: 10-year CONUS precipitation from GDEX, computed on casper + +Run-of-show for demonstrating the uxarray MCP server against data that never +leaves NSF NCAR. Every number below was measured on 2026-09-11 against endpoint +`ucar-uxarray-yac` (casper02). + +--- + +## The claim being demonstrated + +> Point an LLM at an HPC endpoint, name the files in English, get a publication-shaped +> map back — and be able to say afterwards exactly which function ran, on which host, +> under which PBS job, over which 14,600 time steps. + +Three properties the audience should walk away with: + +1. **The data never moves.** 10 files x ~3.75 GB = ~37 GB of 6-hourly CAM output stays + on the GDEX filesystem. What crosses the wire is a ~110 KB PNG and a JSON record. +2. **The compute is where the data is.** uxarray runs on a casper worker via Globus + Compute. The laptop has no uxarray-scale memory and never needs it. +3. **Every answer is auditable.** Each call returns `_provenance`: tool name, argument + list, execution venue, remote hostname, PBS job id, operation id. + +--- + +## Before the audience arrives + +### 1. Restart the MCP server + +The `temporal_mean` plot type was added this session. A server process started +before that edit will not expose it. + +``` +# in the client (Claude Code / Claude Desktop): restart the session so the +# uxarray MCP server re-registers its tool list +``` + +Confirm with a trivial call that `plot_dataset` accepts `plot_type="temporal_mean"`. + +### 1b. Config prerequisites (both of these were wrong; both are demo-fatal) + +In `~/.config/uxarray-mcp/config.yaml`, under `hpc.endpoints.ucar-uxarray-yac`: + +```yaml + ucar-uxarray-yac: + endpoint_id: 79bf66fc-0507-42d0-a6bc-81628e9f1d77 + path_prefixes: + - /glade/ + - /gdex/ # was missing — data lives here, not under /glade/ + timeout_seconds: 2400 # was 300 — the 10-year run takes 464 s +``` + +- **Timeout.** At 300 s the ten-year call raises + `RuntimeError: Remote execution of temporal_mean_map timed out after 300 seconds` + *after* the worker has already been doing the work. Now fixed to 2400. +- **Path prefix.** Only `/glade/` was listed. The GDEX data paths start `/gdex/`, + match no prefix, and route to `default_endpoint`, which is **`chrysalis`** — the + wrong machine, which cannot see these files at all. Now fixed. + +**Still your call:** `default_endpoint` remains `chrysalis`. With the `/gdex/` +prefix added, path-based routing now sends this dataset to casper correctly, and +the demo prompts also name `endpoint="ucar-uxarray-yac"` explicitly, so it is +belt-and-braces. Switch the default only if you want *every* unqualified call +going to NCAR during the demo. + +Harmless warning you will see and can ignore: + +``` +Environment differences detected between local SDK and endpoint ... + SDK: Python 3.12.10/Dill 0.3.9 + Workers: Python 3.11.12/Dill 0.3.9 +``` + +Serialization works across this pair. If someone in the room asks: the submitter +must be 3.12 for the serializer (globus/globus-compute#2139); the worker being 3.11 +has not caused a failure in any run here. + +### 2. Confirm the endpoint is warm + +``` +Check the status of the ucar-uxarray-yac endpoint with a worker probe. +``` + +Expected: `status: active`, node `casperNN`. If it reports `registered` only, the +manager is up but no worker is allocated yet — the first real call will sit in the +PBS queue. **Do one throwaway call before the audience arrives** so the worker is +already allocated; this is the difference between a 30 s demo and a 4-minute one. + +### 3. Resolve the ensemble member (OPEN ITEM) + +Orhan asked for **member #10**. Under +`/gdex/data/d651007/` only `...cesm-ihesp-hires1.0.30-1920-2005.002` and `.003` +exist for the 6-hourly atm stream. **All numbers in this document were measured on +`.002`.** Before the demo, run on casper: + +```bash +ls -d /gdex/data/d651007/*/atm/proc/tseries/hour_6 | sed 's#/gdex/data/d651007/##;s#/atm.*##' +``` + +If a member #10 case name exists, substitute it in the prompts below; nothing else +changes. If it does not, say so on the slide — "this is member 002, the 010 stream +is not staged in the 6-hourly tier" is a perfectly good thing to say out loud, and +it is exactly the kind of fact the provenance record makes checkable. + +### 4. Pre-bake the 10-year map + +Run Act III once beforehand and keep the PNG. See the timing note in Act III. + +--- + +## Act I — "what can this thing do with my data?" (~20 s) + +**Say:** "I have not told it anything about this dataset. It is going to look at the +mesh and tell me which operations are even legal on it." + +**Paste:** + +``` +Using the uxarray MCP server against the ucar-uxarray-yac endpoint, tell me what +I can do with this mesh: + + grid: /glade/p/cesmdata/cseg/inputdata/share/scripgrids/ne120np4_pentagons_100310.nc + +Run get_capabilities remotely — the file is on the NCAR filesystem, not on my +laptop. Report the topology and which MCP operations apply. +``` + +**What comes back:** 777,602 faces / 780,456 nodes / 2,329,471 edges, SCRIP format, +and the applicable-tool list. + +**Talking points while it runs:** + +- This is a spectral-element ne120 mesh. The grid file presents GLL nodes as face + centers, which is a quirk of how SE grids get written to SCRIP — uxarray reads it + as 777,602 "faces" and everything downstream is consistent. +- ~0.25 degree. 777k cells is where the "just load it in xarray on your laptop" + workflow starts to hurt. + +--- + +## Act II — one year, live (~50-80 s) + +**Say:** "Now the real question. One year first, so you can watch it happen." + +**Paste:** + +``` +Compute the time-mean of PRECT over CONUS for 1979 and plot it. + + grid: /glade/p/cesmdata/cseg/inputdata/share/scripgrids/ne120np4_pentagons_100310.nc + data: /gdex/data/d651007/b.e13.BHISTC5.ne120_t12.cesm-ihesp-hires1.0.30-1920-2005.002/atm/proc/tseries/hour_6/b.e13.BHISTC5.ne120_t12.cesm-ihesp-hires1.0.30-1920-2005.002.cam.h2.PRECT.1979010100-1980010100.nc + +Use plot_dataset with plot_type="temporal_mean", use_remote=True, +endpoint="ucar-uxarray-yac". Subset to CONUS with lon_bounds=[-125,-67] and +lat_bounds=[24,50]. PRECT is in m/s — convert to mm/day with +scale_factor=86400000 and units_label="mm/day". Use cmap="YlGnBu", +width=1000, height=560, and draw coastlines. Then show me the provenance block. +``` + +**Measured:** 29.1 s calling the compute function directly; 51.4 s and 77.5 s through +the full MCP front door on two separate runs. 1,460 time steps (365 x 4, no-leap). + +**Talking points while it runs:** + +- 1,460 time steps of a 777k-cell field are being reduced on the worker. The mean is + computed *after* the bounding-box subset, so only 23,510 cells — 3.02% of the mesh — + are ever carried through the reduction. +- The `-125..-67` is not a typo. uxarray normalizes longitudes to −180..180, so the + CONUS box is negative, not 235..293. Getting this wrong used to return an empty + selection silently; the server now refuses with a message that names the convention. + +**Then read the provenance out loud:** + +``` +tool: remote_temporal_mean_map +execution_venue: hpc:ucar-uxarray-yac +remote_hostname: casper02 +operation_id: op_01417508a9d9 +n_time_steps: 1460 +n_face_subset: 23510 of 777602 (3.02%) +``` + +--- + +## Act III — ten years (7.7 min) + +**Do not run this in silence.** 464 s is a long time in front of a room. + +Two ways to play it, pick one: + +**(a) Kick it off and talk over it.** Paste the prompt, then spend the eight minutes +on the architecture: function-by-value serialization, why no endpoint redeploy was +needed, the provenance record, the per-operation remote-support boundary. Come back +to the map when it lands. This is the honest version and it demos the real cost. + +**(b) Show the pre-baked artifact.** Run it before the session, show the PNG and its +provenance JSON, and say "this took 7.7 minutes on one casper worker, here is the +record." Then run Act II live so they still see a live call. + +**Paste:** + +``` +Same thing, but the full ten-year average: 1979 through 1988 inclusive, ten annual +files from the same directory (…PRECT.1979010100-1980010100.nc through +…PRECT.1988010100-1989010100.nc). Same CONUS box, same mm/day conversion, same +colormap and size, coastlines on. Report wall-clock, the number of time steps that +went into the mean, and the full provenance block. +``` + +**Measured (direct to casper, 2026-09-11):** + +| quantity | value | +|---|---| +| wall clock | 464.4 s (7.7 min) | +| files read | 10 | +| time steps in the mean | 14,600 | +| time span | 1979-01-01 00:00 → 1988-12-31 18:00 | +| faces after subset | 23,510 of 777,602 (3.02%) | +| mean PRECT | 2.232 mm/day | +| min / max | 0.111 / 8.584 mm/day | +| non-finite cells | 0 | +| PNG returned | 112,475 bytes | +| PBS job | 5890924.casper-pbs | + +**The science check — say this while pointing at the map:** + +- Dry Great Basin and Desert Southwest. +- Wet Pacific Northwest and Sierra crest. +- Wet Gulf Coast, Southeast, and a clear Appalachian ridge signal. +- Maximum offshore over the Gulf Stream. +- 2.23 mm/day CONUS-average is the right order for a historical CESM run. + +That the field registers correctly against the drawn coastlines is itself the +regression test — a longitude-convention bug would put the Sierra in Kansas. + +--- + +## Numbers for the slide + +### Wall clock + +| what | time | +|---|---| +| capability query (Act I) | ~20 s | +| 1 year, direct compute function | 29.1 s | +| 1 year, full MCP front door | 51.4 s / 77.5 s | +| 10 years, full chain | 464.4 s | +| cold worker penalty (PBS queue) | add 1-4 min if not pre-warmed | + +Per-year marginal cost is ~46 s, so the 10-year run is dominated by I/O over the +~37 GB of source data, not by the mesh operation. + +### Bytes over the wire + +| direction | payload | +|---|---| +| laptop → casper | the serialized function + arguments, a few KB | +| casper → laptop | 112 KB PNG (base64: ~150 KB) + ~3 KB JSON | +| what stayed put | ~37 GB of 6-hourly CAM output | + +**This ratio is the demo.** ~37 GB read, ~115 KB returned — a factor of ~320,000. + +### Token cost (approximate, per request) + +| item | tokens | +|---|---| +| uxarray MCP tool schema, 31 tools | ~10,600 on *every* request | +| returned map, 1000x560 | ~750 image tokens | +| returned metadata + provenance JSON | ~750 | +| the prompt you paste | ~200 | + +Honest framing for the room: the tool schema dominates. The interesting per-call +cost is small; the standing cost of having 31 tools registered is not. This is a +real argument for the front-door design — `run_analysis` and `plot_dataset` cover +most of the surface, and a narrower registered tool list would cut the standing +cost several-fold. + +--- + +## Provenance: the part to linger on + +Every call returns `_provenance`. Show the raw block on screen: + +```json +"_provenance": { + "tool": "remote_temporal_mean_map", + "inputs": {"args": ["", "[<10 data paths>]", "PRECT", ...]}, + "execution_venue": "hpc:ucar-uxarray-yac", + "remote_hostname": "casper02", + "remote_pbs_job_id": "5890924.casper-pbs", + "operation_id": "op_..." +} +``` + +Alongside it, the result carries the reduction record itself: + +```json +"reduced_dims": {"time": {"kind": "time", "how": "mean", "size": 14600}}, +"subset_applied": true, +"lon_bounds": [-125.0, -67.0], "lat_bounds": [24.0, 50.0], +"n_face_total": 777602, "n_face_subset": 23510, +"value_stats": {"min": 0.111, "mean": 2.232, "max": 8.584, "n_nonfinite": 0} +``` + +The point to make: **a picture cannot tell you what was averaged away.** The PNG +alone cannot say whether you got one time step or 14,600, whether a level index was +silently taken, or whether the box selected anything. `reduced_dims`, +`n_face_subset` and `n_nonfinite` say it in the same response, so a wrong plot is +falsifiable rather than merely pretty. + +Earlier remote calls available to cite as additional evidence: +`calculate_zonal_mean` → `op_37acf617bf97`; `plot_dataset(variable)` → +`op_51ce6b5bdb70`; both on PBS job `5890924.casper-pbs`. + +--- + +## If something breaks live + +| symptom | say this | do this | +|---|---|---| +| call hangs >2 min | "we are in the PBS queue — the worker is being allocated" | wait, or fall back to the pre-baked artifact | +| "timed out after 300 seconds" | config regression — the endpoint timeout is back at its default | the work may still be finishing on the worker; note the task id and move on | +| result comes back but paths look unreadable | routed to the wrong endpoint | check `execution_venue` in provenance — it must say `hpc:ucar-uxarray-yac` | +| `status: offline` | "endpoint manager is down" | ssh, `globus-compute-endpoint start ucar-uxarray-yac` | +| "does not support use_remote=True yet" | "remote support is per-operation, not global — that one still runs locally" | pick a different operation; do not retry | +| box selects no faces | "longitude convention — uxarray uses −180..180" | this is the guard working; use negative lon | +| `plot_type="variable"` refuses the box | "that plot type draws the whole mesh; it now refuses a box rather than silently ignoring it" | use `temporal_mean` | + +The last two are worth *deliberately* triggering if the audience is technical. A tool +that refuses loudly is a better demo than one that always succeeds. + +--- + +## What changed in the server to make this work + +Worth one slide if the audience is uxarray developers: + +- `remote_temporal_mean_map` in `remote/compute_functions.py` — new worker function: + multi-file open, bbox subset before the reduction, time-mean, unit scaling, + choropleth, Natural Earth geography overlay drawn from the worker's cartopy cache. +- `temporal_mean_map_remote` action in `remote/agent.py`, `temporal_mean_map` + wrapper in `tools/remote_tools.py`. +- `plot_dataset` in `tools/frontdoor.py` gained `plot_type="temporal_mean"` plus + `data_paths`, `scale_factor`, `units_label`, `region_name`. +- `plot_type="variable"` now **raises** on `lon_bounds`/`lat_bounds` instead of + dropping them silently. That was a real bug: it returned a global map and nothing + in the response said the box had been ignored. + +**No endpoint redeploy was required.** Globus Compute's `AllCodeStrategies` +serializes function code by value (`remote/agent.py:200`), so a new worker function +ships with the task. This is a good thirty seconds of the talk — it is why iterating +on remote analysis code is cheap. From b7b31a6c7a5d6ece5b28bbb969b15cb64ce1e810 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 11 Sep 2026 21:59:16 -0500 Subject: [PATCH 8/9] Read the worker dispatch guard from the syntax tree, not the source text test_every_dispatch_offers_the_same_input_kinds counted two substrings and required the counts to match. That tied the guard to formatting: an extension list the formatter wrapped over three lines read as zero shapefile branches, and a nested if naming the HEALPix prefix twice read as two HEALPix branches. remote_temporal_mean_map is both, so it was reported as offering HEALPix without shapefiles while opening shapefiles two lines away. The same miscount could cancel out and let a genuine omission through. Read the branch kinds off the AST and assert both are present, which is the property the docstring already claimed to protect. A new test pins the guard itself: it must flag a payload that drops the shapefile read, and must judge the one-line and wrapped spellings of the extension test alike. Bump the three deliberate copy counts for the new remote function, and ship the case-study reproduction script with its unused import, formatting and two None-safety errors fixed. --- .gitignore | 11 +- CHANGELOG.md | 15 ++ scripts/reproduce_conus_case_study.py | 339 ++++++++++++++++++++++++++ tests/test_worker_payload_drift.py | 111 +++++++-- 4 files changed, 456 insertions(+), 20 deletions(-) create mode 100644 scripts/reproduce_conus_case_study.py diff --git a/.gitignore b/.gitignore index c9a6eec..763bd03 100644 --- a/.gitignore +++ b/.gitignore @@ -226,10 +226,19 @@ scripts/convergence_agent/ # macOS metadata .DS_Store -# Generated plots / scratch scripts dropped at repo root +# Generated plots / scratch scripts dropped at repo root. +# Figures published with a case study are committed past this with `git add -f`. *.png save_plots.py +# Artifacts written by scripts/reproduce_conus_case_study.py next to the +# case study it reproduces. The run is the point; its output is not reviewed. +case-studies/**/*-reproduced.json +case-studies/conus-precipitation-gdex/conus-precip-1yr-1979.json + +# R session leftovers. +.Rhistory + # Eval result JSON files are per-run and regenerated by the runners. evals/results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d4bf1ab..a6854ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,6 +156,21 @@ built against; see `docs/release.md`. Versions through `0.3.1` were SemVer. issue, and a human merge is what reaches PyPI. ### Fixed +- The worker-payload drift guard read source text, so it failed on formatting + and could pass on real drift. `test_every_dispatch_offers_the_same_input_kinds` + counted the substrings `startswith("healpix:")` and `".shp", ".geojson"` and + required the two counts to be equal. A dispatch whose extension list the + formatter wrapped over three lines therefore read as zero shapefile branches, + and a nested `if` naming the HEALPix prefix twice read as two HEALPix + branches -- `remote_temporal_mean_map` is both, and was reported as offering + HEALPix without shapefiles while doing no such thing. Equally, a payload that + genuinely dropped the shapefile read would have passed whenever the miscount + balanced. The guard now reads the syntax tree and asserts that both input + kinds are *present*, which is the property the test was written to protect; + how many times a function spells either one is its own business. A new test + pins the guard's own teeth: it must flag a payload that drops the shapefile + read, and must judge the one-line and formatter-wrapped spellings of the + extension test alike. - A gradient could not be obtained on a grid without a `sphere_radius` attribute. The refusal for the missing attribute told the caller to pass `scale_by_radius=False`; doing so was refused in turn with the advice to diff --git a/scripts/reproduce_conus_case_study.py b/scripts/reproduce_conus_case_study.py new file mode 100644 index 0000000..b2087b2 --- /dev/null +++ b/scripts/reproduce_conus_case_study.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Reproduce all results in case-studies/conus-precipitation-gdex/README.md. + +Runs remote HPC operations against Casper at NSF NCAR via Globus Compute +endpoint `ucar-uxarray-yac`. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import sys +import time +from pathlib import Path +from typing import Any, Dict + +GRID_PATH = ( + "/glade/p/cesmdata/cseg/inputdata/share/scripgrids/ne120np4_pentagons_100310.nc" +) +DATA_BASE_DIR = ( + "/gdex/data/d651007/b.e13.BHISTC5.ne120_t12.cesm-ihesp-hires1.0.30-1920-2005.002" + "/atm/proc/tseries/hour_6/" +) +DATA_PREFIX = ( + "b.e13.BHISTC5.ne120_t12.cesm-ihesp-hires1.0.30-1920-2005.002.cam.h2.PRECT." +) +ENDPOINT = "ucar-uxarray-yac" + +LON_BOUNDS = [-125.0, -67.0] +LAT_BOUNDS = [24.0, 50.0] +SCALE_FACTOR = 86400000.0 # m/s -> mm/day +UNITS_LABEL = "mm/day" +REGION_NAME = "CONUS" +CMAP = "YlGnBu" +WIDTH = 1000 +HEIGHT = 560 + + +def get_data_paths(start_year: int = 1979, end_year: int = 1988) -> list[str]: + paths = [] + for y in range(start_year, end_year + 1): + filename = f"{DATA_PREFIX}{y}010100-{y + 1}010100.nc" + paths.append(f"{DATA_BASE_DIR}{filename}") + return paths + + +def run_act1() -> Dict[str, Any]: + """Act I: Remote capabilities query.""" + print("=" * 70) + print("ACT I: Remote Capabilities Query") + print(f"Grid: {GRID_PATH}") + print(f"Endpoint: {ENDPOINT}") + print("=" * 70) + + from uxarray_mcp.tools.capabilities import get_capabilities + + t0 = time.time() + res = get_capabilities( + grid_path=GRID_PATH, + use_remote=True, + endpoint=ENDPOINT, + ) + elapsed = time.time() - t0 + + grid_summary = res.get("grid_summary") or {} + prov = res.get("_provenance") or {} + + print(f"ELAPSED {elapsed:.1f} s") + print(f"format {grid_summary.get('format')}") + print(f"n_face {grid_summary.get('n_face')}") + print(f"n_node {grid_summary.get('n_node')}") + print(f"n_edge {grid_summary.get('n_edge')}") + print(f"execution_venue {prov.get('execution_venue')}") + print(f"applicable_tools {len(res.get('mcp_server_tools') or [])} tools") + print("-" * 70) + + assert grid_summary.get("n_face") == 777602, ( + f"Expected 777602 faces, got {grid_summary.get('n_face')}" + ) + assert grid_summary.get("n_node") == 780456, ( + f"Expected 780456 nodes, got {grid_summary.get('n_node')}" + ) + assert grid_summary.get("n_edge") == 2329471, ( + f"Expected 2329471 edges, got {grid_summary.get('n_edge')}" + ) + print("✓ Act I checks passed successfully!") + return res + + +def extract_plot_result(mcp_contents: list[Any]) -> tuple[Dict[str, Any], bytes | None]: + meta: Dict[str, Any] = {} + png_bytes: bytes | None = None + + for block in mcp_contents: + if isinstance(block, dict): + btype = block.get("type") + if btype == "text": + text = block.get("text", "{}") + try: + meta = json.loads(text) + except Exception: + meta = {"raw_text": text} + elif btype == "image": + src = block.get("source") or {} + data = src.get("data") + if data: + png_bytes = base64.b64decode(data) + else: + btype = getattr(block, "type", None) + if btype == "text": + text = getattr(block, "text", "{}") + try: + meta = json.loads(text) + except Exception: + meta = {"raw_text": text} + elif btype == "image": + src = getattr(block, "source", None) + if isinstance(src, dict) and "data" in src: + png_bytes = base64.b64decode(src["data"]) + elif src is not None and hasattr(src, "data"): + png_bytes = base64.b64decode(src.data) + elif hasattr(block, "data"): + png_bytes = base64.b64decode(block.data) + + if not png_bytes and "png_b64" in meta: + png_bytes = base64.b64decode(meta["png_b64"]) + + return meta, png_bytes + + +def run_act2(output_dir: Path) -> Dict[str, Any]: + """Act II: 1-Year Remote Precipitation Mean (1979).""" + print("\n" + "=" * 70) + print("ACT II: 1-Year Remote Precipitation Mean (1979)") + paths = get_data_paths(1979, 1979) + print(f"Grid: {GRID_PATH}") + print(f"Data: {paths[0]}") + print(f"Endpoint: {ENDPOINT}") + print("=" * 70) + + from uxarray_mcp.tools.frontdoor import plot_dataset + + t0 = time.time() + res = plot_dataset( + plot_type="temporal_mean", + grid_path=GRID_PATH, + data_paths=paths, + variable_name="PRECT", + lon_bounds=LON_BOUNDS, + lat_bounds=LAT_BOUNDS, + scale_factor=SCALE_FACTOR, + units_label=UNITS_LABEL, + region_name=REGION_NAME, + cmap=CMAP, + width=WIDTH, + height=HEIGHT, + coastlines=True, + use_remote=True, + endpoint=ENDPOINT, + ) + elapsed = time.time() - t0 + + meta, png_bytes = extract_plot_result(res) + prov = meta.get("_provenance") or {} + worker = (meta.get("_worker_runtime") or {}).get("hostname") or prov.get( + "remote_hostname" + ) + vstats = meta.get("value_stats") or {} + + print(f"ELAPSED {elapsed:.1f} s") + print(f"n_files {meta.get('n_files')}") + print(f"n_time_steps {meta.get('n_time_steps')}") + print(f"time span {meta.get('time_start')} → {meta.get('time_end')}") + n_sub = meta.get("n_face_subset") + n_tot = meta.get("n_face_total") + frac = (n_sub / n_tot * 100.0) if n_sub and n_tot else 0.0 + print(f"n_face_subset {n_sub} of {n_tot} ({frac:.2f}% of the mesh)") + print( + f"value_stats min {vstats.get('min'):.3f} " + f"mean {vstats.get('mean'):.3f} " + f"max {vstats.get('max'):.3f} {UNITS_LABEL} " + f"n_nonfinite {vstats.get('n_nonfinite')}" + ) + print(f"execution_venue {meta.get('execution_venue')}") + print(f"worker {worker}") + print(f"provenance.tool {prov.get('tool')}") + print(f"provenance.op_id {prov.get('operation_id')}") + + if png_bytes: + out_png = output_dir / "conus-precip-1yr-1979.png" + out_png.write_bytes(png_bytes) + print(f"Saved plot: {out_png} ({len(png_bytes)} bytes)") + + out_json = output_dir / "conus-precip-1yr-1979.json" + out_json.write_text(json.dumps(meta, indent=2)) + print(f"Saved metadata: {out_json}") + + print("-" * 70) + assert meta.get("n_time_steps") == 1460, ( + f"Expected 1460 time steps, got {meta.get('n_time_steps')}" + ) + assert meta.get("n_face_subset") == 23510, ( + f"Expected 23510 subset faces, got {meta.get('n_face_subset')}" + ) + assert meta.get("n_face_total") == 777602, ( + f"Expected 777602 total faces, got {meta.get('n_face_total')}" + ) + print("✓ Act II checks passed successfully!") + return meta + + +def run_act3(output_dir: Path) -> Dict[str, Any]: + """Act III: 10-Year Remote Precipitation Mean (1979-1988).""" + print("\n" + "=" * 70) + print("ACT III: 10-Year Remote Precipitation Mean (1979-1988)") + paths = get_data_paths(1979, 1988) + print(f"Grid: {GRID_PATH}") + print(f"Data files: {len(paths)} annual files") + print(f"Endpoint: {ENDPOINT}") + print("=" * 70) + + from uxarray_mcp.tools.frontdoor import plot_dataset + + t0 = time.time() + res = plot_dataset( + plot_type="temporal_mean", + grid_path=GRID_PATH, + data_paths=paths, + variable_name="PRECT", + lon_bounds=LON_BOUNDS, + lat_bounds=LAT_BOUNDS, + scale_factor=SCALE_FACTOR, + units_label=UNITS_LABEL, + region_name=REGION_NAME, + cmap=CMAP, + width=WIDTH, + height=HEIGHT, + coastlines=True, + use_remote=True, + endpoint=ENDPOINT, + ) + elapsed = time.time() - t0 + + meta, png_bytes = extract_plot_result(res) + prov = meta.get("_provenance") or {} + worker = (meta.get("_worker_runtime") or {}).get("hostname") or prov.get( + "remote_hostname" + ) + vstats = meta.get("value_stats") or {} + + print(f"ELAPSED {elapsed:.1f} s") + print(f"n_files {meta.get('n_files')}") + print(f"n_time_steps {meta.get('n_time_steps')}") + print(f"time span {meta.get('time_start')} → {meta.get('time_end')}") + n_sub = meta.get("n_face_subset") + n_tot = meta.get("n_face_total") + frac = (n_sub / n_tot * 100.0) if n_sub and n_tot else 0.0 + print(f"n_face_subset {n_sub} of {n_tot} ({frac:.2f}% of the mesh)") + print( + f"value_stats min {vstats.get('min'):.3f} " + f"mean {vstats.get('mean'):.3f} " + f"max {vstats.get('max'):.3f} {UNITS_LABEL} " + f"n_nonfinite {vstats.get('n_nonfinite')}" + ) + print(f"execution_venue {meta.get('execution_venue')}") + print(f"worker {worker}") + print(f"provenance.tool {prov.get('tool')}") + print(f"provenance.op_id {prov.get('operation_id')}") + + if png_bytes: + out_png = output_dir / "conus-precip-10yr-reproduced.png" + out_png.write_bytes(png_bytes) + print(f"Saved plot: {out_png} ({len(png_bytes)} bytes)") + + out_json = output_dir / "conus-precip-10yr-reproduced.json" + out_json.write_text(json.dumps(meta, indent=2)) + print(f"Saved metadata: {out_json}") + + print("-" * 70) + assert meta.get("n_files") == 10, f"Expected 10 files, got {meta.get('n_files')}" + assert meta.get("n_time_steps") == 14600, ( + f"Expected 14600 time steps, got {meta.get('n_time_steps')}" + ) + assert meta.get("n_face_subset") == 23510, ( + f"Expected 23510 subset faces, got {meta.get('n_face_subset')}" + ) + assert meta.get("n_face_total") == 777602, ( + f"Expected 777602 total faces, got {meta.get('n_face_total')}" + ) + assert vstats.get("n_nonfinite") == 0, ( + f"Expected 0 non-finite values, got {vstats.get('n_nonfinite')}" + ) + # Verify value stats match published case study within rounding tolerance. + # The type is asserted first so a missing field fails as a missing field + # rather than as a TypeError inside abs(). + mean_val = vstats.get("mean") + assert isinstance(mean_val, (int, float)), ( + f"Expected a numeric mean in value_stats, got {mean_val!r}" + ) + assert abs(mean_val - 2.232) < 0.05, f"Expected mean ~2.232, got {mean_val}" + print("✓ Act III checks passed successfully!") + return meta + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--act", choices=["1", "2", "3"], help="Run specific act") + parser.add_argument( + "--all", action="store_true", help="Run Act I, Act II, and Act III" + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path(__file__).resolve().parent.parent + / "case-studies" + / "conus-precipitation-gdex", + help="Directory to save generated artifacts", + ) + args = parser.parse_args() + + args.output_dir.mkdir(parents=True, exist_ok=True) + + if args.act == "1": + run_act1() + elif args.act == "2": + run_act2(args.output_dir) + elif args.act == "3": + run_act3(args.output_dir) + elif args.all or not args.act: + run_act1() + run_act2(args.output_dir) + run_act3(args.output_dir) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_worker_payload_drift.py b/tests/test_worker_payload_drift.py index 4e83c0d..391450f 100644 --- a/tests/test_worker_payload_drift.py +++ b/tests/test_worker_payload_drift.py @@ -90,12 +90,35 @@ def _healpix_zoom_calls(fn: ast.FunctionDef) -> list[ast.Call]: ] -def _dispatch_branch_counts(source: str) -> tuple[int, int]: - """How many HEALPix and shapefile branches a function's source carries.""" - return ( - source.count('startswith("healpix:")'), - source.count('".shp", ".geojson"'), +def _dispatch_input_kinds(fn: ast.FunctionDef) -> tuple[bool, bool]: + """Whether a function's dispatch offers the HEALPix and shapefile kinds. + + Read from the syntax tree, not from the source text. An earlier version + counted the substrings ``startswith("healpix:")`` and ``".shp", + ".geojson"`` and asserted the two counts were equal, which tied the guard + to formatting rather than to behaviour: a dispatch whose extension list the + formatter wrapped over three lines read as zero shapefile branches, and a + nested ``if`` that names the HEALPix prefix twice read as two HEALPix + branches. Neither is drift. + + What the guard is for is that both kinds are offered *at all*, so that is + what is returned. How many times a function spells either one is its own + business. + """ + healpix = any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "startswith" + and len(node.args) == 1 + and isinstance(node.args[0], ast.Constant) + and node.args[0].value == "healpix:" + for node in ast.walk(fn) + ) + shapefile = any( + isinstance(node, ast.Constant) and node.value in (".shp", ".geojson") + for node in ast.walk(fn) ) + return healpix, shapefile def _assigned_literal(fn: ast.FunctionDef, name: str): @@ -171,12 +194,12 @@ class TestInlinedCopiesAgree: def test_the_zoom_is_extracted_the_same_way_everywhere(self): """One spelling of the zoom argument across every copy. - Twenty call sites, and a worker that read the zoom differently from - the other nineteen would answer the same request with a different + Twenty-one call sites, and a worker that read the zoom differently + from the other twenty would answer the same request with a different mesh. The count is asserted too: a copy that vanished is drift. """ blocks = _all_blocks(_healpix_zoom_calls, "the HEALPix zoom argument") - _assert_one_shape(blocks, "the HEALPix zoom argument", 20) + _assert_one_shape(blocks, "the HEALPix zoom argument", 21) def test_every_dispatch_offers_the_same_input_kinds(self): """A path the worker can open in one tool must open in all of them. @@ -185,20 +208,70 @@ def test_every_dispatch_offers_the_same_input_kinds(self): through geopandas, and everything ``ux.open_grid`` handles. Nothing made them travel together, so a tool added with only the HEALPix branch would reject a shapefile that every neighbouring tool accepts. + + A function that dispatches on neither is not in scope: the smoke and + probe payloads build their own grids and never take a path. """ offenders = { - name: counts - for name, obj in ( - (n, getattr(cf, n)) for n in sorted(dir(cf)) if n.startswith("remote_") - ) - if callable(obj) - and (counts := _dispatch_branch_counts(inspect.getsource(obj))) - and counts[0] != counts[1] + name: {"healpix": kinds[0], "shapefile": kinds[1]} + for name, fn in _remote_functions() + if (kinds := _dispatch_input_kinds(fn))[0] != kinds[1] } assert not offenders, ( - "these remote functions dispatch on HEALPix without an equal " - f"number of shapefile branches: {offenders}" + "these remote functions offer the HEALPix input kind without the " + f"shapefile kind, or the reverse: {offenders}" + ) + + def test_the_input_kind_guard_would_notice_a_missing_branch(self): + """The test above passing is only worth something if this one does. + + Its predecessor counted substrings, so it reported a wrapped + extension list as no shapefile branch at all and a nested ``if`` as + two HEALPix branches -- it failed on formatting and would equally + have passed on a real omission that happened to balance. Both cases + are pinned here: the payload that genuinely drops the shapefile read + is caught, and the two spellings of the extension test that the + formatter chooses between are read the same way. + """ + + def parse(src: str) -> ast.FunctionDef: + return ast.parse(textwrap.dedent(src)).body[0] + + dropped_the_shapefile_read = parse( + """ + def remote_example(grid_path): + if grid_path.lower().startswith("healpix:"): + grid = ux.Grid.from_healpix(int(grid_path.split(":")[1])) + else: + grid = ux.open_grid(grid_path) + """ + ) + assert _dispatch_input_kinds(dropped_the_shapefile_read) == (True, False) + + on_one_line = parse( + """ + def remote_example(grid_path): + if grid_path.lower().startswith("healpix:"): + grid = None + elif os.path.splitext(grid_path.lower())[1] in [".shp", ".geojson"]: + grid = None + """ + ) + wrapped_by_the_formatter = parse( + """ + def remote_example(grid_path): + if grid_path.lower().startswith("healpix:") or os.path.splitext( + grid_path.lower() + )[1] in [ + ".shp", + ".geojson", + ]: + if grid_path.lower().startswith("healpix:"): + grid = None + """ ) + assert _dispatch_input_kinds(on_one_line) == (True, True) + assert _dispatch_input_kinds(wrapped_by_the_formatter) == (True, True) def test_the_worker_runtime_envelope_reports_the_same_keys(self): """Presence was already guarded; shape was not. @@ -217,7 +290,7 @@ def test_the_worker_runtime_envelope_reports_the_same_keys(self): key: repr([ast.literal_eval(k) for k in node.keys]) for key, node in _each(_worker_runtime_dicts) } - _assert_one_shape(blocks, "the _worker_runtime key list", 20) + _assert_one_shape(blocks, "the _worker_runtime key list", 21) @pytest.mark.parametrize("constant", ["_LEVEL_EXACT", "_LEVEL_SUBSTR"]) def test_the_dimension_classification_is_one_policy(self, constant): @@ -226,7 +299,7 @@ def test_the_dimension_classification_is_one_policy(self, constant): for name, fn in _remote_functions() if (value := _assigned_literal(fn, constant)) is not None } - _assert_one_shape(blocks, f"the inlined {constant}", 7) + _assert_one_shape(blocks, f"the inlined {constant}", 8) class TestInlinedHelpersMatchTheirDomainTwin: From 315597bdd7ec7ef8d141952f1683cef43e62b48a Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Fri, 11 Sep 2026 22:06:39 -0500 Subject: [PATCH 9/9] Put the GDEX demo in the toctree and stop calling a fragment JSON The docs job builds with `-W`, so two warnings from the new demo page failed it. The page was never added to a toctree, and its `_provenance` excerpt was fenced as `json` while being neither a complete object nor valid JSON -- the `...` placeholders that make it readable are what the lexer choked on. Fenced as `text`, which is what it is. --- docs/demo-gdex-conus-precip.md | 2 +- docs/index.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/demo-gdex-conus-precip.md b/docs/demo-gdex-conus-precip.md index a568e2c..e0209df 100644 --- a/docs/demo-gdex-conus-precip.md +++ b/docs/demo-gdex-conus-precip.md @@ -281,7 +281,7 @@ cost several-fold. Every call returns `_provenance`. Show the raw block on screen: -```json +```text "_provenance": { "tool": "remote_temporal_mean_map", "inputs": {"args": ["", "[<10 data paths>]", "PRECT", ...]}, diff --git a/docs/index.rst b/docs/index.rst index aa48b9d..917cac5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,6 +29,7 @@ HTTP clients) from a single install. improv ucar chrysalis + demo-gdex-conus-precip .. toctree:: :maxdepth: 2