Skip to content

Screen bounding-box subset candidates on node coordinates before computing bounds - #1779

Open
rajeeja wants to merge 10 commits into
mainfrom
rajeeja/bbox-subset-prefilter
Open

rajeeja wants to merge 10 commits into
mainfrom
rajeeja/bbox-subset-prefilter

Conversation

@rajeeja

@rajeeja rajeeja commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1778 (its first point; the face_node_connectivity cost is a reader problem and stays open in #1778's discussion).

Overview

subset.bounding_box computed Grid.bounds for every face before testing which fall inside the box, so a small crop of a large mesh cost memory and time proportional to the mesh.

It now screens faces on their node coordinates first: a face's bounds contain each of its nodes, so a face can only lie inside the box if all its nodes do. Bounds are computed for those candidates only, on the nodes they use. The screen needs no margin and makes no assumption about face size or shape. The result is identical to the whole-mesh path, and cached bounds are used when present.

Along the way, the bounds kernel dropped a node from its own face's bounds when both adjacent edges bulge poleward (70 faces in geoflow-small); fixed in the first commit, since the screen relies on bounds containing the nodes.

faces_within_lon_bounds / faces_within_lat_bounds docstrings now say what they do (containment, not overlap).

Measured

12-corner SCRIP mesh from the #1778 MCVE, crop of ~0.007% of faces:

n_face before after
400,000 0.19 GiB, 0.77 s 0.003 GiB, 0.00 s
1,600,000 0.78 GiB, 2.77 s 0.003 GiB, 0.02 s

HEALPix zoom 8 (786k faces), Texas box: 88 MB / 973 ms → 13 MB / 11 ms.

Testing

TestBoundingBoxSubset compares the screened path against the whole-mesh path on 7 grids × 12 boxes (polar, antimeridian-crossing, whole globe, empty), asserts bounds is never populated, counts the faces the bounds kernel sees, checks cached bounds are used, that faces straddling a box edge are excluded, nodes on the box edge, antimeridian and pole faces explicitly, and an empty region on a partial grid. test_face_bounds_contain_every_node pins the kernel fix.

New benchmarks/subset.py: time_ and track_peakmem_ for bounding_box on fresh oQU and HEALPix grids.

PR Checklist

General

  • An issue is created and linked
  • Added appropriate labels
  • Filled out Overview and Expected Usage (if applicable) sections

Testing & Benchmarking

  • There is adequate test coverage of changes from this PR
  • If this PR could affect performance, ran ASV benchmarks and confirmed they show expected behavior (added benchmarks/subset.py)

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
  • [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

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

subset.bounding_box asked for Grid.bounds, which builds a per-face box for
every face in the mesh -- materializing the node coordinate and connectivity
arrays to do it -- and only then tested which faces fall inside the region.
The cost was proportional to the mesh rather than the crop, so cropping a
few hundred faces out of a large grid needed memory proportional to the
whole grid. On a 300M-face SCRIP mesh that exceeds the machine, and the
subset is not slow but impossible (#1778).

Face latitudes are already resident, so they now discard faces that cannot
be in the result before any bounds are computed; exact bounds are built for
the survivors only. Measured on a 12-corner SCRIP mesh, crop ~0.007% of the
faces:

  1,600,000 faces   0.84 GiB, 2.6 s  ->  0.00 GiB, 0.8 s
  3,200,000 faces   1.48 GiB, 5.1 s  ->  0.00 GiB, 1.6 s

Two things measurement settled that reasoning got wrong. The first attempt
filtered longitude on face centres too, with a margin sized from the mean
face area; that is unsafe near the poles, where a face is recorded as
spanning every longitude and its bounds sit up to 354 degrees from its own
centre on a HEALPix z3 grid. No finite margin covers that, so longitude is
left to the exact stage. The second attempt kept a latitude margin for the
same reason -- and it is unnecessary, because faces_within_lat_bounds keeps
a face only when its bounds are fully contained in the interval, despite the
docstring saying "overlap". A contained face has a contained centre, so the
filter is conservative by equality rather than by a fudge factor.

The exact path is unchanged below a million faces and whenever bounds are
already computed, since the filter cannot repay itself there.

Tests compare the filtered and exact paths across three resolutions and
eight boxes including antimeridian-spanning and polar cases, and pin the two
measurements the design rests on: that longitude bounds reach far beyond a
face centre, and that the latitude test is containment rather than overlap.
If either stops being true the pre-filter is wrong and these fail.
The latitude prefilter alone leaves a global belt: every face at the crop's
latitude, at every longitude. On a HEALPix mesh a Texas-sized box still kept
8.3% of the faces.

Longitude needs a margin where latitude did not, and the margin has to widen
towards the poles because meridians converge -- a fixed-area face spans more
longitude the higher it sits. Normalising the measured reach by 1/cos(lat)
collapses it to a constant 4.39 face widths across zooms 4, 5 and 6, which is
what makes the bound derivable rather than tuned. Eight face widths,
latitude-corrected, had zero violations from zoom 3 through 7.

Two kinds of face cannot be filtered on a centre at all and are always kept:
one whose bounds are stored min > max crosses the antimeridian, so its centre
is not between them, and one containing a pole is recorded as spanning every
longitude. Both are under 1% of faces by zoom 6.

Texas candidates fall from 8.3% to 0.5% of the mesh, improving with
resolution. 44 of 44 boxes return exactly what the unfiltered path returns,
including antimeridian-spanning and polar queries.
@Sevans711 Sevans711 added scalability Related to scalability & performance efforts run-benchmark Run ASV benchmark workflow labels Sep 23, 2026
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [6d6c6da] After [f21521c] Ratio Benchmark (Parameter)
- 87.7±0.3ms 10.4±0.4ms 0.12 subset.BoundingBox.time_bounding_box('120km')
- 15.2±0.1ms 6.20±0.2ms 0.41 subset.BoundingBox.time_bounding_box('480km')
- 3.38M 2.38M 0.7 subset.BoundingBox.track_peakmem_bounding_box('120km')
- 328k 203k 0.62 subset.BoundingBox.track_peakmem_bounding_box('480km')
- 111±0.4ms 4.28±0.2ms 0.04 subset.BoundingBoxHealpix.time_bounding_box(6)
- 1.74±0.01s 17.8±0.5ms 0.01 subset.BoundingBoxHealpix.time_bounding_box(8)
- 5.53M 2.28M 0.41 subset.BoundingBoxHealpix.track_peakmem_bounding_box(6)
- 88.3M 13.2M 0.15 subset.BoundingBoxHealpix.track_peakmem_bounding_box(8)

Benchmarks that have stayed the same:

Change Before [6d6c6da] After [f21521c] Ratio Benchmark (Parameter)
1.24±0.06ms 1.15±0.06ms 0.93 connectivity.Connectivity.time_edge_face('120km')
410±30μs 429±20μs 1.05 connectivity.Connectivity.time_edge_face('480km')
3.24±0.4ms 3.31±0.4ms 1.02 connectivity.Connectivity.time_edge_node('120km')
947±100μs 1.04±0.05ms 1.09 connectivity.Connectivity.time_edge_node('480km')
60.5±20μs 64.2±10μs 1.06 connectivity.Connectivity.time_face_edge('120km')
57.2±6μs 58.1±5μs 1.02 connectivity.Connectivity.time_face_edge('480km')
952±100μs 902±50μs 0.95 connectivity.Connectivity.time_face_face('120km')
390±50μs 347±20μs ~0.89 connectivity.Connectivity.time_face_face('480km')
57.0±10μs 56.0±20μs 0.98 connectivity.Connectivity.time_face_node('120km')
60.9±20μs 52.6±4μs ~0.86 connectivity.Connectivity.time_face_node('480km')
487±50μs 513±50μs 1.05 connectivity.Connectivity.time_n_nodes_per_face('120km')
345±20μs 403±50μs ~1.17 connectivity.Connectivity.time_n_nodes_per_face('480km')
1.10±0.1ms 1.12±0.05ms 1.02 connectivity.Connectivity.time_node_edge('120km')
431±20μs 398±20μs 0.92 connectivity.Connectivity.time_node_edge('480km')
55.4±0.7ms 57.6±3ms 1.04 connectivity.Connectivity.time_node_face('120km')
3.54±0.2ms 3.53±0.06ms 1.00 connectivity.Connectivity.time_node_face('480km')
1.42M 1.42M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('120km')
106k 106k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('480km')
6.48M 6.48M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('120km')
420k 420k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('480km')
2.42k 2.42k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('120km')
2.42k 2.42k 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.54k 2.54k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('120km')
2.54k 2.54k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('480km')
240k 240k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('120km')
26.3k 26.3k 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.46±0.1ms 8.63±0.07ms 1.02 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.74±0.07ms 2.78±0.07ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
10.3±10ms 12.5±10ms ~1.22 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.54±0.05ms 1.60±0.02ms 1.04 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'))
2.04M 1.86M 0.91 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.03M 1.85M 0.91 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.19M 2.01M 0.92 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
35.6k 35.6k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
862±10ns 866±20ns 1.00 geometry_kernels.AccucrossKernels.time_accucross
1.96±0.02μs 1.98±0.03μs 1.01 geometry_kernels.AccucrossKernels.time_accucross_pair
421±10ns 411±5ns 0.98 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
431±5ns 446±20ns 1.03 geometry_kernels.EFTPrimitives.time_diff_of_products
400±7ns 395±10ns 0.99 geometry_kernels.EFTPrimitives.time_two_prod
396±10ns 396±20ns 1.00 geometry_kernels.EFTPrimitives.time_two_sum
662±10ns 687±20ns 1.04 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
781±5ns 716±10ns 0.92 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
806±9ns 791±20ns 0.98 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
640±30ns 722±20ns ~1.13 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
983±30ns 1.04±0.03μs 1.06 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
959±30ns 1.02±0.03μs 1.07 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
50.1±0.6μs 52.1±2μs 1.04 geometry_kernels.OrientPredicates.time_on_minor_arc
49.6±0.8μs 51.0±1μs 1.03 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.81±0.03ms 3.75±0.09ms 0.98 geometry_samebody.SameBodyConstLat.time_accux_dispatch
660±5μs 661±10μs 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
3.71±0.09ms 3.83±0.08ms 1.03 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
106±6μs 101±3μs 0.96 geometry_samebody.SameBodyConstLat.time_fp64_kernel
23.0±0.1ms 22.8±0.04ms 0.99 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
5.54±0.01ms 5.53±0ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
18.1±0.2ms 17.8±0.01ms 0.98 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
696±2μs 699±6μs 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
919±7ms 921±7ms 1.00 import.Imports.timeraw_import_uxarray
2.51±0.02ms 2.48±0.05ms 0.99 mpas_ocean.CheckNorm.time_check_norm('120km')
2.01±0.01ms 2.00±0.01ms 0.99 mpas_ocean.CheckNorm.time_check_norm('480km')
792±7μs 797±9μs 1.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
386±8μs 393±7μs 1.02 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
520±9μs 522±3μs 1.00 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
417±7μs 424±6μs 1.02 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
4.00±0.2ms 4.00±0.02ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.09±0.06ms 3.15±0.1ms 1.02 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
130±5ms 121±6ms 0.93 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
10.5±0.4ms 11.9±1ms ~1.14 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.7±0.1ms 18.8±0.08ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
966±10μs 959±10μs 0.99 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
9.95±0.04ms 9.93±0.03ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
609±9μs 603±10μs 0.99 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
600±5ms 614±10ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 1)
304±2ms 314±5ms 1.03 mpas_ocean.CrossSections.time_const_lat('120km', 2)
157±1ms 163±5ms 1.04 mpas_ocean.CrossSections.time_const_lat('120km', 4)
557±3ms 558±8ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 1)
274±4ms 279±2ms 1.02 mpas_ocean.CrossSections.time_const_lat('480km', 2)
141±2ms 147±0.4ms 1.05 mpas_ocean.CrossSections.time_const_lat('480km', 4)
340M 361M 1.06 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
341M 361M 1.06 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
340M 361M 1.06 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
18.0±0.06ms 18.3±0.07ms 1.02 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.00±0.01ms 2.02±0.02ms 1.01 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
11.3±2ms 11.2±0.3ms 0.99 mpas_ocean.FaceAreas.time_face_areas('120km')
2.88±0.1ms 2.92±0.2ms 1.01 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')
732k 731k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
322±2ms 337±1ms 1.04 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
50.0±0.7ms 52.9±0.5ms 1.06 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
41.9±0.4ms 42.5±1ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.09±0.2ms 4.98±0.3ms 0.98 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
13.3±0.07ms 13.1±0.2ms 0.99 mpas_ocean.Gradient.time_gradient('120km')
1.81±0.01ms 1.82±0.01ms 1.01 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.2M 3.2M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
204k 204k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
336M 363M 1.08 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
316±30μs 284±20μs ~0.90 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
122±6μs 118±3μs 0.97 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
209±8μs 202±5μs 0.97 mpas_ocean.Integrate.time_integrate('120km')
189±3μs 186±10μ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')
145±0.6ms 152±1ms 1.05 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
145±0.8ms 152±2ms 1.05 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
146±1ms 152±2ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
10.0±0.06ms 10.2±0.03ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
10.0±0.1ms 10.3±0.09ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
10.2±0.2ms 10.1±0.04ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
247±1ms 246±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
508±2ms 511±3ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
13.4±0.05ms 13.4±0.3ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
16.4±0.03ms 16.4±0.04ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
241±1ms 242±0.8ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
500±1ms 502±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
13.1±0.05ms 12.9±0.04ms 0.99 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
16.2±0.2ms 16.2±0.09ms 1.00 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)
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)
32.0±0.3ms 31.7±0.1ms 0.99 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
14.4±0.08ms 14.5±0.04ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
29.1±0.4ms 30.1±0.4ms 1.04 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
7.40±0.04ms 7.42±0.06ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
501±10μs 497±10μs 0.99 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
5.11±0.05ms 5.26±0.1ms 1.03 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.83M 5.83M 1.00 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')
679k 678k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
547k 546k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
4.53±0.02s 4.60±0.05s 1.02 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
4.65±0s 4.68±0.03s 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
124±0.7ms 124±0.5ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
125±0.7ms 125±0.6ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
510±1ms 515±1ms 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
544±2ms 543±3ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
16.7±0.02ms 17.0±0.09ms 1.02 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
17.1±0.1ms 17.4±0.04ms 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
4.55±0.03ms 4.52±0.01ms 0.99 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
37.5±0.04ms 37.4±0.2ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
243±2μs 239±1μs 0.98 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
537±20μs 570±20μs 1.06 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.5k 19.5k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
315±10μs 323±20μs 1.02 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
296±20μs 291±9μs 0.99 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
323±20μs 292±20μs ~0.90 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
333±60μs 281±8μs ~0.85 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
109±0.8ms 113±0.6ms 1.04 mpas_ocean.RemapDownsample.time_bilinear_remapping
17.4±0.1ms 17.6±0.2ms 1.01 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.8±0.08ms 15.9±0.2ms 1.01 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
829±10ms 811±20ms 0.98 mpas_ocean.RemapUpsample.time_bilinear_remapping
31.6±1ms 31.4±0.6ms 0.99 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
13.9±0.4ms 13.8±0.06ms 0.99 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
8.34±0.2ms 8.49±0.2ms 1.02 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.13±0.1ms 5.23±0.07ms 1.02 mpas_ocean.ZonalAverage.time_zonal_average('480km')
342M 363M 1.06 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
7.37±0.03ms 7.50±0.03ms 1.02 quad_hexagon.QuadHexagon.time_open_dataset
6.29±0.04ms 6.36±0.03ms 1.01 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
72.6k 73.4k 1.01 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
71.6k 71.8k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid
930±5ms 930±6ms 1.00 to_raster.ToRaster.time_to_raster((10000.0, 10.0))
2.22±0.02s 2.24±0.02s 1.01 to_raster.ToRaster.time_to_raster((10000.0, 100.0))
2.59±0.02s 2.58±0.01s 1.00 to_raster.ToRaster.time_to_raster((100000.0, 100.0))
271M 271M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 10.0))
684M 684M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 100.0))
768M 768M 1.00 to_raster.ToRaster.track_peakmem((100000.0, 100.0))

