Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions autotest/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import pytest
from modflow_devtools.misc import is_in_ci

from autotest.test_grid_cases import GridCases

# import modflow-devtools fixtures

pytest_plugins = ["modflow_devtools.fixtures", "modflow_devtools.snapshots"]
Expand Down Expand Up @@ -79,6 +81,51 @@ def patch_macos_ci_matplotlib():
matplotlib.use("agg")


# grid fixtures (from GridCases)


@pytest.fixture
def minimal_vertex_grid():
"""Minimal 2-cell vertex grid."""
return GridCases.vertex_minimal()


@pytest.fixture
def minimal_unstructured_grid():
"""Minimal 2-cell unstructured grid."""
return GridCases.unstructured_minimal()


@pytest.fixture
def triangular_vertex_grid():
"""Triangular vertex grid using Delaunay triangulation."""
return GridCases.vertex_triangular()


@pytest.fixture
def triangular_unstructured_grid():
"""Triangular unstructured grid using Delaunay triangulation."""
return GridCases.unstructured_triangular()


@pytest.fixture
def simple_vertex_grid():
"""10x10 rectangular vertex grid (100 cells, 10x10 each)."""
return GridCases.vertex_rectangular(nrow=10, ncol=10, cell_size=10.0)


@pytest.fixture
def rotated_vertex_grid():
"""Rotated 5x5 rectangular vertex grid."""
return GridCases.vertex_rectangular(nrow=5, ncol=5, cell_size=20.0, angrot=45.0)


@pytest.fixture
def layered_unstructured_grid():
"""3-layer unstructured grid for 3D testing."""
return GridCases.unstructured_layered()


# pytest configuration hooks


Expand Down
288 changes: 277 additions & 11 deletions autotest/test_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
gridlist_to_disv_gridprops,
to_cvfd,
)
from flopy.utils.geospatial_index import GeospatialIndex
from flopy.utils.triangle import Triangle
from flopy.utils.voronoi import VoronoiGrid

Expand Down Expand Up @@ -1603,6 +1604,53 @@ def test_geo_dataframe(structured_grid, vertex_grid, unstructured_grid):
raise AssertionError(f"Cell vertices incorrect for node={node}")


def test_structured_boundary_tiebreaker(simple_structured_grid):
"""Test StructuredGrid uses lowest row/col for boundary points."""
grid = simple_structured_grid

# Boundary at x=10 -> col 0
_, col = grid.intersect(10.0, 95.0)
assert col == 0

# Boundary at y=90 -> row 0
row, _ = grid.intersect(5.0, 90.0)
assert row == 0


def test_return_type_structured(simple_structured_grid):
"""Test StructuredGrid return types are consistent."""
grid = simple_structured_grid

# Scalar inside -> int
row, col = grid.intersect(5.0, 95.0)
assert isinstance(row, (int, np.integer))
assert isinstance(col, (int, np.integer))

# Scalar outside -> nan
row, col = grid.intersect(150.0, 50.0, forgive=True)
assert np.isnan(row) and np.isnan(col)

# Scalar with z inside -> int
lay, row, col = grid.intersect(5.0, 95.0, z=5.0)
assert all(isinstance(v, (int, np.integer)) for v in [lay, row, col])

# Scalar with z outside -> nan
lay, row, col = grid.intersect(150.0, 50.0, z=5.0, forgive=True)
assert all(np.isnan(v) for v in [lay, row, col])

# Array all inside -> any integer dtype
rows, cols = grid.intersect(np.array([5.0, 55.0]), np.array([95.0, 95.0]))
assert np.issubdtype(rows.dtype, np.integer)
assert np.issubdtype(cols.dtype, np.integer)

# Array with outside -> must be float64 (due to NaNs)
rows, cols = grid.intersect(
np.array([5.0, 150.0]), np.array([95.0, 50.0]), forgive=True
)
assert rows.dtype == np.float64
assert cols.dtype == np.float64


def test_unstructured_iverts_cleanup():
grid = GridCases.structured_small()

Expand Down Expand Up @@ -1657,18 +1705,236 @@ def test_unstructured_iverts_cleanup():
raise AssertionError("Improper number of vertices for cleaned 'shared' iverts")


