Skip to content

Update bounds_to_vertices to handle descending arrays #579

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged

Conversation

tomvothecoder
Copy link
Contributor

@tomvothecoder tomvothecoder commented Jul 9, 2025

This pull request updates the _bounds_helper function in cf_xarray/helpers.py to be able to handle descending arrays (bounds). The output vertices will be sorted in the same direction as the input bounds, as shown in the example.

Example

import xarray as xr
import cf_xarray as cfxr

# Descending bounds
bounds = xr.DataArray([[50.5, 50. ], [51.,  50.5], [51.5, 51. ], [52.,  51.5], [52.5, 52.]], dims=('lat', 'bounds'))
# Descending vertices
cfxr.bounds_to_vertices(bounds, "bounds") # array([52.5, 52. , 51.5, 51. , 50.5, 50. ])

# Ascending bounds
bounds = xr.DataArray(
    [[50.0, 50.5], [50.5, 51.0], [51.0, 51.5], [51.5, 52.0], [52.0, 52.5]],
    dims=("lat", "bounds"),
)
# Ascending vertices
cfxr.bounds_to_vertices(bounds, "bounds") # array([50. , 50.5, 51. , 51.5, 52. , 52.5])

Related to

Copy link

codecov bot commented Jul 9, 2025

Codecov Report

❌ Patch coverage is 93.33333% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.62%. Comparing base (a9cebee) to head (322a538).
⚠️ Report is 63 commits behind head on main.

Files with missing lines Patch % Lines
cf_xarray/helpers.py 93.33% 1 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #579      +/-   ##
==========================================
+ Coverage   85.78%   86.62%   +0.83%     
==========================================
  Files          13       15       +2     
  Lines        2364     3027     +663     
  Branches      183      296     +113     
==========================================
+ Hits         2028     2622     +594     
- Misses        303      353      +50     
- Partials       33       52      +19     
Flag Coverage Δ
mypy 44.32% <50.00%> (+5.79%) ⬆️
unittests 93.50% <86.53%> (-0.48%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tomvothecoder tomvothecoder marked this pull request as ready for review July 9, 2025 17:56
@tomvothecoder
Copy link
Contributor Author

Hey @aulemahal, this PR is ready for review when you get the chance.

Copy link
Contributor

@aulemahal aulemahal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @tomvothecoder for the PR. While this works well with the usual case, it seems to drop support for some other cases (duplicated coordinates, extra non-core dimensions).

Copy link
Contributor Author

@tomvothecoder tomvothecoder left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @aulemahal, this PR is ready for another review. Please make any changes or suggestions if you think the code could be better written or simplified.

I added new tests to cover additional cases and provided some examples below.

Example script:

import xarray as xr

import cf_xarray as cfxr


def print_section(title):
    print(f"\n{title}")
    print("=" * len(title))


def print_bounds_and_vertices(bounds, vertices):
    print("Bounds:")
    print(bounds)
    print("Vertices:")
    print(vertices)
    print("-" * 40)


# 0a. Strictly monotonic bounds (increasing)
print_section("0a. Strictly monotonic bounds (increasing)")
bounds = xr.DataArray(
    [[10.0, 10.5], [10.5, 11.0], [11.0, 11.5], [11.5, 12.0], [12.0, 12.5]],
    dims=("lat", "bounds"),
)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 0b. Strictly monotonic bounds (decreasing)
print_section("0b. Strictly monotonic bounds (decreasing)")
bounds = xr.DataArray(
    [[12.5, 12.0], [12.0, 11.5], [11.5, 11.0], [11.0, 10.5], [10.5, 10.0]],
    dims=("lat", "bounds"),
)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 1. Descending coordinates
print_section("1. Descending coordinates")
bounds = xr.DataArray(
    [[50.5, 50.0], [51.0, 50.5], [51.5, 51.0], [52.0, 51.5], [52.5, 52.0]],
    dims=("lat", "bounds"),
)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 2. Descending coordinates with duplicated values
print_section("2. Descending coordinates with duplicated values")
bounds = xr.DataArray(
    [[50.5, 50.0], [51.0, 50.5], [51.0, 50.5], [52.0, 51.5], [52.5, 52.0]],
    dims=("lat", "bounds"),
)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 3. Ascending coordinates
print_section("3. Ascending coordinates")
bounds = xr.DataArray(
    [[50.0, 50.5], [50.5, 51.0], [51.0, 51.5], [51.5, 52.0], [52.0, 52.5]],
    dims=("lat", "bounds"),
)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 4. Ascending coordinates with duplicated values
print_section("4. Ascending coordinates with duplicated values")
bounds = xr.DataArray(
    [[50.0, 50.5], [50.5, 51.0], [50.5, 51.0], [51.0, 51.5], [51.5, 52.0]],
    dims=("lat", "bounds"),
)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 5. 3D array (extra non-core dim)
print_section("5. 3D array (extra non-core dim)")
bounds_3d = xr.DataArray(
    [
        [
            [50.0, 50.5],
            [50.5, 51.0],
            [51.0, 51.5],
            [51.5, 52.0],
            [52.0, 52.5],
        ],
        [
            [60.0, 60.5],
            [60.5, 61.0],
            [61.0, 61.5],
            [61.5, 62.0],
            [62.0, 62.5],
        ],
    ],
    dims=("extra", "lat", "bounds"),
)
vertices_3d = cfxr.bounds_to_vertices(bounds_3d, "bounds", core_dims=["lat"])
print_bounds_and_vertices(bounds_3d, vertices_3d)

# 6. 4D array (time, extra, lat, bounds)
print_section("6. 4D array (time, extra, lat, bounds)")
bounds_4d = xr.DataArray(
    [
        [
            [
                [50.0, 50.5],
                [50.5, 51.0],
                [51.0, 51.5],
                [51.5, 52.0],
                [52.0, 52.5],
            ],
            [
                [60.0, 60.5],
                [60.5, 61.0],
                [61.0, 61.5],
                [61.5, 62.0],
                [62.0, 62.5],
            ],
        ],
        [
            [
                [70.0, 70.5],
                [70.5, 71.0],
                [71.0, 71.5],
                [71.5, 72.0],
                [72.0, 72.5],
            ],
            [
                [80.0, 80.5],
                [80.5, 81.0],
                [81.0, 81.5],
                [81.5, 82.0],
                [82.0, 82.5],
            ],
        ],
    ],
    dims=("time", "extra", "lat", "bounds"),
)
vertices_4d = cfxr.bounds_to_vertices(bounds_4d, "bounds", core_dims=["lat"])
print_bounds_and_vertices(bounds_4d, vertices_4d)

Example result (all seem good)

0a. Strictly monotonic bounds (increasing)
==========================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[10. , 10.5],
       [10.5, 11. ],
       [11. , 11.5],
       [11.5, 12. ],
       [12. , 12.5]])
