uxarray-mcp 0.1.0__py3-none-any.whl
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/__init__.py +7 -0
- uxarray_mcp/__main__.py +16 -0
- uxarray_mcp/cli.py +356 -0
- uxarray_mcp/domain/__init__.py +27 -0
- uxarray_mcp/domain/area.py +32 -0
- uxarray_mcp/domain/mesh.py +26 -0
- uxarray_mcp/domain/plotting.py +499 -0
- uxarray_mcp/domain/variable.py +77 -0
- uxarray_mcp/domain/vector_calc.py +256 -0
- uxarray_mcp/domain/zonal.py +66 -0
- uxarray_mcp/provenance.py +79 -0
- uxarray_mcp/py.typed +0 -0
- uxarray_mcp/remote/__init__.py +6 -0
- uxarray_mcp/remote/agent.py +493 -0
- uxarray_mcp/remote/compute_functions.py +1151 -0
- uxarray_mcp/remote/config.py +322 -0
- uxarray_mcp/remote/health.py +372 -0
- uxarray_mcp/server.py +230 -0
- uxarray_mcp/state.py +521 -0
- uxarray_mcp/tools/__init__.py +115 -0
- uxarray_mcp/tools/advanced.py +1110 -0
- uxarray_mcp/tools/capabilities.py +669 -0
- uxarray_mcp/tools/catalog.py +369 -0
- uxarray_mcp/tools/execution_control.py +763 -0
- uxarray_mcp/tools/inspection.py +557 -0
- uxarray_mcp/tools/orchestration.py +327 -0
- uxarray_mcp/tools/plotting.py +854 -0
- uxarray_mcp/tools/remote_tools.py +702 -0
- uxarray_mcp/tools/scientific_agent.py +367 -0
- uxarray_mcp/tools/stateful.py +402 -0
- uxarray_mcp/tools/vector_calc.py +432 -0
- uxarray_mcp-0.1.0.dist-info/METADATA +468 -0
- uxarray_mcp-0.1.0.dist-info/RECORD +35 -0
- uxarray_mcp-0.1.0.dist-info/WHEEL +4 -0
- uxarray_mcp-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
"""Globus Compute functions for remote execution.
|
|
2
|
+
|
|
3
|
+
These functions are serialized and sent to HPC endpoints via AllCodeStrategies.
|
|
4
|
+
They must be FULLY SELF-CONTAINED — only import packages available on the HPC
|
|
5
|
+
environment (uxarray, numpy, etc.). Never import from uxarray_mcp here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def remote_runtime_probe() -> Dict[str, Any]:
|
|
14
|
+
"""Return lightweight runtime diagnostics from the remote worker."""
|
|
15
|
+
import getpass
|
|
16
|
+
import importlib.util
|
|
17
|
+
import os
|
|
18
|
+
import platform
|
|
19
|
+
import shutil
|
|
20
|
+
import socket
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
modules: Dict[str, Any] = {}
|
|
24
|
+
for name in ("uxarray", "xarray", "numpy"):
|
|
25
|
+
spec = importlib.util.find_spec(name)
|
|
26
|
+
info: Dict[str, Any] = {"available": spec is not None}
|
|
27
|
+
if spec is not None:
|
|
28
|
+
try:
|
|
29
|
+
module = __import__(name)
|
|
30
|
+
info["version"] = getattr(module, "__version__", None)
|
|
31
|
+
info["file"] = getattr(module, "__file__", None)
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
info["import_error"] = f"{type(exc).__name__}: {exc}"
|
|
34
|
+
modules[name] = info
|
|
35
|
+
|
|
36
|
+
yac_info: Dict[str, Any] = {}
|
|
37
|
+
try:
|
|
38
|
+
yac_spec = importlib.util.find_spec("yac")
|
|
39
|
+
yac_info["package_available"] = yac_spec is not None
|
|
40
|
+
if yac_spec is not None:
|
|
41
|
+
yac_info["package_origin"] = yac_spec.origin
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
yac_info["package_available"] = False
|
|
44
|
+
yac_info["package_error"] = f"{type(exc).__name__}: {exc}"
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
yac_core = importlib.import_module("yac.core")
|
|
48
|
+
yac_info["core_importable"] = True
|
|
49
|
+
yac_info["core_file"] = getattr(yac_core, "__file__", None)
|
|
50
|
+
yac_info["version"] = getattr(yac_core, "__version__", None)
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
yac_info["core_importable"] = False
|
|
53
|
+
yac_info["core_import_error"] = f"{type(exc).__name__}: {exc}"
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from uxarray.remap.yac import _import_yac
|
|
57
|
+
|
|
58
|
+
yc = _import_yac()
|
|
59
|
+
yac_info["uxarray_helper_ok"] = True
|
|
60
|
+
yac_info["uxarray_helper_module"] = getattr(yc, "__name__", None)
|
|
61
|
+
yac_info["uxarray_helper_file"] = getattr(yc, "__file__", None)
|
|
62
|
+
yac_info["has_basicgrid"] = hasattr(yc, "BasicGrid")
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
yac_info["uxarray_helper_ok"] = False
|
|
65
|
+
yac_info["uxarray_helper_error"] = f"{type(exc).__name__}: {exc}"
|
|
66
|
+
modules["yac"] = yac_info
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"hostname": socket.gethostname(),
|
|
70
|
+
"user": getpass.getuser(),
|
|
71
|
+
"cwd": os.getcwd(),
|
|
72
|
+
"python_executable": sys.executable,
|
|
73
|
+
"python_version": platform.python_version(),
|
|
74
|
+
"qsub_path": shutil.which("qsub"),
|
|
75
|
+
"path_head": os.environ.get("PATH", "").split(":")[:5],
|
|
76
|
+
"modules": modules,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def remote_probe_path(file_path: str, inspect_netcdf: bool = True) -> Dict[str, Any]:
|
|
81
|
+
"""Return remote path accessibility details.
|
|
82
|
+
|
|
83
|
+
This is intentionally simpler than UXarray inspection. The goal is to prove
|
|
84
|
+
that a remote worker can reach and read the exact target path before
|
|
85
|
+
debugging mesh parsing or scheduler fan-out.
|
|
86
|
+
"""
|
|
87
|
+
import os
|
|
88
|
+
import socket
|
|
89
|
+
from pathlib import Path
|
|
90
|
+
|
|
91
|
+
path = Path(file_path)
|
|
92
|
+
exists = path.exists()
|
|
93
|
+
is_file = path.is_file()
|
|
94
|
+
readable = os.access(path, os.R_OK) if exists else False
|
|
95
|
+
|
|
96
|
+
result: Dict[str, Any] = {
|
|
97
|
+
"path": str(path),
|
|
98
|
+
"hostname": socket.gethostname(),
|
|
99
|
+
"exists": exists,
|
|
100
|
+
"is_file": is_file,
|
|
101
|
+
"readable": readable,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if exists:
|
|
105
|
+
stat = path.stat()
|
|
106
|
+
result["size_bytes"] = int(stat.st_size)
|
|
107
|
+
result["mtime_epoch"] = float(stat.st_mtime)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
with path.open("rb") as handle:
|
|
111
|
+
result["header_hex"] = handle.read(8).hex()
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
result["header_error"] = f"{type(exc).__name__}: {exc}"
|
|
114
|
+
|
|
115
|
+
if inspect_netcdf and exists and readable and is_file:
|
|
116
|
+
try:
|
|
117
|
+
import xarray as xr
|
|
118
|
+
|
|
119
|
+
with xr.open_dataset(file_path, decode_cf=False) as ds:
|
|
120
|
+
result["netcdf"] = {
|
|
121
|
+
"opened": True,
|
|
122
|
+
"dims": {name: int(size) for name, size in ds.sizes.items()},
|
|
123
|
+
"data_vars": list(ds.data_vars)[:20],
|
|
124
|
+
"coords": list(ds.coords)[:20],
|
|
125
|
+
"attrs_keys": sorted(list(ds.attrs.keys()))[:20],
|
|
126
|
+
}
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
result["netcdf"] = {
|
|
129
|
+
"opened": False,
|
|
130
|
+
"error_type": type(exc).__name__,
|
|
131
|
+
"error": str(exc),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def remote_inspect_mesh(file_path: str) -> Dict[str, Any]:
|
|
138
|
+
"""Inspect mesh topology on remote HPC node.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
file_path : str
|
|
143
|
+
Path to mesh file on HPC filesystem
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
dict
|
|
148
|
+
Mesh topology including n_face, n_node, n_edge, source
|
|
149
|
+
|
|
150
|
+
Notes
|
|
151
|
+
-----
|
|
152
|
+
This function executes on the HPC endpoint, not locally.
|
|
153
|
+
All imports must be within function scope for serialization.
|
|
154
|
+
"""
|
|
155
|
+
import uxarray as ux
|
|
156
|
+
|
|
157
|
+
if file_path.startswith("healpix:"):
|
|
158
|
+
zoom = int(file_path.split(":")[1])
|
|
159
|
+
grid = ux.Grid.from_healpix(zoom)
|
|
160
|
+
else:
|
|
161
|
+
grid = ux.open_grid(file_path)
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
"n_face": int(grid.n_face),
|
|
165
|
+
"n_node": int(grid.n_node),
|
|
166
|
+
"n_edge": int(grid.n_edge),
|
|
167
|
+
"source": file_path,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def remote_calculate_area(file_path: str) -> Dict[str, Any]:
|
|
172
|
+
"""Calculate face areas on remote HPC node.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
file_path : str
|
|
177
|
+
Path to mesh file on HPC filesystem
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
dict
|
|
182
|
+
Area statistics including total_area, mean_area, min_area, max_area
|
|
183
|
+
|
|
184
|
+
Notes
|
|
185
|
+
-----
|
|
186
|
+
This function executes on the HPC endpoint, not locally.
|
|
187
|
+
All imports must be within function scope for serialization.
|
|
188
|
+
"""
|
|
189
|
+
import numpy as np
|
|
190
|
+
import uxarray as ux
|
|
191
|
+
|
|
192
|
+
if file_path.startswith("healpix:"):
|
|
193
|
+
zoom = int(file_path.split(":")[1])
|
|
194
|
+
grid = ux.Grid.from_healpix(zoom)
|
|
195
|
+
else:
|
|
196
|
+
grid = ux.open_grid(file_path)
|
|
197
|
+
|
|
198
|
+
areas = grid.face_areas
|
|
199
|
+
units = getattr(areas, "units", "m^2") if hasattr(areas, "units") else "m^2"
|
|
200
|
+
values = areas.values if hasattr(areas, "values") else np.asarray(areas)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"total_area": float(np.sum(values)),
|
|
204
|
+
"mean_area": float(np.mean(values)),
|
|
205
|
+
"min_area": float(np.min(values)),
|
|
206
|
+
"max_area": float(np.max(values)),
|
|
207
|
+
"area_units": str(units),
|
|
208
|
+
"n_face": int(grid.n_face),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def remote_inspect_variable(
|
|
213
|
+
grid_path: str, data_path: str, variable_name: Optional[str] = None
|
|
214
|
+
) -> Dict[str, Any]:
|
|
215
|
+
"""Inspect data variables on remote HPC node.
|
|
216
|
+
|
|
217
|
+
Parameters
|
|
218
|
+
----------
|
|
219
|
+
grid_path : str
|
|
220
|
+
Path to grid file on HPC filesystem
|
|
221
|
+
data_path : str
|
|
222
|
+
Path to data file on HPC filesystem
|
|
223
|
+
variable_name : str | None
|
|
224
|
+
Specific variable to inspect, or None for all variables
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
dict
|
|
229
|
+
Variable metadata including dimensions, shapes, statistics
|
|
230
|
+
|
|
231
|
+
Notes
|
|
232
|
+
-----
|
|
233
|
+
This function executes on the HPC endpoint, not locally.
|
|
234
|
+
"""
|
|
235
|
+
import numpy as np
|
|
236
|
+
import uxarray as ux
|
|
237
|
+
|
|
238
|
+
uxds = ux.open_dataset(grid_path, data_path)
|
|
239
|
+
|
|
240
|
+
face_dims = {"n_face", "nCells"}
|
|
241
|
+
node_dims = {"n_node", "nVertices"}
|
|
242
|
+
edge_dims = {"n_edge", "nEdges"}
|
|
243
|
+
|
|
244
|
+
var_names = [variable_name] if variable_name else list(uxds.keys())
|
|
245
|
+
|
|
246
|
+
if variable_name and variable_name not in uxds:
|
|
247
|
+
raise ValueError(f"Variable '{variable_name}' not found in dataset")
|
|
248
|
+
|
|
249
|
+
variables = []
|
|
250
|
+
|
|
251
|
+
for name in var_names:
|
|
252
|
+
if name not in uxds:
|
|
253
|
+
continue
|
|
254
|
+
var = uxds[name]
|
|
255
|
+
dims = var.dims
|
|
256
|
+
|
|
257
|
+
location = "other"
|
|
258
|
+
if any(d in face_dims for d in dims):
|
|
259
|
+
location = "faces"
|
|
260
|
+
elif any(d in node_dims for d in dims):
|
|
261
|
+
location = "nodes"
|
|
262
|
+
elif any(d in edge_dims for d in dims):
|
|
263
|
+
location = "edges"
|
|
264
|
+
|
|
265
|
+
stats = None
|
|
266
|
+
try:
|
|
267
|
+
values = var.values
|
|
268
|
+
finite = values[np.isfinite(values)]
|
|
269
|
+
if len(finite) > 0:
|
|
270
|
+
stats = {
|
|
271
|
+
"min": float(np.min(finite)),
|
|
272
|
+
"max": float(np.max(finite)),
|
|
273
|
+
"mean": float(np.mean(finite)),
|
|
274
|
+
}
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
variables.append(
|
|
279
|
+
{
|
|
280
|
+
"name": name,
|
|
281
|
+
"dims": list(dims),
|
|
282
|
+
"shape": list(var.shape),
|
|
283
|
+
"dtype": str(var.dtype),
|
|
284
|
+
"location": location,
|
|
285
|
+
"attrs": dict(var.attrs),
|
|
286
|
+
"statistics": stats,
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
"variables": variables,
|
|
292
|
+
"grid_info": {
|
|
293
|
+
"n_face": int(uxds.uxgrid.n_face),
|
|
294
|
+
"n_node": int(uxds.uxgrid.n_node),
|
|
295
|
+
"n_edge": int(uxds.uxgrid.n_edge),
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def remote_plot_mesh(
|
|
301
|
+
grid_path: str,
|
|
302
|
+
width: int = 800,
|
|
303
|
+
height: int = 400,
|
|
304
|
+
) -> Dict[str, Any]:
|
|
305
|
+
"""Render a mesh wireframe on the remote HPC node and return base64 PNG.
|
|
306
|
+
|
|
307
|
+
Parameters
|
|
308
|
+
----------
|
|
309
|
+
grid_path : str
|
|
310
|
+
Path to mesh file on HPC filesystem, or "healpix:<zoom>".
|
|
311
|
+
width : int
|
|
312
|
+
Image width in pixels.
|
|
313
|
+
height : int
|
|
314
|
+
Image height in pixels.
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
dict
|
|
319
|
+
- png_b64: base64-encoded PNG string
|
|
320
|
+
- image_size_bytes: size of the PNG
|
|
321
|
+
- grid_info: n_face, n_node, n_edge
|
|
322
|
+
"""
|
|
323
|
+
import base64
|
|
324
|
+
import io
|
|
325
|
+
|
|
326
|
+
import matplotlib
|
|
327
|
+
|
|
328
|
+
matplotlib.use("Agg")
|
|
329
|
+
import matplotlib.pyplot as plt
|
|
330
|
+
import uxarray as ux
|
|
331
|
+
|
|
332
|
+
if grid_path.startswith("healpix:"):
|
|
333
|
+
zoom = int(grid_path.split(":")[1])
|
|
334
|
+
grid = ux.Grid.from_healpix(zoom)
|
|
335
|
+
else:
|
|
336
|
+
grid = ux.open_grid(grid_path)
|
|
337
|
+
|
|
338
|
+
import holoviews as hv
|
|
339
|
+
|
|
340
|
+
hv.extension("matplotlib")
|
|
341
|
+
|
|
342
|
+
dpi = 100
|
|
343
|
+
element = grid.plot.mesh(backend="matplotlib")
|
|
344
|
+
renderer = hv.Store.renderers["matplotlib"]
|
|
345
|
+
plot = renderer.get_plot(element)
|
|
346
|
+
fig = plot.state
|
|
347
|
+
fig.set_size_inches(width / dpi, height / dpi)
|
|
348
|
+
fig.set_dpi(dpi)
|
|
349
|
+
fig.tight_layout()
|
|
350
|
+
|
|
351
|
+
buf = io.BytesIO()
|
|
352
|
+
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
|
|
353
|
+
plt.close(fig)
|
|
354
|
+
buf.seek(0)
|
|
355
|
+
png_bytes = buf.read()
|
|
356
|
+
if not png_bytes:
|
|
357
|
+
raise ValueError("Rendered mesh plot is empty.")
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
"png_b64": base64.b64encode(png_bytes).decode("utf-8"),
|
|
361
|
+
"image_size_bytes": len(png_bytes),
|
|
362
|
+
"grid_info": {
|
|
363
|
+
"n_face": int(grid.n_face),
|
|
364
|
+
"n_node": int(grid.n_node),
|
|
365
|
+
"n_edge": int(grid.n_edge),
|
|
366
|
+
},
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def remote_plot_variable(
|
|
371
|
+
grid_path: str,
|
|
372
|
+
data_path: str,
|
|
373
|
+
variable_name: Optional[str] = None,
|
|
374
|
+
width: int = 800,
|
|
375
|
+
height: int = 400,
|
|
376
|
+
cmap: str = "viridis",
|
|
377
|
+
vmin: Optional[float] = None,
|
|
378
|
+
vmax: Optional[float] = None,
|
|
379
|
+
title: Optional[str] = None,
|
|
380
|
+
time_index: int = 0,
|
|
381
|
+
) -> Dict[str, Any]:
|
|
382
|
+
"""Render a face-centered variable plot on the remote HPC node and return base64 PNG.
|
|
383
|
+
|
|
384
|
+
Parameters
|
|
385
|
+
----------
|
|
386
|
+
grid_path : str
|
|
387
|
+
Path to mesh grid file on HPC filesystem.
|
|
388
|
+
data_path : str
|
|
389
|
+
Path to data file on HPC filesystem.
|
|
390
|
+
variable_name : str | None
|
|
391
|
+
Variable to plot. If None, first face-centered variable is used.
|
|
392
|
+
width : int
|
|
393
|
+
Image width in pixels.
|
|
394
|
+
height : int
|
|
395
|
+
Image height in pixels.
|
|
396
|
+
cmap : str
|
|
397
|
+
Matplotlib colormap name.
|
|
398
|
+
vmin : float | None
|
|
399
|
+
Colormap minimum.
|
|
400
|
+
vmax : float | None
|
|
401
|
+
Colormap maximum.
|
|
402
|
+
title : str | None
|
|
403
|
+
Plot title.
|
|
404
|
+
|
|
405
|
+
Returns
|
|
406
|
+
-------
|
|
407
|
+
dict
|
|
408
|
+
- png_b64: base64-encoded PNG string
|
|
409
|
+
- image_size_bytes: size of the PNG
|
|
410
|
+
- variable_name: plotted variable name
|
|
411
|
+
- grid_info: n_face, n_node, n_edge
|
|
412
|
+
"""
|
|
413
|
+
import base64
|
|
414
|
+
import io
|
|
415
|
+
|
|
416
|
+
import matplotlib
|
|
417
|
+
|
|
418
|
+
matplotlib.use("Agg")
|
|
419
|
+
import matplotlib.pyplot as plt
|
|
420
|
+
import uxarray as ux
|
|
421
|
+
|
|
422
|
+
uxds = ux.open_dataset(grid_path, data_path)
|
|
423
|
+
|
|
424
|
+
face_dims = {"n_face", "nCells"}
|
|
425
|
+
|
|
426
|
+
if variable_name is None:
|
|
427
|
+
for var in uxds.data_vars:
|
|
428
|
+
if any(d in face_dims for d in uxds[var].dims):
|
|
429
|
+
variable_name = var
|
|
430
|
+
break
|
|
431
|
+
if variable_name is None:
|
|
432
|
+
raise ValueError(
|
|
433
|
+
f"No face-centered variable found. Available: {list(uxds.data_vars.keys())}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if variable_name not in uxds.data_vars:
|
|
437
|
+
raise ValueError(
|
|
438
|
+
f"Variable '{variable_name}' not found. Available: {list(uxds.data_vars.keys())}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
uxda = uxds[variable_name]
|
|
442
|
+
if not any(d in face_dims for d in uxda.dims):
|
|
443
|
+
raise ValueError(f"Variable '{variable_name}' is not face-centered.")
|
|
444
|
+
|
|
445
|
+
extra_dims = [d for d in uxda.dims if d not in face_dims]
|
|
446
|
+
if extra_dims:
|
|
447
|
+
uxda = uxda.isel(
|
|
448
|
+
**{d: 0 if uxda.sizes[d] == 1 else time_index for d in extra_dims}
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
import holoviews as hv
|
|
452
|
+
|
|
453
|
+
hv.extension("matplotlib")
|
|
454
|
+
|
|
455
|
+
dpi = 100
|
|
456
|
+
kwargs: Dict[str, Any] = {"backend": "matplotlib", "cmap": cmap}
|
|
457
|
+
if vmin is not None:
|
|
458
|
+
import numpy as np
|
|
459
|
+
|
|
460
|
+
kwargs["clim"] = (
|
|
461
|
+
vmin,
|
|
462
|
+
vmax if vmax is not None else float(np.nanmax(uxda.values)),
|
|
463
|
+
)
|
|
464
|
+
elif vmax is not None:
|
|
465
|
+
import numpy as np
|
|
466
|
+
|
|
467
|
+
kwargs["clim"] = (float(np.nanmin(uxda.values)), vmax)
|
|
468
|
+
|
|
469
|
+
element = uxda.plot.polygons(**kwargs)
|
|
470
|
+
renderer = hv.Store.renderers["matplotlib"]
|
|
471
|
+
plot = renderer.get_plot(element)
|
|
472
|
+
fig = plot.state
|
|
473
|
+
fig.set_size_inches(width / dpi, height / dpi)
|
|
474
|
+
fig.set_dpi(dpi)
|
|
475
|
+
if title is not None:
|
|
476
|
+
fig.axes[0].set_title(title)
|
|
477
|
+
fig.tight_layout()
|
|
478
|
+
|
|
479
|
+
buf = io.BytesIO()
|
|
480
|
+
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
|
|
481
|
+
plt.close(fig)
|
|
482
|
+
buf.seek(0)
|
|
483
|
+
png_bytes = buf.read()
|
|
484
|
+
if not png_bytes:
|
|
485
|
+
raise ValueError("Rendered variable plot is empty.")
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
"png_b64": base64.b64encode(png_bytes).decode("utf-8"),
|
|
489
|
+
"image_size_bytes": len(png_bytes),
|
|
490
|
+
"variable_name": variable_name,
|
|
491
|
+
"grid_info": {
|
|
492
|
+
"n_face": int(uxds.uxgrid.n_face),
|
|
493
|
+
"n_node": int(uxds.uxgrid.n_node),
|
|
494
|
+
"n_edge": int(uxds.uxgrid.n_edge),
|
|
495
|
+
},
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def remote_plot_zonal_mean(
|
|
500
|
+
grid_path: str,
|
|
501
|
+
data_path: str,
|
|
502
|
+
variable_name: str,
|
|
503
|
+
width: int = 800,
|
|
504
|
+
height: int = 400,
|
|
505
|
+
lat_spec: Optional[tuple | float | list] = None,
|
|
506
|
+
conservative: bool = False,
|
|
507
|
+
line_color: str = "#1f77b4",
|
|
508
|
+
title: Optional[str] = None,
|
|
509
|
+
) -> Dict[str, Any]:
|
|
510
|
+
"""Render a zonal mean profile on the remote HPC node and return base64 PNG.
|
|
511
|
+
|
|
512
|
+
Parameters
|
|
513
|
+
----------
|
|
514
|
+
grid_path : str
|
|
515
|
+
Path to mesh grid file on HPC filesystem.
|
|
516
|
+
data_path : str
|
|
517
|
+
Path to data file on HPC filesystem.
|
|
518
|
+
variable_name : str
|
|
519
|
+
Variable to compute zonal mean for (must be face-centered).
|
|
520
|
+
width : int
|
|
521
|
+
Image width in pixels.
|
|
522
|
+
height : int
|
|
523
|
+
Image height in pixels.
|
|
524
|
+
lat_spec : tuple | float | list | None
|
|
525
|
+
Latitude specification. None uses default 10-degree bands.
|
|
526
|
+
conservative : bool
|
|
527
|
+
Use area-weighted averaging.
|
|
528
|
+
line_color : str
|
|
529
|
+
Matplotlib color string for the profile line.
|
|
530
|
+
title : str | None
|
|
531
|
+
Plot title.
|
|
532
|
+
|
|
533
|
+
Returns
|
|
534
|
+
-------
|
|
535
|
+
dict
|
|
536
|
+
- png_b64: base64-encoded PNG string
|
|
537
|
+
- image_size_bytes: size of the PNG
|
|
538
|
+
- variable_name: plotted variable name
|
|
539
|
+
- latitudes: list of latitude values
|
|
540
|
+
- zonal_mean_values: list of zonal mean values
|
|
541
|
+
"""
|
|
542
|
+
import base64
|
|
543
|
+
import io
|
|
544
|
+
|
|
545
|
+
import matplotlib
|
|
546
|
+
|
|
547
|
+
matplotlib.use("Agg")
|
|
548
|
+
import matplotlib.pyplot as plt
|
|
549
|
+
import uxarray as ux
|
|
550
|
+
|
|
551
|
+
uxds = ux.open_dataset(grid_path, data_path)
|
|
552
|
+
|
|
553
|
+
if variable_name not in uxds:
|
|
554
|
+
raise ValueError(f"Variable '{variable_name}' not found")
|
|
555
|
+
|
|
556
|
+
var = uxds[variable_name]
|
|
557
|
+
face_dims = {"n_face", "nCells"}
|
|
558
|
+
if not any(d in face_dims for d in var.dims):
|
|
559
|
+
raise ValueError(f"Variable '{variable_name}' is not face-centered")
|
|
560
|
+
|
|
561
|
+
if lat_spec is None:
|
|
562
|
+
lat_spec = (-90, 90, 10)
|
|
563
|
+
|
|
564
|
+
if conservative:
|
|
565
|
+
result = var.zonal_mean(lat=lat_spec, conservative=True)
|
|
566
|
+
else:
|
|
567
|
+
result = var.zonal_mean(lat=lat_spec)
|
|
568
|
+
|
|
569
|
+
latitudes = result.coords[result.dims[0]].values.tolist()
|
|
570
|
+
values = result.values.tolist()
|
|
571
|
+
|
|
572
|
+
dpi = 100
|
|
573
|
+
fig, ax = plt.subplots(figsize=(width / dpi, height / dpi), dpi=dpi)
|
|
574
|
+
ax.plot(latitudes, values, linewidth=1.5, color=line_color)
|
|
575
|
+
ax.set_xlabel("Latitude (°)")
|
|
576
|
+
ax.set_ylabel(variable_name)
|
|
577
|
+
ax.set_title(title if title is not None else f"Zonal Mean — {variable_name}")
|
|
578
|
+
ax.grid(True, alpha=0.3)
|
|
579
|
+
fig.tight_layout()
|
|
580
|
+
|
|
581
|
+
buf = io.BytesIO()
|
|
582
|
+
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
|
|
583
|
+
plt.close(fig)
|
|
584
|
+
buf.seek(0)
|
|
585
|
+
png_bytes = buf.read()
|
|
586
|
+
if not png_bytes:
|
|
587
|
+
raise ValueError("Rendered zonal mean plot is empty.")
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
"png_b64": base64.b64encode(png_bytes).decode("utf-8"),
|
|
591
|
+
"image_size_bytes": len(png_bytes),
|
|
592
|
+
"variable_name": variable_name,
|
|
593
|
+
"latitudes": latitudes,
|
|
594
|
+
"zonal_mean_values": values,
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def remote_calculate_zonal_mean(
|
|
599
|
+
grid_path: str,
|
|
600
|
+
data_path: str,
|
|
601
|
+
variable_name: str,
|
|
602
|
+
lat_spec: Optional[tuple | float | list] = None,
|
|
603
|
+
conservative: bool = False,
|
|
604
|
+
) -> Dict[str, Any]:
|
|
605
|
+
"""Calculate zonal mean on remote HPC node.
|
|
606
|
+
|
|
607
|
+
Parameters
|
|
608
|
+
----------
|
|
609
|
+
grid_path : str
|
|
610
|
+
Path to grid file on HPC filesystem
|
|
611
|
+
data_path : str
|
|
612
|
+
Path to data file on HPC filesystem
|
|
613
|
+
variable_name : str
|
|
614
|
+
Variable to compute zonal mean for
|
|
615
|
+
lat_spec : tuple | float | list | None
|
|
616
|
+
Latitude specification
|
|
617
|
+
conservative : bool
|
|
618
|
+
Whether to use conservative averaging
|
|
619
|
+
|
|
620
|
+
Returns
|
|
621
|
+
-------
|
|
622
|
+
dict
|
|
623
|
+
Zonal mean results including latitudes and values
|
|
624
|
+
|
|
625
|
+
Notes
|
|
626
|
+
-----
|
|
627
|
+
This function executes on the HPC endpoint, not locally.
|
|
628
|
+
"""
|
|
629
|
+
import uxarray as ux
|
|
630
|
+
|
|
631
|
+
uxds = ux.open_dataset(grid_path, data_path)
|
|
632
|
+
|
|
633
|
+
if variable_name not in uxds:
|
|
634
|
+
raise ValueError(f"Variable '{variable_name}' not found")
|
|
635
|
+
|
|
636
|
+
var = uxds[variable_name]
|
|
637
|
+
face_dims = {"n_face", "nCells"}
|
|
638
|
+
if not any(d in face_dims for d in var.dims):
|
|
639
|
+
raise ValueError(f"Variable '{variable_name}' is not face-centered")
|
|
640
|
+
|
|
641
|
+
if lat_spec is None:
|
|
642
|
+
lat_spec = (-90, 90, 10)
|
|
643
|
+
|
|
644
|
+
if conservative:
|
|
645
|
+
result = var.zonal_mean(lat=lat_spec, conservative=True)
|
|
646
|
+
else:
|
|
647
|
+
result = var.zonal_mean(lat=lat_spec)
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
"variable_name": variable_name,
|
|
651
|
+
"latitudes": result.coords[result.dims[0]].values.tolist(),
|
|
652
|
+
"zonal_mean_values": result.values.tolist(),
|
|
653
|
+
"conservative": conservative,
|
|
654
|
+
"grid_info": {
|
|
655
|
+
"n_face": int(uxds.uxgrid.n_face),
|
|
656
|
+
"n_node": int(uxds.uxgrid.n_node),
|
|
657
|
+
"n_edge": int(uxds.uxgrid.n_edge),
|
|
658
|
+
},
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def remote_subset_bbox_plot(
|
|
663
|
+
grid_path: str,
|
|
664
|
+
lon_bounds: list,
|
|
665
|
+
lat_bounds: list,
|
|
666
|
+
region_name: str = "",
|
|
667
|
+
width: int = 800,
|
|
668
|
+
height: int = 450,
|
|
669
|
+
edgecolor: str = "steelblue",
|
|
670
|
+
facecolor: str = "lightcyan",
|
|
671
|
+
linewidth: float = 0.3,
|
|
672
|
+
) -> Dict[str, Any]:
|
|
673
|
+
"""Subset a mesh by bounding box and return stats + a wireframe PNG.
|
|
674
|
+
|
|
675
|
+
Runs entirely on the HPC worker so multi-GB files never leave the
|
|
676
|
+
facility filesystem. All rendering parameters are echoed back in the
|
|
677
|
+
return dict so the caller has a complete provenance record: to reproduce
|
|
678
|
+
or modify the plot, change any field and resubmit.
|
|
679
|
+
|
|
680
|
+
Parameters
|
|
681
|
+
----------
|
|
682
|
+
grid_path : str
|
|
683
|
+
Path to mesh file on HPC filesystem.
|
|
684
|
+
lon_bounds : list
|
|
685
|
+
[lon_min, lon_max] in degrees.
|
|
686
|
+
lat_bounds : list
|
|
687
|
+
[lat_min, lat_max] in degrees.
|
|
688
|
+
region_name : str
|
|
689
|
+
Human-readable label for the plot title.
|
|
690
|
+
width, height : int
|
|
691
|
+
PNG dimensions in pixels.
|
|
692
|
+
edgecolor : str
|
|
693
|
+
Matplotlib color for cell edges.
|
|
694
|
+
facecolor : str
|
|
695
|
+
Matplotlib color for cell fill.
|
|
696
|
+
linewidth : float
|
|
697
|
+
Edge line width in points.
|
|
698
|
+
|
|
699
|
+
Returns
|
|
700
|
+
-------
|
|
701
|
+
dict
|
|
702
|
+
- region_name, lon_bounds, lat_bounds: inputs echoed for provenance
|
|
703
|
+
- plot_params: all rendering parameters (edgecolor, facecolor,
|
|
704
|
+
linewidth, width, height, dpi) — change any field and resubmit
|
|
705
|
+
to modify the plot without re-running the analysis
|
|
706
|
+
- n_face_total, n_face_subset, fraction_of_mesh: coverage statistics
|
|
707
|
+
- mean_area_full_sr, mean_area_subset_sr: face-area statistics (sr)
|
|
708
|
+
- resolution_ratio: mean_area_full / mean_area_subset (>1 = finer)
|
|
709
|
+
- uxarray_version: library version on the HPC worker
|
|
710
|
+
- png_b64: base64 PNG of the subset wireframe
|
|
711
|
+
- image_size_bytes: PNG size in bytes
|
|
712
|
+
"""
|
|
713
|
+
import base64
|
|
714
|
+
import importlib.metadata
|
|
715
|
+
import io
|
|
716
|
+
import math
|
|
717
|
+
|
|
718
|
+
import matplotlib
|
|
719
|
+
|
|
720
|
+
matplotlib.use("Agg")
|
|
721
|
+
import matplotlib.pyplot as plt
|
|
722
|
+
import uxarray as ux
|
|
723
|
+
|
|
724
|
+
grid = ux.open_grid(grid_path)
|
|
725
|
+
n_face_total = int(grid.n_face)
|
|
726
|
+
|
|
727
|
+
# full-mesh mean area
|
|
728
|
+
full_areas = grid.face_areas
|
|
729
|
+
mean_area_full = float(full_areas.values.mean())
|
|
730
|
+
|
|
731
|
+
# subset by bounding box
|
|
732
|
+
subset = grid.subset.bounding_box(
|
|
733
|
+
lon_bounds=lon_bounds,
|
|
734
|
+
lat_bounds=lat_bounds,
|
|
735
|
+
)
|
|
736
|
+
n_face_subset = int(subset.n_face)
|
|
737
|
+
|
|
738
|
+
subset_areas = subset.face_areas
|
|
739
|
+
mean_area_subset = (
|
|
740
|
+
float(subset_areas.values.mean()) if n_face_subset > 0 else float("nan")
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
resolution_ratio = (
|
|
744
|
+
mean_area_full / mean_area_subset
|
|
745
|
+
if mean_area_subset and not math.isnan(mean_area_subset)
|
|
746
|
+
else None
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# plot subset: draw face-edge polygons using node coordinates
|
|
750
|
+
dpi = 100
|
|
751
|
+
fig, ax = plt.subplots(figsize=(width / dpi, height / dpi), dpi=dpi)
|
|
752
|
+
title = region_name if region_name else f"lon{lon_bounds} lat{lat_bounds}"
|
|
753
|
+
subtitle = (
|
|
754
|
+
f"{n_face_subset:,} faces | res_ratio={resolution_ratio:.2f}x"
|
|
755
|
+
if resolution_ratio
|
|
756
|
+
else f"{n_face_subset:,} faces"
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
from matplotlib.collections import PolyCollection
|
|
760
|
+
|
|
761
|
+
# Build polygon vertices from face-node connectivity
|
|
762
|
+
try:
|
|
763
|
+
face_lon = subset.node_lon.values # (n_node,)
|
|
764
|
+
face_lat = subset.node_lat.values # (n_node,)
|
|
765
|
+
conn = subset.face_node_connectivity.values # (n_face, max_nodes)
|
|
766
|
+
fill_val = getattr(subset.face_node_connectivity, "_FillValue", -1)
|
|
767
|
+
polys = []
|
|
768
|
+
for row in conn:
|
|
769
|
+
idx = row[row != fill_val]
|
|
770
|
+
if len(idx) >= 3:
|
|
771
|
+
polys.append(list(zip(face_lon[idx], face_lat[idx])))
|
|
772
|
+
if polys:
|
|
773
|
+
col = PolyCollection(
|
|
774
|
+
polys, edgecolors=edgecolor, facecolors=facecolor, linewidths=linewidth
|
|
775
|
+
)
|
|
776
|
+
ax.add_collection(col)
|
|
777
|
+
ax.set_xlim(lon_bounds)
|
|
778
|
+
ax.set_ylim(lat_bounds)
|
|
779
|
+
else:
|
|
780
|
+
raise ValueError("no valid polygons")
|
|
781
|
+
except Exception:
|
|
782
|
+
# fallback: scatter face centres
|
|
783
|
+
lons = subset.face_lon.values
|
|
784
|
+
lats = subset.face_lat.values
|
|
785
|
+
ax.scatter(lons, lats, s=2, color=edgecolor, alpha=0.6)
|
|
786
|
+
ax.set_xlim(lon_bounds)
|
|
787
|
+
ax.set_ylim(lat_bounds)
|
|
788
|
+
|
|
789
|
+
ax.set_title(f"{title}\n{subtitle}", fontsize=10)
|
|
790
|
+
ax.set_xlabel("Longitude")
|
|
791
|
+
ax.set_ylabel("Latitude")
|
|
792
|
+
ax.set_aspect("equal", adjustable="box")
|
|
793
|
+
fig.tight_layout()
|
|
794
|
+
|
|
795
|
+
buf = io.BytesIO()
|
|
796
|
+
fig.savefig(buf, format="png", dpi=dpi)
|
|
797
|
+
plt.close(fig)
|
|
798
|
+
buf.seek(0)
|
|
799
|
+
png_bytes = buf.read()
|
|
800
|
+
|
|
801
|
+
try:
|
|
802
|
+
ux_version = importlib.metadata.version("uxarray")
|
|
803
|
+
except Exception:
|
|
804
|
+
ux_version = "unknown"
|
|
805
|
+
|
|
806
|
+
return {
|
|
807
|
+
"region_name": region_name,
|
|
808
|
+
"lon_bounds": lon_bounds,
|
|
809
|
+
"lat_bounds": lat_bounds,
|
|
810
|
+
"plot_params": {
|
|
811
|
+
"edgecolor": edgecolor,
|
|
812
|
+
"facecolor": facecolor,
|
|
813
|
+
"linewidth": linewidth,
|
|
814
|
+
"width_px": width,
|
|
815
|
+
"height_px": height,
|
|
816
|
+
"dpi": dpi,
|
|
817
|
+
},
|
|
818
|
+
"n_face_total": n_face_total,
|
|
819
|
+
"n_face_subset": n_face_subset,
|
|
820
|
+
"fraction_of_mesh": n_face_subset / n_face_total if n_face_total else None,
|
|
821
|
+
"mean_area_full_sr": mean_area_full,
|
|
822
|
+
"mean_area_subset_sr": mean_area_subset,
|
|
823
|
+
"resolution_ratio": resolution_ratio,
|
|
824
|
+
"uxarray_version": ux_version,
|
|
825
|
+
"png_b64": base64.b64encode(png_bytes).decode(),
|
|
826
|
+
"image_size_bytes": len(png_bytes),
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def remote_yac_remap_smoke() -> Dict[str, Any]:
|
|
831
|
+
"""Smoke-test YAC's availability on the remote worker.
|
|
832
|
+
|
|
833
|
+
Loads YAC via uxarray's canonical helper (works around the broken upstream
|
|
834
|
+
``yac/__init__.py`` that does ``from ._yac import *``), reports the YAC
|
|
835
|
+
surface, and runs a minimal nearest-neighbour remap from one HEALPix grid
|
|
836
|
+
to another using uxarray's YAC-backed remap path. Returns a dict that
|
|
837
|
+
includes both the static surface check and, if a remap was attempted, its
|
|
838
|
+
shape and timing.
|
|
839
|
+
|
|
840
|
+
The function is self-contained and serialised via AllCodeStrategies so the
|
|
841
|
+
Python 3.13/3.11 mismatch between local SDK and worker doesn't bite.
|
|
842
|
+
"""
|
|
843
|
+
import importlib.metadata
|
|
844
|
+
import time
|
|
845
|
+
import traceback
|
|
846
|
+
|
|
847
|
+
out: Dict[str, Any] = {}
|
|
848
|
+
|
|
849
|
+
def _surface(mod):
|
|
850
|
+
return {
|
|
851
|
+
name: hasattr(mod, name)
|
|
852
|
+
for name in (
|
|
853
|
+
"BasicGrid",
|
|
854
|
+
"InterpField",
|
|
855
|
+
"InterpolationStack",
|
|
856
|
+
"compute_weights",
|
|
857
|
+
"Reg2dGrid",
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
yc = None
|
|
862
|
+
try:
|
|
863
|
+
from uxarray.remap.yac import _import_yac
|
|
864
|
+
|
|
865
|
+
yc = _import_yac()
|
|
866
|
+
out["yac_helper_ok"] = True
|
|
867
|
+
out["yac_loader"] = "uxarray.remap.yac._import_yac"
|
|
868
|
+
out["yac_module"] = getattr(yc, "__name__", None)
|
|
869
|
+
out["yac_file"] = getattr(yc, "__file__", None)
|
|
870
|
+
out["surface"] = _surface(yc)
|
|
871
|
+
except Exception as exc:
|
|
872
|
+
out["yac_helper_ok"] = False
|
|
873
|
+
out["yac_helper_error"] = f"{type(exc).__name__}: {exc}"
|
|
874
|
+
|
|
875
|
+
# Fallback: load core.so directly via ExtensionFileLoader, bypassing
|
|
876
|
+
# YAC's broken __init__.py. Lets us report surface even when the
|
|
877
|
+
# site uxarray is unavailable or itself broken.
|
|
878
|
+
try:
|
|
879
|
+
import importlib.machinery as _m
|
|
880
|
+
import importlib.util as _u
|
|
881
|
+
import os as _os
|
|
882
|
+
import sys as _sys
|
|
883
|
+
import sysconfig as _sc
|
|
884
|
+
import types as _t
|
|
885
|
+
from pathlib import Path as _P
|
|
886
|
+
|
|
887
|
+
search_roots = [_sc.get_paths()["purelib"]]
|
|
888
|
+
search_roots.extend(p for p in _sys.path if p)
|
|
889
|
+
for env_var in ("YAC_PREFIX", "YAC_ROOT"):
|
|
890
|
+
if _os.environ.get(env_var):
|
|
891
|
+
search_roots.append(_os.environ[env_var])
|
|
892
|
+
home = _os.path.expanduser("~")
|
|
893
|
+
search_roots.append(_os.path.join(home, "yac"))
|
|
894
|
+
search_roots.append("/opt/yac")
|
|
895
|
+
|
|
896
|
+
so_candidates: list[str] = []
|
|
897
|
+
for root in search_roots:
|
|
898
|
+
rp = _P(root)
|
|
899
|
+
if not rp.exists():
|
|
900
|
+
continue
|
|
901
|
+
so_candidates.extend(str(p) for p in rp.rglob("yac/core.cpython-*.so"))
|
|
902
|
+
so_candidates = list(dict.fromkeys(so_candidates)) # dedupe
|
|
903
|
+
if not so_candidates:
|
|
904
|
+
raise FileNotFoundError(f"No yac/core*.so found in: {search_roots[:6]}")
|
|
905
|
+
so = so_candidates[0]
|
|
906
|
+
out["yac_search_hits"] = so_candidates[:5]
|
|
907
|
+
|
|
908
|
+
pkg = _t.ModuleType("yacshim")
|
|
909
|
+
pkg.__path__ = []
|
|
910
|
+
_sys.modules["yacshim"] = pkg
|
|
911
|
+
loader = _m.ExtensionFileLoader("yacshim.core", so)
|
|
912
|
+
spec = _u.spec_from_loader("yacshim.core", loader)
|
|
913
|
+
assert spec is not None and spec.loader is not None
|
|
914
|
+
yc = _u.module_from_spec(spec)
|
|
915
|
+
spec.loader.exec_module(yc)
|
|
916
|
+
out["yac_loader"] = "direct_so"
|
|
917
|
+
out["yac_file"] = so
|
|
918
|
+
out["surface"] = _surface(yc)
|
|
919
|
+
except Exception as exc2:
|
|
920
|
+
out["yac_direct_load_ok"] = False
|
|
921
|
+
out["yac_direct_load_error"] = f"{type(exc2).__name__}: {exc2}"
|
|
922
|
+
out["yac_direct_load_traceback"] = traceback.format_exc()
|
|
923
|
+
return out
|
|
924
|
+
|
|
925
|
+
try:
|
|
926
|
+
out["uxarray_version"] = importlib.metadata.version("uxarray")
|
|
927
|
+
except Exception:
|
|
928
|
+
out["uxarray_version"] = "unknown"
|
|
929
|
+
|
|
930
|
+
try:
|
|
931
|
+
import numpy as np
|
|
932
|
+
import uxarray as ux
|
|
933
|
+
import xarray as xr
|
|
934
|
+
|
|
935
|
+
src = ux.Grid.from_healpix(zoom=2)
|
|
936
|
+
dst = ux.Grid.from_healpix(zoom=3)
|
|
937
|
+
out["src_n_face"] = int(src.n_face)
|
|
938
|
+
out["dst_n_face"] = int(dst.n_face)
|
|
939
|
+
|
|
940
|
+
rng = np.random.default_rng(0)
|
|
941
|
+
face_data = rng.standard_normal(int(src.n_face))
|
|
942
|
+
uxda = ux.UxDataArray(
|
|
943
|
+
xr.DataArray(face_data, dims=("n_face",), name="field"),
|
|
944
|
+
uxgrid=src,
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
t0 = time.perf_counter()
|
|
948
|
+
try:
|
|
949
|
+
remapped = uxda.remap.nearest_neighbor(
|
|
950
|
+
destination_grid=dst, remap_to="face centers"
|
|
951
|
+
)
|
|
952
|
+
out["remap_method"] = "nearest_neighbor"
|
|
953
|
+
out["remap_ok"] = True
|
|
954
|
+
out["remap_seconds"] = time.perf_counter() - t0
|
|
955
|
+
out["remap_dst_shape"] = list(remapped.shape)
|
|
956
|
+
out["remap_dst_mean"] = float(np.asarray(remapped).mean())
|
|
957
|
+
except Exception as exc:
|
|
958
|
+
out["remap_method"] = "nearest_neighbor"
|
|
959
|
+
out["remap_ok"] = False
|
|
960
|
+
out["remap_error"] = f"{type(exc).__name__}: {exc}"
|
|
961
|
+
out["remap_traceback"] = traceback.format_exc()
|
|
962
|
+
except Exception as exc:
|
|
963
|
+
out["remap_setup_ok"] = False
|
|
964
|
+
out["remap_setup_error"] = f"{type(exc).__name__}: {exc}"
|
|
965
|
+
out["remap_setup_traceback"] = traceback.format_exc()
|
|
966
|
+
|
|
967
|
+
return out
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
# ---------------------------------------------------------------------------
|
|
971
|
+
# Vector calculus remote functions
|
|
972
|
+
# Each function is fully self-contained — no uxarray_mcp imports.
|
|
973
|
+
# Serialized via AllCodeStrategies so the HPC worker only needs uxarray+numpy.
|
|
974
|
+
# ---------------------------------------------------------------------------
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def remote_calculate_gradient(
|
|
978
|
+
grid_path: str, data_path: str, variable_name: str
|
|
979
|
+
) -> Dict[str, Any]:
|
|
980
|
+
"""Compute the spatial gradient of a face-centered scalar field on HPC."""
|
|
981
|
+
import numpy as np
|
|
982
|
+
import uxarray as ux
|
|
983
|
+
|
|
984
|
+
uxds = ux.open_dataset(grid_path, data_path)
|
|
985
|
+
if variable_name not in uxds.data_vars:
|
|
986
|
+
raise ValueError(
|
|
987
|
+
f"Variable '{variable_name}' not found. Available: {list(uxds.data_vars)}"
|
|
988
|
+
)
|
|
989
|
+
var = uxds[variable_name]
|
|
990
|
+
if "n_face" not in var.dims and "nCells" not in var.dims:
|
|
991
|
+
raise ValueError(
|
|
992
|
+
f"Variable '{variable_name}' is not face-centered. "
|
|
993
|
+
"Gradient requires face-centered data."
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
grad = var.gradient()
|
|
997
|
+
comp_names = list(grad.data_vars)
|
|
998
|
+
|
|
999
|
+
def _stats(arr: Any) -> Dict[str, Any]:
|
|
1000
|
+
vals = arr.values
|
|
1001
|
+
finite = vals[np.isfinite(vals)]
|
|
1002
|
+
if finite.size == 0:
|
|
1003
|
+
return {"min": None, "max": None, "mean": None}
|
|
1004
|
+
return {
|
|
1005
|
+
"min": float(finite.min()),
|
|
1006
|
+
"max": float(finite.max()),
|
|
1007
|
+
"mean": float(finite.mean()),
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
return {
|
|
1011
|
+
"variable_name": variable_name,
|
|
1012
|
+
"components": comp_names,
|
|
1013
|
+
"component_stats": {name: _stats(grad[name]) for name in comp_names},
|
|
1014
|
+
"n_face": int(uxds.uxgrid.n_face),
|
|
1015
|
+
"interpretation": "zonal (d/dx) and meridional (d/dy) components of the gradient",
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def remote_calculate_curl(
|
|
1020
|
+
grid_path: str, data_path: str, u_variable: str, v_variable: str
|
|
1021
|
+
) -> Dict[str, Any]:
|
|
1022
|
+
"""Compute relative vorticity (curl) of a 2-D wind field on HPC.
|
|
1023
|
+
|
|
1024
|
+
zeta = dv/dx - du/dy
|
|
1025
|
+
"""
|
|
1026
|
+
import numpy as np
|
|
1027
|
+
import uxarray as ux
|
|
1028
|
+
|
|
1029
|
+
uxds = ux.open_dataset(grid_path, data_path)
|
|
1030
|
+
for name in (u_variable, v_variable):
|
|
1031
|
+
if name not in uxds.data_vars:
|
|
1032
|
+
raise ValueError(
|
|
1033
|
+
f"Variable '{name}' not found. Available: {list(uxds.data_vars)}"
|
|
1034
|
+
)
|
|
1035
|
+
u, v = uxds[u_variable], uxds[v_variable]
|
|
1036
|
+
for name, var in ((u_variable, u), (v_variable, v)):
|
|
1037
|
+
if "n_face" not in var.dims and "nCells" not in var.dims:
|
|
1038
|
+
raise ValueError(
|
|
1039
|
+
f"Variable '{name}' is not face-centered. "
|
|
1040
|
+
"Curl requires face-centered vector components."
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
result = u.curl(v)
|
|
1044
|
+
vals = result.values
|
|
1045
|
+
finite = vals[np.isfinite(vals)]
|
|
1046
|
+
stats: Dict[str, Any] = (
|
|
1047
|
+
{
|
|
1048
|
+
"min": float(finite.min()),
|
|
1049
|
+
"max": float(finite.max()),
|
|
1050
|
+
"mean": float(finite.mean()),
|
|
1051
|
+
"std": float(finite.std()),
|
|
1052
|
+
}
|
|
1053
|
+
if finite.size > 0
|
|
1054
|
+
else {"min": None, "max": None, "mean": None, "std": None}
|
|
1055
|
+
)
|
|
1056
|
+
return {
|
|
1057
|
+
"u_variable": u_variable,
|
|
1058
|
+
"v_variable": v_variable,
|
|
1059
|
+
"interpretation": "relative vorticity zeta = dv/dx - du/dy",
|
|
1060
|
+
"n_face": int(uxds.uxgrid.n_face),
|
|
1061
|
+
"stats": stats,
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def remote_calculate_divergence(
|
|
1066
|
+
grid_path: str, data_path: str, u_variable: str, v_variable: str
|
|
1067
|
+
) -> Dict[str, Any]:
|
|
1068
|
+
"""Compute horizontal divergence of a 2-D vector field on HPC.
|
|
1069
|
+
|
|
1070
|
+
divergence = du/dx + dv/dy
|
|
1071
|
+
"""
|
|
1072
|
+
import numpy as np
|
|
1073
|
+
import uxarray as ux
|
|
1074
|
+
|
|
1075
|
+
uxds = ux.open_dataset(grid_path, data_path)
|
|
1076
|
+
for name in (u_variable, v_variable):
|
|
1077
|
+
if name not in uxds.data_vars:
|
|
1078
|
+
raise ValueError(
|
|
1079
|
+
f"Variable '{name}' not found. Available: {list(uxds.data_vars)}"
|
|
1080
|
+
)
|
|
1081
|
+
u, v = uxds[u_variable], uxds[v_variable]
|
|
1082
|
+
for name, var in ((u_variable, u), (v_variable, v)):
|
|
1083
|
+
if "n_face" not in var.dims and "nCells" not in var.dims:
|
|
1084
|
+
raise ValueError(
|
|
1085
|
+
f"Variable '{name}' is not face-centered. "
|
|
1086
|
+
"Divergence requires face-centered vector components."
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
result = u.divergence(v)
|
|
1090
|
+
vals = result.values
|
|
1091
|
+
finite = vals[np.isfinite(vals)]
|
|
1092
|
+
stats: Dict[str, Any] = (
|
|
1093
|
+
{
|
|
1094
|
+
"min": float(finite.min()),
|
|
1095
|
+
"max": float(finite.max()),
|
|
1096
|
+
"mean": float(finite.mean()),
|
|
1097
|
+
"std": float(finite.std()),
|
|
1098
|
+
}
|
|
1099
|
+
if finite.size > 0
|
|
1100
|
+
else {"min": None, "max": None, "mean": None, "std": None}
|
|
1101
|
+
)
|
|
1102
|
+
return {
|
|
1103
|
+
"u_variable": u_variable,
|
|
1104
|
+
"v_variable": v_variable,
|
|
1105
|
+
"interpretation": "horizontal divergence du/dx + dv/dy",
|
|
1106
|
+
"n_face": int(uxds.uxgrid.n_face),
|
|
1107
|
+
"stats": stats,
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def remote_calculate_azimuthal_mean(
|
|
1112
|
+
grid_path: str,
|
|
1113
|
+
data_path: str,
|
|
1114
|
+
variable_name: str,
|
|
1115
|
+
center_lon: float,
|
|
1116
|
+
center_lat: float,
|
|
1117
|
+
outer_radius: float,
|
|
1118
|
+
radius_step: float,
|
|
1119
|
+
) -> Dict[str, Any]:
|
|
1120
|
+
"""Compute the azimuthal (radial) mean around a centre point on HPC."""
|
|
1121
|
+
import uxarray as ux
|
|
1122
|
+
|
|
1123
|
+
uxds = ux.open_dataset(grid_path, data_path)
|
|
1124
|
+
if variable_name not in uxds.data_vars:
|
|
1125
|
+
raise ValueError(
|
|
1126
|
+
f"Variable '{variable_name}' not found. Available: {list(uxds.data_vars)}"
|
|
1127
|
+
)
|
|
1128
|
+
var = uxds[variable_name]
|
|
1129
|
+
if "n_face" not in var.dims and "nCells" not in var.dims:
|
|
1130
|
+
raise ValueError(
|
|
1131
|
+
f"Variable '{variable_name}' is not face-centered. "
|
|
1132
|
+
"Azimuthal mean requires face-centered data."
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
result = var.azimuthal_mean(
|
|
1136
|
+
center_coord=(center_lon, center_lat),
|
|
1137
|
+
outer_radius=outer_radius,
|
|
1138
|
+
radius_step=radius_step,
|
|
1139
|
+
)
|
|
1140
|
+
radii = result.coords[result.dims[0]].values.tolist()
|
|
1141
|
+
values = result.values.tolist()
|
|
1142
|
+
|
|
1143
|
+
return {
|
|
1144
|
+
"variable_name": variable_name,
|
|
1145
|
+
"center": {"lon": center_lon, "lat": center_lat},
|
|
1146
|
+
"outer_radius_deg": outer_radius,
|
|
1147
|
+
"radius_step_deg": radius_step,
|
|
1148
|
+
"radii_deg": radii,
|
|
1149
|
+
"azimuthal_mean_values": values,
|
|
1150
|
+
"n_face": int(uxds.uxgrid.n_face),
|
|
1151
|
+
}
|