def test_structured_grid_intersect_edge_case(simple_structured_grid):
"""Test StructuredGrid.intersect() edge case: out-of-bounds x,y with z.
# ============================================================================
# GeospatialIndex-specific Tests
# ============================================================================


@pytest.mark.parametrize(
"grid_fixture,grid_type,expected_cells,expected_points_per_cell",
[
("minimal_vertex_grid", "vertex", 2, 5),
("minimal_unstructured_grid", "unstructured", 2, 5),
("simple_vertex_grid", "vertex", 100, 5),
],
)
def test_index_build(
grid_fixture, grid_type, expected_cells, expected_points_per_cell, request
):
"""Test building GeospatialIndex for different grid types."""
grid = request.getfixturevalue(grid_fixture)
index = GeospatialIndex(grid)

assert index.grid.grid_type == grid_type
assert index.grid is grid
assert index.tree is not None
assert index.points.shape[0] == expected_cells * expected_points_per_cell
assert len(np.unique(index.point_to_cell)) == expected_cells


def test_index_repr(simple_vertex_grid):
"""Test string representation of GeospatialIndex."""
index = GeospatialIndex(simple_vertex_grid)
repr_str = repr(index)

assert "GeospatialIndex" in repr_str
assert "vertex grid" in repr_str
assert "100 cells" in repr_str
assert "500 indexed points" in repr_str


@pytest.mark.parametrize("k", [1, 5, 10, 20])
def test_index_k_parameter(simple_vertex_grid, k):
"""Test that different k values still find correct cell."""
index = GeospatialIndex(simple_vertex_grid)

assert index.query_point(5.0, 95.0, k=k) == 0

cellids = index.query_points(
np.array([5.0, 55.0, 95.0]),
np.array([95.0, 95.0, 5.0]),
k=k,
)
np.testing.assert_array_equal(cellids, [0, 5, 99])

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

@pytest.fixture
def sliver_grid_geometry():
"""Shared geometry for sliver cell tests.

Creates a 1000:1 aspect ratio sliver cell (100 units wide, 0.1 units tall)
completely surrounded by larger cells on all four sides. This is the
worst-case scenario for centroid-based spatial indexing because query
points near the sliver edges are much closer to neighboring cell centroids.

Returns dict with vertices, iverts, xcenters, ycenters for 5 cells:
Cell 0: SLIVER (x: 0-100, y: 0-0.1)
Cell 1: TOP (above sliver)
Cell 2: BOTTOM (below sliver)
Cell 3: LEFT (left of sliver)
Cell 4: RIGHT (right of sliver)
"""
grid = simple_structured_grid
sliver_height = 0.1
sliver_yc = sliver_height / 2
top_yc = (sliver_height + 10.0) / 2

# Base x,y coordinates for vertices (no vertex IDs yet)
base_vertices = [
# Cell 0: SLIVER
(0.0, 0.0),
(100.0, 0.0),
(100.0, sliver_height),
(0.0, sliver_height),
# Cell 1: TOP
(0.0, sliver_height),
(100.0, sliver_height),
(100.0, 10.0),
(0.0, 10.0),
# Cell 2: BOTTOM
(0.0, -10.0),
(100.0, -10.0),
(100.0, 0.0),
(0.0, 0.0),
# Cell 3: LEFT
(-10.0, -10.0),
(0.0, -10.0),
(0.0, 10.0),
(-10.0, 10.0),
# Cell 4: RIGHT
(100.0, -10.0),
(110.0, -10.0),
(110.0, 10.0),
(100.0, 10.0),
]

base_iverts = [
[0, 1, 2, 3],
[4, 5, 6, 7],
[8, 9, 10, 11],
[12, 13, 14, 15],
[16, 17, 18, 19],
]

xcenters = [50.0, 50.0, 50.0, -5.0, 105.0]
ycenters = [sliver_yc, top_yc, -5.0, 0.0, 0.0]

return {
"base_vertices": base_vertices,
"base_iverts": base_iverts,
"xcenters": xcenters,
"ycenters": ycenters,
"sliver_height": sliver_height,
}


def test_index_thin_sliver_cell(sliver_grid_geometry):
"""Test GeospatialIndex finds points in very thin sliver cells (2D).

Query point at (95, 0.05) inside the sliver:
- Distance to sliver centroid (50, 0.05): 45 units
- Distance to RIGHT cell centroid (105, 0): ~10 units <- MUCH CLOSER!

