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,499 @@
|
|
|
1
|
+
"""Shared plotting logic for UXarray MCP Server.
|
|
2
|
+
|
|
3
|
+
Pure rendering functions that take loaded UXarray objects and return
|
|
4
|
+
PNG bytes. No MCP, provenance, or file-loading logic belongs here.
|
|
5
|
+
|
|
6
|
+
## Geographic rendering
|
|
7
|
+
|
|
8
|
+
``render_mesh_geo`` produces a Cartopy-backed figure with:
|
|
9
|
+
|
|
10
|
+
- Cartopy 50m Natural Earth land/ocean/coastlines/lakes (always, no new deps)
|
|
11
|
+
- Semi-transparent white mesh cells overlaid (alpha=0.35)
|
|
12
|
+
- Mesh-derived boundary edges in red — traced from ``boundary_edge_indices``
|
|
13
|
+
using ``edge_node_connectivity`` so the boundary follows actual cell edges
|
|
14
|
+
rather than a geographic dataset. This is format-agnostic: it works for
|
|
15
|
+
MPAS, UGRID, SCRIP, ESMF, and any other format UXarray supports.
|
|
16
|
+
For closed spherical meshes (ICON, HEALPix) ``boundary_edge_indices``
|
|
17
|
+
returns an empty array and the feature is silently skipped.
|
|
18
|
+
- Optional contextily terrain basemap (``basemap=True``); requires
|
|
19
|
+
``pip install contextily`` and internet access. Falls back to
|
|
20
|
+
``ax.stock_img()`` when contextily is not installed.
|
|
21
|
+
|
|
22
|
+
## Performance note
|
|
23
|
+
|
|
24
|
+
``boundary_edge_indices`` triggers connectivity construction and can be slow
|
|
25
|
+
for very large meshes (>1M faces). The MCP tool exposes
|
|
26
|
+
``show_mesh_boundary`` which defaults to **False** — users opt in explicitly.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import io
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import matplotlib
|
|
33
|
+
|
|
34
|
+
matplotlib.use("Agg")
|
|
35
|
+
import matplotlib.pyplot as plt
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_mesh(
|
|
39
|
+
grid: Any,
|
|
40
|
+
width: int = 800,
|
|
41
|
+
height: int = 400,
|
|
42
|
+
) -> bytes:
|
|
43
|
+
"""Render a mesh wireframe to PNG bytes.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
grid : ux.Grid
|
|
48
|
+
Loaded UXarray grid.
|
|
49
|
+
width : int
|
|
50
|
+
Image width in pixels.
|
|
51
|
+
height : int
|
|
52
|
+
Image height in pixels.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
bytes
|
|
57
|
+
PNG image data.
|
|
58
|
+
"""
|
|
59
|
+
import holoviews as hv
|
|
60
|
+
|
|
61
|
+
hv.extension("matplotlib")
|
|
62
|
+
|
|
63
|
+
dpi = 100
|
|
64
|
+
fig_w = width / dpi
|
|
65
|
+
fig_h = height / dpi
|
|
66
|
+
|
|
67
|
+
element = grid.plot.mesh(backend="matplotlib")
|
|
68
|
+
|
|
69
|
+
renderer = hv.Store.renderers["matplotlib"]
|
|
70
|
+
plot = renderer.get_plot(element)
|
|
71
|
+
fig = plot.state
|
|
72
|
+
fig.set_size_inches(fig_w, fig_h)
|
|
73
|
+
fig.set_dpi(dpi)
|
|
74
|
+
fig.tight_layout()
|
|
75
|
+
|
|
76
|
+
buf = io.BytesIO()
|
|
77
|
+
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
|
|
78
|
+
plt.close(fig)
|
|
79
|
+
buf.seek(0)
|
|
80
|
+
png_bytes = buf.read()
|
|
81
|
+
if not png_bytes:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"Rendered mesh plot is empty. The file may be empty or contain no "
|
|
84
|
+
"plottable geometry."
|
|
85
|
+
)
|
|
86
|
+
return png_bytes
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render_mesh_geo(
|
|
90
|
+
grid: Any,
|
|
91
|
+
width: int = 1200,
|
|
92
|
+
height: int = 800,
|
|
93
|
+
lon_bounds: tuple[float, float] | None = None,
|
|
94
|
+
lat_bounds: tuple[float, float] | None = None,
|
|
95
|
+
coastlines: bool = True,
|
|
96
|
+
borders: bool = True,
|
|
97
|
+
rivers: bool = False,
|
|
98
|
+
lakes: bool = True,
|
|
99
|
+
show_mesh_boundary: bool = False,
|
|
100
|
+
basemap: bool = False,
|
|
101
|
+
mesh_alpha: float = 0.35,
|
|
102
|
+
mesh_edgecolor: str = "#333333",
|
|
103
|
+
mesh_linewidth: float = 0.25,
|
|
104
|
+
) -> tuple[bytes, dict]:
|
|
105
|
+
"""Render a mesh with geographic context using Cartopy.
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
tuple[bytes, dict]
|
|
110
|
+
``(png_bytes, render_info)`` where ``render_info`` is a dict describing
|
|
111
|
+
what was actually drawn — used by the tool layer to build a human-readable
|
|
112
|
+
note for the user. Keys:
|
|
113
|
+
|
|
114
|
+
``n_faces_rendered``
|
|
115
|
+
Number of mesh faces included in the plot.
|
|
116
|
+
``n_faces_total``
|
|
117
|
+
Total faces in the grid.
|
|
118
|
+
``boundary_drawn``
|
|
119
|
+
True if mesh boundary edges were drawn in red.
|
|
120
|
+
``n_boundary_edges``
|
|
121
|
+
Number of boundary edge segments drawn.
|
|
122
|
+
``boundary_status``
|
|
123
|
+
One of ``"drawn"``, ``"closed_mesh"``, ``"all_filtered"``,
|
|
124
|
+
``"disabled"``.
|
|
125
|
+
``seam_faces_skipped``
|
|
126
|
+
Number of faces skipped due to antimeridian crossing (will be 0
|
|
127
|
+
once UXarray PR #1519 lands).
|
|
128
|
+
``basemap_used``
|
|
129
|
+
``"contextily"``, ``"stock_img"``, or ``"none"``.
|
|
130
|
+
``features_drawn``
|
|
131
|
+
List of geographic features added (e.g. ``["coastlines","borders"]``).
|
|
132
|
+
``region``
|
|
133
|
+
``"global"`` or ``"regional"``.
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
grid : ux.Grid
|
|
139
|
+
Loaded UXarray grid (any format — MPAS, UGRID, SCRIP, ICON, HEALPix).
|
|
140
|
+
width, height : int
|
|
141
|
+
Image dimensions in pixels.
|
|
142
|
+
lon_bounds, lat_bounds : tuple[float, float] | None
|
|
143
|
+
If provided, subset the mesh to this region before rendering.
|
|
144
|
+
coastlines : bool, default True
|
|
145
|
+
Add Cartopy 50m Natural Earth coastlines.
|
|
146
|
+
borders : bool, default True
|
|
147
|
+
Add country borders.
|
|
148
|
+
rivers : bool, default False
|
|
149
|
+
Add major rivers.
|
|
150
|
+
lakes : bool, default True
|
|
151
|
+
Add lakes with blue fill.
|
|
152
|
+
show_mesh_boundary : bool, default False
|
|
153
|
+
Trace the actual mesh boundary edges in red using
|
|
154
|
+
``boundary_edge_indices`` and ``edge_node_connectivity``.
|
|
155
|
+
Format-agnostic — works for all formats; silently skipped when the
|
|
156
|
+
mesh has no boundary (ICON, HEALPix). Disabled by default because
|
|
157
|
+
boundary construction can be slow for large meshes.
|
|
158
|
+
basemap : bool, default False
|
|
159
|
+
Fetch terrain tiles via contextily (requires ``pip install contextily``
|
|
160
|
+
and internet access). Falls back to ``ax.stock_img()`` if not installed.
|
|
161
|
+
mesh_alpha : float, default 0.35
|
|
162
|
+
Opacity of mesh cell fill. 0 = invisible, 1 = opaque.
|
|
163
|
+
mesh_edgecolor : str, default "#333333"
|
|
164
|
+
Colour of mesh cell edges.
|
|
165
|
+
mesh_linewidth : float, default 0.25
|
|
166
|
+
Width of mesh cell edges in points.
|
|
167
|
+
|
|
168
|
+
"""
|
|
169
|
+
import cartopy.crs as ccrs
|
|
170
|
+
import cartopy.feature as cfeature
|
|
171
|
+
import numpy as np
|
|
172
|
+
from matplotlib.collections import LineCollection, PolyCollection
|
|
173
|
+
|
|
174
|
+
proj = ccrs.PlateCarree()
|
|
175
|
+
dpi = 120
|
|
176
|
+
fig, ax = plt.subplots(
|
|
177
|
+
figsize=(width / dpi, height / dpi),
|
|
178
|
+
dpi=dpi,
|
|
179
|
+
subplot_kw={"projection": proj},
|
|
180
|
+
facecolor="white",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# ── Region ────────────────────────────────────────────────────────────────
|
|
184
|
+
is_regional = lon_bounds is not None and lat_bounds is not None
|
|
185
|
+
if is_regional and lon_bounds is not None and lat_bounds is not None:
|
|
186
|
+
ax.set_extent(
|
|
187
|
+
[lon_bounds[0], lon_bounds[1], lat_bounds[0], lat_bounds[1]], crs=proj
|
|
188
|
+
)
|
|
189
|
+
g = grid.subset.bounding_box(
|
|
190
|
+
lon_bounds=list(lon_bounds), lat_bounds=list(lat_bounds)
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
ax.set_global()
|
|
194
|
+
g = grid
|
|
195
|
+
|
|
196
|
+
n_faces_total = int(grid.n_face)
|
|
197
|
+
|
|
198
|
+
# ── Basemap ───────────────────────────────────────────────────────────────
|
|
199
|
+
basemap_used = "none"
|
|
200
|
+
if basemap:
|
|
201
|
+
try:
|
|
202
|
+
import contextily as ctx
|
|
203
|
+
|
|
204
|
+
ctx.add_basemap(
|
|
205
|
+
ax,
|
|
206
|
+
crs=proj,
|
|
207
|
+
source=ctx.providers.OpenTopoMap,
|
|
208
|
+
zoom="auto",
|
|
209
|
+
attribution=False,
|
|
210
|
+
)
|
|
211
|
+
basemap_used = "contextily"
|
|
212
|
+
except ImportError:
|
|
213
|
+
ax.stock_img()
|
|
214
|
+
basemap_used = "stock_img"
|
|
215
|
+
else:
|
|
216
|
+
ax.add_feature(cfeature.LAND.with_scale("110m"), facecolor="#e8dcc8", zorder=1)
|
|
217
|
+
ax.add_feature(cfeature.OCEAN.with_scale("110m"), facecolor="#d4e8f5", zorder=1)
|
|
218
|
+
|
|
219
|
+
# ── Mesh cells ────────────────────────────────────────────────────────────
|
|
220
|
+
node_lon = g.node_lon.values
|
|
221
|
+
node_lat = g.node_lat.values
|
|
222
|
+
conn = g.face_node_connectivity.values
|
|
223
|
+
fill = np.iinfo(conn.dtype).min if np.issubdtype(conn.dtype, np.integer) else -1
|
|
224
|
+
|
|
225
|
+
polys = []
|
|
226
|
+
seam_skipped = 0
|
|
227
|
+
for row in conn:
|
|
228
|
+
idx = row[row != fill]
|
|
229
|
+
if len(idx) < 3:
|
|
230
|
+
continue
|
|
231
|
+
lons = node_lon[idx]
|
|
232
|
+
lats = node_lat[idx]
|
|
233
|
+
if lons.max() - lons.min() > 180:
|
|
234
|
+
seam_skipped += 1
|
|
235
|
+
continue # antimeridian-crossing — PR #1519 will split properly
|
|
236
|
+
polys.append(np.column_stack([lons, lats]))
|
|
237
|
+
|
|
238
|
+
if polys:
|
|
239
|
+
col = PolyCollection(
|
|
240
|
+
polys,
|
|
241
|
+
facecolors="white",
|
|
242
|
+
edgecolors=mesh_edgecolor,
|
|
243
|
+
linewidths=mesh_linewidth,
|
|
244
|
+
alpha=mesh_alpha,
|
|
245
|
+
transform=proj,
|
|
246
|
+
zorder=2,
|
|
247
|
+
)
|
|
248
|
+
ax.add_collection(col)
|
|
249
|
+
|
|
250
|
+
# ── Mesh-derived boundary edges (opt-in) ──────────────────────────────────
|
|
251
|
+
boundary_drawn = False
|
|
252
|
+
n_boundary_drawn = 0
|
|
253
|
+
boundary_status = "disabled"
|
|
254
|
+
|
|
255
|
+
if show_mesh_boundary:
|
|
256
|
+
try:
|
|
257
|
+
b_idx = g.boundary_edge_indices
|
|
258
|
+
n_raw = len(b_idx)
|
|
259
|
+
if n_raw == 0:
|
|
260
|
+
boundary_status = "closed_mesh"
|
|
261
|
+
else:
|
|
262
|
+
enc = g.edge_node_connectivity
|
|
263
|
+
lines = []
|
|
264
|
+
for ei in b_idx:
|
|
265
|
+
n0, n1 = int(enc[ei, 0].values), int(enc[ei, 1].values)
|
|
266
|
+
if n0 < 0 or n1 < 0:
|
|
267
|
+
continue
|
|
268
|
+
lon0, lat0 = node_lon[n0], node_lat[n0]
|
|
269
|
+
lon1, lat1 = node_lon[n1], node_lat[n1]
|
|
270
|
+
dlon = abs(lon1 - lon0)
|
|
271
|
+
if dlon > 10.0:
|
|
272
|
+
continue # antimeridian-crossing edge
|
|
273
|
+
if dlon < 0.1 and abs(round(lon0) % 180) < 1:
|
|
274
|
+
continue # prime meridian / antimeridian seam artefact
|
|
275
|
+
lines.append([[lon0, lat0], [lon1, lat1]])
|
|
276
|
+
|
|
277
|
+
if lines:
|
|
278
|
+
lc = LineCollection(
|
|
279
|
+
lines,
|
|
280
|
+
colors="#cc2200",
|
|
281
|
+
linewidths=1.8,
|
|
282
|
+
transform=proj,
|
|
283
|
+
zorder=5,
|
|
284
|
+
)
|
|
285
|
+
ax.add_collection(lc)
|
|
286
|
+
boundary_drawn = True
|
|
287
|
+
n_boundary_drawn = len(lines)
|
|
288
|
+
boundary_status = "drawn"
|
|
289
|
+
else:
|
|
290
|
+
boundary_status = "all_filtered"
|
|
291
|
+
except Exception:
|
|
292
|
+
boundary_status = "error"
|
|
293
|
+
|
|
294
|
+
# ── Cartopy geographic features ───────────────────────────────────────────
|
|
295
|
+
scale = "50m"
|
|
296
|
+
features_drawn = []
|
|
297
|
+
if coastlines:
|
|
298
|
+
ax.add_feature(
|
|
299
|
+
cfeature.COASTLINE.with_scale(scale),
|
|
300
|
+
linewidth=0.9,
|
|
301
|
+
edgecolor="#222222",
|
|
302
|
+
zorder=4,
|
|
303
|
+
)
|
|
304
|
+
features_drawn.append("coastlines")
|
|
305
|
+
if borders:
|
|
306
|
+
ax.add_feature(
|
|
307
|
+
cfeature.BORDERS.with_scale(scale),
|
|
308
|
+
linewidth=0.4,
|
|
309
|
+
edgecolor="#555555",
|
|
310
|
+
linestyle="--",
|
|
311
|
+
zorder=4,
|
|
312
|
+
)
|
|
313
|
+
features_drawn.append("borders")
|
|
314
|
+
if lakes:
|
|
315
|
+
ax.add_feature(
|
|
316
|
+
cfeature.LAKES.with_scale(scale),
|
|
317
|
+
facecolor="#aad4f5",
|
|
318
|
+
edgecolor="#3399ff",
|
|
319
|
+
linewidth=0.4,
|
|
320
|
+
zorder=3,
|
|
321
|
+
)
|
|
322
|
+
features_drawn.append("lakes")
|
|
323
|
+
if rivers:
|
|
324
|
+
ax.add_feature(
|
|
325
|
+
cfeature.RIVERS.with_scale(scale),
|
|
326
|
+
edgecolor="#3399ff",
|
|
327
|
+
linewidth=0.6,
|
|
328
|
+
zorder=3,
|
|
329
|
+
)
|
|
330
|
+
features_drawn.append("rivers")
|
|
331
|
+
|
|
332
|
+
ax.gridlines(linewidth=0.2, color="gray", alpha=0.4)
|
|
333
|
+
fig.tight_layout(pad=0.3)
|
|
334
|
+
|
|
335
|
+
buf = io.BytesIO()
|
|
336
|
+
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", facecolor="white")
|
|
337
|
+
plt.close(fig)
|
|
338
|
+
buf.seek(0)
|
|
339
|
+
png_bytes = buf.read()
|
|
340
|
+
if not png_bytes:
|
|
341
|
+
raise ValueError("Rendered geographic mesh plot is empty.")
|
|
342
|
+
|
|
343
|
+
render_info = {
|
|
344
|
+
"n_faces_rendered": len(polys),
|
|
345
|
+
"n_faces_total": n_faces_total,
|
|
346
|
+
"boundary_drawn": boundary_drawn,
|
|
347
|
+
"n_boundary_edges": n_boundary_drawn,
|
|
348
|
+
"boundary_status": boundary_status,
|
|
349
|
+
"seam_faces_skipped": seam_skipped,
|
|
350
|
+
"basemap_used": basemap_used,
|
|
351
|
+
"features_drawn": features_drawn,
|
|
352
|
+
"region": "regional" if is_regional else "global",
|
|
353
|
+
}
|
|
354
|
+
return png_bytes, render_info
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
_FACE_DIMS = {"n_face", "nCells"}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _reduce_to_face(uxda: Any, time_index: int = 0) -> Any:
|
|
361
|
+
"""Squeeze or isel any non-face extra dims so uxda is 1-D face-centered."""
|
|
362
|
+
extra = [d for d in uxda.dims if d not in _FACE_DIMS]
|
|
363
|
+
if not extra:
|
|
364
|
+
return uxda
|
|
365
|
+
return uxda.isel(**{d: 0 if uxda.sizes[d] == 1 else time_index for d in extra})
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def render_variable(
|
|
369
|
+
uxda: Any,
|
|
370
|
+
width: int = 800,
|
|
371
|
+
height: int = 400,
|
|
372
|
+
cmap: str = "viridis",
|
|
373
|
+
vmin: float | None = None,
|
|
374
|
+
vmax: float | None = None,
|
|
375
|
+
title: str | None = None,
|
|
376
|
+
time_index: int = 0,
|
|
377
|
+
) -> bytes:
|
|
378
|
+
"""Render a face-centered variable as a filled polygon plot to PNG bytes.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
uxda : ux.UxDataArray
|
|
383
|
+
Face-centered UXarray data array.
|
|
384
|
+
width : int
|
|
385
|
+
Image width in pixels.
|
|
386
|
+
height : int
|
|
387
|
+
Image height in pixels.
|
|
388
|
+
cmap : str
|
|
389
|
+
Matplotlib colormap name (e.g. "viridis", "plasma", "RdBu_r", "coolwarm").
|
|
390
|
+
vmin : float | None
|
|
391
|
+
Minimum value for the colormap. Defaults to data minimum.
|
|
392
|
+
vmax : float | None
|
|
393
|
+
Maximum value for the colormap. Defaults to data maximum.
|
|
394
|
+
title : str | None
|
|
395
|
+
Plot title. Defaults to the variable name.
|
|
396
|
+
|
|
397
|
+
Returns
|
|
398
|
+
-------
|
|
399
|
+
bytes
|
|
400
|
+
PNG image data.
|
|
401
|
+
"""
|
|
402
|
+
import holoviews as hv
|
|
403
|
+
|
|
404
|
+
hv.extension("matplotlib")
|
|
405
|
+
|
|
406
|
+
dpi = 100
|
|
407
|
+
fig_w = width / dpi
|
|
408
|
+
fig_h = height / dpi
|
|
409
|
+
|
|
410
|
+
kwargs: dict[str, Any] = {"backend": "matplotlib", "cmap": cmap}
|
|
411
|
+
if vmin is not None:
|
|
412
|
+
kwargs["clim"] = (vmin, vmax if vmax is not None else uxda.values.max())
|
|
413
|
+
elif vmax is not None:
|
|
414
|
+
kwargs["clim"] = (uxda.values.min(), vmax)
|
|
415
|
+
|
|
416
|
+
uxda = _reduce_to_face(uxda, time_index)
|
|
417
|
+
element = uxda.plot.polygons(**kwargs)
|
|
418
|
+
|
|
419
|
+
renderer = hv.Store.renderers["matplotlib"]
|
|
420
|
+
plot = renderer.get_plot(element)
|
|
421
|
+
fig = plot.state
|
|
422
|
+
fig.set_size_inches(fig_w, fig_h)
|
|
423
|
+
fig.set_dpi(dpi)
|
|
424
|
+
|
|
425
|
+
if title is not None:
|
|
426
|
+
fig.axes[0].set_title(title)
|
|
427
|
+
|
|
428
|
+
fig.tight_layout()
|
|
429
|
+
|
|
430
|
+
buf = io.BytesIO()
|
|
431
|
+
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
|
|
432
|
+
plt.close(fig)
|
|
433
|
+
buf.seek(0)
|
|
434
|
+
png_bytes = buf.read()
|
|
435
|
+
if not png_bytes:
|
|
436
|
+
raise ValueError(
|
|
437
|
+
"Rendered variable plot is empty. The file may be empty or contain "
|
|
438
|
+
"no plottable data."
|
|
439
|
+
)
|
|
440
|
+
return png_bytes
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def render_zonal_mean(
|
|
444
|
+
latitudes: list[float],
|
|
445
|
+
values: list[float],
|
|
446
|
+
variable_name: str,
|
|
447
|
+
width: int = 800,
|
|
448
|
+
height: int = 400,
|
|
449
|
+
line_color: str = "#1f77b4",
|
|
450
|
+
title: str | None = None,
|
|
451
|
+
) -> bytes:
|
|
452
|
+
"""Render a zonal mean profile (latitude vs value) to PNG bytes.
|
|
453
|
+
|
|
454
|
+
Parameters
|
|
455
|
+
----------
|
|
456
|
+
latitudes : list[float]
|
|
457
|
+
Latitude values.
|
|
458
|
+
values : list[float]
|
|
459
|
+
Zonal mean values at each latitude.
|
|
460
|
+
variable_name : str
|
|
461
|
+
Variable name for the axis label.
|
|
462
|
+
width : int
|
|
463
|
+
Image width in pixels.
|
|
464
|
+
height : int
|
|
465
|
+
Image height in pixels.
|
|
466
|
+
line_color : str
|
|
467
|
+
Matplotlib color string for the profile line (e.g. "red", "#e74c3c",
|
|
468
|
+
"steelblue"). Defaults to "#1f77b4" (matplotlib blue).
|
|
469
|
+
title : str | None
|
|
470
|
+
Plot title. Defaults to "Zonal Mean — <variable_name>".
|
|
471
|
+
|
|
472
|
+
Returns
|
|
473
|
+
-------
|
|
474
|
+
bytes
|
|
475
|
+
PNG image data.
|
|
476
|
+
"""
|
|
477
|
+
dpi = 100
|
|
478
|
+
fig_w = width / dpi
|
|
479
|
+
fig_h = height / dpi
|
|
480
|
+
|
|
481
|
+
fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi)
|
|
482
|
+
ax.plot(latitudes, values, linewidth=1.5, color=line_color)
|
|
483
|
+
ax.set_xlabel("Latitude (°)")
|
|
484
|
+
ax.set_ylabel(variable_name)
|
|
485
|
+
ax.set_title(title if title is not None else f"Zonal Mean — {variable_name}")
|
|
486
|
+
ax.grid(True, alpha=0.3)
|
|
487
|
+
fig.tight_layout()
|
|
488
|
+
|
|
489
|
+
buf = io.BytesIO()
|
|
490
|
+
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
|
|
491
|
+
plt.close(fig)
|
|
492
|
+
buf.seek(0)
|
|
493
|
+
png_bytes = buf.read()
|
|
494
|
+
if not png_bytes:
|
|
495
|
+
raise ValueError(
|
|
496
|
+
"Rendered zonal mean plot is empty. The data may be empty or contain "
|
|
497
|
+
"no plottable values."
|
|
498
|
+
)
|
|
499
|
+
return png_bytes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared variable inspection logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compute_variable_info(uxds: Any, variable_name: Optional[str] = None) -> dict:
|
|
9
|
+
"""Extract variable metadata and statistics from a UXarray dataset.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
uxds : ux.UxDataset
|
|
14
|
+
Loaded UXarray dataset.
|
|
15
|
+
variable_name : str | None
|
|
16
|
+
Specific variable to inspect, or None for all variables.
|
|
17
|
+
|
|
18
|
+
Returns
|
|
19
|
+
-------
|
|
20
|
+
dict
|
|
21
|
+
Keys: variables (list of metadata dicts), grid_info
|
|
22
|
+
"""
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
if variable_name:
|
|
26
|
+
if variable_name not in uxds.data_vars:
|
|
27
|
+
available = list(uxds.data_vars.keys())
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Variable '{variable_name}' not found. Available variables: {available}"
|
|
30
|
+
)
|
|
31
|
+
variables_to_inspect = [variable_name]
|
|
32
|
+
else:
|
|
33
|
+
variables_to_inspect = list(uxds.data_vars.keys())
|
|
34
|
+
|
|
35
|
+
variables_info = []
|
|
36
|
+
for var_name in variables_to_inspect:
|
|
37
|
+
var = uxds[var_name]
|
|
38
|
+
|
|
39
|
+
location = "other"
|
|
40
|
+
if "n_face" in var.dims or "nCells" in var.dims:
|
|
41
|
+
location = "faces"
|
|
42
|
+
elif "n_node" in var.dims or "nVertices" in var.dims:
|
|
43
|
+
location = "nodes"
|
|
44
|
+
elif "n_edge" in var.dims or "nEdges" in var.dims:
|
|
45
|
+
location = "edges"
|
|
46
|
+
|
|
47
|
+
var_info = {
|
|
48
|
+
"name": var_name,
|
|
49
|
+
"dims": var.dims,
|
|
50
|
+
"shape": var.shape,
|
|
51
|
+
"dtype": str(var.dtype),
|
|
52
|
+
"location": location,
|
|
53
|
+
"attrs": dict(var.attrs),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
if np.issubdtype(var.dtype, np.number):
|
|
58
|
+
values = var.values
|
|
59
|
+
var_info["statistics"] = {
|
|
60
|
+
"min": float(np.nanmin(values)),
|
|
61
|
+
"max": float(np.nanmax(values)),
|
|
62
|
+
"mean": float(np.nanmean(values)),
|
|
63
|
+
}
|
|
64
|
+
else:
|
|
65
|
+
var_info["statistics"] = None
|
|
66
|
+
except Exception:
|
|
67
|
+
var_info["statistics"] = None
|
|
68
|
+
|
|
69
|
+
variables_info.append(var_info)
|
|
70
|
+
|
|
71
|
+
grid_info = {
|
|
72
|
+
"n_face": int(uxds.uxgrid.n_face),
|
|
73
|
+
"n_node": int(uxds.uxgrid.n_node),
|
|
74
|
+
"n_edge": int(uxds.uxgrid.n_edge),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {"variables": variables_info, "grid_info": grid_info}
|