Skip to content

Merge coincident nodes when constructing any Grid - #1692

Open
rajeeja wants to merge 38 commits into
mainfrom
rajeeja/coincident-nodes
Open

Merge coincident nodes when constructing any Grid#1692
rajeeja wants to merge 38 commits into
mainfrom
rajeeja/coincident-nodes

Conversation

@rajeeja

@rajeeja rajeeja commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes #865

  • Grids with duplicate node indices were rejected outright; the dual is now built after canonicalizing those indices in the face-node connectivity.
  • geoflow-small previously could not produce a dual at all and now yields 3803 faces; the test asserts this and fails on main.
  • _find_duplicate_nodes is vectorized with np.unique instead of a per-node dict.
  • Grid.get_dual(check_duplicate_nodes=...) is now ignored and deprecated rather than removed, so existing callers keep working.
  • UxDataArray.get_dual and UxDataset.get_dual still raise GridInvalidError, since node-centered data cannot be remapped onto a merged node set.
  • Does not cover nodes that are coincident on the sphere but differ in (lon, lat), such as poles and the antimeridian; that is Grid.from_structured does not merge coincident pole and antimeridian nodes #1689 / Merge coincident pole and antimeridian nodes in structured grids #1690.

Match nodes in Cartesian space rather than the lon/lat plane so pole and
antimeridian nodes are recognized as the same point, and store the resulting
polar faces as triangles instead of quads with a repeated corner.
Canonicalize duplicate node indices in the face-node connectivity before
building the dual, so grids with repeated nodes produce a correct dual instead
of being rejected. Also vectorize the duplicate lookup and deprecate the now
redundant check_duplicate_nodes argument.
@rajeeja rajeeja self-assigned this Aug 19, 2026
@rajeeja
rajeeja requested a review from Sevans711 August 19, 2026 22:06

@Sevans711 Sevans711 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Mostly looks like a good fix! I think it still needs a little bit of extra work, and I left some inline comments accordingly.

