gmpas 0.4.2__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.
gmpas/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """gmpas — fast native-mesh plotting for MPAS output.
2
+
3
+ MPAS is unstructured: a variable-resolution spherical Voronoi tessellation with
4
+ scalars at cell centres and normal velocity on cell edges. gmpas draws it on
5
+ that mesh directly, with no regridding, so refinement survives into the figure
6
+ exactly as the model carries it.
7
+
8
+ import gmpas
9
+
10
+ ds = gmpas.open_mpas("diag.2019-09-01_00.00.00.nc", mesh="maritime.region.nc")
11
+ ds.mpas.plot("mslp") # cell field on the native Voronoi mesh
12
+ ds.mpas.plot("u") # edge field, drawn on the cell faces
13
+ ds.mpas.plot_mesh() # where the mesh refines
14
+
15
+ Depends on numpy, scipy, xarray and netCDF4; matplotlib and cartopy are only
16
+ needed to actually draw, and are imported lazily. It does not use uxarray
17
+ (which rebuilds grid topology on every call) or geopandas (the .npz geometry
18
+ cache replaces a .gpkg and also carries edge geometry).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from .accessor import MpasAccessor, open_mpas
24
+ from .data import field_label, open_data, plottable, select, spatial_dim
25
+ from .mesh import MpasMesh, has_mesh, reconstruct_cell_winds
26
+ from .paths import cache_dir, data_dir, resolve_path
27
+ from .plot import cell_field, edge_field, mesh_structure, vectors
28
+ from .raster import RASTER_THRESHOLD, rasterize, should_raster, target_grid
29
+ from .style import CMAPS, EXTENTS, Style, resolve_extent, save_figure
30
+
31
+ # kept in step with pyproject.toml by test_version.py -- `gmpas --version` and
32
+ # the built wheel disagreeing is the kind of thing only noticed after a release
33
+ __version__ = "0.4.2"
34
+
35
+ __all__ = [
36
+ # entry points
37
+ "open_mpas", "open_data", "MpasMesh", "MpasAccessor",
38
+ # rendering
39
+ "cell_field", "edge_field", "vectors", "mesh_structure", "save_figure",
40
+ # geometry / sampling
41
+ "rasterize", "target_grid", "should_raster", "RASTER_THRESHOLD",
42
+ "reconstruct_cell_winds",
43
+ # data access
44
+ "select", "spatial_dim", "field_label", "plottable", "has_mesh",
45
+ # configuration
46
+ "Style", "EXTENTS", "CMAPS", "resolve_extent",
47
+ "cache_dir", "data_dir", "resolve_path",
48
+ "__version__",
49
+ ]
gmpas/accessor.py ADDED
@@ -0,0 +1,139 @@
1
+ """An xarray accessor for MPAS output, so plotting is one line.
2
+
3
+ xarray gives you `ds.t2m.plot()` because it knows the field's coordinates. MPAS
4
+ output has no such luxury: a `diag.*.nc` is a bare (Time, nCells) array with the
5
+ geometry living in a separate mesh file. This accessor supplies the missing
6
+ half, then gets out of the way::
7
+
8
+ import gmpas
9
+ ds = gmpas.open_mpas("diag.2019-09-01_00.00.00.nc", mesh="maritime.region.nc")
10
+
11
+ ds.mpas.plot("mslp") # cell field, native Voronoi mesh
12
+ ds.mpas.plot("u") # edge field -- drawn on the faces
13
+ ds.mpas.plot("mslp", time=3)
14
+ ds.mpas.plot_mesh() # where the mesh refines
15
+
16
+ The mesh is loaded once per dataset and cached on disk between sessions, so the
17
+ second plot -- and every plot in every later session -- skips the build.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import xarray as xr
23
+
24
+ from . import data as _data
25
+ from . import plot as _plot
26
+ from .mesh import MpasMesh, has_mesh
27
+ from .paths import resolve_path
28
+
29
+ MESH_ATTR = "gmpas_mesh_path"
30
+
31
+
32
+ def open_mpas(data_path: str, mesh: str = "", **kwargs) -> xr.Dataset:
33
+ """Open MPAS output with its mesh attached, ready for `.mpas.plot(...)`.
34
+
35
+ `mesh` may be omitted when the file carries its own mesh information, or
36
+ when a mesh file with a matching cell count sits in the same directory.
37
+ """
38
+ kwargs.setdefault("decode_timedelta", False)
39
+ kwargs.setdefault("engine", "netcdf4")
40
+ path = resolve_path(data_path)
41
+ ds = xr.open_dataset(path, **kwargs)
42
+
43
+ if mesh:
44
+ ds.attrs[MESH_ATTR] = str(resolve_path(mesh))
45
+ elif has_mesh(ds):
46
+ ds.attrs[MESH_ATTR] = str(path)
47
+ else:
48
+ found = _data.find_mesh_beside(path, int(ds.sizes.get("nCells", -1)))
49
+ if found is not None:
50
+ ds.attrs[MESH_ATTR] = str(found)
51
+ return ds
52
+
53
+
54
+ @xr.register_dataset_accessor("mpas")
55
+ class MpasAccessor:
56
+ """`ds.mpas` -- native-mesh plotting for an MPAS dataset."""
57
+
58
+ def __init__(self, ds: xr.Dataset):
59
+ self._ds = ds
60
+ self._mesh: MpasMesh | None = None
61
+
62
+ # -- mesh ------------------------------------------------------------
63
+
64
+ @property
65
+ def mesh(self) -> MpasMesh:
66
+ """The mesh backing this dataset, loaded once and cached."""
67
+ if self._mesh is None:
68
+ path = self._ds.attrs.get(MESH_ATTR)
69
+ if not path:
70
+ raise ValueError(
71
+ "No mesh attached to this dataset. Open it with "
72
+ "gmpas.open_mpas(path, mesh=...), or call "
73
+ "ds.mpas.use_mesh('mesh.nc')."
74
+ )
75
+ self._mesh = MpasMesh.load(path)
76
+ return self._mesh
77
+
78
+ def use_mesh(self, mesh_path: str) -> "MpasAccessor":
79
+ """Attach (or replace) the mesh for this dataset."""
80
+ self._ds.attrs[MESH_ATTR] = str(resolve_path(mesh_path))
81
+ self._mesh = None
82
+ return self
83
+
84
+ # -- introspection ---------------------------------------------------
85
+
86
+ @property
87
+ def variables(self) -> dict[str, list[str]]:
88
+ """Plottable variables grouped by mesh element."""
89
+ return _data.plottable(self._ds)
90
+
91
+ def __repr__(self) -> str:
92
+ try:
93
+ m = self.mesh
94
+ head = f"<MPAS {m.n_cells:,} cells / {m.n_edges:,} edges — {m.path.name}>"
95
+ except ValueError:
96
+ head = "<MPAS dataset (no mesh attached)>"
97
+ groups = self.variables
98
+ counts = ", ".join(f"{len(v)} on {k}" for k, v in groups.items() if v)
99
+ return f"{head}\n {counts}"
100
+
101
+ # -- plotting --------------------------------------------------------
102
+
103
+ def plot(self, variable: str, time: int = 0, level: int = 0, **kwargs):
104
+ """Plot a variable on the native mesh, dispatching on where it lives.
105
+
106
+ Cell fields fill the Voronoi polygons; edge fields are drawn on the cell
107
+ faces they belong to. Returns (fig, ax) like a matplotlib call.
108
+ """
109
+ if variable not in self._ds:
110
+ raise KeyError(
111
+ f"{variable!r} not in dataset. Available: {self.variables}"
112
+ )
113
+ da = self._ds[variable]
114
+ dim = _data.spatial_dim(da)
115
+ values = _data.select(da, time=time, level=level)
116
+ kwargs.setdefault("label", _data.field_label(da))
117
+ kwargs.setdefault("title", variable)
118
+
119
+ if dim == "nCells":
120
+ return _plot.cell_field(self.mesh, values, **kwargs)
121
+ if dim == "nEdges":
122
+ return _plot.edge_field(self.mesh, values, **kwargs)
123
+ raise NotImplementedError(
124
+ f"{variable!r} lives on {dim}; only cell and edge fields are "
125
+ f"plotted directly. Vertex fields need the dual triangle mesh."
126
+ )
127
+
128
+ def quiver(self, u: str, v: str, time: int = 0, level: int = 0,
129
+ background: str = "", **kwargs):
130
+ """Quiver cell-centred vector components, optionally over a filled field."""
131
+ uu = _data.select(self._ds[u], time=time, level=level)
132
+ vv = _data.select(self._ds[v], time=time, level=level)
133
+ bg = _data.select(self._ds[background], time=time, level=level) \
134
+ if background else None
135
+ return _plot.vectors(self.mesh, uu, vv, background=bg, **kwargs)
136
+
137
+ def plot_mesh(self, **kwargs):
138
+ """Show the mesh itself, cells coloured by width in km."""
139
+ return _plot.mesh_structure(self.mesh, **kwargs)