From 39e683f2e770e6b6fcd2d3837e1237dbdbb410e3 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Tue, 23 Jun 2026 12:35:07 -0500 Subject: [PATCH 1/6] Post-toolregistry follow-ups: uxarray 2026.6.0, new ops, science workflows Dependencies - Bump uxarray floor to >=2026.6.0; regenerate uv.lock (drops fastmcp, pulls toolregistry). New UXarray-backed operations (run_analysis) - zonal_anomaly: per-face deviation from each latitude band's zonal mean. - remap_to_rectilinear: remap an unstructured variable onto a regular lon/lat grid. - gradient/curl gain a scale_by_radius flag (default False preserves the unit-sphere result; True divides by uxgrid.sphere_radius). Guided science workflows (prompt/ tools) - cyclone_structure, eddy_activity, model_evaluation, climatology_anomaly, joining the existing vorticity_analysis workflow. Each composes existing operations around a scientific question and returns an interpretation plan. Docs - Fix stale architecture.html labels (toolregistry, Python 3.12). - CHANGELOG 0.2.0 + 0.1.1 backfill + Unreleased entries. - New docs/serving.md (profiles, transports, OpenAPI/REST, discovery). - tools.md operations table, science-workflow prompts, AGENTS.md counts. Policy tags - Add FILE_SYSTEM to session tools and FILE_SYSTEM+NETWORK to get_execution_mode; fix a stale FastMCP RuntimeError message. Tests - New test_new_uxarray_features.py; workflow-prompt and policy-tag tests; update deferred/prompt counts. Full suite passes (322). --- AGENTS.md | 7 +- CHANGELOG.md | 29 ++++ docs/architecture.html | 4 +- docs/index.rst | 1 + docs/serving.md | 93 +++++++++++ docs/tools.md | 60 +++++++- pyproject.toml | 2 +- src/uxarray_mcp/domain/__init__.py | 3 +- src/uxarray_mcp/domain/vector_calc.py | 24 ++- src/uxarray_mcp/domain/zonal.py | 89 +++++++++++ src/uxarray_mcp/registry.py | 214 +++++++++++++++++++++++++- src/uxarray_mcp/tools/__init__.py | 5 +- src/uxarray_mcp/tools/advanced.py | 121 +++++++++++++++ src/uxarray_mcp/tools/capabilities.py | 32 ++++ src/uxarray_mcp/tools/frontdoor.py | 46 +++++- src/uxarray_mcp/tools/inspection.py | 57 +++++++ src/uxarray_mcp/tools/vector_calc.py | 18 ++- tests/test_new_uxarray_features.py | 167 ++++++++++++++++++++ tests/test_server.py | 48 +++++- uv.lock | 18 +-- 20 files changed, 996 insertions(+), 42 deletions(-) create mode 100644 docs/serving.md create mode 100644 tests/test_new_uxarray_features.py diff --git a/AGENTS.md b/AGENTS.md index ccaa82c..78186c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,9 +25,9 @@ It supports local execution and optional remote execution on HPC clusters via The tool surface is built by `uxarray_mcp.registry.build_registry()`: -- **`core` (default, ~27 tools)** — 11 front-door gateway tools at the +- **`core` (default, ~31 tools)** — 11 front-door gateway tools at the top level, 12 control/status tools under `session/` and `hpc/` - namespaces, `io-list_datasets`, and 3 prompt helpers under `prompt/`. + namespaces, `io-list_datasets`, and 7 prompt helpers under `prompt/`. No deferred tools, no BM25 discovery. This is what clients see by default when running `uxarray-mcp serve`. @@ -54,7 +54,8 @@ HPC control (`hpc/`): `endpoint_status`, `get_execution_mode`, IO (`io/`): `list_datasets`. Prompt helpers (`prompt/`): `first_look`, `vorticity_analysis`, -`hpc_diagnose`. +`cyclone_structure`, `eddy_activity`, `model_evaluation`, +`climatology_anomaly`, `hpc_diagnose`. Low-level implementation functions such as `inspect_mesh`, `calculate_area`, `plot_mesh`, and `calculate_curl` remain importable from `uxarray_mcp.tools` diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d7477a..a77951a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ uses Semantic Versioning for public releases. ## Unreleased +### Added +- Guided science workflows as `prompt/` tools, each composing existing + operations around a scientific question: `cyclone_structure` (storm radial + structure), `eddy_activity` (departures from the zonal mean), + `model_evaluation` (bias/RMSE/pattern correlation vs a reference), and + `climatology_anomaly` (time-mean state and anomalies). These join the existing + `vorticity_analysis` workflow. +- `run_analysis` operation `zonal_anomaly` — per-face deviation from the zonal + mean of each latitude band (`UxDataArray.zonal_anomaly`). +- `run_analysis` operation `remap_to_rectilinear` — remap an unstructured + variable onto a regular lon/lat grid (`UxDataArray.remap.to_rectilinear`). +- `gradient` and `curl` operations now accept a `scale_by_radius` flag. It + defaults to `False` to preserve unit-sphere results; set it to `True` to + divide by `uxgrid.sphere_radius` for physical units. + +### Changed +- Bumped the `uxarray` floor to `>=2026.6.0` for the new zonal-anomaly, + rectilinear-remap, and radius-scaled gradient/curl APIs. + +## 0.2.0 — 2026-06-19 + ### Changed - **Server engine**: replaced FastMCP with [toolregistry](https://github.com/Oaklight/ToolRegistry) + @@ -32,6 +53,14 @@ uses Semantic Versioning for public releases. - `fastmcp` dependency. - `@mcp.prompt()` decorators (replaced by `prompt/` namespace tools). +## 0.1.1 — 2026-06-11 + +### Changed +- Pinned Python to `>=3.12,<3.13` to match the supported runtime and avoid + Globus Compute pickle version-mismatch failures. +- Aligned the published package metadata with the current PyPI release for the + conda-forge recipe. + ## 0.1.0 — 2026-06-04 Initial public release. diff --git a/docs/architecture.html b/docs/architecture.html index f048dab..64b5c11 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -104,7 +104,7 @@

UXarray MCP Server — Architecture Diagram

- feature/tool-discovery  ·  Python 3.13  ·  FastMCP + main  ·  Python 3.12  ·  toolregistry-server
@@ -173,7 +173,7 @@

UXarray MCP Server — Architecture Diagram

