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.
@@ -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}