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/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