@@ -175,19 +175,69 @@ 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
+ nonzero_diffs = diffs [diffs != 0 ]
226
+
227
+ if nonzero_diffs .size == 0 :
228
+ # All values are equal, treat as ascending
229
+ core_dim_orders [dim ] = "ascending"
230
+ elif np .all (nonzero_diffs > 0 ):
231
+ core_dim_orders [dim ] = "ascending"
232
+ elif np .all (nonzero_diffs < 0 ):
233
+ core_dim_orders [dim ] = "descending"
234
+ else :
235
+ core_dim_orders [dim ] = "mixed"
236
+
237
+ return core_dim_orders
238
+
239
+
240
+ def _bounds_helper (values , n_core_dims , nbounds , order , core_dim_orders ):
191
241
if n_core_dims == 2 and nbounds == 4 :
192
242
# Vertices case (2D lat/lon)
193
243
if order in ["counterclockwise" , None ]:
@@ -211,78 +261,80 @@ def _bounds_helper(values, n_core_dims, nbounds, order):
211
261
vertex_vals = np .block ([[bot_left , bot_right ], [top_left , top_right ]])
212
262
elif n_core_dims == 1 and nbounds == 2 :
213
263
# Middle points case (1D lat/lon)
214
- vertex_vals = _get_ordered_vertices (values )
264
+ vertex_vals = _get_ordered_vertices (values , core_dim_orders )
215
265
216
266
return vertex_vals
217
267
218
268
219
- def _get_ordered_vertices (bounds : np .ndarray ) -> np .ndarray :
269
+ def _get_ordered_vertices (
270
+ bounds : np .ndarray , core_dim_orders : dict [str , str ]
271
+ ) -> np .ndarray :
220
272
"""
221
273
Convert a bounds array of shape (..., N, 2) or (N, 2) into a 1D array of vertices.
222
274
223
275
This function reconstructs the vertices from a bounds array, handling both
224
- strictly monotonic and non-strictly monotonic bounds.
225
-
226
- - For strictly monotonic bounds (all values increase or decrease when flattened),
227
- it concatenates the left endpoints and the last right endpoint.
228
- - For non-strictly monotonic bounds (bounds are consistently ascending or descending
229
- within intervals, but not strictly so), it:
230
- - Uses the minimum of each interval as the lower endpoint.
231
- - Uses the maximum of the last interval as the final vertex.
232
- - Sorts the vertices in ascending or descending order to match the direction of the bounds.
276
+ monotonic and non-monotonic cases.
277
+
278
+ Monotonic bounds (all values strictly increase or decrease when flattened):
279
+ - Concatenate the left endpoints (bounds[..., :, 0]) with the last right
280
+ endpoint (bounds[..., -1, 1]) to form the vertices.
281
+
282
+ Non-monotonic bounds:
283
+ - Determine the order of the core dimension(s) ('ascending' or 'descending').
284
+ - For ascending order:
285
+ - Use the minimum of each interval as the vertex.
286
+ - Use the maximum of the last interval as the final vertex.
287
+ - For descending order:
288
+ - Use the maximum of each interval as the vertex.
289
+ - Use the minimum of the last interval as the final vertex.
290
+ - Vertices are then sorted to match the coordinate direction.
233
291
234
292
Features:
235
- - Handles both ascending and descending bounds.
236
- - Does not require bounds to be strictly monotonic.
237
- - Preserves repeated coordinates if present.
238
- - Output shape is (..., N+1) or (N+1,).
293
+ - Handles both ascending and descending bounds.
294
+ - Preserves repeated coordinates if present.
295
+ - Output shape is (..., N+1) or (N+1,).
239
296
240
297
Parameters
241
298
----------
242
299
bounds : np.ndarray
243
300
Array of bounds, typically with shape (N, 2) or (..., N, 2).
301
+ core_dim_orders : dict[str, str]
302
+ Dictionary mapping core dimension names to their order ('ascending' or
303
+ 'descending'). Used for sorting the vertices.
244
304
245
305
Returns
246
306
-------
247
307
np.ndarray
248
308
Array of vertices with shape (..., N+1) or (N+1,).
249
309
"""
250
- if _is_bounds_strictly_monotonic (bounds ):
251
- # Example: [[51.0, 50.5], [50.5, 50.0]]
252
- # Example Result: [51.0, 50.5, 50.0]
310
+ if _is_bounds_monotonic (bounds ):
253
311
vertices = np .concatenate ((bounds [..., :, 0 ], bounds [..., - 1 :, 1 ]), axis = - 1 )
254
312
else :
255
- # Example with bounds (descending) [[50.5, 50.0], [51.0, 50.5]]
256
- # Get the lower endpoints of each bounds interval
257
- # Example Result: [50, 50.5]
258
- lower_endpoints = np .minimum (bounds [..., :, 0 ], bounds [..., :, 1 ])
259
313
260
- # Get the upper endpoint of the last interval.
261
- # Example Result: 51.0
262
- last_upper_endpoint = np .maximum (bounds [..., - 1 , 0 ], bounds [..., - 1 , 1 ])
314
+ order = _get_order_of_core_dims (core_dim_orders )
315
+
316
+ if order == "ascending" :
317
+ endpoints = np .minimum (bounds [..., :, 0 ], bounds [..., :, 1 ])
318
+ last_endpoint = np .maximum (bounds [..., - 1 , 0 ], bounds [..., - 1 , 1 ])
319
+ elif order == "descending" :
320
+ endpoints = np .maximum (bounds [..., :, 0 ], bounds [..., :, 1 ])
321
+ last_endpoint = np .minimum (bounds [..., - 1 , 0 ], bounds [..., - 1 , 1 ])
263
322
264
- # Concatenate lower endpoints and the last upper endpoint.
265
- # Example Result: [50.0, 50.5, 51.0]
266
323
vertices = np .concatenate (
267
- [lower_endpoints , np .expand_dims (last_upper_endpoint , axis = - 1 )], axis = - 1
324
+ [endpoints , np .expand_dims (last_endpoint , axis = - 1 )], axis = - 1
268
325
)
269
326
270
- # Sort vertices based on the direction of the bounds
271
- # Example Result: [51.0, 50.5, 50.0]
272
- ascending = is_bounds_ascending (bounds )
273
- if ascending :
274
- vertices = np .sort (vertices , axis = - 1 )
275
- else :
276
- vertices = np .sort (vertices , axis = - 1 )[..., ::- 1 ]
327
+ vertices = _sort_vertices (vertices , order )
277
328
278
329
return vertices
279
330
280
331
281
- def _is_bounds_strictly_monotonic (arr : np .ndarray ) -> bool :
282
- """
283
- Check if the input array is strictly monotonic (either strictly increasing
284
- or strictly decreasing) when flattened, ignoring any intervals where
285
- consecutive values are equal.
332
+ def _is_bounds_monotonic (bounds : np .ndarray ) -> bool :
333
+ """Check if the bounds are monotonic.
334
+
335
+ Arrays are monotonic if all values are increasing or decreasing. This
336
+ functions ignores an intervals where consecutive values are equal, which
337
+ represent repeated coordinates.
286
338
287
339
Parameters
288
340
----------
@@ -292,43 +344,92 @@ def _is_bounds_strictly_monotonic(arr: np.ndarray) -> bool:
292
344
Returns
293
345
-------
294
346
bool
295
- True if the flattened array is strictly increasing or decreasing,
296
- False otherwise.
347
+ True if the flattened array is increasing or decreasing, False otherwise.
297
348
"""
298
349
# NOTE: Python 3.10 uses numpy 1.26.4. If the input is a datetime64 array,
299
350
# numpy 1.26.4 may raise: numpy.core._exceptions._UFuncInputCastingError:
300
351
# Cannot cast ufunc 'greater' input 0 from dtype('<m8[ns]') to dtype('<m8')
301
352
# with casting rule 'same_kind' To avoid this, always cast to float64 before
302
353
# np.diff.
303
- arr_numeric = arr .astype ("float64" ).flatten ()
354
+ arr_numeric = bounds .astype ("float64" ).flatten ()
304
355
diffs = np .diff (arr_numeric )
305
356
nonzero_diffs = diffs [diffs != 0 ]
306
357
358
+ # All values are equal, treat as monotonic
307
359
if nonzero_diffs .size == 0 :
308
- return True # All values are equal, treat as monotonic
360
+ return True
309
361
310
362
return np .all (nonzero_diffs > 0 ) or np .all (nonzero_diffs < 0 )
311
363
312
364
313
- def is_bounds_ascending (bounds : np .ndarray ) -> bool :
314
- """Check if bounds are in ascending order (between intervals).
365
+ def _get_order_of_core_dims (core_dim_orders : dict [str , str ]) -> str :
366
+ """
367
+ Determines the common order of core dimensions from a dictionary of
368
+ dimension orders.
315
369
316
370
Parameters
317
371
----------
318
- bounds : np.ndarray
319
- An array containing bounds information, typically with shape (N, 2)
320
- or (..., N, 2).
372
+ core_dim_orders : dict of str
373
+ A dictionary mapping dimension names to their respective order strings.
321
374
322
375
Returns
323
376
-------
324
- bool
325
- True if bounds are in ascending order, False if they are in descending
326
- order.
377
+ order : str
378
+ The common order string shared by all core dimensions.
379
+
380
+ Raises
381
+ ------
382
+ ValueError
383
+ If the core dimension orders are not all aligned (i.e., not all values
384
+ are the same).
385
+ """
386
+ orders = set (core_dim_orders .values ())
387
+
388
+ if len (orders ) != 1 :
389
+ raise ValueError (
390
+ f"All core dimension orders must be aligned. Got orders: { core_dim_orders } "
391
+ )
392
+
393
+ order = next (iter (orders ))
394
+
395
+ return order
396
+
397
+
398
+ def _sort_vertices (vertices : np .ndarray , order : str ) -> np .ndarray :
327
399
"""
328
- lower = bounds [..., :, 0 ]
329
- upper = bounds [..., :, 1 ]
400
+ Sorts the vertices array along the last axis in ascending or descending order.
401
+
402
+ Parameters
403
+ ----------
404
+ vertices : np.ndarray
405
+ An array of vertices to be sorted. Sorting is performed along the last
406
+ axis.
407
+ order : str
408
+ The order in which to sort the vertices. Must be either "ascending" or
409
+ any other value for descending order.
410
+
411
+ Returns
412
+ -------
413
+ np.ndarray
414
+ The sorted array of vertices, with the same shape as the input.
415
+
416
+ Examples
417
+ --------
418
+ >>> import numpy as np
419
+ >>> vertices = np.array([[3, 1, 2], [6, 5, 4]])
420
+ >>> _sort_vertices(vertices, "ascending")
421
+ array([[1, 2, 3],
422
+ [4, 5, 6]])
423
+ >>> _sort_vertices(vertices, "descending")
424
+ array([[3, 2, 1],
425
+ [6, 5, 4]])
426
+ """
427
+ if order == "ascending" :
428
+ new_vertices = np .sort (vertices , axis = - 1 )
429
+ else :
430
+ new_vertices = np .sort (vertices , axis = - 1 )[..., ::- 1 ]
330
431
331
- return np . all ( lower < upper )
432
+ return new_vertices
332
433
333
434
334
435
def vertices_to_bounds (
0 commit comments