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.
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/PKG-INFO +8 -2
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/README.md +7 -1
- uxarray_mcp-0.2.0/pyproject.toml +108 -0
- uxarray_mcp-0.1.3/pyproject.toml → uxarray_mcp-0.2.0/pyproject.toml.orig +1 -1
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/__init__.py +1 -1
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/area.py +7 -2
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/vector_calc.py +182 -18
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/provenance.py +17 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/remote/agent.py +24 -4
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/remote/compute_functions.py +182 -18
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/state.py +30 -4
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/capabilities.py +79 -8
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/frontdoor.py +17 -3
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/orchestration.py +39 -3
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/scientific_agent.py +1 -1
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/vector_calc.py +89 -12
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/LICENSE +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/__main__.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/app.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/cli.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/__init__.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/mesh.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/plotting.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/variable.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/domain/zonal.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/py.typed +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/registry.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/remote/__init__.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/remote/config.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/remote/health.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/__init__.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/advanced.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/catalog.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/execution_control.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/inspection.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/plotting.py +0 -0
- {uxarray_mcp-0.1.3 → uxarray_mcp-0.2.0}/src/uxarray_mcp/tools/remote_tools.py +0 -0
- {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.
|
|
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"
|
|
@@ -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 =
|
|
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
|
-
|
|
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,
|
|
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
|
|
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``).
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
|
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``).
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|