feat: add numpy array support to .intersect()#2646
Conversation
Add vectorized array support to the intersect() method with significant performance improvements for large datasets. Key improvements: - Support numpy arrays for x, y, z coordinates - Use cKDTree spatial indexing for efficient neighbor queries - Pre-compute bounding boxes for faster filtering - Batch point-in-polygon checks using Path.contains_points() - Maintain backward compatibility with scalar inputs Performance: - Small datasets: Same speed as before - Large datasets: ~10-100x faster with cKDTree and batching Testing: - All existing intersection tests pass - Passes ruff check and format 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
…l centers - Move defaultdict import to module level - Use xyzcellcenters cached property instead of direct _xc/_yc access - Ensures coordinate transformations are applied correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #2646 +/- ##
===========================================
+ Coverage 55.5% 72.6% +17.1%
===========================================
Files 644 667 +23
Lines 124135 129332 +5197
===========================================
+ Hits 68947 93965 +25018
+ Misses 55188 35367 -19821
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Thanks for this @aacovski. Some thoughts
- It would be best to keep the
Grid.intersect()API consistent. If one subclass supports both scalars and arrays, they all should IMO? - We have a dedicated
GridIntersectutility to whoseintersect()method you can pass ashapely.MultiPoint. IfGrid.intersect()begins to support arrays, can it delegate toGridIntersect? Does this new intersection method then belong inGridIntersect? Does it have any limitations, e.g. only work for point intersections or can it do arbitrary shapes? - On that note, can you describe your motivation for kdtree over e.g. rtree from shapely? a performance comparison may be worthwile? I am aware kdtree is more expensive to change after building but I guess that isn't relevant as the usage pattern for
GridIntersectis currently if your grid changes, make a newGridIntersect. but an intersection benchmark seems in order. - Regardless how we proceed this should probably have tests before merging
Maybe @dbrakenhoff can weigh in. He has written most of the GridIntersect capability AFAIK and is much more knowledgeable here than I
|
Thank you for your response @wpbonelli. I used a similar intersection method with MINEDW, where we're dealing with 3D unstructured meshes, and I changed my method ad hoc to use a 2D grid approach without thinking about the tree choice. Edit: I did a benchmark with my model data against a strtree method using bounding boxes: KDtree is still much faster, to my surprise. The problems with shapely's strtree: geometry (bounding boxes) vs point and you can't query multiple points. My method is similar to the current approach where we look at grid cell centroids for candidates, then check if grid cells contain points. If I understand correctly, Regarding your first point about API consistency: I don't think there's a workaround for getting true vectorized performance while maintaining a scalar-only API. |
|
@aacovski apology unnecessary, this is valuable! Would be nice to see your benchmark numbers.
Idk, it's still an intersection, just limited to points, no?
I didn't mean to suggest the API should stay scalar-only, rather that the API for all |
|
Attached is the benchmark script. Note that I had to implement UnstructuredGrid support for GridIntersect (which didn't exist), so this comparison isn't perfect, the methods return different data structures. It's a geometric speedup curve (I tried 50k points too). More broadly, it could be worth replacing STR-tree with KDTree throughout If you make a Github issue, I could potentially resolve the API differences that would be caused by this, and I can try to implement it for all of the |
|
Hi @aacovski and @wpbonelli, Thanks for putting this together! First of all, I think it would be great to support vector based operations on all As for
Because of these down sides, the method I have used to get a faster point -> grid cell intersection uses geopandas. No idea how it compares, but posting it here in case it is useful. import geopandas as gpd
import flopy
x = ... # some array
y = ... # some array
# only for collecting grid geometries
gi = flopy.utils.GridIntersect(grid)
# spatial join points with grid and add resulting cellid to obs_ds
spatial_join = gpd.GeoDataFrame(geometry=gpd.points_from_xy(x, y)).sjoin(
gpd.GeoDataFrame({"cellid": gi.cellids}, geometry=gi.geoms),
how="left",
)
# deal with edge cases, sort by index and cellid, then pick lowest cellid
cellids = (
spatial_join.reset_index()
.sort_values(["index", "cellid"])
.groupby("index")
.first()["cellid"]
)Another thing that came to mind is this library https://github.com/Deltares/numba_celltree, which also contains numba optimized unstructured grid operations. Not sure what types of operations it supports exactly, but might be interesting as well. As you mentioned, Anyway, summarizing, I'd very much support the addition of vectorized intersect calls, and I'd be happy to take a look at any addition of UnstructuredGrid support to the GridIntersect class 👍 . |
|
Using numba (or numba_celltree) automatically gives use parallelization which is an added benefit. I did further benchmarking and it is much faster my single-threaded kdtree. I'll redo this PR to just handle the |
Extends array/vectorization support to all three grid types for consistent API: - StructuredGrid: Process arrays of points efficiently - VertexGrid: Accept array inputs matching UnstructuredGrid behavior - UnstructuredGrid: Fix z-coordinate layer indexing bug - Add comprehensive tests in autotest/test_grid.py Performance improvements (scalar loop vs vectorized, ~10k cells): - StructuredGrid: 5.19x average speedup (up to 8.99x) - VertexGrid: 1.75x average speedup (up to 3.54x) - UnstructuredGrid: 2.13x average speedup (up to 2.65x) All implementations verified for equivalence with original methods. Tests pass for all grid types with various point counts (1-10,000). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Split long exception messages across multiple lines to comply with 88-character line limit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
…ds with z When x,y coordinates are out of bounds and z is provided with forgive=True, the method now correctly returns (None, nan, nan) instead of (None, None, None). This ensures exact equivalence with the original implementation where: - lay (layer) is set to None when x,y are out of bounds - row and col remain as nan (not None) Added test case to verify this edge case behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
| """Test StructuredGrid.intersect() edge case: out-of-bounds x,y with z. | ||
|
|
||
| This tests the specific case where x,y are out of bounds and z is provided. | ||
| The expected behavior is to return (None, nan, nan). |
There was a problem hiding this comment.
I think it would make more sense to return (nan, nan, nan) in the future (or at least be consistent in output types). Maybe something we could consider changing in a future version?
dbrakenhoff
left a comment
There was a problem hiding this comment.
Looks good! I made some minor comments, more for future PRs I think.
- Run ruff format on modified files - Move Delaunay import to top of test_grid.py - Simplify valid_mask calculation by flipping invalid_mask instead of recalculating with isnan checks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
wpbonelli
left a comment
There was a problem hiding this comment.
Yeah the failure is unrelated, we have had unending CI issues with that, it's just for pyvista notebooks. I'll look at it separately. No reason to hold this up though if you guys are happy with it, LGTM.
jlarsen-usgs
left a comment
There was a problem hiding this comment.
This looks good to me. The only thing that I see that might warrant some consideration is how we return intersections when arrays are supplied. Should it be a tuple of n numpy arrays ex. (layer_array, row_array, col_array) or should it be zipped into ((lay_0, row_0, col_0), ...., (lay_n, row_n, col_n)).
I think there are advantages and disadvantages of each approach, but it might be worth thinking about before the final merge.
Nice work!
|
@dbrakenhoff comment meant for pastas not here? |
|
Oops! |
I'd lean towards tuple of arrays so
Assuming we did zip and returned a materialized list/array not just the |
|
Agreed, it's also (slightly) more convenient to return 3 indexing arrays for indexing numpy arrays. |
|
thanks again for this @aacovski |
|
Sorry @aacovski just noticed one last thing, do you mind switching the generic |
Replace generic Exception with more specific ValueError for out-of-bounds coordinates in StructuredGrid and VertexGrid intersect() methods. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
…intersect Complete the refactoring by replacing the remaining generic Exception with ValueError in the UnstructuredGrid intersect() method. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
No problem, I just wanted to remain consistent. I wouldn't say 2x speedup is very significant though. We'll unlock significant once we have kdtree (or numba_celltree if we want to introduce another library, but I don't think it's necessary). |

Summary
.intersect()Gridsubclasses with significant performance improvements for large datasets, while maintaining full backward compatibility.Turned 20 minute intersect() loop with float coords to vectorized intersect() passing arrays down to around 6 seconds.Will change the methods in a future PR.Key Improvements
StructuredGrid (10,000 cells)
VertexGrid (9,975 cells)
UnstructuredGrid (9,975 cells)
benchmark_scalar_vs_vectorized.py
Verification:
The vectorization provides significant performance improvements while maintaining 100% backward compatibility and result equivalence.