uxarray-mcp 0.1.3__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/PKG-INFO +8 -2
  2. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/README.md +7 -1
  3. uxarray_mcp-0.2.0/pyproject.toml +108 -0
  4. uxarray_mcp-0.1.3/pyproject.toml → uxarray_mcp-0.2.0/pyproject.toml.orig +1 -1
  5. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/__init__.py +1 -1
  6. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/area.py +7 -2
  7. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/vector_calc.py +182 -18
  8. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/provenance.py +17 -0
  9. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/remote/agent.py +24 -4
  10. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/remote/compute_functions.py +182 -18
  11. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/state.py +30 -4
  12. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/capabilities.py +79 -8
  13. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/frontdoor.py +17 -3
  14. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/orchestration.py +39 -3
  15. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/scientific_agent.py +1 -1
  16. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/vector_calc.py +89 -12
  17. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/LICENSE +0 -0
  18. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/__main__.py +0 -0
  19. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/app.py +0 -0
  20. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/cli.py +0 -0
  21. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/__init__.py +0 -0
  22. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/mesh.py +0 -0
  23. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/plotting.py +0 -0
  24. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/variable.py +0 -0
  25. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/zonal.py +0 -0
  26. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/py.typed +0 -0
  27. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/registry.py +0 -0
  28. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/remote/__init__.py +0 -0
  29. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/remote/config.py +0 -0
  30. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/remote/health.py +0 -0
  31. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/__init__.py +0 -0
  32. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/advanced.py +0 -0
  33. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/catalog.py +0 -0
  34. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/execution_control.py +0 -0
  35. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/inspection.py +0 -0
  36. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/plotting.py +0 -0
  37. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/remote_tools.py +0 -0
  38. {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/stateful.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: uxarray-mcp
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: MCP server for analyzing unstructured meshes with UXarray
5
5
  Keywords: uxarray,mcp,unstructured grids,scientific computing,globus compute
6
6
  Author: Rajeev Jain, Dayan Abdulla
@@ -427,10 +427,16 @@ auditable and the server actively flags common scientific pitfalls:
427
427
  - **Derivative unit convention is never hidden.** `gradient`, `curl`, and
428
428
  `divergence` echo `scale_by_radius` in both the result and provenance, so a
429
429
  unit-sphere result can never be mistaken for a physical (per-metre) one.
430
+ Gradient and curl default to physical scaling, matching UXarray; pass
431
+ `scale_by_radius=False` explicitly for unit-sphere output.
430
432
  - **Vector-calculus sanity guard.** `curl`/`divergence` warn (without blocking)
431
433
  when the two inputs are the same field, or when neither carries a
432
434
  velocity/flux-like `units` attribute — the classic "vorticity from two random
433
- scalars" mistake now surfaces a warning in `_provenance.warnings`.
435
+ scalars" mistake now surfaces a warning in `_provenance.warnings` and a
436
+ machine-actionable `scientific_status` with stable warning codes.
437
+ - **Applicability is not suitability.** `get_capabilities` reports whether
438
+ vector operations are structurally computable separately from whether
439
+ metadata supports physical interpretation.
434
440
  - **Local/remote version drift is surfaced.** Remote results record the
435
441
  worker's *actual* UXarray version (`remote_uxarray_version`) and emit a
436
442
  warning when it differs from the local version, so silent numerical
@@ -191,10 +191,16 @@ auditable and the server actively flags common scientific pitfalls:
191
191
  - **Derivative unit convention is never hidden.** `gradient`, `curl`, and
192
192
  `divergence` echo `scale_by_radius` in both the result and provenance, so a
193
193
  unit-sphere result can never be mistaken for a physical (per-metre) one.
194
+ Gradient and curl default to physical scaling, matching UXarray; pass
195
+ `scale_by_radius=False` explicitly for unit-sphere output.
194
196
  - **Vector-calculus sanity guard.** `curl`/`divergence` warn (without blocking)
195
197
  when the two inputs are the same field, or when neither carries a
196
198
  velocity/flux-like `units` attribute — the classic "vorticity from two random
197
- scalars" mistake now surfaces a warning in `_provenance.warnings`.
199
+ scalars" mistake now surfaces a warning in `_provenance.warnings` and a
200
+ machine-actionable `scientific_status` with stable warning codes.
201
+ - **Applicability is not suitability.** `get_capabilities` reports whether
202
+ vector operations are structurally computable separately from whether
203
+ metadata supports physical interpretation.
198
204
  - **Local/remote version drift is surfaced.** Remote results record the
199
205
  worker's *actual* UXarray version (`remote_uxarray_version`) and emit a
200
206
  warning when it differs from the local version, so silent numerical
@@ -0,0 +1,108 @@
1
+ [project]
2
+ name = "uxarray-mcp"
3
+ version = "0.2.0"
4
+ description = "MCP server for analyzing unstructured meshes with UXarray"
5
+ readme = "README.md"
6
+ keywords = [
7
+ "uxarray",
8
+ "mcp",
9
+ "unstructured grids",
10
+ "scientific computing",
11
+ "globus compute",
12
+ ]
13
+ classifiers = [
14
+ "Operating System :: OS Independent",
15
+ "Intended Audience :: Science/Research",
16
+ "Topic :: Scientific/Engineering",
17
+ "Programming Language :: Python",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "License :: OSI Approved :: Apache Software License",
21
+ ]
22
+ requires-python = ">=3.12,<3.13"
23
+ dependencies = [
24
+ "toolregistry-server[mcp]>=0.4.0",
25
+ "holoviews>=1.19.0",
26
+ "matplotlib>=3.9.0",
27
+ "pyyaml>=6.0",
28
+ "uxarray>=2026.6.0",
29
+ ]
30
+
31
+ [[project.authors]]
32
+ name = "Rajeev Jain"
33
+ email = "rajeeja@gmail.com"
34
+
35
+ [[project.authors]]
36
+ name = "Dayan Abdulla"
37
+ email = "dayanabdulla000@gmail.com"
38
+
39
+ [project.license]
40
+ file = "LICENSE"
41
+
42
+ [project.optional-dependencies]
43
+ openapi = ["toolregistry-server[openapi]>=0.4.0"]
44
+ hpc = [
45
+ "academy-py>=0.3.1",
46
+ "globus-compute-sdk>=4.5.0",
47
+ ]
48
+ docs = [
49
+ "sphinx>=7.0",
50
+ "sphinx-book-theme>=1.1.0",
51
+ "myst-parser>=3.0",
52
+ ]
53
+
54
+ [project.scripts]
55
+ uxarray-mcp = "uxarray_mcp.cli:main"
56
+
57
+ [project.urls]
58
+ Documentation = "https://uxarray-mcp-server.readthedocs.io"
59
+ Source = "https://github.com/UXARRAY/uxarray-mcp-server"
60
+ Tracker = "https://github.com/UXARRAY/uxarray-mcp-server/issues"
61
+
62
+ [dependency-groups]
63
+ dev = [
64
+ "mypy>=1.10.0",
65
+ "pre-commit>=4.3.0",
66
+ "pytest>=9.0.2",
67
+ "pytest-asyncio>=1.0.0",
68
+ "ruff>=0.15.2",
69
+ ]
70
+
71
+ [tool.mypy]
72
+ python_version = "3.12"
73
+ check_untyped_defs = true
74
+ ignore_missing_imports = true
75
+ warn_unused_ignores = true
76
+ warn_return_any = false
77
+ exclude = [
78
+ "tests/",
79
+ "docs/",
80
+ ]
81
+
82
+ [[tool.mypy.overrides]]
83
+ module = "yaml"
84
+ ignore_missing_imports = true
85
+
86
+ [[tool.mypy.overrides]]
87
+ module = "uxarray_mcp.remote.agent"
88
+ warn_unused_ignores = false
89
+
90
+ [tool.ruff]
91
+ extend-exclude = ["docs/architecture.html"]
92
+
93
+ [tool.ruff.lint]
94
+ extend-select = ["I"]
95
+
96
+ [tool.ruff.lint.per-file-ignores]
97
+ "notebooks/*.py" = ["E402"]
98
+ "notebooks/*.ipynb" = ["E402"]
99
+
100
+ [tool.ruff.lint.isort]
101
+ known-first-party = ["uxarray_mcp"]
102
+
103
+ [tool.ruff.format]
104
+ docstring-code-format = true
105
+
106
+ [build-system]
107
+ requires = ["uv_build>=0.9.26,<0.10.0"]
108
+ build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uxarray-mcp"
3
- version = "0.1.3"
3
+ version = "0.2.0"
4
4
  description = "MCP server for analyzing unstructured meshes with UXarray"
5
5
  readme = "README.md"
6
6
  keywords = ["uxarray", "mcp", "unstructured grids", "scientific computing", "globus compute"]
@@ -3,4 +3,4 @@
3
3
  from uxarray_mcp.tools import inspect_mesh
4
4
 
5
5
  __all__ = ["inspect_mesh"]
6
- __version__ = "0.1.3"
6
+ __version__ = "0.2.0"
@@ -14,11 +14,16 @@ def compute_area_stats(grid: Any) -> dict:
14
14
  Returns
15
15
  -------
16
16
  dict
17
- Keys: total_area, mean_area, min_area, max_area, area_units, n_face
17
+ Keys: total_area, mean_area, min_area, max_area, area_units, n_face.
18
+ ``area_units`` is ``None`` when the grid carries no ``units``
19
+ attribute at all -- reporting a fabricated ``"m^2"`` default in
20
+ that case would silently invent metadata the source file never
21
+ provided, which is the exact failure mode this server's
22
+ provenance and guardrail mechanisms exist to prevent.
18
23
  """
19
24
  face_areas = grid.face_areas
20
25
 
21
- area_units = "m^2"
26
+ area_units = None
22
27
  if hasattr(face_areas, "attrs") and "units" in face_areas.attrs:
23
28
  area_units = face_areas.attrs["units"]
24
29
 
@@ -2,7 +2,31 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any
5
+ import warnings as _warnings_module
6
+ from typing import Any, Callable, TypeVar
7
+
8
+ _T = TypeVar("_T")
9
+
10
+
11
+ def _call_capturing_warnings(fn: Callable[[], _T]) -> tuple[_T, list[str]]:
12
+ """Call ``fn`` and capture any Python warnings it raises as strings.
13
+
14
+ UXarray itself emits ``UserWarning``s for conditions a caller needs to
15
+ know about to trust a result -- e.g. ``scale_by_radius=True`` silently
16
+ falling back to unit-sphere output when the grid has no
17
+ ``sphere_radius`` attribute. Those warnings normally only reach a
18
+ terminal's stderr and are invisible to an agent reading the tool's
19
+ structured JSON result. Capturing them here and merging them into the
20
+ same ``warnings`` list as our own guardrail messages means every
21
+ warning that would change how a scientist should trust the number ends
22
+ up in ``_provenance.warnings``, not just the ones this server authored.
23
+ """
24
+ with _warnings_module.catch_warnings(record=True) as caught:
25
+ _warnings_module.simplefilter("always")
26
+ value = fn()
27
+ messages = [str(w.message) for w in caught]
28
+ return value, messages
29
+
6
30
 
7
31
  # Units that look like a genuine 2-D vector (velocity/flux) component. Used only
8
32
  # to raise a soft, non-blocking warning when curl/divergence inputs do not look
@@ -23,13 +47,62 @@ _VELOCITY_LIKE_UNIT_HINTS = (
23
47
  )
24
48
 
25
49
 
50
+ _FACE_DIMS = {"n_face", "nCells"}
51
+
52
+ # Common non-spatial dimension names, in the order we prefer to select from
53
+ # when a caller gives a single generic index but the data has both a time and
54
+ # a vertical dimension (rare, but keeps behavior predictable).
55
+ _TIME_DIM_NAMES = ("time", "Time", "time_counter")
56
+ _LEVEL_DIM_NAMES = ("lev", "level", "levels", "plev", "z", "nVertLevels")
57
+
58
+
59
+ def _reduce_to_face(
60
+ var: Any,
61
+ *,
62
+ time_index: int = 0,
63
+ level_index: int = 0,
64
+ ) -> Any:
65
+ """Select a single time/level slice so ``var`` is 1-D face-centered.
66
+
67
+ UXarray's vector-calculus operators (``.curl()``, ``.divergence()``,
68
+ ``.gradient()``) require a single face-centered dimension with no leading
69
+ time/level/ensemble axes. Real model output almost always carries at
70
+ least a time dimension, and 3-D fields (e.g. atmospheric wind) also carry
71
+ a vertical dimension, so gateway tools take explicit ``time_index`` /
72
+ ``level_index`` selectors rather than requiring the caller to pre-slice
73
+ the file themselves.
74
+
75
+ Any extra dimension not recognized as time-like or level-like, and not
76
+ size-1, is squeezed via index 0 with a caveat left to the caller to
77
+ surface as a warning if desired.
78
+ """
79
+ extra = [d for d in var.dims if d not in _FACE_DIMS]
80
+ if not extra:
81
+ return var
82
+
83
+ selection: dict[str, int] = {}
84
+ for dim in extra:
85
+ if var.sizes[dim] == 1:
86
+ selection[dim] = 0
87
+ elif dim in _TIME_DIM_NAMES:
88
+ selection[dim] = time_index
89
+ elif dim in _LEVEL_DIM_NAMES:
90
+ selection[dim] = level_index
91
+ else:
92
+ # Unrecognized extra dimension: fall back to the first slice
93
+ # rather than erroring, matching plotting.py's existing behavior.
94
+ selection[dim] = 0
95
+
96
+ return var.isel(**selection)
97
+
98
+
26
99
  def _vector_component_warnings(
27
100
  u_variable: str,
28
101
  v_variable: str,
29
102
  u: Any,
30
103
  v: Any,
31
104
  operation: str,
32
- ) -> list[str]:
105
+ ) -> tuple[list[str], list[str]]:
33
106
  """Return soft warnings when (u, v) do not look like real vector components.
