Skip to content

Commit 009e70a

Browse files
adacovskclaude
andcommitted
feat(geospatial): add tie-breaking for boundary points
Implements grid-specific tie-breaking rules when points fall on cell boundaries and match multiple cells. This ensures deterministic cell selection and prevents particle tracking issues. Changes: - GeospatialIndex: Collect all matching cells and apply tie-breaker - StructuredGrid: Use searchsorted with side='left'/'right' for tie-breaking - Add boundary detection methods for 2D and 3D points - Implement _apply_tiebreaker() with grid-specific rules: - StructuredGrid: lowest row, then lowest column - VertexGrid/UnstructuredGrid: lowest cell ID 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 7faa929 commit 009e70a

File tree

2 files changed

+97
-10
lines changed

2 files changed

+97
-10
lines changed

flopy/discretization/structuredgrid.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,17 +1029,19 @@ def intersect(self, x, y, z=None, local=False, forgive=False):
10291029
cols = np.full(n_points, np.nan, dtype=float)
10301030

10311031
# Vectorized column finding using searchsorted
1032-
# For each x[i], find rightmost edge where x[i] > edge
1033-
cols_valid = np.searchsorted(xe, x, side="right") - 1
1032+
# When point is on edge, use lower column index (tie-breaking rule)
1033+
# side="left" ensures x==edge goes to the cell on the left
1034+
cols_valid = np.searchsorted(xe, x, side="left") - 1
10341035
# searchsorted returns index in [0, len(xe)], but valid cols are [0, ncol-1]
10351036
cols_mask = (cols_valid >= 0) & (cols_valid < self.ncol)
10361037
cols[cols_mask] = cols_valid[cols_mask]
10371038

10381039
# Vectorized row finding using searchsorted
1039-
# For each y[i], find rightmost edge where y[i] < edge
1040+
# When point is on edge, use lower row index (tie-breaking rule)
10401041
# Since ye is in descending order (top to bottom), we need to flip it
1042+
# side="right" on flipped array ensures y==edge goes to the cell below
10411043
ye_flipped = ye[::-1]
1042-
rows_flipped = np.searchsorted(ye_flipped, y, side="left")
1044+
rows_flipped = np.searchsorted(ye_flipped, y, side="right")
10431045
rows_valid = len(ye) - 1 - rows_flipped
10441046
rows_mask = (rows_valid >= 0) & (rows_valid < self.nrow)
10451047
rows[rows_mask] = rows_valid[rows_mask]

flopy/utils/geospatial_index.py

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -391,26 +391,30 @@ def query_points(self, x, y, z=None, k=None):
391391
# Query KD-tree adaptively to get k unique cells
392392
candidates = self._get_k_unique_cells(point, k)
393393

394+
# Collect all matching cells for tie-breaking
395+
matching_cells = []
396+
394397
# Test candidates in order (nearest first)
395398
for cellid in candidates:
396399
if self.is_3d:
397400
# 3D: check 3D bounding box
398401
if self._point_in_cell_3d(point, cellid):
399-
results[i] = cellid
400-
break
402+
matching_cells.append(cellid)
401403
else:
402404
# 2D: use ConvexHull test
403405
if self._point_in_cell_vectorized(point[:2], cellid):
404406
# Found 2D cell, now check z if provided
405407
if z is None:
406-
results[i] = cellid
407-
break
408+
matching_cells.append(cellid)
408409
else:
409410
# Search through layers to find the right z
410411
cell_3d = self._find_layer_for_z(cellid, z[i])
411412
if cell_3d is not None:
412-
results[i] = cell_3d
413-
break
413+
matching_cells.append(cell_3d)
414+
415+
# Apply grid-specific tie-breaking when multiple cells match
416+
if len(matching_cells) > 0:
417+
results[i] = self._apply_tiebreaker(matching_cells)
414418

415419
return results
416420

@@ -534,6 +538,87 @@ def _point_in_cell_3d(self, point, cellid):
534538
and bbox[4] - self.epsilon <= point[2] <= bbox[5] + self.epsilon
535539
)
536540

541+
def _is_on_boundary_2d(self, point, cellid):
542+
"""
543+
Check if a 2D point is on the boundary of a cell.
544+
545+
A point is considered on the boundary if it's very close (< 1e-9) to any edge.
546+
Uses a much tighter tolerance than epsilon to detect true boundary cases.
547+
"""
548+
bbox = self.bounding_boxes[cellid]
549+
boundary_tol = 1e-9 # Much tighter than epsilon for exact boundary detection
550+
# Check if point is on any bounding box edge
551+
on_x_edge = (
552+
abs(point[0] - bbox[0]) < boundary_tol or
553+
abs(point[0] - bbox[1]) < boundary_tol
554+
)
555+
on_y_edge = (
556+
abs(point[1] - bbox[2]) < boundary_tol or
557+
abs(point[1] - bbox[3]) < boundary_tol
558+
)
559+
return on_x_edge or on_y_edge
560+
561+
def _is_on_boundary_3d(self, point, cellid):
562+
"""
563+
Check if a 3D point is on the boundary of a cell.
564+
565+
A point is considered on the boundary if it's very close (< 1e-9) to any face.
566+
Uses a much tighter tolerance than epsilon to detect true boundary cases.
567+
"""
568+
bbox = self.bounding_boxes_3d[cellid]
569+
boundary_tol = 1e-9 # Much tighter than epsilon for exact boundary detection
570+
# Check if point is on any bounding box face
571+
on_x_face = (
572+
abs(point[0] - bbox[0]) < boundary_tol or
573+
abs(point[0] - bbox[1]) < boundary_tol
574+
)
575+
on_y_face = (
576+
abs(point[1] - bbox[2]) < boundary_tol or
577+
abs(point[1] - bbox[3]) < boundary_tol
578+
)
579+
on_z_face = (
580+
abs(point[2] - bbox[4]) < boundary_tol or
581+
abs(point[2] - bbox[5]) < boundary_tol
582+
)
583+
return on_x_face or on_y_face or on_z_face
584+
585+
def _apply_tiebreaker(self, matching_cells):
586+
"""
587+
Apply grid-specific tie-breaking when multiple cells match.
588+
589+
Implements the same tie-breaking logic as the original grid implementations:
590+
- StructuredGrid: cell with lowest row, then lowest column
591+
- VertexGrid/UnstructuredGrid: cell with lowest cell ID
592+
593+
Parameters
594+
----------
595+
matching_cells : list
596+
List of cell indices that all contain the point
597+
598+
Returns
599+
-------
600+
cellid : int
601+
The selected cell after tie-breaking
602+
"""
603+
if len(matching_cells) == 1:
604+
return matching_cells[0]
605+
606+
if self.grid.grid_type == "structured":
607+
# For structured grids: choose cell with lowest row, then lowest column
608+
# Convert cell indices to (row, col) tuples for comparison
609+
rows_cols = []
610+
for cellid in matching_cells:
611+
row = cellid // self.grid.ncol
612+
col = cellid % self.grid.ncol
613+
rows_cols.append((row, col, cellid))
614+
615+
# Sort by row first, then column
616+
rows_cols.sort(key=lambda x: (x[0], x[1]))
617+
return rows_cols[0][2] # Return the cellid
618+
else:
619+
# For vertex/unstructured grids: choose cell with lowest cell ID
620+
return min(matching_cells)
621+
537622
def _find_layer_for_z(self, icell2d, z):
538623
"""
539624
Find 3D cell index for a 2D cell and z-coordinate.

0 commit comments

Comments
 (0)