Skip to content

Implements Grid.compute_face_node_angles() - #1672

Merged
Sevans711 merged 15 commits into
mainfrom
face_node_angles
Aug 18, 2026
Merged

Implements Grid.compute_face_node_angles()#1672
Sevans711 merged 15 commits into
mainfrom
face_node_angles

Conversation

@Sevans711

@Sevans711 Sevans711 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #1566

Overview

Implements new function: Grid.compute_face_node_angles(), which computes the internal angles at each node, for each face in a grid. Intentionally excludes many options initially suggested in #1566, as the discussion there revealed:

  • There is no need to support non-spherical geometry or non-convex faces (see also Clarify in docs: UXarray supports only spherical 2D grids with convex faces #1626).
  • The implementation here is extremely fast (e.g., roughly 3 seconds for 3.75km MPAS grid, even when numba has only 4 threads), and the result's size is nontrivial (size=n_faces*n_max_face_nodes), so it doesn't really make sense to include a caching option right now.

Also adds corresponding tests, checking that the angle computations give correct values in a roughly 30,60,90 degree triangle and a hexagon grid. Also checks nan handling in a grid with both pentagons and hexagons (result should be nan wherever n_max_face_nodes dimension index is larger than n_nodes_per_face). For all three cases, checks that the generalized spherical excess formula holds, by comparing to Grid.face_areas results.

Within scope but possibly unexpected change included this PR: refactored _small_angle_of_2_vectors to improve efficiency when inputs are not numpy arrays, and rewrote docstring accordingly. (See also: #1648.) This was necessary to improve efficiency for compute_face_node_angles() without inlining all of the _small_angle_of_2_vectors logic. This is unlikely to affect efficiency elsewhere in the code, because other regions of the code seem to always construct tiny numpy arrays before calling _small_angle_of_2_vectors. Running ASV benchmarks here to check, just in case (but it would be acceptable if benchmarks show no improvements).

Also related (may help visualize face node angle results, if implemented): #1578

Expected Usage

import uxarray as ux

grid_path = "/path/to/grid.nc"
data_path = "/path/to/data.nc"

uxds = ux.open_dataset(grid_path, data_path)
grid = uxds.uxgrid   # or just do: grid = ux.open_grid(grid_path)

# internal angle [in radians] at each node, for each face, as an xr.DataArray
angles_rad = grid.compute_face_node_angles()

# internal angle [in degrees] at each node, for each face, as an xr.DataArray
angles_deg = grid.compute_face_node_angles(degrees=True)

# internal angle [in radians] at each node, for each face, as a ux.UxDataArray
angles_rad_uxarr = grid.compute_face_node_angles(as_uxarray=True)

PR Checklist

General

  • An issue is created and linked
  • Added appropriate labels (if your uxarray repo permissions allow it)
  • Filled out Overview and Expected Usage (if applicable) sections

Testing & Benchmarking

  • Adequate tests are created if there is new functionality
  • Tests are not too basic (such as simply calling a function and nothing else)
  • Tests cover all major paths in your new functions
  • If this PR could affect performance, ran ASV benchmarks and confirmed they show expected behavior (add a new benchmark if necessary)

Documentation

  • Docstrings have been added to all new functions
  • Docstrings have been updated with any function changes
  • User (public) functions have been added to docs/api.rst
  • Internal (private) function names start with an underscore (_)

AI Disclosure

AI Usage: discussions with Claude and ChatGPT, inline code suggestions from GitHub Copilot. Also (not in this PR, but contributed to decisions, see original issue for details) used Claude to make a numpy-only method for efficiency comparisons, and other people used Claude to iterate and run tests for more efficiency improvements.

  • I take responsibility for all AI-generated content in my PR.
  • I have tested all AI-generated content in my PR.

Sevans711 and others added 9 commits July 15, 2026 13:45
This provides an initial implementation which seems to be working. See #1566

TODO:
- support assume_convex=False
- type-hint possible UxDataArray return type?
- tests
- examples
removes "geometry" (always use spherical)
removes "assume_convex" (always assume convex)

Also, improves type-hinting for compute_face_node_angles().

Also, formats using pre-commit ruff formatting.
use tuples instead of tiny arrays in numba!

Cleans up docstring in grid.py (forgot to change in previous commit).
(it was making a type error in numba when trying to run the method)
compute_face_node_angles() seems to be really fast, so including a cache option in initial implementation isn't worthwhile.
@Sevans711 Sevans711 added new feature New feature or request run-benchmark Run ASV benchmark workflow labels Aug 12, 2026
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [fda71bb] After [de36c56] Ratio Benchmark (Parameter)
- 3.45±0.01s 2.49±0.01s 0.72 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
- 224±0.7ms 162±1ms 0.72 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')

Benchmarks that have stayed the same:

Change Before [fda71bb] After [de36c56] Ratio Benchmark (Parameter)
200±0.2ms 204±1ms 1.02 bench_connectivity.Connectivity.time_edge_face('120km')
12.2±0.1ms 12.3±0.08ms 1.01 bench_connectivity.Connectivity.time_edge_face('480km')
200±1ms 203±0.4ms 1.01 bench_connectivity.Connectivity.time_edge_node('120km')
11.3±0.03ms 11.3±0.06ms 1.00 bench_connectivity.Connectivity.time_edge_node('480km')
199±1ms 203±0.6ms 1.02 bench_connectivity.Connectivity.time_face_edge('120km')
11.4±0.1ms 12.1±0.4ms 1.06 bench_connectivity.Connectivity.time_face_edge('480km')
898±6ms 921±5ms 1.03 bench_connectivity.Connectivity.time_face_face('120km')
57.7±0.6ms 58.0±4ms 1.01 bench_connectivity.Connectivity.time_face_face('480km')
70.6±6μs 69.2±3μs 0.98 bench_connectivity.Connectivity.time_face_node('120km')
68.0±2μs 67.5±1μs 0.99 bench_connectivity.Connectivity.time_face_node('480km')
426±5μs 431±8μs 1.01 bench_connectivity.Connectivity.time_n_nodes_per_face('120km')
355±10μs 360±8μs 1.01 bench_connectivity.Connectivity.time_n_nodes_per_face('480km')
200±0.6ms 204±0.6ms 1.02 bench_connectivity.Connectivity.time_node_edge('120km')
11.6±0.09ms 11.6±0.08ms 1.00 bench_connectivity.Connectivity.time_node_edge('480km')
82.4±0.9ms 80.2±0.6ms 0.97 bench_connectivity.Connectivity.time_node_face('120km')
5.35±0.07ms 5.29±0.06ms 0.99 bench_connectivity.Connectivity.time_node_face('480km')
8.52±0.09ms 8.51±0.07ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.76±0.1ms 2.79±0.04ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
10.5±10s 10.3±10ms ~0.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
2.26±0.01ms 2.18±0.04ms 0.97 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.98M 1.98M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.99M 1.99M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.15M 2.15M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
38.3k 38.3k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
336M 336M 1.00 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
365M 367M 1.01 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
338M 338M 1.00 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
337M 337M 1.00 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.20±0.06μs 1.22±0.03μs 1.02 geometry_kernels.AccucrossKernels.time_accucross
2.79±0.04μs 2.75±0.04μs 0.99 geometry_kernels.AccucrossKernels.time_accucross_pair
471±20ns 451±10ns 0.96 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
446±20ns 441±30ns 0.99 geometry_kernels.EFTPrimitives.time_diff_of_products
390±20ns 426±20ns 1.09 geometry_kernels.EFTPrimitives.time_two_prod
411±30ns 400±20ns 0.97 geometry_kernels.EFTPrimitives.time_two_sum
1.68±0.06μs 1.59±0.06μs 0.95 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
1.13±0.02μs 1.13±0.01μs 1.00 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
1.92±0.03μs 1.94±0.02μs 1.01 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
1.72±0.02μs 1.68±0.05μs 0.98 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
1.44±0.04μs 1.41±0.03μs 0.98 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
2.19±0.02μs 2.22±0.02μs 1.02 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
52.9±0.8μs 53.1±1μs 1.00 geometry_kernels.OrientPredicates.time_on_minor_arc
1.09±0.03μs 1.12±0.04μs 1.03 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
2.72±0.1ms 2.63±0.02ms 0.97 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.17±0.01ms 1.17±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
1.73±0.01ms 1.72±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
147±0.7μs 147±0.3μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
32.3±0ms 32.5±0.2ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
10.3±0ms 11.9±0.8ms ~1.16 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
27.0±0.2ms 26.7±0.1ms 0.99 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
4.90±0ms 4.96±0.07ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
833±3ms 830±2ms 1.00 import.Imports.timeraw_import_uxarray
293M 293M 1.00 import.Imports.track_peakmem_import_uxarray
2.76±0.02ms 2.85±0.04ms 1.03 mpas_ocean.CheckNorm.time_check_norm('120km')
2.27±0.03ms 2.31±0.01ms 1.02 mpas_ocean.CheckNorm.time_check_norm('480km')
858±10ms 851±9ms 0.99 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
55.2±0.3ms 56.0±0.5ms 1.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
678±10μs 677±9μs 1.00 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
587±10μs 606±10μs 1.03 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.46±0.03ms 5.47±0.04ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.99±0.02ms 3.96±0.04ms 0.99 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
18.2±0.02ms 18.2±0.01ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.05±0.01ms 1.04±0.02ms 0.99 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.6±0.03ms 10.6±0.03ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
730±7μs 732±10μs 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
587±4ms 591±2ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 1)
297±0.7ms 296±1ms 1.00 mpas_ocean.CrossSections.time_const_lat('120km', 2)
155±0.5ms 156±1ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 4)
534±0.7ms 536±3ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 1)
270±0.8ms 270±1ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 2)
139±0.3ms 139±0.6ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 4)
356M 356M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 1)
356M 356M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 2)
355M 356M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 4)
341M 339M 0.99 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 1)
339M 339M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 2)
339M 339M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 4)
25.0±0.4ms 24.5±0.07ms 0.98 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
3.21±0.07ms 3.25±0.06ms 1.01 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
61.6±1ms 61.7±0.6ms 1.00 mpas_ocean.FaceAreas.time_face_areas('120km')
4.90±5s 7.88±5ms ~0.00 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')
823k 823k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
949±9ms 943±10ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
54.5±0.8ms 55.2±2ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
84.5±0.3ms 84.6±0.5ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.76±0.05ms 5.98±0.1ms 1.04 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
175±0.7ms 175±0.2ms 1.00 mpas_ocean.Gradient.time_gradient('120km')
12.2±0.01ms 12.2±0.06ms 1.00 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')
5.08M 5.08M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
328k 328k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
351M 352M 1.00 mpas_ocean.GradientColdStartRss.peakmem_gradient('120km')
331M 331M 1.00 mpas_ocean.GradientColdStartRss.peakmem_gradient('480km')
371±10μs 370±10μs 1.00 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
201±10μs 199±3μs 0.99 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
566±9μs 579±6μs 1.02 mpas_ocean.Integrate.time_integrate('120km')
494±5μs 500±10μs 1.01 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')
184±1ms 183±1ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
187±6ms 184±1ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
183±2ms 182±1ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
13.7±0.09ms 13.7±0.08ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
14.0±0.08ms 13.7±0.03ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.6±0.09ms 13.7±0.2ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
430±20μs 433±10μs 1.01 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
414±6μs 438±10μs 1.06 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
407±9μs 402±10μs 0.99 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
408±10μs 383±7μs 0.94 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
244±1ms 239±0.7ms 0.98 mpas_ocean.RemapDownsample.time_bilinear_remapping
290±1ms 281±0.8ms 0.97 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.6±0.1ms 15.7±0.1ms 1.00 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.44±0.01s 1.39±0s 0.97 mpas_ocean.RemapUpsample.time_bilinear_remapping
36.0±0.3ms 37.0±0.4ms 1.03 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
12.5±0.2ms 12.3±0.1ms 0.99 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
9.29±0.2ms 9.12±0.04ms 0.98 mpas_ocean.ZonalAverage.time_zonal_average('120km')
4.82±0.06ms 4.79±0.04ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('480km')
358M 358M 1.00 mpas_ocean.ZonalAveragePeakMem.peakmem_zonal_average('120km')
341M 341M 1.00 mpas_ocean.ZonalAveragePeakMem.peakmem_zonal_average('480km')
6.83±0.03ms 6.84±0.01ms 1.00 quad_hexagon.QuadHexagon.time_open_dataset
5.86±0.01ms 5.87±0.01ms 1.00 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.5k 73.8k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
72.7k 73k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid

