@@ -175,19 +175,69 @@ def bounds_to_vertices(
175175 f"Bounds format not understood. Got { bounds .dims } with shape { bounds .shape } ."
176176 )
177177
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+
178183 return xr .apply_ufunc (
179184 _bounds_helper ,
180185 bounds ,
181186 input_core_dims = [core_dims + [bounds_dim ]],
182187 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+ },
184194 output_core_dims = [output_core_dims ],
185195 dask_gufunc_kwargs = dict (output_sizes = output_sizes ),
186196 output_dtypes = [bounds .dtype ],
187197 )
188198
189199
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 ):
191241 if n_core_dims == 2 and nbounds == 4 :
192242 # Vertices case (2D lat/lon)
193243 if order in ["counterclockwise" , None ]:
@@ -211,78 +261,80 @@ def _bounds_helper(values, n_core_dims, nbounds, order):
211261 vertex_vals = np .block ([[bot_left , bot_right ], [top_left , top_right ]])
212262 elif n_core_dims == 1 and nbounds == 2 :
213263 # Middle points case (1D lat/lon)
214- vertex_vals = _get_ordered_vertices (values )
264+ vertex_vals = _get_ordered_vertices (values , core_dim_orders )
215265
216266 return vertex_vals
217267
218268
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 :
220272 """
221273 Convert a bounds array of shape (..., N, 2) or (N, 2) into a 1D array of vertices.
222274
223275 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.
233291
234292 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,).
239296
240297 Parameters
241298 ----------
242299 bounds : np.ndarray
243300 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.
244304
245305 Returns
246306 -------
247307 np.ndarray
248308 Array of vertices with shape (..., N+1) or (N+1,).
249309 """
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 ):
253311 vertices = np .concatenate ((bounds [..., :, 0 ], bounds [..., - 1 :, 1 ]), axis = - 1 )
254312 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 ])
259313
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 ])
263322
264- # Concatenate lower endpoints and the last upper endpoint.
265- # Example Result: [50.0, 50.5, 51.0]
266323 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
268325 )
269326
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 )
277328
278329 return vertices
279330
280331
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.
286338
287339 Parameters
288340 ----------
@@ -292,43 +344,92 @@ def _is_bounds_strictly_monotonic(arr: np.ndarray) -> bool:
292344 Returns
293345 -------
294346 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.
297348 """
298349 # NOTE: Python 3.10 uses numpy 1.26.4. If the input is a datetime64 array,
299350 # numpy 1.26.4 may raise: numpy.core._exceptions._UFuncInputCastingError:
300351 # Cannot cast ufunc 'greater' input 0 from dtype('<m8[ns]') to dtype('<m8')
301352 # with casting rule 'same_kind' To avoid this, always cast to float64 before
302353 # np.diff.
303- arr_numeric = arr .astype ("float64" ).flatten ()
354+ arr_numeric = bounds .astype ("float64" ).flatten ()
304355 diffs = np .diff (arr_numeric )
305356 nonzero_diffs = diffs [diffs != 0 ]
306357
358+ # All values are equal, treat as monotonic
307359 if nonzero_diffs .size == 0 :
308- return True # All values are equal, treat as monotonic
360+ return True
309361
310362 return np .all (nonzero_diffs > 0 ) or np .all (nonzero_diffs < 0 )
311363
312364
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.
315369
316370 Parameters
317371 ----------
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.
321374
322375 Returns
323376 -------
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 :
327399 """
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 ]
330431
331- return np . all ( lower < upper )
432+ return new_vertices
332433
333434
334435def vertices_to_bounds (
0 commit comments