Skip to content

Fix _small_angle_of_2_vectors to evaluate Kahan's formula accurately - #1863

Merged
rajeeja merged 3 commits into
UXARRAY:mainfrom
hongyuchen1030:fix/small-angle-kahan-eft
Oct 30, 2026
Merged

rajeeja merged 3 commits into
UXARRAY:mainfrom
hongyuchen1030:fix/small-angle-kahan-eft

Conversation

@hongyuchen1030

@hongyuchen1030 hongyuchen1030 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1864

Overview

_small_angle_of_2_vectors implements Kahan's angle formula for vectors of arbitrary length,

angle = 2 * atan2(| v|u| - u|v| |, | v|u| + u|v| |)

but it formed each component of v|u| - u|v| from two separately rounded products. For nearly parallel vectors this is a naive difference of two nearly equal products, so the relative error grew as eps/angle (a mean of about 2e-8 at 1e-9 rad). This was found while reviewing #1806.

This PR evaluates each component of v|u| - u|v| with the FMA-based diff_of_products (Kahan's 2x2 determinant) from uxarray/utils/computing.py, through a new vector helper _numba_diff_of_products3 in uxarray/utils/numba_math.py. The norms and the sum v|u| + u|v| are still evaluated directly:

  • Compensating the norms only matters below about 1e-7 rad (it removes a second-order (eps/angle)^2 term) and roughly doubles the time per call, so it is not included.
  • The sum cancels only for nearly antiparallel vectors, and that does not affect the returned angle (near pi, its error enters as an absolute error that is tiny relative to the angle).

The function keeps supporting inputs of arbitrary length, which its callers rely on (tangent-plane vectors in grid/angles.py, chord vectors in grid/point_in_face.py).

Accuracy benchmark

Mean relative error over 200 random pairs per angle:

angle (rad) 1e-1 1e-3 1e-5 1e-7 1e-9
main 2.4e-16 2.2e-14 2.5e-12 2.4e-10 2.1e-08
this PR 8.8e-17 9.2e-17 7.5e-17 8.5e-17 3.3e-15

Speed (mean time per call over 10 runs of 2,000,000 calls inside an @njit loop, after a warm-up run): main 41.9 ns, this PR 48.5 ns.

Setup

  • Inputs: u ~ N(0, I_3); v = cos(angle) * u/|u| + sin(angle) * w/|w| with w = u x r, r ~ N(0, I_3); then u and v are scaled by independent factors from U(0.1, 10), so the two vectors have different, non-power-of-two lengths. RNG: numpy.random.default_rng(0), the same pairs for both implementations.
  • Reference: the exact angle between the float64 inputs as given. Their cross and dot products are formed exactly with fractions.Fraction, then atan(|u x v| / (u . v)) is evaluated with a 50-digit decimal Taylor series (_exact_small_angle in test/grid/geometry/test_angles.py).
  • Environment: macOS 26.6.2, Apple Silicon (arm64), numpy 2.4.6, numba 0.66.0.
Benchmark script
import sys, time
sys.path.insert(0, "test/grid/geometry")  # run from the repository root
import numpy as np
from numba import njit
from test_angles import _exact_small_angle
from uxarray.grid.utils import _small_angle_of_2_vectors as new
from uxarray.utils.numba_math import _numba_norm3, _numba_mul3_scalar, _numba_sub3, _numba_add3

@njit
def old(u, v):  # implementation on main
    u_times_v_norm = _numba_mul3_scalar(v, _numba_norm3(u))
    v_times_u_norm = _numba_mul3_scalar(u, _numba_norm3(v))
    vec_minus = _numba_sub3(u_times_v_norm, v_times_u_norm)
    vec_sum = _numba_add3(u_times_v_norm, v_times_u_norm)
    return 2 * np.arctan2(_numba_norm3(vec_minus), _numba_norm3(vec_sum))

# Accuracy: mean relative error over 200 pairs per angle
for name, f in (("main", old), ("this PR", new)):
    row = []
    for angle in (1e-1, 1e-3, 1e-5, 1e-7, 1e-9):
        rng = np.random.default_rng(0)
        errs = []
        for _ in range(200):
            u = rng.normal(size=3)
            w = np.cross(u, rng.normal(size=3))
            v = u / np.linalg.norm(u) * np.cos(angle) + w / np.linalg.norm(w) * np.sin(angle)
            u, v = u * rng.uniform(0.1, 10.0), v * rng.uniform(0.1, 10.0)
            exact = _exact_small_angle(u, v)
            errs.append(abs(f((u[0], u[1], u[2]), (v[0], v[1], v[2])) - exact) / exact)
        row.append(f"{np.mean(errs):.1e}")
    print(name, " | ".join(row))

# Speed: mean time per call over 10 runs of 2,000,000 calls, after a warm-up
U = np.random.default_rng(5).normal(size=(2_000_000, 3))
V = np.random.default_rng(6).normal(size=(2_000_000, 3))

@njit
def loop(f, U, V):
    s = 0.0
    for i in range(U.shape[0]):
        s += f((U[i, 0], U[i, 1], U[i, 2]), (V[i, 0], V[i, 1], V[i, 2]))
    return s

for name, f in (("main", old), ("this PR", new)):
    loop(f, U, V)
    times = []
    for _ in range(10):
        t0 = time.perf_counter()
        loop(f, U, V)
        times.append(time.perf_counter() - t0)
    print(name, f"{np.mean(times) / U.shape[0] * 1e9:.1f} ns per call")

Tests

test/grid/geometry/test_angles.py now holds the angle-function tests: the two existing _angle_of_2_vectors tests (moved from test/test_helpers.py) and new tests for _small_angle_of_2_vectors, which previously had none: special values (orthogonal, parallel, antiparallel, zero vector) and nearly parallel vectors of different lengths at 1e-3 to 1e-9 rad against the exact reference above. The accuracy tests fail on main and pass with this change. test/utils/test_numba_math.py adds a test for _numba_diff_of_products3 against exact rational arithmetic.

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

  • There is adequate test coverage of changes from this PR (add new tests if needed)
  • If this PR could affect performance, ran ASV benchmarks and confirmed they show expected behavior (add a new benchmark if necessary)

Documentation and Examples

  • Docstrings updated with any function changes, and included in all new functions
  • [N/A] User (public) functions added to docs/api.rst; internal (private) function names start with an underscore (_)
  • [N/A] If touched any notebook files, cleared the output of all cells before committing
  • [N/A] If added new notebook files, put into appropriate directories and referenced in appropriate files

AI Disclosure

AI Usage: Claude Opus 5.5,

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

@hongyuchen1030 hongyuchen1030 self-assigned this Oct 30, 2026
@hongyuchen1030 hongyuchen1030 added improvement Improvements on existing features or infrastructure accuracy Related to mathematical accuracy of operations, such as numerical precision or rounding errors labels Oct 30, 2026
@hongyuchen1030 hongyuchen1030 added this to the Calculus Operators milestone Oct 30, 2026
@hongyuchen1030 hongyuchen1030 moved this to 🏗 In progress in UXarray Development Oct 30, 2026
_small_angle_of_2_vectors implements Kahan's angle formula for vectors of
arbitrary length, 2*atan2(|v|u| - u|v||, |v|u| + u|v||), but formed each
component of v|u| - u|v| from two separately rounded products. For nearly
parallel vectors this is a naive difference of products, and the relative
error grew as eps/angle (a mean of about 2e-8 at 1e-9 rad).

Each component of v|u| - u|v| is now evaluated with diff_of_products. The
norms and the sum are still computed directly: compensating the norms only
removes a second-order (eps/angle)^2 term below ~1e-7 rad, and the sum's
cancellation near antiparallel vectors does not affect the returned angle.
The mean relative error is now below 1e-16 down to 1e-7 rad and 3.3e-15 at
1e-9 rad, at a mean of 48.5 ns per call (41.9 ns before).

Add tests against an exact rational reference; they fail on the previous
implementation.
@hongyuchen1030
hongyuchen1030 force-pushed the fix/small-angle-kahan-eft branch from 0687705 to 470472b Compare October 30, 2026 04:24
@hongyuchen1030
hongyuchen1030 requested review from Sevans711, cmdupuis3 and rajeeja and removed request for Sevans711 and cmdupuis3 October 30, 2026 04:25
@hongyuchen1030
hongyuchen1030 marked this pull request as ready for review October 30, 2026 04:25
@Sevans711 Sevans711 added the run-benchmark Run ASV benchmark workflow label Oct 30, 2026
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have stayed the same:

Change Before [d0b8e6e] After [9936925] Ratio Benchmark (Parameter)
929±50μs 976±30μs 1.05 connectivity.Connectivity.time_edge_face('120km')
418±20μs 431±8μs 1.03 connectivity.Connectivity.time_edge_face('480km')
6.25±0.2ms 6.14±0.1ms 0.98 connectivity.Connectivity.time_edge_node('120km')
1.04±0.02ms 1.07±0.03ms 1.03 connectivity.Connectivity.time_edge_node('480km')
59.1±9μs 58.2±6μs 0.99 connectivity.Connectivity.time_face_edge('120km')
48.1±3μs 49.5±9μs 1.03 connectivity.Connectivity.time_face_edge('480km')
758±20μs 789±30μs 1.04 connectivity.Connectivity.time_face_face('120km')
340±20μs 345±10μs 1.01 connectivity.Connectivity.time_face_face('480km')
46.7±5μs 51.3±9μs 1.10 connectivity.Connectivity.time_face_node('120km')
46.6±4μs 50.3±10μs 1.08 connectivity.Connectivity.time_face_node('480km')
392±9μs 418±10μs 1.07 connectivity.Connectivity.time_n_nodes_per_face('120km')
325±20μs 319±10μs 0.98 connectivity.Connectivity.time_n_nodes_per_face('480km')
1.36±0.07ms 1.29±0.06ms 0.95 connectivity.Connectivity.time_node_edge('120km')
438±10μs 440±10μs 1.00 connectivity.Connectivity.time_node_edge('480km')
89.6±4ms 90.6±2ms 1.01 connectivity.Connectivity.time_node_face('120km')
5.04±0.03ms 5.19±0.05ms 1.03 connectivity.Connectivity.time_node_face('480km')
1.42M 1.42M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('120km')
105k 105k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('480km')
6.48M 6.48M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('120km')
419k 419k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('480km')
2.05k 2.05k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('120km')
2.05k 2.05k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('480km')
1.6M 1.6M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_face('120km')
101k 101k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_face('480km')
2.18k 2.18k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('120km')
2.18k 2.18k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('480km')
240k 240k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('120km')
26k 26k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('480km')
1.9M 1.9M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_edge('120km')
127k 127k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_edge('480km')
11.9M 11.9M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_face('120km')
747k 747k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_face('480km')
8.32±0.3ms 8.57±0.4ms 1.03 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.66±0.03ms 2.75±0.08ms 1.04 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
10.1±0.7ms 10.2±9ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.48±0.07ms 1.61±0.08ms 1.09 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.83M 1.83M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.83M 1.83M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
1.99M 1.99M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
35.3k 35.3k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.15±0.08μs 1.31±0.03μs ~1.14 geometry_kernels.AccucrossKernels.time_accucross
2.42±0.07μs 2.63±0.07μs 1.09 geometry_kernels.AccucrossKernels.time_accucross_pair
511±20ns 506±20ns 0.99 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
461±20ns 481±5ns 1.04 geometry_kernels.EFTPrimitives.time_diff_of_products
421±20ns 411±8ns 0.98 geometry_kernels.EFTPrimitives.time_two_prod
431±10ns 426±20ns 0.99 geometry_kernels.EFTPrimitives.time_two_sum
671±40ns 614±30ns 0.92 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
885±50ns 810±60ns 0.92 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
858±60ns 984±200ns ~1.15 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
785±50ns 750±30ns 0.96 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
1.22±0.05μs 1.21±0.02μs 0.99 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
44.3±0.7μs 44.1±1μs 1.00 geometry_kernels.OrientPredicates.time_on_minor_arc
43.7±0.5μs 44.4±1μs 1.02 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
4.25±0.01ms 4.26±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_dispatch
783±5μs 764±3μs 0.98 geometry_samebody.SameBodyConstLat.time_accux_kernel
4.12±0ms 4.14±0.08ms 1.01 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
117±0.1μs 117±0.1μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.6±0.08ms 29.7±0.05ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.34±0.01ms 6.34±0.03ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.6±0.02ms 23.9±0.02ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
866±6μs 884±10μs 1.02 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
830±6ms 857±8ms 1.03 import.Imports.timeraw_import_uxarray
1 1 1.00 lazy_grid_construction.LazyGridConstruction.track_computes_isel
2 2 1.00 lazy_grid_construction.LazyGridConstruction.track_computes_open_grid_chunked
96.7±2ms 95.2±1ms 0.98 lazy_grid_construction.OpenGridChunked.time_open_grid('120km')
130±2ms 131±0.9ms 1.01 lazy_grid_construction.OpenGridChunked.time_open_grid('480km')
2.01M 1.99M 0.99 lazy_grid_construction.OpenGridChunked.track_peakmem_open_grid('120km')
2.32M 2.3M 0.99 lazy_grid_construction.OpenGridChunked.track_peakmem_open_grid('480km')
2.72±0.09ms 2.68±0.09ms 0.99 mpas_ocean.CheckNorm.time_check_norm('120km')
1.97±0.04ms 1.94±0.04ms 0.99 mpas_ocean.CheckNorm.time_check_norm('480km')
990±7μs 1.01±0.03ms 1.02 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
484±10μs 479±10μs 0.99 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
569±3μs 605±20μs 1.06 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
505±8μs 514±4μs 1.02 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.27±0.01ms 5.35±0.09ms 1.02 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.69±0.04ms 3.74±0.1ms 1.01 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
105±0.2ms 105±0.6ms 1.01 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
9.98±0.07ms 10.5±0.5ms 1.05 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
13.0±0.04ms 12.9±0.02ms 0.99 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
763±10μs 769±10μs 1.01 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
9.31±0.03ms 9.13±0.05ms 0.98 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
580±10μs 564±20μs 0.97 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
674±5ms 696±20ms 1.03 mpas_ocean.CrossSections.time_const_lat('120km', 1)
335±3ms 334±2ms 1.00 mpas_ocean.CrossSections.time_const_lat('120km', 2)
180±5ms 172±6ms 0.96 mpas_ocean.CrossSections.time_const_lat('120km', 4)
608±2ms 605±10ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 1)
299±5ms 297±5ms 0.99 mpas_ocean.CrossSections.time_const_lat('480km', 2)
155±3ms 151±2ms 0.98 mpas_ocean.CrossSections.time_const_lat('480km', 4)
28.9±0.1ms 29.1±0.6ms 1.01 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
3.01±0.01ms 2.97±0.03ms 0.99 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
14.1±0.7ms 14.5±0.3ms 1.02 mpas_ocean.FaceAreas.time_face_areas('120km')
3.68±0.1ms 3.75±0.3ms 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')
703k 703k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
54.3±2ms 54.6±0.9ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', 'exclude')
148±2ms 141±3ms 0.95 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', 'split')
4.81±0.3ms 4.73±0.4ms 0.98 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', 'exclude')
26.0±0.7ms 26.5±0.2ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', 'split')
13.9±0.09ms 14.5±0.2ms 1.05 mpas_ocean.Gradient.time_gradient('120km')
2.26±0.01ms 2.25±0.02ms 0.99 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.44M 3.44M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
226k 226k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
285±6μs 288±10μs 1.01 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
128±7μs 129±3μs 1.01 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
171±4μs 166±2μs 0.97 mpas_ocean.Integrate.time_integrate('120km')
148±4μs 145±2μs 0.98 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')
144±0.7ms 143±1ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
144±2ms 146±2ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
145±1ms 143±2ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
10.6±0.04ms 10.5±0.4ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
10.6±0.2ms 11.9±1ms ~1.12 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
10.6±0.06ms 11.2±0.06ms 1.06 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
250±3ms 249±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
514±1ms 515±3ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
13.5±0.04ms 13.6±0.2ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
17.1±0.07ms 16.9±0.1ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
244±0.6ms 245±0.9ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
511±3ms 508±3ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
13.3±0.1ms 13.2±0.2ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
16.3±0.08ms 16.5±0.2ms 1.02 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 5.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)
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)
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)
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)
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)
824k 824k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 15.0)
384k 384k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 5.0)
39.9±0.4ms 40.9±0.7ms 1.03 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
17.3±0.05ms 17.3±0.1ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
36.3±0.4ms 36.3±0.4ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
10.1±0.04ms 10.2±0.08ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
598±3μs 606±9μs 1.01 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
7.07±0.07ms 7.29±0.2ms 1.03 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.82M 5.74M 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.67M 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')
521k 535k 1.03 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
3.11±0.01s 3.11±0.01s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
3.29±0.01s 3.30±0.02s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
90.4±1ms 88.7±0.4ms 0.98 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
91.0±0.4ms 93.1±0.2ms 1.02 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
344±3ms 344±1ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
373±1ms 374±4ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
12.8±0.1ms 12.6±0.05ms 0.98 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
12.9±0.2ms 12.9±0.02ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
3.75±0.06ms 3.86±0.03ms 1.03 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
37.4±0.1ms 37.3±0.08ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
187±4μs 184±4μs 0.99 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
535±20μs 530±7μs 0.99 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'median')
234k 234k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'mean')
235k 235k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'median')
19.5k 19.5k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'mean')
19.6k 19.6k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
354±10μs 373±6μs 1.05 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
323±10μs 342±40μs 1.06 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
340±20μs 324±8μs 0.95 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
307±20μs 316±10μs 1.03 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
98.5±1ms 99.5±0.4ms 1.01 mpas_ocean.RemapDownsample.time_bilinear_remapping
15.2±0.3ms 15.2±0.2ms 1.00 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
13.7±0.04ms 13.9±0.09ms 1.02 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.12±0s 1.11±0s 0.99 mpas_ocean.RemapUpsample.time_bilinear_remapping
28.3±0.3ms 27.2±0.1ms 0.96 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
11.8±0.01ms 12.1±0.05ms 1.02 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
7.91±0.1ms 7.94±0.2ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('120km')
4.17±0.07ms 4.14±0.1ms 0.99 mpas_ocean.ZonalAverage.time_zonal_average('480km')
1.030169778446527 1.01888713024832 0.99 nogil_scaling.GILScaling.track_gil_scaling
308±2ms 314±3ms 1.02 plots.PlotHealpix.time_default_plot(6)
2.99±0s 3.01±0.01s 1.01 plots.PlotHealpix.time_default_plot(8)
28M 27.9M 1.00 plots.PlotHealpix.track_peakmem_default_plot(6)
443M 443M 1.00 plots.PlotHealpix.track_peakmem_default_plot(8)
9.05±0.2ms 8.87±0.3ms 0.98 quad_hexagon.QuadHexagon.time_open_dataset
7.63±0.2ms 7.52±0.2ms 0.99 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
70.1k 70.1k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
69.9k 69.9k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid
776±3ms 772±4ms 1.00 to_raster.ToRaster.time_to_raster((10000.0, 10.0))
1.64±0.01s 1.64±0.01s 1.00 to_raster.ToRaster.time_to_raster((10000.0, 100.0))
1.94±0.02s 1.92±0.01s 0.99 to_raster.ToRaster.time_to_raster((100000.0, 100.0))
270M 270M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 10.0))
680M 680M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 100.0))
765M 765M 1.00 to_raster.ToRaster.track_peakmem((100000.0, 100.0))

