Skip to content

Commit 41f1840

Browse files
authored
refactor(plot): better shared face finding for HFB plotting (#2682)
Improve the shared face finding algorithm for HFB (Horizontal Flow Barrier) plotting. Factor out @jlarsen-usgs's index-based approach from #2671 into reusable function(s) and use it instead of floating point comparisons as before. Should be faster. I moved the new face-related functions to a new module faceutil.py instead of plotutil.py as they are used not only for plotting but for export, and may be useful in other cases. I considered putting them in gridutil.py but that is used by the grid classes, while the face functions use the grid classes, so it would have created a circular import. I also changed the name of is_vertical_barrier -> is_vertical and inverted the semantics so it works on faces, not barriers (a face between horizontally adjacent cells is a vertical polygon, and a face between vertically adjacent cells is a horizontal polygon, but we call the former a "horizontal" flow barrier and the latter a "vertical" flow barrier). I figure writing these utils in terms of faces for the general case makes sense, and just interpreting the results for the specific case of HFBs. This PR builds on - #2671's shared face finding approach - #2680 which gives all grid types a consistent get_node() method - #2681 which adds an ihc init parameter/attribute to UnstructuredGrid Some ad hoc cell ID -> node number conversion utilities are removed and get_node() used instead. I am classifying all this a refactor though it introduces a new utility module, as the new utils are in service of the refactor.
1 parent 6bb9c1a commit 41f1840

7 files changed

Lines changed: 539 additions & 273 deletions

File tree

autotest/test_faceutil.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import numpy as np
2+
import pytest
3+
4+
from flopy.discretization import StructuredGrid, UnstructuredGrid, VertexGrid
5+
from flopy.utils.faceutil import get_shared_face_3d
6+
7+
8+
def test_get_shared_face_3d_structured_horizontal():
9+
nlay, nrow, ncol = 1, 2, 2
10+
delr = np.array([1.0, 1.0])
11+
delc = np.array([1.0, 1.0])
12+
top = np.array([[10.0, 10.0], [10.0, 10.0]])
13+
botm = np.array([[[0.0, 0.0], [0.0, 0.0]]])
14+
15+
grid = StructuredGrid(delc=delc, delr=delr, top=top, botm=botm)
16+
17+
# Test horizontally adjacent cells (0, 0, 0) and (0, 0, 1)
18+
cellid1 = (0, 0, 0)
19+
cellid2 = (0, 0, 1)
20+
21+
face = get_shared_face_3d(grid, cellid1, cellid2)
22+
23+
assert face is not None
24+
assert len(face) == 4 # Vertical face should have 4 vertices
25+
26+
# Check that face is vertical (two z-coordinates: top and bottom)
27+
z_coords = sorted({v[2] for v in face})
28+
assert len(z_coords) == 2
29+
assert z_coords[0] == 0.0 # bottom
30+
assert z_coords[1] == 10.0 # top
31+
32+
# Check x-coordinate is at the shared boundary (x=1.0)
33+
x_coords = {v[0] for v in face}
34+
assert 1.0 in x_coords
35+
36+
37+
def test_get_shared_face_3d_structured_vertical():
38+
nlay, nrow, ncol = 2, 2, 2
39+
delr = np.array([1.0, 1.0])
40+
delc = np.array([1.0, 1.0])
41+
top = np.array([[10.0, 10.0], [10.0, 10.0]])
42+
botm = np.array([[[5.0, 5.0], [5.0, 5.0]], [[0.0, 0.0], [0.0, 0.0]]])
43+
44+
grid = StructuredGrid(delc=delc, delr=delr, top=top, botm=botm)
45+
46+
# Test vertically adjacent cells (0, 0, 0) and (1, 0, 0)
47+
cellid1 = (0, 0, 0)
48+
cellid2 = (1, 0, 0)
49+
50+
face = get_shared_face_3d(grid, cellid1, cellid2)
51+
52+
assert face is not None
53+
assert len(face) == 2 # Horizontal face should have 2 vertices (the edge)
54+
55+
# Check that face is horizontal (all z-coordinates the same at interface)
56+
z_coords = {v[2] for v in face}
57+
assert len(z_coords) == 1
58+
assert next(iter(z_coords)) == 5.0 # Interface between layers
59+
60+
61+
def test_get_shared_face_3d_vertex():
62+
nlay = 2
63+
ncpl = 2
64+
65+
# Two square cells side by side
66+
vertices = [
67+
[0, 0.0, 0.0],
68+
[1, 1.0, 0.0],
69+
[2, 2.0, 0.0],
70+
[3, 0.0, 1.0],
71+
[4, 1.0, 1.0],
72+
[5, 2.0, 1.0],
73+
]
74+
75+
cell2d = [
76+
[0, 0.5, 0.5, 4, 0, 1, 4, 3],
77+
[1, 1.5, 0.5, 4, 1, 2, 5, 4],
78+
]
79+
80+
top = np.array([10.0, 10.0])
81+
botm = np.array([[5.0, 5.0], [0.0, 0.0]])
82+
83+
grid = VertexGrid(vertices=vertices, cell2d=cell2d, top=top, botm=botm, nlay=nlay)
84+
85+
# Test horizontally adjacent cells (0, 0) and (0, 1)
86+
cellid1 = (0, 0)
87+
cellid2 = (0, 1)
88+
89+
face = get_shared_face_3d(grid, cellid1, cellid2)
90+
91+
assert face is not None
92+
assert len(face) == 4 # Vertical face
93+
94+
# Test vertically adjacent cells (0, 0) and (1, 0)
95+
cellid1 = (0, 0)
96+
cellid2 = (1, 0)
97+
98+
face = get_shared_face_3d(grid, cellid1, cellid2)
99+
100+
assert face is not None
101+
assert len(face) == 2 # Horizontal face
102+
103+
# Check all z-coordinates are the same (interface elevation)
104+
z_coords = {v[2] for v in face}
105+
assert len(z_coords) == 1
106+
assert next(iter(z_coords)) == 5.0
107+
108+
109+
def test_get_shared_face_3d_unstructured():
110+
# Two cells stacked vertically
111+
vertices = [
112+
# Bottom layer vertices (z=0)
113+
[0, 0.0, 0.0, 0.0],
114+
[1, 1.0, 0.0, 0.0],
115+
[2, 1.0, 1.0, 0.0],
116+
[3, 0.0, 1.0, 0.0],
117+
# Middle layer vertices (z=5)
118+
[4, 0.0, 0.0, 5.0],
119+
[5, 1.0, 0.0, 5.0],
120+
[6, 1.0, 1.0, 5.0],
121+
[7, 0.0, 1.0, 5.0],
122+
# Top layer vertices (z=10)
123+
[8, 0.0, 0.0, 10.0],
124+
[9, 1.0, 0.0, 10.0],
125+
[10, 1.0, 1.0, 10.0],
126+
[11, 0.0, 1.0, 10.0],
127+
]
128+
129+
iverts = [
130+
[0, 1, 2, 3, 4, 5, 6, 7], # Bottom cell
131+
[4, 5, 6, 7, 8, 9, 10, 11], # Top cell
132+
]
133+
134+
xc = np.array([0.5, 0.5])
135+
yc = np.array([0.5, 0.5])
136+
top = np.array([10.0, 10.0])
137+
botm = np.array([0.0, 5.0])
138+
139+
grid = UnstructuredGrid(
140+
vertices=vertices,
141+
iverts=iverts,
142+
xcenters=xc,
143+
ycenters=yc,
144+
top=top,
145+
botm=botm,
146+
ncpl=[2],
147+
)
148+
149+
# Test shared face between two vertically stacked cells
150+
cellid1 = (0,)
151+
cellid2 = (1,)
152+
153+
face = get_shared_face_3d(grid, cellid1, cellid2)
154+
155+
assert face is not None
156+
assert len(face) == 4 # Should have 4 shared vertices at the interface
157+
158+
# Check all shared vertices are at z=5.0 (the interface)
159+
z_coords = {v[2] for v in face}
160+
assert len(z_coords) == 1
161+
assert next(iter(z_coords)) == 5.0
162+
163+
164+
def test_get_shared_face_3d_error_cases():
165+
nlay, nrow, ncol = 1, 2, 2
166+
delr = np.array([1.0, 1.0])
167+
delc = np.array([1.0, 1.0])
168+
top = np.array([[10.0, 10.0], [10.0, 10.0]])
169+
botm = np.array([[[0.0, 0.0], [0.0, 0.0]]])
170+
171+
grid = StructuredGrid(delc=delc, delr=delr, top=top, botm=botm)
172+
173+
# Test with same cellid
174+
with pytest.raises(ValueError, match="cellid1 and cellid2 must be different"):
175+
get_shared_face_3d(grid, (0, 0, 0), (0, 0, 0))
176+
177+
# Test with non-adjacent cells (should return None)
178+
cellid1 = (0, 0, 0)
179+
cellid2 = (0, 1, 1) # Diagonally opposite, not adjacent
180+
181+
face = get_shared_face_3d(grid, cellid1, cellid2)
182+
assert face is None

flopy/mf6/mfpackage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2160,7 +2160,7 @@ def to_geodataframe(self, gdf=None, kper=0, full_grid=True, shorten_attr=False,
21602160
if modelgrid is not None:
21612161
if self.package_type == "hfb":
21622162
gpd = import_optional_dependency("geopandas")
2163-
from ..plot.plotutil import hfb_data_to_linework
2163+
from ..utils.faceutil import hfb_data_to_linework
21642164

21652165
recarray = self.stress_period_data.data[kper]
21662166
lines = hfb_data_to_linework(recarray, modelgrid)

flopy/modflow/mfhfb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def _get_hfb_lines(self):
190190
-------
191191
list : list of hfb lines
192192
"""
193-
from ..plot.plotutil import hfb_data_to_linework
193+
from ..utils.faceutil import hfb_data_to_linework
194194

195195
return hfb_data_to_linework(self.hfb_data, self.parent.modelgrid)
196196

flopy/plot/crosssection.py

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from . import plotutil
1414
from .plotutil import (
1515
get_shared_face_3d,
16-
is_vertical_barrier,
16+
is_vertical,
1717
to_mp7_endpoints,
1818
to_mp7_pathlines,
1919
)
@@ -781,22 +781,6 @@ def plot_grid(self, **kwargs):
781781
ax.add_collection(col)
782782
return col
783783

784-
def _cellid_to_node(self, cellid):
785-
"""
786-
Convert a cellid tuple to a node number.
787-
788-
Parameters
789-
----------
790-
cellid : tuple
791-
Cell identifier
792-
793-
Returns
794-
-------
795-
int
796-
Node number
797-
"""
798-
return self.mg.get_node([cellid])[0]
799-
800784
def _plot_vertical_hfb_lines(self, color=None, **kwargs):
801785
"""
802786
Plot vertical HFBs as lines at layer interfaces.
@@ -848,8 +832,8 @@ def _plot_vertical_hfb_lines(self, color=None, **kwargs):
848832

849833
# Get the x-coordinates along the cross section for this cell
850834
# Need to find this cell in projpts - convert both cellids to nodes
851-
node1 = self._cellid_to_node(cellid1)
852-
node2 = self._cellid_to_node(cellid2)
835+
node1 = self.mg.get_node([cellid1])[0]
836+
node2 = self.mg.get_node([cellid2])[0]
853837

854838
# Get x-coordinates from either node's projection
855839
xcoords = []
@@ -976,11 +960,11 @@ def plot_bc(
976960
cellid1 = tuple(entry["cellid1"])
977961
cellid2 = tuple(entry["cellid2"])
978962

979-
if is_vertical_barrier(self.mg, cellid1, cellid2):
980-
# Store vertical HFBs for line plotting
963+
if not is_vertical(self.mg, cellid1, cellid2):
964+
# horizontal face, vertical barrier
981965
vertical_hfbs.append((cellid1, cellid2))
982966
else:
983-
# Horizontal barriers - plot both cells as patches
967+
# vertical face, horizontal barrier
984968
cellids.append(list(entry["cellid1"]))
985969
cellids.append(list(entry["cellid2"]))
986970

flopy/plot/map.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from .plotutil import (
1414
get_shared_face,
1515
get_shared_face_3d,
16-
is_vertical_barrier,
16+
is_vertical,
1717
to_mp7_pathlines,
1818
)
1919

@@ -469,20 +469,18 @@ def _plot_barrier_bc(self, barrier_data, color=None, name=None, **kwargs):
469469
"""
470470
ax = kwargs.pop("ax", self.ax)
471471

472-
# Separate horizontal and vertical barriers
473472
horizontal_line_segments = []
474473
vertical_cell_indices = []
475474

476475
for cellid1, cellid2 in barrier_data:
477476
# Only plot barriers on the current layer (for layered grids)
478477
# For DISU (len==1), plot all barriers since there's no layer filtering
479-
if len(cellid1) >= 2: # DIS or DISV (has layer info)
478+
if len(cellid1) >= 2:
480479
if cellid1[0] != self.layer and cellid2[0] != self.layer:
481480
continue
482481

483-
# Check if this is a vertical barrier
484-
if is_vertical_barrier(self.mg, cellid1, cellid2):
485-
# For vertical barriers, plot the cells on the current layer
482+
# horizontal face, vertical barrier
483+
if not is_vertical(self.mg, cellid1, cellid2):
486484
if len(cellid1) >= 2:
487485
if cellid1[0] == self.layer:
488486
if len(cellid1) == 3:
@@ -499,7 +497,7 @@ def _plot_barrier_bc(self, barrier_data, color=None, name=None, **kwargs):
499497
else:
500498
vertical_cell_indices.append([self.layer, cellid2[1]])
501499
else:
502-
# Horizontal barrier - plot as line on shared face
500+
# vertical face, horizontal barrier
503501
shared_face = get_shared_face(self.mg, cellid1, cellid2)
504502
if shared_face is not None:
505503
horizontal_line_segments.append(shared_face)

0 commit comments

Comments
 (0)