Dimensions without coordinates: lat, bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([10. , 10.5, 11. , 11.5, 12. , 12.5])
Dimensions without coordinates: lat_vertices
----------------------------------------

0b. Strictly monotonic bounds (decreasing)
==========================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[12.5, 12. ],
       [12. , 11.5],
       [11.5, 11. ],
       [11. , 10.5],
       [10.5, 10. ]])
Dimensions without coordinates: lat, bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([12.5, 12. , 11.5, 11. , 10.5, 10. ])
Dimensions without coordinates: lat_vertices
----------------------------------------

1. Descending coordinates
=========================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[50.5, 50. ],
       [51. , 50.5],
       [51.5, 51. ],
       [52. , 51.5],
       [52.5, 52. ]])
Dimensions without coordinates: lat, bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([52.5, 52. , 51.5, 51. , 50.5, 50. ])
Dimensions without coordinates: lat_vertices
----------------------------------------

2. Descending coordinates with duplicated values
================================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[50.5, 50. ],
       [51. , 50.5],
       [51. , 50.5],
       [52. , 51.5],
       [52.5, 52. ]])
Dimensions without coordinates: lat, bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([52.5, 52. , 51.5, 50.5, 50.5, 50. ])
Dimensions without coordinates: lat_vertices
----------------------------------------

3. Ascending coordinates
========================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[50. , 50.5],
       [50.5, 51. ],
       [51. , 51.5],
       [51.5, 52. ],
       [52. , 52.5]])
Dimensions without coordinates: lat, bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([50. , 50.5, 51. , 51.5, 52. , 52.5])
Dimensions without coordinates: lat_vertices
----------------------------------------

4. Ascending coordinates with duplicated values
===============================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[50. , 50.5],
       [50.5, 51. ],
       [50.5, 51. ],
       [51. , 51.5],
       [51.5, 52. ]])
Dimensions without coordinates: lat, bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([50. , 50.5, 50.5, 51. , 51.5, 52. ])
Dimensions without coordinates: lat_vertices
----------------------------------------

5. 3D array (extra non-core dim)
================================
Bounds:
<xarray.DataArray (extra: 2, lat: 5, bounds: 2)> Size: 160B
array([[[50. , 50.5],
        [50.5, 51. ],
        [51. , 51.5],
        [51.5, 52. ],
        [52. , 52.5]],

       [[60. , 60.5],
        [60.5, 61. ],
        [61. , 61.5],
        [61.5, 62. ],
        [62. , 62.5]]])
Dimensions without coordinates: extra, lat, bounds
Vertices:
<xarray.DataArray (extra: 2, lat_vertices: 6)> Size: 96B
array([[50. , 50.5, 51. , 51.5, 52. , 52.5],
       [60. , 60.5, 61. , 61.5, 62. , 62.5]])
Dimensions without coordinates: extra, lat_vertices
----------------------------------------

