marEx 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- marEx/__init__.py +78 -0
- marEx/detect.py +490 -0
- marEx/helper.py +251 -0
- marEx/plotX/__init__.py +83 -0
- marEx/plotX/base.py +387 -0
- marEx/plotX/gridded.py +55 -0
- marEx/plotX/unstructured.py +147 -0
- marEx/track.py +3646 -0
- marex-2.0.0.dist-info/METADATA +247 -0
- marex-2.0.0.dist-info/RECORD +12 -0
- marex-2.0.0.dist-info/WHEEL +5 -0
- marex-2.0.0.dist-info/top_level.txt +1 -0
marEx/plotX/base.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import numpy as np
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import cartopy.crs as ccrs
|
|
5
|
+
import cartopy.feature as cfeature
|
|
6
|
+
import dask
|
|
7
|
+
from PIL import Image
|
|
8
|
+
import subprocess
|
|
9
|
+
from matplotlib.colors import ListedColormap, BoundaryNorm
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Optional, Dict, List, Union, Tuple
|
|
12
|
+
import shutil
|
|
13
|
+
import warnings
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PlotConfig:
|
|
18
|
+
"""Configuration class for plot parameters"""
|
|
19
|
+
title: Optional[str] = None
|
|
20
|
+
var_units: str = ''
|
|
21
|
+
issym: bool = False
|
|
22
|
+
cmap: Optional[str] = None
|
|
23
|
+
cperc: List[int] = None
|
|
24
|
+
clim: Optional[Tuple[float, float]] = None
|
|
25
|
+
show_colorbar: bool = True
|
|
26
|
+
grid_lines: bool = True
|
|
27
|
+
grid_labels: bool = False
|
|
28
|
+
dimensions: Dict[str, str] = None
|
|
29
|
+
norm: Optional[object] = None
|
|
30
|
+
plot_IDs: bool = False
|
|
31
|
+
extend: str = 'both'
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
if self.cperc is None:
|
|
35
|
+
self.cperc = [4, 96]
|
|
36
|
+
if self.dimensions is None:
|
|
37
|
+
self.dimensions = {'time': 'time', 'ydim': 'lat', 'xdim': 'lon'}
|
|
38
|
+
if self.plot_IDs:
|
|
39
|
+
self.show_colorbar = False
|
|
40
|
+
|
|
41
|
+
class PlotterBase:
|
|
42
|
+
def __init__(self, xarray_obj):
|
|
43
|
+
self.da = xarray_obj
|
|
44
|
+
|
|
45
|
+
# Cache common features
|
|
46
|
+
self._land = cfeature.LAND.with_scale('50m')
|
|
47
|
+
self._coastlines = cfeature.COASTLINE.with_scale('50m')
|
|
48
|
+
|
|
49
|
+
def _setup_common_params(self, config: PlotConfig) -> Tuple:
|
|
50
|
+
"""Centralise common parameter setup"""
|
|
51
|
+
self.setup_plot_params()
|
|
52
|
+
|
|
53
|
+
if config.plot_IDs:
|
|
54
|
+
cmap, norm, var_units = self.setup_id_plot_params(config.cmap)
|
|
55
|
+
clim = None
|
|
56
|
+
extend = 'neither'
|
|
57
|
+
self.da = self.da.where(self.da > 0) # Fill value to NaN (get rid of 0s)
|
|
58
|
+
else:
|
|
59
|
+
if config.cmap is None:
|
|
60
|
+
cmap = 'RdBu_r' if config.issym else 'viridis'
|
|
61
|
+
else:
|
|
62
|
+
cmap = config.cmap
|
|
63
|
+
norm = config.norm
|
|
64
|
+
if config.clim is None and norm is None:
|
|
65
|
+
clim = self.clim_robust(self.da.values, config.issym, config.cperc)
|
|
66
|
+
else:
|
|
67
|
+
clim = config.clim
|
|
68
|
+
var_units = config.var_units
|
|
69
|
+
extend = config.extend
|
|
70
|
+
|
|
71
|
+
return cmap, norm, clim, var_units, extend
|
|
72
|
+
|
|
73
|
+
def _setup_axes(self, ax=None):
|
|
74
|
+
"""Create or use existing axes with projection"""
|
|
75
|
+
if ax is None:
|
|
76
|
+
fig = plt.figure(figsize=(7, 5))
|
|
77
|
+
ax = plt.axes(projection=ccrs.Robinson())
|
|
78
|
+
else:
|
|
79
|
+
fig = ax.get_figure()
|
|
80
|
+
return fig, ax
|
|
81
|
+
|
|
82
|
+
def _add_map_features(self, ax, grid_lines=True, grid_labels=True):
|
|
83
|
+
"""Add common map features to the plot"""
|
|
84
|
+
ax.add_feature(self._land, facecolor='darkgrey', zorder=2)
|
|
85
|
+
ax.add_feature(self._coastlines, linewidth=0.5, zorder=3)
|
|
86
|
+
if grid_lines:
|
|
87
|
+
ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=grid_labels,
|
|
88
|
+
linewidth=1, color='gray', alpha=0.5, linestyle='--', zorder=4)
|
|
89
|
+
|
|
90
|
+
def _setup_colorbar(self, fig, im, show_colorbar, var_units, extend='both',
|
|
91
|
+
position=None):
|
|
92
|
+
"""Set up colorbar with common parameters"""
|
|
93
|
+
if not show_colorbar:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if position is not None:
|
|
97
|
+
# For column plots
|
|
98
|
+
cbar_ax = fig.add_axes(position)
|
|
99
|
+
cb = fig.colorbar(im, cax=cbar_ax, extend=extend)
|
|
100
|
+
else:
|
|
101
|
+
# For single plots
|
|
102
|
+
cb = plt.colorbar(im, shrink=0.6, ax=plt.gca(), extend=extend)
|
|
103
|
+
|
|
104
|
+
if var_units:
|
|
105
|
+
cb.ax.set_ylabel(var_units, fontsize=10)
|
|
106
|
+
cb.ax.tick_params(labelsize=10)
|
|
107
|
+
return cb
|
|
108
|
+
|
|
109
|
+
def _get_title(self, time_index, col_name, dimensions):
|
|
110
|
+
"""Generate appropriate title based on dimension"""
|
|
111
|
+
if col_name == dimensions['time']:
|
|
112
|
+
return f"{self.da[col_name].isel({col_name: time_index}).time.dt.strftime('%Y-%m-%d').values}"
|
|
113
|
+
return f"{col_name}={self.da[col_name].isel({col_name: time_index}).values}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def single_plot(self, config: PlotConfig, ax=None):
|
|
117
|
+
"""Make a single plot with given configuration"""
|
|
118
|
+
cmap, norm, clim, var_units, extend = self._setup_common_params(config)
|
|
119
|
+
|
|
120
|
+
fig, ax = self._setup_axes(ax)
|
|
121
|
+
|
|
122
|
+
# Call implementation-specific plot function
|
|
123
|
+
ax, im = self.plot(ax=ax, cmap=cmap, clim=clim, norm=norm)
|
|
124
|
+
|
|
125
|
+
if config.title:
|
|
126
|
+
ax.set_title(config.title, size=12)
|
|
127
|
+
|
|
128
|
+
self._setup_colorbar(fig, im, config.show_colorbar, var_units, extend)
|
|
129
|
+
self._add_map_features(ax, config.grid_lines, config.grid_labels)
|
|
130
|
+
|
|
131
|
+
return fig, ax
|
|
132
|
+
|
|
133
|
+
def multi_plot(self, config: PlotConfig, col='time', col_wrap=3):
|
|
134
|
+
"""Make wrapped subplots with given configuration"""
|
|
135
|
+
npanels = self.da[col].size
|
|
136
|
+
nrows = int(np.ceil(npanels/col_wrap))
|
|
137
|
+
ncols = min(npanels, col_wrap)
|
|
138
|
+
|
|
139
|
+
cmap, norm, clim, var_units, extend = self._setup_common_params(config)
|
|
140
|
+
|
|
141
|
+
fig = plt.figure(figsize=(6*ncols, 3*nrows))
|
|
142
|
+
axes = fig.subplots(nrows, ncols,
|
|
143
|
+
subplot_kw={'projection': ccrs.Robinson()}).flatten()
|
|
144
|
+
|
|
145
|
+
# Create a single plotter instance to be reused
|
|
146
|
+
base_plotter = type(self)(self.da)
|
|
147
|
+
for attr in ['fpath_tgrid', 'fpath_ckdtree']:
|
|
148
|
+
if hasattr(self, attr):
|
|
149
|
+
setattr(base_plotter, attr, getattr(self, attr))
|
|
150
|
+
|
|
151
|
+
for i, ax in enumerate(axes):
|
|
152
|
+
if i < npanels:
|
|
153
|
+
title = self._get_title(i, col, config.dimensions)
|
|
154
|
+
|
|
155
|
+
# Create new config for individual panel
|
|
156
|
+
panel_config = PlotConfig(
|
|
157
|
+
title=title, cmap=cmap, clim=clim,
|
|
158
|
+
show_colorbar=False, grid_labels=False,
|
|
159
|
+
norm=norm, plot_IDs=False, extend=extend
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Update data in base plotter instead of creating new instance
|
|
163
|
+
base_plotter.da = self.da.isel({col: i})
|
|
164
|
+
|
|
165
|
+
# Plot individual panel using the same plotter instance
|
|
166
|
+
base_plotter.single_plot(panel_config, ax=ax)
|
|
167
|
+
else:
|
|
168
|
+
fig.delaxes(ax)
|
|
169
|
+
|
|
170
|
+
# Add single colorbar for all panels
|
|
171
|
+
if config.show_colorbar:
|
|
172
|
+
fig.subplots_adjust(right=0.9)
|
|
173
|
+
if norm is None and clim is not None:
|
|
174
|
+
# Create a proper norm from clim
|
|
175
|
+
from matplotlib.colors import Normalize
|
|
176
|
+
norm = Normalize(vmin=clim[0], vmax=clim[1])
|
|
177
|
+
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
|
|
178
|
+
sm.set_array([])
|
|
179
|
+
self._setup_colorbar(fig, sm, True, var_units, extend,
|
|
180
|
+
position=[0.92, 0.15, 0.02, 0.7])
|
|
181
|
+
|
|
182
|
+
return fig, axes
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def animate(self, config: PlotConfig, plot_dir='./', file_name=None):
|
|
186
|
+
"""Create an animation from time series data"""
|
|
187
|
+
|
|
188
|
+
# Check if ffmpeg is installed
|
|
189
|
+
if shutil.which('ffmpeg') is None:
|
|
190
|
+
warnings.warn(
|
|
191
|
+
"ffmpeg executable not found in system PATH. Cannot create animation.\n"
|
|
192
|
+
"Please install ffmpeg using one of the following methods:\n"
|
|
193
|
+
" - Linux: sudo apt install ffmpeg (Ubuntu/Debian) or sudo yum install ffmpeg (CentOS/RHEL)\n"
|
|
194
|
+
" - Conda: conda install -c conda-forge ffmpeg\n"
|
|
195
|
+
"Alternatively, use matplotlib for animation in Jupyter notebooks."
|
|
196
|
+
)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
plot_dir = Path(plot_dir)
|
|
200
|
+
plot_dir.mkdir(exist_ok=True)
|
|
201
|
+
temp_dir = plot_dir / "blobs_seq"
|
|
202
|
+
if os.path.exists(temp_dir):
|
|
203
|
+
shutil.rmtree(temp_dir)
|
|
204
|
+
temp_dir.mkdir(exist_ok=True)
|
|
205
|
+
if not file_name:
|
|
206
|
+
file_name = f"movie_{self.da.name}.mp4"
|
|
207
|
+
|
|
208
|
+
output_file = plot_dir / f'{file_name}.mp4'
|
|
209
|
+
|
|
210
|
+
# Set up plotting parameters
|
|
211
|
+
cmap, norm, clim, var_units, extend = self._setup_common_params(config)
|
|
212
|
+
|
|
213
|
+
plot_params = {
|
|
214
|
+
'cmap': cmap,
|
|
215
|
+
'norm': norm,
|
|
216
|
+
'clim': clim,
|
|
217
|
+
'var_units': var_units,
|
|
218
|
+
'extend': extend,
|
|
219
|
+
'show_colorbar': config.show_colorbar
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Set up grid information if needed
|
|
223
|
+
grid_info = None
|
|
224
|
+
if hasattr(self, 'fpath_tgrid') or hasattr(self, 'fpath_ckdtree'):
|
|
225
|
+
grid_info = {
|
|
226
|
+
'type': 'unstructured',
|
|
227
|
+
'tgrid_path': getattr(self, 'fpath_tgrid', None),
|
|
228
|
+
'ckdtree_path': getattr(self, 'fpath_ckdtree', None),
|
|
229
|
+
'res': 0.3
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# Generate frames using dask for parallel processing
|
|
233
|
+
delayed_tasks = []
|
|
234
|
+
for time_ind in range(len(self.da[config.dimensions['time']])):
|
|
235
|
+
data_slice = self.da.isel({config.dimensions['time']: time_ind})
|
|
236
|
+
plot_params['time_str'] = str(
|
|
237
|
+
self.da[config.dimensions['time']]
|
|
238
|
+
.isel({config.dimensions['time']: time_ind})
|
|
239
|
+
.dt.strftime('%Y-%m-%d').values
|
|
240
|
+
)
|
|
241
|
+
delayed_tasks.append(
|
|
242
|
+
make_frame(data_slice, time_ind, temp_dir, plot_params, grid_info)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
filenames = dask.compute(*delayed_tasks)
|
|
246
|
+
filenames = sorted(filenames, key=lambda x: int(x.split('_')[-1].split('.')[0]))
|
|
247
|
+
|
|
248
|
+
# Create movie using ffmpeg
|
|
249
|
+
subprocess.run([
|
|
250
|
+
'ffmpeg', '-y', '-threads', '0', '-framerate', '10',
|
|
251
|
+
'-i', str(temp_dir / 'time_%04d.jpg'),
|
|
252
|
+
'-c:v', 'libx264', '-preset', 'fast', '-crf', '22',
|
|
253
|
+
'-pix_fmt', 'yuv420p', '-movflags', '+faststart',
|
|
254
|
+
str(output_file)
|
|
255
|
+
], check=True)
|
|
256
|
+
|
|
257
|
+
return str(output_file)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def clim_robust(self, data, issym, percentiles=[2, 98]):
|
|
261
|
+
"""Base method for computing colour limits"""
|
|
262
|
+
clim = np.nanpercentile(data, percentiles)
|
|
263
|
+
|
|
264
|
+
if issym:
|
|
265
|
+
clim = np.abs(clim).max()
|
|
266
|
+
clim = np.array([-clim, clim])
|
|
267
|
+
elif percentiles[0] == 0:
|
|
268
|
+
clim = np.array([0, clim[1]])
|
|
269
|
+
|
|
270
|
+
return clim
|
|
271
|
+
|
|
272
|
+
def setup_plot_params(self):
|
|
273
|
+
"""Set up common plotting parameters"""
|
|
274
|
+
plt.rc('text', usetex=False)
|
|
275
|
+
plt.rc('font', family='serif')
|
|
276
|
+
|
|
277
|
+
def setup_id_plot_params(self, cmap=None):
|
|
278
|
+
"""Set up parameters for plotting IDs"""
|
|
279
|
+
unique_values = np.unique(self.da.values[~np.isnan(self.da.values)])
|
|
280
|
+
unique_values = unique_values[unique_values > 0]
|
|
281
|
+
bounds = np.arange(unique_values.min(), unique_values.max() + 2) - 0.5
|
|
282
|
+
n_bins = len(bounds) - 1
|
|
283
|
+
|
|
284
|
+
if cmap is None:
|
|
285
|
+
np.random.seed(42)
|
|
286
|
+
cmap = ListedColormap(np.random.random(size=(n_bins, 3)))
|
|
287
|
+
|
|
288
|
+
norm = BoundaryNorm(bounds, cmap.N)
|
|
289
|
+
return cmap, norm, 'ID'
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dask.delayed
|
|
294
|
+
def make_frame(data_slice, time_ind, temp_dir, plot_params, grid_info=None):
|
|
295
|
+
"""Create a single frame for movies - minimise memory usage with dask
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
data_slice: The data for this specific frame
|
|
299
|
+
time_ind: Frame index
|
|
300
|
+
temp_dir: Directory for temporary files
|
|
301
|
+
plot_params: Dict containing plotting parameters
|
|
302
|
+
grid_info: Dict containing grid paths and settings for unstructured data
|
|
303
|
+
"""
|
|
304
|
+
# Set up plotting parameters
|
|
305
|
+
plt.rc('text', usetex=False)
|
|
306
|
+
plt.rc('font', family='serif')
|
|
307
|
+
|
|
308
|
+
fig = plt.figure(figsize=(7, 5))
|
|
309
|
+
ax = plt.axes(projection=ccrs.Robinson())
|
|
310
|
+
|
|
311
|
+
data_slice_np = data_slice.values
|
|
312
|
+
|
|
313
|
+
# Set up plot kwargs
|
|
314
|
+
plot_kwargs = {
|
|
315
|
+
'transform': ccrs.PlateCarree(),
|
|
316
|
+
'cmap': plot_params['cmap'],
|
|
317
|
+
'shading': 'auto'
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if plot_params.get('norm'):
|
|
321
|
+
plot_kwargs['norm'] = plot_params['norm']
|
|
322
|
+
elif plot_params.get('clim'):
|
|
323
|
+
plot_kwargs['vmin'] = plot_params['clim'][0]
|
|
324
|
+
plot_kwargs['vmax'] = plot_params['clim'][1]
|
|
325
|
+
|
|
326
|
+
# Handle different grid types
|
|
327
|
+
if grid_info and grid_info.get('type') == 'unstructured':
|
|
328
|
+
from .unstructured import _load_ckdtree, _load_triangulation
|
|
329
|
+
if grid_info.get('ckdtree_path'):
|
|
330
|
+
# Use cached ckdtree data
|
|
331
|
+
ckdt_data = _load_ckdtree(grid_info['ckdtree_path'], grid_info.get('res', 0.3))
|
|
332
|
+
grid_data = data_slice_np[ckdt_data['indices']].reshape(
|
|
333
|
+
ckdt_data['lat'].size,
|
|
334
|
+
ckdt_data['lon'].size
|
|
335
|
+
)
|
|
336
|
+
grid_data = np.ma.masked_invalid(grid_data)
|
|
337
|
+
im = ax.pcolormesh(
|
|
338
|
+
ckdt_data['lon'], ckdt_data['lat'],
|
|
339
|
+
grid_data, **plot_kwargs
|
|
340
|
+
)
|
|
341
|
+
elif grid_info.get('tgrid_path'):
|
|
342
|
+
# Use triangulation
|
|
343
|
+
triang = _load_triangulation(grid_info['tgrid_path'])
|
|
344
|
+
data_masked = np.ma.masked_invalid(data_slice_np)
|
|
345
|
+
im = ax.tripcolor(triang, data_masked, **plot_kwargs)
|
|
346
|
+
else:
|
|
347
|
+
# Regular grid plotting
|
|
348
|
+
lat = data_slice.lat.values
|
|
349
|
+
lon = data_slice.lon.values
|
|
350
|
+
im = ax.pcolormesh(
|
|
351
|
+
lon, lat,
|
|
352
|
+
data_slice_np, **plot_kwargs
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
time_str = plot_params.get('time_str', f'Frame {time_ind}')
|
|
356
|
+
ax.set_title(time_str, size=12)
|
|
357
|
+
|
|
358
|
+
if plot_params.get('show_colorbar'):
|
|
359
|
+
cb = plt.colorbar(im, shrink=0.6, ax=ax, extend=plot_params.get('extend', 'both'))
|
|
360
|
+
if plot_params.get('var_units'):
|
|
361
|
+
cb.ax.set_ylabel(plot_params['var_units'], fontsize=10)
|
|
362
|
+
cb.ax.tick_params(labelsize=10)
|
|
363
|
+
|
|
364
|
+
land = cfeature.LAND.with_scale('50m')
|
|
365
|
+
coastlines = cfeature.COASTLINE.with_scale('50m')
|
|
366
|
+
ax.add_feature(land, facecolor='darkgrey', zorder=2)
|
|
367
|
+
ax.add_feature(coastlines, linewidth=0.5, zorder=3)
|
|
368
|
+
ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True,
|
|
369
|
+
linewidth=1, color='gray', alpha=0.5, linestyle='--', zorder=4)
|
|
370
|
+
|
|
371
|
+
# Save and process frame
|
|
372
|
+
filename = f'time_{time_ind:04d}.jpg'
|
|
373
|
+
temp_file = temp_dir / f'temp_{filename}'
|
|
374
|
+
fig.savefig(str(temp_file), dpi=300, bbox_inches='tight')
|
|
375
|
+
plt.close(fig)
|
|
376
|
+
|
|
377
|
+
# Ensure dimensions are even for video encoding
|
|
378
|
+
image = Image.open(str(temp_file))
|
|
379
|
+
width, height = image.size
|
|
380
|
+
new_width = width - (width % 2)
|
|
381
|
+
new_height = height - (height % 2)
|
|
382
|
+
image = image.resize((new_width, new_height), Image.LANCZOS)
|
|
383
|
+
|
|
384
|
+
image.save(str(temp_dir / filename))
|
|
385
|
+
temp_file.unlink()
|
|
386
|
+
|
|
387
|
+
return filename
|
marEx/plotX/gridded.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import xarray as xr
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import cartopy.crs as ccrs
|
|
5
|
+
|
|
6
|
+
from .base import PlotterBase
|
|
7
|
+
|
|
8
|
+
class GriddedPlotter(PlotterBase):
|
|
9
|
+
def __init__(self, xarray_obj):
|
|
10
|
+
super().__init__(xarray_obj)
|
|
11
|
+
|
|
12
|
+
def wrap_lon(self, data, dimensions):
|
|
13
|
+
"""Handle periodic boundary in longitude by adding a column of data."""
|
|
14
|
+
lon = data[dimensions['xdim']]
|
|
15
|
+
|
|
16
|
+
# Check if we're dealing with global data that needs wrapping
|
|
17
|
+
lon_spacing = np.diff(lon)[0]
|
|
18
|
+
if abs(360 - (lon.max() - lon.min())) < 2 * lon_spacing:
|
|
19
|
+
# Add a column at lon=360 that equals the data at lon=0
|
|
20
|
+
new_lon = np.append(lon, lon[0] + 360)
|
|
21
|
+
wrapped_data = xr.concat([data, data.isel({dimensions['xdim']: 0})],
|
|
22
|
+
dim=dimensions['xdim'])
|
|
23
|
+
wrapped_data[dimensions['xdim']] = new_lon
|
|
24
|
+
return wrapped_data
|
|
25
|
+
return data
|
|
26
|
+
|
|
27
|
+
def plot(self, ax, cmap='viridis', clim=None, norm=None):
|
|
28
|
+
"""Implement plotting for gridded (i.e. regular grid) data."""
|
|
29
|
+
dimensions = {'ydim': 'lat', 'xdim': 'lon'}
|
|
30
|
+
data = self.wrap_lon(self.da, dimensions)
|
|
31
|
+
|
|
32
|
+
# Ensure data has only required dimensions for imshow
|
|
33
|
+
if 'time' in data.dims and len(data.time) == 1:
|
|
34
|
+
data = data.squeeze(dim='time') # Remove time dimension if it's singular
|
|
35
|
+
|
|
36
|
+
plot_kwargs = {
|
|
37
|
+
'transform': ccrs.PlateCarree(),
|
|
38
|
+
'cmap': cmap,
|
|
39
|
+
'shading': 'auto'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if norm is not None:
|
|
43
|
+
plot_kwargs['norm'] = norm
|
|
44
|
+
elif clim is not None:
|
|
45
|
+
plot_kwargs['vmin'] = clim[0]
|
|
46
|
+
plot_kwargs['vmax'] = clim[1]
|
|
47
|
+
|
|
48
|
+
lons = data[dimensions['xdim']].values
|
|
49
|
+
lats = data[dimensions['ydim']].values
|
|
50
|
+
values = data.values
|
|
51
|
+
|
|
52
|
+
# imshow has some dimension issues with cartopy...
|
|
53
|
+
im = ax.pcolormesh(lons, lats, values, **plot_kwargs)
|
|
54
|
+
|
|
55
|
+
return ax, im
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import xarray as xr
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import cartopy.crs as ccrs
|
|
5
|
+
from matplotlib.tri import Triangulation
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import PlotterBase
|
|
9
|
+
|
|
10
|
+
# Global cache for grid data
|
|
11
|
+
_GRID_CACHE = {
|
|
12
|
+
'triangulation': {}, # key: grid_path, value: triangulation object
|
|
13
|
+
'ckdtree': {} # key: (ckdtree_path, res), value: ckdtree data
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
def clear_cache():
|
|
17
|
+
"""Clear the global grid cache."""
|
|
18
|
+
_GRID_CACHE['triangulation'].clear()
|
|
19
|
+
_GRID_CACHE['ckdtree'].clear()
|
|
20
|
+
|
|
21
|
+
def _load_triangulation(fpath_tgrid):
|
|
22
|
+
"""Load and cache triangulation data globally."""
|
|
23
|
+
fpath_tgrid = str(fpath_tgrid) # Convert Path to string for dict key
|
|
24
|
+
if fpath_tgrid not in _GRID_CACHE['triangulation']:
|
|
25
|
+
# Only load required variables
|
|
26
|
+
grid_data = xr.open_dataset(
|
|
27
|
+
fpath_tgrid,
|
|
28
|
+
chunks={},
|
|
29
|
+
drop_variables=[v for v in xr.open_dataset(fpath_tgrid).variables
|
|
30
|
+
if v not in ['vertex_of_cell', 'clon', 'clat']]
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if 'vertex_of_cell' not in grid_data.variables or 'clon' not in grid_data.variables or 'clat' not in grid_data.variables:
|
|
34
|
+
raise ValueError("Triangulation Grid File must contain 'vertex_of_cell', 'clon', and 'clat' variables")
|
|
35
|
+
|
|
36
|
+
# Extract triangulation vertices - convert to 0-based indexing
|
|
37
|
+
triangles = grid_data.vertex_of_cell.values.T - 1
|
|
38
|
+
# Create matplotlib triangulation object
|
|
39
|
+
_GRID_CACHE['triangulation'][fpath_tgrid] = Triangulation(
|
|
40
|
+
grid_data.clon.values,
|
|
41
|
+
grid_data.clat.values,
|
|
42
|
+
triangles
|
|
43
|
+
)
|
|
44
|
+
grid_data.close()
|
|
45
|
+
|
|
46
|
+
return _GRID_CACHE['triangulation'][fpath_tgrid]
|
|
47
|
+
|
|
48
|
+
def _load_ckdtree(fpath_ckdtree, res):
|
|
49
|
+
"""Load and cache ckdtree data globally."""
|
|
50
|
+
cache_key = (str(fpath_ckdtree), res) # Convert Path to string for dict key
|
|
51
|
+
|
|
52
|
+
if cache_key not in _GRID_CACHE['ckdtree']:
|
|
53
|
+
# Format resolution string to match file naming
|
|
54
|
+
ckdtree_file = Path(fpath_ckdtree) / f"res{res:3.2f}.nc"
|
|
55
|
+
|
|
56
|
+
if not ckdtree_file.exists():
|
|
57
|
+
raise ValueError(f"No ckdtree file found for resolution {res}")
|
|
58
|
+
|
|
59
|
+
ds_ckdt = xr.open_dataset(ckdtree_file)
|
|
60
|
+
_GRID_CACHE['ckdtree'][cache_key] = {
|
|
61
|
+
'indices': ds_ckdt.ickdtree_c.values,
|
|
62
|
+
'lon': ds_ckdt.lon.values,
|
|
63
|
+
'lat': ds_ckdt.lat.values
|
|
64
|
+
}
|
|
65
|
+
ds_ckdt.close()
|
|
66
|
+
|
|
67
|
+
return _GRID_CACHE['ckdtree'][cache_key]
|
|
68
|
+
|
|
69
|
+
class UnstructuredPlotter(PlotterBase):
|
|
70
|
+
def __init__(self, xarray_obj):
|
|
71
|
+
super().__init__(xarray_obj)
|
|
72
|
+
|
|
73
|
+
from . import _fpath_tgrid, _fpath_ckdtree
|
|
74
|
+
self.fpath_tgrid = _fpath_tgrid
|
|
75
|
+
self.fpath_ckdtree = _fpath_ckdtree
|
|
76
|
+
|
|
77
|
+
def specify_grid(self, fpath_tgrid=None, fpath_ckdtree=None):
|
|
78
|
+
"""Set the path to the unstructured grid files."""
|
|
79
|
+
self.fpath_tgrid = Path(fpath_tgrid) if fpath_tgrid else None
|
|
80
|
+
self.fpath_ckdtree = Path(fpath_ckdtree) if fpath_ckdtree else None
|
|
81
|
+
|
|
82
|
+
def plot(self, ax, cmap='viridis', clim=None, norm=None):
|
|
83
|
+
"""Implement plotting for unstructured data."""
|
|
84
|
+
if self.fpath_ckdtree is not None:
|
|
85
|
+
# Interpolate using pre-computed KDTree indices
|
|
86
|
+
grid_lon, grid_lat, grid_data = self._interpolate_with_ckdtree(self.da.values, res=0.3)
|
|
87
|
+
|
|
88
|
+
plot_kwargs = {
|
|
89
|
+
'transform': ccrs.PlateCarree(),
|
|
90
|
+
'cmap': cmap,
|
|
91
|
+
'shading': 'auto'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if norm is not None:
|
|
95
|
+
plot_kwargs['norm'] = norm
|
|
96
|
+
elif clim is not None:
|
|
97
|
+
plot_kwargs['vmin'] = clim[0]
|
|
98
|
+
plot_kwargs['vmax'] = clim[1]
|
|
99
|
+
|
|
100
|
+
# Mask NaNs
|
|
101
|
+
grid_data = np.ma.masked_invalid(grid_data)
|
|
102
|
+
im = ax.pcolormesh(grid_lon, grid_lat, grid_data, **plot_kwargs)
|
|
103
|
+
|
|
104
|
+
else:
|
|
105
|
+
# Use triangulation from file if available
|
|
106
|
+
if self.fpath_tgrid is None:
|
|
107
|
+
raise ValueError("Either fpath_tgrid or fpath_ckdtree must be provided")
|
|
108
|
+
|
|
109
|
+
triang = _load_triangulation(self.fpath_tgrid)
|
|
110
|
+
|
|
111
|
+
plot_kwargs = {
|
|
112
|
+
'transform': ccrs.PlateCarree(),
|
|
113
|
+
'cmap': cmap
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if norm is not None:
|
|
117
|
+
plot_kwargs['norm'] = norm
|
|
118
|
+
elif clim is not None:
|
|
119
|
+
plot_kwargs['vmin'] = clim[0]
|
|
120
|
+
plot_kwargs['vmax'] = clim[1]
|
|
121
|
+
|
|
122
|
+
# Mask NaNs
|
|
123
|
+
native_data = self.da.copy()
|
|
124
|
+
native_data = np.ma.masked_invalid(native_data)
|
|
125
|
+
|
|
126
|
+
im = ax.tripcolor(triang, native_data, **plot_kwargs)
|
|
127
|
+
|
|
128
|
+
return ax, im
|
|
129
|
+
|
|
130
|
+
def _interpolate_with_ckdtree(self, data, res):
|
|
131
|
+
"""Interpolate unstructured data using pre-computed KDTree indices."""
|
|
132
|
+
if self.fpath_ckdtree is None:
|
|
133
|
+
raise ValueError("ckdtree path not specified")
|
|
134
|
+
|
|
135
|
+
# Load or get cached ckdtree data
|
|
136
|
+
ckdt_data = _load_ckdtree(self.fpath_ckdtree, res)
|
|
137
|
+
|
|
138
|
+
# Create meshgrid for plotting
|
|
139
|
+
grid_lon_2d, grid_lat_2d = np.meshgrid(ckdt_data['lon'], ckdt_data['lat'])
|
|
140
|
+
|
|
141
|
+
# Use indices to create interpolated data
|
|
142
|
+
grid_data = data[ckdt_data['indices']].reshape(
|
|
143
|
+
ckdt_data['lat'].size,
|
|
144
|
+
ckdt_data['lon'].size
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return grid_lon_2d, grid_lat_2d, grid_data
|