Skip to content

Commit 978d7d0

Browse files
authored
fix(get_transmissivities): support DISU (#2685)
Follow up on #2611 to support DISU. Closes #2170. Plus forward-looking refactoring in case we add more grid types before 4.x comes out. Rather than conditionals everywhere to access/modify grid properties differently based on the grid type, add an adapter exposing what's needed consistently. This is necessary because grid properties may have different shapes depending on the grid type. In 4.x this can be avoided by design I think. But for now this makes the essence of the transmissivities calculation clearer.
1 parent a88f0e1 commit 978d7d0

2 files changed

Lines changed: 246 additions & 21 deletions

File tree

autotest/test_postprocessing.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,55 @@ def test_get_transmissivities_mf6_vertex(function_tmpdir):
584584
assert "r, c parameters only valid for structured grids" in str(e.value)
585585

586586

587+
def test_get_transmissivities_mf6_unstructured(function_tmpdir):
588+
nl = 1
589+
nr = 8
590+
nc = 8
591+
botm = np.ones((nl, nr, nc), dtype=float)
592+
top = np.ones((nr, nc), dtype=float) * 2.1
593+
hk = np.ones((nl, nr, nc), dtype=float) * 2.0
594+
for i in range(nl):
595+
botm[nl - i - 1, :, :] = i
596+
597+
gridprops = get_disv_kwargs(
598+
nlay=nl, nrow=nr, ncol=nc, tp=top, botm=botm, delr=1.0, delc=1.0
599+
)
600+
from flopy.utils.gridutil import get_disu_kwargs
601+
602+
gridprops_disu = get_disu_kwargs(
603+
nlay=nl,
604+
nrow=nr,
605+
ncol=nc,
606+
tp=top,
607+
botm=botm,
608+
delr=1.0,
609+
delc=1.0,
610+
return_vertices=True,
611+
)
612+
nodes = gridprops_disu["nodes"]
613+
614+
ws = function_tmpdir
615+
name = "test_mf6_unstructured_transmissivity"
616+
sim = flopy.mf6.MFSimulation(sim_name=name, sim_ws=ws, exe_name="mf6")
617+
gwf = flopy.mf6.ModflowGwf(sim, modelname=name, save_flows=True)
618+
disu = flopy.mf6.ModflowGwfdisu(gwf, **gridprops_disu)
619+
npf = flopy.mf6.ModflowGwfnpf(gwf, k=hk, save_specific_discharge=True)
620+
621+
# test specific point
622+
T = get_transmissivities(disu.bot.array, gwf, x=[0.5], y=[0.5])
623+
assert T.shape == (nl, 1)
624+
625+
# test all cell centers
626+
T = get_transmissivities(
627+
disu.bot.array, gwf, x=gwf.modelgrid.xcellcenters, y=gwf.modelgrid.ycellcenters
628+
)
629+
assert T.shape == (nl, nodes)
630+
631+
with pytest.raises(ValueError) as e:
632+
get_transmissivities(disu.bot.array, gwf, r=[0], c=[0])
633+
assert "r, c parameters only valid for structured grids" in str(e.value)
634+
635+
587636
def test_get_water_table():
588637
hdry = -1e30
589638
hds = np.ones((3, 3, 3), dtype=float) * hdry

flopy/utils/postprocessing.py

Lines changed: 197 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,185 @@
11
import numpy as np
22

33

4+
class _GridAdapter:
5+
"""
6+
Temporary workaround to normalize grid-specific array access patterns.
7+
8+
Different grid types (DIS, DISV, DISU) store arrays in different shapes.
9+
Adapters provide a uniformly shaped interface, (nlay, ncells_per_layer).
10+
11+
Currently this is used only in get_transmissivities(). It should not be
12+
necessary anymore in 4.x.
13+
"""
14+
15+
def __init__(self, model, dis_package, nlay):
16+
self.model = model
17+
self.dis = dis_package
18+
self.nlay = nlay
19+
20+
def reshape_if_needed(self, array, target_shape):
21+
"""Reshape 1D array to 2D if needed."""
22+
if array.ndim == 1 and len(target_shape) == 2:
23+
return array.reshape(target_shape)
24+
return array
25+
26+
def get_k_array(self, paklist):
27+
"""
28+
Return hydraulic conductivity array in shape (nlay, ncells_per_layer).
29+
30+
Parameters
31+
----------
32+
paklist : list
33+
List of package names in the model
34+
"""
35+
# Check for flow packages in order of preference
36+
if "LPF" in paklist:
37+
return self.model.lpf.hk.array
38+
elif "UPW" in paklist:
39+
return self.model.upw.hk.array
40+
elif "NPF" in paklist:
41+
return self._get_npf_k_array()
42+
else:
43+
raise ValueError("No LPF, UPW, or NPF package.")
44+
45+
def _get_npf_k_array(self):
46+
"""Get NPF k array. Subclasses may override for grid-specific handling."""
47+
return self.model.npf.k.array
48+
49+
def get_bottom_array(self):
50+
"""Return bottom elevation array in shape (nlay, ncells_per_layer)."""
51+
raise NotImplementedError
52+
53+
def get_top_for_slice(self, indices, grid_type):
54+
"""Return model top elevation for the given cell indices."""
55+
raise NotImplementedError
56+
57+
def get_layer_tops(self, botm_sliced, indices, grid_type):
58+
"""Return top elevation for each layer at the given indices."""
59+
raise NotImplementedError
60+
61+
def normalize_heads_array(self, heads):
62+
"""
63+
Normalize heads array to (nlay, ncells_per_layer) shape if needed.
64+
65+
Parameters
66+
----------
67+
heads : ndarray
68+
Heads array in any valid format for this grid type
69+
70+
Returns
71+
-------
72+
ndarray
73+
Heads array in (nlay, ncells_per_layer) shape
74+
"""
75+
# Default: no normalization needed
76+
return heads
77+
78+
79+
class _DisAdapter(_GridAdapter):
80+
"""Adapter for structured (DIS) grids."""
81+
82+
def get_bottom_array(self):
83+
return self.dis.botm.array
84+
85+
def get_top_for_slice(self, indices, grid_type):
86+
return self.dis.top.array[indices]
87+
88+
def get_layer_tops(self, botm_sliced, indices, grid_type):
89+
tops = np.empty_like(botm_sliced, dtype=float)
90+
tops[0, :] = self.dis.top.array[indices]
91+
tops[1:, :] = botm_sliced[:-1]
92+
return tops
93+
94+
95+
class _DisvAdapter(_GridAdapter):
96+
"""Adapter for vertex (DISV) grids."""
97+
98+
def get_bottom_array(self):
99+
return self.dis.botm.array
100+
101+
def get_top_for_slice(self, indices, grid_type):
102+
# DISV top is (ncpl,), indices is a tuple with one element
103+
return self.dis.top.array[indices[0]]
104+
105+
def get_layer_tops(self, botm_sliced, indices, grid_type):
106+
tops = np.empty_like(botm_sliced, dtype=float)
107+
tops[0, :] = self.dis.top.array[indices[0]]
108+
tops[1:, :] = botm_sliced[:-1]
109+
return tops
110+
111+
112+
class _DisuAdapter(_GridAdapter):
113+
"""Adapter for unstructured (DISU) grids."""
114+
115+
def _get_npf_k_array(self):
116+
"""Get NPF k array, reshaping from (nodes,) to (nlay, ncpl) if needed."""
117+
k_array = self.model.npf.k.array
118+
if k_array.ndim == 1:
119+
return k_array.reshape((self.nlay, -1))
120+
return k_array
121+
122+
def get_bottom_array(self):
123+
bot_array = self.dis.bot.array
124+
if bot_array.ndim == 1:
125+
return bot_array.reshape((self.nlay, -1))
126+
return bot_array
127+
128+
def get_top_for_slice(self, indices, grid_type):
129+
# DISU top is per-node (nodes,), reshape to (nlay, ncpl) and get layer 0
130+
top_array = self.dis.top.array
131+
if top_array.ndim == 1:
132+
top_array = top_array.reshape((self.nlay, -1))
133+
return top_array[0, indices[0]]
134+
135+
def get_layer_tops(self, botm_sliced, indices, grid_type):
136+
# DISU has per-node tops, so each layer has different top values
137+
tops = np.empty_like(botm_sliced, dtype=float)
138+
top_array = self.dis.top.array
139+
if top_array.ndim == 1:
140+
top_array = top_array.reshape((self.nlay, -1))
141+
tops[0, :] = top_array[0, indices[0]]
142+
tops[1:, :] = botm_sliced[:-1]
143+
return tops
144+
145+
def normalize_heads_array(self, heads):
146+
"""Normalize heads from flat (nnodes,) to (nlay, ncpl) if needed."""
147+
if heads.ndim == 1:
148+
return heads.reshape((self.nlay, -1))
149+
return heads
150+
151+
152+
def _get_grid_adapter(model, nlay):
153+
"""
154+
Get the appropriate grid adapter for the model.
155+
156+
Parameters
157+
----------
158+
model : flopy.modflow.Modflow or flopy.mf6.ModflowGwf object
159+
Model object
160+
nlay : int
161+
Number of layers
162+
163+
Returns
164+
-------
165+
adapter : _GridAdapter subclass instance
166+
Grid-specific adapter
167+
dis_package : discretization package
168+
The DIS, DISV, or DISU package
169+
"""
170+
paklist = model.get_package_list()
171+
172+
if "DISU" in paklist:
173+
return _DisuAdapter(model, model.disu, nlay)
174+
elif "DISV" in paklist:
175+
return _DisvAdapter(model, model.disv, nlay)
176+
elif "DIS" in paklist:
177+
return _DisAdapter(model, model.dis, nlay)
178+
else:
179+
# For older MODFLOW versions, default to DIS adapter
180+
return _DisAdapter(model, model.dis, nlay)
181+
182+
4183
def get_transmissivities(
5184
heads,
6185
m,
@@ -24,9 +203,9 @@ def get_transmissivities(
24203
heads : 2D array OR 3D array
25204
numpy array of shape nlay by n locations (2D) OR complete heads array
26205
with the correct shape for structured grids (nlay, nrow, ncol) or for
27-
vertex grids (nlay, ncpl).
206+
vertex grids (nlay, ncpl) or unstructured grids (nnodes).
28207
m : flopy.modflow.Modflow or flopy.mf6.ModflowGwf object
29-
Must have dis and lpf, upw, or npf packages.
208+
Must have dis, disv, or disu and lpf, upw, or npf packages.
30209
r : 1D array-like of ints, of length n locations
31210
row indices (optional; alternately specify x, y).
32211
Only valid for structured grids.
@@ -55,7 +234,7 @@ def get_transmissivities(
55234
- r, c (row, column indices)
56235
- x, y (real world coordinates)
57236
58-
For vertex grids only x, y coordinates are supported.
237+
For vertex and unstructured grids, only x, y coordinates are supported.
59238
60239
Examples
61240
--------
@@ -78,6 +257,9 @@ def get_transmissivities(
78257
nrow = m.nrow
79258
ncol = m.ncol
80259

260+
# get grid adapter
261+
adapter = _get_grid_adapter(m, nlay)
262+
81263
# get slicing indices
82264
if r is not None and c is not None:
83265
if grid_type != "structured":
@@ -93,21 +275,18 @@ def get_transmissivities(
93275
else:
94276
raise ValueError("Must specify r, c indices or x, y locations.")
95277

96-
# slice k
278+
# get k array using adapter (handles all flow packages)
97279
paklist = m.get_package_list()
98-
if "LPF" in paklist:
99-
hk = m.lpf.hk.array[(slice(None),) + indices]
100-
elif "UPW" in paklist:
101-
hk = m.upw.hk.array[(slice(None),) + indices]
102-
elif "NPF" in paklist:
103-
hk = m.npf.k.array[(slice(None),) + indices]
104-
else:
105-
raise ValueError("No LPF, UPW, or NPF package.")
280+
k_array = adapter.get_k_array(paklist)
281+
hk = k_array[(slice(None),) + indices]
282+
283+
# get and slice bottom array
284+
botm_array = adapter.get_bottom_array()
285+
botm = botm_array[(slice(None),) + indices]
106286

107-
# slice botm
108-
botm = m.dis.botm.array[(slice(None),) + indices]
287+
# normalize and slice heads using adapter
288+
heads = adapter.normalize_heads_array(heads)
109289

110-
# slice heads
111290
if grid_type == "structured" and heads.shape == (nlay, nrow, ncol):
112291
heads = heads[(slice(None),) + indices]
113292
elif grid_type != "structured" and heads.shape == (nlay, ncpl):
@@ -117,14 +296,11 @@ def get_transmissivities(
117296

118297
# open interval tops/bottoms default to model top/bottom
119298
if sctop is None:
120-
sctop = m.dis.top.array[indices]
299+
sctop = adapter.get_top_for_slice(indices, grid_type)
121300
if scbot is None:
122-
scbot = m.dis.botm.array[(-1,) + indices]
301+
scbot = botm[-1, :]
123302

124-
# make an array of layer tops
125-
tops = np.empty_like(botm, dtype=float)
126-
tops[0, :] = m.dis.top.array[indices]
127-
tops[1:, :] = botm[:-1]
303+
tops = adapter.get_layer_tops(botm, indices, grid_type)
128304

129305
# expand top and bottom arrays to be same shape as botm, thickness, etc.
130306
# (so we have an open interval value for each layer)

0 commit comments

Comments
 (0)