6. 4D array (time, extra, lat, bounds)
======================================
Bounds:
<xarray.DataArray (time: 2, extra: 2, lat: 5, bounds: 2)> Size: 320B
array([[[[50. , 50.5],
         [50.5, 51. ],
         [51. , 51.5],
         [51.5, 52. ],
         [52. , 52.5]],

        [[60. , 60.5],
         [60.5, 61. ],
         [61. , 61.5],
         [61.5, 62. ],
         [62. , 62.5]]],


       [[[70. , 70.5],
         [70.5, 71. ],
         [71. , 71.5],
         [71.5, 72. ],
         [72. , 72.5]],

        [[80. , 80.5],
         [80.5, 81. ],
         [81. , 81.5],
         [81.5, 82. ],
         [82. , 82.5]]]])
Dimensions without coordinates: time, extra, lat, bounds
Vertices:
<xarray.DataArray (time: 2, extra: 2, lat_vertices: 6)> Size: 192B
array([[[50. , 50.5, 51. , 51.5, 52. , 52.5],
        [60. , 60.5, 61. , 61.5, 62. , 62.5]],

       [[70. , 70.5, 71. , 71.5, 72. , 72.5],
        [80. , 80.5, 81. , 81.5, 82. , 82.5]]])
Dimensions without coordinates: time, extra, lat_vertices
----------------------------------------

Comment on lines 249 to 313
if _is_bounds_strictly_monotonic(bounds):
# Example: [[51.0, 50.5], [50.5, 50.0]]
# Example Result: [51.0, 50.5, 50.0]
vertices = np.concatenate((bounds[..., :, 0], bounds[..., -1:, 1]), axis=-1)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This portion of code now handles "strictly" monotonic bounds, which are when values are all descending/ascending if flattened. For example:

  • strictly monotonic (descending) [[51.5, 51.0], [51.0, 50.5], [50.5, 50.0]]
  • Non-strictly monotonic (descending): [[50.5, 50.0], [51.0, 50.5], [51.5, 51.0]]

I noticed the 1D test case was failing with because the values are "strictly" monotonic. Now it passes.

def test_bounds_to_vertices() -> None:
# 1D case
ds = airds.cf.add_bounds(["lon", "lat", "time"])
lat_c = cfxr.bounds_to_vertices(ds.lat_bounds, bounds_dim="bounds")
assert_array_equal(ds.lat.values + 1.25, lat_c.values[:-1])

Comment on lines 38 to 90
# 2D case (descending)
bounds_2d_desc = xr.DataArray(
[[50.5, 50.0], [51.0, 50.5], [51.0, 50.5], [52.0, 51.5], [52.5, 52.0]],
dims=("lat", "bounds"),
)
expected_vertices_2d_desc = xr.DataArray(
[52.5, 52.0, 51.5, 50.5, 50.5, 50.0],
dims=["lat_vertices"],
)
vertices_2d_desc = cfxr.bounds_to_vertices(bounds_2d_desc, bounds_dim="bounds")
assert_equal(expected_vertices_2d_desc, vertices_2d_desc)

# 3D case (ascending, "extra" non-core dim should be preserved)
bounds_3d = xr.DataArray(
[
[
[50.0, 50.5],
[50.5, 51.0],
[51.0, 51.5],
[51.5, 52.0],
[52.0, 52.5],
],
[
[60.0, 60.5],
[60.5, 61.0],
[61.0, 61.5],
[61.5, 62.0],
[62.0, 62.5],
],
],
dims=("extra", "lat", "bounds"),
)
expected_vertices_3d = xr.DataArray(
[
[50.0, 50.5, 51.0, 51.5, 52.0, 52.5],
[60.0, 60.5, 61.0, 61.5, 62.0, 62.5],
],
dims=("extra", "lat_vertices"),
)
vertices_3d = cfxr.bounds_to_vertices(
bounds_3d, bounds_dim="bounds", core_dims=["lat"]
)
assert_equal(vertices_3d, expected_vertices_3d)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some new test cases that were missing.

Comment on lines 219 to 328
def _get_ordered_vertices(bounds: np.ndarray) -> np.ndarray:
"""
Convert a bounds array of shape (..., N, 2) or (N, 2) into a 1D array of vertices.

This function reconstructs the vertices from a bounds array, handling both
strictly monotonic and non-strictly monotonic bounds.

- For strictly monotonic bounds (all values increase or decrease when flattened),
it concatenates the left endpoints and the last right endpoint.
- For non-strictly monotonic bounds (bounds are consistently ascending or descending
within intervals, but not strictly so), it:
- Uses the minimum of each interval as the lower endpoint.
- Uses the maximum of the last interval as the final vertex.
- Sorts the vertices in ascending or descending order to match the direction of the bounds.

Features:
- Handles both ascending and descending bounds.
- Does not require bounds to be strictly monotonic.
- Preserves repeated coordinates if present.
- Output shape is (..., N+1) or (N+1,).

Parameters
----------
bounds : np.ndarray
Array of bounds, typically with shape (N, 2) or (..., N, 2).

Returns
-------
np.ndarray
Array of vertices with shape (..., N+1) or (N+1,).
"""
if _is_bounds_strictly_monotonic(bounds):
# Example: [[51.0, 50.5], [50.5, 50.0]]
# Example Result: [51.0, 50.5, 50.0]
vertices = np.concatenate((bounds[..., :, 0], bounds[..., -1:, 1]), axis=-1)
else:
# Example with bounds (descending) [[50.5, 50.0], [51.0, 50.5]]
# Get the lower endpoints of each bounds interval
# Example Result: [50, 50.5]
lower_endpoints = np.minimum(bounds[..., :, 0], bounds[..., :, 1])