Benchmarks that have got worse:

Change Before [6d6c6da] After [f21521c] Ratio Benchmark (Parameter)
+ 320M 361M 1.13 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
+ 321M 361M 1.13 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
+ 322M 361M 1.12 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
+ 321M 361M 1.12 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
+ 285M 363M 1.28 import.Imports.track_peakmem_import_uxarray
+ 326M 361M 1.11 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
+ 324M 361M 1.11 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
+ 324M 361M 1.11 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
+ 314M 363M 1.16 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')
+ 326M 363M 1.12 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('480km')
+ 1.076394971555632 1.4932506231440825 1.39 nogil_scaling.GILScaling.track_gil_scaling

@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 is a great idea. It definitely still needs some work before it is ready to merge; see inline comments. Two more notes here:

(1) This doesn't fully close the original issue; #1778 is scoped to include the face_node_connectivity issue too. The original message in this PR should be updated accordingly, to avoid the "Closes #XXX" pattern. (EDIT: actually, upon a more careful read through of 1778, it's a bit unclear to me what the intent was. The title seems to indicate it is scoped just to the bounding-box subset issue, but the text seems to include the connectivity. If you meant to scope it only to the former, please clarify, and then feel free to keep this PR marked as "would close 1778"!)

(2) The ASV benchmarks suite does not show any relevant improvements. Can benchmarks be added which show improvement from this PR? Ideally, at least one showing memory improvement, and at least one showing speed improvement. That would help prevent performance degradation in the future.

