Skip to content

Commit 31e7282

Browse files
committed
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
1 parent 5a86c58 commit 31e7282

File tree

2 files changed

+162
-60
lines changed

2 files changed

+162
-60
lines changed

cf_xarray/helpers.py

Lines changed: 158 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
order = _get_order_of_core_dims(core_dim_orders)
311+
312+
if _is_bounds_monotonic(bounds):
253313
vertices = np.concatenate((bounds[..., :, 0], bounds[..., -1:, 1]), axis=-1)
254314
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])
259315

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])
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

334435
def vertices_to_bounds(

cf_xarray/tests/test_helpers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,20 @@ def test_bounds_to_vertices() -> None:
3535
lon_no = cfxr.bounds_to_vertices(rotds.lon_bounds, bounds_dim="bounds", order=None)
3636
assert_equal(lon_no, lon_ccw)
3737

38-
# 2D case (descending)
38+
# 2D case (monotonicly increasing coords, non-monotonic bounds)
3939
bounds_2d_desc = xr.DataArray(
4040
[[50.5, 50.0], [51.0, 50.5], [51.0, 50.5], [52.0, 51.5], [52.5, 52.0]],
4141
dims=("lat", "bounds"),
42+
coords={"lat": [50.75, 50.75, 51.25, 51.75, 52.25]},
4243
)
4344
expected_vertices_2d_desc = xr.DataArray(
44-
[52.5, 52.0, 51.5, 50.5, 50.5, 50.0],
45+
[50.0, 50.5, 50.5, 51.5, 52.0, 52.5],
4546
dims=["lat_vertices"],
4647
)
4748
vertices_2d_desc = cfxr.bounds_to_vertices(bounds_2d_desc, bounds_dim="bounds")
4849
assert_equal(expected_vertices_2d_desc, vertices_2d_desc)
4950

50-
# 3D case (ascending, "extra" non-core dim should be preserved)
51+
# 3D case (non-monotonic bounds, monotonicly increasing coords)
5152
bounds_3d = xr.DataArray(
5253
[
5354
[

0 commit comments

Comments
 (0)