# Get the upper endpoint of the last interval.
# Example Result: 51.0
last_upper_endpoint = np.maximum(bounds[..., -1, 0], bounds[..., -1, 1])

# Concatenate lower endpoints and the last upper endpoint.
# Example Result: [50.0, 50.5, 51.0]
vertices = np.concatenate(
[lower_endpoints, np.expand_dims(last_upper_endpoint, axis=-1)], axis=-1
)

# Sort vertices based on the direction of the bounds
# Example Result: [51.0, 50.5, 50.0]
ascending = is_bounds_ascending(bounds)
if ascending:
vertices = np.sort(vertices, axis=-1)
else:
vertices = np.sort(vertices, axis=-1)[..., ::-1]

return vertices
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function now handles strictly monotonic bounds (all values descend or ascend) and non-strictly monotonic bounds (values ascend/descend with each bounds interval). It also operates on the last two dimensions, preserving the leading dimensions (related #579 (comment)) and does not remove repeated coordinates (related #579 (comment)).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I will remove all of the example comments if it looks good.

Comment on lines +298 to +352
# NOTE: Python 3.10 uses numpy 1.26.4. If the input is a datetime64 array,
# numpy 1.26.4 may raise: numpy.core._exceptions._UFuncInputCastingError:
# Cannot cast ufunc 'greater' input 0 from dtype('<m8[ns]') to dtype('<m8')
# with casting rule 'same_kind' To avoid this, always cast to float64 before
# np.diff.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note about Python 3.10 and numpy 1.26.4 issue in the build.

@tomvothecoder
Copy link
Contributor Author

mypy is failing: https://github.com/xarray-contrib/cf-xarray/actions/runs/16306220786/job/46052777211?pr=579#step:6:61, along with readthedocs: https://app.readthedocs.org/projects/cf-xarray/builds/28868281/

Can somebody help address the failing build (tests are passing)

@dcherian dcherian requested a review from aulemahal July 16, 2025 19:40
Copy link
Contributor

@aulemahal aulemahal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @tomvothecoder for the changes!

Sadly I think there are still some cases lacking. Sorry if I missed that earlier!

Comment on lines 270 to 276
# Sort vertices based on the direction of the bounds
# Example Result: [51.0, 50.5, 50.0]
ascending = is_bounds_ascending(bounds)
if ascending:
vertices = np.sort(vertices, axis=-1)
else:
vertices = np.sort(vertices, axis=-1)[..., ::-1]
Copy link
Contributor

@aulemahal aulemahal Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't want to sort on "bounds". I think the vertices should be in the same order as the coordinate, not the same as the bounds.
My comprehension of the idea of "vertices" is that if you have bounds of shape ($N$, 2), coordinate $i$ has bounds ($i$, 0) and ($i$, 1) which correspond to vertices $i$ and $i + 1$. As the bounds and the vertices do not share any dimension, we hit a "limitation" of the xarray model as we have to rely on actual value order instead of having some coordinate to allow alignment. As a side note, xESMF assumes that the vertices have the same order as the coordinate.

Thus if we sort, it should be on the coordinate dim, but this current implementation assumes a ascending coordinate as it takes the "min" of all bounds and then the max of the last one. For a descending coordinate, we would need to do the opposite : max of bounds and then min of the last one.

Also, this change would make the monotonic and non-monotonic cases match. Your 0b example does what I expected for descending coordinate.

Here are two tests cases that do not work with the current implementation, if it can help:

# Non-monotonic, descending bounds, descending coord
bounds = xr.DataArray(
    [[12.5, 12.0], [12.5, 12.0], [12.0, 11.5], [11.5, 11.0], [11.0, 10.5], [10.5, 10.0]],
    dims=("lat", "bounds"),
)
# should be : array([12.5 , 12.5 , 12.0. 11.5, 11. , 10.5, 10.])

# Non-monotonic, ascending bounds, descending coord
bounds = xr.DataArray(
    [[12.0, 12.5], [12.0, 12.5], [11.5, 12.0], [11.0, 11.5], [10.5, 11.0], [10.0, 10.5]],
    dims=("lat", "bounds"),
)
# should be : array([12.5 , 12.5 , 12.0. 11.5, 11. , 10.5, 10.])

@tomvothecoder
Copy link
Contributor Author

Thanks @tomvothecoder for the changes!

Sadly I think there are still some cases lacking. Sorry if I missed that earlier!

Thanks for the review @aulemahal. I will give this another shot with your latest insight!

@tomvothecoder tomvothecoder force-pushed the bug/576-bounds-to-vertices branch from 17e8be4 to 05308d2 Compare July 21, 2025 19:09
Copy link
Contributor Author

@tomvothecoder tomvothecoder left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @aulemahal, I think I'm getting closer (hopefully). I made changes to address your comments here.

Values for the vertices are extracted from the bounds and sorted based on the order of the coordinates.

Example script

import cf_xarray as cfxr
import xarray as xr


def print_section(title):
    print(f"\n{title}")
    print("=" * len(title))


def print_bounds_and_vertices(bounds, vertices):
    print("Bounds:")
    print(bounds)
    print("Vertices:")
    print(vertices)
    print("-" * 40)


# 0a. Monotonic bounds (ascending) and coordinates (ascending)
print_section("0a. Monotonic bounds (ascending) and coordinates (ascending)")
bounds = xr.DataArray(
    [[10.0, 10.5], [10.5, 11.0], [11.0, 11.5], [11.5, 12.0], [12.0, 12.5]],
    dims=("lat", "bounds"),
)
lat = xr.DataArray([10.25, 10.75, 11.25, 11.75, 12.25], dims="lat", name="lat")
bounds = bounds.assign_coords(lat=lat)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 0b. Monotonic bounds (ascending) and coordinates (descending)
print_section("0b. Monotonic bounds (ascending) and coordinates (descending)")
bounds = xr.DataArray(
    [[10.0, 10.5], [10.5, 11.0], [11.0, 11.5], [11.5, 12.0], [12.0, 12.5]],
    dims=("lat", "bounds"),
)
lat = xr.DataArray([12.25, 11.75, 11.25, 10.75, 10.25], dims="lat", name="lat")
bounds = bounds.assign_coords(lat=lat)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 0c. Monotonic bounds (descending) and coordinates (ascending)
print_section("0c. Monotonic bounds (descending) and coordinates (ascending)")
bounds = xr.DataArray(
    [[12.5, 12.0], [12.0, 11.5], [11.5, 11.0], [11.0, 10.5], [10.5, 10.0]],
    dims=("lat", "bounds"),
)
lat = xr.DataArray([10.25, 10.75, 11.25, 11.75, 12.25], dims="lat", name="lat")
bounds = bounds.assign_coords(lat=lat)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 1. Non-monotonic, ascending coordinates
print_section("1. Non-monotonic bounds, ascending coordinates")
bounds = xr.DataArray(
    [[50.5, 50.0], [51.0, 50.5], [51.5, 51.0], [52.0, 51.5], [52.5, 52.0]],
    dims=("lat", "bounds"),
)
lat = xr.DataArray([50.25, 50.75, 51.25, 51.75, 52.25], dims="lat", name="lat")
bounds = bounds.assign_coords(lat=lat)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 2. Non-montonic, descending coordinates
print_section("2. Non-monotonic bounds, descending coordinates")
bounds = xr.DataArray(
    [[50.0, 50.5], [50.5, 51.0], [51.0, 51.5], [51.5, 52.0], [52.0, 52.5]],
    dims=("lat", "bounds"),
)
lat = xr.DataArray([52.25, 51.75, 51.25, 50.75, 50.25], dims="lat", name="lat")
bounds = bounds.assign_coords(lat=lat)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 3. Non-montonic, ascending coordinates with duplicated values
print_section("3. Non-monotonic bounds, ascending coordinates with duplicated values")
bounds = xr.DataArray(
    [[50.5, 50.0], [51.0, 50.5], [51.0, 50.5], [52.0, 51.5], [52.5, 52.0]],
    dims=("lat", "bounds"),
)
lat = xr.DataArray([50.25, 50.75, 50.75, 51.75, 52.25], dims="lat", name="lat")
bounds = bounds.assign_coords(lat=lat)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 4. Non-montonic bounds, descending coordinates with duplicated values
print_section("4. Non-monotonic bounds, descending coordinates with duplicated values")
bounds = xr.DataArray(
    [[50.0, 50.5], [50.5, 51.0], [50.5, 51.0], [51.0, 51.5], [51.5, 52.0]],
    dims=("lat", "bounds"),
)
lat = xr.DataArray([52.25, 51.75, 51.75, 50.75, 50.25], dims="lat", name="lat")
bounds = bounds.assign_coords(lat=lat)
vertices = cfxr.bounds_to_vertices(bounds, "bounds")
print_bounds_and_vertices(bounds, vertices)

# 5. 3D array (extra non-core dim)
print_section("5. 3D array (extra non-core dim)")
extra = xr.DataArray([0, 1], dims="extra", name="extra")
bounds_3d = xr.DataArray(
    [
        [
            [50.0, 50.5],
            [50.5, 51.0],
            [51.0, 51.5],
            [51.5, 52.0],
            [52.0, 52.5],
        ],
        [
            [60.0, 60.5],
            [60.5, 61.0],
            [61.0, 61.5],
            [61.5, 62.0],
            [62.0, 62.5],
        ],
    ],
    dims=("extra", "lat", "bounds"),
)
lat = xr.DataArray([50.25, 50.75, 51.25, 51.75, 52.25], dims="lat", name="lat")
bounds_3d = bounds_3d.assign_coords(extra=extra, lat=lat)
vertices_3d = cfxr.bounds_to_vertices(bounds_3d, "bounds", core_dims=["lat"])
print_bounds_and_vertices(bounds_3d, vertices_3d)

# 6. 4D array (time, extra, lat, bounds)
print_section("6. 4D array (time, extra, lat, bounds)")
time = xr.DataArray([0, 1], dims="time", name="time")
extra = xr.DataArray([0, 1], dims="extra", name="extra")
bounds_4d = xr.DataArray(
    [
        [
            [
                [50.0, 50.5],
                [50.5, 51.0],
                [51.0, 51.5],
                [51.5, 52.0],
                [52.0, 52.5],
            ],
            [
                [60.0, 60.5],
                [60.5, 61.0],
                [61.0, 61.5],
                [61.5, 62.0],
                [62.0, 62.5],
            ],
        ],
        [
            [
                [70.0, 70.5],
                [70.5, 71.0],
                [71.0, 71.5],
                [71.5, 72.0],
                [72.0, 72.5],
            ],
            [
                [80.0, 80.5],
                [80.5, 81.0],
                [81.0, 81.5],
                [81.5, 82.0],
                [82.0, 82.5],
            ],
        ],
    ],
    dims=("time", "extra", "lat", "bounds"),
)
lat = xr.DataArray([50.25, 50.75, 51.25, 51.75, 52.25], dims="lat", name="lat")
bounds_4d = bounds_4d.assign_coords(time=time, extra=extra, lat=lat)
vertices_4d = cfxr.bounds_to_vertices(bounds_4d, "bounds", core_dims=["lat"])
print_bounds_and_vertices(bounds_4d, vertices_4d)


# Non-monotonic, descending bounds, descending coord
lat = xr.DataArray([12.25, 12.25, 11.75, 11.25, 10.75, 10.25], dims="lat", name="lat")
bounds = xr.DataArray(
    [
        [12.5, 12.0],
        [12.5, 12.0],
        [12.0, 11.5],
        [11.5, 11.0],
        [11.0, 10.5],
        [10.5, 10.0],
    ],
    dims=("lat", "bounds"),
)
bounds = bounds.assign_coords(lat=lat)
result = cfxr.bounds_to_vertices(bounds, "bounds")
print(result)
# should be : array([12.5 , 12.5 , 12.0, 11.5, 11. , 10.5, 10.])

# Non-monotonic, ascending bounds, descending coord
lat = xr.DataArray([12.25, 12.25, 11.75, 11.25, 10.75, 10.25], dims="lat", name="lat")
bounds = xr.DataArray(
    [
        [12.0, 12.5],
        [12.0, 12.5],
        [11.5, 12.0],
        [11.0, 11.5],
        [10.5, 11.0],
        [10.0, 10.5],
    ],
    dims=("lat", "bounds"),
)
bounds = bounds.assign_coords(lat=lat)
result = cfxr.bounds_to_vertices(bounds, "bounds")
print(result)
# should be : array([12.5 , 12.5 , 12.0. 11.5, 11. , 10.5, 10.])

Example result

0a. Monotonic bounds (ascending) and coordinates (ascending)
============================================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[10. , 10.5],
       [10.5, 11. ],
       [11. , 11.5],
       [11.5, 12. ],
       [12. , 12.5]])
