Skip to content

Commit cfe87d0

Browse files
Update bounds_to_vertices to handle descending arrays (#579)
* Update `bounds_to_vertices` to handle descending arrays Add `_get_ordered_vertices()` to handle extraction of unique vertex values in ascending order * Update docstring * Update `_get_ordered_vertices()` to preserve order of values * Fix `_get_ordered_vertices()` to preserve leading dims - Also handle strictly monotonic bounds * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix casting issue with numpy 1.26.4 * Update docstrings for clarity * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix logic for checking strictly monotonic * Fix logic for checking strictly monotonic * Delete qa/mvce.py * Update `bounds_to_vertices to sort on coordinates - Add `_get_core_dim_orders()` to get core dim orders for sorting vertices - Update `_bounds_helper()` to accept `core_dim_orders` param - Update `_get_ordered_vertices()` to sort on `core_dim_orders` param - Fix `_get_ordered_vertices()` to get correct array values for vertices based on order of core dims * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix 3d vertices test * Add typecasting to prevent numpy 1.26.4 issue * Add typecasting to prevent numpy 1.26.4 issue * Add typecasting to prevent numpy 1.26.4 issue * Fix mypy error expecting bool not np.bool --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 3829d0c commit cfe87d0

File tree

2 files changed

+283
-4
lines changed

2 files changed

+283
-4
lines changed

cf_xarray/helpers.py

Lines changed: 228 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
219444
def vertices_to_bounds(
220445
vertices: DataArray, out_dims: Sequence[str] = ("bounds", "x", "y")
221446
) -> DataArray:

cf_xarray/tests/test_helpers.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import xarray as xr
12
from numpy.testing import assert_array_equal
23
from xarray.testing import assert_equal
34

@@ -12,7 +13,7 @@
1213

1314

1415
def test_bounds_to_vertices() -> None:
15-
# 1D case
16+
# 1D case (stricly monotonic, descending bounds)
1617
ds = airds.cf.add_bounds(["lon", "lat", "time"])
1718
lat_c = cfxr.bounds_to_vertices(ds.lat_bounds, bounds_dim="bounds")
1819
assert_array_equal(ds.lat.values + 1.25, lat_c.values[:-1])
@@ -34,6 +35,59 @@ def test_bounds_to_vertices() -> None:
3435
lon_no = cfxr.bounds_to_vertices(rotds.lon_bounds, bounds_dim="bounds", order=None)
3536
assert_equal(lon_no, lon_ccw)
3637

38+
# 2D case (monotonicly increasing coords, non-monotonic bounds)
39+
bounds_2d_desc = xr.DataArray(
40+
[[50.5, 50.0], [51.0, 50.5], [51.0, 50.5], [52.0, 51.5], [52.5, 52.0]],
41+
dims=("lat", "bounds"),
42+
coords={"lat": [50.75, 50.75, 51.25, 51.75, 52.25]},
43+
)
44+
expected_vertices_2d_desc = xr.DataArray(
45+
[50.0, 50.5, 50.5, 51.5, 52.0, 52.5],
46+
dims=["lat_vertices"],
47+
)
48+
vertices_2d_desc = cfxr.bounds_to_vertices(bounds_2d_desc, bounds_dim="bounds")
49+
assert_equal(expected_vertices_2d_desc, vertices_2d_desc)
50+
51+
# 3D case (non-monotonic bounds, monotonicly increasing coords)
52+
bounds_3d = xr.DataArray(
53+
[
54+
[
55+
[50.0, 50.5],
56+
[50.5, 51.0],
57+
[51.0, 51.5],
58+
[51.5, 52.0],
59+
[52.0, 52.5],
60+
],
61+
[
62+
[60.0, 60.5],
63+
[60.5, 61.0],
64+
[61.0, 61.5],
65+
[61.5, 62.0],
66+
[62.0, 62.5],
67+
],
68+
],
69+
dims=("extra", "lat", "bounds"),
70+
coords={
71+
"extra": [0, 1],
72+
"lat": [0, 1, 2, 3, 4],
73+
"bounds": [0, 1],
74+
},
75+
)
76+
expected_vertices_3d = xr.DataArray(
77+
[
78+
[50.0, 50.5, 51.0, 51.5, 52.0, 52.5],
79+
[60.0, 60.5, 61.0, 61.5, 62.0, 62.5],
80+
],
81+
dims=("extra", "lat_vertices"),
82+
coords={
83+
"extra": [0, 1],
84+
},
85+
)
86+
vertices_3d = cfxr.bounds_to_vertices(
87+
bounds_3d, bounds_dim="bounds", core_dims=["lat"]
88+
)
89+
assert_equal(vertices_3d, expected_vertices_3d)
90+
3791
# Transposing the array changes the bounds direction
3892
ds = mollwds.transpose("x", "y", "x_vertices", "y_vertices", "bounds")
3993
lon_cw = cfxr.bounds_to_vertices(

0 commit comments

Comments
 (0)