@@ -175,19 +175,79 @@ 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+
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 ):
191251 if n_core_dims == 2 and nbounds == 4 :
192252 # Vertices case (2D lat/lon)
193253 if order in ["counterclockwise" , None ]:
@@ -211,11 +271,176 @@ def _bounds_helper(values, n_core_dims, nbounds, order):
211271 vertex_vals = np .block ([[bot_left , bot_right ], [top_left , top_right ]])
212272 elif n_core_dims == 1 and nbounds == 2 :
213273 # 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 )
215275
216276 return vertex_vals
217277
218278
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+
219444def vertices_to_bounds (
220445 vertices : DataArray , out_dims : Sequence [str ] = ("bounds" , "x" , "y" )
221446) -> DataArray :
0 commit comments