Coordinates:
  * lat      (lat) float64 40B 10.25 10.75 11.25 11.75 12.25
Dimensions without coordinates: bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([10. , 10.5, 11. , 11.5, 12. , 12.5])
Dimensions without coordinates: lat_vertices
----------------------------------------

0b. Monotonic bounds (ascending) and coordinates (descending)
=============================================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[10. , 10.5],
       [10.5, 11. ],
       [11. , 11.5],
       [11.5, 12. ],
       [12. , 12.5]])
Coordinates:
  * lat      (lat) float64 40B 12.25 11.75 11.25 10.75 10.25
Dimensions without coordinates: bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([12.5, 12. , 11.5, 11. , 10.5, 10. ])
Dimensions without coordinates: lat_vertices
----------------------------------------

0c. Monotonic bounds (descending) and coordinates (ascending)
=============================================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[12.5, 12. ],
       [12. , 11.5],
       [11.5, 11. ],
       [11. , 10.5],
       [10.5, 10. ]])
Coordinates:
  * lat      (lat) float64 40B 10.25 10.75 11.25 11.75 12.25
Dimensions without coordinates: bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([10. , 10.5, 11. , 11.5, 12. , 12.5])
Dimensions without coordinates: lat_vertices
----------------------------------------

1. Non-monotonic bounds, ascending coordinates
==============================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[50.5, 50. ],
       [51. , 50.5],
       [51.5, 51. ],
       [52. , 51.5],
       [52.5, 52. ]])