34
107
 
35
108
  curl and divergence are only physically meaningful when ``u`` and ``v`` are
@@ -45,8 +118,10 @@ def _vector_component_warnings(
45
118
  scientist — can see that the inputs were suspicious.
46
119
  """
47
120
  warnings: list[str] = []
121
+ warning_codes: list[str] = []
48
122
 
49
123
  if u_variable == v_variable:
124
+ warning_codes.append("VECTOR_COMPONENTS_IDENTICAL")
50
125
  warnings.append(
51
126
  f"{operation}: u_variable and v_variable are the same field "
52
127
  f"('{u_variable}'). {operation} is only physically meaningful for a "
@@ -65,6 +140,7 @@ def _vector_component_warnings(
65
140
  return any(hint in unit for hint in _VELOCITY_LIKE_UNIT_HINTS)
66
141
 
67
142
  if not (_looks_velocity(u_unit) or _looks_velocity(v_unit)):
143
+ warning_codes.append("VECTOR_UNITS_UNVERIFIED")
68
144
  seen = ", ".join(
69
145
  f"{name}='{unit or 'unset'}'"
70
146
  for name, unit in ((u_variable, u_unit), (v_variable, v_unit))
@@ -76,11 +152,15 @@ def _vector_component_warnings(
76
152
  f"{operation} physically."
77
153
  )
78
154
 
79
- return warnings
155
+ return warnings, warning_codes
80
156
 
81
157
 
82
158
  def compute_gradient(
83
- uxds: Any, variable_name: str, scale_by_radius: bool = False
159
+ uxds: Any,
160
+ variable_name: str,
161
+ scale_by_radius: bool = True,
162
+ time_index: int = 0,
163
+ level_index: int = 0,
84
164
  ) -> dict:
85
165
  """Compute the gradient of a face-centered scalar field.
86
166
 
@@ -94,10 +174,17 @@ def compute_gradient(
94
174
  Loaded UXarray dataset.
95
175
  variable_name : str
96
176
  Face-centered scalar variable to differentiate.
97
- scale_by_radius : bool, default False
177
+ scale_by_radius : bool, default True
98
178
  When ``True``, divide the unit-sphere derivatives by
99
179
  ``uxgrid.sphere_radius`` to return physical units (requires a grid with
100
- ``sphere_radius``). The default ``False`` keeps the unit-sphere result.
180
+ ``sphere_radius``). Pass ``False`` to keep the unit-sphere result.
181
+ time_index : int, default 0
182
+ Time index to select if the variable carries a leading time
183
+ dimension. Ignored if there is no time dimension.
184
+ level_index : int, default 0
185
+ Vertical-level index to select if the variable carries a level
186
+ dimension (e.g. atmospheric ``lev``). Ignored if there is no level
187
+ dimension.
101
188
 
102
189
  Returns
103
190
  -------
@@ -115,11 +202,21 @@ def compute_gradient(
115
202
  f"Variable '{variable_name}' is not face-centered. "
116
203
  "Gradient requires face-centered data."
117
204
  )
205
+ var = _reduce_to_face(var, time_index=time_index, level_index=level_index)
118
206
 
119
207
  import numpy as np
120
208
 
121
209
  # gradient() returns a UxDataset with zonal and meridional components.
122
- grad = var.gradient(scale_by_radius=scale_by_radius)
210
+ grad, uxarray_warnings = _call_capturing_warnings(
211
+ lambda: var.gradient(scale_by_radius=scale_by_radius)
212
+ )
213
+ _dedup_seen: set[str] = set()
214
+ _deduped: list[str] = []
215
+ for _w in uxarray_warnings:
216
+ if _w not in _dedup_seen:
217
+ _dedup_seen.add(_w)
218
+ _deduped.append(_w)
219
+ uxarray_warnings = _deduped
123
220
  comp_names = list(grad.data_vars)
124
221
 
125
222
  def _stats(arr: Any) -> dict:
@@ -135,18 +232,31 @@ def compute_gradient(
135
232
 
136
233
  components = {name: _stats(grad[name]) for name in comp_names}
137
234
 
138
- return {
235
+ result = {
139
236
  "variable_name": variable_name,
140
237
  "components": comp_names,
141
238
  "component_stats": components,
142
239
  "n_face": int(uxds.uxgrid.n_face),
143
240
  "scale_by_radius": bool(scale_by_radius),
144
241
  "interpretation": "zonal (∂/∂x) and meridional (∂/∂y) components of the gradient",
242
+ "component_warnings": uxarray_warnings,
145
243
  }
244
+ from uxarray_mcp.provenance import attach_scientific_status
245
+
246
+ return attach_scientific_status(
247
+ result,
248
+ warnings=uxarray_warnings,
249
+ warning_codes=["SPHERE_RADIUS_UNAVAILABLE"] if uxarray_warnings else [],
250
+ )
146
251
 
147
252
 
148
253
  def compute_curl(
149
- uxds: Any, u_variable: str, v_variable: str, scale_by_radius: bool = False
254
+ uxds: Any,
255
+ u_variable: str,
256
+ v_variable: str,
257
+ scale_by_radius: bool = True,
258
+ time_index: int = 0,
259
+ level_index: int = 0,
150
260
  ) -> dict:
151
261
  """Compute the curl (relative vorticity) of a 2-D vector field (u, v).
152
262
 
@@ -163,10 +273,17 @@ def compute_curl(
163
273
  Zonal (east–west) component variable name.
164
274
  v_variable : str
165
275
  Meridional (north–south) component variable name.
166
- scale_by_radius : bool, default False
276
+ scale_by_radius : bool, default True
167
277
  When ``True``, divide the unit-sphere result by ``uxgrid.sphere_radius``
168
278
  to return physical units (``1/s`` for wind in ``m/s``; requires a grid
169
- with ``sphere_radius``). The default ``False`` keeps the unit sphere.
279
+ with ``sphere_radius``). Pass ``False`` to keep the unit sphere.
280
+ time_index : int, default 0
281
+ Time index to select if the components carry a leading time
282
+ dimension. Ignored if there is no time dimension.
283
+ level_index : int, default 0
284
+ Vertical-level index to select if the components carry a level
285
+ dimension (e.g. atmospheric ``lev``). Ignored if there is no level
286
+ dimension.
170
287
 
171
288
  Returns
172
289
  -------
@@ -186,14 +303,31 @@ def compute_curl(
186
303
  f"Variable '{name}' is not face-centered. "
187
304
  "Curl requires face-centered vector components."
188
305
  )
306
+ u = _reduce_to_face(u, time_index=time_index, level_index=level_index)
307
+ v = _reduce_to_face(v, time_index=time_index, level_index=level_index)
189
308
 
190
309
  import numpy as np
191
310
 
192
- component_warnings = _vector_component_warnings(
311
+ component_warnings, warning_codes = _vector_component_warnings(
193
312
  u_variable, v_variable, u, v, "curl"
194
313
  )
195
314
 
196
- result = u.curl(v, scale_by_radius=scale_by_radius)
315
+ result, uxarray_warnings = _call_capturing_warnings(
316
+ lambda: u.curl(v, scale_by_radius=scale_by_radius)
317
+ )
318
+ # UXarray's curl() computes an internal gradient per component, so the
319
+ # same sphere_radius UserWarning can fire more than once for one call;
320
+ # de-duplicate while preserving order for a clean, non-repetitive
321
+ # provenance record.
322
+ seen: set[str] = set()
323
+ deduped_uxarray_warnings = []
324
+ for w in uxarray_warnings:
325
+ if w not in seen:
326
+ seen.add(w)
327
+ deduped_uxarray_warnings.append(w)
328
+ component_warnings = component_warnings + deduped_uxarray_warnings
329
+ if deduped_uxarray_warnings:
330
+ warning_codes.append("SPHERE_RADIUS_UNAVAILABLE")
197
331
  vals = result.values
198
332
  finite = vals[np.isfinite(vals)]
199
333
 
@@ -208,7 +342,7 @@ def compute_curl(
208
342
  else:
209
343
  stats = {"min": None, "max": None, "mean": None, "std": None}
210
344
 
211
- return {
345
+ result = {
212
346
  "u_variable": u_variable,
213
347
  "v_variable": v_variable,
214
348
  "interpretation": "relative vorticity ζ = ∂v/∂x − ∂u/∂y",
@@ -217,9 +351,20 @@ def compute_curl(
217
351
  "stats": stats,
218
352
  "component_warnings": component_warnings,
219
353
  }
354
+ from uxarray_mcp.provenance import attach_scientific_status
220
355
 
356
+ return attach_scientific_status(
357
+ result, warnings=component_warnings, warning_codes=warning_codes
358
+ )
221
359
 
222
- def compute_divergence(uxds: Any, u_variable: str, v_variable: str) -> dict:
360
+
361
+ def compute_divergence(
362
+ uxds: Any,
363
+ u_variable: str,
364
+ v_variable: str,
365
+ time_index: int = 0,
366
+ level_index: int = 0,
367
+ ) -> dict:
223
368
  """Compute the horizontal divergence of a 2-D vector field (u, v).
224
369
 
225
370
  Divergence = ∂u/∂x + ∂v/∂y.
@@ -236,6 +381,13 @@ def compute_divergence(uxds: Any, u_variable: str, v_variable: str) -> dict:
236
381
  Zonal (east–west) component variable name.
237
382
  v_variable : str
238
383
  Meridional (north–south) component variable name.
384
+ time_index : int, default 0
385
+ Time index to select if the components carry a leading time
386
+ dimension. Ignored if there is no time dimension.
387
+ level_index : int, default 0
388
+ Vertical-level index to select if the components carry a level
389
+ dimension (e.g. atmospheric ``lev``). Ignored if there is no level
390
+ dimension.
239
391
 
240
392
  Returns
241
393
  -------
@@ -255,14 +407,21 @@ def compute_divergence(uxds: Any, u_variable: str, v_variable: str) -> dict:
255
407
  f"Variable '{name}' is not face-centered. "
256
408
  "Divergence requires face-centered vector components."
257
409
  )
410
+ u = _reduce_to_face(u, time_index=time_index, level_index=level_index)
411
+ v = _reduce_to_face(v, time_index=time_index, level_index=level_index)
258
412
 
259
413
  import numpy as np
260
414
 
261
- component_warnings = _vector_component_warnings(
415
+ component_warnings, warning_codes = _vector_component_warnings(
262
416
  u_variable, v_variable, u, v, "divergence"
263
417
  )
264
418
 
265
- result = u.divergence(v)
419
+ result, uxarray_warnings = _call_capturing_warnings(lambda: u.divergence(v))
420
+ for warning in uxarray_warnings:
421
+ if warning not in component_warnings:
422
+ component_warnings.append(warning)
423
+ if uxarray_warnings:
424
+ warning_codes.append("SPHERE_RADIUS_UNAVAILABLE")
266
425
  vals = result.values
267
426
  finite = vals[np.isfinite(vals)]
268
427
 
@@ -277,7 +436,7 @@ def compute_divergence(uxds: Any, u_variable: str, v_variable: str) -> dict:
277
436
  else:
278
437
  stats = {"min": None, "max": None, "mean": None, "std": None}
279
438
 
280
- return {
439
+ output = {
281
440
  "u_variable": u_variable,
282
441
  "v_variable": v_variable,
283
442
  "interpretation": "horizontal divergence ∂u/∂x + ∂v/∂y",
@@ -285,6 +444,11 @@ def compute_divergence(uxds: Any, u_variable: str, v_variable: str) -> dict:
285
444
  "stats": stats,
286
445
  "component_warnings": component_warnings,
287
446
  }
447
+ from uxarray_mcp.provenance import attach_scientific_status
448
+
449
+ return attach_scientific_status(
450
+ output, warnings=component_warnings, warning_codes=warning_codes
451
+ )
288
452
 
289
453
 
290
454
  def compute_azimuthal_mean(
@@ -77,3 +77,20 @@ def attach_provenance(
77
77
  provenance["validation_summary"] = validation_summary
78
78
  result["_provenance"] = provenance
79
79
  return result
80
+
81
+
82
+ def attach_scientific_status(
83
+ result: dict[str, Any],
84
+ *,
85
+ warnings: list[str] | None = None,
86
+ warning_codes: list[str] | None = None,
87
+ ) -> dict[str, Any]:
88
+ """Attach a machine-actionable scientific interpretation status."""
89
+ messages = warnings or []
90
+ result["scientific_status"] = {
91
+ "status": "warning" if messages else "complete",
92
+ "physically_interpretable": not messages,
93
+ "warning_codes": warning_codes or [],
94
+ "warnings": messages,
95
+ }
96
+ return result
@@ -218,7 +218,9 @@ class UXarrayComputeAgent(_AcademyAgent):
218
218
  grid_path: str,
219
219
  data_path: str,
220
220
  variable_name: str,
221
- scale_by_radius: bool = False,
221
+ scale_by_radius: bool = True,
222
+ time_index: int = 0,
223
+ level_index: int = 0,
222
224
  ) -> Dict[str, Any]:
223
225
  """Compute spatial gradient on HPC."""
224
226
  return await self._run_on_hpc(
@@ -227,6 +229,8 @@ class UXarrayComputeAgent(_AcademyAgent):
227
229
  data_path,
228
230
  variable_name,
229
231
  scale_by_radius,
232
+ time_index,
233
+ level_index,
230
234
  )
231
235
 
232
236
  @action
@@ -236,7 +240,9 @@ class UXarrayComputeAgent(_AcademyAgent):
236
240
  data_path: str,
237
241
  u_variable: str,
238
242
  v_variable: str,
239
- scale_by_radius: bool = False,
243
+ scale_by_radius: bool = True,
244
+ time_index: int = 0,
245
+ level_index: int = 0,
240
246
  ) -> Dict[str, Any]:
241
247
  """Compute relative vorticity (curl) on HPC."""
242
248
  return await self._run_on_hpc(
@@ -246,15 +252,29 @@ class UXarrayComputeAgent(_AcademyAgent):
246
252
  u_variable,
247
253
  v_variable,
248
254
  scale_by_radius,
255
+ time_index,
256
+ level_index,
249
257
  )
250
258
 
251
259
  @action
252
260
  async def calculate_divergence_remote(
253
- self, grid_path: str, data_path: str, u_variable: str, v_variable: str
261
+ self,
262
+ grid_path: str,
263
+ data_path: str,
264
+ u_variable: str,
265
+ v_variable: str,
266
+ time_index: int = 0,
267
+ level_index: int = 0,
254
268
  ) -> Dict[str, Any]:
255
269
  """Compute horizontal divergence on HPC."""
256
270
  return await self._run_on_hpc(
257
- remote_calculate_divergence, grid_path, data_path, u_variable, v_variable
271
+ remote_calculate_divergence,
272
+ grid_path,
273
+ data_path,
274
+ u_variable,
275
+ v_variable,
276
+ time_index,
277
+ level_index,
258
278
  )
259
279
 
260
280
  @action