ncplot 0.4.2__py3-none-any.whl → 0.4.3__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.
ncplot/command_line.py CHANGED
@@ -3,6 +3,7 @@ from ncplot import view
3
3
  import sys
4
4
  import re
5
5
  import os
6
+ import glob
6
7
 
7
8
  def is_url(x):
8
9
  regex = re.compile(
@@ -19,23 +20,29 @@ def is_url(x):
19
20
 
20
21
 
21
22
  def main():
23
+ """
24
+ The ``ncplot`` command: view the files, URLs or glob patterns it is given.
25
+ Returns a message (which ``sys.exit`` prints, exiting with status 1) if
26
+ there is nothing to view.
27
+ """
22
28
  if len(sys.argv) == 1:
23
29
  return "Please supply a file"
24
30
 
31
+ ff = []
25
32
  for x in sys.argv[1:]:
26
- if os.path.exists(x):
27
- ff = x
28
- break
29
- if is_url(x):
30
- ff = x
31
-
32
- if ff is None:
33
- return f"{ff} does not exist!"
34
-
35
- ff = sys.argv[1]
36
-
37
- return view(ff)
38
-
33
+ if os.path.exists(x) or is_url(x):
34
+ ff.append(x)
35
+ elif glob.has_magic(x):
36
+ # a pattern the shell did not expand (it was quoted)
37
+ ff += sorted(glob.glob(x))
38
+
39
+ if len(ff) == 0:
40
+ return f"{sys.argv[1]} does not exist!"
41
+
42
+ if len(ff) == 1:
43
+ view(ff[0])
44
+ else:
45
+ view(ff)
39
46
 
40
47
 
41
48
  if __name__ == '__main__':
ncplot/grid.py ADDED
@@ -0,0 +1,266 @@
1
+ """Grids that cannot be drawn as they are, drawn on a regular longitude-latitude
2
+ grid instead: an unstructured grid (ICON's or FESOM's cells along one dimension,
3
+ with a longitude and latitude each) and a curvilinear one that a quadmesh pulls
4
+ out of shape (the seam of a global ocean grid, where the longitudes jump by 360
5
+ degrees, and the north fold of a tripolar one, where two rows of cells are in
6
+ the same places), as the native grids of the CMIP6 ocean models are.
7
+
8
+ (Ported from oceanval's viewer_grid.py.)
9
+
10
+ Each pixel of the image takes the value of the cell whose centre is nearest
11
+ (on the sphere, so the seam and the fold do not matter), if it is near enough
12
+ to be in that cell: within its radius, times RADIUS_FACTOR (half the diagonal
13
+ of a square cell is 0.71 of its side). A curvilinear grid's cell's radius is
14
+ the distance to the farthest of its neighbours along the rows and columns; an
15
+ unstructured grid's, the distance to its fourth nearest other cell. Cells with
16
+ no longitude or latitude, or piled up at one place (NEMO puts the cells of the
17
+ land processors it leaves out at one point; more than the two cells of a north
18
+ fold or a repeated column), are left out. A pixel whose cell has no value
19
+ takes the next nearest cell it is in that has one (OWNERS are looked at), on
20
+ a grid folded over itself only (has_folds: a ROMS grid's lines crossing),
21
+ which can put land cells over sea cells. Which cell each pixel takes is worked out once for a grid (Raster) and
22
+ kept (raster_for), so each map after the first costs one take. The cells'
23
+ corners (CMIP's vertices) are not needed.
24
+ """
25
+
26
+ import hashlib
27
+ import warnings
28
+ from collections import OrderedDict
29
+
30
+ import numpy as np
31
+
32
+ # the longest side of the image, in pixels (a quarter of a degree, round the globe)
33
+ MAX_SIDE = 1440
34
+ # the pixels are about this fraction of the cells' narrowest side (the median of the nearest neighbours)
35
+ PIXEL_FACTOR = 0.5
36
+ # a pixel is in its nearest cell if within this times the distance from that cell to its 4th nearest
37
+ RADIUS_FACTOR = 0.75
38
+ # how many of a cell's nearest others are looked at for its radius (piled-up cells are skipped)
39
+ NEIGHBOURS = 8
40
+ # a pixel's cells looked at, nearest first: the first with a value that the pixel is in gives it its value
41
+ OWNERS = 16
42
+ # rasters kept, for the grids drawn last
43
+ KEPT = 4
44
+ _kept = OrderedDict()
45
+
46
+
47
+ def _xyz(lon, lat):
48
+ lon, lat = np.radians(lon), np.radians(lat)
49
+ return np.column_stack([np.cos(lat) * np.cos(lon), np.cos(lat) * np.sin(lon), np.sin(lat)])
50
+
51
+
52
+ def _wrap(lon):
53
+ """lon in [-180, 180)."""
54
+ return (np.asarray(lon, dtype="float64") + 180) % 360 - 180
55
+
56
+
57
+ def has_seam(lon):
58
+ """Whether neighbouring cells of a 2-D longitude are more than 180 degrees
59
+ apart (the seam of a global grid, or a regional one across the dateline)."""
60
+ lon = _wrap(lon)
61
+ if lon.ndim != 2:
62
+ return False
63
+ with np.errstate(invalid="ignore"):
64
+ return bool(
65
+ (np.abs(np.diff(lon, axis=0)) > 180).any() or (np.abs(np.diff(lon, axis=1)) > 180).any()
66
+ )
67
+
68
+
69
+ def has_folds(lon, lat):
70
+ """Whether some cells of a 2-D grid are turned over: the quadrilateral of
71
+ four neighbouring centres goes round the other way from most (a ROMS
72
+ grid's lines crossing over land), which a quadmesh draws as spikes
73
+ across the map."""
74
+ lon, lat = np.asarray(lon, dtype="float64"), np.asarray(lat, dtype="float64")
75
+ if lon.ndim != 2 or min(lon.shape) < 2:
76
+ return False
77
+ # longitudes as steps from a neighbour, so a seam is no fold
78
+ along = _wrap(lon[:-1, 1:] - lon[:-1, :-1]), lat[:-1, 1:] - lat[:-1, :-1]
79
+ across = _wrap(lon[1:, :-1] - lon[:-1, :-1]), lat[1:, :-1] - lat[:-1, :-1]
80
+ with np.errstate(invalid="ignore"):
81
+ turn = along[0] * across[1] - along[1] * across[0]
82
+ turn = turn[np.isfinite(turn) & (turn != 0)]
83
+ if not turn.size:
84
+ return False
85
+ return bool(min((turn > 0).sum(), (turn < 0).sum()) > 0)
86
+
87
+
88
+ def _grid_radius(xyz):
89
+ """For each cell of a curvilinear grid (xyz: (ny, nx, 3), NaN where a cell
90
+ is left out), the farthest and the nearest of its neighbours along the
91
+ grid's rows and columns (chords on the unit sphere), 0 if it has none."""
92
+ distances = []
93
+ for axis in (0, 1):
94
+ step = np.linalg.norm(np.diff(xyz, axis=axis), axis=-1)
95
+ pad = [(0, 0), (0, 0)]
96
+ pad[axis] = (1, 0)
97
+ distances.append(np.pad(step, pad, constant_values=np.nan))
98
+ pad[axis] = (0, 1)
99
+ distances.append(np.pad(step, pad, constant_values=np.nan))
100
+ distances = np.stack(distances)
101
+ # a cell in the same place as its neighbour (a repeated column) is no measure of its size
102
+ distances[distances <= 1e-7] = np.nan
103
+ with np.errstate(invalid="ignore"), warnings.catch_warnings():
104
+ warnings.simplefilter("ignore", RuntimeWarning)
105
+ radius = np.nanmax(distances, axis=0)
106
+ nearest = np.nanmin(distances, axis=0)
107
+ return np.nan_to_num(radius, nan=0.0), np.nan_to_num(nearest, nan=0.0)
108
+
109
+
110
+ def _cloud_radius(tree, points):
111
+ """For each of the cells at points (an unstructured grid's, (n, 3) on the
112
+ unit sphere, whose cKDTree is tree), the chord to its 4th nearest other
113
+ cell (or the farthest of those looked at), and to its nearest."""
114
+ k = min(NEIGHBOURS, len(points))
115
+ distances, _ = tree.query(points, k=k, workers=-1)
116
+ apart = np.where(distances > 1e-7, distances, np.nan)
117
+ counted = np.sum(np.isfinite(apart), axis=1)
118
+ apart.sort(axis=1)
119
+ which = np.clip(np.minimum(counted, 4) - 1, 0, None)
120
+ radius = np.where(counted > 0, apart[np.arange(len(apart)), which], 0.0)
121
+ return radius, np.where(counted > 0, apart[:, 0], 0.0)
122
+
123
+
124
+ def _degrees(chord):
125
+ return np.degrees(2 * np.arcsin(np.clip(chord / 2, 0, 1)))
126
+
127
+
128
+ class Raster:
129
+ """For the cells at lon, lat (arrays of one shape: a curvilinear grid's 2-D
130
+ ones, or an unstructured grid's 1-D ones), a regular longitude-latitude
131
+ grid (x_axis, y_axis: the pixels' centres) over them, at most max_side
132
+ pixels on its longest side, and for each pixel the cell it is in, if any."""
133
+
134
+ def __init__(self, lon, lat, max_side=MAX_SIDE):
135
+ from scipy.spatial import cKDTree
136
+
137
+ lon = np.asarray(lon, dtype="float64")
138
+ lat = np.asarray(lat, dtype="float64")
139
+ self.cells = lon.size
140
+ self.grid_shape = lon.shape
141
+ usable = np.isfinite(lon) & np.isfinite(lat) & (np.abs(lat) <= 90)
142
+ # cells piled up in one place (more than the two of a fold or a grid's repeated columns): fill values
143
+ rounded = np.where(usable, np.round(_wrap(lon), 5) + 1j * np.round(lat, 5), np.nan)
144
+ _, where, counts = np.unique(rounded.ravel(), return_inverse=True, return_counts=True)
145
+ piled = usable & (counts[where] > 2).reshape(lon.shape)
146
+ # not shown, whatever their values: a model's filler (CESM's land processors at -1, -1 have tos -1)
147
+ self.piled = piled.ravel()
148
+ usable &= ~piled
149
+ xyz = np.full(lon.shape + (3,), np.nan)
150
+ xyz[usable] = _xyz(lon[usable], lat[usable])
151
+ tree = None
152
+ if lon.ndim == 2:
153
+ radius, nearest = _grid_radius(xyz)
154
+ else:
155
+ radius, nearest = np.zeros(lon.shape), np.zeros(lon.shape)
156
+ if usable.sum() >= 2:
157
+ tree = cKDTree(xyz[usable])
158
+ radius[usable], nearest[usable] = _cloud_radius(tree, xyz[usable])
159
+ # the two cells in one place (across a fold) are as big as the bigger: the nearest of them is either
160
+ group = where.reshape(lon.shape)[usable]
161
+ largest = np.zeros(counts.size)
162
+ np.maximum.at(largest, group, radius[usable])
163
+ radius[usable] = largest[group]
164
+ keep = (usable & (radius > 0)).ravel()
165
+ if keep.sum() < 2:
166
+ raise ValueError("The grid's longitudes and latitudes place too few cells.")
167
+ placed = np.flatnonzero(keep)
168
+ points, radius = xyz.reshape(-1, 3)[keep], radius.ravel()[keep]
169
+ lon, lat = lon.ravel(), lat.ravel()
170
+ self.kept = placed
171
+ # the cells' size (the farthest neighbour), for the margin; their narrowest side (the nearest), for the
172
+ # pixels, so a cell narrow one way (near a pole) still has a pixel of its own, MAX_SIDE allowing
173
+ self.spacing = float(_degrees(np.median(radius)))
174
+ narrowest = nearest.ravel()[keep]
175
+ self.narrowest = float(_degrees(np.median(narrowest[narrowest > 0]))) if (narrowest > 0).any() else self.spacing
176
+ radius = radius * RADIUS_FACTOR
177
+
178
+ lons, lats = _wrap(lon[self.kept]), lat[self.kept]
179
+ # longitudes from -180 or from 0, whichever covers the cells in less (a grid across the dateline),
180
+ # -180 unless 0 saves more than a cell
181
+ shifted = lons % 360
182
+ if np.ptp(shifted) < np.ptp(lons) - self.spacing:
183
+ lons = shifted
184
+ pad = self.spacing / 2
185
+ x0, x1 = float(lons.min()) - pad, float(lons.max()) + pad
186
+ y0, y1 = max(float(lats.min()) - pad, -90.0), min(float(lats.max()) + pad, 90.0)
187
+ if x1 - x0 > 360 - self.spacing:
188
+ # round the globe
189
+ x0, x1 = (-180.0, 180.0) if lons.min() < 0 else (0.0, 360.0)
190
+ step = max(self.narrowest * PIXEL_FACTOR, max(x1 - x0, y1 - y0) / max_side)
191
+ nx, ny = max(2, int(np.ceil((x1 - x0) / step))), max(2, int(np.ceil((y1 - y0) / step)))
192
+ self.shape = (ny, nx)
193
+ self.step = step
194
+ self.x_axis = x0 + (np.arange(nx) + 0.5) * (x1 - x0) / nx
195
+ self.y_axis = y0 + (np.arange(ny) + 0.5) * (y1 - y0) / ny
196
+
197
+ if tree is None or tree.n != len(points):
198
+ tree = cKDTree(points)
199
+ px, py = np.meshgrid(self.x_axis, self.y_axis)
200
+ # pixels farther than any cell's radius from every cell are in none: the search stops there (without the
201
+ # bound, a pixel far from a dense mesh, on land, searches most of the tree)
202
+ # only a grid folded over itself looks past a pixel's nearest cell: elsewhere a coast's sea would spread
203
+ # into its land cells
204
+ k = min(OWNERS if has_folds(lon.reshape(self.grid_shape), lat.reshape(self.grid_shape)) else 1, len(points))
205
+ distance, nearest = tree.query(
206
+ _xyz(px.ravel(), py.ravel()), k=k, workers=-1, distance_upper_bound=float(radius.max()) * (1 + 1e-9)
207
+ )
208
+ distance, nearest = distance.reshape(-1, k), nearest.reshape(-1, k)
209
+ found = nearest < len(points)
210
+ inside = np.zeros(found.shape, dtype=bool)
211
+ inside[found] = distance[found] <= radius[nearest[found]]
212
+ self.pixels = np.flatnonzero(inside.any(axis=1))
213
+ # for each pixel, the cells it is in, nearest first (-1: no more); the first is its own cell
214
+ owners = np.where(inside, self.kept[np.where(found, nearest, 0)], -1)[self.pixels]
215
+ # each pixel's cells in order, those it is in first
216
+ order = np.argsort(owners < 0, axis=1, kind="stable")
217
+ owners = np.take_along_axis(owners, order, axis=1).astype("int32" if self.cells < 2**31 else "int64")
218
+ # no more columns than the most cells a pixel is in
219
+ self.candidates = owners[:, : max(1, int((owners >= 0).sum(axis=1).max()))]
220
+ self.owners = self.candidates[:, 0]
221
+
222
+ def image(self, values):
223
+ """The values of the cells (of the grid's shape, or flattened) as a
224
+ (ny, nx) float64 array: NaN where no cell is, or where its value is
225
+ missing. A pixel whose cell's value is missing takes that of the next
226
+ nearest cell it is in that has one (a grid folded over itself puts
227
+ land cells over sea cells, which they would hide)."""
228
+ values = np.asarray(values, dtype="float64").ravel()
229
+ if values.size != self.cells:
230
+ raise ValueError("The values are not of the grid they are drawn on.")
231
+ taken = values[self.owners]
232
+ for column in range(1, self.candidates.shape[1]):
233
+ missing = ~np.isfinite(taken)
234
+ if not missing.any():
235
+ break
236
+ cells = self.candidates[missing, column]
237
+ taken[missing] = np.where(cells >= 0, values[np.maximum(cells, 0)], np.nan)
238
+ out = np.full(self.shape[0] * self.shape[1], np.nan)
239
+ out[self.pixels] = taken
240
+ return out.reshape(self.shape)
241
+
242
+ def placed_share(self, values):
243
+ """The share of the cells with values that the image shows (some, at
244
+ least, of its pixels), cells piled up in one place left out."""
245
+ values = np.asarray(values, dtype="float64").ravel()
246
+ valued = np.isfinite(values) & ~self.piled
247
+ if not valued.any():
248
+ return 1.0
249
+ shown = np.zeros(values.size, dtype=bool)
250
+ shown[self.kept] = True
251
+ return float((shown & valued).sum() / valued.sum())
252
+
253
+
254
+ def raster_for(lon, lat):
255
+ """The Raster of the grid at lon, lat, made the first time and kept."""
256
+ lon = np.ascontiguousarray(lon, dtype="float64")
257
+ lat = np.ascontiguousarray(lat, dtype="float64")
258
+ key = (lon.shape, hashlib.sha1(lon.tobytes()).hexdigest(), hashlib.sha1(lat.tobytes()).hexdigest())
259
+ if key in _kept:
260
+ _kept.move_to_end(key)
261
+ return _kept[key]
262
+ raster = Raster(lon, lat)
263
+ _kept[key] = raster
264
+ while len(_kept) > KEPT:
265
+ _kept.popitem(last=False)
266
+ return raster
ncplot/loading.py ADDED
@@ -0,0 +1,38 @@
1
+ """Opening a NetCDF file lazily and forgivingly."""
2
+
3
+ import warnings
4
+
5
+ import xarray as xr
6
+
7
+
8
+ def decode_what_decodes(ds):
9
+ """``ds`` (opened with ``decode_times=False``) with each time variable that
10
+ can be decoded decoded, and those that cannot (FVCOM writes ``Itime2`` in
11
+ ``msec since 00:00:00``, which has no date) left as they were."""
12
+ from xarray.conventions import decode_cf_variable
13
+
14
+ decoded = {}
15
+ for name, variable in ds.variables.items():
16
+ if " since " not in str(variable.attrs.get("units", "")):
17
+ continue
18
+ try:
19
+ decoded[name] = decode_cf_variable(name, variable, decode_times=True)
20
+ except Exception: # noqa: BLE001 - whatever xarray or cftime cannot decode stays as it is
21
+ continue
22
+ coords = {name: variable for name, variable in decoded.items() if name in ds.coords}
23
+ data = {name: variable for name, variable in decoded.items() if name not in ds.coords}
24
+ return ds.assign_coords(coords).assign(data)
25
+
26
+
27
+ def open_one(path, **kwargs):
28
+ """``xr.open_dataset(path)``, falling back to decoding the times one
29
+ variable at a time when xarray cannot decode all of them."""
30
+ with warnings.catch_warnings():
31
+ # a reference date like FVCOM's "msec since 00:00:00" is ambiguous, and told so
32
+ warnings.filterwarnings("ignore", category=xr.SerializationWarning)
33
+ try:
34
+ return xr.open_dataset(path, **kwargs)
35
+ except ValueError as error:
36
+ if "decode" not in str(error).lower() and "time" not in str(error).lower():
37
+ raise
38
+ return decode_what_decodes(xr.open_dataset(path, decode_times=False, **kwargs))
ncplot/mesh.py ADDED
@@ -0,0 +1,197 @@
1
+ """FVCOM output: an unstructured mesh of triangles, with values on its nodes
2
+ (node) or on its elements (nele), the triangles' nodes in nv (three, nele,
3
+ 1-based), and sigma layers (siglay, siglev) as the vertical.
4
+
5
+ Which output is FVCOM's is told by the mesh's dimensions and nv.
6
+
7
+ (Ported from oceanval's viewer_mesh.py.)
8
+
9
+ The plotting uses pyfvcom2's approach
10
+ (https://github.com/PlymouthMarineLaboratory/pyfvcom2, plotting/plot.py,
11
+ FVCOMPlotter): the triangles are nv transposed, less 1 (its
12
+ Triangulation(x, y, nv.T - 1)), on the nodes' longitudes and latitudes, and
13
+ a field is coloured over them as its tripcolor colours it: a value on the
14
+ nodes shaded linearly across each triangle, a value on the elements filling
15
+ its triangle. As Bokeh cannot draw a mesh of many triangles quickly, that is
16
+ done here onto a regular grid (Raster) and drawn as an image, pixels outside
17
+ the mesh missing. Which triangle each pixel
18
+ is in, and its weights, are worked out once for a mesh, so each map after
19
+ the first costs one weighted sum. Only numpy is needed.
20
+ """
21
+
22
+ import numpy as np
23
+
24
+ # the variables that describe the mesh rather than the ocean, which are not mapped
25
+ MESH_VARIABLES = {
26
+ "nv", "nbe", "nbve", "nbsn", "ntve", "ntsn", "partition", "lon", "lat", "lonc", "latc",
27
+ "x", "y", "xc", "yc", "siglay", "siglev", "siglay_center", "siglev_center", "Itime", "Itime2",
28
+ "Times", "time", "iint", "a1u", "a2u", "aw0", "awx", "awy", "art1", "art2",
29
+ }
30
+
31
+ # the longest side of a map's image, in pixels
32
+ MAX_SIDE = 1000
33
+ # triangle-pixel pairs looked at at once, as the raster is made
34
+ PAIRS_AT_ONCE = 4_000_000
35
+
36
+
37
+ def looks_like_fvcom(ds):
38
+ """Whether an open dataset (xarray, netCDF4 or anything with ``.dims`` or
39
+ ``.dimensions``, and ``.variables``) is raw FVCOM output: the unstructured
40
+ mesh, which output that has been regridded no longer has."""
41
+ dims = ds.dimensions if hasattr(ds, "dimensions") else ds.dims
42
+ return {"node", "nele"} <= set(dims) and "nv" in ds.variables
43
+
44
+
45
+ def mesh_location(dims):
46
+ """"node" or "nele", whichever of the mesh's dimensions dims has, or None."""
47
+ return "node" if "node" in dims else "nele" if "nele" in dims else None
48
+
49
+
50
+ def _array(ds, name):
51
+ """The variable as a float64 numpy array, NaN where it is missing."""
52
+ return np.asarray(ds.variables[name].values, dtype="float64")
53
+
54
+
55
+ class Mesh:
56
+ """The mesh of an FVCOM file: where its nodes are (x, y: longitude and
57
+ latitude, or metres when the file has no longitudes), and its triangles
58
+ (nele, 3), 0-based."""
59
+
60
+ def __init__(self, x, y, triangles, cartesian=False):
61
+ self.x = np.asarray(x, dtype="float64")
62
+ self.y = np.asarray(y, dtype="float64")
63
+ self.triangles = np.asarray(triangles, dtype="int64")
64
+ self.cartesian = cartesian
65
+ self._rasters = {}
66
+
67
+ @classmethod
68
+ def from_dataset(cls, ds):
69
+ """The mesh of an open xarray Dataset of FVCOM output."""
70
+ if "nv" not in ds.variables:
71
+ raise ValueError("The dataset has no nv, so it has no FVCOM mesh.")
72
+ nv = np.asarray(ds.variables["nv"].values).astype("int64")
73
+ lon = _array(ds, "lon") if "lon" in ds.variables else None
74
+ lat = _array(ds, "lat") if "lat" in ds.variables else None
75
+ cartesian = lon is None or lat is None or not (np.nanmax(np.abs(lon)) > 0 or np.nanmax(np.abs(lat)) > 0)
76
+ if cartesian:
77
+ if "x" not in ds.variables or "y" not in ds.variables:
78
+ raise ValueError("The FVCOM mesh has neither longitudes and latitudes nor x and y.")
79
+ x, y = _array(ds, "x"), _array(ds, "y")
80
+ else:
81
+ x, y = np.where(lon > 180, lon - 360, lon), lat
82
+ # nv is (three, nele) and counts from 1
83
+ triangles = (nv.T if nv.shape[0] == 3 else nv) - 1
84
+ return cls(x, y, triangles, cartesian)
85
+
86
+ @classmethod
87
+ def read(cls, path):
88
+ """The mesh of the FVCOM file at path."""
89
+ import xarray as xr
90
+
91
+ # only the mesh is read: the file's times need not be decoded
92
+ with xr.open_dataset(path, decode_times=False, decode_timedelta=False) as ds:
93
+ return cls.from_dataset(ds[[name for name in ("nv", "lon", "lat", "x", "y") if name in ds.variables]])
94
+
95
+ @property
96
+ def nodes(self):
97
+ return len(self.x)
98
+
99
+ @property
100
+ def elements(self):
101
+ return len(self.triangles)
102
+
103
+ def raster(self, max_side=MAX_SIDE):
104
+ """The Raster of this mesh, at most ``max_side`` pixels on its longest
105
+ side (made the first time it is asked for)."""
106
+ if max_side not in self._rasters:
107
+ self._rasters[max_side] = Raster(self, max_side)
108
+ return self._rasters[max_side]
109
+
110
+
111
+ class Raster:
112
+ """A regular grid over a mesh, at most MAX_SIDE pixels on its longest
113
+ side, and for each pixel inside the mesh the triangle it is in and the
114
+ weights of the triangle's three nodes there (barycentric coordinates)."""
115
+
116
+ def __init__(self, mesh, max_side=MAX_SIDE):
117
+ self.mesh = mesh
118
+ x, y, triangles = mesh.x, mesh.y, mesh.triangles
119
+ used = np.unique(triangles)
120
+ x0, x1 = float(np.nanmin(x[used])), float(np.nanmax(x[used]))
121
+ y0, y1 = float(np.nanmin(y[used])), float(np.nanmax(y[used]))
122
+ width, height = max(x1 - x0, 1e-12), max(y1 - y0, 1e-12)
123
+ if not mesh.cartesian:
124
+ # a degree of longitude is shorter than one of latitude
125
+ width *= max(np.cos(np.radians((y0 + y1) / 2)), 0.05)
126
+ if width >= height:
127
+ nx, ny = max_side, max(2, round(max_side * height / width))
128
+ else:
129
+ nx, ny = max(2, round(max_side * width / height)), max_side
130
+ self.shape = (ny, nx)
131
+ dx, dy = (x1 - x0) / nx, (y1 - y0) / ny
132
+ # the centres of the pixels
133
+ self.x_axis = x0 + (np.arange(nx) + 0.5) * dx
134
+ self.y_axis = y0 + (np.arange(ny) + 0.5) * dy
135
+
136
+ corners_x, corners_y = x[triangles], y[triangles]
137
+ # the pixels whose centres are in each triangle's bounding box
138
+ i_low = np.ceil((corners_x.min(axis=1) - x0) / dx - 0.5).astype("int64").clip(0, nx - 1)
139
+ i_high = np.floor((corners_x.max(axis=1) - x0) / dx - 0.5).astype("int64").clip(-1, nx - 1)
140
+ j_low = np.ceil((corners_y.min(axis=1) - y0) / dy - 0.5).astype("int64").clip(0, ny - 1)
141
+ j_high = np.floor((corners_y.max(axis=1) - y0) / dy - 0.5).astype("int64").clip(-1, ny - 1)
142
+ widths = np.maximum(i_high - i_low + 1, 0)
143
+ counts = widths * np.maximum(j_high - j_low + 1, 0)
144
+ counts[~np.isfinite(corners_x).all(axis=1) | ~np.isfinite(corners_y).all(axis=1)] = 0
145
+
146
+ pixels, owners, weights = [], [], []
147
+ start = 0
148
+ ends = np.cumsum(counts)
149
+ while start < len(triangles):
150
+ # triangles enough for PAIRS_AT_ONCE pairs (at least one)
151
+ done = ends[start - 1] if start else 0
152
+ stop = max(start + 1, int(np.searchsorted(ends, done + PAIRS_AT_ONCE, side="right")))
153
+ chunk = np.arange(start, min(stop, len(triangles)))
154
+ start = chunk[-1] + 1
155
+ chunk = chunk[counts[chunk] > 0]
156
+ if not chunk.size:
157
+ continue
158
+ owner = np.repeat(chunk, counts[chunk])
159
+ offsets = np.repeat(np.cumsum(counts[chunk]) - counts[chunk], counts[chunk])
160
+ k = np.arange(owner.size) - offsets
161
+ ii = i_low[owner] + k % widths[owner]
162
+ jj = j_low[owner] + k // widths[owner]
163
+ px, py = x0 + (ii + 0.5) * dx, y0 + (jj + 0.5) * dy
164
+ ax, ay = corners_x[owner, 0], corners_y[owner, 0]
165
+ bx, by = corners_x[owner, 1], corners_y[owner, 1]
166
+ cx, cy = corners_x[owner, 2], corners_y[owner, 2]
167
+ denominator = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
168
+ with np.errstate(divide="ignore", invalid="ignore"):
169
+ wa = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / denominator
170
+ wb = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / denominator
171
+ wc = 1 - wa - wb
172
+ eps = -1e-9
173
+ inside = (denominator != 0) & (wa >= eps) & (wb >= eps) & (wc >= eps)
174
+ pixels.append((jj * nx + ii)[inside])
175
+ owners.append(owner[inside])
176
+ weights.append(np.stack([wa, wb, wc], axis=1)[inside].clip(0, 1))
177
+ pixels = np.concatenate(pixels) if pixels else np.zeros(0, "int64")
178
+ owners = np.concatenate(owners) if owners else np.zeros(0, "int64")
179
+ weights = np.concatenate(weights) if weights else np.zeros((0, 3))
180
+ # a pixel on an edge two triangles share goes to the first
181
+ pixels, first = np.unique(pixels, return_index=True)
182
+ self.pixels = pixels
183
+ self.elements = owners[first]
184
+ self.weights = weights[first]
185
+
186
+ def image(self, values, location="node"):
187
+ """The values of the mesh (one for each node, or for each element:
188
+ location) as a (ny, nx) float64 array: NaN outside the mesh, and
189
+ where any of a triangle's nodes is missing."""
190
+ values = np.asarray(values, dtype="float64").ravel()
191
+ out = np.full(self.shape[0] * self.shape[1], np.nan)
192
+ if location == "nele":
193
+ out[self.pixels] = values[self.elements]
194
+ else:
195
+ corners = values[self.mesh.triangles[self.elements]]
196
+ out[self.pixels] = (corners * self.weights).sum(axis=1)
197
+ return out.reshape(self.shape)
ncplot/plot.py CHANGED
@@ -29,10 +29,44 @@ import numpy as np
29
29
  import os
30
30
 
31
31
  from ncplot.utils import get_dims, check_lon, check_lat, normalise_lon
32
+ from ncplot.loading import open_one
33
+ from ncplot.regrid import regrid_dataset, native_range, warn_notes
32
34
 
33
35
  hv.extension("bokeh")
34
36
  hv.Store.renderers
35
37
 
38
+
39
+ def _give_frames_unique_ids():
40
+ """
41
+ Work around a holoviews bug that leaves a frame undrawn: a plot skips
42
+ redrawing when a new frame's ``_plot_id`` equals the last one drawn, and
43
+ the id defaults to ``id(element)``, a memory address. A freed element's
44
+ address can be reused by the next frame, so a slider step can keep the
45
+ frame before. Dynamic frames are given ids that are never reused instead.
46
+ """
47
+ try:
48
+ import itertools
49
+ from holoviews.plotting.plot import GenericElementPlot
50
+ except ImportError:
51
+ return
52
+ if getattr(GenericElementPlot._get_frame, "_ncplot_unique_ids", False):
53
+ return
54
+
55
+ ids = itertools.count()
56
+ original = GenericElementPlot._get_frame
57
+
58
+ def _get_frame(self, key):
59
+ frame = original(self, key)
60
+ if frame is not None and self.dynamic:
61
+ frame._plot_id = next(ids)
62
+ return frame
63
+
64
+ _get_frame._ncplot_unique_ids = True
65
+ GenericElementPlot._get_frame = _get_frame
66
+
67
+
68
+ _give_frames_unique_ids()
69
+
36
70
  def as_raster():
37
71
  """
38
72
  Simple hack to rasterize when datashader is available.
@@ -400,13 +434,34 @@ def view(x, vars=None, autoscale=True, out=None, **kwargs):
400
434
  nc_vars = ds.data_vars
401
435
  ds = ds.to_xarray()
402
436
  except:
403
- ds = xr.open_dataset(x)
437
+ ds = open_one(x)
404
438
  except:
405
439
  ds = xr.open_dataset(x, decode_times=False)
406
440
  warnings.warn("Warning: xarray could not decode times!")
407
441
  else:
408
442
  ds = x
409
443
 
444
+ # curvilinear grids (a global ocean grid's seam and north fold) and unstructured
445
+ # meshes (FVCOM) are put on a regular longitude-latitude grid, to be plotted as such
446
+ native_limits = None
447
+ regridded, notes = regrid_dataset(ds, vars)
448
+ if regridded is not None:
449
+ warn_notes(notes)
450
+ if vars is None:
451
+ vars = list(regridded.data_vars)
452
+ elif isinstance(vars, str):
453
+ vars = vars if vars in regridded.data_vars else list(regridded.data_vars)
454
+ else:
455
+ vars = [vv for vv in vars if vv in regridded.data_vars]
456
+ if len(vars) == 1 or isinstance(vars, str):
457
+ only = vars if isinstance(vars, str) else vars[0]
458
+ # the range of the values as they are: finding it from the regridded ones would read every frame
459
+ native_limits = native_range(ds, only)
460
+ if native_limits is not None and "clim" not in kwargs:
461
+ kwargs["clim"] = native_limits
462
+ ds = regridded
463
+ nc_vars = None
464
+
410
465
  if vars is None:
411
466
  if nc_vars is not None:
412
467
  vars = [x for x in nc_vars]
@@ -501,11 +556,8 @@ def view(x, vars=None, autoscale=True, out=None, **kwargs):
501
556
 
502
557
  switch_coords = False
503
558
 
504
- try:
505
- #import datashader
506
- rasterize = True
507
- except:
508
- rasterize = False
559
+ # rasterize only if datashader is there to do it
560
+ rasterize = as_raster()
509
561
 
510
562
  if quadmesh:
511
563
  switch_coords = True
@@ -1104,9 +1156,13 @@ def view(x, vars=None, autoscale=True, out=None, **kwargs):
1104
1156
  vars = vars[0]
1105
1157
 
1106
1158
  if autoscale:
1107
- self_max = ds.rename({vars: "x_dim12345"}).x_dim12345.max()
1108
- self_min = ds.rename({vars: "x_dim12345"}).x_dim12345.min()
1109
- v_max = max(self_max.values, -self_min.values)
1159
+ if native_limits is not None:
1160
+ self_min, self_max = native_limits
1161
+ v_max = max(self_max, -self_min)
1162
+ else:
1163
+ self_max = ds.rename({vars: "x_dim12345"}).x_dim12345.max()
1164
+ self_min = ds.rename({vars: "x_dim12345"}).x_dim12345.min()
1165
+ v_max = max(self_max.values, -self_min.values)
1110
1166
  if "clim" in kwargs:
1111
1167
  v_max = float(max(-kwargs["clim"][0], kwargs["clim"][1]))
1112
1168
  else:
ncplot/regrid.py ADDED
@@ -0,0 +1,286 @@
1
+ """Curvilinear grids and unstructured meshes as regular longitude-latitude ones.
2
+
3
+ ``view`` plots regular grids well, and anything else badly: the seam of a
4
+ global ocean grid (where the longitudes jump by 360 degrees), its tripolar
5
+ north fold (two rows of cells in the same places), cells piled up at one
6
+ point, and FVCOM's triangular mesh. ``regrid_dataset`` gives such variables a
7
+ regular grid to be drawn on instead, using the engine ported from oceanval
8
+ (``ncplot.grid`` for curvilinear and unstructured cells, ``ncplot.mesh`` for FVCOM).
9
+
10
+ The result is lazy, and needs no dask: a frame (one time, one level) is read
11
+ and put on the regular grid only when it is asked for, so a 6 GB FVCOM file
12
+ is opened at once and each time step shown costs one slice.
13
+ """
14
+
15
+ import warnings
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ import xarray as xr
20
+ from xarray.backends import BackendArray
21
+ from xarray.core import indexing
22
+
23
+ from ncplot import grid as ncgrid
24
+ from ncplot import mesh as ncmesh
25
+
26
+ _LON_UNITS = ("degrees_east", "degree_east", "degrees_e", "degree_e", "degreee")
27
+ _LAT_UNITS = ("degrees_north", "degree_north", "degrees_n", "degree_n", "degreen")
28
+
29
+ # variables that describe a grid, not the ocean
30
+ _BOUNDS_SUFFIXES = ("_bnds", "_bounds", "_vertices")
31
+
32
+ # a grid whose longitudes and latitudes leave out more of the cells with values than this is not regridded
33
+ MIN_PLACED = 0.9
34
+
35
+
36
+ # ---- what a variable is on ----
37
+
38
+
39
+ def _attr(variable, name):
40
+ value = variable.attrs.get(name, "")
41
+ return str(value) if value is not None else ""
42
+
43
+
44
+ def _bounds_names(ds):
45
+ names = set()
46
+ for variable in ds.variables.values():
47
+ for key in ("bounds", "climatology", "edges"):
48
+ if key in variable.attrs:
49
+ names.add(str(variable.attrs[key]))
50
+ return names
51
+
52
+
53
+ def find_lonlat(ds, name):
54
+ """The names of the longitude and latitude of variable ``name`` of ``ds``,
55
+ or Nones: its coordinates with a standard_name, units or name that says
56
+ so (``nav_lon`` and ``nav_lat`` too). A variable no coordinates name (an
57
+ unstructured grid's, whose longitudes and latitudes are variables
58
+ nothing points at) takes any variable on its dimensions that says it is
59
+ one. Attributes are read through ``ds.variables``, which is cheap."""
60
+ variables = ds.variables
61
+ variable = variables[name]
62
+ dims = set(variable.dims)
63
+ candidates = list(ds[name].coords)
64
+ for word in _attr(variable, "coordinates").split():
65
+ if word in variables and word not in candidates:
66
+ candidates.append(word)
67
+ candidates += [dim for dim in variable.dims if dim in variables and dim not in candidates]
68
+
69
+ def find(keys, standard, units, words):
70
+ for key in keys:
71
+ if _attr(variables[key], "standard_name") == standard:
72
+ return key
73
+ for key in keys:
74
+ if _attr(variables[key], "units").lower() in units:
75
+ return key
76
+ for key in keys:
77
+ if any(key.lower().startswith(word) or key.lower().endswith(word) for word in words):
78
+ return key
79
+ return None
80
+
81
+ lon = find(candidates, "longitude", _LON_UNITS, ("lon", "nav_lon", "glamt"))
82
+ lat = find(candidates, "latitude", _LAT_UNITS, ("lat", "nav_lat", "gphit"))
83
+ if lon is None or lat is None:
84
+ others = [key for key, other in variables.items() if key != name and other.ndim and set(other.dims) <= dims]
85
+ lon = lon or find(others, "longitude", _LON_UNITS, ())
86
+ lat = lat or find(others, "latitude", _LAT_UNITS, ())
87
+ # coordinates that do not span the variable's own dimensions cannot place it
88
+ for key in (lon, lat):
89
+ if key is not None and not set(variables[key].dims) <= dims:
90
+ return None, None
91
+ return lon, lat
92
+
93
+
94
+ def _repeated_coordinates(lons, lats):
95
+ """Whether cells share a longitude and latitude: the 0s (or fill values)
96
+ a model writes at land cells, which put those cells on top of one another."""
97
+ pairs = pd.DataFrame({"lon": np.asarray(lons).ravel(), "lat": np.asarray(lats).ravel()})
98
+ return bool(pairs.duplicated().any())
99
+
100
+
101
+ def needs_raster(lons, lats):
102
+ """Whether a grid at ``lons``, ``lats`` is better drawn on a regular grid
103
+ than as it is: an unstructured grid (1-D, cells along one dimension), or
104
+ a curvilinear one with a seam, cells in the same places, cells turned
105
+ over, or cells with no longitude or latitude."""
106
+ if lons.ndim == 1 and lats.ndim == 1:
107
+ return lons.shape == lats.shape
108
+ if lons.ndim != 2 or lons.shape != lats.shape:
109
+ return False
110
+ if ncgrid.has_seam(lons) or _repeated_coordinates(lons, lats) or ncgrid.has_folds(lons, lats):
111
+ return True
112
+ return bool((~np.isfinite(lons) | ~np.isfinite(lats)).any())
113
+
114
+
115
+ class Source:
116
+ """What one variable is drawn from: its ``kind`` ("mesh", "cells" or
117
+ "grid"), its horizontal dimensions ``dims`` (in the order of its
118
+ longitudes and latitudes), and where its values are on an FVCOM mesh
119
+ (``location``: "node" or "nele")."""
120
+
121
+ def __init__(self, kind, dims, lon=None, lat=None, location=None):
122
+ self.kind, self.dims, self.lon, self.lat, self.location = kind, tuple(dims), lon, lat, location
123
+
124
+ @property
125
+ def key(self):
126
+ return (self.kind, self.dims, self.lon, self.lat, self.location)
127
+
128
+
129
+ def find_source(ds, name):
130
+ """The Source of variable ``name`` if it needs regridding, else None."""
131
+ variable = ds.variables[name]
132
+ if variable.dtype.kind not in "fiu" or not variable.ndim:
133
+ return None
134
+ if ncmesh.looks_like_fvcom(ds):
135
+ location = ncmesh.mesh_location(variable.dims)
136
+ if name in ncmesh.MESH_VARIABLES or location is None or variable.dtype.kind != "f":
137
+ return None
138
+ return Source("mesh", [location], location=location)
139
+ if name.endswith(_BOUNDS_SUFFIXES):
140
+ return None
141
+ lon, lat = find_lonlat(ds, name)
142
+ if lon is None or lat is None:
143
+ return None
144
+ lon_dims, lat_dims = ds.variables[lon].dims, ds.variables[lat].dims
145
+ if len(lon_dims) == 1 and lon_dims == lat_dims and lon_dims[0] in variable.dims:
146
+ # cells along one dimension, each with its longitude and latitude
147
+ source = Source("cells", lon_dims, lon, lat)
148
+ elif len(lon_dims) == 2 and lon_dims == lat_dims and set(lon_dims) <= set(variable.dims):
149
+ source = Source("grid", lon_dims, lon, lat)
150
+ else:
151
+ return None
152
+ lons, lats = np.asarray(ds.variables[lon].values, dtype="float64"), np.asarray(ds.variables[lat].values, dtype="float64")
153
+ return source if needs_raster(lons, lats) else None
154
+
155
+
156
+ # ---- the regular grid ----
157
+
158
+
159
+ class RasterArray(BackendArray):
160
+ """A variable with its horizontal dimensions on a regular grid: the
161
+ ``source`` Variable's values put on ``raster`` when asked for. Its shape
162
+ is that of the source with its horizontal dimensions (``dims``) replaced by
163
+ the raster's two, last; only the frames (the other dimensions) that are
164
+ indexed are read."""
165
+
166
+ def __init__(self, source, dims, raster, location=None):
167
+ self.source, self.dims, self.raster, self.location = source, tuple(dims), raster, location
168
+ self.lead = [dim for dim in source.dims if dim not in self.dims]
169
+ self.shape = tuple(source.sizes[dim] for dim in self.lead) + tuple(raster.shape)
170
+ self.dtype = np.dtype("float32")
171
+
172
+ def __getitem__(self, key):
173
+ return indexing.explicit_indexing_adapter(key, self.shape, indexing.IndexingSupport.OUTER, self._get)
174
+
175
+ def _image(self, values):
176
+ if self.location is not None:
177
+ return self.raster.image(values, self.location)
178
+ return self.raster.image(values)
179
+
180
+ def _get(self, key):
181
+ lead_key, (key_y, key_x) = key[: len(self.lead)], key[len(self.lead):]
182
+ part = self.source.isel({dim: k for dim, k in zip(self.lead, lead_key)})
183
+ kept = [dim for dim in self.lead if dim in part.dims]
184
+ # the frames, each with its horizontal dimensions in the order of the grid's coordinates
185
+ frames = part.transpose(*kept, *self.dims).values.reshape((-1,) + tuple(part.sizes[dim] for dim in self.dims))
186
+ images = np.stack([self._image(np.asarray(frame, dtype="float64")) for frame in frames])
187
+ images = images.reshape(tuple(part.sizes[dim] for dim in kept) + tuple(self.raster.shape))
188
+ return images[..., key_y, :][..., key_x].astype("float32")
189
+
190
+
191
+ def _level_coordinate(ds, dim):
192
+ """The coordinate of a dimension that comes through regridding: its own
193
+ if it has a 1-D one, else the numbers 1 to n (FVCOM's sigma layers have
194
+ values at each node, not one for the layer: 1 is the surface)."""
195
+ if dim in ds.variables and ds.variables[dim].dims == (dim,):
196
+ return ds.variables[dim]
197
+ return xr.Variable((dim,), np.arange(1, ds.sizes[dim] + 1), {"long_name": f"{dim} number"})
198
+
199
+
200
+ def regrid_dataset(ds, names=None):
201
+ """``ds`` with the variables that need it (see ``needs_raster``; ``names``:
202
+ of these only) on a regular longitude-latitude grid, as ``(Dataset, notes)``:
203
+ only those variables, with dimensions ``lat`` and ``lon``, lazy. ``(None,
204
+ [])`` if no variable needs it, so that the dataset is plotted as it is.
205
+
206
+ A grid whose longitudes and latitudes cannot place its cells is left out
207
+ (it is plotted by its indices, as it was)."""
208
+ if names is not None:
209
+ names = [names] if isinstance(names, str) else list(names)
210
+ wanted = list(ds.data_vars) if names is None else names
211
+ skip = _bounds_names(ds)
212
+ sources = {}
213
+ for name in wanted:
214
+ if name in skip or name not in ds.variables:
215
+ continue
216
+ source = find_source(ds, name)
217
+ if source is not None:
218
+ sources[name] = source
219
+ if not sources:
220
+ return None, []
221
+
222
+ notes = []
223
+ rasters = {}
224
+ mesh = None
225
+ variables = {}
226
+ axes = None
227
+ for name, source in sources.items():
228
+ if source.key not in rasters:
229
+ if source.kind == "mesh":
230
+ if mesh is None:
231
+ mesh = ncmesh.Mesh.from_dataset(ds)
232
+ rasters[source.key] = mesh.raster()
233
+ else:
234
+ lons = np.asarray(ds.variables[source.lon].values, dtype="float64")
235
+ lats = np.asarray(ds.variables[source.lat].values, dtype="float64")
236
+ try:
237
+ raster = ncgrid.raster_for(lons, lats)
238
+ except ValueError:
239
+ raster = None
240
+ if raster is not None:
241
+ # a grid whose coordinates leave out many of its cells with values is plotted by its indices
242
+ sample = ds[name].isel({dim: 0 for dim in ds[name].dims if dim not in source.dims})
243
+ if raster.placed_share(sample.transpose(*source.dims).values) < MIN_PLACED:
244
+ raster = None
245
+ rasters[source.key] = raster
246
+ raster = rasters[source.key]
247
+ if raster is None:
248
+ notes.append(f"{name}: the grid's longitudes and latitudes cannot place its cells.")
249
+ continue
250
+ if axes is not None and not (np.array_equal(axes[0], raster.x_axis) and np.array_equal(axes[1], raster.y_axis)):
251
+ notes.append(f"{name}: it is on another grid than the first variable, so it is left out.")
252
+ continue
253
+ axes = (raster.x_axis, raster.y_axis)
254
+ array = RasterArray(ds.variables[name], source.dims, raster, source.location)
255
+ attrs = {k: v for k, v in ds.variables[name].attrs.items() if k in ("long_name", "standard_name", "units")}
256
+ variables[name] = xr.Variable(array.lead + ["lat", "lon"], indexing.LazilyIndexedArray(array), attrs)
257
+ if not variables:
258
+ return None, notes
259
+
260
+ coords = {}
261
+ for name, variable in variables.items():
262
+ for dim in variable.dims[:-2]:
263
+ if dim not in coords:
264
+ coords[dim] = _level_coordinate(ds, dim)
265
+ coords["lat"] = xr.Variable(("lat",), axes[1], {"standard_name": "latitude", "units": "degrees_north", "axis": "Y"})
266
+ coords["lon"] = xr.Variable(("lon",), axes[0], {"standard_name": "longitude", "units": "degrees_east", "axis": "X"})
267
+ result = xr.Dataset(variables, coords=coords, attrs=dict(ds.attrs))
268
+ dropped = [name for name in wanted if name not in variables and name not in skip and name in ds.data_vars]
269
+ if names is not None and dropped:
270
+ notes.append("Not on a grid that is regridded, so left out: " + ", ".join(dropped))
271
+ return result, notes
272
+
273
+
274
+ def native_range(ds, name):
275
+ """The smallest and largest value of variable ``name`` of ``ds``, from the
276
+ values as they are on their own grid (cheaper than on the regular one, and
277
+ one range for every time and level). ``(low, high)``, or None if there
278
+ are no values."""
279
+ values = ds[name]
280
+ low, high = float(values.min(skipna=True)), float(values.max(skipna=True))
281
+ return (low, high) if np.isfinite(low) and np.isfinite(high) else None
282
+
283
+
284
+ def warn_notes(notes):
285
+ for note in notes:
286
+ warnings.warn(note)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ncplot
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Interactive viewing of NetCDF data
5
5
  Home-page: https://github.com/pmlmodelling/ncplot
6
6
  Author: Robert Wilson
@@ -10,12 +10,12 @@ Project-URL: Bug Tracker, https://github.com/pmlmodelling/ncplot/issues
10
10
  Project-URL: Documentation, https://ncplot.readthedocs.io/en/stable
11
11
  Project-URL: Source Code, https://github.com/pmlmodelling/ncplot
12
12
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
13
- Classifier: Programming Language :: Python :: 3.8
14
13
  Classifier: Programming Language :: Python :: 3.9
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
- Requires-Python: >=3.6.1
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.9
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: numpy
@@ -28,9 +28,17 @@ Requires-Dist: panel
28
28
  Requires-Dist: pandas
29
29
  Requires-Dist: metpy
30
30
  Requires-Dist: jupyter_bokeh
31
+ Requires-Dist: scipy
31
32
  Requires-Dist: datashader; python_version <= "3.10"
32
33
  Provides-Extra: complete
33
34
  Requires-Dist: geoviews; extra == "complete"
35
+ Requires-Dist: cartopy; extra == "complete"
36
+ Requires-Dist: datashader; extra == "complete"
37
+ Provides-Extra: test
38
+ Requires-Dist: pytest; extra == "test"
39
+ Requires-Dist: geoviews; extra == "test"
40
+ Requires-Dist: cartopy; extra == "test"
41
+ Requires-Dist: datashader; extra == "test"
34
42
  Dynamic: author
35
43
  Dynamic: author-email
36
44
  Dynamic: classifier
@@ -61,7 +69,7 @@ Dynamic: summary
61
69
 
62
70
  Easy interactive viewing of NetCDF files in Python.
63
71
 
64
- An easy to use Python (3.6 and above) package for quickly plotting the contents of NetCDF files or xarray datasets in Python or on the command line.
72
+ An easy to use Python (3.9 and above) package for quickly plotting the contents of NetCDF files or xarray datasets in Python or on the command line.
65
73
 
66
74
  This package is designed to work with [CF](https://cfconventions.org/) compliant NetCDF data.
67
75
 
@@ -125,6 +133,24 @@ ds.ncplot.view()
125
133
 
126
134
 
127
135
 
136
+ ## Model grids
137
+
138
+ Regular longitude-latitude grids are plotted as they are. Other grids are put on a regular longitude-latitude
139
+ grid first, so that they are plotted in the same way, with the same sliders:
140
+
141
+ * **Curvilinear grids** with 2-D longitudes and latitudes, such as the native ocean grids of CMIP6 models, including
142
+ those with a seam where the longitudes jump by 360 degrees and a tripolar north fold.
143
+ * **Unstructured grids**, whose cells run along one dimension with a longitude and latitude each.
144
+ * **[FVCOM](https://github.com/FVCOM-GitHub/FVCOM) output**: the triangular mesh, with variables on its nodes or its elements, and
145
+ sigma layers as an extra slider.
146
+
147
+ Each time step and level is put on the regular grid only when it is shown. For a large file name the variable you want,
148
+ e.g. `view("fvcom_output.nc", vars="temp")`: with several variables (the default is all of them) ncplot has to read every
149
+ time step of each to set its colour scale.
150
+
151
+ Coastlines (`view("example.nc", coast=True)`) need geoviews and cartopy, and datashader draws large regular grids
152
+ quickly: `pip install "ncplot[complete]"`.
153
+
128
154
  ## How to use on the command line
129
155
 
130
156
  Command line usage is as simple as:
@@ -0,0 +1,16 @@
1
+ ncplot/__init__.py,sha256=Y7JgOH_AwvTpuvmX3rnp0LBeIaEjEZkXxCEQjSpq2mc,29
2
+ ncplot/command_line.py,sha256=uFRXzYy6Yh0-ta6E4OH8qbpnPxf7kd3erR-ZUZyo6HM,1192
3
+ ncplot/deprecated.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ ncplot/grid.py,sha256=w6P_6bc_0GPTDLZzKhAS6EDXFyvxY9q8DH3uwpR5s_k,13223
5
+ ncplot/loading.py,sha256=aA9qH1h15Fw2ph90MTt4_iNlGz33GPm2R0Jk9R9lrAI,1668
6
+ ncplot/mesh.py,sha256=lRaX-pL2UVjDSoMGjlf2Emx5-BTPGlWaYx_dhWequhY,9388
7
+ ncplot/plot.py,sha256=9C39jrybAjgcu2Txgt0kCmpxASYwnUKMRsEaEZICGW4,42336
8
+ ncplot/regrid.py,sha256=OFPZPNDxjlx1yAx1_w2OeWX98Zx4aZxC2B1cciroUFU,13006
9
+ ncplot/utils.py,sha256=pvFahoxeuc26DtdEIp28MkyvDik8NR0aSqdHbBupQr8,6005
10
+ ncplot/xarray.py,sha256=F-kGEufuE8ZvIdWsNpuLGjhn4Tn6aM1UNUWvHIaiOvA,535
11
+ ncplot-0.4.3.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
12
+ ncplot-0.4.3.dist-info/METADATA,sha256=k-Nh97nUIKg4WibB1VhmSga9BJ8cTL9-reLkiC4JXA8,5016
13
+ ncplot-0.4.3.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
14
+ ncplot-0.4.3.dist-info/entry_points.txt,sha256=8CIFyTPZn49GsrojImqnJpkYIbxxZHAP_rHqA-256Eo,52
15
+ ncplot-0.4.3.dist-info/top_level.txt,sha256=hCfsI7KZNMRpk8FI5lwz_PFTyzJl91Iy2TSPAGEu9WI,7
16
+ ncplot-0.4.3.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- ncplot/__init__.py,sha256=Y7JgOH_AwvTpuvmX3rnp0LBeIaEjEZkXxCEQjSpq2mc,29
2
- ncplot/command_line.py,sha256=L4JRPyYv9clgU35H9xSmyp3A4AAiC8Zm0W1ciTYv06A,835
3
- ncplot/deprecated.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- ncplot/plot.py,sha256=NHQzJ-jKt5nF1JDeMfZ_Jz9rt5jbuqNnH8uGteMXQvU,40052
5
- ncplot/utils.py,sha256=pvFahoxeuc26DtdEIp28MkyvDik8NR0aSqdHbBupQr8,6005
6
- ncplot/xarray.py,sha256=F-kGEufuE8ZvIdWsNpuLGjhn4Tn6aM1UNUWvHIaiOvA,535
7
- ncplot-0.4.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
8
- ncplot-0.4.2.dist-info/METADATA,sha256=mjSRD4vxjKR6Ch9VBVAapHaBIaozSGX1KgjbA3Bk2mY,3603
9
- ncplot-0.4.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
- ncplot-0.4.2.dist-info/entry_points.txt,sha256=8CIFyTPZn49GsrojImqnJpkYIbxxZHAP_rHqA-256Eo,52
11
- ncplot-0.4.2.dist-info/top_level.txt,sha256=hCfsI7KZNMRpk8FI5lwz_PFTyzJl91Iy2TSPAGEu9WI,7
12
- ncplot-0.4.2.dist-info/RECORD,,
File without changes