Coordinates:
  * lat      (lat) float64 40B 50.25 50.75 51.25 51.75 52.25
Dimensions without coordinates: bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([50. , 50.5, 51. , 51.5, 52. , 52.5])
Dimensions without coordinates: lat_vertices
----------------------------------------

2. Non-monotonic bounds, descending coordinates
===============================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[50. , 50.5],
       [50.5, 51. ],
       [51. , 51.5],
       [51.5, 52. ],
       [52. , 52.5]])
Coordinates:
  * lat      (lat) float64 40B 52.25 51.75 51.25 50.75 50.25
Dimensions without coordinates: bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([52.5, 52. , 51.5, 51. , 50.5, 50. ])
Dimensions without coordinates: lat_vertices
----------------------------------------

3. Non-monotonic bounds, ascending coordinates with duplicated values
=====================================================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[50.5, 50. ],
       [51. , 50.5],
       [51. , 50.5],
       [52. , 51.5],
       [52.5, 52. ]])
Coordinates:
  * lat      (lat) float64 40B 50.25 50.75 50.75 51.75 52.25
Dimensions without coordinates: bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([50. , 50.5, 50.5, 51.5, 52. , 52.5])
Dimensions without coordinates: lat_vertices
----------------------------------------

4. Non-monotonic bounds, descending coordinates with duplicated values
======================================================================
Bounds:
<xarray.DataArray (lat: 5, bounds: 2)> Size: 80B
array([[50. , 50.5],
       [50.5, 51. ],
       [50.5, 51. ],
       [51. , 51.5],
       [51.5, 52. ]])
