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/track.py ADDED
@@ -0,0 +1,3646 @@
1
+ """
2
+ MarEx: Marine Extreme Event Identification, Tracking, and Splitting/Merging Module
3
+
4
+ MarEx identifies and tracks extreme events in oceanographic data across time,
5
+ supporting both structured (regular grid) and unstructured datasets. It can identify
6
+ discrete objects at single time points and track them as evolving events through time,
7
+ seamlessly handling splitting and merging.
8
+
9
+ This package provides algorithms to:
10
+ - Identify binary objects in spatial data at each time step
11
+ - Track these objects across time to form coherent events
12
+ - Handle merging and splitting of objects over time
13
+ - Calculate and maintain object/event properties through time
14
+ - Filter by size criteria to focus on significant events
15
+
16
+ Key terminology:
17
+ - Object: A connected region in binary data at a single time point
18
+ - Event: One or more objects tracked through time and identified as the same entity
19
+ """
20
+
21
+ import xarray as xr
22
+ import numpy as np
23
+ from dask.distributed import wait
24
+ from dask_image.ndmeasure import label
25
+ from skimage.measure import regionprops_table
26
+ from dask_image.ndmorph import binary_closing as binary_closing_dask
27
+ from dask_image.ndmorph import binary_opening as binary_opening_dask
28
+ from scipy.ndimage import binary_closing, binary_opening
29
+ from scipy.sparse import coo_matrix, csr_matrix, eye
30
+ from scipy.sparse.csgraph import connected_components
31
+ from dask import persist
32
+ import dask.array as dsa
33
+ from dask.base import is_dask_collection
34
+ from numba import jit, njit, prange
35
+ import warnings
36
+ import logging
37
+ import os
38
+ import shutil
39
+ import gc
40
+ import time
41
+
42
+ try:
43
+ import jax.numpy as jnp
44
+ HAS_JAX = True
45
+ except ImportError:
46
+ import numpy as np
47
+ jnp = np # Alias for jnp
48
+ HAS_JAX = False
49
+
50
+ warnings.warn(
51
+ "JAX not installed. Some functionality will be slower. "
52
+ "For best performance, install with: pip install marEx[jax]",
53
+ ImportWarning,
54
+ stacklevel=2
55
+ )
56
+
57
+
58
+ # ============================
59
+ # Main Tracker Class
60
+ # ============================
61
+
62
+ class tracker:
63
+ """
64
+ Tracker identifies and tracks arbitrary binary objects in spatial data through time.
65
+
66
+ The tracker supports both structured (regular grid) and unstructured data,
67
+ and seamlessly handles splitting & merging of objects. It identifies
68
+ connected regions in binary data at each time step, and tracks these as
69
+ evolving events through time.
70
+
71
+ Main workflow:
72
+ 1. Preprocessing: Fill spatiotemporal holes, filter small objects
73
+ 2. Object identification: Label connected components at each time
74
+ 3. Tracking: Determine object correspondences across time
75
+ 4. Optional splitting & merging: Handle complex event evolution
76
+
77
+ Parameters
78
+ ----------
79
+ data_bin : xarray.DataArray
80
+ Binary data to identify and track objects in (True = object, False = background)
81
+ mask : xarray.DataArray
82
+ Binary mask indicating valid regions (True = valid, False = invalid)
83
+ R_fill : int
84
+ Radius for filling holes/gaps in spatial domain (in grid cells)
85
+ area_filter_quartile : float
86
+ Quantile (0-1) for filtering smallest objects (e.g., 0.25 removes smallest 25%)
87
+ temp_dir : str, optional
88
+ Path to temporary directory for storing intermediate results
89
+ T_fill : int, default=2
90
+ Number of timesteps for filling temporal gaps (must be even)
91
+ allow_merging : bool, default=True
92
+ Allow objects to split and merge across time
93
+ nn_partitioning : bool, default=False
94
+ Use nearest-neighbor partitioning for merging events
95
+ overlap_threshold : float, default=0.5
96
+ Minimum fraction of overlap between objects to consider them the same
97
+ unstructured_grid : bool, default=False
98
+ Whether data is on an unstructured grid
99
+ timedim : str, default='time'
100
+ Name of time dimension
101
+ xdim : str, default='lon'
102
+ Name of x/longitude dimension
103
+ ydim : str, default='lat'
104
+ Name of y/latitude dimension
105
+ neighbours : xarray.DataArray, optional
106
+ For unstructured grid, indicates connectivity between cells
107
+ cell_areas : xarray.DataArray, optional
108
+ For unstructured grid, area of each cell
109
+ max_iteration : int, default=40
110
+ Maximum number of iterations for merging/splitting algorithm
111
+ checkpoint : str, default='None'
112
+ Checkpoint strategy ('save', 'load', or None)
113
+ debug : int, default=0
114
+ Debug level (0-2)
115
+ verbosity : int, default=0
116
+ Verbosity level
117
+ """
118
+
119
+ def __init__(self, data_bin, mask, R_fill, area_filter_quartile,
120
+ temp_dir=None, T_fill=2, allow_merging=True, nn_partitioning=False,
121
+ overlap_threshold=0.5, unstructured_grid=False, timedim='time',
122
+ xdim='lon', ydim='lat', neighbours=None, cell_areas=None,
123
+ max_iteration=40, checkpoint=None, debug=0, verbosity=0):
124
+
125
+ self.data_bin = data_bin
126
+ self.mask = mask
127
+ self.R_fill = int(R_fill)
128
+ self.T_fill = T_fill
129
+ self.area_filter_quartile = area_filter_quartile
130
+ self.allow_merging = allow_merging
131
+ self.nn_partitioning = nn_partitioning
132
+ self.overlap_threshold = overlap_threshold
133
+ self.timedim = timedim
134
+ self.xdim = xdim
135
+ self.ydim = ydim
136
+ self.lat = data_bin.lat.persist()
137
+ self.lon = data_bin.lon.persist()
138
+ self.timechunks = data_bin.chunks[data_bin.dims.index(timedim)][0]
139
+ self.unstructured_grid = unstructured_grid
140
+ self.mean_cell_area = 1.0 # For structured grids, units are pixels
141
+ self.checkpoint = checkpoint
142
+ self.debug = debug
143
+ self.verbosity = verbosity
144
+
145
+ # Input validation and preparation
146
+ self._validate_inputs()
147
+
148
+ # Special setup for unstructured grids
149
+ if unstructured_grid:
150
+ self._setup_unstructured_grid(temp_dir, neighbours, cell_areas, max_iteration)
151
+
152
+ self._configure_warnings()
153
+
154
+ def _validate_inputs(self):
155
+ """Validate input parameters and data."""
156
+ # For unstructured grids, adjust dimensions
157
+ if self.unstructured_grid:
158
+ self.ydim = None
159
+ if ((self.timedim, self.xdim) != self.data_bin.dims):
160
+ try:
161
+ self.data_bin = self.data_bin.transpose(self.timedim, self.xdim)
162
+ except:
163
+ raise ValueError(
164
+ f'Unstructured MarEx only supports 2D DataArrays with dimensions '
165
+ f'({self.timedim} and {self.xdim}). Found {list(self.data_bin.dims)}'
166
+ )
167
+ else:
168
+ # For structured grids, ensure 3D data
169
+ if ((self.timedim, self.ydim, self.xdim) != self.data_bin.dims):
170
+ try:
171
+ self.data_bin = self.data_bin.transpose(self.timedim, self.ydim, self.xdim)
172
+ except:
173
+ raise ValueError(
174
+ f'Gridded (structured) MarEx only supports 3D DataArrays with dimensions '
175
+ f'({self.timedim}, {self.ydim}, and {self.xdim}). Found {list(self.data_bin.dims)}'
176
+ )
177
+
178
+ # Check data type and structure
179
+ if (self.data_bin.data.dtype != bool):
180
+ raise ValueError('The input DataArray must be binary (boolean type)')
181
+
182
+ if not is_dask_collection(self.data_bin.data):
183
+ raise ValueError('The input DataArray must be backed by a Dask array')
184
+
185
+ if (self.mask.data.dtype != bool):
186
+ raise ValueError('The mask must be binary (boolean type)')
187
+
188
+ if (self.mask == False).all():
189
+ raise ValueError('Mask contains only False values. It should indicate valid regions with True')
190
+
191
+ if (self.area_filter_quartile < 0) or (self.area_filter_quartile > 1):
192
+ raise ValueError('area_filter_quartile must be between 0 and 1')
193
+
194
+ if (self.T_fill % 2 != 0):
195
+ raise ValueError('T_fill must be even for symmetry')
196
+
197
+ # Check geographic coordinates
198
+ if ((self.lon.max().compute().item() - self.lon.min().compute().item()) < 100):
199
+ raise ValueError('Lat/Lon coordinates must be in degrees')
200
+
201
+ def _setup_unstructured_grid(self, temp_dir, neighbours, cell_areas, max_iteration):
202
+ """Set up special handling for unstructured grids."""
203
+ if not temp_dir:
204
+ raise ValueError('Unstructured grid requires a temporary directory for memory-efficient processing')
205
+
206
+ self.scratch_dir = temp_dir
207
+
208
+ # Clear any existing temporary storage
209
+ if os.path.exists(f'{self.scratch_dir}/marEx_temp_field.zarr/'):
210
+ shutil.rmtree(f'{self.scratch_dir}/marEx_temp_field.zarr/')
211
+
212
+ # Remove coordinate variables to avoid memory issues
213
+ self.data_bin = self.data_bin.drop_vars({'lat', 'lon'})
214
+ self.mask = self.mask.drop_vars({'lat', 'lon'})
215
+ self.lat = self.lat.drop_vars(self.lat.coords)
216
+ self.lon = self.lon.drop_vars(self.lon.coords)
217
+ neighbours = neighbours.drop_vars({'lat', 'lon', 'nv'})
218
+
219
+ self.max_iteration = max_iteration
220
+
221
+ # Store cell areas (in square metres)
222
+ self.cell_area = cell_areas.astype(np.float32).drop_vars({'lat', 'lon'}).persist()
223
+ self.mean_cell_area = cell_areas.mean().compute().item()
224
+
225
+ # Initialise dilation array for unstructured grid
226
+ self.neighbours_int = neighbours.astype(np.int32) - 1 # Convert to 0-based indexing
227
+
228
+ # Validate neighbour array structure
229
+ if self.neighbours_int.shape[0] != 3:
230
+ raise ValueError('Unstructured MarEx only supports triangular grids. Neighbours array must have shape (3, ncells)')
231
+ if self.neighbours_int.dims != ('nv', self.xdim):
232
+ raise ValueError('Neighbours array must have dimensions (nv, xdim)')
233
+
234
+ # Construct sparse dilation matrix
235
+ self._build_sparse_dilation_matrix()
236
+
237
+ def _build_sparse_dilation_matrix(self):
238
+ """Build sparse matrix for efficient dilation operations on unstructured grid."""
239
+ # Create row and column indices for sparse matrix
240
+ row_indices = jnp.repeat(jnp.arange(self.neighbours_int.shape[1]), 3)
241
+ col_indices = self.neighbours_int.data.compute().T.flatten()
242
+
243
+ # Filter out negative values (invalid connections)
244
+ valid_mask = col_indices >= 0
245
+ row_indices = row_indices[valid_mask]
246
+ col_indices = col_indices[valid_mask]
247
+
248
+ max_neighbour = self.neighbours_int.max().compute().item() + 1
249
+
250
+ # Create the sparse matrix for dilation
251
+ dilate_coo = coo_matrix(
252
+ (jnp.ones_like(row_indices, dtype=bool), (row_indices, col_indices)),
253
+ shape=(self.neighbours_int.shape[1], max_neighbour)
254
+ )
255
+ self.dilate_sparse = csr_matrix(dilate_coo)
256
+
257
+ # Add identity matrix to include self-connections
258
+ identity = eye(self.neighbours_int.shape[1], dtype=bool, format='csr')
259
+ self.dilate_sparse = self.dilate_sparse + identity
260
+
261
+ if self.verbosity > 0:
262
+ print('Finished constructing the sparse dilation matrix')
263
+
264
+ def _configure_warnings(self):
265
+ """Configure warning and logging suppression based on debug level."""
266
+ if self.debug < 2:
267
+ # Configure logging warning filters
268
+ logging.getLogger('distributed.scheduler').setLevel(logging.ERROR)
269
+
270
+ def filter_dask_warnings(record):
271
+ msg = str(record.msg)
272
+
273
+ if self.debug == 0:
274
+ # Suppress both run_spec and large graph warnings
275
+ if any(pattern in msg for pattern in [
276
+ 'Detected different `run_spec`',
277
+ 'Sending large graph',
278
+ 'This may cause some slowdown'
279
+ ]):
280
+ return False
281
+ return True
282
+ else:
283
+ # Suppress only run_spec warnings
284
+ if 'Detected different `run_spec`' in msg:
285
+ return False
286
+ return True
287
+
288
+ logging.getLogger('distributed.scheduler').addFilter(filter_dask_warnings)
289
+
290
+ # Configure Python warnings
291
+ if self.debug == 0:
292
+ warnings.filterwarnings('ignore',
293
+ category=UserWarning,
294
+ module='distributed.client')
295
+ warnings.filterwarnings('ignore',
296
+ message='.*Sending large graph.*\n.*This may cause some slowdown.*',
297
+ category=UserWarning)
298
+
299
+ # ============================
300
+ # Main Public Methods
301
+ # ============================
302
+
303
+ def run(self, return_merges=False, checkpoint=None):
304
+ """
305
+ Run the complete object identification and tracking pipeline.
306
+
307
+ This method executes the full workflow:
308
+ 1. Preprocessing: morphological operations and size filtering
309
+ 2. Identification and tracking of objects through time
310
+ 3. Computing and attaching statistics to the results
311
+
312
+ Parameters
313
+ ----------
314
+ return_merges : bool, default=False
315
+ If True, return merge events dataset alongside the main events
316
+ checkpoint : str, optional
317
+ Override the instance checkpoint setting
318
+
319
+ Returns
320
+ -------
321
+ events_ds : xarray.Dataset
322
+ Dataset containing tracked events and their properties
323
+ merges_ds : xarray.Dataset, optional
324
+ Dataset with merge event information (only if return_merges=True)
325
+ """
326
+ # Preprocess the binary data
327
+ data_bin_preprocessed, object_stats = self.run_preprocess(checkpoint=checkpoint)
328
+
329
+ # Run identification and tracking
330
+ events_ds, merges_ds, N_events_final = self.run_tracking(data_bin_preprocessed)
331
+
332
+ # Compute statistics and finalise output
333
+ events_ds = self.run_stats_attributes(events_ds, merges_ds, object_stats, N_events_final)
334
+
335
+ if self.allow_merging and return_merges:
336
+ return events_ds, merges_ds
337
+ else:
338
+ return events_ds
339
+
340
+ def run_preprocess(self, checkpoint=None):
341
+ """
342
+ Preprocess binary data to prepare for tracking.
343
+
344
+ This performs morphological operations to fill holes/gaps in both space and time,
345
+ then filters small objects according to the area_filter_quartile.
346
+
347
+ Parameters
348
+ ----------
349
+ checkpoint : str, optional
350
+ Checkpoint strategy override
351
+
352
+ Returns
353
+ -------
354
+ data_bin_filtered : xarray.DataArray
355
+ Preprocessed binary data
356
+ object_stats : tuple
357
+ Statistics about the preprocessing
358
+ """
359
+ if not checkpoint:
360
+ checkpoint = self.checkpoint
361
+
362
+ def load_data_from_checkpoint():
363
+ """Load preprocessed data from checkpoint files."""
364
+ data_bin_preprocessed = xr.open_zarr(
365
+ f'{self.scratch_dir}/marEx_checkpoint_proc_bin.zarr',
366
+ chunks={self.timedim: self.timechunks}
367
+ )['data_bin_preproc']
368
+ return data_bin_preprocessed
369
+ def load_stats_from_checkpoint():
370
+ object_stats_npz = np.load(f'{self.scratch_dir}/marEx_checkpoint_stats.npz')
371
+ object_stats = [
372
+ object_stats_npz[key] for key in [
373
+ 'total_area_IDed', 'N_objects_prefiltered', 'N_objects_filtered',
374
+ 'area_threshold', 'accepted_area_fraction', 'preprocessed_area_fraction'
375
+ ]
376
+ ]
377
+ return object_stats
378
+
379
+ if checkpoint == 'load':
380
+ print('Loading preprocessed data & stats...')
381
+ return load_data_from_checkpoint(), load_stats_from_checkpoint()
382
+
383
+
384
+ # Compute area of initial binary data
385
+ raw_area = self.compute_area(self.data_bin)
386
+
387
+ # Fill small holes & gaps between objects
388
+ data_bin_filled = self.fill_holes(self.data_bin)
389
+ del self.data_bin # Free memory
390
+ if self.verbosity > 0:
391
+ print('Finished filling spatial holes')
392
+
393
+ # Fill small time-gaps between objects
394
+ data_bin_filled = self.fill_time_gaps(data_bin_filled).persist()
395
+ if self.verbosity > 0:
396
+ print('Finished filling spatio-temporal holes')
397
+
398
+ # Remove small objects
399
+ data_bin_filtered, area_threshold, object_areas, N_objects_prefiltered, N_objects_filtered = (
400
+ self.filter_small_objects(data_bin_filled)
401
+ )
402
+ del data_bin_filled # Free memory
403
+ if self.verbosity > 0:
404
+ print('Finished filtering small objects')
405
+
406
+ # Persist preprocessed data &/or Save checkpoint
407
+ if checkpoint and 'save' in checkpoint:
408
+ print('Saving preprocessed data & stats...')
409
+ time.sleep(5)
410
+ data_bin_filtered.name = 'data_bin_preproc'
411
+ data_bin_filtered.to_zarr(f'{self.scratch_dir}/marEx_checkpoint_proc_bin.zarr', mode='w') # N.B.: This needs to be done without .persist() due to dask to_zarr tuple bug...
412
+ data_bin_filtered = load_data_from_checkpoint()
413
+ else:
414
+ data_bin_filtered = data_bin_filtered.persist()
415
+ wait(data_bin_filtered)
416
+
417
+ # Compute area of processed data
418
+ processed_area = self.compute_area(data_bin_filtered)
419
+
420
+ # Compute statistics
421
+ object_areas = object_areas.compute()
422
+ total_area_IDed = object_areas.sum().item()
423
+
424
+ accepted_area = object_areas.where(object_areas > area_threshold, drop=True).sum().item()
425
+ accepted_area_fraction = accepted_area / total_area_IDed
426
+
427
+ total_hobday_area = raw_area.sum().compute().item()
428
+ total_processed_area = processed_area.sum().compute().item()
429
+ preprocessed_area_fraction = total_hobday_area / total_processed_area
430
+
431
+ object_stats = (
432
+ total_area_IDed, N_objects_prefiltered, N_objects_filtered,
433
+ area_threshold, accepted_area_fraction, preprocessed_area_fraction
434
+ )
435
+
436
+ # Save checkpoint
437
+ if checkpoint and 'save' in checkpoint:
438
+ np.savez(
439
+ f'{self.scratch_dir}/marEx_checkpoint_stats.npz',
440
+ total_area_IDed=total_area_IDed,
441
+ N_objects_prefiltered=N_objects_prefiltered,
442
+ N_objects_filtered=N_objects_filtered,
443
+ area_threshold=area_threshold,
444
+ accepted_area_fraction=accepted_area_fraction,
445
+ preprocessed_area_fraction=preprocessed_area_fraction
446
+ )
447
+ # Reload to refresh the dask graph
448
+ data_bin_filtered = load_data_from_checkpoint()
449
+ object_stats = load_stats_from_checkpoint()
450
+
451
+ return data_bin_filtered, object_stats
452
+
453
+ def run_tracking(self, data_bin_preprocessed):
454
+ """
455
+ Track objects through time to identify events.
456
+
457
+ Parameters
458
+ ----------
459
+ data_bin_preprocessed : xarray.DataArray
460
+ Preprocessed binary data
461
+
462
+ Returns
463
+ -------
464
+ events_ds : xarray.Dataset
465
+ Dataset containing tracked events
466
+ merges_ds : xarray.Dataset
467
+ Dataset with merge information
468
+ N_events_final : int
469
+ Final number of unique events
470
+ """
471
+ if self.allow_merging or self.unstructured_grid:
472
+ # Track with merging & splitting
473
+ events_ds, merges_ds, N_events_final = self.track_objects(data_bin_preprocessed)
474
+ else:
475
+ # Track without merging or splitting
476
+ events_da, _, N_events_final = self.identify_objects(
477
+ data_bin_preprocessed, time_connectivity=True
478
+ )
479
+ events_ds = xr.Dataset({'ID_field': events_da})
480
+ merges_ds = xr.Dataset()
481
+
482
+ # Set all filler IDs < 0 to 0
483
+ events_ds['ID_field'] = events_ds.ID_field.where(events_ds.ID_field > 0, drop=False, other=0)
484
+
485
+ # Delete the last ID -- it is all 0s
486
+ events_ds = events_ds.isel(ID=slice(None,-1)) # At least for the gridded algorithm
487
+
488
+ if self.verbosity > 0:
489
+ print('Finished tracking all extreme events!\n\n')
490
+
491
+ return events_ds, merges_ds, N_events_final
492
+
493
+ def run_stats_attributes(self, events_ds, merges_ds, object_stats, N_events_final):
494
+ """
495
+ Add statistics and attributes to the events dataset.
496
+
497
+ Parameters
498
+ ----------
499
+ events_ds : xarray.Dataset
500
+ Dataset containing tracked events
501
+ merges_ds : xarray.Dataset
502
+ Dataset with merge information
503
+ object_stats : tuple
504
+ Preprocessed object statistics
505
+ N_events_final : int
506
+ Final number of events
507
+
508
+ Returns
509
+ -------
510
+ events_ds : xarray.Dataset
511
+ Dataset with added statistics and attributes
512
+ """
513
+ # Unpack object stats
514
+ (total_area_IDed, N_objects_prefiltered, N_objects_filtered,
515
+ area_threshold, accepted_area_fraction, preprocessed_area_fraction) = object_stats
516
+
517
+ # Add general attributes to dataset
518
+ events_ds.attrs['allow_merging'] = int(self.allow_merging)
519
+ events_ds.attrs['N_objects_prefiltered'] = int(N_objects_prefiltered)
520
+ events_ds.attrs['N_objects_filtered'] = int(N_objects_filtered)
521
+ events_ds.attrs['N_events_final'] = int(N_events_final)
522
+ events_ds.attrs['R_fill'] = self.R_fill
523
+ events_ds.attrs['T_fill'] = self.T_fill
524
+ events_ds.attrs['area_filter_quartile'] = self.area_filter_quartile
525
+ events_ds.attrs['area_threshold (cells)'] = area_threshold
526
+ events_ds.attrs['accepted_area_fraction'] = accepted_area_fraction
527
+ events_ds.attrs['preprocessed_area_fraction'] = preprocessed_area_fraction
528
+
529
+ # Print summary statistics
530
+ print('Tracking Statistics:')
531
+ print(f' Binary Hobday to Processed Area Fraction: {preprocessed_area_fraction}')
532
+ print(f' Total Object Area IDed (cells): {total_area_IDed}')
533
+ print(f' Number of Initial Pre-Filtered Objects: {N_objects_prefiltered}')
534
+ print(f' Number of Final Filtered Objects: {N_objects_filtered}')
535
+ print(f' Area Cutoff Threshold (cells): {area_threshold.astype(np.int32)}')
536
+ print(f' Accepted Area Fraction: {accepted_area_fraction}')
537
+ print(f' Total Events Tracked: {N_events_final}')
538
+
539
+ # Add merge-specific attributes if applicable
540
+ if self.allow_merging:
541
+ events_ds.attrs['overlap_threshold'] = self.overlap_threshold
542
+ events_ds.attrs['nn_partitioning'] = int(self.nn_partitioning)
543
+
544
+ # Add merge summary attributes
545
+ events_ds.attrs['total_merges'] = len(merges_ds.merge_ID)
546
+ events_ds.attrs['multi_parent_merges'] = (merges_ds.n_parents > 2).sum().item()
547
+
548
+ print(f" Total Merging Events Recorded: {events_ds.attrs['total_merges']}")
549
+
550
+ # For unstructured grid, restore coordinates and rechunk
551
+ if self.unstructured_grid:
552
+ # Add lat & lon back as coordinates
553
+ events_ds = events_ds.assign_coords(
554
+ lat=self.lat.compute(),
555
+ lon=self.lon.compute()
556
+ )
557
+ # Rechunk to size 1 for better post-processing
558
+ events_ds = events_ds.chunk({self.timedim: 1})
559
+
560
+ return events_ds
561
+
562
+ # ============================
563
+ # Data Processing Methods
564
+ # ============================
565
+
566
+ def compute_area(self, data_bin):
567
+ """
568
+ Compute the total area of binary data at each time.
569
+
570
+ Parameters
571
+ ----------
572
+ data_bin : xarray.DataArray
573
+ Binary data
574
+
575
+ Returns
576
+ -------
577
+ area : xarray.DataArray
578
+ Total area at each time (units: pixels for structured grid, matching cell_area for unstructured)
579
+ """
580
+ if self.unstructured_grid:
581
+ area = (data_bin * self.cell_area).sum(dim=[self.xdim])
582
+ else:
583
+ area = data_bin.sum(dim=[self.ydim, self.xdim])
584
+
585
+ return area
586
+
587
+ def fill_holes(self, data_bin, R_fill=None):
588
+ """
589
+ Fill holes and gaps using morphological operations.
590
+
591
+ This performs closing (dilation followed by erosion) to fill small gaps,
592
+ then opening (erosion followed by dilation) to remove small isolated objects.
593
+
594
+ Parameters
595
+ ----------
596
+ data_bin : xarray.DataArray
597
+ Binary data to process
598
+ R_fill : int, optional
599
+ Fill radius override
600
+
601
+ Returns
602
+ -------
603
+ data_bin_filled : xarray.DataArray
604
+ Binary data with holes/gaps filled
605
+ """
606
+ if R_fill is None:
607
+ R_fill = self.R_fill
608
+
609
+ if self.unstructured_grid:
610
+ # Process unstructured grid using sparse matrix operations
611
+ # _Put the data into an xarray.DataArray to pass into the apply_ufunc_ -- Needed for correct memory management !
612
+ sp_data = xr.DataArray(self.dilate_sparse.data, dims='sp_data')
613
+ indices = xr.DataArray(self.dilate_sparse.indices, dims='indices')
614
+ indptr = xr.DataArray(self.dilate_sparse.indptr, dims='indptr')
615
+
616
+ def binary_open_close(bitmap_binary, sp_data, indices, indptr, mask):
617
+ """
618
+ Binary opening and closing for unstructured grid.
619
+ Uses sparse matrix power operations for efficiency.
620
+ """
621
+ ## Closing: Dilation then Erosion (fills small gaps)
622
+
623
+ # Dilation
624
+ bitmap_binary = sparse_bool_power(bitmap_binary, sp_data, indices, indptr, R_fill)
625
+
626
+ # Set land values to True (to avoid artificially eroding the shore)
627
+ bitmap_binary[:, ~mask] = True
628
+
629
+ # Erosion (negated dilation of negated image)
630
+ bitmap_binary = ~sparse_bool_power(~bitmap_binary, sp_data, indices, indptr, R_fill)
631
+
632
+ ## Opening: Erosion then Dilation (removes small objects)
633
+
634
+ # Set land values to True (to avoid artificially eroding the shore)
635
+ bitmap_binary[:, ~mask] = True
636
+
637
+ # Erosion
638
+ bitmap_binary = ~sparse_bool_power(~bitmap_binary, sp_data, indices, indptr, R_fill)
639
+
640
+ # Dilation
641
+ bitmap_binary = sparse_bool_power(bitmap_binary, sp_data, indices, indptr, R_fill)
642
+
643
+ return bitmap_binary
644
+
645
+ # Apply the operations
646
+ data_bin = xr.apply_ufunc(
647
+ binary_open_close, data_bin, sp_data, indices, indptr, self.mask,
648
+ input_core_dims=[[self.xdim], ['sp_data'], ['indices'], ['indptr'], [self.xdim]],
649
+ output_core_dims=[[self.xdim]],
650
+ output_dtypes=[np.bool_],
651
+ vectorize=False,
652
+ dask_gufunc_kwargs={'output_sizes': {self.xdim: data_bin.sizes[self.xdim]}},
653
+ dask='parallelized'
654
+ )
655
+
656
+ else:
657
+ # Structured grid using dask-powered morphological operations
658
+ use_dask_morph = True
659
+
660
+ # Generate structuring element (disk-shaped)
661
+ y, x = np.ogrid[-R_fill:R_fill+1, -R_fill:R_fill+1]
662
+ r = x**2 + y**2
663
+ diameter = 2 * R_fill
664
+ se_kernel = r < (R_fill**2)+1
665
+
666
+ if use_dask_morph:
667
+ # Pad data to avoid edge effects
668
+ data_bin = data_bin.pad({self.ydim: diameter, self.xdim: diameter}, mode='wrap')
669
+ data_coords = data_bin.coords
670
+ data_dims = data_bin.dims
671
+
672
+ # Apply morphological operations
673
+ data_bin = binary_closing_dask(data_bin.data, structure=se_kernel[np.newaxis, :, :]) # N.B.: There may be a rearing bug in constructing the dask task graph when we extract and then re-imbed the dask array into an xarray DataArray
674
+ data_bin = binary_opening_dask(data_bin, structure=se_kernel[np.newaxis, :, :])
675
+
676
+ # Convert back to xarray.DataArray and trim padding
677
+ data_bin = xr.DataArray(data_bin, coords=data_coords, dims=data_dims)
678
+ data_bin = data_bin.isel({
679
+ self.ydim: slice(diameter, -diameter),
680
+ self.xdim: slice(diameter, -diameter)
681
+ })
682
+ else:
683
+ def binary_open_close(bitmap_binary):
684
+ """Apply binary opening and closing in one function."""
685
+ bitmap_binary_padded = np.pad(
686
+ bitmap_binary,
687
+ ((diameter, diameter), (diameter, diameter)),
688
+ mode='wrap'
689
+ )
690
+ s1 = binary_closing(bitmap_binary_padded, se_kernel, iterations=1)
691
+ s2 = binary_opening(s1, se_kernel, iterations=1)
692
+ unpadded = s2[diameter:-diameter, diameter:-diameter]
693
+ return unpadded
694
+
695
+ data_bin = xr.apply_ufunc(
696
+ binary_open_close, data_bin,
697
+ input_core_dims=[[self.ydim, self.xdim]],
698
+ output_core_dims=[[self.ydim, self.xdim]],
699
+ output_dtypes=[data_bin.dtype],
700
+ vectorize=True,
701
+ dask='parallelized'
702
+ )
703
+
704
+ # Mask out edge features from morphological operations
705
+ data_bin = data_bin.where(self.mask, drop=False, other=False)
706
+
707
+ return data_bin
708
+
709
+ def fill_time_gaps(self, data_bin):
710
+ """
711
+ Fill temporal gaps between objects.
712
+
713
+ Performs binary closing (dilation then erosion) along the time dimension
714
+ to fill small time gaps between objects.
715
+
716
+ Parameters
717
+ ----------
718
+ data_bin : xarray.DataArray
719
+ Binary data to process
720
+
721
+ Returns
722
+ -------
723
+ data_bin_filled : xarray.DataArray
724
+ Binary data with temporal gaps filled
725
+ """
726
+ if self.T_fill == 0:
727
+ return data_bin
728
+
729
+ # Create temporal structuring element
730
+ kernel_size = self.T_fill + 1 # This will then fill a maximum hole size of self.T_fill
731
+ time_kernel = np.ones(kernel_size, dtype=bool)
732
+
733
+ if self.ydim is None:
734
+ # Unstructured grid has only 1 additional dimension
735
+ time_kernel = time_kernel[:, np.newaxis]
736
+ else:
737
+ time_kernel = time_kernel[:, np.newaxis, np.newaxis]
738
+
739
+ # Pad in time to avoid edge effects
740
+ data_bin = data_bin.pad({self.timedim: kernel_size}, mode='constant', constant_values=False)
741
+
742
+ # Apply temporal closing
743
+ data_bin_dask = data_bin.data
744
+ closed_dask_array = binary_closing_dask(data_bin_dask, structure=time_kernel)
745
+
746
+ # Convert back to xarray.DataArray
747
+ data_bin_filled = xr.DataArray(
748
+ closed_dask_array,
749
+ coords=data_bin.coords,
750
+ dims=data_bin.dims,
751
+ attrs=data_bin.attrs
752
+ )
753
+
754
+ # Remove padding
755
+ data_bin_filled = data_bin_filled.isel({self.timedim: slice(kernel_size, -kernel_size)}).persist()
756
+
757
+ # Fill newly-created spatial holes
758
+ data_bin_filled = self.fill_holes(data_bin_filled, R_fill=self.R_fill//2)
759
+
760
+ return data_bin_filled
761
+
762
+ def refresh_dask_graph(self, data_bin):
763
+ """
764
+ Clear and reset the Dask graph via save/load cycle.
765
+
766
+ This is needed to work around a memory leak bug in Dask where
767
+ "Unmanaged Memory" builds up within loops.
768
+
769
+ Parameters
770
+ ----------
771
+ data_bin : xarray.DataArray
772
+ Data to refresh
773
+
774
+ Returns
775
+ -------
776
+ data_new : xarray.DataArray
777
+ Data with fresh Dask graph
778
+ """
779
+ if self.verbosity > 1:
780
+ print(' Refreshing Dask task graph...')
781
+
782
+ data_bin.name = 'temp'
783
+ data_bin.to_zarr(f'{self.scratch_dir}/marEx_temp_field.zarr', mode='w')
784
+ del data_bin
785
+ gc.collect()
786
+
787
+ data_new = xr.open_zarr(f'{self.scratch_dir}/marEx_temp_field.zarr', chunks={}).temp
788
+ return data_new
789
+
790
+ def filter_small_objects(self, data_bin):
791
+ """
792
+ Remove objects smaller than a threshold area.
793
+
794
+ Parameters
795
+ ----------
796
+ data_bin : xarray.DataArray
797
+ Binary data to filter
798
+
799
+ Returns
800
+ -------
801
+ data_bin_filtered : xarray.DataArray
802
+ Binary data with small objects removed
803
+ area_threshold : float
804
+ Area threshold used for filtering
805
+ object_areas : xarray.DataArray
806
+ Areas of all objects pre-filtering
807
+ N_objects_prefiltered : int
808
+ Number of objects before filtering
809
+ N_objects_filtered : int
810
+ Number of objects after filtering
811
+ """
812
+ # Cluster & Label Binary Data: Time-independent in 2D (i.e. no time connectivity!)
813
+ object_id_field, _, N_objects_unfiltered = self.identify_objects(data_bin, time_connectivity=False)
814
+
815
+ if self.unstructured_grid:
816
+ # Get the maximum ID to dimension arrays
817
+ # Note: identify_objects() starts at ID=0 for every time slice
818
+ max_ID = object_id_field.max().compute().item()
819
+
820
+ def count_cluster_sizes(object_id_field):
821
+ """Count the number of cells in each cluster."""
822
+ unique, counts = np.unique(object_id_field[object_id_field > 0], return_counts=True)
823
+ padded_sizes = np.zeros(max_ID, dtype=np.int32)
824
+ padded_unique = np.zeros(max_ID, dtype=np.int32)
825
+ padded_sizes[:len(counts)] = counts
826
+ padded_unique[:len(counts)] = unique
827
+ return padded_sizes, padded_unique
828
+
829
+ # Calculate cluster sizes
830
+ cluster_sizes, unique_cluster_IDs = xr.apply_ufunc(
831
+ count_cluster_sizes,
832
+ object_id_field,
833
+ input_core_dims=[[self.xdim]],
834
+ output_core_dims=[['ID'], ['ID']],
835
+ dask_gufunc_kwargs={'output_sizes': {'ID': max_ID}},
836
+ output_dtypes=(np.int32, np.int32),
837
+ vectorize=True,
838
+ dask='parallelized'
839
+ )
840
+
841
+ results = persist(cluster_sizes, unique_cluster_IDs)
842
+ cluster_sizes, unique_cluster_IDs = results
843
+
844
+ # Pre-filter tiny objects for performance
845
+ cluster_sizes_filtered_dask = cluster_sizes.where(cluster_sizes > 50).data
846
+ cluster_areas_mask = dsa.isfinite(cluster_sizes_filtered_dask)
847
+ object_areas = cluster_sizes_filtered_dask[cluster_areas_mask].compute()
848
+
849
+ # Filter based on area threshold
850
+ N_objects_unfiltered = len(object_areas)
851
+ area_threshold = np.percentile(object_areas, self.area_filter_quartile*100)
852
+ N_objects_filtered = np.sum(object_areas > area_threshold)
853
+
854
+ def filter_area_binary(cluster_IDs_0, keep_IDs_0):
855
+ """Keep only clusters above threshold area."""
856
+ keep_IDs_0 = keep_IDs_0[keep_IDs_0 > 0]
857
+ keep_where = np.isin(cluster_IDs_0, keep_IDs_0)
858
+ return keep_where
859
+
860
+ # Create filtered binary data
861
+ keep_IDs = xr.where(cluster_sizes > area_threshold, unique_cluster_IDs, 0)
862
+
863
+ data_bin_filtered = xr.apply_ufunc(
864
+ filter_area_binary,
865
+ object_id_field, keep_IDs,
866
+ input_core_dims=[[self.xdim], ['ID']],
867
+ output_core_dims=[[self.xdim]],
868
+ output_dtypes=[data_bin.dtype],
869
+ vectorize=True,
870
+ dask='parallelized'
871
+ )
872
+
873
+ object_areas = cluster_sizes # Store pre-filtered areas
874
+
875
+ else:
876
+ # Structured grid approach
877
+
878
+ # Calculate object properties including area
879
+ object_props = self.calculate_object_properties(object_id_field)
880
+ object_areas, object_ids = object_props.area, object_props.ID
881
+
882
+ # Calculate area threshold
883
+ area_threshold = np.percentile(object_areas, self.area_filter_quartile*100.0)
884
+
885
+ # Keep only objects above threshold
886
+ object_ids_keep = xr.where(object_areas >= area_threshold, object_ids, -1)
887
+ object_ids_keep[0] = -1 # Don't keep ID=0
888
+
889
+ # Create filtered binary data
890
+ data_bin_filtered = object_id_field.isin(object_ids_keep)
891
+
892
+ # Count objects after filtering
893
+ N_objects_filtered = object_ids_keep.where(object_ids_keep > 0).count().item()
894
+
895
+ return data_bin_filtered, area_threshold, object_areas, N_objects_unfiltered, N_objects_filtered
896
+
897
+ # ============================
898
+ # Object Identification Methods
899
+ # ============================
900
+
901
+ def identify_objects(self, data_bin, time_connectivity):
902
+ """
903
+ Identify connected regions in binary data.
904
+
905
+ Parameters
906
+ ----------
907
+ data_bin : xarray.DataArray
908
+ Binary data to identify objects in
909
+ time_connectivity : bool
910
+ Whether to connect objects across time
911
+
912
+ Returns
913
+ -------
914
+ object_id_field : xarray.DataArray
915
+ Field of integer IDs for each object
916
+ None : NoneType
917
+ Placeholder for compatibility with track_objects
918
+ N_objects : int
919
+ Number of objects identified
920
+ """
921
+ if self.unstructured_grid:
922
+ # The resulting ID field for unstructured grid will start at 0 for each time-slice,
923
+ # which differs from structured grid where IDs are unique across time.
924
+
925
+ if time_connectivity:
926
+ raise ValueError('Cannot automatically compute time-connectivity on unstructured grid')
927
+
928
+ # Use Union-Find (Disjoint Set Union) clustering for unstructured grid
929
+ def cluster_true_values(arr, neighbours_int):
930
+ """Cluster connected True values in binary data on unstructured grid."""
931
+ t, n = arr.shape
932
+ labels = np.full((t, n), -1, dtype=np.int32)
933
+
934
+ for i in range(t):
935
+ # Get indices of True values
936
+ true_indices = np.where(arr[i])[0]
937
+ mapping = {old_idx: new_idx for new_idx, old_idx in enumerate(true_indices)}
938
+
939
+ # Find connected components
940
+ valid_mask = (neighbours_int != -1) & arr[i][neighbours_int]
941
+ row_ind, col_ind = np.where(valid_mask)
942
+
943
+ # Map to compact indices for graph algorithm
944
+ mapped_row_ind = []
945
+ mapped_col_ind = []
946
+ for r, c in zip(neighbours_int[row_ind, col_ind], col_ind):
947
+ if r in mapping and c in mapping:
948
+ mapped_row_ind.append(mapping[r])
949
+ mapped_col_ind.append(mapping[c])
950
+
951
+ # Create graph and find connected components
952
+ graph = csr_matrix(
953
+ (np.ones(len(mapped_row_ind)), (mapped_row_ind, mapped_col_ind)),
954
+ shape=(len(true_indices), len(true_indices))
955
+ )
956
+ _, labels_true = connected_components(csgraph=graph, directed=False, return_labels=True)
957
+ labels[i, true_indices] = labels_true
958
+
959
+ return labels + 1 # Add 1 so 0 represents no object
960
+
961
+ # Apply mask and cluster
962
+ data_bin = data_bin.where(self.mask, other=False)
963
+
964
+ object_id_field = xr.apply_ufunc(
965
+ cluster_true_values,
966
+ data_bin,
967
+ self.neighbours_int,
968
+ input_core_dims=[[self.xdim], ['nv', self.xdim]],
969
+ output_core_dims=[[self.xdim]],
970
+ output_dtypes=[np.int32],
971
+ dask_gufunc_kwargs={'output_sizes': {self.xdim: data_bin.sizes[self.xdim]}},
972
+ vectorize=False,
973
+ dask='parallelized'
974
+ )
975
+
976
+ # Ensure ID = 0 on invalid regions
977
+ object_id_field = object_id_field.where(self.mask, other=0)
978
+ object_id_field = object_id_field.persist()
979
+ object_id_field = object_id_field.rename('ID_field')
980
+ N_objects = 1 # Placeholder (IDs aren't unique across time)
981
+
982
+ else: # Structured Grid
983
+ # Create connectivity kernel for labeling
984
+ neighbours = np.zeros((3, 3, 3))
985
+
986
+ if time_connectivity:
987
+ # ID objects in 3D (i.e. space & time) -- N.B. IDs are unique across time
988
+ neighbours[:, :, :] = 1 # including +-1 in time, _and also diagonal in time_ -- i.e. edges can touch
989
+ else:
990
+ # ID objects only in 2D (i.e. space) -- N.B. IDs are _not_ unique across time (i.e. each time starts at 0 again)
991
+ neighbours[1, :, :] = 1 # All 8 neighbours, but ignore time
992
+
993
+ # Cluster & label binary data
994
+ object_id_field, N_objects = label( # Apply dask-powered ndimage & persist in memory
995
+ data_bin,
996
+ structure=neighbours,
997
+ wrap_axes=(2,) # Wrap in x-direction
998
+ )
999
+ results = persist(object_id_field, N_objects)
1000
+ object_id_field, N_objects = results
1001
+
1002
+ N_objects = N_objects.compute()
1003
+
1004
+ # Convert to DataArray with same coordinates as input
1005
+ object_id_field = xr.DataArray(
1006
+ object_id_field,
1007
+ coords=data_bin.coords,
1008
+ dims=data_bin.dims,
1009
+ attrs=data_bin.attrs
1010
+ ).rename('ID_field').astype(np.int32)
1011
+
1012
+ return object_id_field, None, N_objects
1013
+
1014
+ def calculate_centroid(self, binary_mask, original_centroid=None):
1015
+ """
1016
+ Calculate object centroid, handling edge cases for periodic boundaries.
1017
+
1018
+ Parameters
1019
+ ----------
1020
+ binary_mask : numpy.ndarray
1021
+ 2D binary array where True indicates the object (dimensions are (y,x))
1022
+ original_centroid : tuple, optional
1023
+ (y_centroid, x_centroid) from regionprops_table
1024
+
1025
+ Returns
1026
+ -------
1027
+ tuple
1028
+ (y_centroid, x_centroid)
1029
+ """
1030
+ # Check if object is near either edge of x dimension
1031
+ near_left_BC = np.any(binary_mask[:, :100])
1032
+ near_right_BC = np.any(binary_mask[:, -100:])
1033
+
1034
+ if original_centroid is None:
1035
+ # Calculate y centroid from scratch
1036
+ y_indices = np.nonzero(binary_mask)[0]
1037
+ y_centroid = np.mean(y_indices)
1038
+ else:
1039
+ y_centroid = original_centroid[0]
1040
+
1041
+ # If object is near both edges, recalculate x-centroid to handle wrapping
1042
+ # N.B.: We calculate _near_ rather than touching, to catch the edge case where the object may be split and straddling the boundary !
1043
+ if near_left_BC and near_right_BC:
1044
+ # Adjust x coordinates that are near right edge
1045
+ x_indices = np.nonzero(binary_mask)[1]
1046
+ x_indices_adj = x_indices.copy()
1047
+ right_side = x_indices > binary_mask.shape[1] // 2
1048
+ x_indices_adj[right_side] -= binary_mask.shape[1]
1049
+
1050
+ x_centroid = np.mean(x_indices_adj)
1051
+ if x_centroid < 0: # Ensure centroid is positive
1052
+ x_centroid += binary_mask.shape[1]
1053
+
1054
+ elif original_centroid is None:
1055
+ # Calculate x-centroid from scratch
1056
+ x_indices = np.nonzero(binary_mask)[1]
1057
+ x_centroid = np.mean(x_indices)
1058
+
1059
+ else:
1060
+ x_centroid = original_centroid[1]
1061
+
1062
+ return (y_centroid, x_centroid)
1063
+
1064
+ def calculate_object_properties(self, object_id_field, properties=None):
1065
+ """
1066
+ Calculate properties of objects from ID field.
1067
+
1068
+ Parameters
1069
+ ----------
1070
+ object_id_field : xarray.DataArray
1071
+ Field containing object IDs
1072
+ properties : list, optional
1073
+ List of properties to calculate (defaults to ['label', 'area'])
1074
+
1075
+ Returns
1076
+ -------
1077
+ object_props : xarray.Dataset
1078
+ Dataset containing calculated properties with 'ID' dimension
1079
+ """
1080
+ # Set default properties
1081
+ if properties is None:
1082
+ properties = ['label', 'area']
1083
+
1084
+ # Ensure 'label' is included
1085
+ if 'label' not in properties:
1086
+ properties = ['label'] + properties # 'label' is actually 'ID' within regionprops
1087
+
1088
+ check_centroids = 'centroid' in properties
1089
+
1090
+ if self.unstructured_grid:
1091
+ # Compute properties on unstructured grid
1092
+
1093
+ # Convert lat/lon to radians
1094
+ lat_rad = np.radians(self.lat)
1095
+ lon_rad = np.radians(self.lon)
1096
+
1097
+ # Calculate buffer size for IDs in chunks
1098
+ max_ID = object_id_field.max().compute().item() + 1
1099
+ ID_buffer_size = int(max_ID / object_id_field[self.timedim].shape[0]) * 4
1100
+
1101
+ def object_properties_chunk(ids, lat, lon, area, buffer_IDs=True):
1102
+ """
1103
+ Calculate object properties for a chunk of data.
1104
+ Uses vectorised operations for efficiency.
1105
+ """
1106
+ # Find valid IDs
1107
+ valid_mask = ids > 0
1108
+ ids_chunk = np.unique(ids[valid_mask])
1109
+ n_ids = len(ids_chunk)
1110
+
1111
+ if n_ids == 0:
1112
+ # No objects in this chunk
1113
+ if buffer_IDs:
1114
+ result = np.zeros((3, ID_buffer_size), dtype=np.float32)
1115
+ padded_ids = np.zeros(ID_buffer_size, dtype=np.int32)
1116
+ return result, padded_ids
1117
+ else:
1118
+ result = np.zeros((3, 0), dtype=np.float32)
1119
+ padded_ids = np.array([], dtype=np.int32)
1120
+ return result, padded_ids
1121
+
1122
+ # Map IDs to consecutive indices
1123
+ mapped_indices = np.searchsorted(ids_chunk, ids[valid_mask])
1124
+
1125
+ # Pre-allocate arrays
1126
+ areas = np.zeros(n_ids, dtype=np.float32)
1127
+ weighted_x = np.zeros(n_ids, dtype=np.float32)
1128
+ weighted_y = np.zeros(n_ids, dtype=np.float32)
1129
+ weighted_z = np.zeros(n_ids, dtype=np.float32)
1130
+
1131
+ # Convert to Cartesian for centroid calculation
1132
+ cos_lat = np.cos(lat[valid_mask])
1133
+ x = cos_lat * np.cos(lon[valid_mask])
1134
+ y = cos_lat * np.sin(lon[valid_mask])
1135
+ z = np.sin(lat[valid_mask])
1136
+
1137
+ # Compute areas
1138
+ valid_areas = area[valid_mask]
1139
+ np.add.at(areas, mapped_indices, valid_areas)
1140
+
1141
+ # Compute weighted coordinates
1142
+ np.add.at(weighted_x, mapped_indices, valid_areas * x)
1143
+ np.add.at(weighted_y, mapped_indices, valid_areas * y)
1144
+ np.add.at(weighted_z, mapped_indices, valid_areas * z)
1145
+
1146
+ # Clean intermediate arrays
1147
+ del x, y, z, cos_lat, valid_areas
1148
+
1149
+ # Normalise vectors
1150
+ norm = np.sqrt(weighted_x**2 + weighted_y**2 + weighted_z**2)
1151
+ norm = np.where(norm > 0, norm, 1) # Avoid division by zero
1152
+
1153
+ weighted_x /= norm
1154
+ weighted_y /= norm
1155
+ weighted_z /= norm
1156
+
1157
+ # Convert back to lat/lon
1158
+ centroid_lat = np.degrees(np.arcsin(np.clip(weighted_z, -1, 1)))
1159
+ centroid_lon = np.degrees(np.arctan2(weighted_y, weighted_x))
1160
+
1161
+ # Fix longitude range to [-180, 180]
1162
+ centroid_lon = np.where(
1163
+ centroid_lon > 180., centroid_lon - 360.,
1164
+ np.where(centroid_lon < -180., centroid_lon + 360., centroid_lon)
1165
+ )
1166
+
1167
+ if buffer_IDs:
1168
+ # Create padded output arrays
1169
+ result = np.zeros((3, ID_buffer_size), dtype=np.float32)
1170
+ padded_ids = np.zeros(ID_buffer_size, dtype=np.int32)
1171
+
1172
+ # Fill arrays up to n_ids
1173
+ result[0, :n_ids] = areas
1174
+ result[1, :n_ids] = centroid_lat
1175
+ result[2, :n_ids] = centroid_lon
1176
+ padded_ids[:n_ids] = ids_chunk
1177
+ else:
1178
+ result = np.vstack((areas, centroid_lat, centroid_lon))
1179
+ padded_ids = ids_chunk
1180
+
1181
+ return result, padded_ids
1182
+
1183
+ # Process single time or multiple times
1184
+ if object_id_field[self.timedim].size == 1:
1185
+ props_np, ids = object_properties_chunk(
1186
+ object_id_field.values,
1187
+ lat_rad.values,
1188
+ lon_rad.values,
1189
+ self.cell_area.values,
1190
+ buffer_IDs=False
1191
+ )
1192
+ props = xr.DataArray(props_np, dims=['prop', 'out_id'])
1193
+
1194
+ else:
1195
+ # Process in parallel
1196
+ props_buffer, ids_buffer = xr.apply_ufunc(
1197
+ object_properties_chunk,
1198
+ object_id_field,
1199
+ lat_rad,
1200
+ lon_rad,
1201
+ self.cell_area,
1202
+ input_core_dims=[[self.xdim], [self.xdim], [self.xdim], [self.xdim]],
1203
+ output_core_dims=[['prop', 'out_id'], ['out_id']],
1204
+ output_dtypes=[np.float32, np.int32],
1205
+ dask_gufunc_kwargs={'output_sizes': {'prop': 3, 'out_id': ID_buffer_size}},
1206
+ vectorize=True,
1207
+ dask='parallelized'
1208
+ )
1209
+ results = persist(props_buffer, ids_buffer)
1210
+ props_buffer, ids_buffer = results
1211
+ ids_buffer = ids_buffer.compute().values.reshape(-1)
1212
+
1213
+ # Get valid IDs (non-zero)
1214
+ valid_ids_mask = ids_buffer > 0
1215
+
1216
+ # Check if we have any valid IDs before stacking
1217
+ if np.any(valid_ids_mask):
1218
+ ids = ids_buffer[valid_ids_mask]
1219
+ props = props_buffer.stack(combined=('time', 'out_id')).isel(combined=valid_ids_mask)
1220
+ else:
1221
+ # No valid IDs found
1222
+ ids = np.array([], dtype=np.int32)
1223
+ props = xr.DataArray(np.zeros((3, 0), dtype=np.float32), dims=['prop', 'out_id'])
1224
+
1225
+ # Create object properties dataset
1226
+ if len(ids) > 0:
1227
+ object_props = xr.Dataset(
1228
+ {
1229
+ 'area': ('out_id', props.isel(prop=0).data),
1230
+ 'centroid-0': ('out_id', props.isel(prop=1).data),
1231
+ 'centroid-1': ('out_id', props.isel(prop=2).data)
1232
+ },
1233
+ coords={'ID': ('out_id', ids)}
1234
+ ).set_index(out_id='ID').rename({'out_id': 'ID'})
1235
+ else:
1236
+ # Create empty dataset with correct structure
1237
+ object_props = xr.Dataset(
1238
+ {
1239
+ 'area': ('ID', []),
1240
+ 'centroid-0': ('ID', []),
1241
+ 'centroid-1': ('ID', [])
1242
+ },
1243
+ coords={'ID': []}
1244
+ )
1245
+
1246
+ else:
1247
+ # Structured grid approach
1248
+ # N.B.: These operations are simply done on a pixel grid — no cartesian conversion (therefore, polar regions are doubly biased)
1249
+
1250
+ # Define function to calculate properties for each chunk
1251
+ def object_properties_chunk(ids):
1252
+ """Calculate object properties for a chunk of data."""
1253
+ # Use regionprops_table for standard properties
1254
+ props_slice = regionprops_table(ids, properties=properties)
1255
+
1256
+ # Handle centroid calculation for objects that wrap around edges
1257
+ if check_centroids and len(props_slice['label']) > 0:
1258
+ # Get original centroids
1259
+ centroids = list(zip(props_slice['centroid-0'], props_slice['centroid-1']))
1260
+ centroids_wrapped = []
1261
+
1262
+ # Process each object
1263
+ for ID_idx, ID in enumerate(props_slice['label']):
1264
+ binary_mask = ids == ID
1265
+ centroids_wrapped.append(
1266
+ self.calculate_centroid(binary_mask, centroids[ID_idx])
1267
+ )
1268
+
1269
+ # Update centroid values
1270
+ props_slice['centroid-0'] = [c[0] for c in centroids_wrapped]
1271
+ props_slice['centroid-1'] = [c[1] for c in centroids_wrapped]
1272
+
1273
+ return props_slice
1274
+
1275
+ # Process single time or multiple times
1276
+ if object_id_field[self.timedim].size == 1:
1277
+ object_props = object_properties_chunk(object_id_field.values)
1278
+ object_props = xr.Dataset({key: (['ID'], value) for key, value in object_props.items()})
1279
+ else:
1280
+ # Run in parallel
1281
+ object_props = xr.apply_ufunc(
1282
+ object_properties_chunk,
1283
+ object_id_field,
1284
+ input_core_dims=[[self.ydim, self.xdim]],
1285
+ output_core_dims=[[]],
1286
+ output_dtypes=[object],
1287
+ vectorize=True,
1288
+ dask='parallelized'
1289
+ )
1290
+
1291
+ # Concatenate and convert to dataset
1292
+ object_props = xr.concat([
1293
+ xr.Dataset({key: (['ID'], value) for key, value in item.items()})
1294
+ for item in object_props.values
1295
+ ], dim='ID')
1296
+
1297
+ # Set ID as coordinate
1298
+ object_props = object_props.set_index(ID='label')
1299
+
1300
+ # Combine centroid components into a single variable
1301
+ if 'centroid' in properties and len(object_props.ID) > 0:
1302
+ object_props['centroid'] = xr.concat(
1303
+ [object_props['centroid-0'], object_props['centroid-1']],
1304
+ dim='component'
1305
+ )
1306
+ object_props = object_props.drop_vars(['centroid-0', 'centroid-1'])
1307
+
1308
+ return object_props
1309
+
1310
+ # ============================
1311
+ # Overlap and Tracking Methods
1312
+ # ============================
1313
+
1314
+ def check_overlap_slice(self, ids_t0, ids_next):
1315
+ """
1316
+ Find overlapping objects between two consecutive time slices.
1317
+
1318
+ Parameters
1319
+ ----------
1320
+ ids_t0 : numpy.ndarray
1321
+ Object IDs at current time
1322
+ ids_next : numpy.ndarray
1323
+ Object IDs at next time
1324
+
1325
+ Returns
1326
+ -------
1327
+ numpy.ndarray
1328
+ Array of shape (n_overlaps, 3) with [id_t0, id_next, overlap_area]
1329
+ """
1330
+ # Create masks for valid IDs
1331
+ mask_t0 = ids_t0 > 0
1332
+ mask_next = ids_next > 0
1333
+
1334
+ # Only process cells where both times have valid IDs
1335
+ combined_mask = mask_t0 & mask_next
1336
+
1337
+ if not np.any(combined_mask):
1338
+ return np.empty((0, 3), dtype=np.float32 if self.unstructured_grid else np.int32)
1339
+
1340
+ # Extract the overlapping points
1341
+ ids_t0_valid = ids_t0[combined_mask]
1342
+ ids_next_valid = ids_next[combined_mask]
1343
+
1344
+ # Create a unique identifier for each pair
1345
+ # This is faster than using np.unique with axis=1
1346
+ max_id = max(ids_t0.max(), ids_next.max() + 1).astype(np.int64)
1347
+ pair_ids = ids_t0_valid.astype(np.int64) * max_id + ids_next_valid.astype(np.int64)
1348
+
1349
+ if self.unstructured_grid:
1350
+ # Get unique pairs and their inverse indices
1351
+ unique_pairs, inverse_indices = np.unique(pair_ids, return_inverse=True)
1352
+
1353
+ # Sum areas for overlapping cells
1354
+ areas_valid = self.cell_area.values[combined_mask]
1355
+ areas = np.zeros(len(unique_pairs), dtype=np.float32)
1356
+ np.add.at(areas, inverse_indices, areas_valid)
1357
+ else:
1358
+ # Get unique pairs and their counts (pixel counts)
1359
+ unique_pairs, areas = np.unique(pair_ids, return_counts=True)
1360
+ areas = areas.astype(np.int32)
1361
+
1362
+ # Convert back to original ID pairs
1363
+ id_t0 = (unique_pairs // max_id).astype(np.int32)
1364
+ id_next = (unique_pairs % max_id).astype(np.int32)
1365
+
1366
+ # Stack results
1367
+ result = np.column_stack((id_t0, id_next, areas))
1368
+
1369
+ return result
1370
+
1371
+ def find_overlapping_objects(self, object_id_field):
1372
+ """
1373
+ Find all overlapping objects across time.
1374
+
1375
+ Parameters
1376
+ ----------
1377
+ object_id_field : xarray.DataArray
1378
+ Field containing object IDs
1379
+
1380
+ Returns
1381
+ -------
1382
+ overlap_objects_list_unique_filtered : (N x 3) numpy.ndarray
1383
+ Array of object ID pairs that overlap across time, with overlap area
1384
+ The object in the first column precedes the second column in time.
1385
+ The third column contains:
1386
+ - For structured grid: number of overlapping pixels (int32)
1387
+ - For unstructured grid: total overlapping area in m^2 (float32)
1388
+ """
1389
+ ## Check just for overlap with next time slice.
1390
+ # Keep a running list of all object IDs that overlap
1391
+ object_id_field_next = object_id_field.shift({self.timedim: -1}, fill_value=0)
1392
+
1393
+ # Calculate overlaps in parallel
1394
+ input_dims = [self.xdim] if self.unstructured_grid else [self.ydim, self.xdim]
1395
+ overlap_object_pairs_list = xr.apply_ufunc(
1396
+ self.check_overlap_slice,
1397
+ object_id_field,
1398
+ object_id_field_next,
1399
+ input_core_dims=[input_dims, input_dims],
1400
+ output_core_dims=[[]],
1401
+ vectorize=True,
1402
+ dask="parallelized",
1403
+ output_dtypes=[object]
1404
+ ).persist()
1405
+
1406
+ # Concatenate all pairs from different chunks
1407
+ all_pairs_with_areas = np.concatenate(overlap_object_pairs_list.values)
1408
+
1409
+ # Get unique pairs and their indices
1410
+ unique_pairs, inverse_indices = np.unique(all_pairs_with_areas[:, :2], axis=0, return_inverse=True)
1411
+
1412
+ # Sum the overlap areas using the inverse indices
1413
+ output_dtype = np.float32 if self.unstructured_grid else np.int32
1414
+ total_summed_areas = np.zeros(len(unique_pairs), dtype=output_dtype)
1415
+ np.add.at(total_summed_areas, inverse_indices, all_pairs_with_areas[:, 2])
1416
+
1417
+ # Stack the pairs with their summed areas
1418
+ overlap_objects_list_unique = np.column_stack((unique_pairs, total_summed_areas))
1419
+
1420
+ return overlap_objects_list_unique
1421
+
1422
+ def enforce_overlap_threshold(self, overlap_objects_list, object_props):
1423
+ """
1424
+ Filter object pairs based on overlap threshold.
1425
+
1426
+ Parameters
1427
+ ----------
1428
+ overlap_objects_list : (N x 3) numpy.ndarray
1429
+ Array of object ID pairs with overlap area
1430
+ object_props : xarray.Dataset
1431
+ Object properties including area
1432
+
1433
+ Returns
1434
+ -------
1435
+ overlap_objects_list_filtered : (M x 3) numpy.ndarray
1436
+ Filtered array of object ID pairs that meet the overlap threshold
1437
+ """
1438
+ if len(overlap_objects_list) == 0:
1439
+ return np.empty((0, 3), dtype=np.float32 if self.unstructured_grid else np.int32)
1440
+
1441
+ # Calculate overlap fractions
1442
+ areas_0 = object_props['area'].sel(ID=overlap_objects_list[:, 0]).values
1443
+ areas_1 = object_props['area'].sel(ID=overlap_objects_list[:, 1]).values
1444
+ min_areas = np.minimum(areas_0, areas_1)
1445
+ overlap_fractions = overlap_objects_list[:, 2].astype(float) / min_areas
1446
+
1447
+ # Filter by threshold
1448
+ overlap_objects_list_filtered = overlap_objects_list[overlap_fractions >= self.overlap_threshold]
1449
+
1450
+ return overlap_objects_list_filtered
1451
+
1452
+ def compute_id_time_dict(self, da, child_objects, max_objects, all_objects=True):
1453
+ """
1454
+ Generate lookup table mapping object IDs to their time index.
1455
+
1456
+ Parameters
1457
+ ----------
1458
+ da : xarray.DataArray
1459
+ Field of object IDs
1460
+ child_objects : list or array
1461
+ Object IDs to include in the dictionary
1462
+ max_objects : int
1463
+ Maximum number of objects
1464
+ all_objects : bool, default=True
1465
+ Whether to process all objects or just child_objects
1466
+
1467
+ Returns
1468
+ -------
1469
+ time_index_map : dict
1470
+ Dictionary mapping object IDs to time indices
1471
+ """
1472
+ # Estimate max objects per time
1473
+ est_objects_per_time_max = int(max_objects / da[self.timedim].shape[0] * 100)
1474
+
1475
+ def unique_pad(x):
1476
+ """Extract unique values and pad to fixed size."""
1477
+ uniq = np.unique(x)
1478
+ result = np.zeros(est_objects_per_time_max, dtype=x.dtype) # Pad output to maximum size
1479
+ result[:len(uniq)] = uniq
1480
+ return result
1481
+
1482
+ # Get unique IDs for each time slice
1483
+ input_dims = [self.xdim] if self.unstructured_grid else [self.ydim, self.xdim]
1484
+ unique_ids_by_time = xr.apply_ufunc(
1485
+ unique_pad,
1486
+ da,
1487
+ input_core_dims=[input_dims],
1488
+ output_core_dims=[['unique_values']],
1489
+ dask='parallelized',
1490
+ vectorize=True,
1491
+ dask_gufunc_kwargs={'output_sizes': {'unique_values': est_objects_per_time_max}}
1492
+ )
1493
+
1494
+ # Set up IDs to search for
1495
+ if not all_objects:
1496
+ # Just search for the specified child objects
1497
+ search_ids = xr.DataArray(
1498
+ child_objects,
1499
+ dims=['child_id'],
1500
+ coords={'child_id': child_objects}
1501
+ )
1502
+ else:
1503
+ # Search for all possible IDs
1504
+ search_ids = xr.DataArray(
1505
+ np.arange(max_objects, dtype=np.int32),
1506
+ dims=['child_id'],
1507
+ coords={'child_id': np.arange(max_objects)}
1508
+ ).chunk({'child_id': 10000}) # Chunk for better parallelism
1509
+
1510
+ # Find the first time index where each ID appears
1511
+ time_indices = ((unique_ids_by_time == search_ids)
1512
+ .any(dim=['unique_values'])
1513
+ .argmax(dim=self.timedim).compute())
1514
+
1515
+ # Convert to dictionary for fast lookup
1516
+ time_index_map = {
1517
+ int(id_val): int(idx.values)
1518
+ for id_val, idx in zip(time_indices.child_id, time_indices)
1519
+ }
1520
+
1521
+ return time_index_map
1522
+
1523
+ # ============================
1524
+ # Event Tracking Methods
1525
+ # ============================
1526
+
1527
+ def track_objects(self, data_bin):
1528
+ """
1529
+ Track objects through time to form events.
1530
+
1531
+ This is the main tracking method that handles splitting and merging of objects.
1532
+
1533
+ Parameters
1534
+ ----------
1535
+ data_bin : xarray.DataArray
1536
+ Preprocessed binary data: Field of globally unique integer IDs of each element in connected regions. ID = 0 indicates no object.
1537
+
1538
+ Returns
1539
+ -------
1540
+ split_merged_events_ds : xarray.Dataset
1541
+ Dataset containing tracked events
1542
+ merge_events : xarray.Dataset
1543
+ Dataset with merge information
1544
+ N_events : int
1545
+ Final number of events
1546
+ """
1547
+ # Identify objects at each time step
1548
+ object_id_field, _, _ = self.identify_objects(data_bin, time_connectivity=False)
1549
+ object_id_field = object_id_field.persist()
1550
+ del data_bin
1551
+ if self.verbosity > 0:
1552
+ print('Finished object identification')
1553
+
1554
+ # For unstructured grid, make objects unique across time
1555
+ if self.unstructured_grid:
1556
+ cumsum_ids = (object_id_field.max(dim=self.xdim)).cumsum(self.timedim).shift({self.timedim: 1}, fill_value=0)
1557
+ object_id_field = xr.where(object_id_field > 0, object_id_field + cumsum_ids, 0)
1558
+ object_id_field = self.refresh_dask_graph(object_id_field)
1559
+ if self.verbosity > 0:
1560
+ print(f'Finished assigning c. {cumsum_ids.max().compute().values} globally unique object IDs')
1561
+
1562
+ # Calculate object properties
1563
+ object_props = self.calculate_object_properties(object_id_field, properties=['area', 'centroid'])
1564
+ object_props = object_props.persist()
1565
+ wait(object_props)
1566
+ if self.verbosity > 0:
1567
+ print('Finished calculating object properties')
1568
+
1569
+ # Apply splitting & merging logic
1570
+ # This is the most intricate step due to non-trivial loop-wise dependencies
1571
+ # In v2.0_unstruct, this loop has been painstakingly parallelised
1572
+ split_and_merge = self.split_and_merge_objects_parallel if self.unstructured_grid else self.split_and_merge_objects
1573
+ object_id_field, object_props, overlap_objects_list, merge_events = split_and_merge(object_id_field, object_props)
1574
+ if self.verbosity > 0:
1575
+ print('Finished splitting and merging objects')
1576
+
1577
+ # Persist results (This helps avoid block-wise task fusion run_spec issues with dask)
1578
+ results = persist(object_id_field, object_props, overlap_objects_list, merge_events)
1579
+ object_id_field, object_props, overlap_objects_list, merge_events = results
1580
+
1581
+ # Cluster & rename objects to get globally unique event IDs
1582
+ split_merged_events_ds = self.cluster_rename_objects_and_props(
1583
+ object_id_field, object_props, overlap_objects_list, merge_events
1584
+ )
1585
+
1586
+ # Rechunk final output
1587
+ chunk_dict = {
1588
+ self.timedim: self.timechunks,
1589
+ 'ID': -1,
1590
+ 'component': -1,
1591
+ 'sibling_ID': -1,
1592
+ self.xdim: -1
1593
+ }
1594
+ if not self.unstructured_grid:
1595
+ chunk_dict[self.ydim] = -1
1596
+
1597
+ split_merged_events_ds = split_merged_events_ds.chunk(chunk_dict)#.persist()
1598
+ if self.verbosity > 0:
1599
+ print('Finished clustering and renaming objects into coherent consistent events')
1600
+
1601
+ # Count final number of events
1602
+ N_events = split_merged_events_ds.ID_field.max().compute().data
1603
+
1604
+ return split_merged_events_ds, merge_events, N_events
1605
+
1606
+ def cluster_rename_objects_and_props(self, object_id_field_unique, object_props, overlap_objects_list, merge_events):
1607
+ """
1608
+ Cluster the object pairs and relabel to determine final event IDs.
1609
+
1610
+ Parameters
1611
+ ----------
1612
+ object_id_field_unique : xarray.DataArray
1613
+ Field of unique object IDs. IDs must not be repeated across time.
1614
+ object_props : xarray.Dataset
1615
+ Properties of each object that also need to be relabeled.
1616
+ overlap_objects_list : (N x 2) numpy.ndarray
1617
+ Array of object ID pairs that indicate which objects are in the same event. The object in the first column precedes the second column in time.
1618
+ merge_events : xarray.Dataset
1619
+ Information about merge events
1620
+
1621
+ Returns
1622
+ -------
1623
+ split_merged_events_ds : xarray.Dataset
1624
+ Dataset with relabeled events and their properties. ID = 0 indicates no object.
1625
+ """
1626
+ ## Cluster the overlap_pairs into groups of IDs that are actually the same object
1627
+ # Get IDs from overlap pairs
1628
+ max_ID = object_id_field_unique.max().compute().values + 1
1629
+ IDs = np.arange(max_ID)
1630
+
1631
+ # Convert overlap pairs to indices
1632
+ overlap_pairs_indices = np.array([(pair[0], pair[1]) for pair in overlap_objects_list])
1633
+
1634
+ # Create a sparse matrix representation of the graph
1635
+ n = max_ID
1636
+ row_indices, col_indices = overlap_pairs_indices.T
1637
+ data = np.ones(len(overlap_pairs_indices), dtype=np.bool_)
1638
+ graph = csr_matrix((data, (row_indices, col_indices)), shape=(n, n), dtype=np.bool_)
1639
+
1640
+ # Clean up temporary arrays
1641
+ del row_indices
1642
+ del col_indices
1643
+ del data
1644
+
1645
+ # Solve the graph to determine connected components
1646
+ num_components, component_IDs = connected_components(csgraph=graph, directed=False, return_labels=True)
1647
+
1648
+ del graph
1649
+
1650
+ # Group IDs by their component
1651
+ ID_clusters = [[] for _ in range(num_components)]
1652
+ for ID, component_ID in zip(IDs, component_IDs):
1653
+ ID_clusters[component_ID].append(ID)
1654
+
1655
+ ## ID_clusters now is a list of lists of equivalent object IDs that have been tracked across time
1656
+ # We now need to replace all IDs in object_id_field_unique that match the equivalent_IDs with the list index: This is the new/final ID field.
1657
+
1658
+ # Create mapping from original IDs to cluster indices
1659
+ min_int32 = np.iinfo(np.int32).min
1660
+ max_old_ID = object_id_field_unique.max().compute().data
1661
+ ID_to_cluster_index_array = np.full(max_old_ID + 1, min_int32, dtype=np.int32)
1662
+
1663
+ # Fill the lookup array
1664
+ for index, cluster in enumerate(ID_clusters):
1665
+ for ID in cluster:
1666
+ ID_to_cluster_index_array[ID] = np.int32(index) # Because these are the connected IDs, there are many fewer!
1667
+ # ID = 0 is still invalid/no object
1668
+
1669
+ # Convert to DataArray for apply_ufunc
1670
+ # N.B.: **Need to pass da into apply_ufunc, otherwise it doesn't manage the memory correctly with large shared-mem numpy arrays**
1671
+ ID_to_cluster_index_da = xr.DataArray(
1672
+ ID_to_cluster_index_array,
1673
+ dims='ID',
1674
+ coords={'ID': np.arange(max_old_ID + 1)}
1675
+ )
1676
+
1677
+ def map_IDs_to_indices(block, ID_to_cluster_index_array):
1678
+ """Map original IDs to cluster indices."""
1679
+ mask = block > 0
1680
+ new_block = np.zeros_like(block, dtype=np.int32)
1681
+ new_block[mask] = ID_to_cluster_index_array[block[mask]]
1682
+ return new_block
1683
+
1684
+ # Apply the mapping
1685
+ input_dims = [self.xdim] if self.unstructured_grid else [self.ydim, self.xdim]
1686
+ split_merged_relabeled_object_id_field = xr.apply_ufunc(
1687
+ map_IDs_to_indices,
1688
+ object_id_field_unique,
1689
+ ID_to_cluster_index_da,
1690
+ input_core_dims=[input_dims, ['ID']],
1691
+ output_core_dims=[input_dims],
1692
+ vectorize=True,
1693
+ dask="parallelized",
1694
+ output_dtypes=[np.int32]
1695
+ ).persist()
1696
+
1697
+ ## Relabel the object_props to match the new IDs (and add time dimension)
1698
+
1699
+ max_new_ID = num_components + 1 # New IDs range from 0 to max_new_ID
1700
+ new_ids = np.arange(1, max_new_ID+1, dtype=np.int32)
1701
+
1702
+ # Create new object_props dataset
1703
+ object_props_extended = xr.Dataset(coords={
1704
+ 'ID': new_ids,
1705
+ self.timedim: object_id_field_unique[self.timedim]
1706
+ })
1707
+
1708
+ # Create mapping from new IDs to the original IDs _at the corresponding time_
1709
+ valid_new_ids = (split_merged_relabeled_object_id_field > 0)
1710
+ original_ids_field = object_id_field_unique.where(valid_new_ids)
1711
+ new_ids_field = split_merged_relabeled_object_id_field.where(valid_new_ids)
1712
+
1713
+ if not self.unstructured_grid:
1714
+ original_ids_field = original_ids_field.stack(z=(self.ydim, self.xdim), create_index=False)
1715
+ new_ids_field = new_ids_field.stack(z=(self.ydim, self.xdim), create_index=False)
1716
+
1717
+ new_id_to_idx = {id_val: idx for idx, id_val in enumerate(new_ids)}
1718
+
1719
+ def process_timestep(orig_ids, new_ids_t):
1720
+ """Process a single timestep to create ID mapping."""
1721
+ result = np.zeros(len(new_id_to_idx), dtype=np.int32)
1722
+
1723
+ valid_mask = new_ids_t > 0
1724
+
1725
+ # Get valid points for this timestep
1726
+ if not valid_mask.any():
1727
+ return result
1728
+
1729
+ orig_valid = orig_ids[valid_mask]
1730
+ new_valid = new_ids_t[valid_mask]
1731
+
1732
+ if len(orig_valid) == 0:
1733
+ return result
1734
+
1735
+ unique_pairs = np.unique(np.column_stack((orig_valid, new_valid)), axis=0)
1736
+
1737
+ # Create mapping
1738
+ for orig_id, new_id in unique_pairs:
1739
+ if new_id in new_id_to_idx:
1740
+ result[new_id_to_idx[new_id]] = orig_id
1741
+
1742
+ return result
1743
+
1744
+ # Process in parallel
1745
+ input_dim = ['ncells'] if self.unstructured_grid else ['z']
1746
+ global_id_mapping = xr.apply_ufunc(
1747
+ process_timestep,
1748
+ original_ids_field,
1749
+ new_ids_field,
1750
+ input_core_dims=[input_dim, input_dim],
1751
+ output_core_dims=[['ID']],
1752
+ vectorize=True,
1753
+ dask='parallelized',
1754
+ output_dtypes=[np.int32],
1755
+ dask_gufunc_kwargs={'output_sizes': {'ID': len(new_ids)}}
1756
+ ).assign_coords(ID=new_ids).compute()
1757
+
1758
+ # Store original ID mapping
1759
+ object_props_extended['global_ID'] = global_id_mapping
1760
+ # Post-condition: Now, e.g. global_id_mapping.sel(ID=10) --> Given the new ID (10), returns corresponding original_id at every time
1761
+
1762
+ # Transfer all properties from original object_props
1763
+ dummy = object_props.isel(ID=0) * np.nan # Add vale of ID = 0 to this coordinate ID
1764
+ object_props = xr.concat([dummy.assign_coords(ID=0), object_props], dim='ID')
1765
+
1766
+ for var_name in object_props.data_vars:
1767
+ temp = (object_props[var_name]
1768
+ .sel(ID=global_id_mapping.rename({'ID':'new_id'}))
1769
+ .drop_vars('ID').rename({'new_id':'ID'}))
1770
+
1771
+ if var_name == 'ID':
1772
+ temp = temp.astype(np.int32)
1773
+ else:
1774
+ temp = temp.astype(np.float32)
1775
+
1776
+ object_props_extended[var_name] = temp
1777
+
1778
+ ## Map the merge_events using the old IDs to be from dimensions (merge_ID, parent_idx)
1779
+ # --> new merge_ledger with dimensions (time, ID, sibling_ID)
1780
+ # i.e. for each merge_ID --> merge_parent_IDs gives the old IDs --> map to new ID using ID_to_cluster_index_da
1781
+ # --> merge_time
1782
+
1783
+ old_parent_IDs = xr.where(merge_events.parent_IDs > 0, merge_events.parent_IDs, 0)
1784
+ new_IDs_parents = ID_to_cluster_index_da.sel(ID=old_parent_IDs)
1785
+
1786
+ # Replace the coordinate merge_ID in new_IDs_parents with merge_time. merge_events.merge_time gives merge_time for each merge_ID
1787
+ new_IDs_parents_t = (new_IDs_parents
1788
+ .assign_coords({'merge_time': merge_events.merge_time})
1789
+ .drop_vars('ID')
1790
+ .swap_dims({'merge_ID': 'merge_time'})
1791
+ .persist())
1792
+
1793
+ # Map new_IDs_parents_t into a new data array with dimensions time, ID, and sibling_ID
1794
+ merge_ledger = (xr.full_like(global_id_mapping, fill_value=-1)
1795
+ .chunk({self.timedim: self.timechunks})
1796
+ .expand_dims({'sibling_ID': new_IDs_parents_t.parent_idx.shape[0]})
1797
+ .copy())
1798
+
1799
+ # Wrapper for processing/mapping mergers in parallel
1800
+ def process_time_group(time_block, IDs_data, IDs_coords):
1801
+ """Process all mergers for a single block of timesteps."""
1802
+ result = xr.full_like(time_block, -1)
1803
+
1804
+ # Get unique times in this block
1805
+ unique_times = np.unique(time_block[self.timedim])
1806
+
1807
+ for time_val in unique_times:
1808
+ # Get IDs for this time
1809
+ time_mask = IDs_coords['merge_time'] == time_val
1810
+ if not np.any(time_mask):
1811
+ continue
1812
+
1813
+ IDs_at_time = IDs_data[time_mask]
1814
+
1815
+ # Handle single merger case
1816
+ if IDs_at_time.ndim == 1:
1817
+ valid_mask = IDs_at_time > 0
1818
+ if np.any(valid_mask):
1819
+ # Create expanded array for sibling_ID dimension
1820
+ expanded_IDs = np.broadcast_to(
1821
+ IDs_at_time,
1822
+ (len(time_block.sibling_ID), len(IDs_at_time))
1823
+ )
1824
+ result.loc[{self.timedim: time_val, 'ID': IDs_at_time[valid_mask]}] = expanded_IDs[:, valid_mask]
1825
+
1826
+ # Handle multiple mergers case
1827
+ else:
1828
+ for merger_IDs in IDs_at_time:
1829
+ valid_mask = merger_IDs > 0
1830
+ if np.any(valid_mask):
1831
+ expanded_IDs = np.broadcast_to(
1832
+ merger_IDs,
1833
+ (len(time_block.sibling_ID), len(merger_IDs))
1834
+ )
1835
+ result.loc[{self.timedim: time_val, 'ID': merger_IDs[valid_mask]}] = expanded_IDs[:, valid_mask]
1836
+
1837
+ return result
1838
+
1839
+ # Map blocks in parallel
1840
+ merge_ledger = xr.map_blocks(
1841
+ process_time_group,
1842
+ merge_ledger,
1843
+ args=(new_IDs_parents_t.values, new_IDs_parents_t.coords),
1844
+ template=merge_ledger
1845
+ )
1846
+
1847
+ # Format merge ledger
1848
+ merge_ledger = merge_ledger.rename('merge_ledger').transpose(self.timedim, 'ID', 'sibling_ID').persist()
1849
+
1850
+ # For structured grid, convert centroid from pixel to lat/lon
1851
+ if not self.unstructured_grid:
1852
+ y_values = xr.DataArray(
1853
+ split_merged_relabeled_object_id_field[self.ydim].values,
1854
+ coords=[np.arange(len(split_merged_relabeled_object_id_field[self.ydim]))],
1855
+ dims=['pixels']
1856
+ )
1857
+ x_values = xr.DataArray(
1858
+ split_merged_relabeled_object_id_field[self.xdim].values,
1859
+ coords=[np.arange(len(split_merged_relabeled_object_id_field[self.xdim]))],
1860
+ dims=['pixels']
1861
+ )
1862
+
1863
+ object_props_extended['centroid'] = xr.concat([
1864
+ y_values.interp(pixels=object_props_extended['centroid'].sel(component=0)),
1865
+ x_values.interp(pixels=object_props_extended['centroid'].sel(component=1))
1866
+ ], dim='component')
1867
+
1868
+ object_props_extended = object_props_extended.drop_vars('pixels')
1869
+
1870
+ # Add start and end time indices for each ID
1871
+ valid_presence = object_props_extended['global_ID'] > 0 # i.e. where there is valid data
1872
+
1873
+ object_props_extended['presence'] = valid_presence
1874
+ object_props_extended['time_start'] = valid_presence.time[valid_presence.argmax(dim=self.timedim)]
1875
+ object_props_extended['time_end'] = valid_presence.time[
1876
+ (valid_presence.sizes[self.timedim] - 1) - (valid_presence[::-1]).argmax(dim=self.timedim)
1877
+ ]
1878
+
1879
+ # Combine all components into final dataset
1880
+ split_merged_relabeled_events_ds = xr.merge([
1881
+ split_merged_relabeled_object_id_field.rename('ID_field'),
1882
+ object_props_extended,
1883
+ merge_ledger
1884
+ ])
1885
+
1886
+ # Remove the last ID -- it is all 0s
1887
+ return split_merged_relabeled_events_ds.isel(ID=slice(0, -1))
1888
+
1889
+ # ============================
1890
+ # Splitting and Merging Methods
1891
+ # ============================
1892
+
1893
+ def split_and_merge_objects(self, object_id_field_unique, object_props):
1894
+ """
1895
+ Implement object splitting and merging logic.
1896
+
1897
+ This identifies and processes cases where objects split or merge over time,
1898
+ creating new object IDs as needed.
1899
+
1900
+ Parameters
1901
+ ----------
1902
+ object_id_field_unique : xarray.DataArray
1903
+ Field of unique object IDs
1904
+ object_props : xarray.Dataset
1905
+ Properties of each object
1906
+
1907
+ Returns
1908
+ -------
1909
+ tuple
1910
+ (object_id_field, object_props, overlap_objects_list, merge_events)
1911
+ """
1912
+ # Find overlapping objects
1913
+ overlap_objects_list = self.find_overlapping_objects(object_id_field_unique) # List object pairs that overlap by at least overlap_threshold percent
1914
+ overlap_objects_list = self.enforce_overlap_threshold(overlap_objects_list, object_props)
1915
+ if self.verbosity > 0:
1916
+ print('Finished finding overlapping objects')
1917
+
1918
+ # Initialise merge tracking lists
1919
+ merge_times = [] # When the merge occurred
1920
+ merge_child_ids = [] # Resulting child ID
1921
+ merge_parent_ids = [] # List of parent IDs that merged
1922
+ merge_areas = [] # Areas of overlap
1923
+ next_new_id = object_props.ID.max().item() + 1 # Start new IDs after highest existing ID
1924
+
1925
+ # Find children (t+1 / RHS) that appear multiple times (merging objects) --> Indicates there are 2+ Parent Objects...
1926
+ unique_children, children_counts = np.unique(overlap_objects_list[:, 1], return_counts=True)
1927
+ merging_objects = unique_children[children_counts > 1]
1928
+
1929
+ # Pre-compute time indices for each child object
1930
+ time_index_map = self.compute_id_time_dict(object_id_field_unique, merging_objects, next_new_id)
1931
+ Nx = object_id_field_unique[self.xdim].size
1932
+
1933
+ # Group objects by time-chunk for efficient processing
1934
+ # Pre-condition: Object IDs should be monotonically increasing in time...
1935
+ chunk_boundaries = np.cumsum([0] + list(object_id_field_unique.chunks[0]))
1936
+ objects_by_chunk = {}
1937
+ # Ensure that objects_by_chunk has entry for every key
1938
+ for chunk_idx in range(len(object_id_field_unique.chunks[0])):
1939
+ objects_by_chunk.setdefault(chunk_idx, [])
1940
+
1941
+ object_id_field_unique = object_id_field_unique.persist()
1942
+
1943
+ # Assign objects to chunks
1944
+ for object_id in merging_objects:
1945
+ chunk_idx = np.searchsorted(chunk_boundaries, time_index_map[object_id], side='right') - 1
1946
+ objects_by_chunk.setdefault(chunk_idx, []).append(object_id)
1947
+
1948
+ future_chunk_merges = []
1949
+ updated_chunks = []
1950
+
1951
+ # Process each time chunk
1952
+ for chunk_idx, chunk_objects in objects_by_chunk.items():
1953
+ # We do this to avoid repetetively re-computing and injecting tiny changes into the full dask-backed DataArray object_id_field_unique
1954
+
1955
+ # Extract and load an entire chunk into memory
1956
+ chunk_start = sum(object_id_field_unique.chunks[0][:chunk_idx])
1957
+ chunk_end = chunk_start + object_id_field_unique.chunks[0][chunk_idx] + 1 # We also want access to the object_id_time_p1... But need to remember to remove the last time later
1958
+
1959
+ chunk_data = object_id_field_unique.isel({self.timedim: slice(chunk_start, chunk_end)}).compute()
1960
+
1961
+ # Create a working queue of objects to process
1962
+ objects_to_process = chunk_objects.copy()
1963
+ # Combine only the future_chunk_merges that don't already appear in objects_to_process
1964
+ objects_to_process = objects_to_process + [
1965
+ object_id for object_id in future_chunk_merges
1966
+ if object_id not in objects_to_process
1967
+ ] # First, assess the new objects from the end of the previous chunk...
1968
+ future_chunk_merges = []
1969
+
1970
+ # Process each object in this chunk
1971
+ while objects_to_process: # Process until queue is empty
1972
+ child_id = objects_to_process.pop(0)
1973
+
1974
+ # Get time index and data slices
1975
+ child_time_idx = time_index_map[child_id]
1976
+ relative_time_idx = child_time_idx - chunk_start
1977
+
1978
+ object_id_time = chunk_data.isel({self.timedim: relative_time_idx})
1979
+ try:
1980
+ object_id_time_p1 = chunk_data.isel({self.timedim: relative_time_idx+1})
1981
+ except:
1982
+ # Last chunk
1983
+ object_id_time_p1 = xr.full_like(object_id_time, 0)
1984
+
1985
+ # Get previous timestep
1986
+ if relative_time_idx-1 >= 0:
1987
+ object_id_time_m1 = chunk_data.isel({self.timedim: relative_time_idx-1})
1988
+ elif updated_chunks:
1989
+ # Get the last time slice from the previous chunk (stored in updated_chunks)
1990
+ _, _, last_chunk_data = updated_chunks[-1]
1991
+ object_id_time_m1 = last_chunk_data[-1]
1992
+ else:
1993
+ object_id_time_m1 = xr.full_like(object_id_time, 0)
1994
+
1995
+ # Get mask of child object
1996
+ child_mask_2d = (object_id_time == child_id).values
1997
+
1998
+ # Find all pairs involving this child
1999
+ child_mask = overlap_objects_list[:, 1] == child_id
2000
+ child_where = np.where(overlap_objects_list[:, 1] == child_id)[0]
2001
+ merge_group = overlap_objects_list[child_mask]
2002
+
2003
+ # Get parent objects (LHS) that overlap with this child object
2004
+ parent_ids = merge_group[:, 0]
2005
+ num_parents = len(parent_ids)
2006
+
2007
+ # Create new IDs for the other half of the child object & record in the merge ledger
2008
+ new_object_id = np.arange(next_new_id, next_new_id + (num_parents - 1), dtype=np.int32)
2009
+ next_new_id += num_parents - 1
2010
+
2011
+ # Replace the 2nd+ child in the overlap objects list with the new child ID
2012
+ overlap_objects_list[child_where[1:], 1] = new_object_id
2013
+ child_ids = np.concatenate((np.array([child_id]), new_object_id))
2014
+
2015
+ # Record merge event
2016
+ merge_times.append(chunk_data.isel({self.timedim: relative_time_idx}).time.values)
2017
+ merge_child_ids.append(child_ids)
2018
+ merge_parent_ids.append(parent_ids)
2019
+ merge_areas.append(overlap_objects_list[child_mask, 2])
2020
+
2021
+ ## Relabel the Original Child Object ID Field to account for the New ID:
2022
+ # Get parent centroids for partitioning
2023
+ parent_centroids = object_props.sel(ID=parent_ids).centroid.values.T
2024
+
2025
+ # Partition the child object based on parent associations
2026
+ if self.nn_partitioning:
2027
+ # Nearest-neighbor partitioning
2028
+ # --> For every (Original) Child Cell in the ID Field, Find the closest (t-1) Parent _Cell_
2029
+ if self.unstructured_grid:
2030
+ # Prepare parent masks
2031
+ parent_masks = np.zeros((len(parent_ids), object_id_time.shape[0]), dtype=bool)
2032
+ for idx, parent_id in enumerate(parent_ids):
2033
+ parent_masks[idx] = (object_id_time_m1 == parent_id).values
2034
+
2035
+ # Calculate maximum search distance
2036
+ max_area = np.max(object_props.sel(ID=parent_ids).area.values) / self.mean_cell_area
2037
+ max_distance = int(np.sqrt(max_area) * 2.0)
2038
+
2039
+ # Use optimised unstructured partitioning
2040
+ new_labels = partition_nn_unstructured(
2041
+ child_mask_2d,
2042
+ parent_masks,
2043
+ child_ids,
2044
+ parent_centroids,
2045
+ self.neighbours_int.values,
2046
+ self.lat.values, # Need to pass these as NumPy arrays for JIT compatibility
2047
+ self.lon.values,
2048
+ max_distance=max(max_distance, 20)*2 # Set minimum threshold, in cells
2049
+ )
2050
+ else:
2051
+ # Prepare parent masks for structured grid
2052
+ parent_masks = np.zeros((len(parent_ids), object_id_time.shape[0], object_id_time.shape[1]), dtype=bool)
2053
+ for idx, parent_id in enumerate(parent_ids):
2054
+ parent_masks[idx] = (object_id_time_m1 == parent_id).values
2055
+
2056
+ # Calculate maximum search distance
2057
+ max_area = np.max(object_props.sel(ID=parent_ids).area.values)
2058
+ max_distance = int(np.sqrt(max_area) * 2.0) # Use 2x the max blob radius
2059
+
2060
+ # Use optimised structured grid partitioning
2061
+ new_labels = partition_nn_grid(
2062
+ child_mask_2d,
2063
+ parent_masks,
2064
+ child_ids,
2065
+ parent_centroids,
2066
+ Nx,
2067
+ max_distance=max(max_distance, 20) # Set minimum threshold, in cells
2068
+ )
2069
+
2070
+ else:
2071
+ # Centroid-based partitioning
2072
+ # --> For every (Original) Child Cell in the ID Field, Find the closest (t-1) Parent _Centroid_
2073
+ if self.unstructured_grid:
2074
+ new_labels = partition_centroid_unstructured(
2075
+ child_mask_2d,
2076
+ parent_centroids,
2077
+ child_ids,
2078
+ self.lat.values,
2079
+ self.lon.values
2080
+ )
2081
+ else:
2082
+ # Calculate distances to each parent centroid
2083
+ distances = wrapped_euclidian_parallel(child_mask_2d, parent_centroids, Nx)
2084
+
2085
+ # Assign based on closest parent
2086
+ new_labels = child_ids[np.argmin(distances, axis=1)]
2087
+
2088
+ ## Update values in child_time_idx and assign the updated slice back to the original DataArray
2089
+ temp = np.zeros_like(object_id_time)
2090
+ temp[child_mask_2d] = new_labels
2091
+ object_id_time = object_id_time.where(~child_mask_2d, temp)
2092
+ chunk_data[{self.timedim: relative_time_idx}] = object_id_time
2093
+
2094
+ # Add new entries to time_index_map for each of new_object_id corresponding to the current time index
2095
+ time_index_map.update({new_id: child_time_idx for new_id in new_object_id})
2096
+
2097
+ # Update the Properties of the N Children Objects
2098
+ new_child_props = self.calculate_object_properties(object_id_time, properties=['area', 'centroid'])
2099
+
2100
+ # Update the object_props DataArray: (but first, check if the original children still exists)
2101
+ if child_id in new_child_props.ID:
2102
+ # Update existing entry
2103
+ object_props.loc[dict(ID=child_id)] = new_child_props.sel(ID=child_id)
2104
+ else: # Delete child_id: The object has split/morphed such that it doesn't get a partition of this child...
2105
+ object_props = object_props.drop_sel(ID=child_id) # N.B.: This means that the IDs are no longer continuous...
2106
+ if self.verbosity > 0:
2107
+ print(f"Deleted child_id {child_id} because parents have split/morphed")
2108
+
2109
+ # Add the properties for the N-1 other new child ID
2110
+ new_object_ids_still = new_child_props.ID.where(
2111
+ new_child_props.ID.isin(new_object_id), drop=True
2112
+ ).ID
2113
+ object_props = xr.concat([object_props, new_child_props.sel(ID=new_object_ids_still)], dim='ID')
2114
+
2115
+ missing_ids = set(new_object_id) - set(new_object_ids_still.values)
2116
+ if len(missing_ids) > 0 and self.verbosity > 0:
2117
+ print(f"Missing newly created child_ids {missing_ids} because parents have split/morphed in the meantime...")
2118
+
2119
+ ## Finally, Re-assess all of the Parent IDs (LHS) equal to the (original) child_id
2120
+
2121
+ # Look at the overlap IDs between the original child_id and the next time-step, and also the new_object_id and the next time-step
2122
+ new_overlaps = self.check_overlap_slice(object_id_time.values, object_id_time_p1.values)
2123
+ new_child_overlaps_list = new_overlaps[(new_overlaps[:, 0] == child_id) | np.isin(new_overlaps[:, 0], new_object_id)]
2124
+ new_child_overlaps_list = self.enforce_overlap_threshold(new_child_overlaps_list, object_props)
2125
+
2126
+ # Replace the lines in the overlap_objects_list where (original) child_id is on the LHS, with these new pairs in new_child_overlaps_list
2127
+ child_mask_LHS = overlap_objects_list[:, 0] == child_id
2128
+ overlap_objects_list = np.concatenate([
2129
+ overlap_objects_list[~child_mask_LHS],
2130
+ new_child_overlaps_list
2131
+ ])
2132
+
2133
+ ## Finally, _FINALLY_, we need to ensure that of the new children objects we made, they only overlap with their respective parent...
2134
+ new_unique_children, new_children_counts = np.unique(
2135
+ new_child_overlaps_list[:, 1],
2136
+ return_counts=True
2137
+ )
2138
+ new_merging_objects = new_unique_children[new_children_counts > 1]
2139
+
2140
+ if new_merging_objects.size > 0:
2141
+ if relative_time_idx + 1 < chunk_data.sizes[self.timedim] - 1: # If there is a next time-step in this chunk
2142
+ # Add to current queue if in this chunk
2143
+ for new_child_id in new_merging_objects:
2144
+ if new_child_id not in objects_to_process: # We aren't already going to assess this object
2145
+ objects_to_process.insert(0, new_child_id)
2146
+ else: # This is out of our current jurisdiction: Defer this reassessment to the beginning of the next chunk
2147
+ # Add to next chunk's queue
2148
+ future_chunk_merges.extend(new_merging_objects)
2149
+
2150
+ # Store the processed chunk
2151
+ updated_chunks.append((chunk_start, chunk_end-1, chunk_data[:(chunk_end-1-chunk_start)]))
2152
+
2153
+ if chunk_idx % 10 == 0 and self.verbosity > 0:
2154
+ print(f"Processing splitting and merging in chunk {chunk_idx} of {len(objects_by_chunk)}")
2155
+
2156
+ # Periodically update main array to manage memory
2157
+ if len(updated_chunks) > 1: # Keep the last chunk for potential object_id_time_m1 reference
2158
+ for start, end, chunk_data in updated_chunks[:-1]:
2159
+ object_id_field_unique[{self.timedim: slice(start, end)}] = chunk_data
2160
+ updated_chunks = updated_chunks[-1:] # Keep only the last chunk
2161
+ object_id_field_unique = object_id_field_unique.persist()
2162
+
2163
+ # Apply final chunk updates
2164
+ for start, end, chunk_data in updated_chunks:
2165
+ object_id_field_unique[{self.timedim: slice(start, end)}] = chunk_data
2166
+ object_id_field_unique = object_id_field_unique.persist()
2167
+
2168
+ # Process merge events into a dataset
2169
+ max_parents = max(len(ids) for ids in merge_parent_ids)
2170
+ max_children = max(len(ids) for ids in merge_child_ids)
2171
+
2172
+ # Convert lists to padded numpy arrays
2173
+ parent_ids_array = np.full((len(merge_parent_ids), max_parents), -1, dtype=np.int32)
2174
+ child_ids_array = np.full((len(merge_child_ids), max_children), -1, dtype=np.int32)
2175
+ overlap_areas_array = np.full((len(merge_areas), max_parents), -1, dtype=np.int32)
2176
+
2177
+ for i, parents in enumerate(merge_parent_ids):
2178
+ parent_ids_array[i, :len(parents)] = parents
2179
+
2180
+ for i, children in enumerate(merge_child_ids):
2181
+ child_ids_array[i, :len(children)] = children
2182
+
2183
+ for i, areas in enumerate(merge_areas):
2184
+ overlap_areas_array[i, :len(areas)] = areas
2185
+
2186
+ # Create merge events dataset
2187
+ merge_events = xr.Dataset(
2188
+ {
2189
+ 'parent_IDs': (('merge_ID', 'parent_idx'), parent_ids_array),
2190
+ 'child_IDs': (('merge_ID', 'child_idx'), child_ids_array),
2191
+ 'overlap_areas': (('merge_ID', 'parent_idx'), overlap_areas_array),
2192
+ 'merge_time': ('merge_ID', merge_times),
2193
+ 'n_parents': ('merge_ID', np.array([len(p) for p in merge_parent_ids], dtype=np.int8)),
2194
+ 'n_children': ('merge_ID', np.array([len(c) for c in merge_child_ids], dtype=np.int8))
2195
+ },
2196
+ attrs={
2197
+ 'fill_value': -1
2198
+ }
2199
+ )
2200
+
2201
+ object_props = object_props.persist()
2202
+
2203
+ return (object_id_field_unique,
2204
+ object_props,
2205
+ overlap_objects_list[:, :2],
2206
+ merge_events)
2207
+
2208
+
2209
+ def split_and_merge_objects_parallel(self, object_id_field_unique, object_props):
2210
+ """
2211
+ Optimised parallel implementation of object splitting and merging.
2212
+
2213
+ This version is specifically designed for unstructured grids with more efficient
2214
+ memory handling and better parallelism than the standard split_and_merge_objects
2215
+ method. It processes data in chunks, handles merging events, and efficiently
2216
+ updates object IDs.
2217
+
2218
+ Parameters
2219
+ ----------
2220
+ object_id_field_unique : xarray.DataArray
2221
+ Field of unique object IDs
2222
+ object_props : xarray.Dataset
2223
+ Properties of each object
2224
+
2225
+ Returns
2226
+ -------
2227
+ tuple
2228
+ (object_id_field, object_props, overlap_objects_list, merge_events)
2229
+ """
2230
+
2231
+ # Constants for memory allocation
2232
+ MAX_MERGES = 20 # Maximum number of merges per timestep
2233
+ MAX_PARENTS = 10 # Maximum number of parents per merge
2234
+ MAX_CHILDREN = MAX_PARENTS
2235
+
2236
+ def process_chunk(chunk_data_m1_full, chunk_data_p1_full, merging_objects, next_id_start, lat, lon, area, neighbours_int):
2237
+ """
2238
+ Process a single chunk of merging objects.
2239
+
2240
+ This function handles the complex batch processing of splitting and merging objects
2241
+ across timesteps within a single chunk. It finds overlapping objects, determines
2242
+ parent-child relationships, and creates new IDs as needed.
2243
+
2244
+ Parameters
2245
+ ----------
2246
+ chunk_data_m1_full : numpy.ndarray
2247
+ Data from previous timestep (t-1) and current timestep (t)
2248
+ chunk_data_p1_full : numpy.ndarray
2249
+ Data from next timestep (t+1)
2250
+ merging_objects : (n_time, max_merges) numpy.ndarray
2251
+ IDs of objects to process
2252
+ next_id_start : (n_time, max_merges) numpy.ndarray
2253
+ Starting ID values for new objects
2254
+ lat, lon : numpy.ndarray
2255
+ Latitude/longitude arrays
2256
+ area : numpy.ndarray
2257
+ Cell area array
2258
+ neighbours_int : numpy.ndarray
2259
+ Neighbor connectivity array
2260
+
2261
+ Returns
2262
+ -------
2263
+ tuple
2264
+ Contains merge events, object updates, and newly created objects
2265
+ """
2266
+
2267
+ ## Fix Broadcasted dimensions of inputs:
2268
+ # Remove extra dimension if present while preserving time chunks
2269
+ # N.B.: This is a weird artefact/choice of xarray apply_ufunc broadcasting... (i.e. 'nv' dimension gets injected into all the other arrays!)
2270
+
2271
+ chunk_data_m1 = chunk_data_m1_full.squeeze()[0].astype(np.int32).copy()
2272
+ chunk_data = chunk_data_m1_full.squeeze()[1].astype(np.int32).copy()
2273
+ del chunk_data_m1_full # Free memory immediately
2274
+ chunk_data_p1 = chunk_data_p1_full.squeeze().astype(np.int32).copy()
2275
+ del chunk_data_p1_full
2276
+
2277
+ # Extract and prepare input arrays
2278
+ lat = lat.squeeze().astype(np.float32)
2279
+ lon = lon.squeeze().astype(np.float32)
2280
+ area = area.squeeze().astype(np.float32)
2281
+ next_id_start = next_id_start.squeeze()
2282
+
2283
+ # Handle neighbours_int with correct dimensions (nv, ncells)
2284
+ neighbours_int = neighbours_int.squeeze()
2285
+ if neighbours_int.shape[1] != lat.shape[0]:
2286
+ neighbours_int = neighbours_int.T
2287
+
2288
+ # Handle multiple merging objects - ensure proper dimensionality
2289
+ merging_objects = merging_objects.squeeze()
2290
+ if merging_objects.ndim == 1:
2291
+ merging_objects = merging_objects[:, None] # Add dimension for max_merges
2292
+
2293
+ # Pre-convert lat/lon to Cartesian coordinates for efficiency
2294
+ x = (np.cos(np.radians(lat)) * np.cos(np.radians(lon))).astype(np.float32)
2295
+ y = (np.cos(np.radians(lat)) * np.sin(np.radians(lon))).astype(np.float32)
2296
+ z = np.sin(np.radians(lat)).astype(np.float32)
2297
+
2298
+ # Pre-allocate output arrays
2299
+ n_time = chunk_data_p1.shape[0]
2300
+ n_points = chunk_data_p1.shape[1]
2301
+
2302
+ merge_child_ids = np.full((n_time, MAX_MERGES, MAX_PARENTS), -1, dtype=np.int32)
2303
+ merge_parent_ids = np.full((n_time, MAX_MERGES, MAX_PARENTS), -1, dtype=np.int32)
2304
+ merge_areas = np.full((n_time, MAX_MERGES, MAX_PARENTS), -1, dtype=np.float32)
2305
+ merge_counts = np.zeros(n_time, dtype=np.int16) # Number of merges per timestep
2306
+
2307
+ updates_array = np.full((n_time, n_points), 255, dtype=np.uint8)
2308
+ updates_ids = np.full((n_time, 255), -1, dtype=np.int32)
2309
+ has_merge = np.zeros(n_time, dtype=np.bool_)
2310
+
2311
+ # Prepare merging objects list for each timestep
2312
+ merging_objects_list = [list(merging_objects[i][merging_objects[i] > 0])
2313
+ for i in range(merging_objects.shape[0])]
2314
+ final_merging_objects = np.full((n_time, MAX_MERGES), -1, dtype=np.int32)
2315
+ final_merge_count = 0
2316
+
2317
+ # Process each timestep
2318
+ for t in range(n_time):
2319
+ next_new_id = next_id_start[t] # Use the offset for this timestep
2320
+
2321
+ # Get current time slice data
2322
+ if t == 0:
2323
+ data_m1 = chunk_data_m1
2324
+ data_t = chunk_data
2325
+ del chunk_data_m1, chunk_data # Free memory
2326
+ else:
2327
+ data_m1 = data_t
2328
+ data_t = data_p1
2329
+ data_p1 = chunk_data_p1[t]
2330
+
2331
+ # Process each merging object at this timestep
2332
+ while merging_objects_list[t]:
2333
+ child_id = merging_objects_list[t].pop(0)
2334
+
2335
+ # Get child mask and identify overlapping parents
2336
+ child_mask = (data_t == child_id)
2337
+
2338
+ # Find parent objects that overlap with this child
2339
+ potential_parents = np.unique(data_m1[child_mask])
2340
+ parent_iterator = 0
2341
+ parent_masks_uint = np.full(n_points, 255, dtype=np.uint8)
2342
+ parent_centroids = np.full((MAX_PARENTS, 2), -1.e10, dtype=np.float32)
2343
+ parent_ids = np.full(MAX_PARENTS, -1, dtype=np.int32)
2344
+ parent_areas = np.zeros(MAX_PARENTS, dtype=np.float32)
2345
+ overlap_areas = np.zeros(MAX_PARENTS, dtype=np.float32)
2346
+ n_parents = 0
2347
+
2348
+ # Find all unique parent IDs with significant overlap
2349
+ for parent_id in potential_parents[potential_parents > 0]:
2350
+ if n_parents >= MAX_PARENTS:
2351
+ raise RuntimeError(f"Reached maximum number of parents ({MAX_PARENTS}) for child {child_id} at timestep {t}")
2352
+
2353
+ parent_mask = (data_m1 == parent_id)
2354
+ if np.any(parent_mask & child_mask):
2355
+ # Calculate overlap area and check if it's large enough
2356
+ area_0 = area[parent_mask].sum() # Parent area
2357
+ area_1 = area[child_mask].sum() # Child area
2358
+ min_area = np.minimum(area_0, area_1)
2359
+ overlap_area = area[parent_mask & child_mask].sum()
2360
+
2361
+ # Skip if overlap is below threshold
2362
+ if overlap_area / min_area < self.overlap_threshold:
2363
+ continue
2364
+
2365
+ # Record parent information
2366
+ parent_masks_uint[parent_mask] = parent_iterator
2367
+ parent_ids[n_parents] = parent_id
2368
+ overlap_areas[n_parents] = overlap_area
2369
+
2370
+ # Calculate area-weighted centroid for this parent
2371
+ mask_area = area[parent_mask]
2372
+ weighted_coords = np.array([
2373
+ np.sum(mask_area * x[parent_mask]),
2374
+ np.sum(mask_area * y[parent_mask]),
2375
+ np.sum(mask_area * z[parent_mask])
2376
+ ], dtype=np.float32)
2377
+
2378
+ norm = np.sqrt(np.sum(weighted_coords * weighted_coords))
2379
+
2380
+ # Convert back to lat/lon
2381
+ parent_centroids[n_parents, 0] = np.degrees(np.arcsin(weighted_coords[2]/norm))
2382
+ parent_centroids[n_parents, 1] = np.degrees(np.arctan2(weighted_coords[1], weighted_coords[0]))
2383
+
2384
+ # Fix longitude range to [-180, 180]
2385
+ if parent_centroids[n_parents, 1] > 180:
2386
+ parent_centroids[n_parents, 1] -= 360
2387
+ elif parent_centroids[n_parents, 1] < -180:
2388
+ parent_centroids[n_parents, 1] += 360
2389
+
2390
+ parent_areas[n_parents] = area_0
2391
+ parent_iterator += 1
2392
+ n_parents += 1
2393
+
2394
+ # Need at least 2 parents for merging
2395
+ if n_parents < 2:
2396
+ continue
2397
+
2398
+ # Create new IDs for each partition
2399
+ new_child_ids = np.arange(next_new_id, next_new_id + (n_parents - 1), dtype=np.int32)
2400
+ child_ids = np.concatenate((np.array([child_id]), new_child_ids))
2401
+
2402
+ # Record merge event
2403
+ curr_merge_idx = merge_counts[t]
2404
+ if curr_merge_idx > MAX_MERGES:
2405
+ raise RuntimeError(f"Reached maximum number of merges ({MAX_MERGES}) at timestep {t}")
2406
+
2407
+ merge_child_ids[t, curr_merge_idx, :n_parents] = child_ids[:n_parents]
2408
+ merge_parent_ids[t, curr_merge_idx, :n_parents] = parent_ids[:n_parents]
2409
+ merge_areas[t, curr_merge_idx, :n_parents] = overlap_areas[:n_parents]
2410
+ merge_counts[t] += 1
2411
+ has_merge[t] = True
2412
+
2413
+ # Partition the child object based on parent associations
2414
+ if self.nn_partitioning:
2415
+ # Estimate maximum search distance based on object size
2416
+ max_area = parent_areas.max() / self.mean_cell_area
2417
+ max_distance = int(np.sqrt(max_area) * 2.0)
2418
+
2419
+ # Use optimised nearest-neighbor partitioning
2420
+ new_labels_uint = partition_nn_unstructured_optimised(
2421
+ child_mask.copy(),
2422
+ parent_masks_uint.copy(),
2423
+ parent_centroids,
2424
+ neighbours_int.copy(),
2425
+ lat,
2426
+ lon,
2427
+ max_distance=max(max_distance, 20)*2
2428
+ )
2429
+ # Returned 'new_labels_uint' is just the index of the child_ids
2430
+ new_labels = child_ids[new_labels_uint]
2431
+
2432
+ # Help garbage collection
2433
+ new_labels_uint = None
2434
+
2435
+ else:
2436
+ # Use centroid-based partitioning
2437
+ new_labels = partition_centroid_unstructured(
2438
+ child_mask,
2439
+ parent_centroids,
2440
+ child_ids,
2441
+ lat,
2442
+ lon
2443
+ )
2444
+
2445
+ # Update slice data for subsequent merging in process_chunk
2446
+ data_t[child_mask] = new_labels
2447
+
2448
+ # Record which cells get which new IDs for later updates
2449
+ spatial_indices_all = np.where(child_mask)[0]
2450
+ child_mask = None # Free memory
2451
+ gc.collect()
2452
+
2453
+ # Record update information for each new ID
2454
+ for new_id in child_ids[1:]:
2455
+ update_idx = np.where(updates_ids[t] == -1)[0][0] # Find next non-negative index in updates_ids
2456
+ updates_ids[t, update_idx] = new_id
2457
+ updates_array[t, spatial_indices_all[new_labels == new_id]] = update_idx
2458
+
2459
+ next_new_id += n_parents - 1
2460
+
2461
+ # Find all child objects in the next timestep that overlap with our newly labeled regions
2462
+ new_merging_list = []
2463
+ for new_id in child_ids:
2464
+ parent_mask = (data_t == new_id)
2465
+ if np.any(parent_mask):
2466
+ area_0 = area[parent_mask].sum()
2467
+ potential_children = np.unique(data_p1[parent_mask])
2468
+
2469
+ for potential_child in potential_children[potential_children > 0]:
2470
+ potential_child_mask = (data_p1 == potential_child)
2471
+ area_1 = area[potential_child_mask].sum()
2472
+ min_area = min(area_0, area_1)
2473
+ overlap_area = area[parent_mask & potential_child_mask].sum()
2474
+
2475
+ if overlap_area / min_area > self.overlap_threshold:
2476
+ new_merging_list.append(potential_child)
2477
+
2478
+ # Add newly found merging objects to processing queue
2479
+ if t < n_time - 1:
2480
+ # Add to next timestep in this chunk
2481
+ for new_object_id in new_merging_list:
2482
+ if new_object_id not in merging_objects_list[t+1]:
2483
+ merging_objects_list[t+1].append(new_object_id)
2484
+ else:
2485
+ # Record for next chunk
2486
+ for new_object_id in new_merging_list:
2487
+ if final_merge_count > MAX_MERGES:
2488
+ raise RuntimeError(f"Reached maximum number of merges ({MAX_MERGES}) at timestep {t}")
2489
+
2490
+ if not np.any(final_merging_objects[t][:final_merge_count] == new_object_id):
2491
+ final_merging_objects[t][final_merge_count] = new_object_id
2492
+ final_merge_count += 1
2493
+
2494
+
2495
+ return (merge_child_ids, merge_parent_ids, merge_areas, merge_counts,
2496
+ has_merge, updates_array, updates_ids, final_merging_objects)
2497
+
2498
+
2499
+ def update_object_id_field_inplace(object_id_field, id_lookup, updates_array, updates_ids, has_merge):
2500
+ """
2501
+ Update the object field with chunk results using xarray operations.
2502
+
2503
+ This is memory efficient as it avoids creating full copies of the object_id_field.
2504
+
2505
+ Parameters
2506
+ ----------
2507
+ object_id_field : xarray.DataArray
2508
+ The full object field to update
2509
+ id_lookup : dict
2510
+ Dictionary mapping temporary IDs to new IDs
2511
+ updates_array : xarray.DataArray
2512
+ Array indicating which spatial indices to update
2513
+ updates_ids : xarray.DataArray
2514
+ The new IDs to assign to updated indices
2515
+ has_merge : xarray.DataArray
2516
+ Boolean indicating whether each timestep has merges
2517
+
2518
+ Returns
2519
+ -------
2520
+ xarray.DataArray
2521
+ Updated object field
2522
+ """
2523
+
2524
+ # Quick return if no merges to update
2525
+ if not has_merge.any():
2526
+ return object_id_field
2527
+
2528
+ def update_timeslice(data, updates, update_ids, lookup_values):
2529
+ """Process a single timeslice."""
2530
+ # Extract valid update IDs
2531
+ valid_ids = update_ids[update_ids > -1]
2532
+ if len(valid_ids) == 0:
2533
+ return data
2534
+
2535
+ # Create result array starting with original values
2536
+ result = data.copy()
2537
+
2538
+ # Apply each update
2539
+ for idx, update_id in enumerate(valid_ids):
2540
+ mask = updates == idx
2541
+ if mask.any():
2542
+ result = np.where(mask, lookup_values[update_id], result)
2543
+
2544
+ return result
2545
+
2546
+ # Convert lookup dict to array for vectorized access
2547
+ max_id = max(id_lookup.keys()) + 1
2548
+ lookup_array = np.full(max_id, -1, dtype=np.int32)
2549
+ for temp_id, new_id in id_lookup.items():
2550
+ lookup_array[temp_id] = new_id
2551
+
2552
+ # Apply updates in parallel
2553
+ result = xr.apply_ufunc(
2554
+ update_timeslice,
2555
+ object_id_field,
2556
+ updates_array,
2557
+ updates_ids,
2558
+ kwargs={'lookup_values': lookup_array},
2559
+ input_core_dims=[[self.xdim],
2560
+ [self.xdim],
2561
+ ['update_idx']],
2562
+ output_core_dims=[[self.xdim]],
2563
+ vectorize=True,
2564
+ dask='parallelized',
2565
+ output_dtypes=[np.int32]
2566
+ )
2567
+
2568
+ return result
2569
+
2570
+ def update_object_id_field_zarr(object_id_field, id_lookup, updates_array, updates_ids, has_merge):
2571
+ """
2572
+ Update object field using a temporary zarr store for better memory efficiency.
2573
+
2574
+ This approach minimises memory usage by writing changes directly to disk,
2575
+ allowing for more efficient parallel processing of large datasets.
2576
+
2577
+ Parameters
2578
+ ----------
2579
+ object_id_field : xarray.DataArray
2580
+ The object field to update
2581
+ id_lookup : dict
2582
+ Dictionary mapping temporary IDs to new IDs
2583
+ updates_array : xarray.DataArray
2584
+ Array indicating which spatial indices to update
2585
+ updates_ids : xarray.DataArray
2586
+ The new IDs to assign to updated indices
2587
+ has_merge : xarray.DataArray
2588
+ Boolean indicating whether each timestep has merges
2589
+
2590
+ Returns
2591
+ -------
2592
+ xarray.DataArray
2593
+ Updated object field from zarr store
2594
+ """
2595
+
2596
+ # Early return if no merges to save memory
2597
+ if not has_merge.any().compute().item():
2598
+ return object_id_field
2599
+
2600
+ zarr_path = f'{self.scratch_dir}/marEx_temp_field.zarr/'
2601
+
2602
+ # Initialise zarr store if needed
2603
+ if not os.path.exists(zarr_path):
2604
+ object_id_field.name = 'temp'
2605
+ object_id_field.to_zarr(zarr_path, mode='w')
2606
+
2607
+ def update_time_chunk(ds_chunk, lookup_dict):
2608
+ """Process a single chunk with optimised memory usage."""
2609
+
2610
+ # Skip processing if no merges in this chunk
2611
+ needs_update = ds_chunk['has_merge'].any().compute().item()
2612
+ if not needs_update:
2613
+ return ds_chunk['object_field']
2614
+
2615
+ # Extract data from the chunk
2616
+ chunk_data = ds_chunk['object_field']
2617
+ chunk_updates = ds_chunk['updates']
2618
+ chunk_update_ids = ds_chunk['update_ids']
2619
+
2620
+ # Get zarr region indices
2621
+ time_idx_start = int(ds_chunk['time_indices'].values[0])
2622
+ time_idx_end = int(ds_chunk['time_indices'].values[-1]) + 1
2623
+
2624
+ updated_chunk = chunk_data.copy()
2625
+
2626
+ # Process each time slice in the chunk
2627
+ for t in range(chunk_data.sizes[self.timedim]):
2628
+ # Get update information for this time
2629
+ updates_slice = chunk_updates.isel({self.timedim: t}).values
2630
+ update_ids_slice = chunk_update_ids.isel({self.timedim: t}).values
2631
+
2632
+ # Get valid update IDs
2633
+ valid_mask = update_ids_slice > -1
2634
+ if not np.any(valid_mask):
2635
+ continue
2636
+
2637
+ valid_ids = update_ids_slice[valid_mask]
2638
+
2639
+ # Get the time slice data and apply updates
2640
+ result_slice = updated_chunk.isel({self.timedim: t})
2641
+
2642
+ for idx, update_id in enumerate(valid_ids):
2643
+ mask = updates_slice == idx
2644
+ if np.any(mask):
2645
+ new_id = lookup_dict.get(int(update_id), update_id)
2646
+ result_slice = xr.where(mask, new_id, result_slice)
2647
+
2648
+ # Store updated slice
2649
+ updated_chunk[t] = result_slice
2650
+
2651
+ # Write the updated chunk directly to zarr
2652
+ updated_chunk.name = 'temp'
2653
+ updated_chunk.to_zarr(zarr_path,
2654
+ region={self.timedim: slice(time_idx_start, time_idx_end)})
2655
+
2656
+ return chunk_data # Return original data for dask graph consistency
2657
+
2658
+ # Create time indices for slicing
2659
+ time_coords = object_id_field[self.timedim].values
2660
+ time_indices = np.arange(len(time_coords))
2661
+ time_index_da = xr.DataArray(time_indices, dims=[self.timedim], coords={self.timedim: time_coords})
2662
+
2663
+ # Create dataset with all necessary components
2664
+ ds = xr.Dataset({
2665
+ 'object_field': object_id_field,
2666
+ 'updates': updates_array,
2667
+ 'update_ids': updates_ids,
2668
+ 'time_indices': time_index_da,
2669
+ 'has_merge': has_merge
2670
+ }).chunk({self.timedim: self.timechunks})
2671
+
2672
+ # Process chunks in parallel
2673
+ result = xr.map_blocks(
2674
+ update_time_chunk,
2675
+ ds,
2676
+ kwargs={"lookup_dict": id_lookup},
2677
+ template=object_id_field
2678
+ )
2679
+
2680
+ # Force computation to ensure all writes complete
2681
+ result = result.persist()
2682
+ wait(result)
2683
+
2684
+ # Release resources
2685
+ del result, ds, object_id_field
2686
+ gc.collect()
2687
+
2688
+ # Load the updated data from zarr store
2689
+ object_id_field_new = xr.open_zarr(zarr_path, chunks={self.timedim: self.timechunks}).temp
2690
+
2691
+ return object_id_field_new
2692
+
2693
+
2694
+ def merge_objects_parallel_iteration(object_id_field_unique, merging_objects, global_id_counter):
2695
+ """
2696
+ Perform a single iteration of the parallel merging process.
2697
+
2698
+ This function handles one complete batch of merging objects across all
2699
+ timesteps, updating object IDs and tracking merge events.
2700
+
2701
+ Parameters
2702
+ ----------
2703
+ object_id_field_unique : xarray.DataArray
2704
+ Field of unique object IDs
2705
+ merging_objects : set
2706
+ Set of object IDs to process in this iteration
2707
+ global_id_counter : int
2708
+ Current counter for assigning new global IDs
2709
+
2710
+ Returns
2711
+ -------
2712
+ tuple
2713
+ (updated_field, merge_data, new_merging_objects, updated_counter)
2714
+ """
2715
+
2716
+ n_time = len(object_id_field_unique[self.timedim])
2717
+
2718
+ # Pre-allocate arrays for this iteration
2719
+ child_ids_iter = np.full((n_time, MAX_MERGES, MAX_CHILDREN), -1, dtype=np.int32) # List of child ID arrays for this time
2720
+ parent_ids_iter = np.full((n_time, MAX_MERGES, MAX_PARENTS), -1, dtype=np.int32) # List of parent ID arrays for this time
2721
+ merge_areas_iter = np.full((n_time, MAX_MERGES, MAX_PARENTS), -1, dtype=np.float32) # List of areas for this time
2722
+ merge_counts_iter = np.zeros(n_time, dtype=np.int32)
2723
+
2724
+ # Prepare neighbour information
2725
+ neighbours_int = self.neighbours_int.chunk({self.xdim: -1, 'nv': -1})
2726
+
2727
+ if self.verbosity > 0:
2728
+ print(f"Processing Parallel Iteration {iteration + 1} with {len(merging_objects)} Merging Objects...")
2729
+
2730
+ # Pre-compute the child_time_idx for merging_objects
2731
+ time_index_map = self.compute_id_time_dict(object_id_field_unique, list(merging_objects), global_id_counter)
2732
+ if self.verbosity > 1:
2733
+ print(' Finished Mapping Children to Time Indices')
2734
+
2735
+ # Create uniform array of merging objects for each timestep
2736
+ max_merges = max(len([b for b in merging_objects if time_index_map.get(b, -1) == t]) for t in range(n_time))
2737
+ uniform_merging_objects_array = np.zeros((n_time, max_merges), dtype=np.int64)
2738
+ for t in range(n_time):
2739
+ objects_at_t = [b for b in merging_objects if time_index_map.get(b, -1) == t]
2740
+ if objects_at_t: # Only fill if there are objects at this time
2741
+ uniform_merging_objects_array[t, :len(objects_at_t)] = np.array(objects_at_t, dtype=np.int64)
2742
+
2743
+ # Create DataArrays for parallel processing
2744
+ merging_objects_da = xr.DataArray(
2745
+ uniform_merging_objects_array,
2746
+ dims=[self.timedim, 'merges'],
2747
+ coords={self.timedim: object_id_field_unique[self.timedim]}
2748
+ )
2749
+
2750
+ # Calculate ID offsets for each timestep to ensure unique IDs
2751
+ next_id_offsets = np.arange(n_time) * max_merges * self.timechunks + global_id_counter
2752
+ # N.B.: We also need to account for possibility of newly-split objects then creating more than max_merges by the end of the iteration through the chunk
2753
+ # !!! This is likely the root cause of any errors such as "ID needs to be contiguous/continuous/full/unrepeated"
2754
+ next_id_offsets_da = xr.DataArray(
2755
+ next_id_offsets,
2756
+ dims=[self.timedim],
2757
+ coords={self.timedim: object_id_field_unique[self.timedim]}
2758
+ )
2759
+
2760
+ # Create shifted arrays for time connectivity
2761
+ object_id_field_unique_p1 = object_id_field_unique.shift({self.timedim: -1}, fill_value=0)
2762
+ object_id_field_unique_m1 = object_id_field_unique.shift({self.timedim: 1}, fill_value=0)
2763
+
2764
+ # Align chunks for better parallel processing
2765
+ object_id_field_unique_m1 = object_id_field_unique_m1.chunk({self.timedim: self.timechunks})
2766
+ object_id_field_unique_p1 = object_id_field_unique_p1.chunk({self.timedim: self.timechunks})
2767
+ merging_objects_da = merging_objects_da.chunk({self.timedim: self.timechunks})
2768
+ next_id_offsets_da = next_id_offsets_da.chunk({self.timedim: self.timechunks})
2769
+
2770
+ # Process chunks in parallel
2771
+ results = xr.apply_ufunc(
2772
+ process_chunk,
2773
+ object_id_field_unique_m1,
2774
+ object_id_field_unique_p1,
2775
+ merging_objects_da,
2776
+ next_id_offsets_da,
2777
+ self.lat,
2778
+ self.lon,
2779
+ self.cell_area,
2780
+ neighbours_int,
2781
+ input_core_dims=[
2782
+ [self.xdim], [self.xdim], ['merges'], [],
2783
+ [self.xdim], [self.xdim], [self.xdim], ['nv', self.xdim]
2784
+ ],
2785
+ output_core_dims=[
2786
+ ['merge', 'parent'], ['merge', 'parent'], ['merge', 'parent'], [],
2787
+ [], [self.xdim], ['update_idx'], ['merge']
2788
+ ],
2789
+ output_dtypes=[
2790
+ np.int32, np.int32, np.float32, np.int16,
2791
+ np.bool_, np.uint8, np.int32, np.int32
2792
+ ],
2793
+ dask_gufunc_kwargs={
2794
+ 'output_sizes': {
2795
+ 'merge': MAX_MERGES,
2796
+ 'parent': MAX_PARENTS,
2797
+ 'update_idx': 255
2798
+ }
2799
+ },
2800
+ vectorize=False,
2801
+ dask='parallelized'
2802
+ )
2803
+
2804
+ # Unpack and persist results
2805
+ (merge_child_ids, merge_parent_ids, merge_areas, merge_counts,
2806
+ has_merge, updates_array, updates_ids, final_merging_objects) = results
2807
+
2808
+ results = persist(
2809
+ merge_child_ids, merge_parent_ids, merge_areas, merge_counts,
2810
+ has_merge, updates_array, updates_ids, final_merging_objects
2811
+ )
2812
+ (merge_child_ids, merge_parent_ids, merge_areas, merge_counts,
2813
+ has_merge, updates_array, updates_ids, final_merging_objects) = results
2814
+
2815
+ # Get time indices where merges occurred
2816
+ has_merge = has_merge.compute()
2817
+ time_indices = np.where(has_merge)[0]
2818
+
2819
+ # Clean up temporary arrays to save memory
2820
+ del object_id_field_unique_p1, object_id_field_unique_m1, merging_objects_da, next_id_offsets_da
2821
+ gc.collect()
2822
+
2823
+ if self.verbosity > 1:
2824
+ print(' Finished Batch Processing Step')
2825
+
2826
+
2827
+ # ====== Global Consolidation of Data ======
2828
+
2829
+ # 1. Collect all temporary IDs and create global mapping
2830
+ all_temp_ids = np.unique(
2831
+ merge_child_ids.where(merge_child_ids >= global_id_counter, other=0).compute().values
2832
+ )
2833
+ all_temp_ids = all_temp_ids[all_temp_ids > 0] # Remove the 0
2834
+
2835
+ if not len(all_temp_ids): # If no temporary IDs exist
2836
+ id_lookup = {}
2837
+ else:
2838
+ # Create mapping from temporary to permanent IDs
2839
+ id_lookup = {
2840
+ temp_id: np.int32(new_id) for temp_id, new_id in zip(
2841
+ all_temp_ids,
2842
+ range(global_id_counter, global_id_counter + len(all_temp_ids))
2843
+ )
2844
+ }
2845
+ global_id_counter += len(all_temp_ids)
2846
+
2847
+ if self.verbosity > 1:
2848
+ print(' Finished Consolidation Step 1: Temporary ID Mapping')
2849
+
2850
+ # 2. Update object ID field with new IDs
2851
+ update_on_disk = True # This is more memory efficient because it refreshes the dask graph every iteration
2852
+
2853
+ if update_on_disk:
2854
+ object_id_field_unique = update_object_id_field_zarr(
2855
+ object_id_field_unique, id_lookup, updates_array, updates_ids, has_merge
2856
+ )
2857
+ else:
2858
+ object_id_field_unique = update_object_id_field_inplace(
2859
+ object_id_field_unique, id_lookup, updates_array, updates_ids, has_merge
2860
+ )
2861
+ object_id_field_unique = object_id_field_unique.chunk({self.timedim: self.timechunks}) # Rechunk to avoid accumulating chunks...
2862
+
2863
+ # Clean up arrays no longer needed
2864
+ del updates_array, updates_ids
2865
+ gc.collect()
2866
+
2867
+ if self.verbosity > 1:
2868
+ print(' Finished Consolidation Step 2: Data Field Update')
2869
+
2870
+ # 3. Update merge events
2871
+ new_merging_objects = set()
2872
+ merge_counts = merge_counts.compute()
2873
+
2874
+ for t in time_indices:
2875
+ count = merge_counts.isel({self.timedim: t}).item()
2876
+ if count > 0:
2877
+ merge_counts_iter[t] = count
2878
+
2879
+ # Extract valid IDs and areas for each merge event
2880
+ for merge_idx in range(count):
2881
+ # Get child IDs
2882
+ child_ids = merge_child_ids.isel({self.timedim: t, 'merge': merge_idx}).compute().values
2883
+ child_ids = child_ids[child_ids >= 0]
2884
+
2885
+ # Get parent IDs and areas
2886
+ parent_ids = merge_parent_ids.isel({self.timedim: t, 'merge': merge_idx}).compute().values
2887
+ areas = merge_areas.isel({self.timedim: t, 'merge': merge_idx}).compute().values
2888
+ valid_mask = parent_ids >= 0
2889
+ parent_ids = parent_ids[valid_mask]
2890
+ areas = areas[valid_mask]
2891
+
2892
+ # Map temporary IDs to permanent IDs
2893
+ mapped_child_ids = [id_lookup.get(id_.item(), id_.item()) for id_ in child_ids]
2894
+ mapped_parent_ids = [id_lookup.get(id_.item(), id_.item()) for id_ in parent_ids]
2895
+
2896
+ # Store in pre-allocated arrays
2897
+ child_ids_iter[t, merge_idx, :len(mapped_child_ids)] = mapped_child_ids
2898
+ parent_ids_iter[t, merge_idx, :len(mapped_parent_ids)] = mapped_parent_ids
2899
+ merge_areas_iter[t, merge_idx, :len(areas)] = areas
2900
+
2901
+ # Process final merging objects for next iteration
2902
+ final_merging_objects = final_merging_objects.compute().values
2903
+ final_merging_objects = final_merging_objects[final_merging_objects > 0]
2904
+ mapped_final_objects = [id_lookup.get(id_, id_) for id_ in final_merging_objects]
2905
+ new_merging_objects.update(mapped_final_objects)
2906
+
2907
+ if self.verbosity > 1:
2908
+ print(' Finished Consolidation Step 3: Merge List Dictionary Consolidation')
2909
+
2910
+ # Clean up memory
2911
+ del merge_child_ids, merge_parent_ids, merge_areas, merge_counts, has_merge
2912
+ gc.collect()
2913
+
2914
+ return (
2915
+ object_id_field_unique,
2916
+ (child_ids_iter, parent_ids_iter, merge_areas_iter, merge_counts_iter),
2917
+ new_merging_objects,
2918
+ global_id_counter
2919
+ )
2920
+
2921
+ # ============================
2922
+ # Main Loop for Parallel Merging
2923
+ # ============================
2924
+
2925
+ # Find overlapping objects
2926
+ overlap_objects_list = self.find_overlapping_objects(object_id_field_unique) # List object pairs that overlap by at least overlap_threshold percent
2927
+ overlap_objects_list = self.enforce_overlap_threshold(overlap_objects_list, object_props)
2928
+ if self.verbosity > 0:
2929
+ print('Finished finding overlapping objects')
2930
+
2931
+ # Find initial merging objects
2932
+ unique_children, children_counts = np.unique(overlap_objects_list[:, 1], return_counts=True)
2933
+ merging_objects = set(unique_children[children_counts > 1].astype(np.int32))
2934
+ del overlap_objects_list
2935
+
2936
+ ## Process chunks iteratively until no new merging objects remain
2937
+
2938
+ iteration = 0
2939
+ processed_chunks = set()
2940
+ global_id_counter = object_props.ID.max().item() + 1
2941
+
2942
+ # Initialize global merge event tracking
2943
+ global_child_ids = []
2944
+ global_parent_ids = []
2945
+ global_merge_areas = []
2946
+ global_merge_tidx = []
2947
+
2948
+ while merging_objects and iteration < self.max_iteration:
2949
+ object_id_field_new, merge_data_iter, new_merging_objects, global_id_counter = merge_objects_parallel_iteration(
2950
+ object_id_field_unique, merging_objects, global_id_counter
2951
+ )
2952
+ child_ids_iter, parent_ids_iter, merge_areas_iter, merge_counts_iter = merge_data_iter
2953
+
2954
+ # Consolidate merge events from this iteration
2955
+ for t in range(len(merge_counts_iter)):
2956
+ count = merge_counts_iter[t]
2957
+ if count > 0:
2958
+ for merge_idx in range(count):
2959
+ # Extract valid children
2960
+ children = child_ids_iter[t, merge_idx]
2961
+ children = children[children >= 0]
2962
+
2963
+ # Extract valid parents and areas
2964
+ parents = parent_ids_iter[t, merge_idx]
2965
+ areas = merge_areas_iter[t, merge_idx]
2966
+ valid_mask = parents >= 0
2967
+ parents = parents[valid_mask]
2968
+ areas = areas[valid_mask]
2969
+
2970
+ # Record valid merge events
2971
+ if len(children) > 0 and len(parents) > 0:
2972
+ global_child_ids.append(children)
2973
+ global_parent_ids.append(parents)
2974
+ global_merge_areas.append(areas)
2975
+ global_merge_tidx.append(t)
2976
+
2977
+ # Prepare for next iteration - only process objects not already handled
2978
+ merging_objects = new_merging_objects - processed_chunks
2979
+ processed_chunks.update(new_merging_objects)
2980
+ iteration += 1
2981
+
2982
+ # Update the object field
2983
+ object_id_field_unique = object_id_field_new
2984
+ del object_id_field_new
2985
+
2986
+ # Check if we reached maximum iterations
2987
+ if iteration == self.max_iteration:
2988
+ raise RuntimeError(
2989
+ f"Reached maximum iterations ({self.max_iteration}) in split_and_merge_objects_parallel. "
2990
+ f"Set optional argument 'max_iteration' to a higher value."
2991
+ )
2992
+
2993
+ ## Process the collected merge events
2994
+
2995
+ times = object_id_field_unique[self.timedim].values
2996
+
2997
+ # Find maximum dimensions for arrays
2998
+ max_parents = max(len(ids) for ids in global_parent_ids)
2999
+ max_children = max(len(ids) for ids in global_child_ids)
3000
+
3001
+ # Create padded arrays for merge events
3002
+ parent_ids_array = np.full((len(global_parent_ids), max_parents), -1, dtype=np.int32)
3003
+ child_ids_array = np.full((len(global_child_ids), max_children), -1, dtype=np.int32)
3004
+ overlap_areas_array = np.full(
3005
+ (len(global_merge_areas), max_parents),
3006
+ -1,
3007
+ dtype=np.float32 if self.unstructured_grid else np.int32
3008
+ )
3009
+
3010
+ # Fill arrays with merge data
3011
+ for i, parents in enumerate(global_parent_ids):
3012
+ parent_ids_array[i, :len(parents)] = parents
3013
+
3014
+ for i, children in enumerate(global_child_ids):
3015
+ child_ids_array[i, :len(children)] = children
3016
+
3017
+ for i, areas in enumerate(global_merge_areas):
3018
+ overlap_areas_array[i, :len(areas)] = areas
3019
+
3020
+ # Create merge events dataset
3021
+ merge_events = xr.Dataset(
3022
+ {
3023
+ 'parent_IDs': (('merge_ID', 'parent_idx'), parent_ids_array),
3024
+ 'child_IDs': (('merge_ID', 'child_idx'), child_ids_array),
3025
+ 'overlap_areas': (('merge_ID', 'parent_idx'), overlap_areas_array),
3026
+ 'merge_time': ('merge_ID', times[global_merge_tidx]),
3027
+ 'n_parents': ('merge_ID', np.array([len(p) for p in global_parent_ids], dtype=np.int8)),
3028
+ 'n_children': ('merge_ID', np.array([len(c) for c in global_child_ids], dtype=np.int8))
3029
+ },
3030
+ attrs={
3031
+ 'fill_value': -1
3032
+ }
3033
+ )
3034
+
3035
+ # Recompute object properties and overlaps after all merging
3036
+ object_id_field_unique = object_id_field_unique.persist(optimize_graph=True)
3037
+ object_props = self.calculate_object_properties(
3038
+ object_id_field_unique,
3039
+ properties=['area', 'centroid']
3040
+ ).persist(optimize_graph=True)
3041
+
3042
+ # Recompute overlaps based on final object configuration
3043
+ overlap_objects_list = self.find_overlapping_objects(object_id_field_unique)
3044
+ overlap_objects_list = self.enforce_overlap_threshold(overlap_objects_list, object_props)
3045
+ overlap_objects_list = overlap_objects_list[:, :2].astype(np.int32)
3046
+
3047
+ return (
3048
+ object_id_field_unique,
3049
+ object_props,
3050
+ overlap_objects_list,
3051
+ merge_events
3052
+ )
3053
+
3054
+
3055
+
3056
+
3057
+
3058
+ """
3059
+ MarEx Helper Functions
3060
+
3061
+ These are the remaining implementations of helper functions for the MarEx package,
3062
+ providing optimised algorithms for partitioning, distance calculations, and spatial
3063
+ operations on both structured and unstructured grids.
3064
+ """
3065
+
3066
+ @jit(nopython=True, parallel=True, fastmath=True)
3067
+ def wrapped_euclidian_parallel(mask_values, parent_centroids_values, Nx):
3068
+ """
3069
+ Optimised function for computing wrapped Euclidean distances.
3070
+
3071
+ Efficiently calculates distances between points in a binary mask and a set of
3072
+ centroids, accounting for periodic boundaries in the x dimension.
3073
+
3074
+ Parameters
3075
+ ----------
3076
+ mask_values : np.ndarray
3077
+ 2D boolean array where True indicates points to calculate distances for
3078
+ parent_centroids_values : np.ndarray
3079
+ Array of shape (n_parents, 2) containing (y, x) coordinates of parent centroids
3080
+ Nx : int
3081
+ Size of the x-dimension for periodic boundary wrapping
3082
+
3083
+ Returns
3084
+ -------
3085
+ distances : np.ndarray
3086
+ Array of shape (n_true_points, n_parents) with minimum distances
3087
+ """
3088
+ n_parents = len(parent_centroids_values)
3089
+ half_Nx = Nx / 2
3090
+
3091
+ y_indices, x_indices = np.nonzero(mask_values)
3092
+ n_true = len(y_indices)
3093
+
3094
+ distances = np.empty((n_true, n_parents), dtype=np.float64)
3095
+
3096
+ # Precompute for faster access
3097
+ parent_y = parent_centroids_values[:, 0]
3098
+ parent_x = parent_centroids_values[:, 1]
3099
+
3100
+ # Parallel loop over true positions
3101
+ for idx in prange(n_true):
3102
+ y, x = y_indices[idx], x_indices[idx]
3103
+
3104
+ # Pre-compute y differences for all parents
3105
+ dy = y - parent_y
3106
+
3107
+ # Pre-compute x differences for all parents
3108
+ dx = x - parent_x
3109
+
3110
+ # Wrapping correction
3111
+ dx = np.where(dx > half_Nx, dx - Nx, dx)
3112
+ dx = np.where(dx < -half_Nx, dx + Nx, dx)
3113
+
3114
+ distances[idx] = np.sqrt(dy * dy + dx * dx)
3115
+
3116
+ return distances
3117
+
3118
+ @jit(nopython=True, fastmath=True)
3119
+ def create_grid_index_arrays(points_y, points_x, grid_size, ny, nx):
3120
+ """
3121
+ Create a grid-based spatial index for efficient point lookup.
3122
+
3123
+ This function divides space into a grid and assigns points to grid cells
3124
+ for more efficient spatial queries compared to brute force comparisons.
3125
+
3126
+ Parameters
3127
+ ----------
3128
+ points_y, points_x : np.ndarray
3129
+ Coordinates of points to index
3130
+ grid_size : int
3131
+ Size of each grid cell
3132
+ ny, nx : int
3133
+ Dimensions of the overall grid
3134
+
3135
+ Returns
3136
+ -------
3137
+ grid_points : np.ndarray
3138
+ 3D array mapping grid cells to point indices
3139
+ grid_counts : np.ndarray
3140
+ 2D array with count of points in each grid cell
3141
+ """
3142
+ n_grids_y = (ny + grid_size - 1) // grid_size
3143
+ n_grids_x = (nx + grid_size - 1) // grid_size
3144
+ max_points_per_cell = len(points_y)
3145
+
3146
+ grid_points = np.full((n_grids_y, n_grids_x, max_points_per_cell), -1, dtype=np.int32)
3147
+ grid_counts = np.zeros((n_grids_y, n_grids_x), dtype=np.int32)
3148
+
3149
+ for idx in range(len(points_y)):
3150
+ grid_y = min(points_y[idx] // grid_size, n_grids_y - 1)
3151
+ grid_x = min(points_x[idx] // grid_size, n_grids_x - 1)
3152
+ count = grid_counts[grid_y, grid_x]
3153
+ if count < max_points_per_cell:
3154
+ grid_points[grid_y, grid_x, count] = idx
3155
+ grid_counts[grid_y, grid_x] += 1
3156
+
3157
+ return grid_points, grid_counts
3158
+
3159
+ @jit(nopython=True, fastmath=True)
3160
+ def calculate_wrapped_distance(y1, x1, y2, x2, nx, half_nx):
3161
+ """
3162
+ Calculate distance with periodic boundary conditions in x dimension.
3163
+
3164
+ Parameters
3165
+ ----------
3166
+ y1, x1 : float
3167
+ Coordinates of first point
3168
+ y2, x2 : float
3169
+ Coordinates of second point
3170
+ nx : int
3171
+ Size of x dimension
3172
+ half_nx : float
3173
+ Half the size of x dimension
3174
+
3175
+ Returns
3176
+ -------
3177
+ float
3178
+ Euclidean distance accounting for periodic boundary in x
3179
+ """
3180
+ dy = y1 - y2
3181
+ dx = x1 - x2
3182
+
3183
+ if dx > half_nx:
3184
+ dx -= nx
3185
+ elif dx < -half_nx:
3186
+ dx += nx
3187
+
3188
+ return np.sqrt(dy * dy + dx * dx)
3189
+
3190
+ @jit(nopython=True, parallel=True, fastmath=True)
3191
+ def partition_nn_grid(child_mask, parent_masks, child_ids, parent_centroids, Nx, max_distance=20):
3192
+ """
3193
+ Partition a child object based on nearest parent object points.
3194
+
3195
+ This implementation uses spatial indexing and highly-threaded processing
3196
+ for efficient distance calculations. The algorithm assigns each point
3197
+ in the child object to the closest parent object.
3198
+
3199
+ Parameters
3200
+ ----------
3201
+ child_mask : np.ndarray
3202
+ Binary mask of the child object
3203
+ parent_masks : np.ndarray
3204
+ List of binary masks for each parent object
3205
+ child_ids : np.ndarray
3206
+ List of IDs to assign to partitions
3207
+ parent_centroids : np.ndarray
3208
+ Array of shape (n_parents, 2) with parent centroids
3209
+ Nx : int
3210
+ Size of x dimension for periodic boundaries
3211
+ max_distance : int, default=20
3212
+ Maximum search distance
3213
+
3214
+ Returns
3215
+ -------
3216
+ new_labels : np.ndarray
3217
+ Array containing assigned child_ids for each point
3218
+ """
3219
+ ny, nx = child_mask.shape
3220
+ half_Nx = Nx / 2
3221
+ n_parents = len(parent_masks)
3222
+ grid_size = max(2, max_distance // 4)
3223
+
3224
+ y_indices, x_indices = np.nonzero(child_mask)
3225
+ n_child_points = len(y_indices)
3226
+
3227
+ min_distances = np.full(n_child_points, np.inf)
3228
+ parent_assignments = np.zeros(n_child_points, dtype=np.int32)
3229
+ found_close = np.zeros(n_child_points, dtype=np.bool_)
3230
+
3231
+ for parent_idx in range(n_parents):
3232
+ py, px = np.nonzero(parent_masks[parent_idx])
3233
+
3234
+ if len(py) == 0: # Skip empty parents
3235
+ continue
3236
+
3237
+ # Create grid index for this parent
3238
+ n_grids_y = (ny + grid_size - 1) // grid_size
3239
+ n_grids_x = (nx + grid_size - 1) // grid_size
3240
+ grid_points, grid_counts = create_grid_index_arrays(py, px, grid_size, ny, nx)
3241
+
3242
+ # Process child points in parallel
3243
+ for child_idx in prange(n_child_points):
3244
+ if found_close[child_idx]: # Skip if we already found an exact match
3245
+ continue
3246
+
3247
+ child_y, child_x = y_indices[child_idx], x_indices[child_idx]
3248
+ grid_y = min(child_y // grid_size, n_grids_y - 1)
3249
+ grid_x = min(child_x // grid_size, n_grids_x - 1)
3250
+
3251
+ min_dist_to_parent = np.inf
3252
+
3253
+ # Check nearby grid cells
3254
+ for dy in range(-1, 2):
3255
+ grid_y_check = (grid_y + dy) % n_grids_y
3256
+
3257
+ for dx in range(-1, 2):
3258
+ grid_x_check = (grid_x + dx) % n_grids_x
3259
+
3260
+ # Process points in this grid cell
3261
+ n_points = grid_counts[grid_y_check, grid_x_check]
3262
+
3263
+ for p_idx in range(n_points):
3264
+ point_idx = grid_points[grid_y_check, grid_x_check, p_idx]
3265
+ if point_idx == -1:
3266
+ break
3267
+
3268
+ dist = calculate_wrapped_distance(
3269
+ child_y, child_x,
3270
+ py[point_idx], px[point_idx],
3271
+ Nx, half_Nx
3272
+ )
3273
+
3274
+ if dist > max_distance:
3275
+ continue
3276
+
3277
+ if dist < min_dist_to_parent:
3278
+ min_dist_to_parent = dist
3279
+
3280
+ if dist < 1e-6: # Found exact same point (within numerical precision)
3281
+ min_dist_to_parent = dist
3282
+ found_close[child_idx] = True
3283
+ break
3284
+
3285
+ if found_close[child_idx]:
3286
+ break
3287
+
3288
+ if found_close[child_idx]:
3289
+ break
3290
+
3291
+ # Update assignment if this parent is closer
3292
+ if min_dist_to_parent < min_distances[child_idx]:
3293
+ min_distances[child_idx] = min_dist_to_parent
3294
+ parent_assignments[child_idx] = parent_idx
3295
+
3296
+ # Handle any unassigned points using centroids
3297
+ unassigned = min_distances == np.inf
3298
+ if np.any(unassigned):
3299
+ for child_idx in np.nonzero(unassigned)[0]:
3300
+ child_y, child_x = y_indices[child_idx], x_indices[child_idx]
3301
+ min_dist = np.inf
3302
+ best_parent = 0
3303
+
3304
+ for parent_idx in range(n_parents):
3305
+ # Calculate distance to centroid with periodic boundary conditions
3306
+ dist = calculate_wrapped_distance(
3307
+ child_y, child_x,
3308
+ parent_centroids[parent_idx, 0],
3309
+ parent_centroids[parent_idx, 1],
3310
+ Nx, half_Nx
3311
+ )
3312
+
3313
+ if dist < min_dist:
3314
+ min_dist = dist
3315
+ best_parent = parent_idx
3316
+
3317
+ parent_assignments[child_idx] = best_parent
3318
+
3319
+ # Convert from parent indices to child_ids
3320
+ new_labels = child_ids[parent_assignments]
3321
+
3322
+ return new_labels
3323
+
3324
+ @jit(nopython=True, fastmath=True)
3325
+ def partition_nn_unstructured(child_mask, parent_masks, child_ids, parent_centroids, neighbours_int, lat, lon, max_distance=20):
3326
+ """
3327
+ Partition a child object on an unstructured grid based on nearest parent points.
3328
+
3329
+ This function implements an efficient algorithm for assigning each cell in a child
3330
+ object to the nearest parent object, using graph traversal and spatial distances.
3331
+ It is optimised for unstructured grids.
3332
+
3333
+ Parameters
3334
+ ----------
3335
+ child_mask : np.ndarray
3336
+ 1D boolean array where True indicates points in the child object
3337
+ parent_masks : np.ndarray
3338
+ 2D boolean array of shape (n_parents, n_points) where True indicates points in each parent object
3339
+ child_ids : np.ndarray
3340
+ 1D array containing the IDs to assign to each partition of the child object
3341
+ parent_centroids : np.ndarray
3342
+ Array of shape (n_parents, 2) containing (lat, lon) coordinates of parent centroids in degrees
3343
+ neighbours_int : np.ndarray
3344
+ 2D array of shape (3, n_points) containing indices of neighboring cells for each point
3345
+ lat, lon : np.ndarray
3346
+ Latitude/longitude arrays in degrees
3347
+ max_distance : int, default=20
3348
+ Maximum number of edge hops to search for parent points
3349
+
3350
+ Returns
3351
+ -------
3352
+ new_labels : np.ndarray
3353
+ 1D array containing the assigned child_ids for each True point in child_mask
3354
+ """
3355
+
3356
+ # Force contiguous arrays in memory for optimal vectorised performance
3357
+ child_mask = np.ascontiguousarray(child_mask)
3358
+ parent_masks = np.ascontiguousarray(parent_masks)
3359
+
3360
+ n_points = len(child_mask)
3361
+ n_parents = len(parent_masks)
3362
+
3363
+ # Pre-allocate arrays
3364
+ distances = np.full(n_points, np.inf, dtype=np.float32)
3365
+ parent_assignments = np.full(n_points, -1, dtype=np.int32)
3366
+ visited = np.zeros((n_parents, n_points), dtype=np.bool_)
3367
+
3368
+ # Initialise with direct overlaps
3369
+ for parent_idx in range(n_parents):
3370
+ overlap_mask = parent_masks[parent_idx] & child_mask
3371
+ if np.any(overlap_mask):
3372
+ visited[parent_idx, overlap_mask] = True
3373
+ unclaimed_overlap = distances[overlap_mask] == np.inf
3374
+ if np.any(unclaimed_overlap):
3375
+ overlap_points = np.where(overlap_mask)[0]
3376
+ valid_points = overlap_points[unclaimed_overlap]
3377
+ distances[valid_points] = 0
3378
+ parent_assignments[valid_points] = parent_idx
3379
+
3380
+ # Pre-compute trig values for efficiency
3381
+ lat_rad = np.deg2rad(lat)
3382
+ lon_rad = np.deg2rad(lon)
3383
+ cos_lat = np.cos(lat_rad)
3384
+
3385
+ # Graph traversal for remaining points - expanding from parent frontiers
3386
+ current_distance = 0
3387
+ any_unassigned = np.any(child_mask & (parent_assignments == -1))
3388
+
3389
+ while current_distance < max_distance and any_unassigned:
3390
+ current_distance += 1
3391
+ updates_made = False
3392
+
3393
+ for parent_idx in range(n_parents):
3394
+ # Get current frontier points
3395
+ frontier_mask = visited[parent_idx]
3396
+ if not np.any(frontier_mask):
3397
+ continue
3398
+
3399
+ # Process neighbors
3400
+ for i in range(3): # For each neighbor direction
3401
+ neighbors = neighbours_int[i, frontier_mask]
3402
+ valid_neighbors = neighbors >= 0
3403
+ if not np.any(valid_neighbors):
3404
+ continue
3405
+
3406
+ valid_points = neighbors[valid_neighbors]
3407
+ unvisited = ~visited[parent_idx, valid_points]
3408
+ new_points = valid_points[unvisited]
3409
+
3410
+ if len(new_points) > 0:
3411
+ visited[parent_idx, new_points] = True
3412
+ update_mask = distances[new_points] > current_distance
3413
+ if np.any(update_mask):
3414
+ points_to_update = new_points[update_mask]
3415
+ distances[points_to_update] = current_distance
3416
+ parent_assignments[points_to_update] = parent_idx
3417
+ updates_made = True
3418
+
3419
+ if not updates_made:
3420
+ break
3421
+
3422
+ any_unassigned = np.any(child_mask & (parent_assignments == -1))
3423
+
3424
+ # Handle remaining unassigned points using great circle distances
3425
+ unassigned_mask = child_mask & (parent_assignments == -1)
3426
+ if np.any(unassigned_mask):
3427
+ parent_lat_rad = np.deg2rad(parent_centroids[:, 0])
3428
+ parent_lon_rad = np.deg2rad(parent_centroids[:, 1])
3429
+ cos_parent_lat = np.cos(parent_lat_rad)
3430
+
3431
+ unassigned_points = np.where(unassigned_mask)[0]
3432
+ for point in unassigned_points:
3433
+ # Vectorized haversine calculation
3434
+ dlat = parent_lat_rad - lat_rad[point]
3435
+ dlon = parent_lon_rad - lon_rad[point]
3436
+ a = np.sin(dlat/2)**2 + cos_lat[point] * cos_parent_lat * np.sin(dlon/2)**2
3437
+ dist = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))
3438
+ parent_assignments[point] = np.argmin(dist)
3439
+
3440
+ # Return only the assignments for points in child_mask
3441
+ child_points = np.where(child_mask)[0]
3442
+ return child_ids[parent_assignments[child_points]]
3443
+
3444
+ @jit(nopython=True, fastmath=True)
3445
+ def partition_nn_unstructured_optimised(child_mask, parent_frontiers, parent_centroids, neighbours_int, lat, lon, max_distance=20):
3446
+ """
3447
+ Memory-optimised nearest neighbor partitioning for unstructured grids.
3448
+
3449
+ This version uses more efficient memory management compared to partition_nn_unstructured,
3450
+ making it suitable for very large grids. It uses a compact representation of parent
3451
+ frontiers to reduce memory usage during graph traversal.
3452
+
3453
+ Parameters
3454
+ ----------
3455
+ child_mask : np.ndarray
3456
+ 1D boolean array indicating which cells belong to the child object
3457
+ parent_frontiers : np.ndarray
3458
+ 1D uint8 array with parent indices (255 for unvisited points)
3459
+ parent_centroids : np.ndarray
3460
+ Array of shape (n_parents, 2) containing (lat, lon) coordinates
3461
+ neighbours_int : np.ndarray
3462
+ 2D array of shape (3, n_points) containing indices of neighboring cells
3463
+ lat, lon : np.ndarray
3464
+ 1D arrays of latitude/longitude in degrees
3465
+ max_distance : int, default=20
3466
+ Maximum number of edge hops to search for parent points
3467
+
3468
+ Returns
3469
+ -------
3470
+ result : np.ndarray
3471
+ 1D array containing the assigned parent indices for points in child_mask
3472
+ """
3473
+
3474
+ # Create working copies to ensure memory cleanup
3475
+ parent_frontiers_working = parent_frontiers.copy()
3476
+ child_mask_working = child_mask.copy()
3477
+
3478
+ n_parents = np.max(parent_frontiers_working[parent_frontiers_working < 255]) + 1
3479
+
3480
+ # Graph traversal - expanding frontiers
3481
+ current_distance = 0
3482
+ any_unassigned = np.any(child_mask_working & (parent_frontiers_working == 255))
3483
+
3484
+ while current_distance < max_distance and any_unassigned:
3485
+ current_distance += 1
3486
+ updates_made = False
3487
+
3488
+ for parent_idx in range(n_parents):
3489
+ # Skip if no frontier points for this parent
3490
+ if not np.any(parent_frontiers_working == parent_idx):
3491
+ continue
3492
+
3493
+ # Process neighbours for current parent's frontier
3494
+ for i in range(3):
3495
+ neighbors = neighbours_int[i, parent_frontiers_working == parent_idx]
3496
+ valid_neighbors = neighbors >= 0
3497
+
3498
+ if not np.any(valid_neighbors):
3499
+ continue
3500
+
3501
+ valid_points = neighbors[valid_neighbors]
3502
+ unvisited = parent_frontiers_working[valid_points] == 255
3503
+
3504
+ if not np.any(unvisited):
3505
+ continue
3506
+
3507
+ # Update new frontier points
3508
+ new_points = valid_points[unvisited]
3509
+ parent_frontiers_working[new_points] = parent_idx
3510
+
3511
+ if np.any(child_mask_working[new_points]):
3512
+ updates_made = True
3513
+
3514
+ if not updates_made:
3515
+ break
3516
+
3517
+ any_unassigned = np.any(child_mask_working & (parent_frontiers_working == 255))
3518
+
3519
+ # Handle remaining unassigned points using great circle distances
3520
+ unassigned_mask = child_mask_working & (parent_frontiers_working == 255)
3521
+ if np.any(unassigned_mask):
3522
+ # Pre-compute parent coordinates in radians
3523
+ parent_lat_rad = np.deg2rad(parent_centroids[:, 0])
3524
+ parent_lon_rad = np.deg2rad(parent_centroids[:, 1])
3525
+ cos_parent_lat = np.cos(parent_lat_rad)
3526
+
3527
+ # Process each unassigned point
3528
+ unassigned_points = np.where(unassigned_mask)[0]
3529
+ for point in unassigned_points:
3530
+ dlat = parent_lat_rad - np.deg2rad(lat[point])
3531
+ dlon = parent_lon_rad - np.deg2rad(lon[point])
3532
+
3533
+ a = np.sin(dlat/2)**2 + np.cos(np.deg2rad(lat[point])) * cos_parent_lat * np.sin(dlon/2)**2
3534
+ dist = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))
3535
+
3536
+ parent_frontiers_working[point] = np.argmin(dist)
3537
+
3538
+ # Extract result for child points only
3539
+ result = parent_frontiers_working[child_mask_working].copy()
3540
+
3541
+ # Explicitly clear working arrays to help with memory management
3542
+ parent_frontiers_working = None
3543
+ child_mask_working = None
3544
+
3545
+ return result
3546
+
3547
+ @jit(nopython=True, parallel=True, fastmath=True)
3548
+ def partition_centroid_unstructured(child_mask, parent_centroids, child_ids, lat, lon):
3549
+ """
3550
+ Partition a child object based on closest parent centroids on an unstructured grid.
3551
+
3552
+ This function assigns each cell in the child object to the parent with the closest
3553
+ centroid, using great circle distances on a spherical grid.
3554
+
3555
+ Parameters
3556
+ ----------
3557
+ child_mask : np.ndarray
3558
+ 1D boolean array indicating which cells belong to the child object
3559
+ parent_centroids : np.ndarray
3560
+ Array of shape (n_parents, 2) containing (lat, lon) coordinates of parent centroids in degrees
3561
+ child_ids : np.ndarray
3562
+ Array of IDs to assign to each partition of the child object
3563
+ lat, lon : np.ndarray
3564
+ Latitude/longitude arrays in degrees
3565
+
3566
+ Returns
3567
+ -------
3568
+ new_labels : np.ndarray
3569
+ 1D array containing assigned child_ids for cells in child_mask
3570
+ """
3571
+ n_cells = len(child_mask)
3572
+ n_parents = len(parent_centroids)
3573
+
3574
+ # Convert to radians for spherical calculations
3575
+ lat_rad = np.deg2rad(lat)
3576
+ lon_rad = np.deg2rad(lon)
3577
+ parent_coords_rad = np.deg2rad(parent_centroids)
3578
+
3579
+ new_labels = np.zeros(n_cells, dtype=child_ids.dtype)
3580
+
3581
+ # Process each child cell in parallel
3582
+ for i in prange(n_cells):
3583
+ if not child_mask[i]:
3584
+ continue
3585
+
3586
+ min_dist = np.inf
3587
+ closest_parent = 0
3588
+
3589
+ # Calculate great circle distance to each parent centroid
3590
+ for j in range(n_parents):
3591
+ dlat = parent_coords_rad[j, 0] - lat_rad[i]
3592
+ dlon = parent_coords_rad[j, 1] - lon_rad[i]
3593
+
3594
+ # Use haversine formula for great circle distance
3595
+ a = np.sin(dlat/2)**2 + np.cos(lat_rad[i]) * np.cos(parent_coords_rad[j, 0]) * np.sin(dlon/2)**2
3596
+ dist = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))
3597
+
3598
+ if dist < min_dist:
3599
+ min_dist = dist
3600
+ closest_parent = j
3601
+
3602
+ new_labels[i] = child_ids[closest_parent]
3603
+
3604
+ return new_labels
3605
+
3606
+ @njit(fastmath=True, parallel=True)
3607
+ def sparse_bool_power(vec, sp_data, indices, indptr, exponent):
3608
+ """
3609
+ Efficient sparse boolean matrix power operation.
3610
+
3611
+ This function implements a fast sparse matrix power operation for boolean matrices,
3612
+ avoiding memory leaks present in scipy+Dask implementations. It's used for efficient
3613
+ morphological operations on unstructured grids.
3614
+
3615
+ Parameters
3616
+ ----------
3617
+ vec : np.ndarray
3618
+ Boolean vector to multiply
3619
+ sp_data, indices, indptr : np.ndarray
3620
+ Sparse matrix in CSR format
3621
+ exponent : int
3622
+ Number of times to apply the matrix
3623
+
3624
+ Returns
3625
+ -------
3626
+ np.ndarray
3627
+ Result of (sparse_matrix ^ exponent) * vec
3628
+ """
3629
+ vec = vec.T
3630
+ num_rows = indptr.size - 1
3631
+ num_cols = vec.shape[1]
3632
+ result = vec.copy()
3633
+
3634
+ for _ in range(exponent):
3635
+ temp_result = np.zeros((num_rows, num_cols), dtype=np.bool_)
3636
+
3637
+ for i in prange(num_rows):
3638
+ for j in range(indptr[i], indptr[i + 1]):
3639
+ if sp_data[j]:
3640
+ for k in range(num_cols):
3641
+ if result[indices[j], k]:
3642
+ temp_result[i, k] = True
3643
+
3644
+ result = temp_result
3645
+
3646
+ return result.T