@@ -175,19 +175,79 @@ def bounds_to_vertices(
175
175
f"Bounds format not understood. Got { bounds .dims } with shape { bounds .shape } ."
176
176
)
177
177
178
+ core_dim_coords = {
179
+ dim : bounds .coords [dim ].values for dim in core_dims if dim in bounds .coords
180
+ }
181
+ core_dim_orders = _get_core_dim_orders (core_dim_coords )
182
+
178
183
return xr .apply_ufunc (
179
184
_bounds_helper ,
180
185
bounds ,
181
186
input_core_dims = [core_dims + [bounds_dim ]],
182
187
dask = "parallelized" ,
183
- kwargs = {"n_core_dims" : n_core_dims , "nbounds" : nbounds , "order" : order },
188
+ kwargs = {
189
+ "n_core_dims" : n_core_dims ,
190
+ "nbounds" : nbounds ,
191
+ "order" : order ,
192
+ "core_dim_orders" : core_dim_orders ,
193
+ },
184
194
output_core_dims = [output_core_dims ],
185
195
dask_gufunc_kwargs = dict (output_sizes = output_sizes ),
186
196
output_dtypes = [bounds .dtype ],
187
197
)
188
198
189
199
190
- def _bounds_helper (values , n_core_dims , nbounds , order ):
200
+ def _get_core_dim_orders (core_dim_coords : dict [str , np .ndarray ]) -> dict [str , str ]:
201
+ """
202
+ Determine the order (ascending, descending, or mixed) of each core dimension
203
+ based on its coordinates.
204
+
205
+ Repeated (equal) coordinates are ignored when determining the order. If all
206
+ coordinates are equal, the order is treated as "ascending".
207
+
208
+ Parameters
209
+ ----------
210
+ core_dim_coords : dict of str to np.ndarray
211
+ A dictionary mapping dimension names to their coordinate arrays.
212
+
213
+ Returns
214
+ -------
215
+ core_dim_orders : dict of str to str
216
+ A dictionary mapping each dimension name to a string indicating the order:
217
+ - "ascending": strictly increasing (ignoring repeated values)
218
+ - "descending": strictly decreasing (ignoring repeated values)
219
+ - "mixed": neither strictly increasing nor decreasing (ignoring repeated values)
220
+ """
221
+ core_dim_orders = {}
222
+
223
+ for dim , coords in core_dim_coords .items ():
224
+ diffs = np .diff (coords )
225
+
226
+ # Handle datetime64 and timedelta64 safely for both numpy 1.26.4 and numpy 2
227
+ if np .issubdtype (coords .dtype , np .datetime64 ) or np .issubdtype (
228
+ coords .dtype , np .timedelta64
229
+ ):
230
+ # Cast to float64 for safe comparison
231
+ diffs_float = diffs .astype ("float64" )
232
+ nonzero_diffs = diffs_float [diffs_float != 0 ]
233
+ else :
234
+ zero = 0
235
+ nonzero_diffs = diffs [diffs != zero ]
236
+
237
+ if nonzero_diffs .size == 0 :
238
+ # All values are equal, treat as ascending
239
+ core_dim_orders [dim ] = "ascending"
240
+ elif np .all (nonzero_diffs > 0 ):
241
+ core_dim_orders [dim ] = "ascending"
242
+ elif np .all (nonzero_diffs < 0 ):
243
+ core_dim_orders [dim ] = "descending"
244
+ else :
245
+ core_dim_orders [dim ] = "mixed"
246
+
247
+ return core_dim_orders
248
+
249
+
250
+ def _bounds_helper (values , n_core_dims , nbounds , order , core_dim_orders ):
191
251
if n_core_dims == 2 and nbounds == 4 :
192
252
# Vertices case (2D lat/lon)
193
253
if order in ["counterclockwise" , None ]:
@@ -211,11 +271,176 @@ def _bounds_helper(values, n_core_dims, nbounds, order):
211
271
vertex_vals = np .block ([[bot_left , bot_right ], [top_left , top_right ]])
212
272
elif n_core_dims == 1 and nbounds == 2 :
213
273
# Middle points case (1D lat/lon)
214
- vertex_vals = np . concatenate (( values [..., :, 0 ], values [..., - 1 :, 1 ]), axis = - 1 )
274
+ vertex_vals = _get_ordered_vertices ( values , core_dim_orders )
215
275
216
276
return vertex_vals
217
277
218
278
279
+ def _get_ordered_vertices (
280
+ bounds : np .ndarray , core_dim_orders : dict [str , str ]
281
+ ) -> np .ndarray :
282
+ """
283
+ Convert a bounds array of shape (..., N, 2) or (N, 2) into a 1D array of vertices.
284
+
285
+ This function reconstructs the vertices from a bounds array, handling both
286
+ monotonic and non-monotonic cases.
287
+
288
+ Monotonic bounds (all values strictly increase or decrease when flattened):
289
+ - Concatenate the left endpoints (bounds[..., :, 0]) with the last right
290
+ endpoint (bounds[..., -1, 1]) to form the vertices.
291
+
292
+ Non-monotonic bounds:
293
+ - Determine the order of the core dimension(s) ('ascending' or 'descending').
294
+ - For ascending order:
295
+ - Use the minimum of each interval as the vertex.
296
+ - Use the maximum of the last interval as the final vertex.
297
+ - For descending order:
298
+ - Use the maximum of each interval as the vertex.
299
+ - Use the minimum of the last interval as the final vertex.
300
+ - Vertices are then sorted to match the coordinate direction.
301
+
302
+ Features:
303
+ - Handles both ascending and descending bounds.
304
+ - Preserves repeated coordinates if present.
305
+ - Output shape is (..., N+1) or (N+1,).
306
+
307
+ Parameters
308
+ ----------
309
+ bounds : np.ndarray
310
+ Array of bounds, typically with shape (N, 2) or (..., N, 2).
311
+ core_dim_orders : dict[str, str]
312
+ Dictionary mapping core dimension names to their order ('ascending' or
313
+ 'descending'). Used for sorting the vertices.
314
+
315
+ Returns
316
+ -------
317
+ np.ndarray
318
+ Array of vertices with shape (..., N+1) or (N+1,).
319
+ """
320
+ order = _get_order_of_core_dims (core_dim_orders )
321
+
322
+ if _is_bounds_monotonic (bounds ):
323
+ vertices = np .concatenate ((bounds [..., :, 0 ], bounds [..., - 1 :, 1 ]), axis = - 1 )
324
+ else :
325
+ if order == "ascending" :
326
+ endpoints = np .minimum (bounds [..., :, 0 ], bounds [..., :, 1 ])
327
+ last_endpoint = np .maximum (bounds [..., - 1 , 0 ], bounds [..., - 1 , 1 ])
328
+ elif order == "descending" :
329
+ endpoints = np .maximum (bounds [..., :, 0 ], bounds [..., :, 1 ])
330
+ last_endpoint = np .minimum (bounds [..., - 1 , 0 ], bounds [..., - 1 , 1 ])
331
+
332
+ vertices = np .concatenate (
333
+ [endpoints , np .expand_dims (last_endpoint , axis = - 1 )], axis = - 1
334
+ )
335
+
336
+ vertices = _sort_vertices (vertices , order )
337
+
338
+ return vertices
339
+
340
+
341
+ def _is_bounds_monotonic (bounds : np .ndarray ) -> bool :
342
+ """Check if the bounds are monotonic.
343
+
344
+ Arrays are monotonic if all values are increasing or decreasing. This
345
+ functions ignores an intervals where consecutive values are equal, which
346
+ represent repeated coordinates.
347
+
348
+ Parameters
349
+ ----------
350
+ arr : np.ndarray
351
+ Numpy array to check, typically with shape (..., N, 2).
352
+
353
+ Returns
354
+ -------
355
+ bool
356
+ True if the flattened array is increasing or decreasing, False otherwise.
357
+ """
358
+ # NOTE: Python 3.10 uses numpy 1.26.4. If the input is a datetime64 array,
359
+ # numpy 1.26.4 may raise: numpy.core._exceptions._UFuncInputCastingError:
360
+ # Cannot cast ufunc 'greater' input 0 from dtype('<m8[ns]') to dtype('<m8')
361
+ # with casting rule 'same_kind' To avoid this, always cast to float64 before
362
+ # np.diff.
363
+ arr_numeric = bounds .astype ("float64" ).flatten ()
364
+ diffs = np .diff (arr_numeric )
365
+ nonzero_diffs = diffs [diffs != 0 ]
366
+
367
+ # All values are equal, treat as monotonic
368
+ if nonzero_diffs .size == 0 :
369
+ return True
370
+
371
+ return bool (np .all (nonzero_diffs > 0 ) or np .all (nonzero_diffs < 0 ))
372
+
373
+
374
+ def _get_order_of_core_dims (core_dim_orders : dict [str , str ]) -> str :
375
+ """
376
+ Determines the common order of core dimensions from a dictionary of
377
+ dimension orders.
378
+
379
+ Parameters
380
+ ----------
381
+ core_dim_orders : dict of str
382
+ A dictionary mapping dimension names to their respective order strings.
383
+
384
+ Returns
385
+ -------
386
+ order : str
387
+ The common order string shared by all core dimensions.
388
+
389
+ Raises
390
+ ------
391
+ ValueError
392
+ If the core dimension orders are not all aligned (i.e., not all values
393
+ are the same).
394
+ """
395
+ orders = set (core_dim_orders .values ())
396
+
397
+ if len (orders ) != 1 :
398
+ raise ValueError (
399
+ f"All core dimension orders must be aligned. Got orders: { core_dim_orders } "
400
+ )
401
+
402
+ order = next (iter (orders ))
403
+
404
+ return order
405
+
406
+
407
+ def _sort_vertices (vertices : np .ndarray , order : str ) -> np .ndarray :
408
+ """
409
+ Sorts the vertices array along the last axis in ascending or descending order.
410
+
411
+ Parameters
412
+ ----------
413
+ vertices : np.ndarray
414
+ An array of vertices to be sorted. Sorting is performed along the last
415
+ axis.
416
+ order : str
417
+ The order in which to sort the vertices. Must be either "ascending" or
418
+ any other value for descending order.
419
+
420
+ Returns
421
+ -------
422
+ np.ndarray
423
+ The sorted array of vertices, with the same shape as the input.
424
+
425
+ Examples
426
+ --------
427
+ >>> import numpy as np
428
+ >>> vertices = np.array([[3, 1, 2], [6, 5, 4]])
429
+ >>> _sort_vertices(vertices, "ascending")
430
+ array([[1, 2, 3],
431
+ [4, 5, 6]])
432
+ >>> _sort_vertices(vertices, "descending")
433
+ array([[3, 2, 1],
434
+ [6, 5, 4]])
435
+ """
436
+ if order == "ascending" :
437
+ new_vertices = np .sort (vertices , axis = - 1 )
438
+ else :
439
+ new_vertices = np .sort (vertices , axis = - 1 )[..., ::- 1 ]
440
+
441
+ return new_vertices
442
+
443
+
219
444
def vertices_to_bounds (
220
445
vertices : DataArray , out_dims : Sequence [str ] = ("bounds" , "x" , "y" )
221
446
) -> DataArray :
0 commit comments