Coordinates:
  * lat      (lat) float64 40B 52.25 51.75 51.75 50.75 50.25
Dimensions without coordinates: bounds
Vertices:
<xarray.DataArray (lat_vertices: 6)> Size: 48B
array([52. , 51.5, 51.5, 51. , 51. , 50.5])
Dimensions without coordinates: lat_vertices
----------------------------------------

5. 3D array (extra non-core dim)
================================
Bounds:
<xarray.DataArray (extra: 2, lat: 5, bounds: 2)> Size: 160B
array([[[50. , 50.5],
        [50.5, 51. ],
        [51. , 51.5],
        [51.5, 52. ],
        [52. , 52.5]],

       [[60. , 60.5],
        [60.5, 61. ],
        [61. , 61.5],
        [61.5, 62. ],
        [62. , 62.5]]])
Coordinates:
  * extra    (extra) int64 16B 0 1
  * lat      (lat) float64 40B 50.25 50.75 51.25 51.75 52.25
Dimensions without coordinates: bounds
Vertices:
<xarray.DataArray (extra: 2, lat_vertices: 6)> Size: 96B
array([[50. , 50.5, 51. , 51.5, 52. , 52.5],
       [60. , 60.5, 61. , 61.5, 62. , 62.5]])
Coordinates:
  * extra    (extra) int64 16B 0 1
Dimensions without coordinates: lat_vertices
----------------------------------------

6. 4D array (time, extra, lat, bounds)
======================================
Bounds:
<xarray.DataArray (time: 2, extra: 2, lat: 5, bounds: 2)> Size: 320B
array([[[[50. , 50.5],
         [50.5, 51. ],
         [51. , 51.5],
         [51.5, 52. ],
         [52. , 52.5]],

        [[60. , 60.5],
         [60.5, 61. ],
         [61. , 61.5],
         [61.5, 62. ],
         [62. , 62.5]]],


       [[[70. , 70.5],
         [70.5, 71. ],
         [71. , 71.5],
         [71.5, 72. ],
         [72. , 72.5]],

        [[80. , 80.5],
         [80.5, 81. ],
         [81. , 81.5],
         [81.5, 82. ],
         [82. , 82.5]]]])
Coordinates:
  * time     (time) int64 16B 0 1
  * extra    (extra) int64 16B 0 1
  * lat      (lat) float64 40B 50.25 50.75 51.25 51.75 52.25
Dimensions without coordinates: bounds
Vertices:
<xarray.DataArray (time: 2, extra: 2, lat_vertices: 6)> Size: 192B
array([[[50. , 50.5, 51. , 51.5, 52. , 52.5],
        [60. , 60.5, 61. , 61.5, 62. , 62.5]],

       [[70. , 70.5, 71. , 71.5, 72. , 72.5],
        [80. , 80.5, 81. , 81.5, 82. , 82.5]]])
Coordinates:
  * time     (time) int64 16B 0 1
  * extra    (extra) int64 16B 0 1

Previously failing example cases now passing (from here)

# Non-monotonic, descending bounds, descending coord
lat = xr.DataArray([12.25, 12.25, 11.75, 11.25, 10.75, 10.25], dims="lat", name="lat")
bounds = xr.DataArray(
    [
        [12.5, 12.0],
        [12.5, 12.0],
        [12.0, 11.5],
        [11.5, 11.0],
        [11.0, 10.5],
        [10.5, 10.0],
    ],
    dims=("lat", "bounds"),
)
bounds = bounds.assign_coords(lat=lat)
result = cfxr.bounds_to_vertices(bounds, "bounds")
print(result)
# should be : array([12.5 , 12.5 , 12.0, 11.5, 11. , 10.5, 10.])