Comment thread test/test_subset.py Outdated
Comment on lines +386 to +391
def test_a_precomputed_bounds_array_is_used_as_is(self):
"""A grid that already has ``bounds`` should not pay for the filter.
The saving comes from *not* building the whole-mesh index. Once it
exists the exact path is free, and taking the filter anyway would add
work for nothing.

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 test is actually doing what its docstring suggests? It doesn't look like there is any check here which confirms that the grid has not paid for the filter; the code just tests whether the result is equal to what was expected (which is redundant with the test_prefilter_matches_the_exact_path test 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.

Replaced. test_bounds_are_computed_for_candidates_only now counts the faces the bounds kernel receives and asserts bounds is never populated; every screened call also asserts that.

Comment thread test/test_subset.py Outdated
Comment on lines +403 to +405
def test_an_empty_region_returns_no_faces(self):
"""A box over open ocean far from any face must come back empty, not
raise -- the candidate array is empty before the exact stage runs."""

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.

Again, this test is not doing what its name and docstring suggest. The reason the result has length 0 here is that the bounds are tiny, so no face is fully contained within them; there is no empty region anywhere here at all (healpix grids span the entire sphere).

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.

Replaced with test_region_outside_a_partial_grid_is_empty, which uses the quad-hexagon grid and a box that is genuinely far from it.

Comment thread test/test_subset.py Outdated
Comment on lines +420 to +432
def test_a_polar_face_is_not_excluded_by_its_centre(self):
"""Longitude must not be filtered on face centres.
A face containing a pole is recorded as spanning every longitude, so
its bounds sit up to 354 degrees from its own centre on a HEALPix z3
grid. An early version of the pre-filter tested longitude centres
with a generous margin and still would have dropped those faces for
a narrow box -- no finite margin can cover a 354 degree gap. The
equivalence tests above did not catch it, because the boxes they use
happen not to need any face whose centre lies outside them.
This pins the measurement directly rather than through a subset, so
the reason survives even if the filter is rewritten.

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.