Comment thread uxarray/grid/validation.py Outdated
"""Map duplicate node indices to the first index with the same coordinates."""
node_coordinates = np.column_stack((grid.node_lon.values, grid.node_lat.values))
_, first_indices, inverse_indices = np.unique(
node_coordinates, axis=0, return_index=True, return_inverse=True

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is "exact equality" the correct way to go here? My intuition originally was that there should probably be some sort of tolerance here, e.g. if values agree to within 1e-12, they are probably the same node, right?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In fact, looking back at the original issue thread, it looks like you were the person who originally suggested there be a tolerance in the first place! So, I would actually now assert that exact inequality is not the desired implementation, there should be a tolerance, as clarified in thread of #865.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — matching goes through _coincident_node_canonical_indices, which is a KDTree query within ERROR_TOLERANCE on the sphere, not exact equality.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This fix looks reasonable. One more nitpick here though: your comment on the original thread #865 (comment) suggested you wanted the user to be able to specify the tolerance.

Right now, there is no way to specify the "tolerance for merging duplicate nodes" from any public-facing functions. To specify it as a larger value (e.g. 1e-4) a user would need to call _merge_coincident_grid_ds_nodes directly on grid_ds before passing to Grid.__init__, while there is presumably no way to properly specify it as a smaller value (e.g. 1e-12) because the _merge_coincident_grid_ds_nodes call inside Grid.__init__ would always rerun the process with a tolerance of ERROR_TOLERANCE=1e-8.

Should this option be added somewhere (e.g. something like a duplicates_tolerance parameter in Grid.__init__) or is "allow the user to specify the tolerance" no longer desirable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can be a good edition, but not in this PR. I'll open a separate issue for a user-facing tolerance.
Adding this means threading it through open_grid and the from_* constructors..

Comment thread uxarray/grid/validation.py Outdated
np.arange(grid.n_node, dtype=INT_DTYPE) != first_indices[inverse_indices]
)
return {
INT_DTYPE(index): INT_DTYPE(first_indices[inverse_indices[index]])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would guess that creating a dict here is extremely inefficient… maybe that doesn't matter if there are only ever a tiny number of duplicate nodes. Not necessarily blocking, but have you looked into how long this takes to run for any larger grids containing duplicate nodes? (Or, do you expect a very limited number of duplicate nodes in most cases? I would guess it is probably not worth worrying about if there are less than ~1000 duplicates or so.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The dict only holds duplicates, not every node, so it stays small. The real cost was _check_duplicate_nodes_indices looping over every face in Python; that is now one np.isin over the connectivity array.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think that makes sense. Added the run-benchmarks label because this PR adds a call to _merge_coincident_grid_ds_nodes into Grid.__init__ so it might affect uxarray performance. I believe my original comment here is resolved, but I am keeping this thread open for now as a reminder to myself to consider the benchmarking results before approving this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The cache wasn't the whole story - dual mesh is still ~1.6x slower because get_dual builds a new Grid, which re-runs the coincident merge on the dual. Fixing that next, so don't go by the current benchmark numbers.

Comment thread uxarray/grid/connectivity.py Outdated
Comment thread test/grid/grid/test_core.py Outdated
def test_dual_duplicate(gridpath):
"""Test dual mesh creation with duplicate grids."""
dataset = ux.open_dataset(gridpath("ugrid", "geoflow-small", "grid.nc"), gridpath("ugrid", "geoflow-small", "grid.nc"))
"""Test dual mesh creation with duplicate node indices."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could you include something in this test to assert there are actually duplicate nodes in the original grid? That would help to prove this test is actually testing what it claims to be testing. Right now I just have to trust that geoflow-small grid happens to contain duplicates, but I can't see from these lines if that's really true, or how many duplicates there are.

Can you also clarify with a comment where the number 3803 comes from?

Extra helpful, but not necessarily required, would be if you are able to construct a tiny example inline here which clearly has some duplicate nodes, something small enough to directly reason through how they should be handled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added assert grid.n_node == 6000 and len(duplicates) == 2150, and derived 3840 in the test: 3850 distinct nodes remain after the merge, ten touched by one face only, so no dual cell. Also added test_duplicate_nodes_minimal_example — two quads, eight nodes at six locations, asserting the map is {6: 1, 7: 2}.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The new version of the test looks much clearer and the comments do a good job explaining the steps overall.

I still don't think the number 3850 is explained clearly. In particular, why should we expect len(duplicates)==2150 for this case? I am extra suspicious because the implementation changes have changed the number from 3803 to 3850.

One way forward would be to somehow separately figure out the number you would expect here. Although, I do think that the test_duplicate_nodes_minimal_example might be a reasonable enough test for ensuring the code is identifying duplicates correctly. If you want to avoid finding a justification for the len(duplicates)==2150 number, it would be sufficient to leave a comment there to say something like "The source file just happened to have 2150 duplicates according to uxarray, but there is not necessarily any reason to suspect it must be 2150. This test does not check for correctness of which duplicates are identified, the number is hard-coded just to prevent unexpected regressions. For a correctness test of the duplicate-identifying algorithm, see test_duplicate_nodes_minimal_example."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

2150 is 2111 nodes exactly equal in lon/lat plus 39 within ERROR_TOLERANCE of an earlier node, and 10 pole nodes are held out of merging by design. The 3803 you're comparing to was the old dual.n_face assertion, not a node count; the comparable number now is 3840, and 3850 is distinct nodes.

Comment thread test/grid/grid/test_core.py
@Sevans711

Copy link
Copy Markdown
Collaborator

Actually, apologies for not including this during the original review, comment but one more thought: does this actually fully close the original issue? The issue writeup makes it sound to me like duplicate nodes should be handled immediately upon constructing the grid, not just during one functionality (get_dual). Is there a reason that duplicate nodes should be handled only during get_dual, instead of immediately? (Do all other current/planned functions work properly regardless of whether there are duplicate nodes?)

@Sevans711 Sevans711 added the bug Something isn't working label Aug 21, 2026
rajeeja and others added 11 commits August 21, 2026 11:00
float32 input (e.g. real climate datasets) silently ran the whole
xyz/tolerance pipeline at float32 precision, causing pole/antimeridian
merges to fail or merge only partially.
Match nodes in Cartesian space rather than the lon/lat plane so pole and
antimeridian nodes are recognized as the same point, and store the resulting
polar faces as triangles instead of quads with a repeated corner.
float32 input (e.g. real climate datasets) silently ran the whole
xyz/tolerance pipeline at float32 precision, causing pole/antimeridian
merges to fail or merge only partially.
Extend #865's fix beyond the dual mesh: canonicalize duplicate/coincident
node indices in connectivity for every Grid construction path, not just
construct_dual. Detection is now tolerance-based (unit-sphere chordal
distance) instead of exact lon/lat match, so pole-degenerate duplicates
are also caught. Node coordinate arrays are left untouched by design;
only connectivity is remapped to canonical indices, with any resulting
repeated face corners collapsed.
construct_dual no longer needs its own per-call duplicate detection and
remap, and get_dual() no longer needs to hard-gate on duplicate node
indices, since Grid construction now canonicalizes them structurally
before any of this code runs.
Since duplicate node coordinates are intentionally left unreferenced by
connectivity, a node KDTree/BallTree built over the raw coordinate array
could select an index no face actually points to, silently returning
empty or wrong nearest-neighbor results. Build the "nodes" tree only
over live (referenced) indices and translate query results back to
original index space.
polars' unique() with maintain_order unset does not guarantee row order
across runs, so the node index assigned to a given corner coordinate
could vary between reads of the same file. This is normally harmless,
but it made canonical-node selection for coincident duplicates
(e.g. pole points with differing longitude) flaky from run to run.
test_dual_duplicate: validate() now succeeds since connectivity is fully
canonicalized (duplicate coordinates remain by design, but nothing
references a dead index anymore). test_grid_nn_subset: max valid k for a
node search is now bounded by the live node count, not raw node count.
test_to_geodataframe_preserves_antimeridian_faces: pole-coincident
corners with differing longitude are now also merged, shifting the
antimeridian face count.
@rajeeja
rajeeja requested a review from Sevans711 August 21, 2026 19:58
Merging pole-adjacent duplicate nodes was collapsing each face's own
locally-meaningful longitude at the pole into one arbitrary canonical
value, which corrupted lat/lon bounds and broke zonal weight
computation for cube-sphere grids near the poles.

@Sevans711 Sevans711 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: please change the title of this PR to reflect the full scope. Duplicate nodes are now handled directly whenever constructing a Grid, not just when constructing the dual mesh. I was confused about why _check_duplicate_nodes_indices checks were removed despite construct_dual() being unchanged. I think the reason is because all Grid objects are now guaranteed to not have duplicate nodes.

I tried leaving a full review but I kept getting the feeling that something weird was happening, a nagging feeling like "hey I think I've looked at this code before and left an inline comment, why am I reviewing it again?" Then I realized many of the changes here are also in #1690 which hasn't been merged yet. That led to duplicating review work and will probably lead to needing to apply fixes multiple times. I'm not sure the cleanest way forwards at this point, but I might suggest waiting to merge this until after #1690 gets merged. At least, I will want to do another close review of this PR after that PR merges, because I lost track of which things I already looked at closely and which I need to consider again.

Comment thread test/io/test_structured.py Outdated
return canonical

tree = KDTree(points_xyz[mergeable_indices])
pairs = tree.query_pairs(r=tolerance, output_type="ndarray")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why does this tolerance use ERROR_TOLERANCE for a radius, but other tolerances convert to chord_tol?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ERROR_TOLERANCE is already a chord distance on the unit sphere, so it is the radius directly. Conversion is only needed where the tolerance arrives in degrees, as in _read_structured_grid.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is there any reason to avoid using the same meaning for tolerance here as in _read_structured_grid? I would be less confused by it if it meant the same thing in both places. It seems like both routines are using the input tolerance in a similar way, while building a KDTree to query for everything within a certain distance of a given point.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Only the input unit differs: _read_structured_grid takes tol in degrees from the user so it converts, while ERROR_TOLERANCE is already a chord on the unit sphere. Both query the KDTree with the same chord radius by default.

Comment thread uxarray/io/_structured.py Outdated
…des' into rajeeja/coincident-nodes

# Conflicts:
#	test/io/test_structured.py
#	uxarray/core/dataarray.py
#	uxarray/core/dataset.py
#	uxarray/grid/grid.py
#	uxarray/io/_structured.py
"Coincident nodes" is the domain term used by TempestRemap, MOAB, and our own
validation module; "dedupe" reads like a dataframe operation. Also note in the
docstring that we keep the redundant coordinates and only remap connectivity,
where TempestRemap and MOAB delete and renumber.
_coincident_node_canonical_indices exempts pole nodes from merging so each face
touching a pole keeps its own longitude. The mask used np.isclose(|z|, 1.0,
atol=tolerance), which is wrong twice: tolerance is a chord radius everywhere
else in the function, and np.isclose's default rtol=1e-5 swamped atol entirely.
The carve-out spanned 1 - |z| <= 1.001e-5, a chord of 4.5e-3 or ~28 km on Earth,
so any node within 28 km of a pole was silently exempt from merging.

For a point at colatitude t, 1 - |z| = 1 - cos(t) = chord**2 / 2, so the correct
cap is tolerance**2 / 2 with rtol pinned to zero.
construct_dual reads node_face_connectivity with no duplicate handling, so a
face still referencing a coincident duplicate index yields a degenerate dual
face instead of an error. Merging at construction makes that unreachable today,
so the guard is a no-op safety net; check_duplicate_nodes is now deprecated and
ignored, since the check always runs.
The pole/seam merge in _read_structured_grid changes the ersstv5 grid (16290
nodes to 16200) and turns pole quads into triangles, so the near-pole plots were
stale. Outputs re-executed; the stray OMP stderr line is dropped.
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

The notebook's cell sources are identical to main; only the outputs
differed, and those differences were an xarray coordinate-ordering repr
change and a stray OMP info line. Keeping the re-run added ~1700 lines of
diff with no content change.
Coincident-node merging now runs on every Grid construction, so the cost
is paid by grids that have no duplicates at all. Two points within a
chord of ERROR_TOLERANCE differ by at most that in x, so in x-sorted
order every consecutive gap between them is also within tolerance; a
point whose sorted neighbours are both further away cannot be coincident
with anything. Filtering on that is exact and drops the tree build
entirely for a clean grid: 1.23s -> 0.28s over 2M nodes.
@rajeeja rajeeja changed the title Merge duplicate nodes when constructing the dual mesh Merge coincident nodes when constructing any Grid Sep 8, 2026
@Sevans711 Sevans711 added the run-benchmark Run ASV benchmark workflow label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have stayed the same:

Change Before [38b33d8] <v2026.09.0> After [6dee854] Ratio Benchmark (Parameter)
5.29±0.08ms 5.39±0.06ms 1.02 bench_connectivity.Connectivity.time_edge_face('120km')
1.72±0.01ms 1.76±0.02ms 1.02 bench_connectivity.Connectivity.time_edge_face('480km')
4.21±0.01ms 4.34±0.2ms 1.03 bench_connectivity.Connectivity.time_edge_node('120km')
1.35±0.01ms 1.40±0.02ms 1.04 bench_connectivity.Connectivity.time_edge_node('480km')
4.16±0.01ms 4.31±0.02ms 1.03 bench_connectivity.Connectivity.time_face_edge('120km')
1.36±0.01ms 1.40±0.02ms 1.03 bench_connectivity.Connectivity.time_face_edge('480km')
6.23±0.04ms 6.31±0.04ms 1.01 bench_connectivity.Connectivity.time_face_face('120km')
2.11±0.02ms 2.15±0.01ms 1.02 bench_connectivity.Connectivity.time_face_face('480km')
57.5±0.4μs 62.2±1μs 1.08 bench_connectivity.Connectivity.time_face_node('480km')
360±8μs 374±7μs 1.04 bench_connectivity.Connectivity.time_n_nodes_per_face('480km')
5.72±0.03ms 5.90±0.1ms 1.03 bench_connectivity.Connectivity.time_node_edge('120km')
1.74±0.01ms 1.79±0.01ms 1.02 bench_connectivity.Connectivity.time_node_edge('480km')
76.1±0.7ms 77.7±2ms 1.02 bench_connectivity.Connectivity.time_node_face('120km')
4.80±0.05ms 4.83±0.04ms 1.01 bench_connectivity.Connectivity.time_node_face('480km')
7.29±0.05ms 7.36±0.06ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.52±0.03ms 2.36±0.03ms 0.93 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
10.2±10s 9.97±10ms ~0.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.93±0.01ms 1.83±0.02ms 0.95 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
57.3k 57.3k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
12.3k 12.3k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
123k 123k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
128 128 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.27M 1.27M 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
50.1k 50.1k 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
1.48M 1.48M 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
712 712 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.99M 2.01M 1.01 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2M 1.98M 0.99 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.16M 2.16M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
38.3k 37.9k 0.99 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
335M 342M 1.02 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
367M 374M 1.02 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
336M 346M 1.03 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
336M 344M 1.02 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.18±0.04μs 1.22±0.02μs 1.04 geometry_kernels.AccucrossKernels.time_accucross
2.53±0.04μs 2.57±0.03μs 1.01 geometry_kernels.AccucrossKernels.time_accucross_pair
401±10ns 386±10ns 0.96 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
425±30ns 405±10ns 0.95 geometry_kernels.EFTPrimitives.time_diff_of_products
340±4ns 350±20ns 1.03 geometry_kernels.EFTPrimitives.time_two_prod
401±40ns 361±9ns ~0.90 geometry_kernels.EFTPrimitives.time_two_sum
631±20ns 646±20ns 1.02 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
646±10ns 686±20ns 1.06 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
736±20ns 727±10ns 0.99 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
711±10ns 726±20ns 1.02 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
791±20ns 836±20ns 1.06 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
937±20ns 946±20ns 1.01 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
37.2±0.3μs 36.6±2μs 0.98 geometry_kernels.OrientPredicates.time_on_minor_arc
656±20ns 661±20ns 1.01 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.13±0ms 3.13±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.34±0.01ms 1.33±0.01ms 0.99 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.10±0ms 2.17±0.04ms 1.03 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
155±0.3μs 155±0.7μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
27.8±0.02ms 29.3±0.8ms 1.05 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.81±0.01ms 6.82±0ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
22.2±0.6ms 22.3±0.6ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
897±3μs 904±0.5μs 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
831±3ms 842±7ms 1.01 import.Imports.timeraw_import_uxarray
292M 292M 1.00 import.Imports.track_peakmem_import_uxarray
2.23±0.01ms 2.34±0.02ms 1.05 mpas_ocean.CheckNorm.time_check_norm('120km')
1.84±0.03ms 1.86±0.02ms 1.02 mpas_ocean.CheckNorm.time_check_norm('480km')
1.07±0.01ms 1.10±0.01ms 1.03 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
540±20μs 535±4μs 0.99 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
664±10μs 672±10μs 1.01 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
581±6μs 605±8μs 1.04 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.02±0.03ms 5.07±0.03ms 1.01 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.55±0.07ms 3.56±0.01ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
101±0.4ms 102±0.7ms 1.01 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
10.3±0.06ms 10.0±0.3ms 0.98 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
19.7±0.03ms 19.7±0.02ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.09±0.02ms 1.12±0.01ms 1.02 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.6±0.02ms 10.6±0.01ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
736±20μs 748±5μs 1.02 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
519±6ms 554±2ms 1.07 mpas_ocean.CrossSections.time_const_lat('120km', 1)
261±0.7ms 280±1ms 1.08 mpas_ocean.CrossSections.time_const_lat('120km', 2)
135±0.4ms 146±2ms 1.08 mpas_ocean.CrossSections.time_const_lat('120km', 4)
468±1ms 490±4ms 1.05 mpas_ocean.CrossSections.time_const_lat('480km', 1)
235±0.9ms 243±3ms 1.04 mpas_ocean.CrossSections.time_const_lat('480km', 2)
119±2ms 127±0.7ms 1.07 mpas_ocean.CrossSections.time_const_lat('480km', 4)
355M 363M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 1)
355M 364M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 2)
355M 363M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 4)
338M 346M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 1)
338M 346M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 2)
338M 346M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 4)
13.6±0.09ms 13.7±0.2ms 1.01 mpas_ocean.FaceAreas.time_face_areas('120km')
3.69±0.04ms 3.75±0.05ms 1.02 mpas_ocean.FaceAreas.time_face_areas('480km')
229k 229k 1.00 mpas_ocean.FaceAreas.track_nbytes_face_areas('120km')
14.3k 14.3k 1.00 mpas_ocean.FaceAreas.track_nbytes_face_areas('480km')
2.12M 2.12M 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('120km')
743k 743k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
828±3ms 836±2ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
50.5±1ms 50.8±1ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
72.2±0.3ms 72.4±0.5ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.55±0.1ms 5.69±0.06ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
14.7±0.2ms 14.6±0.2ms 1.00 mpas_ocean.Gradient.time_gradient('120km')
2.04±0.02ms 2.01±0.02ms 0.98 mpas_ocean.Gradient.time_gradient('480km')
457k 457k 1.00 mpas_ocean.Gradient.track_nbytes_gradient('120km')
28.7k 28.7k 1.00 mpas_ocean.Gradient.track_nbytes_gradient('480km')
3.43M 3.43M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
218k 218k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
350M 362M 1.03 mpas_ocean.GradientColdStartRss.peakmem_gradient('120km')
330M 337M 1.02 mpas_ocean.GradientColdStartRss.peakmem_gradient('480km')
362±10μs 367±10μs 1.02 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
190±5μs 209±10μs ~1.10 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
555±8μs 588±20μs 1.06 mpas_ocean.Integrate.time_integrate('120km')
494±20μs 526±20μs 1.06 mpas_ocean.Integrate.time_integrate('480km')
18.4M 18.4M 1.00 mpas_ocean.Integrate.track_nbytes_integrate('120km')
1.2M 1.2M 1.00 mpas_ocean.Integrate.track_nbytes_integrate('480km')
186±2ms 186±1ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
187±1ms 189±1ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
190±2ms 186±1ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
15.1±1ms 14.2±0.5ms 0.94 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
14.0±0.09ms 14.1±0.4ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.9±0.2ms 14.3±0.4ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
256±0.2ms 257±0.7ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
1.37±0s 1.37±0s 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 15.0)
532±2ms 532±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
14.0±0.03ms 14.1±0.05ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
27.1±0.06ms 27.0±0.05ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 15.0)
17.4±0.03ms 17.5±0.09ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
252±0.2ms 252±0.5ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
1.37±0s 1.36±0.01s 0.99 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 15.0)
526±2ms 530±3ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
13.7±0.05ms 13.7±0.02ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
26.3±0.08ms 26.2±0.04ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 15.0)
16.9±0.02ms 17.0±0.03ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 5.0)
1.19 1.19 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 1.0)
612.76 612.76 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 15.0)
74.17 74.17 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 5.0)
1.0 1.0 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 1.0)
37.29 37.29 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 15.0)
6.57 6.57 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 5.0)
728k 728k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 1.0)
141M 141M 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 15.0)
17.4M 17.4M 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 5.0)
43k 43k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 1.0)
563k 563k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 15.0)
123k 123k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 5.0)
5.72M 5.72M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 1.0)
145M 145M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 15.0)
21.5M 21.5M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 5.0)
362k 362k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 1.0)
825k 825k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 15.0)
384k 384k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 5.0)
47.6±0.4ms 47.7±0.6ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
24.9±0.08ms 25.0±0.02ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
44.2±2ms 44.7±1ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
10.8±0.1ms 11.0±0.1ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
550±3μs 547±10μs 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
7.91±0.2ms 7.94±0.1ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.84M 5.76M 0.99 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'grid_chunks')
2.75M 2.75M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'numpy')
5.68M 5.68M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'time_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
545k 529k 0.97 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
13.3±0.02s 13.4±0.03s 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
14.1±0.01s 14.0±0.01s 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
243±0.6ms 244±0.7ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
251±0.1ms 252±1ms 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
1.41±0s 1.41±0s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
1.62±0s 1.62±0s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
27.4±0.2ms 27.5±0.06ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
28.8±0.09ms 28.8±0.07ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
44.8±0.04ms 44.9±0.03ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
259±0.3ms 260±0.3ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
610±10μs 622±10μs 1.02 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
2.09±0.01ms 2.11±0.06ms 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'median')
239k 239k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'mean')
245k 245k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'median')
19.4k 19.2k 0.99 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'mean')
19.9k 19.7k 0.99 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
374±20μs 352±10μs 0.94 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
336±10μs 351±4μs 1.04 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
326±10μs 334±8μs 1.02 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
307±8μs 314±5μs 1.02 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
200±0.6ms 211±0.6ms 1.05 mpas_ocean.RemapDownsample.time_bilinear_remapping
232±2ms 228±0.7ms 0.99 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.4±0.07ms 15.4±0.06ms 1.00 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.11±0.01s 1.12±0s 1.01 mpas_ocean.RemapUpsample.time_bilinear_remapping
36.4±2ms 36.2±0.2ms 1.00 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
11.0±0.5ms 10.9±0.08ms 0.99 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
9.41±0.4ms 9.15±0.08ms 0.97 mpas_ocean.ZonalAverage.time_zonal_average('120km')
4.86±0.05ms 4.85±0.05ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('480km')
357M 365M 1.02 mpas_ocean.ZonalAveragePeakMem.peakmem_zonal_average('120km')
340M 348M 1.02 mpas_ocean.ZonalAveragePeakMem.peakmem_zonal_average('480km')
6.21±0.01ms 6.66±0.09ms 1.07 quad_hexagon.QuadHexagon.time_open_dataset
5.18±0.02ms 5.60±0.07ms 1.08 quad_hexagon.QuadHexagon.time_open_grid
408 408 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_dataset
392 392 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_grid
73.8k 72.6k 0.98 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
73k 72.6k 0.99 quad_hexagon.QuadHexagon.track_peakmem_open_grid

Benchmarks that have got worse:

Change Before [38b33d8] <v2026.09.0> After [6dee854] Ratio Benchmark (Parameter)
+ 59.6±0.8μs 85.2±5μs 1.43 bench_connectivity.Connectivity.time_face_node('120km')
+ 446±10μs 535±7μs 1.2 bench_connectivity.Connectivity.time_n_nodes_per_face('120km')
+ 22.5±0.4ms 32.5±0.1ms 1.45 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
+ 2.56±0.05ms 3.43±0.03ms 1.34 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
+ 633k 764k 1.21 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')

@Sevans711 Sevans711 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Making good progress! Still a few inline comments to resolve, see below and also I left some follow-up comments on previous inline comment threads.

Also, looking at ASV benchmark results, it looks like quite a few benchmarks have slowed down significantly, especially dual mesh construction being ~50% slower. Why would dual mesh construction slow down so much, if the search for duplicates primarily occurs when building the original grid? Tagging @cmdupuis3 to maybe comment on the benchmarks if you have additional insights. See also #1710 as a sanity check that the benchmark changes represent real changes to performance, rather than quirks of the benchmarking suite.

Comment thread test/grid/grid/test_core.py Outdated

3---2---7 lat 1 nodes 2,3 are the shared edge
| | | nodes 7,6 are their duplicates
0---1---6 lat 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think this diagram corresponds to the words/code… did a robot draw this? Please fix! (Or, please clarify how it corresponds to the data, if I misunderstood….) Where are nodes 4 and 5?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also, the repeated nodes in the code below are 1,2 mapping to 6,7, not 2,3 mapping to 6,7 as claimed in the test comments above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Redrew the diagram and fixed the node numbering in 77b5f83.

0---1---6 lat 0
"""
node_lon = np.array([0.0, 1.0, 1.0, 0.0, 2.0, 2.0, 1.0, 1.0])
node_lat = np.array([0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The test suite is missing a (non-pole) test demonstrating that error tolerance is being handled properly. Adding a tiny number (1e-9?) to one of the duplicates here would be sufficient to spot check this. E.g. node 1 could keep node_lon = 1.0, but set node 6's node_lon to 1.0+1e-9.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added test_duplicate_nodes_tolerance in c968d01, node 6 at lon 1.0+1e-9.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction: that offset was 1e-9 degrees but the cutoff is a 1e-8 chord (~5.7e-7 degrees), so the test passed far inside the boundary. Parametrized in 4d580fc over 1e-9 and 1e-7 (merge) and 1e-5 (must not).

)
)
else:
return grid_ds

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why return grid_ds unchanged if it doesn't have x,y,z or lon,lat values? My concern is that this is a good way to silently cause incorrect results, where you expect duplicates to have been merged but can't trust that they actually were merged. Maybe crash or at least raise a warning in this case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Warns with a RuntimeWarning now instead of returning silently, in f1e6e5c. Kept the unchanged return so datasets with no coordinates still round-trip.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added test_merge_warns_when_node_locations_are_unknown in 4d580fc; the branch had no coverage.

)
)
grid_ds["face_node_connectivity"] = grid_ds["face_node_connectivity"].copy(
data=_collapse_repeated_face_corners(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why is something like _collapse_repeated_face_corners only relevant in the face_node_connectivity case? Maybe it is true but could you clarify with comment reasoning through the other connectivity combinations and justifying why it wouldn't happen for them?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the reasoning as a comment in e61e1fd.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction: node_node_connectivity can gain a repeat too ([1, 1, 3, FILL] when a row lists both a duplicate and its canonical node). Rewrote the comment in 4d580fc to cover all three groups.

@rajeeja

rajeeja commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Making good progress! Still a few inline comments to resolve, see below and also I left some follow-up comments on previous inline comment threads.

Also, looking at ASV benchmark results, it looks like quite a few benchmarks have slowed down significantly, especially dual mesh construction being ~50% slower. Why would dual mesh construction slow down so much, if the search for duplicates primarily occurs when building the original grid? Tagging @cmdupuis3 to maybe comment on the benchmarks if you have additional insights. See also #1710 as a sanity check that the benchmark changes represent real changes to performance, rather than quirks of the benchmarking suite.

Thanks, the dual mesh slowdown was the duplicate check running on every get_dual call and rescanning every node each time. It's cached on the grid now.

@rajeeja
rajeeja requested a review from Sevans711 September 11, 2026 16:36
get_dual constructs a new Grid from this grid's face centers, which re-ran
the coincident node search on every call and made dual mesh construction
~1.6x slower. The dual's connectivity is already canonical, so the merge is
skipped there via a new internal Grid flag.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working run-benchmark Run ASV benchmark workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handling Duplicate Node Indices

2 participants