Benchmarks that have got worse:

Change Before [d0b8e6e] After [9936925] Ratio Benchmark (Parameter)
+ 301M 386M 1.28 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
+ 301M 386M 1.28 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
+ 302M 386M 1.28 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
+ 301M 386M 1.28 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
+ 1.03±0.03μs 1.23±0.05μs 1.19 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
+ 276M 384M 1.39 import.Imports.track_peakmem_import_uxarray
+ 320M 386M 1.21 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
+ 321M 386M 1.2 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
+ 320M 386M 1.21 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
+ 304M 386M 1.27 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
+ 303M 386M 1.27 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
+ 304M 386M 1.27 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
+ 317M 384M 1.21 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
+ 296M 384M 1.3 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')
+ 676k 745k 1.1 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
+ 322M 385M 1.2 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
+ 305M 385M 1.27 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('480km')

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

This looks good overall, and it will be great to have more accurate angle computations! I confirmed locally that tests fail on main but pass here.

Would it make more sense to put these new tests into test/grid/geometry/test_angles.py instead? I do see that there are actually already a couple tests above the ones you added in test_helpers.py which test _angle_of_2_vectors, though. I would slightly prefer to move all of them into test_angles.py, although this would be a slight expansion of scope for the PR and it is just a non-blocking stylistic preference.

