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 ADDED
@@ -0,0 +1,78 @@
1
+ """
2
+ MarEx: Marine Extremes Detection and Tracking
3
+ ==============================================
4
+
5
+ A Python package for efficient identification and tracking of marine extremes
6
+ such as Marine Heatwaves (MHWs).
7
+
8
+ Core Functionality
9
+ -----------------
10
+ - preprocess_data: Convert raw time series into standardised anomalies
11
+ - tracker: Identify and track extreme events through time
12
+
13
+ Example
14
+ -------
15
+ >>> import xarray as xr
16
+ >>> import marEx
17
+ >>> # Load SST data
18
+ >>> sst = xr.open_dataset('sst_data.nc').sst
19
+ >>> # Preprocess data to identify extreme events
20
+ >>> extreme_events_ds = marEx.preprocess_data(sst, threshold_percentile=95)
21
+ >>> # Track events through time
22
+ >>> tracker = marEx.tracker(extreme_events_ds.extreme_events, extreme_events_ds.mask,
23
+ ... R_fill=8, area_filter_quartile=0.5)
24
+ >>> events_ds = tracker.run()
25
+ """
26
+
27
+ try:
28
+ import jax
29
+ HAS_JAX = True
30
+ except ImportError:
31
+ HAS_JAX = False
32
+ import warnings
33
+ warnings.warn(
34
+ "JAX not installed. Some operations will be slower. "
35
+ "For best performance, install with: pip install marEx[jax]",
36
+ ImportWarning,
37
+ stacklevel=2
38
+ )
39
+
40
+ # Import core functionality
41
+ from .detect import (
42
+ preprocess_data,
43
+ compute_normalised_anomaly,
44
+ rechunk_for_cohorts,
45
+ identify_extremes
46
+ )
47
+
48
+ from .track import tracker
49
+
50
+ # Import plotting utilities
51
+ from .plotX import (
52
+ specify_grid,
53
+ PlotConfig
54
+ )
55
+
56
+ # Convenience variables
57
+ __all__ = [
58
+ # Core data preprocessing
59
+ 'preprocess_data',
60
+ 'compute_normalised_anomaly',
61
+ 'rechunk_for_cohorts',
62
+ 'identify_extremes',
63
+
64
+ # Tracking
65
+ 'tracker',
66
+
67
+ # Visualization
68
+ 'specify_grid',
69
+ 'PlotConfig',
70
+ ]
71
+
72
+ # Version information
73
+ from importlib.metadata import version
74
+ try:
75
+ __version__ = version("marEx")
76
+ except ImportError:
77
+ # Package is not installed
78
+ __version__ = "unknown"
marEx/detect.py ADDED
@@ -0,0 +1,490 @@
1
+ """
2
+ MarEx-Detect: Marine Extremes Detection Module
3
+
4
+ Preprocessing toolkit for marine extremes identification from scalar oceanographic data.
5
+ Converts raw time series into standardised anomalies and identifies extreme events
6
+ (e.g., Marine Heatwaves using Sea Surface Temperature).
7
+
8
+ Core capabilities:
9
+ - Detrending with polynomial fitting and seasonal cycle removal
10
+ - Optional standardisation using rolling statistics
11
+ - Threshold-based extreme event identification
12
+ - Efficient processing of both structured (gridded) and unstructured data
13
+
14
+ Compatible data formats:
15
+ - Structured data: 3D arrays (time, lat, lon)
16
+ - Unstructured data: 2D arrays (time, cell)
17
+ """
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ import xarray as xr
22
+ import dask
23
+ from dask.base import is_dask_collection
24
+ import flox.xarray
25
+ from xhistogram.xarray import histogram
26
+ import logging
27
+
28
+ logging.getLogger('distributed.shuffle._scheduler_plugin').setLevel(logging.ERROR)
29
+
30
+
31
+ # ============================
32
+ # Data Preparation Functions
33
+ # ============================
34
+
35
+ def add_decimal_year(da, dim='time'):
36
+ """
37
+ Add decimal year coordinate to DataArray for trend analysis.
38
+
39
+ Parameters
40
+ ----------
41
+ da : xarray.DataArray
42
+ Input data with datetime coordinate
43
+ dim : str, optional
44
+ Name of the time dimension
45
+
46
+ Returns
47
+ -------
48
+ xarray.DataArray
49
+ Input data with added 'decimal_year' coordinate
50
+ """
51
+ time = pd.to_datetime(da[dim])
52
+ start_of_year = pd.to_datetime(time.year.astype(str) + '-01-01')
53
+ start_of_next_year = pd.to_datetime((time.year + 1).astype(str) + '-01-01')
54
+ year_elapsed = (time - start_of_year).days
55
+ year_duration = (start_of_next_year - start_of_year).days
56
+
57
+ decimal_year = time.year + year_elapsed / year_duration
58
+ return da.assign_coords(decimal_year=(dim, decimal_year))
59
+
60
+
61
+ def rechunk_for_cohorts(da, chunksize=100, dim='time'):
62
+ """
63
+ Optimise chunking for climatology calculations using day-of-year cohorts.
64
+
65
+ Parameters
66
+ ----------
67
+ da : xarray.DataArray
68
+ Input data array
69
+ chunksize : int, optional
70
+ Target chunk size
71
+ dim : str, optional
72
+ Homogeneous dimension along which to rechunk
73
+
74
+ Returns
75
+ -------
76
+ xarray.DataArray
77
+ Optimally chunked data for climatology calculations
78
+ """
79
+ return flox.xarray.rechunk_for_cohorts(da,
80
+ dim=dim,
81
+ labels=da[dim].dt.dayofyear,
82
+ force_new_chunk_at=1,
83
+ chunksize=chunksize,
84
+ ignore_old_chunks=True)
85
+
86
+
87
+ # ============================
88
+ # Core Anomaly Processing
89
+ # ============================
90
+
91
+ def compute_normalised_anomaly(da, std_normalise=False, detrend_orders=[1],
92
+ dimensions={'time':'time', 'xdim':'lon', 'ydim':'lat'},
93
+ force_zero_mean=True):
94
+ """
95
+ Generate normalised anomalies by removing trends, seasonal cycles, and optionally
96
+ standardising by temporal variability.
97
+
98
+ Parameters
99
+ ----------
100
+ da : xarray.DataArray
101
+ Input data with dimensions matching the 'dimensions' parameter
102
+ std_normalise : bool, optional
103
+ Whether to normalise by 30-day rolling standard deviation
104
+ detrend_orders : list, optional
105
+ Polynomial orders for trend removal (e.g., [1] for linear, [1,2] for linear+quadratic, etc.)
106
+ dimensions : dict, optional
107
+ Mapping of conceptual dimensions to actual dimension names in the data
108
+ force_zero_mean : bool, optional
109
+ Explicitly enforce zero mean in final anomalies
110
+
111
+ Returns
112
+ -------
113
+ xarray.Dataset
114
+ Dataset containing detrended anomalies, optional standardised anomalies,
115
+ ocean/land mask, and rolling standard deviation if requested
116
+ """
117
+ da = da.astype(np.float32)
118
+
119
+ # Ensure time is the first dimension for efficient processing
120
+ if da.dims[0] != dimensions['time']:
121
+ da = da.transpose(dimensions['time'], ...)
122
+
123
+ # Warn if using higher-order detrending without linear component
124
+ if 1 not in detrend_orders and len(detrend_orders) > 1:
125
+ print('Warning: Higher-order detrending without linear term may be unstable')
126
+
127
+ # Add decimal year for trend modelling
128
+ da = add_decimal_year(da)
129
+ dy = da.decimal_year.compute()
130
+
131
+ # Build model matrix with constant term, trends, and seasonal harmonics
132
+ model_components = [np.ones(len(dy))] # Constant term
133
+
134
+ # Add polynomial trend terms
135
+ centered_time = da.decimal_year - np.mean(dy)
136
+ for order in detrend_orders:
137
+ model_components.append(centered_time ** order)
138
+
139
+ # Add annual and semi-annual cycles (harmonics)
140
+ model_components.extend([
141
+ np.sin(2 * np.pi * dy), # Annual sine
142
+ np.cos(2 * np.pi * dy), # Annual cosine
143
+ np.sin(4 * np.pi * dy), # Semi-annual sine
144
+ np.cos(4 * np.pi * dy) # Semi-annual cosine
145
+ ])
146
+
147
+ # Convert to numpy array for matrix operations
148
+ model = np.array(model_components)
149
+
150
+ # Orthogonalise model components for numerical stability
151
+ for i in range(1, model.shape[0]):
152
+ model[i] = model[i] - np.mean(model[i]) * model[0]
153
+
154
+ # Compute pseudo-inverse for model fitting
155
+ pmodel = np.linalg.pinv(model)
156
+ n_coeffs = len(model_components)
157
+
158
+ # Convert model matrices to xarray
159
+ model_da = xr.DataArray(
160
+ model.T,
161
+ dims=[dimensions['time'],'coeff'],
162
+ coords={dimensions['time']: da[dimensions['time']].values,
163
+ 'coeff': np.arange(1, n_coeffs+1)}
164
+ ).chunk({dimensions['time']: da.chunks[0]})
165
+
166
+ pmodel_da = xr.DataArray(
167
+ pmodel.T,
168
+ dims=['coeff', dimensions['time']],
169
+ coords={'coeff': np.arange(1, n_coeffs+1),
170
+ dimensions['time']: da[dimensions['time']].values}
171
+ ).chunk({dimensions['time']: da.chunks[0]})
172
+
173
+ # Prepare dimensions for model coefficients based on data structure
174
+ dims = ['coeff']
175
+ coords = {'coeff': np.arange(1, n_coeffs + 1)}
176
+
177
+ # Handle both 2D (unstructured) and 3D (gridded) data
178
+ if 'ydim' in dimensions: # 3D gridded case
179
+ dims.extend([dimensions['ydim'], dimensions['xdim']])
180
+ coords[dimensions['ydim']] = da[dimensions['ydim']].values
181
+ coords[dimensions['xdim']] = da[dimensions['xdim']].values
182
+ else: # 2D unstructured case
183
+ dims.append(dimensions['xdim'])
184
+ coords.update(da[dimensions['xdim']].coords)
185
+
186
+ # Fit model to data
187
+ model_fit_da = xr.DataArray(
188
+ pmodel_da.dot(da),
189
+ dims=dims,
190
+ coords=coords
191
+ )
192
+
193
+ # Remove trend and seasonal cycle
194
+ da_detrend = (da.drop_vars({'decimal_year'}) - model_da.dot(model_fit_da).astype(np.float32))
195
+
196
+ # Force zero mean if requested
197
+ if force_zero_mean:
198
+ da_detrend = da_detrend - da_detrend.mean(dim=dimensions['time'])
199
+
200
+ # Create ocean/land mask from first time step
201
+ chunk_dict_mask = {dimensions[dim]: -1 for dim in ['xdim', 'ydim'] if dim in dimensions}
202
+ mask = np.isfinite(da.isel({dimensions['time']: 0})).chunk(chunk_dict_mask).drop_vars({'decimal_year', 'time'})
203
+
204
+ # Initialise output dataset
205
+ data_vars = {
206
+ 'dat_detrend': da_detrend,
207
+ 'mask': mask
208
+ }
209
+
210
+ # Standardise anomalies by temporal variability if requested
211
+ if std_normalise:
212
+ print('Note: It is highly recommended to use rechunk_for_cohorts on input data before STD normalisation')
213
+ print(' e.g. To compute optimal cohort chunks for your data on disk, and then load directly into the dask array:')
214
+ print(' da_predictor = xr.open_dataset(\'path_to_data.nc\', chunks={}).var')
215
+ print(' time_chunk = marex.rechunk_for_cohorts(da_predictor).chunks[0]')
216
+ print(' var = xr.open_dataset(\'path_to_data.nc\', chunks={\'time\': time_chunk})')
217
+
218
+
219
+
220
+ # Calculate day-of-year standard deviation using cohorts
221
+ std_day = flox.xarray.xarray_reduce(
222
+ da_detrend,
223
+ da_detrend[dimensions['time']].dt.dayofyear,
224
+ dim=dimensions['time'],
225
+ func='std',
226
+ isbin=False,
227
+ method='cohorts'
228
+ )
229
+
230
+ # Calculate 30-day rolling standard deviation with annual wrapped padding
231
+ std_day_wrap = std_day.pad(dayofyear=16, mode='wrap')
232
+ std_rolling = np.sqrt(
233
+ (std_day_wrap**2)
234
+ .rolling(dayofyear=30, center=True)
235
+ .mean()
236
+ ).isel(dayofyear=slice(16, 366+16))
237
+
238
+ # Divide anomalies by rolling standard deviation
239
+ # Replace any zeros or extremely small values with NaN to avoid division warnings
240
+ std_rolling_safe = std_rolling.where(std_rolling > 1e-10, np.nan)
241
+ da_stn = da_detrend.groupby(da_detrend[dimensions['time']].dt.dayofyear) / std_rolling_safe
242
+
243
+ # Rechunk data for efficient processing
244
+ chunk_dict_std = chunk_dict_mask.copy()
245
+ chunk_dict_std['dayofyear'] = -1
246
+
247
+ da_stn = da_stn.chunk(chunk_dict_std)
248
+ std_rolling = std_rolling.chunk(chunk_dict_std)
249
+
250
+ # Add standardised data to output
251
+ data_vars['dat_stn'] = da_stn.drop_vars({'dayofyear', 'decimal_year'})
252
+ data_vars['STD'] = std_rolling
253
+
254
+ # Build output dataset with metadata
255
+ return xr.Dataset(
256
+ data_vars=data_vars,
257
+ attrs={
258
+ 'description': 'Standardised & Detrended Data',
259
+ 'preprocessing_steps': [
260
+ f'Removed {"polynomial trend orders=" + str(detrend_orders)} & seasonal cycle',
261
+ 'Normalised by 30-day rolling STD' if std_normalise else 'No STD normalisation'
262
+ ],
263
+ 'detrend_orders': detrend_orders,
264
+ 'force_zero_mean': force_zero_mean
265
+ }
266
+ )
267
+
268
+
269
+ # ============================
270
+ # Extreme Event Identification
271
+ # ============================
272
+
273
+ def compute_histogram_quantile(da, q, dim='time'):
274
+ """
275
+ Efficiently compute quantiles using binned histograms optimised for extreme values.
276
+ Uses fine-grained bins for positive anomalies and a single bin for negative values.
277
+
278
+ Parameters
279
+ ----------
280
+ da : xarray.DataArray
281
+ Input data array
282
+ q : float
283
+ Quantile to compute (0-1)
284
+ dim : str, optional
285
+ Dimension along which to compute quantile
286
+
287
+ Returns
288
+ -------
289
+ xarray.DataArray
290
+ Computed quantile value for each spatial location
291
+ """
292
+ # Configure histogram with asymmetric bins (higher resolution for positive values)
293
+ precision = 0.025 # Bin width for positive values
294
+ max_anomaly = 10.0 # Maximum expected anomaly value
295
+
296
+ # Create bin edges with special treatment for negative values
297
+ bin_edges = np.concatenate([
298
+ [-np.inf, 0.], # Single bin for all negative values
299
+ np.arange(precision, max_anomaly+precision, precision) # Fine bins for positive values
300
+ ])
301
+
302
+ # Compute histogram along specified dimension
303
+ hist = histogram(
304
+ da,
305
+ bins=[bin_edges],
306
+ dim=[dim]
307
+ )
308
+
309
+ # Convert to PDF and CDF with handling for empty histograms
310
+ hist_sum = hist.sum(dim='dat_detrend_bin') + 1e-10
311
+ pdf = hist / hist_sum
312
+ cdf = pdf.cumsum(dim='dat_detrend_bin')
313
+
314
+ # Get bin centres for interpolation
315
+ bin_centers = (bin_edges[1:] + bin_edges[:-1]) / 2
316
+ bin_centers[0] = 0. # Set negative bin centre to 0
317
+
318
+ # Find bins where CDF crosses desired quantile
319
+ mask = cdf >= q
320
+
321
+ # Get the first bin that exceeds the quantile
322
+ first_true = mask.argmax(dim='dat_detrend_bin')
323
+
324
+ # Convert bin indices to actual values
325
+ result = first_true.copy(data=bin_centers[first_true])
326
+
327
+ return result
328
+
329
+
330
+ def identify_extremes(da, threshold_percentile=95, exact_percentile=False,
331
+ dimensions={'time':'time', 'xdim':'lon'}):
332
+ """
333
+ Identify extreme events exceeding a percentile threshold.
334
+
335
+ Parameters
336
+ ----------
337
+ da : xarray.DataArray
338
+ DataArray containing detrended anomalies
339
+ threshold_percentile : float, optional
340
+ Percentile threshold (e.g., 95 for 95th percentile)
341
+ exact_percentile : bool, optional
342
+ Whether to compute exact percentiles (slower) or use histogram approximation (faster)
343
+ dimensions : dict, optional
344
+ Mapping of dimension types to names in the data
345
+
346
+ Returns
347
+ -------
348
+ xarray.DataArray
349
+ Boolean array identifying extreme events
350
+ """
351
+ if exact_percentile: # Compute exact percentile (memory-intensive)
352
+ # Determine appropriate chunk size based on data dimensions
353
+ if 'ydim' in dimensions:
354
+ rechunk_size = 'auto'
355
+ else:
356
+ rechunk_size = 100*int(np.sqrt(da.ncells.size)*1.5/100)
357
+ # N.B.: If this rechunk_size is too small, then dask will be overwhelmed by the number of tasks
358
+ chunk_dict = {dimensions[dim]: rechunk_size for dim in ['xdim', 'ydim'] if dim in dimensions}
359
+ chunk_dict[dimensions['time']] = -1
360
+ da_rechunk = da.chunk(chunk_dict)
361
+
362
+ # Calculate threshold
363
+ threshold = da_rechunk.quantile(threshold_percentile/100.0, dim=dimensions['time'])
364
+
365
+ else: # Use an efficient histogram-based method with specified accuracy
366
+
367
+ threshold = compute_histogram_quantile(da, threshold_percentile/100.0, dim=dimensions['time'])
368
+
369
+ # Create boolean mask for values exceeding threshold
370
+ extremes = da >= threshold
371
+
372
+ # Clean up coordinates if needed
373
+ if 'quantile' in extremes.coords:
374
+ extremes = extremes.drop_vars('quantile')
375
+
376
+ return extremes
377
+
378
+
379
+ # ============================
380
+ # Main Processing Pipeline
381
+ # ============================
382
+
383
+ def preprocess_data(da, std_normalise=False, threshold_percentile=95,
384
+ detrend_orders=[1], force_zero_mean=True,
385
+ exact_percentile=False, dask_chunks={'time': 25},
386
+ dimensions={'time':'time', 'xdim':'lon'},
387
+ neighbours=None, cell_areas=None):
388
+ """
389
+ Complete preprocessing pipeline for marine extreme event identification.
390
+
391
+ Workflow:
392
+ 1. Compute detrended (and optionally standardised) anomalies
393
+ 2. Identify values exceeding the percentile threshold
394
+ 3. Attach optional spatial metadata (neighbour connectivity, cell areas)
395
+
396
+ Parameters
397
+ ----------
398
+ da : xarray.DataArray
399
+ Raw input data
400
+ std_normalise : bool, optional
401
+ Whether to standardise anomalies by rolling standard deviation
402
+ threshold_percentile : float, optional
403
+ Percentile threshold for extreme event identification
404
+ detrend_orders : list, optional
405
+ Polynomial orders for detrending
406
+ force_zero_mean : bool, optional
407
+ Whether to enforce zero mean in detrended anomalies
408
+ exact_percentile : bool, optional
409
+ Whether to use exact or approximate percentile calculation
410
+ dask_chunks : dict, optional
411
+ Chunking specification for distributed computation
412
+ dimensions : dict, optional
413
+ Mapping of dimension types to names in the data
414
+ neighbours : xarray.DataArray, optional
415
+ Neighbour connectivity for spatial clustering (optional)
416
+ cell_areas : xarray.DataArray, optional
417
+ Cell areas for weighted spatial statistics (optional)
418
+
419
+ Returns
420
+ -------
421
+ xarray.Dataset
422
+ Processed dataset with anomalies and extreme event identification
423
+ """
424
+ # Check if input data is dask-backed
425
+ if not is_dask_collection(da.data):
426
+ raise ValueError('The input DataArray must be backed by a Dask array. Ensure the input data is chunked, e.g. with chunks={}')
427
+
428
+ # Step 1: Compute anomalies with detrending and optional standardisation
429
+ ds = compute_normalised_anomaly(
430
+ da,
431
+ std_normalise,
432
+ detrend_orders=detrend_orders,
433
+ force_zero_mean=force_zero_mean,
434
+ dimensions=dimensions
435
+ )
436
+
437
+ # Prevent dask from splitting chunks during slicing operations (otherwise dask task graph may be overwhelmed!)
438
+ with dask.config.set(**{'array.slicing.split_large_chunks': False}):
439
+
440
+ # Step 2: Identify extreme events in detrended anomalies
441
+ extremes_detrend = identify_extremes(
442
+ ds.dat_detrend,
443
+ threshold_percentile=threshold_percentile,
444
+ exact_percentile=exact_percentile,
445
+ dimensions=dimensions
446
+ )
447
+ ds['extreme_events'] = extremes_detrend
448
+
449
+ # Also process standardised anomalies if requested
450
+ if std_normalise:
451
+ extremes_stn = identify_extremes(
452
+ ds.dat_stn,
453
+ threshold_percentile=threshold_percentile,
454
+ exact_percentile=exact_percentile,
455
+ dimensions=dimensions
456
+ )
457
+ ds['extreme_events_stn'] = extremes_stn
458
+
459
+ # Step 3: Add optional spatial metadata
460
+ if neighbours is not None:
461
+ chunk_dict = {dim: -1 for dim in neighbours.dims}
462
+ ds['neighbours'] = neighbours.astype(np.int32).chunk(chunk_dict)
463
+
464
+ if 'nv' in neighbours.dims:
465
+ ds = ds.assign_coords(nv=neighbours.nv)
466
+
467
+ if cell_areas is not None:
468
+ chunk_dict = {dim: -1 for dim in cell_areas.dims}
469
+ ds['cell_areas'] = cell_areas.astype(np.float32).chunk(chunk_dict)
470
+
471
+ # Add processing parameters to metadata
472
+ ds.attrs.update({
473
+ 'threshold_percentile': threshold_percentile,
474
+ 'detrend_orders': detrend_orders,
475
+ 'force_zero_mean': force_zero_mean,
476
+ 'std_normalise': std_normalise
477
+ })
478
+
479
+ # Final rechunking for optimal performance
480
+ chunk_dict = {dimensions[dim]: -1 for dim in ['xdim', 'ydim'] if dim in dimensions}
481
+ chunk_dict[dimensions['time']] = dask_chunks['time']
482
+ ds = ds.chunk(chunk_dict)
483
+
484
+ # Fix encoding issue with saving when calendar & units attribute is present
485
+ if 'calendar' in ds[dimensions['time']].attrs:
486
+ del ds[dimensions['time']].attrs['calendar']
487
+ if 'units' in ds[dimensions['time']].attrs:
488
+ del ds[dimensions['time']].attrs['units']
489
+
490
+ return ds