@Sevans711

Copy link
Copy Markdown
Collaborator Author

Looks like this PR is having a nice side effect: the optimization to _small_angle_of_2_vectors did in fact improve the mpas_ocean.ConstructFaceLatLon.time_welzl benchmarks. Following the code reveals that it does in fact get called there, so this is probably a real improvement, not just a rounding error.

(The chain of calls is deep, so I'm putting it here for reference: construct_face_centers(method='welzl') --> _populate_face_centerpoints --> _construct_face_centerpoints --> _smallest_enclosing_circle --> _welzl_recursive --> _circle_from_two_points or _circle_from_three_points --> _small_angle_of_2_vectors)

Ignoring the apparent peakmem improvements (see #1605).

Most importantly, none of the benchmarks show performance degradation, which helps demonstrate that the _small_angle_of_2_vectors are at best an improvement, at worst causing no change.

@erogluorhan erogluorhan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This looks great to me! Thanks for all the invaluable discussion throughout this!

Comment thread uxarray/grid/utils.py
Comment thread test/grid/geometry/test_angles.py Outdated
Comment thread test/grid/geometry/test_angles.py Outdated
Comment thread test/grid/geometry/test_angles.py Outdated
@Sevans711
Sevans711 requested a review from cmdupuis3 August 18, 2026 17:42

@cmdupuis3 cmdupuis3 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.

LGTM, tests pass and the tests and numba parallelization look reasonable. The vector arithmetic will probably need to sync up with other incoming PRs in the future though

@Sevans711
Sevans711 merged commit ab39109 into main Aug 18, 2026
13 checks passed
@Sevans711
Sevans711 deleted the face_node_angles branch August 18, 2026 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new feature New feature or request run-benchmark Run ASV benchmark workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compute face_node_angles from uxarray.Grid?

3 participants