Comment thread uxarray/grid/utils.py Resolved
Comment thread uxarray/grid/utils.py Resolved
Comment thread uxarray/grid/utils.py Resolved
@github-project-automation github-project-automation Bot moved this from 🏗 In progress to 👀 In review in UXarray Development Oct 30, 2026
- Add _numba_diff_of_products3(u, a, v, b) to utils/numba_math.py: the
  component-wise u*a - v*b of 3-vectors via diff_of_products, returning
  (hi, lo) tuples.
- Write _small_angle_of_2_vectors with vector helpers instead of spelling
  out components; this also drops the float() conversions, which were only
  there to unpack components.
- Move the _angle_of_2_vectors and _small_angle_of_2_vectors tests from
  test_helpers.py to test/grid/geometry/test_angles.py, and test the new
  helper against exact rational arithmetic.

Accuracy and speed are unchanged.
@Sevans711
Sevans711 self-requested a review October 30, 2026 17:42

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

All my previous comments have been addressed; everything looks good to me now, happy to approve!

@rajeeja
rajeeja merged commit 9e2ad90 into UXARRAY:main Oct 30, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

accuracy Related to mathematical accuracy of operations, such as numerical precision or rounding errors improvement Improvements on existing features or infrastructure run-benchmark Run ASV benchmark workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_small_angle_of_2_vectors loses accuracy for nearly parallel vectors (naive difference of products in Kahan's formula)

3 participants