Again, this test is not doing what its name and docstring suggest. Please fix name and docstring, or fix test.

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.

Dropped. The centre filter and its margin are gone, so there is nothing for this test to pin.

Comment thread test/test_subset.py Outdated
"""The assumption the pre-filter rests on, checked rather than trusted.
``faces_within_lat_bounds`` reads as an overlap test -- its docstring
says "overlap" -- but the implementation keeps a face only when its

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.

That function's docstring should just be updated as part of this PR, instead of explaining this as a quirk in multiple places.

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.

Done, both docstrings now say containment. The test is gone with the centre filter; test_partially_contained_faces_are_excluded now checks straddling faces directly.

Comment thread test/test_subset.py Outdated
Comment on lines +475 to +490
straddlers = 0
rng = np.random.default_rng(0)
for _ in range(40):
la = rng.uniform(-70, 60)
lat_bounds = [la, la + 4]
kept = grid.get_faces_between_latitudes(lat_bounds)
centre_inside = np.flatnonzero(
(face_lat >= lat_bounds[0]) & (face_lat <= lat_bounds[1])
)
straddlers += len(np.setdiff1d(kept, centre_inside))

assert straddlers == 0, (
f"{straddlers} faces were kept whose centre lies outside the "
"query interval, so the latitude filter is now an overlap test "
"and the centre pre-filter would silently drop them"
)

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 actually confirms that you've tested any relevant points? I would have expected a test like this to instead choose lat bounds based on face bounds, checking what happens to faces which are only-partially contained within the lat bounds. I think this is another case of the test contents not actually corresponding to the docstring.

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.

Rewritten as you suggest: the box edge is placed through the middle of a face taken from the reference bounds, every straddling face must be excluded, and moving the edge to that face's own bound must bring exactly it in.

Comment thread uxarray/subset/grid_accessor.py Outdated
Comment on lines +481 to +495
# Longitude needs a margin, unlike latitude, and the margin has to widen
# towards the poles. Meridians converge, so a face of fixed area spans
# more longitude the higher its latitude -- the same cell that covers a
# degree at the equator covers many near the pole. Normalising the
# measured reach by ``1 / cos(lat)`` collapses it to a constant 4.39 face
# widths across HEALPix zooms 4, 5 and 6, which is what makes a bound
# derivable rather than tuned: 8 face widths, latitude-corrected, had zero
# violations across zooms 3 through 7.
#
# Two cases cannot be filtered on a centre at all and are always kept.
# A face whose bounds are stored min > max crosses the antimeridian, so
# its "centre" is not between them. A face containing a pole is recorded
# as spanning every longitude. Both are rare -- under 1% of faces by zoom
# 6 -- so keeping them unconditionally costs almost nothing, and the exact
# stage discards them if they do not belong.

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.

What?

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.

Removed along with the face-width margin.

Comment thread uxarray/subset/grid_accessor.py Outdated
Comment on lines +503 to +504
face_width = np.degrees(np.sqrt(4.0 * np.pi / max(int(uxgrid.n_face), 1)))
margin = 8.0 * face_width / np.maximum(np.cos(np.radians(face_lat)), _MIN_COS_LAT)

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.

What?

This function can be run with any grid, not just healpix. I think these are healpix estimates for face width. I could easily make a grid with a different face width. Right?

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.

You were right, that was HEALPix-specific. The new screen uses no face width at all: a face is a candidate iff all of its nodes lie in the box, which holds for any mesh.

Comment thread uxarray/subset/grid_accessor.py Outdated
bounds_lon = np.asarray(uxgrid.face_bounds_lon.values)

unfilterable = (bounds_lon[:, 0] > bounds_lon[:, 1]) | (
(bounds_lon[:, 0] <= -179.999) & (bounds_lon[:, 1] >= 179.999)

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.

What is this actually doing? Why <= 179.999 and not <= 179.9999999 or <= 179.9 or < 180?

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.

Gone. Pole faces are no longer special-cased; their bounds are [-180, 180], so the exact test on candidates handles them.

Comment thread uxarray/subset/grid_accessor.py Outdated
Comment on lines +519 to +525
np.asarray(uxgrid.face_node_connectivity.values)[candidates],
np.asarray(uxgrid.n_nodes_per_face.values)[candidates],
np.asarray(uxgrid.node_x.values),
np.asarray(uxgrid.node_y.values),
np.asarray(uxgrid.node_z.values),
np.asarray(uxgrid.node_lon.values),
np.asarray(uxgrid.node_lat.values),

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 any of these asarray calls are necessary? It probably won't hurt to include (except in special case of subclass, where np.asarray converts to numpy array while np.asanyarray does not), but these aren't included in other parts of uxarray from what I can tell, and it feels like better style to keep them out unless there is any plausible reason to expect that these .values objects won't be numpy arrays already.

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.

Removed.

Comment thread uxarray/subset/grid_accessor.py Outdated
keep_lon = faces_within_lon_bounds(lon_bounds, bounds_lon)
keep_lat = faces_within_lat_bounds(lat_bounds, bounds_lat)

return candidates[np.intersect1d(keep_lon, keep_lat)]

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 didn't fully understand the longitude-handling logic in this function. That doesn't mean it is wrong, just leaving a reminder to myself to take a closer look and try to understand it before approving things.

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 longitude logic is now the same as faces_within_lon_bounds: a node is in the interval when lon_min <= lon <= lon_max, or, for a box with lon_min > lon_max, when it is in either half. Nodes are normalized to [-180, 180) as the bounds are.

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.

What do you mean by "either half"?

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.

Sorry, that was unclear. A box with lon_min > lon_max is the union of two intervals, [lon_min, 180) and [-180, lon_max] — the two "halves" I meant. A node is inside when it lies in either one, so equivalently it is outside only when lon_max < lon < lon_min, the gap between them. That is what the lon < lon_min and lon > lon_max test is checking.

For [170, -170]: 175 and -175 are in, 0 is out.

The docstring now spells this out with that example, and the branch has a one-line comment.

Each edge contributed either its interior latitude extreme or its first
node, never both, so a face whose edges all bulge poleward lost its lowest
node from its own bounds. geoflow-small has 70 such faces.
A face's bounds contain each of its nodes, so a face can only lie inside
the box if all its nodes do. Bounds are then computed for those candidates
alone, on the nodes they use, so the cost follows the region rather than
the mesh. Replaces the centre-based filter and its tuned margins.
@rajeeja rajeeja changed the title Filter a bounding-box subset on face latitudes before computing bounds Screen bounding-box subset candidates on node coordinates before computing bounds Sep 24, 2026
@rajeeja

rajeeja commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

@Sevans711 Reworked in 7e577f7..dd351b0: the centre filter is replaced by a node-coordinate screen with no margins, the bounds kernel now includes every node (it was dropping some), tests rewritten to check what they say, and benchmarks/subset.py added for time and peak memory. Description updated; #1778's connectivity point stays open there. Could you re-review?

@rajeeja
rajeeja requested a review from Sevans711 September 24, 2026 22:35

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

Getting much closer! Some small inline comments.

The ASV benchmarks look good and show the relevant changes, which addresses my previous comment (2).

For my previous comment (1), your updated description is actually even more confusing to me. Do you think the connectivity point is in scope or out of scope for the original issue? (in scope --> this PR shouldn't close the issue. out of scope --> this PR should close the issue.)

Comment thread uxarray/subset/grid_accessor.py Outdated
Comment on lines +493 to +495
from uxarray.constants import INT_FILL_VALUE
from uxarray.grid.bounds import _construct_face_bounds_array
from uxarray.grid.coordinates import _lonlat_rad_to_xyz

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.

Tiny style comment: please move these imports to top of file, unless you have a strong preference to put them here (or unless they somehow cause circular imports, but that would surprise me).

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.

Moved to the top of the file — no circular import, you were right to expect that.

One knock-on worth flagging: test_bounds_are_computed_for_candidates_only and test_cached_bounds_are_used monkeypatched uxarray.grid.bounds, which stops working once the names are bound in grid_accessor at import time. They now patch uxarray.subset.grid_accessor instead. I checked the counting test still fails if I bypass the screen, so it is still load-bearing rather than silently passing.

Comment thread test/test_subset.py
actual = self.screened(make(gridpath), lon_bounds, lat_bounds)
np.testing.assert_array_equal(actual, np.sort(expected))

def test_accessor_matches_whole_mesh_path(self, gridpath):

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 add at least a small docstring for this test and the other tests being added here? Even one or two sentences for each test would make it much easier to quickly understand what the intent is for all of them.

I think I have the right understanding for them all because of the review but I'm not 100% sure; if you add docstrings I will be able to confirm whether I agree the tests are doing what you expect them to be doing.

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, one or two sentences on each of the nine tests in TestBoundingBoxSubset, saying what the test pins and why rather than restating the code. The class docstring already covers the shared invariant (screened result == whole-mesh result).

Please do check them against what you expected — a couple are subtle, particularly test_nodes_on_the_box_edge (nodes sit exactly on the box edge but the arcs between them bulge past it, so the face fits its own bounds and not the box) and test_pole_face (bounds are [-180, 180], so only a full-width box contains it).

Comment thread test/grid/geometry/test_bounds.py Outdated
def test_face_bounds_contain_every_node(gridpath):
"""A face's latitude bounds must not be narrower than its own nodes.

