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