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 +20 -13
- ncplot/grid.py +266 -0
- ncplot/loading.py +38 -0
- ncplot/mesh.py +197 -0
- ncplot/plot.py +65 -9
- ncplot/regrid.py +286 -0
- {ncplot-0.4.2.dist-info → ncplot-0.4.3.dist-info}/METADATA +30 -4
- ncplot-0.4.3.dist-info/RECORD +16 -0
- ncplot-0.4.2.dist-info/RECORD +0 -12
- {ncplot-0.4.2.dist-info → ncplot-0.4.3.dist-info}/WHEEL +0 -0
- {ncplot-0.4.2.dist-info → ncplot-0.4.3.dist-info}/entry_points.txt +0 -0
- {ncplot-0.4.2.dist-info → ncplot-0.4.3.dist-info}/licenses/LICENSE +0 -0
- {ncplot-0.4.2.dist-info → ncplot-0.4.3.dist-info}/top_level.txt +0 -0
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
ff
|
|
31
|
-
|
|
32
|
-
if ff
|
|
33
|
-
return f"{
|
|
34
|
-
|
|
35
|
-
ff
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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,,
|
ncplot-0.4.2.dist-info/RECORD
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|