Each edge used to contribute either its interior latitude extreme or its

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.

What do you mean by "used to"? Is this relative to main, or relative to an earlier commit in 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.

Relative to main — the bounds kernel on main has this bug today. Each edge contributed either its interior latitude extreme or its first node, never both, so a face whose edges all bulge poleward lost its lowest node (70 faces in geoflow-small). Fixed in this PR's first commit, 7e577f7.

It is in scope here because the screen relies on a face's bounds containing its nodes; without the fix the screen could drop a face the exact test keeps. Docstring now says all of that explicitly.

…ian logic

- Move bounds/intersections/coordinates imports to module scope; no
  circular import. Monkeypatch targets in the tests move with them.
- Add a docstring to each test in TestBoundingBoxSubset saying what it
  pins and why.
- Spell out the lon_min > lon_max case in _faces_with_nodes_within_box:
  the box is the union of two intervals, so a node is outside only in
  the gap between them.
- test_face_bounds_contain_every_node: say the behaviour it pins is
  relative to main, fixed in this PR's first commit.
@rajeeja

rajeeja commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Updated with latest main (brings in #1767) and pushed 513c6a6 addressing all four open threads: imports moved to module scope, a docstring on every test in TestBoundingBoxSubset, the antimeridian "either half" logic spelled out in the docstring with an example, and the "used to" in test_face_bounds_contain_every_node now says it is relative to main.

Locally: test/test_subset.py, test/grid, test/core = 797 passed, pre-commit clean.

@Sevans711 ready for another look when you have time.

@rajeeja
rajeeja requested a review from Sevans711 October 5, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-benchmark Run ASV benchmark workflow scalability Related to scalability & performance efforts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bounding-box subset allocates memory proportional to the whole mesh, not the crop

2 participants