# Non-monotonic, ascending bounds, descending coord
lat = xr.DataArray([12.25, 12.25, 11.75, 11.25, 10.75, 10.25], dims="lat", name="lat")
bounds = xr.DataArray(
    [
        [12.0, 12.5],
        [12.0, 12.5],
        [11.5, 12.0],
        [11.0, 11.5],
        [10.5, 11.0],
        [10.0, 10.5],
    ],
    dims=("lat", "bounds"),
)
bounds = bounds.assign_coords(lat=lat)
result = cfxr.bounds_to_vertices(bounds, "bounds")
print(result)
# should be : array([12.5 , 12.5 , 12.0. 11.5, 11. , 10.5, 10.])
----------------------------------------
<xarray.DataArray (lat_vertices: 7)> Size: 56B
array([12.5, 12.5, 12. , 11.5, 11. , 10.5, 10. ])
Dimensions without coordinates: lat_vertices
<xarray.DataArray (lat_vertices: 7)> Size: 56B
array([12.5, 12.5, 12. , 11.5, 11. , 10.5, 10. ])
Dimensions without coordinates: lat_vertices

@tomvothecoder tomvothecoder requested a review from aulemahal July 21, 2025 19:13
Copy link
Contributor

@aulemahal aulemahal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! I think this works. You even when a bit further than I was expecting by using the actual order of the coordinate and not simply the order of the bounds across the non-bound dimension. Your way is better as it implicitly fixes bounds given in the wrong order (like example 0b of the last comment).

Thanks a lot!

tomvothecoder and others added 14 commits July 28, 2025 12:06
Add `_get_ordered_vertices()` to handle extraction of unique vertex values in ascending order
- Also handle strictly monotonic bounds
- 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
@tomvothecoder tomvothecoder force-pushed the bug/576-bounds-to-vertices branch from b06fd78 to caae886 Compare July 28, 2025 19:06
@dcherian
Copy link
Contributor

@aulemahal please merge and release when you're happy.

@tomvothecoder
Copy link
Contributor Author

Thank you @aulemahal @dcherian. I just fixed a stubborn type casting issue with numpy 1.26.4 and Python 3.11. All of the Python builds now pass.

The only thing that needs to be fixed is the mypy 3.11 workflow (issue below):

 Downloading types_docutils-0.21.0.20250728-py3-none-any.whl (89 kB)
Installing collected packages: types-regex, types-PyYAML, types-pytz, types-psutil, types-pexpect, types-networkx, types-docutils, types-defusedxml, types-colorama, optype, types-Pygments, scipy-stubs, pandas-stubs

Successfully installed optype-0.12.2 pandas-stubs-2.3.0.250703 scipy-stubs-1.16.0.2 types-PyYAML-6.0.12.20250516 types-Pygments-2.19.0.20250715 types-colorama-0.4.15.20240311 types-defusedxml-0.7.0.20250708 types-docutils-0.21.0.20250728 types-networkx-3.5.0.20250728 types-pexpect-4.9.0.20250516 types-psutil-7.0.0.20250601 types-pytz-2025.2.0.20250516 types-regex-2024.11.6.20250403
cf_xarray/helpers.py:371: error: Incompatible return value type (got "numpy.bool[builtins.bool]", expected "builtins.bool")  [return-value]
Generated Cobertura report: /home/runner/work/cf-xarray/cf-xarray/mypy_report/cobertura.xml
Installing missing stub packages:
/home/runner/micromamba/envs/cf_xarray_test/bin/python -m pip install pandas-stubs scipy-stubs types-PyYAML types-Pygments types-colorama types-defusedxml types-networkx types-pexpect types-psutil types-regex


Generated Cobertura report: /home/runner/work/cf-xarray/cf-xarray/mypy_report/cobertura.xml

@tomvothecoder tomvothecoder force-pushed the bug/576-bounds-to-vertices branch from 6f3ee60 to 322a538 Compare August 5, 2025 20:02
@tomvothecoder
Copy link
Contributor Author

@aulemahal please merge and release when you're happy.

@aulemahal I just fixed the build. It is ready to be merged now

@dcherian dcherian requested a review from aulemahal August 5, 2025 20:36
@aulemahal aulemahal merged commit cfe87d0 into xarray-contrib:main Aug 11, 2025
13 checks passed
@aulemahal
Copy link
Contributor

aulemahal commented Aug 11, 2025

Sorry for the delay, I was on vacation! Thanks @tomvothecoder for all the work!

@tomvothecoder
Copy link
Contributor Author

@aulemahal No worries and thanks!

@tomvothecoder tomvothecoder deleted the bug/576-bounds-to-vertices branch August 11, 2025 17:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

cf_xarray.bounds_to_vertices produces incorrect results with descending 2D array (e.g., bounds)
3 participants