CLAUDE DESKTOP MCP Client MCP SERVER - FastMCP · Provenance · Dynamic Registration + toolregistry-server · Provenance · Policy Tags SCIENTIFIC AGENT Analyze → Plan → Execute → Verify LOCAL UXARRAY diff --git a/docs/index.rst b/docs/index.rst index acf515f..4e95c42 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,6 +11,7 @@ via Globus Compute. :caption: User Guide getting-started + serving remote-hpc operating-an-endpoint globus-compute diff --git a/docs/serving.md b/docs/serving.md new file mode 100644 index 0000000..9f29cc1 --- /dev/null +++ b/docs/serving.md @@ -0,0 +1,93 @@ +# Serving and Transports + +The server is built on +[toolregistry](https://github.com/Oaklight/ToolRegistry) + +[toolregistry-server](https://github.com/Oaklight/toolregistry-server). The same +tool implementations are exposed over multiple protocols from a single process, +so the server is not tied to any one client. Claude Desktop is the default +target, but the tools are equally usable over REST or from plain Python. + +For the Claude Desktop quickstart, see {doc}`getting-started`. This page covers +the additional surfaces the engine provides. + +## The `serve` command + +```bash +uxarray-mcp serve [--profile {core,deferred-full}] + [--transport {stdio,sse,http}] + [--host HOST] [--port PORT] +``` + +With no flags, `uxarray-mcp serve` starts MCP over stdio with the `core` +profile — the same behavior earlier releases had. Existing Claude Desktop +configurations work unchanged. + +## Profiles + +The visible tool surface is selected by `--profile`: + +| Profile | Visible tools | Use when | +|---|---|---| +| `core` (default) | ~27 | Most clients. Front-door gateway tools, session/HPC control, `list_datasets`, and prompts. Predictable, conservative. | +| `deferred-full` | ~28 visible (+30 deferred) | You want the full low-level surface. The 30 raw implementation tools load with `defer=True`, so they do not appear in the initial list; agents find them with `discover_tools`. | + +```bash +uxarray-mcp serve --profile deferred-full +``` + +In `core`, low-level operations are still reachable through the front-door +dispatchers (for example `run_analysis(operation="curl", ...)`). + +## Transports + +`--transport` selects the MCP transport: + +| Transport | Description | +|---|---| +| `stdio` (default) | Subprocess transport used by Claude Desktop and Claude Code. | +| `sse` | Server-Sent Events over HTTP. Bind with `--host`/`--port`. | +| `http` | Streamable HTTP. Bind with `--host`/`--port`. | + +```bash +uxarray-mcp serve --transport sse --host 127.0.0.1 --port 8001 +uxarray-mcp serve --transport http --port 8000 +``` + +## OpenAPI / REST + +The same tools can be exposed as an OpenAPI/REST service for clients that speak +HTTP rather than MCP — curl, cloud assistants, chat UIs, or scripts. Install the +optional extra: + +```bash +pip install "uxarray-mcp[openapi]" +``` + +The tool implementations, provenance, and HPC dispatch are shared across MCP and +REST; only the protocol adapter differs. + +## Tool discovery (`deferred-full`) + +In the `deferred-full` profile, deferred tools are searchable via +`discover_tools`, which ranks tools by a BM25 match over names, docstrings, and +domain search hints. For example, a query like `"compute vorticity wind curl"` +surfaces `calculate_curl`. Operators can also promote deferred tools to the +visible set from the admin panel. + +## Using the tools without an MCP client + +Because the tools are ordinary Python functions, you can drive them directly — +no AI client, no transport: + +```python +from uxarray_mcp.tools import inspect_mesh +result = inspect_mesh(file_path="grid.nc") # dict with a _provenance block + +# Or call by name through the same registry the server uses: +from uxarray_mcp.server import make_registry +reg = make_registry(profile="core") +run_analysis = reg.get_callable("run_analysis") +stats = run_analysis(operation="calculate_area", grid_path="grid.nc") +``` + +This is the basis for the REST surface and for pipeline/post-processing use. diff --git a/docs/tools.md b/docs/tools.md index c637000..02f99d2 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -7,6 +7,11 @@ clients should use the intent-shaped tools below. Most tools return structured dictionaries with a `_provenance` block. Plotting returns MCP content blocks: an inline PNG plus JSON metadata. +The visible tool set depends on the profile (`core` by default, or +`deferred-full`), and the server can expose these tools over MCP stdio/SSE/HTTP +or OpenAPI/REST. See {doc}`serving` for profiles, transports, and tool +discovery. + ## Front-Door Tools ### `get_capabilities` @@ -43,10 +48,12 @@ Supported operations: | `validate_dataset` | NaN/Inf/fill-value checks | | `calculate_area` | Face area statistics | | `calculate_zonal_mean` | Latitude-band mean for a face-centered variable | +| `zonal_anomaly` | Per-face deviation from its latitude-band zonal mean | | `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_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 | | `export` | Write a persisted result or dataset to NetCDF/CSV | @@ -57,11 +64,34 @@ Common parameters include `grid_path`, `data_path`, `variable_name`, `endpoint`. Each operation validates the parameters it requires and returns a clear error if one is missing. +`gradient` and `curl` accept `scale_by_radius` (default `False`). When `False`, +results stay on the unit sphere (the historical behavior). Set it to `True` to +divide by `uxgrid.sphere_radius` for physical units; the grid must define +`sphere_radius`. + +`zonal_anomaly` and `remap_to_rectilinear` are backed by +`UxDataArray.zonal_anomaly` and `UxDataArray.remap.to_rectilinear`, available in +the pinned UXarray (`>=2026.6.0`). + Examples: ```python run_analysis(operation="inspect_mesh", grid_path="healpix:4") run_analysis(operation="calculate_area", grid_path="/path/grid.nc") +run_analysis( + operation="zonal_anomaly", + grid_path="/path/grid.nc", + data_path="/path/data.nc", + variable_name="temperature", +) +run_analysis( + operation="remap_to_rectilinear", + grid_path="/path/grid.nc", + data_path="/path/data.nc", + variable_name="temperature", + target_lon=[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330], + target_lat=[-60, -30, 0, 30, 60], +) run_analysis( operation="calculate_zonal_mean", grid_path="/path/grid.nc", @@ -134,13 +164,29 @@ dispatcher either falls back locally or reports a structured readiness error. ## MCP Prompts -Prompts are user-invokable slash commands. In Claude Code or Claude Desktop -they appear as `/first_look`, `/vorticity_analysis`, and `/hpc_diagnose`. +Prompts are user-invokable slash commands that return a guided, multi-step +analysis plan (instruction text, not results) — the assistant then runs the +chained operations and interprets them. In Claude Code or Claude Desktop they +appear as `/first_look`, `/vorticity_analysis`, etc. + +General: - `/first_look path` calls `get_capabilities` and `analyze_dataset`. -- `/vorticity_analysis grid_path data_path u_var v_var` calls - `run_analysis(operation="curl")` and - `run_analysis(operation="divergence")`. -- `/hpc_diagnose [endpoint]` calls - `diagnose_endpoint(action="status")` and +- `/hpc_diagnose [endpoint]` calls `diagnose_endpoint(action="status")` and `diagnose_endpoint(action="validate")`. + +Science workflows (each composes existing `run_analysis` operations around a +scientific question): + +- `/vorticity_analysis grid_path data_path u_var v_var` — rotation and + divergence of a wind field (`curl` + `divergence`). +- `/cyclone_structure grid_path data_path variable_name center_lon center_lat [u_var v_var outer_radius]` + — radial structure of a storm/vortex (`azimuthal_mean` + `subset_bbox`, + optionally `curl`). +- `/eddy_activity grid_path data_path variable_name` — departures from the + latitudinal background state (`calculate_zonal_mean` + `zonal_anomaly` + + `gradient`). +- `/model_evaluation grid_path data_path_a data_path_b variable_name` — verify a + field against a reference (`bias` + `rmse` + `pattern_correlation`). +- `/climatology_anomaly data_path variable_name [grid_path]` — time-mean state + and departures (`temporal_mean` + `anomaly`, optionally `calculate_zonal_mean`). diff --git a/pyproject.toml b/pyproject.toml index 156c240..5873c66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "holoviews>=1.19.0", "matplotlib>=3.9.0", "pyyaml>=6.0", - "uxarray>=2025.12.0", + "uxarray>=2026.6.0", ] [project.optional-dependencies] diff --git a/src/uxarray_mcp/domain/__init__.py b/src/uxarray_mcp/domain/__init__.py index d3c3b29..c529deb 100644 --- a/src/uxarray_mcp/domain/__init__.py +++ b/src/uxarray_mcp/domain/__init__.py @@ -13,7 +13,7 @@ compute_divergence, compute_gradient, ) -from .zonal import compute_zonal_mean_stats +from .zonal import compute_zonal_anomaly_stats, compute_zonal_mean_stats __all__ = [ "load_grid", @@ -21,6 +21,7 @@ "compute_area_stats", "compute_variable_info", "compute_zonal_mean_stats", + "compute_zonal_anomaly_stats", "compute_gradient", "compute_curl", "compute_divergence", diff --git a/src/uxarray_mcp/domain/vector_calc.py b/src/uxarray_mcp/domain/vector_calc.py index 8e74301..a9471e7 100644 --- a/src/uxarray_mcp/domain/vector_calc.py +++ b/src/uxarray_mcp/domain/vector_calc.py @@ -5,7 +5,9 @@ from typing import Any -def compute_gradient(uxds: Any, variable_name: str) -> dict: +def compute_gradient( + uxds: Any, variable_name: str, scale_by_radius: bool = False +) -> dict: """Compute the gradient of a face-centered scalar field. Uses UXarray's Green-Gauss finite-volume gradient, which forms a closed @@ -18,6 +20,10 @@ def compute_gradient(uxds: Any, variable_name: str) -> dict: Loaded UXarray dataset. variable_name : str Face-centered scalar variable to differentiate. + scale_by_radius : bool, default False + When ``True``, divide the unit-sphere derivatives by + ``uxgrid.sphere_radius`` to return physical units (requires a grid with + ``sphere_radius``). The default ``False`` keeps the unit-sphere result. Returns ------- @@ -38,8 +44,8 @@ def compute_gradient(uxds: Any, variable_name: str) -> dict: import numpy as np - grad = var.gradient() - # gradient() returns a UxDataset with two variables + # gradient() returns a UxDataset with zonal and meridional components. + grad = var.gradient(scale_by_radius=scale_by_radius) comp_names = list(grad.data_vars) def _stats(arr: Any) -> dict: @@ -60,11 +66,14 @@ def _stats(arr: Any) -> dict: "components": comp_names, "component_stats": components, "n_face": int(uxds.uxgrid.n_face), + "scale_by_radius": bool(scale_by_radius), "interpretation": "zonal (∂/∂x) and meridional (∂/∂y) components of the gradient", } -def compute_curl(uxds: Any, u_variable: str, v_variable: str) -> dict: +def compute_curl( + uxds: Any, u_variable: str, v_variable: str, scale_by_radius: bool = False +) -> dict: """Compute the curl (relative vorticity) of a 2-D vector field (u, v). The curl is the vertical component of ∇ × (u, v): @@ -80,6 +89,10 @@ def compute_curl(uxds: Any, u_variable: str, v_variable: str) -> dict: Zonal (east–west) component variable name. v_variable : str Meridional (north–south) component variable name. + scale_by_radius : bool, default False + When ``True``, divide the unit-sphere result by ``uxgrid.sphere_radius`` + to return physical units (``1/s`` for wind in ``m/s``; requires a grid + with ``sphere_radius``). The default ``False`` keeps the unit sphere. Returns ------- @@ -102,7 +115,7 @@ def compute_curl(uxds: Any, u_variable: str, v_variable: str) -> dict: import numpy as np - result = u.curl(v) + result = u.curl(v, scale_by_radius=scale_by_radius) vals = result.values finite = vals[np.isfinite(vals)] @@ -122,6 +135,7 @@ def compute_curl(uxds: Any, u_variable: str, v_variable: str) -> dict: "v_variable": v_variable, "interpretation": "relative vorticity ζ = ∂v/∂x − ∂u/∂y", "n_face": int(uxds.uxgrid.n_face), + "scale_by_radius": bool(scale_by_radius), "stats": stats, } diff --git a/src/uxarray_mcp/domain/zonal.py b/src/uxarray_mcp/domain/zonal.py index ee7c546..ee46f8d 100644 --- a/src/uxarray_mcp/domain/zonal.py +++ b/src/uxarray_mcp/domain/zonal.py @@ -64,3 +64,92 @@ def compute_zonal_mean_stats( "conservative": conservative, "grid_info": grid_info, } + + +def compute_zonal_anomaly_stats( + uxds: Any, + variable_name: str, + lat_spec: Optional[tuple | float | list] = None, + conservative: bool = False, +) -> dict: + """Compute zonal-anomaly statistics from a loaded UXarray dataset. + + The zonal anomaly is each face value minus the zonal mean of its latitude + band, producing a per-face field with the same shape as the input variable. + + Parameters + ---------- + uxds : ux.UxDataset + Loaded UXarray dataset. + variable_name : str + Name of the face-centered variable. + lat_spec : tuple | float | list | None + Latitude band specification passed through to ``zonal_anomaly``. A + ``(start, end, step)`` tuple or explicit band edges. ``None`` uses the + UXarray default ``(-90, 90, 10)``. + conservative : bool + If True, use area-weighted band means. + + Returns + ------- + dict + Keys: variable_name, conservative, n_face, stats (min/max/mean/std of + the anomaly field), grid_info. + + Raises + ------ + NotImplementedError + If the installed UXarray does not provide ``UxDataArray.zonal_anomaly``. + """ + import numpy as np + + if variable_name not in uxds.data_vars: + available = list(uxds.data_vars.keys()) + raise ValueError( + f"Variable '{variable_name}' not found. Available variables: {available}" + ) + + var = uxds[variable_name] + + if "n_face" not in var.dims and "nCells" not in var.dims: + raise ValueError( + f"Variable '{variable_name}' is not face-centered. " + "Zonal anomaly only supports face-centered data." + ) + + if not hasattr(var, "zonal_anomaly"): + raise NotImplementedError( + "zonal_anomaly requires a UXarray release that provides " + "UxDataArray.zonal_anomaly. Upgrade uxarray to use this operation." + ) + + if lat_spec is not None: + result = var.zonal_anomaly(lat=lat_spec, conservative=conservative) + else: + result = var.zonal_anomaly(conservative=conservative) + + vals = result.values + finite = vals[np.isfinite(vals)] + stats: dict[str, float | None] + if finite.size > 0: + stats = { + "min": float(finite.min()), + "max": float(finite.max()), + "mean": float(finite.mean()), + "std": float(finite.std()), + } + else: + stats = {"min": None, "max": None, "mean": None, "std": None} + + return { + "variable_name": variable_name, + "conservative": conservative, + "n_face": int(uxds.uxgrid.n_face), + "stats": stats, + "interpretation": "per-face deviation from the zonal mean of its latitude band", + "grid_info": { + "n_face": int(uxds.uxgrid.n_face), + "n_node": int(uxds.uxgrid.n_node), + "n_edge": int(uxds.uxgrid.n_edge), + }, + } diff --git a/src/uxarray_mcp/registry.py b/src/uxarray_mcp/registry.py index 97dbd0b..a63c1c2 100644 --- a/src/uxarray_mcp/registry.py +++ b/src/uxarray_mcp/registry.py @@ -102,6 +102,7 @@ "calculate_ensemble_spread", "calculate_area", "calculate_zonal_mean", + "calculate_zonal_anomaly", ), "shape": ( "subset_bbox", @@ -109,6 +110,7 @@ "extract_cross_section", "remap_variable", "regrid_dataset", + "remap_to_rectilinear", ), "inspect": ( "inspect_mesh", @@ -189,6 +191,196 @@ def vorticity_analysis(grid_path: str, data_path: str, u_var: str, v_var: str) - ) +def cyclone_structure( + grid_path: str, + data_path: str, + variable_name: str, + center_lon: float, + center_lat: float, + u_var: str = "", + v_var: str = "", + outer_radius: float = 10.0, +) -> str: + """Generate a guided plan to characterise a cyclone / vortex structure. + + Builds a radial picture of a storm or vortex around a centre point using the + azimuthal (radial) mean, optionally adding the rotational field. Returns + instructional text — the LLM runs the operations and interprets them. + + Args: + grid_path: Path to the mesh grid file. + data_path: Path to the data file. + variable_name: Field to profile radially (e.g. wind speed, pressure). + center_lon: Longitude of the storm centre (degrees). + center_lat: Latitude of the storm centre (degrees). + u_var: Optional zonal wind component for a vorticity check. + v_var: Optional meridional wind component for a vorticity check. + outer_radius: Maximum radius in great-circle degrees. + + Returns: + Multi-step cyclone-structure analysis plan as a string. + """ + steps = [ + "1. Call `run_analysis` with " + f'operation="azimuthal_mean", grid_path="{grid_path}", ' + f'data_path="{data_path}", variable_name="{variable_name}", ' + f"center_lon={center_lon}, center_lat={center_lat}, " + f"outer_radius={outer_radius}, radius_step=0.5 to build the radial " + "profile.", + "2. Call `run_analysis` with " + f'operation="subset_bbox", grid_path="{grid_path}", ' + f'data_path="{data_path}", variable_name="{variable_name}", ' + f"lon_bounds=[{center_lon - outer_radius}, {center_lon + outer_radius}], " + f"lat_bounds=[{center_lat - outer_radius}, {center_lat + outer_radius}] " + "to isolate the storm region.", + ] + if u_var and v_var: + steps.append( + "3. Call `run_analysis` with " + f'operation="curl", grid_path="{grid_path}", data_path="{data_path}", ' + f'u_variable="{u_var}", v_variable="{v_var}" to confirm the ' + "rotational signature (relative vorticity)." + ) + steps.append( + f"{len(steps) + 1}. Interpret the radial profile: locate the radius of " + "maximum wind / minimum pressure, the storm's radial extent, and any " + 'asymmetry. Plot the subset with `plot_dataset(plot_type="variable")`.' + ) + return ( + f"Characterise the cyclone/vortex near ({center_lon}, {center_lat}) in " + f"`{data_path}`.\n\n" + "\n".join(steps) + ) + + +def eddy_activity( + grid_path: str, + data_path: str, + variable_name: str, +) -> str: + """Generate a guided plan to assess eddy / wave activity. + + Quantifies departures from the latitudinal background state — the signature + of eddies, stationary waves, and storm tracks — using the zonal anomaly and + its gradient. Returns instructional text. + + Args: + grid_path: Path to the mesh grid file. + data_path: Path to the data file. + variable_name: Face-centered field to analyse (e.g. geopotential height, + temperature). + + Returns: + Multi-step eddy-activity analysis plan as a string. + """ + return ( + f"Assess eddy/wave activity for `{variable_name}` in `{data_path}`.\n\n" + "1. Call `run_analysis` with " + f'operation="calculate_zonal_mean", grid_path="{grid_path}", ' + f'data_path="{data_path}", variable_name="{variable_name}" to establish ' + "the latitudinal background state.\n" + "2. Call `run_analysis` with " + f'operation="zonal_anomaly", grid_path="{grid_path}", ' + f'data_path="{data_path}", variable_name="{variable_name}" to isolate ' + "departures from each latitude band (the eddy field).\n" + "3. Call `run_analysis` with " + f'operation="gradient", grid_path="{grid_path}", data_path="{data_path}", ' + f'variable_name="{variable_name}" to highlight sharp gradients and ' + "fronts associated with the waves.\n" + "4. Interpret the anomaly amplitude (std/max) as eddy strength, note " + "where activity concentrates, and plot the anomaly field with " + '`plot_dataset(plot_type="variable")`.' + ) + + +def model_evaluation( + grid_path: str, + data_path_a: str, + data_path_b: str, + variable_name: str, +) -> str: + """Generate a guided plan to evaluate a model field against a reference. + + Computes the standard verification triple — bias, RMSE, and pattern + correlation — between two same-grid fields and guides interpretation. + Returns instructional text. + + Args: + grid_path: Path to the shared mesh grid file. + data_path_a: Model / candidate dataset. + data_path_b: Reference / observation dataset. + variable_name: Field to compare. + + Returns: + Multi-step model-evaluation plan as a string. + """ + return ( + f"Evaluate `{variable_name}` in `{data_path_a}` against reference " + f"`{data_path_b}`.\n\n" + "1. Call `run_analysis` with " + f'operation="bias", grid_path="{grid_path}", ' + f'data_path_a="{data_path_a}", data_path_b="{data_path_b}", ' + f'variable_name="{variable_name}" for the mean signed error.\n' + "2. Call `run_analysis` with " + f'operation="rmse", grid_path="{grid_path}", ' + f'data_path_a="{data_path_a}", data_path_b="{data_path_b}", ' + f'variable_name="{variable_name}" for the magnitude of the error.\n' + "3. Call `run_analysis` with " + f'operation="pattern_correlation", grid_path="{grid_path}", ' + f'data_path_a="{data_path_a}", data_path_b="{data_path_b}", ' + f'variable_name="{variable_name}" for spatial-pattern skill.\n' + "4. Interpret together: bias = systematic offset, RMSE = typical error " + "size, pattern correlation = structural agreement (1.0 = perfect). Call " + "out whether errors are a uniform offset or a structural mismatch." + ) + + +def climatology_anomaly( + data_path: str, + variable_name: str, + grid_path: str = "", +) -> str: + """Generate a guided plan for a climatology and anomaly analysis. + + Establishes the time-mean state and the departures from it over a time + series, then summarises the anomaly latitudinally. Returns instructional + text. + + Args: + data_path: Path to a time-series data file. + variable_name: Field to analyse. + grid_path: Optional mesh grid file (needed for the zonal summary). + + Returns: + Multi-step climatology/anomaly plan as a string. + """ + grid_arg = f'grid_path="{grid_path}", ' if grid_path else "" + last = ( + "4. Call `run_analysis` with " + f'operation="calculate_zonal_mean", grid_path="{grid_path}", ' + f'data_path="{data_path}", variable_name="{variable_name}" to summarise ' + "the anomaly by latitude.\n" + if grid_path + else "" + ) + return ( + f"Build a climatology and anomalies for `{variable_name}` in " + f"`{data_path}`.\n\n" + "1. Call `run_analysis` with " + f'operation="temporal_mean", data_path="{data_path}", ' + f'variable_name="{variable_name}" to compute the time-mean climatology.\n' + "2. Call `run_analysis` with " + f'operation="anomaly", {grid_arg}data_path="{data_path}", ' + f'variable_name="{variable_name}" to compute departures from the mean ' + "state.\n" + f'3. Plot the anomaly with `plot_dataset(plot_type="variable")`' + + (".\n" + last if last else " and interpret the spatial structure.\n") + + ( + f"{5 if grid_arg and last else 4}. Interpret where and when the " + "field departs most from its climatology." + ) + ) + + def hpc_diagnose(endpoint: str = "") -> str: """Generate a step-by-step prompt for HPC endpoint diagnosis. @@ -214,7 +406,15 @@ def hpc_diagnose(endpoint: str = "") -> str: _PROMPT_TOOLS: dict[str, tuple[str, ...]] = { - "prompt": ("first_look", "vorticity_analysis", "hpc_diagnose"), + "prompt": ( + "first_look", + "vorticity_analysis", + "cyclone_structure", + "eddy_activity", + "model_evaluation", + "climatology_anomaly", + "hpc_diagnose", + ), } # Map prompt tool names to their implementing functions (defined above @@ -222,6 +422,10 @@ def hpc_diagnose(endpoint: str = "") -> str: _PROMPT_FUNCS: dict[str, object] = { "first_look": first_look, "vorticity_analysis": vorticity_analysis, + "cyclone_structure": cyclone_structure, + "eddy_activity": eddy_activity, + "model_evaluation": model_evaluation, + "climatology_anomaly": climatology_anomaly, "hpc_diagnose": hpc_diagnose, } @@ -261,6 +465,10 @@ def hpc_diagnose(endpoint: str = "") -> str: # Prompt tools are always read-only (they just return text) "first_look": ({ToolTag.READ_ONLY}, set()), "vorticity_analysis": ({ToolTag.READ_ONLY}, set()), + "cyclone_structure": ({ToolTag.READ_ONLY}, set()), + "eddy_activity": ({ToolTag.READ_ONLY}, set()), + "model_evaluation": ({ToolTag.READ_ONLY}, set()), + "climatology_anomaly": ({ToolTag.READ_ONLY}, set()), "hpc_diagnose": ({ToolTag.READ_ONLY}, set()), } @@ -271,6 +479,7 @@ def hpc_diagnose(endpoint: str = "") -> str: "calculate_gradient", "calculate_azimuthal_mean", "calculate_zonal_mean", + "calculate_zonal_anomaly", "calculate_temporal_mean", "calculate_anomaly", "calculate_ensemble_mean", @@ -281,6 +490,7 @@ def hpc_diagnose(endpoint: str = "") -> str: "calculate_pattern_correlation", "remap_variable", "regrid_dataset", + "remap_to_rectilinear", "subset_polygon", "extract_cross_section", "plot_mesh", @@ -339,6 +549,7 @@ def _apply_tags( "calculate_gradient": "spatial derivative slope field gradient", "calculate_azimuthal_mean": "radial profile cyclone storm azimuthal", "calculate_zonal_mean": "latitudinal average belt zonal", + "calculate_zonal_anomaly": "zonal anomaly deviation latitude band eddy wave departure", "calculate_temporal_mean": "time average climatology", "calculate_anomaly": "deviation departure climatology", "calculate_ensemble_mean": "model average multi-member", @@ -353,6 +564,7 @@ def _apply_tags( "extract_cross_section": "transect slice latitude longitude", "remap_variable": "interpolation target grid", "regrid_dataset": "interpolation target grid all variables", + "remap_to_rectilinear": "remap rectilinear regular lon lat structured grid interpolation", "inspect_mesh": "topology nodes faces edges grid summary", "inspect_variable": "data variable metadata stats", "validate_dataset": "data quality NaN Inf fill check", diff --git a/src/uxarray_mcp/tools/__init__.py b/src/uxarray_mcp/tools/__init__.py index 8e083ae..bd750da 100644 --- a/src/uxarray_mcp/tools/__init__.py +++ b/src/uxarray_mcp/tools/__init__.py @@ -11,6 +11,7 @@ export_to_netcdf, extract_cross_section, regrid_dataset, + remap_to_rectilinear, remap_variable, subset_bbox, subset_polygon, @@ -33,7 +34,7 @@ plot_dataset, run_analysis, ) -from .inspection import validate_dataset +from .inspection import calculate_zonal_anomaly, validate_dataset from .orchestration import analyze_dataset # Public tool surface for inspection and plotting. Each function is a @@ -90,6 +91,7 @@ "inspect_variable", "calculate_area", "calculate_zonal_mean", + "calculate_zonal_anomaly", "validate_dataset", "plot_mesh", "plot_mesh_geo", @@ -104,6 +106,7 @@ "calculate_pattern_correlation", "remap_variable", "regrid_dataset", + "remap_to_rectilinear", "calculate_temporal_mean", "calculate_anomaly", "calculate_ensemble_mean", diff --git a/src/uxarray_mcp/tools/advanced.py b/src/uxarray_mcp/tools/advanced.py index dd0e02d..90a3c1b 100644 --- a/src/uxarray_mcp/tools/advanced.py +++ b/src/uxarray_mcp/tools/advanced.py @@ -760,6 +760,127 @@ def regrid_dataset( return result +def remap_to_rectilinear( + variable_name: str, + target_lon: Sequence[float], + target_lat: Sequence[float], + grid_path: str | None = None, + data_path: str | None = None, + backend: str = "uxarray", + session_id: str | None = None, + dataset_handle: str | None = None, + result_name: str | None = None, +) -> dict[str, Any]: + """Remap a face-centered variable onto a regular lon/lat (rectilinear) grid. + + Uses ``UxDataArray.remap.to_rectilinear`` to interpolate unstructured data + onto 1-D longitude/latitude coordinate arrays, producing a structured grid + suitable for downstream lon/lat workflows. + + Parameters + ---------- + variable_name : str + Face-centered variable to remap. + target_lon, target_lat : sequence of float + 1-D longitude and latitude cell-center coordinates in degrees. + grid_path, data_path : str, optional + Source grid and data files (or resolve from session/dataset handle). + backend : str + Remapping backend: ``"uxarray"`` (default) or ``"yac"``. + session_id, dataset_handle, result_name : optional + Session/result-handle plumbing. + + Returns + ------- + dict + Keys: ``variable_name``, ``backend``, ``target_shape`` (n_lat, n_lon), + ``stats`` (min/max/mean of the remapped field), ``result_handle``, and + ``_provenance``. + + Raises + ------ + NotImplementedError + If the installed UXarray lacks ``remap.to_rectilinear``. + """ + tracker = OperationTracker("remap_to_rectilinear", session_id=session_id) + resolved_grid, resolved_data = _resolve_paths( + session_id=session_id, + dataset_handle=dataset_handle, + grid_path=grid_path, + data_path=data_path, + ) + if resolved_data is None: + raise ValueError("data_path is required for remapping.") + + _, uxda, selected = _load_dataarray(resolved_grid, resolved_data, variable_name) + + if not hasattr(uxda.remap, "to_rectilinear"): + raise NotImplementedError( + "remap_to_rectilinear requires a UXarray release that provides " + "remap.to_rectilinear. Upgrade uxarray to use this operation." + ) + + lon = list(target_lon) + lat = list(target_lat) + tracker.stage("remapping", f"Remapping {selected} to {len(lat)}x{len(lon)} grid.") + remapped = uxda.remap.to_rectilinear(lon, lat, backend=backend) + + # remapped is a plain xarray.DataArray with lat/lon axes. + remapped_ds = remapped.to_dataset(name=selected) + result_handle = _persist_dataset_result( + dataset=remapped_ds, + session_id=session_id, + name=result_name or f"rectilinear:{selected}", + kind="rectilinear_variable", + summary=summarize_dataset(remapped_ds), + metadata={ + "source_grid": resolved_grid, + "variable_name": selected, + "backend": backend, + "n_lon": len(lon), + "n_lat": len(lat), + }, + ) + + vals = np.asarray(remapped.values, dtype=float) + finite = vals[np.isfinite(vals)] + stats = ( + { + "min": float(finite.min()), + "max": float(finite.max()), + "mean": float(finite.mean()), + } + if finite.size > 0 + else {"min": None, "max": None, "mean": None} + ) + + tracker.succeed("Rectilinear remap complete.") + result: dict[str, Any] = { + "variable_name": selected, + "backend": backend, + "target_shape": [len(lat), len(lon)], + "stats": stats, + "result_handle": result_handle, + } + result = attach_provenance( + result, + tool="remap_to_rectilinear", + inputs={ + "variable_name": variable_name, + "target_lon": lon, + "target_lat": lat, + "grid_path": grid_path, + "data_path": data_path, + "backend": backend, + "session_id": session_id, + "dataset_handle": dataset_handle, + }, + selected_variable=selected, + ) + result["_provenance"]["operation_id"] = tracker.operation_id + return result + + def calculate_temporal_mean( data_path: str, variable_name: str, diff --git a/src/uxarray_mcp/tools/capabilities.py b/src/uxarray_mcp/tools/capabilities.py index d942177..0d397e5 100644 --- a/src/uxarray_mcp/tools/capabilities.py +++ b/src/uxarray_mcp/tools/capabilities.py @@ -10,6 +10,30 @@ from uxarray_mcp.remote.config import load_config +def _uxarray_supports(attr_path: str) -> bool: + """Return True if ``UxDataArray`` provides the given (possibly dotted) attr. + + Used to advertise capability-gated operations only when the installed + UXarray actually ships the underlying method (e.g. ``zonal_anomaly`` or + ``remap.to_rectilinear``). + """ + obj: Any = ux.UxDataArray + for part in attr_path.split("."): + if part == "remap": + # The remap accessor class holds the remap methods. + try: + from uxarray.remap.accessor import RemapAccessor + + obj = RemapAccessor + except Exception: + return False + continue + if not hasattr(obj, part): + return False + obj = getattr(obj, part) + return True + + def get_capabilities( grid_path: str, data_path: Optional[str] = None, @@ -154,6 +178,14 @@ def get_capabilities( "run_analysis:ensemble_spread", "run_analysis:export", ] + # Capability-gated operations: only advertise when the installed + # UXarray actually provides the underlying method. + if _uxarray_supports("zonal_anomaly"): + applicable_mcp.append("run_analysis:zonal_anomaly") + applicable_uxarray.append("var.zonal_anomaly()") + if _uxarray_supports("remap.to_rectilinear"): + applicable_mcp.append("run_analysis:remap_to_rectilinear") + applicable_uxarray.append("var.remap.to_rectilinear(lon, lat)") applicable_uxarray += [ "var.zonal_mean()", "var.integrate()", diff --git a/src/uxarray_mcp/tools/frontdoor.py b/src/uxarray_mcp/tools/frontdoor.py index d771f2a..7626f3d 100644 --- a/src/uxarray_mcp/tools/frontdoor.py +++ b/src/uxarray_mcp/tools/frontdoor.py @@ -46,6 +46,11 @@ def run_analysis( session_id: str | None = None, dataset_handle: str | None = None, result_name: str | None = None, + scale_by_radius: bool = False, + lat_spec: Any = None, + conservative: bool = False, + target_lon: list[float] | None = None, + target_lat: list[float] | None = None, use_remote: bool = False, endpoint: str | None = None, ) -> dict[str, Any]: @@ -53,12 +58,17 @@ def run_analysis( Supported operations: ``inspect_mesh``, ``inspect_variable``, ``validate_dataset``, - ``calculate_area``, ``calculate_zonal_mean``, ``gradient``, ``curl``, - ``divergence``, ``azimuthal_mean``, ``subset_bbox``, ``subset_polygon``, - ``cross_section``, ``compare_fields``, ``bias``, ``rmse``, - ``pattern_correlation``, ``remap_variable``, ``regrid_dataset``, - ``temporal_mean``, ``anomaly``, ``ensemble_mean``, ``ensemble_spread``, - and ``export``. + ``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 False keeps the + historical unit-sphere result). ``zonal_anomaly`` accepts ``lat_spec`` and + ``conservative``. ``remap_to_rectilinear`` accepts ``target_lon`` and + ``target_lat`` (1-D coordinate arrays). """ from uxarray_mcp.tools.advanced import ( calculate_anomaly, @@ -71,12 +81,13 @@ def run_analysis( compare_fields, extract_cross_section, regrid_dataset, + remap_to_rectilinear, remap_variable, subset_bbox, subset_polygon, write_result, ) - from uxarray_mcp.tools.inspection import validate_dataset + from uxarray_mcp.tools.inspection import calculate_zonal_anomaly, validate_dataset from uxarray_mcp.tools.remote_tools import ( calculate_area, calculate_zonal_mean, @@ -129,11 +140,20 @@ def run_analysis( endpoint=endpoint, session_id=session_id, ) + if op == "zonal_anomaly": + return calculate_zonal_anomaly( + _require(grid_path, "grid_path", op), + _require(data_path, "data_path", op), + _require(variable_name, "variable_name", op), + lat_spec=lat_spec, + conservative=conservative, + ) if op == "gradient": return calculate_gradient( _require(grid_path, "grid_path", op), _require(data_path, "data_path", op), _require(variable_name, "variable_name", op), + scale_by_radius=scale_by_radius, use_remote=use_remote, endpoint=endpoint, session_id=session_id, @@ -144,6 +164,7 @@ def run_analysis( _require(data_path, "data_path", op), _require(u_variable, "u_variable", op), _require(v_variable, "v_variable", op), + scale_by_radius=scale_by_radius, use_remote=use_remote, endpoint=endpoint, session_id=session_id, @@ -257,6 +278,17 @@ def run_analysis( dataset_handle=dataset_handle, result_name=result_name, ) + if op == "remap_to_rectilinear": + 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, + session_id=session_id, + dataset_handle=dataset_handle, + result_name=result_name, + ) if op == "temporal_mean": return calculate_temporal_mean( data_path=_require(data_path, "data_path", op), diff --git a/src/uxarray_mcp/tools/inspection.py b/src/uxarray_mcp/tools/inspection.py index a4d2fe1..8686900 100644 --- a/src/uxarray_mcp/tools/inspection.py +++ b/src/uxarray_mcp/tools/inspection.py @@ -10,6 +10,7 @@ from uxarray_mcp.domain import ( compute_area_stats, compute_variable_info, + compute_zonal_anomaly_stats, compute_zonal_mean_stats, load_dataset, load_grid, @@ -321,6 +322,62 @@ def _calculate_zonal_mean_local( ) +def calculate_zonal_anomaly( + grid_path: str, + data_path: str, + variable_name: str, + lat_spec: Optional[tuple | float | list] = None, + conservative: bool = False, +) -> Dict[str, Any]: + """Compute the zonal anomaly of a face-centered variable. + + The zonal anomaly is each face value minus the zonal mean of its latitude + band, highlighting departures from the latitudinal background state + (e.g. waves, eddies, stationary anomalies). + + Args: + grid_path: Path to the mesh grid file. + data_path: Path to the data file with variables. + variable_name: Name of the face-centered variable. + lat_spec: Latitude band specification. ``None`` uses the UXarray default + ``(-90, 90, 10)``; a ``(start, end, step)`` tuple or explicit band + edges are also accepted. + conservative: If True, use area-weighted band means. + + Returns: + Dictionary with ``variable_name``, ``conservative``, ``n_face``, + ``stats`` (min/max/mean/std of the anomaly field), ``grid_info``, and + ``_provenance``. + + Raises: + NotImplementedError: If the installed UXarray lacks + ``UxDataArray.zonal_anomaly``. + + Example: + >>> calculate_zonal_anomaly("grid.nc", "data.nc", "temperature") + {"stats": {"min": -12.4, "max": 9.8, ...}, ...} + """ + if not Path(grid_path).exists(): + raise FileNotFoundError(f"Grid file not found: {grid_path}") + if not Path(data_path).exists(): + raise FileNotFoundError(f"Data file not found: {data_path}") + + uxds = load_dataset(grid_path, data_path) + result = compute_zonal_anomaly_stats(uxds, variable_name, lat_spec, conservative) + + return attach_provenance( + result, + tool="calculate_zonal_anomaly", + inputs={ + "grid_path": grid_path, + "data_path": data_path, + "variable_name": variable_name, + "lat_spec": lat_spec, + "conservative": conservative, + }, + ) + + def validate_dataset(grid_path: str, data_path: str) -> Dict[str, Any]: """ Validate a mesh dataset for common data quality issues. diff --git a/src/uxarray_mcp/tools/vector_calc.py b/src/uxarray_mcp/tools/vector_calc.py index 2053144..bc9222c 100644 --- a/src/uxarray_mcp/tools/vector_calc.py +++ b/src/uxarray_mcp/tools/vector_calc.py @@ -85,6 +85,7 @@ def calculate_gradient( grid_path: str, data_path: str, variable_name: str, + scale_by_radius: bool = False, use_remote: bool = False, endpoint: Optional[str] = None, session_id: Optional[str] = None, @@ -102,6 +103,11 @@ def calculate_gradient( Path to the data file containing the variable. variable_name : str Name of the face-centered scalar variable. + scale_by_radius : bool + If True, divide unit-sphere derivatives by ``uxgrid.sphere_radius`` for + physical units (requires a UXarray release that supports it and a grid + with ``sphere_radius``). Default False preserves the unit-sphere result. + Applies to local execution; remote execution uses the unit sphere. use_remote : bool If True and an HPC endpoint is configured, execute remotely. endpoint : str, optional @@ -129,12 +135,13 @@ def calculate_gradient( "grid_path": grid_path, "data_path": data_path, "variable_name": variable_name, + "scale_by_radius": scale_by_radius, } def _local(): uxds = load_dataset(grid_path, data_path) return attach_provenance( - compute_gradient(uxds, variable_name), + compute_gradient(uxds, variable_name, scale_by_radius=scale_by_radius), tool="calculate_gradient", inputs=inputs, ) @@ -157,6 +164,7 @@ def calculate_curl( data_path: str, u_variable: str, v_variable: str, + scale_by_radius: bool = False, use_remote: bool = False, endpoint: Optional[str] = None, session_id: Optional[str] = None, @@ -181,6 +189,11 @@ def calculate_curl( Zonal (east-west) component, e.g. ``"uReconstructZonal"``. v_variable : str Meridional (north-south) component, e.g. ``"uReconstructMeridional"``. + scale_by_radius : bool + If True, divide the unit-sphere result by ``uxgrid.sphere_radius`` for + physical units (requires a UXarray release that supports it and a grid + with ``sphere_radius``). Default False preserves the unit-sphere result. + Applies to local execution; remote execution uses the unit sphere. use_remote : bool If True and an HPC endpoint is configured, execute remotely. endpoint : str, optional @@ -212,12 +225,13 @@ def calculate_curl( "data_path": data_path, "u_variable": u_variable, "v_variable": v_variable, + "scale_by_radius": scale_by_radius, } def _local(): uxds = load_dataset(grid_path, data_path) return attach_provenance( - compute_curl(uxds, u_variable, v_variable), + compute_curl(uxds, u_variable, v_variable, scale_by_radius=scale_by_radius), tool="calculate_curl", inputs=inputs, ) diff --git a/tests/test_new_uxarray_features.py b/tests/test_new_uxarray_features.py new file mode 100644 index 0000000..362ffb9 --- /dev/null +++ b/tests/test_new_uxarray_features.py @@ -0,0 +1,167 @@ +"""Tests for UXarray features adopted in the 0.2.x follow-ups. + +Covers: +- ``scale_by_radius`` opt-in on gradient/curl (default keeps the unit sphere). +- ``zonal_anomaly`` operation. +- ``remap_to_rectilinear`` operation. + +The package pins ``uxarray>=2026.6.0``, which ships all of these, so the tests +exercise them directly. A negative test still confirms the capability guard +raises a clear error if the underlying method is ever absent. +""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest +import uxarray as ux +import xarray as xr + +from uxarray_mcp.domain.vector_calc import compute_curl, compute_gradient +from uxarray_mcp.domain.zonal import compute_zonal_anomaly_stats +from uxarray_mcp.tools.frontdoor import run_analysis + + +@pytest.fixture() +def healpix_dataset(): + """Small HEALPix UxDataset with face-centered u, v, and a scalar field.""" + grid = ux.Grid.from_healpix(zoom=2) + n = grid.n_face + rng = np.random.default_rng(7) + return ux.UxDataset( + { + "u": ux.UxDataArray( + xr.DataArray(rng.standard_normal(n), dims=["n_face"]), uxgrid=grid + ), + "v": ux.UxDataArray( + xr.DataArray(rng.standard_normal(n), dims=["n_face"]), uxgrid=grid + ), + "temperature": ux.UxDataArray( + xr.DataArray(250 + 30 * rng.standard_normal(n), dims=["n_face"]), + uxgrid=grid, + ), + }, + uxgrid=grid, + ) + + +@pytest.fixture() +def structured_mesh_files(tmp_path): + """A coarse global UGRID grid + face-centered data, written to disk. + + ``Grid.from_structured`` produces proper node coordinates that survive a + NetCDF round-trip, making it a reliable file-based fixture for remapping. + """ + lon = np.arange(0, 360, 20.0) + lat = np.arange(-80, 81, 20.0) + grid = ux.Grid.from_structured(lon=lon, lat=lat) + grid_file = tmp_path / "grid.nc" + data_file = tmp_path / "data.nc" + grid.to_xarray().to_netcdf(grid_file) + + rng = np.random.default_rng(11) + xr.Dataset( + {"temperature": (["n_face"], 250 + 30 * rng.random(grid.n_face))} + ).to_netcdf(data_file) + return str(grid_file), str(data_file) + + +# --------------------------------------------------------------------------- +# scale_by_radius opt-in +# --------------------------------------------------------------------------- + + +class TestScaleByRadius: + def test_gradient_default_keeps_unit_sphere(self, healpix_dataset): + result = compute_gradient(healpix_dataset, "temperature") + assert result["scale_by_radius"] is False + + def test_curl_default_keeps_unit_sphere(self, healpix_dataset): + result = compute_curl(healpix_dataset, "u", "v") + assert result["scale_by_radius"] is False + + def test_gradient_records_scale_by_radius_flag(self, healpix_dataset): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # grid has no sphere_radius + result = compute_gradient( + healpix_dataset, "temperature", scale_by_radius=True + ) + assert result["scale_by_radius"] is True + + +# --------------------------------------------------------------------------- +# zonal_anomaly +# --------------------------------------------------------------------------- + + +class TestZonalAnomaly: + def test_domain_returns_stats(self, healpix_dataset): + result = compute_zonal_anomaly_stats(healpix_dataset, "temperature") + assert result["variable_name"] == "temperature" + assert set(result["stats"]) == {"min", "max", "mean", "std"} + assert result["n_face"] == int(healpix_dataset.uxgrid.n_face) + + def test_anomaly_mean_near_zero(self, healpix_dataset): + result = compute_zonal_anomaly_stats(healpix_dataset, "temperature") + assert abs(result["stats"]["mean"]) < 1.0 + + def test_missing_variable_raises(self, healpix_dataset): + with pytest.raises(ValueError): + compute_zonal_anomaly_stats(healpix_dataset, "nope") + + def test_run_analysis_dispatch(self, structured_mesh_files): + grid_file, data_file = structured_mesh_files + result = run_analysis( + operation="zonal_anomaly", + grid_path=grid_file, + data_path=data_file, + variable_name="temperature", + ) + assert "stats" in result + assert "_provenance" in result + + def test_capability_guard_when_unsupported(self, healpix_dataset, monkeypatch): + """The domain layer raises a clear error if zonal_anomaly is absent.""" + monkeypatch.delattr( + type(healpix_dataset["temperature"]), "zonal_anomaly", raising=False + ) + if hasattr(healpix_dataset["temperature"], "zonal_anomaly"): + pytest.skip("could not remove zonal_anomaly for negative test") + with pytest.raises(NotImplementedError): + compute_zonal_anomaly_stats(healpix_dataset, "temperature") + + +# --------------------------------------------------------------------------- +# remap_to_rectilinear +# --------------------------------------------------------------------------- + + +class TestRemapToRectilinear: + def test_run_analysis_dispatch(self, state_dir, structured_mesh_files): + grid_file, data_file = structured_mesh_files + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + result = run_analysis( + operation="remap_to_rectilinear", + grid_path=grid_file, + data_path=data_file, + variable_name="temperature", + target_lon=list(np.arange(0, 360, 30.0)), + target_lat=list(np.arange(-60, 61, 30.0)), + ) + assert result["target_shape"] == [5, 12] + assert set(result["stats"]) == {"min", "max", "mean"} + assert result["result_handle"] + assert "_provenance" in result + + def test_missing_target_coords_raises(self, state_dir, structured_mesh_files): + grid_file, data_file = structured_mesh_files + with pytest.raises(ValueError): + run_analysis( + operation="remap_to_rectilinear", + grid_path=grid_file, + data_path=data_file, + variable_name="temperature", + ) diff --git a/tests/test_server.py b/tests/test_server.py index 246a4ae..bdafd65 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -26,8 +26,9 @@ EXPECTED_FRONTDOOR = 11 EXPECTED_CONTROL = 12 # 8 session + 4 hpc EXPECTED_CORE_EXTRA = 1 # list_datasets -EXPECTED_PROMPTS = 3 # first_look, vorticity_analysis, hpc_diagnose -EXPECTED_DEFERRED = 30 +EXPECTED_PROMPTS = 7 # first_look, vorticity_analysis, cyclone_structure, +# eddy_activity, model_evaluation, climatology_anomaly, hpc_diagnose +EXPECTED_DEFERRED = 32 # +zonal_anomaly, +remap_to_rectilinear # --------------------------------------------------------------------------- @@ -180,7 +181,15 @@ def test_prompt_tools_registered_in_core(): registry = make_registry(profile="core") tools = registry.list_tools() sep = registry._name_sep - for name in ("first_look", "vorticity_analysis", "hpc_diagnose"): + for name in ( + "first_look", + "vorticity_analysis", + "cyclone_structure", + "eddy_activity", + "model_evaluation", + "climatology_anomaly", + "hpc_diagnose", + ): assert f"prompt{sep}{name}" in tools, f"prompt tool {name} missing" @@ -204,6 +213,39 @@ def test_prompt_tool_returns_text(): assert "/tmp/test.nc" in text +def test_science_workflow_prompts_reference_real_operations(): + """Each guided science prompt should chain real run_analysis operations.""" + from uxarray_mcp.registry import ( + climatology_anomaly, + cyclone_structure, + eddy_activity, + model_evaluation, + ) + + cyclone = cyclone_structure( + "g.nc", "d.nc", "wind", 280.0, 25.0, u_var="u", v_var="v" + ) + assert 'operation="azimuthal_mean"' in cyclone + assert 'operation="curl"' in cyclone # wind components present + + # Without wind, the vorticity step is omitted. + cyclone_no_wind = cyclone_structure("g.nc", "d.nc", "pressure", 280.0, 25.0) + assert 'operation="curl"' not in cyclone_no_wind + + eddy = eddy_activity("g.nc", "d.nc", "z500") + assert 'operation="zonal_anomaly"' in eddy + assert 'operation="gradient"' in eddy + + evaluation = model_evaluation("g.nc", "model.nc", "ref.nc", "t2m") + for op in ("bias", "rmse", "pattern_correlation"): + assert f'operation="{op}"' in evaluation + + clim = climatology_anomaly("d.nc", "t2m", grid_path="g.nc") + assert 'operation="temporal_mean"' in clim + assert 'operation="anomaly"' in clim + assert 'operation="calculate_zonal_mean"' in clim # grid_path provided + + # --------------------------------------------------------------------------- # Policy tags # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 43e961f..7cbb38e 100644 --- a/uv.lock +++ b/uv.lock @@ -2248,14 +2248,14 @@ wheels = [ [[package]] name = "toolregistry-server" -version = "0.3.3" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "toolregistry" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/43/f452d737c86ca18f234bd6ccac7a5051a7068da3c8a8e3c2715edfa5528c/toolregistry_server-0.3.3.tar.gz", hash = "sha256:1db4ad838ea8dd2fd7334433249f9cc226295aeb351e83ea63bee8c774d109f2", size = 63105, upload-time = "2026-05-31T09:16:04.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/bb/3678a93918a49faf225439511b1ff49fa65844cbc5ca4d7dc59ff494c793/toolregistry_server-0.4.0.tar.gz", hash = "sha256:c79f355dfba3a4ce1710cd2e24edbd217c2b41b4717d82782534563fe30122a1", size = 71492, upload-time = "2026-06-22T10:28:49.163Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/5b/bf7eaa663be13ff6396b88d52f85fa7ebb627995c0764016b36815109565/toolregistry_server-0.3.3-py3-none-any.whl", hash = "sha256:09198b80838307e17195ecad589509402ac417fdbfe66d13e3c6051ad6057ff3", size = 50051, upload-time = "2026-05-31T09:16:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/fa/35/b67b516511af5431cbdd5bd5c91775bcf3e1ed107d39b9bcee1e8acb80ce/toolregistry_server-0.4.0-py3-none-any.whl", hash = "sha256:7933c29050bd93e04b56ffec999d71489adda0670f7c1ee9b9589944d4f9fc89", size = 58369, upload-time = "2026-06-22T10:28:48.024Z" }, ] [package.optional-dependencies] @@ -2402,7 +2402,7 @@ wheels = [ [[package]] name = "uxarray" -version = "2026.4.1" +version = "2026.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "antimeridian" }, @@ -2430,9 +2430,9 @@ dependencies = [ { name = "spatialpandas" }, { name = "xarray" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/f6/61ff9b8d6b9d4223d3798ee14129c27cc9fad472529b9ea6891e8ef9f75c/uxarray-2026.4.1.tar.gz", hash = "sha256:7934f85430b791186a684022d0e736e26275ac38f597c38b7000c8bea889ab0e", size = 190414, upload-time = "2026-04-23T01:45:32.207Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/13/cbf510afda42e223f7ae50046e4f657c437b09bcf796d96c930a5e523bc0/uxarray-2026.6.0.tar.gz", hash = "sha256:f83d34fd8dae54fd8d87f59ade921f70396bce538a8efe73d20d39b63f9b7f5e", size = 202826, upload-time = "2026-06-18T22:22:05.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/f7/f4d5689dc20b0c112650f225e68c3ec3e2d57bf2a30a6696d74e7d1a5f1c/uxarray-2026.4.1-py3-none-any.whl", hash = "sha256:ed50b3a7871b7359823095c5f7220ab72f885a312cc774e39c43836333a83e16", size = 213343, upload-time = "2026-04-23T01:45:30.545Z" }, + { url = "https://files.pythonhosted.org/packages/38/41/c8a20e78aa886cdfc87979dc6851ea7c67faf989e42a405678f14dc53328/uxarray-2026.6.0-py3-none-any.whl", hash = "sha256:4a20777953dbd0eba4f8f7add28091d6c3f28ec1789b6638c60bf5865782b8c3", size = 227792, upload-time = "2026-06-18T22:22:04.541Z" }, ] [[package]] @@ -2480,9 +2480,9 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0" }, { name = "sphinx-book-theme", marker = "extra == 'docs'", specifier = ">=1.1.0" }, - { name = "toolregistry-server", extras = ["mcp"], specifier = ">=0.3.3" }, - { name = "toolregistry-server", extras = ["openapi"], marker = "extra == 'openapi'", specifier = ">=0.3.3" }, - { name = "uxarray", specifier = ">=2025.12.0" }, + { name = "toolregistry-server", extras = ["mcp"], specifier = ">=0.4.0" }, + { name = "toolregistry-server", extras = ["openapi"], marker = "extra == 'openapi'", specifier = ">=0.4.0" }, + { name = "uxarray", specifier = ">=2026.6.0" }, ] provides-extras = ["openapi", "hpc", "docs"] From b75638c0ba1aa6fd5cdba402ffb79f7346a41795 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Tue, 23 Jun 2026 13:13:31 -0500 Subject: [PATCH 2/6] Document zonal_anomaly/remap_to_rectilinear as local-only These two operations have no remote (Globus Compute) execution path yet, so they cannot run on HPC-resident files. Note the limitation in the tools reference; uniform HPC routing is tracked as a design item. --- docs/tools.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/tools.md b/docs/tools.md index 02f99d2..09aa828 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -73,6 +73,12 @@ divide by `uxgrid.sphere_radius` for physical units; the grid must define `UxDataArray.zonal_anomaly` and `UxDataArray.remap.to_rectilinear`, available in the pinned UXarray (`>=2026.6.0`). +> **Local-only:** `zonal_anomaly` and `remap_to_rectilinear` currently run on the +> local machine and do not have a remote (Globus Compute) execution path yet, so +> they cannot operate on files that live only on an HPC filesystem. The other +> compute operations accept `use_remote=True`. Uniform HPC routing across all +> operations is tracked as a design item. + Examples: ```python From b0bae6f2342654e0b80f8bccf2973c411c51e11d Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Tue, 23 Jun 2026 13:33:40 -0500 Subject: [PATCH 3/6] Honor scale_by_radius on the remote gradient/curl path The local gradient/curl path passed scale_by_radius through, but the remote dispatch dropped it, so use_remote=True silently ran on the unit sphere while provenance could imply otherwise. Thread scale_by_radius through the agent methods and remote compute functions, applied capability-safely (the worker may run an older UXarray): the result now reports the scale_by_radius actually applied. Add a test that locks in the remote threading. Verified on the Improv endpoint: with an older worker UXarray the remote result correctly reports scale_by_radius=False (fallback), and the full old+new operation matrix passes (9/9). --- src/uxarray_mcp/remote/agent.py | 26 +++++++++++--- src/uxarray_mcp/remote/compute_functions.py | 28 ++++++++++++--- src/uxarray_mcp/tools/vector_calc.py | 12 ++++--- tests/test_new_uxarray_features.py | 38 +++++++++++++++++++++ 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/uxarray_mcp/remote/agent.py b/src/uxarray_mcp/remote/agent.py index b3b548b..ee84b26 100644 --- a/src/uxarray_mcp/remote/agent.py +++ b/src/uxarray_mcp/remote/agent.py @@ -214,20 +214,38 @@ async def calculate_zonal_mean_remote( @action async def calculate_gradient_remote( - self, grid_path: str, data_path: str, variable_name: str + self, + grid_path: str, + data_path: str, + variable_name: str, + scale_by_radius: bool = False, ) -> Dict[str, Any]: """Compute spatial gradient on HPC.""" return await self._run_on_hpc( - remote_calculate_gradient, grid_path, data_path, variable_name + remote_calculate_gradient, + grid_path, + data_path, + variable_name, + scale_by_radius, ) @action async def calculate_curl_remote( - self, grid_path: str, data_path: str, u_variable: str, v_variable: str + self, + grid_path: str, + data_path: str, + u_variable: str, + v_variable: str, + scale_by_radius: bool = False, ) -> Dict[str, Any]: """Compute relative vorticity (curl) on HPC.""" return await self._run_on_hpc( - remote_calculate_curl, grid_path, data_path, u_variable, v_variable + remote_calculate_curl, + grid_path, + data_path, + u_variable, + v_variable, + scale_by_radius, ) @action diff --git a/src/uxarray_mcp/remote/compute_functions.py b/src/uxarray_mcp/remote/compute_functions.py index 154d961..a68dbaa 100644 --- a/src/uxarray_mcp/remote/compute_functions.py +++ b/src/uxarray_mcp/remote/compute_functions.py @@ -1035,9 +1035,10 @@ def _surface(mod): def remote_calculate_gradient( - grid_path: str, data_path: str, variable_name: str + grid_path: str, data_path: str, variable_name: str, scale_by_radius: bool = False ) -> Dict[str, Any]: """Compute the spatial gradient of a face-centered scalar field on HPC.""" + import inspect as _inspect import os import numpy as np @@ -1062,7 +1063,14 @@ def remote_calculate_gradient( "Gradient requires face-centered data." ) - grad = var.gradient() + # Honor scale_by_radius when the worker's UXarray supports it; otherwise + # fall back to the unit-sphere call so older workers keep working. + applied_scale = False + if "scale_by_radius" in _inspect.signature(var.gradient).parameters: + grad = var.gradient(scale_by_radius=scale_by_radius) + applied_scale = bool(scale_by_radius) + else: + grad = var.gradient() comp_names = list(grad.data_vars) def _stats(arr: Any) -> Dict[str, Any]: @@ -1081,17 +1089,23 @@ def _stats(arr: Any) -> Dict[str, Any]: "components": comp_names, "component_stats": {name: _stats(grad[name]) for name in comp_names}, "n_face": int(uxds.uxgrid.n_face), + "scale_by_radius": applied_scale, "interpretation": "zonal (d/dx) and meridional (d/dy) components of the gradient", } def remote_calculate_curl( - grid_path: str, data_path: str, u_variable: str, v_variable: str + grid_path: str, + data_path: str, + u_variable: str, + v_variable: str, + scale_by_radius: bool = False, ) -> Dict[str, Any]: """Compute relative vorticity (curl) of a 2-D wind field on HPC. zeta = dv/dx - du/dy """ + import inspect as _inspect import os import numpy as np @@ -1118,7 +1132,12 @@ def remote_calculate_curl( "Curl requires face-centered vector components." ) - result = u.curl(v) + applied_scale = False + if "scale_by_radius" in _inspect.signature(u.curl).parameters: + result = u.curl(v, scale_by_radius=scale_by_radius) + applied_scale = bool(scale_by_radius) + else: + result = u.curl(v) vals = result.values finite = vals[np.isfinite(vals)] stats: Dict[str, Any] = ( @@ -1136,6 +1155,7 @@ def remote_calculate_curl( "v_variable": v_variable, "interpretation": "relative vorticity zeta = dv/dx - du/dy", "n_face": int(uxds.uxgrid.n_face), + "scale_by_radius": applied_scale, "stats": stats, } diff --git a/src/uxarray_mcp/tools/vector_calc.py b/src/uxarray_mcp/tools/vector_calc.py index bc9222c..7a481d9 100644 --- a/src/uxarray_mcp/tools/vector_calc.py +++ b/src/uxarray_mcp/tools/vector_calc.py @@ -107,7 +107,8 @@ def calculate_gradient( If True, divide unit-sphere derivatives by ``uxgrid.sphere_radius`` for physical units (requires a UXarray release that supports it and a grid with ``sphere_radius``). Default False preserves the unit-sphere result. - Applies to local execution; remote execution uses the unit sphere. + Honored on both local and remote execution when the active UXarray + supports it; otherwise the result stays on the unit sphere. use_remote : bool If True and an HPC endpoint is configured, execute remotely. endpoint : str, optional @@ -154,7 +155,9 @@ def _local(): session_id=session_id, local_call=_local, remote_call=lambda agent: _run_sync( - lambda: agent.calculate_gradient_remote(grid_path, data_path, variable_name) + lambda: agent.calculate_gradient_remote( + grid_path, data_path, variable_name, scale_by_radius + ) ), ) @@ -193,7 +196,8 @@ def calculate_curl( If True, divide the unit-sphere result by ``uxgrid.sphere_radius`` for physical units (requires a UXarray release that supports it and a grid with ``sphere_radius``). Default False preserves the unit-sphere result. - Applies to local execution; remote execution uses the unit sphere. + Honored on both local and remote execution when the active UXarray + supports it; otherwise the result stays on the unit sphere. use_remote : bool If True and an HPC endpoint is configured, execute remotely. endpoint : str, optional @@ -245,7 +249,7 @@ def _local(): local_call=_local, remote_call=lambda agent: _run_sync( lambda: agent.calculate_curl_remote( - grid_path, data_path, u_variable, v_variable + grid_path, data_path, u_variable, v_variable, scale_by_radius ) ), ) diff --git a/tests/test_new_uxarray_features.py b/tests/test_new_uxarray_features.py index 362ffb9..bf0f520 100644 --- a/tests/test_new_uxarray_features.py +++ b/tests/test_new_uxarray_features.py @@ -90,6 +90,44 @@ def test_gradient_records_scale_by_radius_flag(self, healpix_dataset): ) assert result["scale_by_radius"] is True + def test_remote_gradient_threads_scale_by_radius(self): + """The remote dispatch must forward scale_by_radius to the agent.""" + from unittest.mock import MagicMock, patch + + from uxarray_mcp.tools import vector_calc + + agent = MagicMock() + agent.config.endpoint_id = "fake-endpoint" + agent.config.endpoint_name = "fake" + agent.config.timeout_seconds = 60 + agent.calculate_gradient_remote.return_value = { + "components": [], + "component_stats": {}, + "n_face": 1, + "scale_by_radius": True, + "_provenance": {"warnings": []}, + } + + with ( + patch("uxarray_mcp.remote.agent.get_agent", return_value=agent), + patch.object( + vector_calc, "_endpoint_manager_is_up", return_value=(True, "ok") + ), + patch.object(vector_calc, "_run_sync", side_effect=lambda f: f()), + ): + vector_calc.calculate_gradient( + "/hpc/grid.nc", + "/hpc/data.nc", + "t", + scale_by_radius=True, + use_remote=True, + endpoint="improv", + ) + + # The agent method must have been called with scale_by_radius=True. + args, kwargs = agent.calculate_gradient_remote.call_args + assert (True in args) or (kwargs.get("scale_by_radius") is True) + # --------------------------------------------------------------------------- # zonal_anomaly From f28bb54abc438b5c92b833f43dbbfca4058d2f07 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Tue, 23 Jun 2026 13:55:12 -0500 Subject: [PATCH 4/6] README: clarify HPC is opt-in and requires a configured endpoint Make explicit that everything runs locally by default and the remote option only becomes available once a Globus Compute endpoint is configured; running one needs an HPC account/allocation, while a shared/service-account endpoint can accept authorized submitters without their own login. Addresses a reviewer question. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index d29e020..8ceb29b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ remotely on an HPC system you have access to. > / vorticity / divergence, subset, remap, plot, and run multi-step workflows. > All as natural-language prompts. +> **Local by default; HPC is opt-in.** Everything runs on your machine unless +> you configure a [Globus Compute](https://www.globus.org/compute) endpoint. +> The remote option only becomes available once such an endpoint exists — +> running one requires an account and allocation on that HPC system, though a +> shared/service-account endpoint can let authorized users submit without their +> own login. + > **⚠️ What the AI can access.** Any file you (or your HPC account) can read. > Any compute the configured endpoint can submit. Outputs are written to your > disk. **See [SECURITY.md](SECURITY.md) before connecting any remote endpoint.** From 02d4c39b0249d464746ac7e3afd409836583356d Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 24 Jun 2026 13:22:33 -0500 Subject: [PATCH 5/6] Clean up test layout and refresh docs Redistribute the catch-all test module into the feature-area suites (vector calculus, zonal, advanced) and share the HEALPix and structured mesh fixtures from conftest. Update the docs surface for the App/OpenAPI serving model, correct the tool counts, document the new analysis operations and prompts, and split the table of contents into a local getting-started track and an optional HPC track. --- docs/api.rst | 12 ++ docs/conf.py | 7 +- docs/index.rst | 22 +++- docs/serving.md | 19 ++- tests/conftest.py | 45 +++++++ tests/test_advanced_tools.py | 37 ++++++ tests/test_calculate_zonal_mean.py | 45 ++++++- tests/test_new_uxarray_features.py | 205 ----------------------------- tests/test_vector_calc.py | 61 ++++++++- 9 files changed, 233 insertions(+), 220 deletions(-) delete mode 100644 tests/test_new_uxarray_features.py diff --git a/docs/api.rst b/docs/api.rst index 9e865e2..cc0bfbb 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -24,6 +24,10 @@ These modules contain the pure computation logic, separate from MCP and I/O. :members: :undoc-members: +.. automodule:: uxarray_mcp.domain.vector_calc + :members: + :undoc-members: + .. automodule:: uxarray_mcp.domain.plotting :members: :undoc-members: @@ -33,10 +37,18 @@ Tools MCP tool functions that are exposed to AI agents. +.. automodule:: uxarray_mcp.tools.frontdoor + :members: + :undoc-members: + .. automodule:: uxarray_mcp.tools.inspection :members: :undoc-members: +.. automodule:: uxarray_mcp.tools.vector_calc + :members: + :undoc-members: + .. automodule:: uxarray_mcp.tools.capabilities :members: :undoc-members: diff --git a/docs/conf.py b/docs/conf.py index 3241bec..99914c1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,7 +46,12 @@ } templates_path = ["_templates"] -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +exclude_patterns = [ + "_build", + "Thumbs.db", + ".DS_Store", + "onepager-uxarray-mcp.md", +] # -- Options for HTML output ------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 4e95c42..8544c04 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,25 +3,33 @@ UXarray MCP Server An MCP server that connects AI assistants like Claude to `UXarray `_ for analyzing unstructured -climate and weather meshes. Supports local execution and remote HPC -via Globus Compute. +climate and weather meshes. It runs locally by default; HPC execution via +Globus Compute is optional and only active once you configure an endpoint. + +The same tools are served over MCP (for AI clients) or OpenAPI/REST (for +HTTP clients) from a single install. .. toctree:: :maxdepth: 2 - :caption: User Guide + :caption: Getting started getting-started + tools serving + workflows + scientific-agent + +.. toctree:: + :maxdepth: 2 + :caption: Running on HPC (optional) + remote-hpc - operating-an-endpoint globus-compute - tools + operating-an-endpoint hpc improv ucar chrysalis - workflows - scientific-agent .. toctree:: :maxdepth: 2 diff --git a/docs/serving.md b/docs/serving.md index 9f29cc1..02dfbcf 100644 --- a/docs/serving.md +++ b/docs/serving.md @@ -22,14 +22,19 @@ With no flags, `uxarray-mcp serve` starts MCP over stdio with the `core` profile — the same behavior earlier releases had. Existing Claude Desktop configurations work unchanged. +The server is assembled by `UXarrayApp`, an `App` subclass from +toolregistry-server that carries a single `ServerIdentity` (name, version, +description) shared by every transport. The same object also drives the +`openapi` command below. + ## Profiles The visible tool surface is selected by `--profile`: | Profile | Visible tools | Use when | |---|---|---| -| `core` (default) | ~27 | Most clients. Front-door gateway tools, session/HPC control, `list_datasets`, and prompts. Predictable, conservative. | -| `deferred-full` | ~28 visible (+30 deferred) | You want the full low-level surface. The 30 raw implementation tools load with `defer=True`, so they do not appear in the initial list; agents find them with `discover_tools`. | +| `core` (default) | ~31 | Most clients. Front-door gateway tools, session/HPC control, `list_datasets`, and prompts. Predictable, conservative. | +| `deferred-full` | ~31 visible (+32 deferred) | You want the full low-level surface. The raw implementation tools load with `defer=True`, so they do not appear in the initial list; agents find them with `discover_tools`. | ```bash uxarray-mcp serve --profile deferred-full @@ -57,14 +62,18 @@ uxarray-mcp serve --transport http --port 8000 The same tools can be exposed as an OpenAPI/REST service for clients that speak HTTP rather than MCP — curl, cloud assistants, chat UIs, or scripts. Install the -optional extra: +optional extra and start the `openapi` command: ```bash pip install "uxarray-mcp[openapi]" + +uxarray-mcp openapi [--profile {core,deferred-full}] [--host HOST] [--port PORT] ``` The tool implementations, provenance, and HPC dispatch are shared across MCP and -REST; only the protocol adapter differs. +REST; only the protocol adapter differs. MCP and OpenAPI can run as separate +processes from the same install — for example `uxarray-mcp serve` for AI clients +and `uxarray-mcp openapi` for HTTP clients, behind a reverse proxy if needed. ## Tool discovery (`deferred-full`) @@ -84,7 +93,7 @@ from uxarray_mcp.tools import inspect_mesh result = inspect_mesh(file_path="grid.nc") # dict with a _provenance block # Or call by name through the same registry the server uses: -from uxarray_mcp.server import make_registry +from uxarray_mcp.app import make_registry reg = make_registry(profile="core") run_analysis = reg.get_callable("run_analysis") stats = run_analysis(operation="calculate_area", grid_path="grid.nc") diff --git a/tests/conftest.py b/tests/conftest.py index d9f6bf0..bf4733f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import sys from unittest.mock import MagicMock +import numpy as np import pytest # Mock uxarray if it's not installed, so we can run logic tests without heavy dependencies @@ -244,3 +245,47 @@ def ensemble_data_files(tmp_path): coords={"sample": [0, 1]}, ).to_netcdf(second) return [str(first), str(second)] + + +@pytest.fixture +def healpix_field_dataset(): + """HEALPix UxDataset with face-centered u, v, and a temperature field.""" + grid = ux.Grid.from_healpix(zoom=2) + n = grid.n_face + rng = np.random.default_rng(7) + return ux.UxDataset( + { + "u": ux.UxDataArray( + xr.DataArray(rng.standard_normal(n), dims=["n_face"]), uxgrid=grid + ), + "v": ux.UxDataArray( + xr.DataArray(rng.standard_normal(n), dims=["n_face"]), uxgrid=grid + ), + "temperature": ux.UxDataArray( + xr.DataArray(250 + 30 * rng.standard_normal(n), dims=["n_face"]), + uxgrid=grid, + ), + }, + uxgrid=grid, + ) + + +@pytest.fixture +def structured_mesh_files(tmp_path): + """Coarse global UGRID grid + face-centered data written to disk. + + ``Grid.from_structured`` produces node coordinates that survive a NetCDF + round-trip, making it a reliable file-based fixture for remapping. + """ + lon = np.arange(0, 360, 20.0) + lat = np.arange(-80, 81, 20.0) + grid = ux.Grid.from_structured(lon=lon, lat=lat) + grid_file = tmp_path / "grid.nc" + data_file = tmp_path / "data.nc" + grid.to_xarray().to_netcdf(grid_file) + + rng = np.random.default_rng(11) + xr.Dataset( + {"temperature": (["n_face"], 250 + 30 * rng.random(grid.n_face))} + ).to_netcdf(data_file) + return str(grid_file), str(data_file) diff --git a/tests/test_advanced_tools.py b/tests/test_advanced_tools.py index cde076d..752c7b7 100644 --- a/tests/test_advanced_tools.py +++ b/tests/test_advanced_tools.py @@ -1,7 +1,11 @@ """Tests for subsetting, comparison, remapping, temporal, and export tools.""" +import warnings from pathlib import Path +import numpy as np +import pytest + from uxarray_mcp.tools import ( calculate_anomaly, calculate_bias, @@ -23,6 +27,7 @@ subset_polygon, write_result, ) +from uxarray_mcp.tools.frontdoor import run_analysis def test_spatial_subset_tools_persist_results(state_dir, synthetic_mesh_with_data): @@ -223,3 +228,35 @@ def test_export_tools_support_result_handles_and_dataset_handles( assert Path(exported_netcdf["output_path"]).exists() assert Path(exported_csv["output_path"]).exists() assert Path(written["output_path"]).exists() + + +def test_remap_to_rectilinear_dispatch(state_dir, structured_mesh_files): + """remap_to_rectilinear returns a regular grid with a result handle.""" + grid_file, data_file = structured_mesh_files + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + result = run_analysis( + operation="remap_to_rectilinear", + grid_path=grid_file, + data_path=data_file, + variable_name="temperature", + target_lon=list(np.arange(0, 360, 30.0)), + target_lat=list(np.arange(-60, 61, 30.0)), + ) + assert result["target_shape"] == [5, 12] + assert set(result["stats"]) == {"min", "max", "mean"} + assert result["result_handle"] + assert "_provenance" in result + + +def test_remap_to_rectilinear_missing_target_coords_raises( + state_dir, structured_mesh_files +): + grid_file, data_file = structured_mesh_files + with pytest.raises(ValueError): + run_analysis( + operation="remap_to_rectilinear", + grid_path=grid_file, + data_path=data_file, + variable_name="temperature", + ) diff --git a/tests/test_calculate_zonal_mean.py b/tests/test_calculate_zonal_mean.py index badae8e..e78b47f 100644 --- a/tests/test_calculate_zonal_mean.py +++ b/tests/test_calculate_zonal_mean.py @@ -1,11 +1,13 @@ -"""Tests for the calculate_zonal_mean tool.""" +"""Tests for the calculate_zonal_mean and zonal_anomaly tools.""" from unittest.mock import MagicMock, patch import numpy as np import pytest +from uxarray_mcp.domain.zonal import compute_zonal_anomaly_stats from uxarray_mcp.tools import calculate_zonal_mean +from uxarray_mcp.tools.frontdoor import run_analysis class TestCalculateZonalMeanUnit: @@ -369,3 +371,44 @@ def test_zonal_mean_with_synthetic_data(self, synthetic_mesh_with_data): assert len(result["latitudes"]) == len(result["zonal_mean_values"]) assert result["conservative"] is False assert result["grid_info"]["n_face"] == 1 + + +class TestZonalAnomaly: + """Tests for the zonal_anomaly operation (per-band departure).""" + + def test_domain_returns_stats(self, healpix_field_dataset): + result = compute_zonal_anomaly_stats(healpix_field_dataset, "temperature") + assert result["variable_name"] == "temperature" + assert set(result["stats"]) == {"min", "max", "mean", "std"} + assert result["n_face"] == int(healpix_field_dataset.uxgrid.n_face) + + def test_anomaly_mean_near_zero(self, healpix_field_dataset): + result = compute_zonal_anomaly_stats(healpix_field_dataset, "temperature") + assert abs(result["stats"]["mean"]) < 1.0 + + def test_missing_variable_raises(self, healpix_field_dataset): + with pytest.raises(ValueError): + compute_zonal_anomaly_stats(healpix_field_dataset, "nope") + + def test_run_analysis_dispatch(self, structured_mesh_files): + grid_file, data_file = structured_mesh_files + result = run_analysis( + operation="zonal_anomaly", + grid_path=grid_file, + data_path=data_file, + variable_name="temperature", + ) + assert "stats" in result + assert "_provenance" in result + + def test_capability_guard_when_unsupported( + self, healpix_field_dataset, monkeypatch + ): + """The domain layer raises a clear error if zonal_anomaly is absent.""" + monkeypatch.delattr( + type(healpix_field_dataset["temperature"]), "zonal_anomaly", raising=False + ) + if hasattr(healpix_field_dataset["temperature"], "zonal_anomaly"): + pytest.skip("could not remove zonal_anomaly for negative test") + with pytest.raises(NotImplementedError): + compute_zonal_anomaly_stats(healpix_field_dataset, "temperature") diff --git a/tests/test_new_uxarray_features.py b/tests/test_new_uxarray_features.py deleted file mode 100644 index bf0f520..0000000 --- a/tests/test_new_uxarray_features.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Tests for UXarray features adopted in the 0.2.x follow-ups. - -Covers: -- ``scale_by_radius`` opt-in on gradient/curl (default keeps the unit sphere). -- ``zonal_anomaly`` operation. -- ``remap_to_rectilinear`` operation. - -The package pins ``uxarray>=2026.6.0``, which ships all of these, so the tests -exercise them directly. A negative test still confirms the capability guard -raises a clear error if the underlying method is ever absent. -""" - -from __future__ import annotations - -import warnings - -import numpy as np -import pytest -import uxarray as ux -import xarray as xr - -from uxarray_mcp.domain.vector_calc import compute_curl, compute_gradient -from uxarray_mcp.domain.zonal import compute_zonal_anomaly_stats -from uxarray_mcp.tools.frontdoor import run_analysis - - -@pytest.fixture() -def healpix_dataset(): - """Small HEALPix UxDataset with face-centered u, v, and a scalar field.""" - grid = ux.Grid.from_healpix(zoom=2) - n = grid.n_face - rng = np.random.default_rng(7) - return ux.UxDataset( - { - "u": ux.UxDataArray( - xr.DataArray(rng.standard_normal(n), dims=["n_face"]), uxgrid=grid - ), - "v": ux.UxDataArray( - xr.DataArray(rng.standard_normal(n), dims=["n_face"]), uxgrid=grid - ), - "temperature": ux.UxDataArray( - xr.DataArray(250 + 30 * rng.standard_normal(n), dims=["n_face"]), - uxgrid=grid, - ), - }, - uxgrid=grid, - ) - - -@pytest.fixture() -def structured_mesh_files(tmp_path): - """A coarse global UGRID grid + face-centered data, written to disk. - - ``Grid.from_structured`` produces proper node coordinates that survive a - NetCDF round-trip, making it a reliable file-based fixture for remapping. - """ - lon = np.arange(0, 360, 20.0) - lat = np.arange(-80, 81, 20.0) - grid = ux.Grid.from_structured(lon=lon, lat=lat) - grid_file = tmp_path / "grid.nc" - data_file = tmp_path / "data.nc" - grid.to_xarray().to_netcdf(grid_file) - - rng = np.random.default_rng(11) - xr.Dataset( - {"temperature": (["n_face"], 250 + 30 * rng.random(grid.n_face))} - ).to_netcdf(data_file) - return str(grid_file), str(data_file) - - -# --------------------------------------------------------------------------- -# scale_by_radius opt-in -# --------------------------------------------------------------------------- - - -class TestScaleByRadius: - def test_gradient_default_keeps_unit_sphere(self, healpix_dataset): - result = compute_gradient(healpix_dataset, "temperature") - assert result["scale_by_radius"] is False - - def test_curl_default_keeps_unit_sphere(self, healpix_dataset): - result = compute_curl(healpix_dataset, "u", "v") - assert result["scale_by_radius"] is False - - def test_gradient_records_scale_by_radius_flag(self, healpix_dataset): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") # grid has no sphere_radius - result = compute_gradient( - healpix_dataset, "temperature", scale_by_radius=True - ) - assert result["scale_by_radius"] is True - - def test_remote_gradient_threads_scale_by_radius(self): - """The remote dispatch must forward scale_by_radius to the agent.""" - from unittest.mock import MagicMock, patch - - from uxarray_mcp.tools import vector_calc - - agent = MagicMock() - agent.config.endpoint_id = "fake-endpoint" - agent.config.endpoint_name = "fake" - agent.config.timeout_seconds = 60 - agent.calculate_gradient_remote.return_value = { - "components": [], - "component_stats": {}, - "n_face": 1, - "scale_by_radius": True, - "_provenance": {"warnings": []}, - } - - with ( - patch("uxarray_mcp.remote.agent.get_agent", return_value=agent), - patch.object( - vector_calc, "_endpoint_manager_is_up", return_value=(True, "ok") - ), - patch.object(vector_calc, "_run_sync", side_effect=lambda f: f()), - ): - vector_calc.calculate_gradient( - "/hpc/grid.nc", - "/hpc/data.nc", - "t", - scale_by_radius=True, - use_remote=True, - endpoint="improv", - ) - - # The agent method must have been called with scale_by_radius=True. - args, kwargs = agent.calculate_gradient_remote.call_args - assert (True in args) or (kwargs.get("scale_by_radius") is True) - - -# --------------------------------------------------------------------------- -# zonal_anomaly -# --------------------------------------------------------------------------- - - -class TestZonalAnomaly: - def test_domain_returns_stats(self, healpix_dataset): - result = compute_zonal_anomaly_stats(healpix_dataset, "temperature") - assert result["variable_name"] == "temperature" - assert set(result["stats"]) == {"min", "max", "mean", "std"} - assert result["n_face"] == int(healpix_dataset.uxgrid.n_face) - - def test_anomaly_mean_near_zero(self, healpix_dataset): - result = compute_zonal_anomaly_stats(healpix_dataset, "temperature") - assert abs(result["stats"]["mean"]) < 1.0 - - def test_missing_variable_raises(self, healpix_dataset): - with pytest.raises(ValueError): - compute_zonal_anomaly_stats(healpix_dataset, "nope") - - def test_run_analysis_dispatch(self, structured_mesh_files): - grid_file, data_file = structured_mesh_files - result = run_analysis( - operation="zonal_anomaly", - grid_path=grid_file, - data_path=data_file, - variable_name="temperature", - ) - assert "stats" in result - assert "_provenance" in result - - def test_capability_guard_when_unsupported(self, healpix_dataset, monkeypatch): - """The domain layer raises a clear error if zonal_anomaly is absent.""" - monkeypatch.delattr( - type(healpix_dataset["temperature"]), "zonal_anomaly", raising=False - ) - if hasattr(healpix_dataset["temperature"], "zonal_anomaly"): - pytest.skip("could not remove zonal_anomaly for negative test") - with pytest.raises(NotImplementedError): - compute_zonal_anomaly_stats(healpix_dataset, "temperature") - - -# --------------------------------------------------------------------------- -# remap_to_rectilinear -# --------------------------------------------------------------------------- - - -class TestRemapToRectilinear: - def test_run_analysis_dispatch(self, state_dir, structured_mesh_files): - grid_file, data_file = structured_mesh_files - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - result = run_analysis( - operation="remap_to_rectilinear", - grid_path=grid_file, - data_path=data_file, - variable_name="temperature", - target_lon=list(np.arange(0, 360, 30.0)), - target_lat=list(np.arange(-60, 61, 30.0)), - ) - assert result["target_shape"] == [5, 12] - assert set(result["stats"]) == {"min", "max", "mean"} - assert result["result_handle"] - assert "_provenance" in result - - def test_missing_target_coords_raises(self, state_dir, structured_mesh_files): - grid_file, data_file = structured_mesh_files - with pytest.raises(ValueError): - run_analysis( - operation="remap_to_rectilinear", - grid_path=grid_file, - data_path=data_file, - variable_name="temperature", - ) diff --git a/tests/test_vector_calc.py b/tests/test_vector_calc.py index 81d0d0e..a6f541f 100644 --- a/tests/test_vector_calc.py +++ b/tests/test_vector_calc.py @@ -2,7 +2,8 @@ from __future__ import annotations -from unittest.mock import patch +import warnings +from unittest.mock import MagicMock, patch import numpy as np import pytest @@ -323,6 +324,64 @@ def test_accepts_use_remote_endpoint_session_params(self): assert "session_id" in sig.parameters +# --------------------------------------------------------------------------- +# scale_by_radius opt-in +# --------------------------------------------------------------------------- + + +class TestScaleByRadius: + def test_gradient_default_keeps_unit_sphere(self, healpix_wind_dataset): + result = compute_gradient(healpix_wind_dataset, "temperature") + assert result["scale_by_radius"] is False + + def test_curl_default_keeps_unit_sphere(self, healpix_wind_dataset): + result = compute_curl(healpix_wind_dataset, "u", "v") + assert result["scale_by_radius"] is False + + def test_gradient_records_scale_by_radius_flag(self, healpix_wind_dataset): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # grid has no sphere_radius + result = compute_gradient( + healpix_wind_dataset, "temperature", scale_by_radius=True + ) + assert result["scale_by_radius"] is True + + def test_remote_gradient_threads_scale_by_radius(self): + """The remote dispatch must forward scale_by_radius to the agent.""" + from uxarray_mcp.tools import vector_calc + + agent = MagicMock() + agent.config.endpoint_id = "fake-endpoint" + agent.config.endpoint_name = "fake" + agent.config.timeout_seconds = 60 + agent.calculate_gradient_remote.return_value = { + "components": [], + "component_stats": {}, + "n_face": 1, + "scale_by_radius": True, + "_provenance": {"warnings": []}, + } + + with ( + patch("uxarray_mcp.remote.agent.get_agent", return_value=agent), + patch.object( + vector_calc, "_endpoint_manager_is_up", return_value=(True, "ok") + ), + patch.object(vector_calc, "_run_sync", side_effect=lambda f: f()), + ): + vector_calc.calculate_gradient( + "/hpc/grid.nc", + "/hpc/data.nc", + "t", + scale_by_radius=True, + use_remote=True, + endpoint="improv", + ) + + args, kwargs = agent.calculate_gradient_remote.call_args + assert (True in args) or (kwargs.get("scale_by_radius") is True) + + # --------------------------------------------------------------------------- # Server registration # --------------------------------------------------------------------------- From 29608017a78450c2606a6a42ab6b80a821b57a51 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Wed, 24 Jun 2026 18:09:14 -0500 Subject: [PATCH 6/6] Address review: type lat_spec and correct scale_by_radius docs Annotate run_analysis lat_spec as tuple/float/list/None so the generated MCP/OpenAPI schema keeps useful type information, matching the zonal-mean helpers. Correct the gradient/curl docstrings: the capability-safe unit-sphere fallback lives on the remote worker path; local execution relies on the pinned UXarray. --- src/uxarray_mcp/tools/frontdoor.py | 2 +- src/uxarray_mcp/tools/vector_calc.py | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/uxarray_mcp/tools/frontdoor.py b/src/uxarray_mcp/tools/frontdoor.py index 7626f3d..9ce02df 100644 --- a/src/uxarray_mcp/tools/frontdoor.py +++ b/src/uxarray_mcp/tools/frontdoor.py @@ -47,7 +47,7 @@ def run_analysis( dataset_handle: str | None = None, result_name: str | None = None, scale_by_radius: bool = False, - lat_spec: Any = None, + lat_spec: tuple | float | list[Any] | None = None, conservative: bool = False, target_lon: list[float] | None = None, target_lat: list[float] | None = None, diff --git a/src/uxarray_mcp/tools/vector_calc.py b/src/uxarray_mcp/tools/vector_calc.py index 7a481d9..2193965 100644 --- a/src/uxarray_mcp/tools/vector_calc.py +++ b/src/uxarray_mcp/tools/vector_calc.py @@ -105,10 +105,12 @@ def calculate_gradient( Name of the face-centered scalar variable. scale_by_radius : bool If True, divide unit-sphere derivatives by ``uxgrid.sphere_radius`` for - physical units (requires a UXarray release that supports it and a grid - with ``sphere_radius``). Default False preserves the unit-sphere result. - Honored on both local and remote execution when the active UXarray - supports it; otherwise the result stays on the unit sphere. + physical units (requires a grid with ``sphere_radius``). Default False + preserves the unit-sphere result. Local execution passes this to the + pinned UXarray directly. The remote worker, which may run an older + UXarray, applies it capability-safely and falls back to the unit sphere + if unsupported; the result reports the ``scale_by_radius`` actually + applied. use_remote : bool If True and an HPC endpoint is configured, execute remotely. endpoint : str, optional @@ -194,10 +196,12 @@ def calculate_curl( Meridional (north-south) component, e.g. ``"uReconstructMeridional"``. scale_by_radius : bool If True, divide the unit-sphere result by ``uxgrid.sphere_radius`` for - physical units (requires a UXarray release that supports it and a grid - with ``sphere_radius``). Default False preserves the unit-sphere result. - Honored on both local and remote execution when the active UXarray - supports it; otherwise the result stays on the unit sphere. + physical units (requires a grid with ``sphere_radius``). Default False + preserves the unit-sphere result. Local execution passes this to the + pinned UXarray directly. The remote worker, which may run an older + UXarray, applies it capability-safely and falls back to the unit sphere + if unsupported; the result reports the ``scale_by_radius`` actually + applied. use_remote : bool If True and an HPC endpoint is configured, execute remotely. endpoint : str, optional