A centroid-only KD-tree would return the wrong cell.
"""
geom = sliver_grid_geometry

# Build VertexGrid vertices with IDs
vertices = [[i, x, y] for i, (x, y) in enumerate(geom["base_vertices"])]

# Build cell2d from geometry
cell2d = []
for cid, iverts in enumerate(geom["base_iverts"]):
xc = geom["xcenters"][cid]
yc = geom["ycenters"][cid]
cell2d.append([cid, xc, yc, len(iverts)] + iverts)

ncells = len(cell2d)
grid = VertexGrid(
vertices=vertices,
cell2d=cell2d,
top=np.ones(ncells) * 10.0,
botm=np.zeros(ncells),
)

index = GeospatialIndex(grid)

# Test sliver cell detection at edges (where neighboring centroids are closer)
assert index.query_point(95.0, 0.05, k=10) == 0, "Right edge of sliver"
assert index.query_point(5.0, 0.05, k=10) == 0, "Left edge of sliver"
assert index.query_point(50.0, 0.05, k=10) == 0, "Center of sliver"

# Sanity check surrounding cells
assert index.query_point(50.0, 5.0, k=10) == 1, "Top cell"
assert index.query_point(50.0, -5.0, k=10) == 2, "Bottom cell"
assert index.query_point(-5.0, 0.0, k=10) == 3, "Left cell"
assert index.query_point(105.0, 0.0, k=10) == 4, "Right cell"


def test_index_thin_sliver_cell_3d(sliver_grid_geometry):
"""Test GeospatialIndex finds thin sliver cells in true 3D grids.

Uses UnstructuredGrid with grid_varies_by_layer=True to test the 3D
KD-tree and 3D bounding box lookup with the same x-y sliver geometry.
"""
geom = sliver_grid_geometry
nlay = 3
ncells_per_layer = len(geom["base_iverts"])
nverts_per_layer = len(geom["base_vertices"])

# Replicate geometry across layers for grid_varies_by_layer=True
vertices = []
iverts = []
xcenters = []
ycenters = []

for lay in range(nlay):
vert_offset = lay * nverts_per_layer
for i, (x, y) in enumerate(geom["base_vertices"]):
vertices.append([vert_offset + i, x, y])
for cell_iverts in geom["base_iverts"]:
iverts.append([v + vert_offset for v in cell_iverts])
xcenters.extend(geom["xcenters"])
ycenters.extend(geom["ycenters"])

ncpl = np.array([ncells_per_layer] * nlay)
nnodes = np.sum(ncpl)

# Set up layer elevations
top = np.zeros(nnodes)
botm = np.zeros(nnodes)
layer_tops = [100.0, 66.67, 33.33]
layer_bots = [66.67, 33.33, 0.0]

for lay in range(nlay):
start = lay * ncells_per_layer
end = start + ncells_per_layer
top[start:end] = layer_tops[lay]
botm[start:end] = layer_bots[lay]

grid = UnstructuredGrid(
vertices=vertices,
iverts=iverts,
xcenters=xcenters,
ycenters=ycenters,
ncpl=ncpl,
top=top,
botm=botm,
)

assert grid.grid_varies_by_layer, "Grid should have grid_varies_by_layer=True"

index = GeospatialIndex(grid)
assert index.is_3d, "Index should be in 3D mode"

# Test sliver cell in each layer (cell 0, 5, 10 are slivers)
# Point (95, 0.05) is far from sliver centroid but inside the cell
assert index.intersect(95.0, 0.05, z=83.33) == 0, "Sliver layer 0"
assert index.intersect(95.0, 0.05, z=50.0) == 5, "Sliver layer 1"
assert index.intersect(95.0, 0.05, z=16.67) == 10, "Sliver layer 2"

# Test out-of-bounds x,y with z coordinate
lay, row, col = grid.intersect(-50.0, -50.0, z=5.0, forgive=True)
# Test left edge of sliver
assert index.intersect(5.0, 0.05, z=83.33) == 0, "Left edge layer 0"

# Expected behavior: lay=None, row=nan, col=nan
assert lay is None, f"Expected lay=None, got {lay}"
assert np.isnan(row), f"Expected row=nan, got {row}"
assert np.isnan(col), f"Expected col=nan, got {col}"
# Test surrounding cells
assert index.intersect(105.0, 0.0, z=83.33) == 4, "Right cell layer 0"
assert index.intersect(50.0, 5.0, z=50.0) == 6, "Top cell layer 1"
Loading
Loading