Skip to content

Commit 79cbcbb

Browse files
authored
fix(prt): fix vertical ternary method (#2281)
Fix two bugs in the disv tracking method, one introduced in #1874, the other in #2183. In the first case the logic to determine whether a particle exits a cell through a vertical or lateral face was incorrectly rearranged. This lost the original (correct) behavior for comparing exit times and selecting an exit face — where a particle should exit through the top or bottom, it could instead exit through a side. In the second case the logic to "clamp" particles to the cell's vertical extent was incorrect, and particles already within that extent were moved abruptly to the bottom of the cell. Both of these were probably missed up to now because the PRT test cases are currently mostly 1-layer. While a multi-layer test is added here, tt would be good to exercise PRT on more DISV models with depth.
1 parent c415d7a commit 79cbcbb

File tree

5 files changed

+371
-12
lines changed

5 files changed

+371
-12
lines changed
Binary file not shown.

autotest/prt_test_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ def get_gwf_sim(name, ws, mf6) -> flopy.mf6.MFSimulation:
178178
nlay=FlopyReadmeCase.nlay,
179179
nrow=FlopyReadmeCase.nrow,
180180
ncol=FlopyReadmeCase.ncol,
181+
top=FlopyReadmeCase.top,
182+
botm=FlopyReadmeCase.botm,
181183
)
182184

183185
# create gwf initial conditions package

autotest/test_prt_disv_vert.py

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
"""
2+
This reproduces a ternary method regression
3+
where the particle's local z coordinate was
4+
improperly set to the bottom of the cell in
5+
an attempt to clamp the z coordinate to the
6+
unit interval.
7+
8+
This is a DISV grid which reduces to a DIS
9+
grid; we run the PRT model twice, once with
10+
pollock's method and once with the ternary
11+
method, and check that the results are equal.
12+
"""
13+
14+
from os import environ
15+
16+
import flopy
17+
import matplotlib.cm as cm
18+
import matplotlib.pyplot as plt
19+
import numpy as np
20+
import pandas as pd
21+
import pytest
22+
from flopy.utils.binaryfile import HeadFile
23+
from flopy.utils.gridutil import get_disv_kwargs
24+
from framework import TestFramework
25+
from modflow_devtools.misc import is_in_ci
26+
from prt_test_utils import get_model_name, get_partdata
27+
28+
simname = "prtdisvvert"
29+
cases = [simname]
30+
31+
32+
# model info
33+
nlay = 2
34+
nrow = 10
35+
ncol = 10
36+
ncpl = nrow * ncol
37+
delr = 1.0
38+
delc = 1.0
39+
nper = 1
40+
perlen = 10
41+
nstp = 5
42+
tsmult = 1.0
43+
tdis_rc = [(perlen, nstp, tsmult)]
44+
top = 25.0
45+
botm = [20.0, 15.0]
46+
strt = 20
47+
nouter, ninner = 100, 300
48+
hclose, rclose, relax = 1e-9, 1e-3, 0.97
49+
porosity = 0.1
50+
tracktimes = list(np.linspace(0, 19, 20))
51+
52+
53+
# vertex grid properties
54+
disvkwargs = get_disv_kwargs(nlay, nrow, ncol, delr, delc, top, botm)
55+
56+
# release points in mp7 format
57+
releasepts_mp7 = [
58+
# node number, localx, localy, localz
59+
(i * 10, 0.5, 0.5, 0.5)
60+
for i in range(10)
61+
]
62+
63+
64+
def build_gwf_sim(name, ws, mf6):
65+
gwf_name = f"{name}_gwf"
66+
sim = flopy.mf6.MFSimulation(
67+
sim_name=gwf_name, version="mf6", exe_name=mf6, sim_ws=ws
68+
)
69+
70+
# create tdis package
71+
tdis = flopy.mf6.ModflowTdis(sim, time_units="DAYS", nper=nper, perioddata=tdis_rc)
72+
73+
# create gwf model
74+
gwf = flopy.mf6.ModflowGwf(
75+
sim, modelname=gwf_name, newtonoptions="NEWTON", save_flows=True
76+
)
77+
78+
# create iterative model solution and register the gwf model with it
79+
ims = flopy.mf6.ModflowIms(
80+
sim,
81+
print_option="SUMMARY",
82+
complexity="MODERATE",
83+
outer_dvclose=hclose,
84+
outer_maximum=nouter,
85+
under_relaxation="DBD",
86+
inner_maximum=ninner,
87+
inner_dvclose=hclose,
88+
rcloserecord=rclose,
89+
linear_acceleration="BICGSTAB",
90+
scaling_method="NONE",
91+
reordering_method="NONE",
92+
relaxation_factor=relax,
93+
)
94+
sim.register_ims_package(ims, [gwf.name])
95+
96+
disv = flopy.mf6.ModflowGwfdisv(gwf, **disvkwargs)
97+
98+
# initial conditions
99+
ic = flopy.mf6.ModflowGwfic(gwf, strt=strt)
100+
101+
# node property flow
102+
npf = flopy.mf6.ModflowGwfnpf(
103+
gwf,
104+
save_flows=True,
105+
save_specific_discharge=True,
106+
save_saturation=True,
107+
)
108+
109+
# constant head boundary
110+
spd = {
111+
0: [[(0, 0), 1.0, 1.0], [(0, 99), 0.0, 0.0]],
112+
# 1: [[(0, 0, 0), 0.0, 0.0], [(0, 9, 9), 1.0, 2.0]],
113+
}
114+
chd = flopy.mf6.ModflowGwfchd(
115+
gwf,
116+
pname="CHD-1",
117+
stress_period_data=spd,
118+
auxiliary=["concentration"],
119+
)
120+
121+
# output control
122+
oc = flopy.mf6.ModflowGwfoc(
123+
gwf,
124+
budget_filerecord=f"{gwf_name}.cbc",
125+
head_filerecord=f"{gwf_name}.hds",
126+
headprintrecord=[("COLUMNS", 10, "WIDTH", 15, "DIGITS", 6, "GENERAL")],
127+
saverecord=[("HEAD", "ALL"), ("BUDGET", "ALL")],
128+
printrecord=[("HEAD", "ALL"), ("BUDGET", "ALL")],
129+
filename=f"{gwf_name}.oc",
130+
)
131+
132+
# Print human-readable heads
133+
obs_lst = []
134+
for k in np.arange(0, 1, 1):
135+
for i in np.arange(40, 50, 1):
136+
obs_lst.append(["obs_" + str(i + 1), "head", (k, i)])
137+
138+
obs_dict = {f"{gwf_name}.obs.csv": obs_lst}
139+
obs = flopy.mf6.ModflowUtlobs(gwf, pname="head_obs", digits=20, continuous=obs_dict)
140+
141+
return sim
142+
143+
144+
def build_prt_sim(name, gwf_ws, prt_ws, mf6):
145+
# create simulation
146+
sim = flopy.mf6.MFSimulation(
147+
sim_name=name,
148+
exe_name=mf6,
149+
version="mf6",
150+
sim_ws=prt_ws,
151+
)
152+
153+
# create tdis package
154+
tdis = flopy.mf6.ModflowTdis(sim, time_units="DAYS", nper=nper, perioddata=tdis_rc)
155+
156+
# create prt model
157+
prt_name = f"{name}_prt"
158+
prt = flopy.mf6.ModflowPrt(sim, modelname=prt_name)
159+
160+
# create prt discretization
161+
disv = flopy.mf6.ModflowGwfdisv(prt, **disvkwargs)
162+
163+
# create mip package
164+
flopy.mf6.ModflowPrtmip(prt, pname="mip", porosity=porosity)
165+
166+
# convert mp7 particledata to prt release points
167+
partdata = get_partdata(prt.modelgrid, releasepts_mp7)
168+
releasepts = list(partdata.to_prp(prt.modelgrid))
169+
170+
# create prp package
171+
for i in range(2):
172+
prp_track_file = f"{prt_name}{i}.prp.trk"
173+
prp_track_csv_file = f"{prt_name}{i}.prp.trk.csv"
174+
flopy.mf6.ModflowPrtprp(
175+
prt,
176+
pname=f"prp{i}",
177+
filename=f"{prt_name}{i}.prp",
178+
nreleasepts=len(releasepts),
179+
packagedata=releasepts,
180+
perioddata={0: ["FIRST"]},
181+
track_filerecord=[prp_track_file],
182+
trackcsv_filerecord=[prp_track_csv_file],
183+
stop_at_weak_sink=False,
184+
boundnames=True,
185+
print_input=True,
186+
dev_forceternary=i == 1,
187+
exit_solve_tolerance=1e-10,
188+
extend_tracking=True,
189+
)
190+
191+
# create output control package
192+
prt_track_file = f"{prt_name}.trk"
193+
prt_track_csv_file = f"{prt_name}.trk.csv"
194+
flopy.mf6.ModflowPrtoc(
195+
prt,
196+
pname="oc",
197+
track_filerecord=[prt_track_file],
198+
trackcsv_filerecord=[prt_track_csv_file],
199+
)
200+
201+
# create the flow model interface
202+
gwf_name = f"{name}_gwf"
203+
gwf_budget_file = gwf_ws / f"{gwf_name}.cbc"
204+
gwf_head_file = gwf_ws / f"{gwf_name}.hds"
205+
flopy.mf6.ModflowPrtfmi(
206+
prt,
207+
packagedata=[
208+
("GWFHEAD", gwf_head_file),
209+
("GWFBUDGET", gwf_budget_file),
210+
],
211+
)
212+
213+
# add explicit model solution
214+
ems = flopy.mf6.ModflowEms(
215+
sim,
216+
pname="ems",
217+
filename=f"{prt_name}.ems",
218+
)
219+
sim.register_solution_package(ems, [prt.name])
220+
221+
return sim
222+
223+
224+
def build_models(idx, test):
225+
gwf_sim = build_gwf_sim(test.name, test.workspace / "gwf", test.targets["mf6"])
226+
prt_sim = build_prt_sim(
227+
test.name, test.workspace / "gwf", test.workspace / "prt", test.targets["mf6"]
228+
)
229+
return gwf_sim, prt_sim
230+
231+
232+
def check_output(idx, test, snapshot):
233+
name = test.name
234+
gwf_ws = test.workspace / "gwf"
235+
prt_ws = test.workspace / "prt"
236+
gwf_name = get_model_name(name, "gwf")
237+
prt_name = get_model_name(name, "prt")
238+
gwf_sim = test.sims[0]
239+
gwf = gwf_sim.get_model(gwf_name)
240+
mg = gwf.modelgrid
241+
242+
prt_track_file = f"{prt_name}.trk"
243+
prt_track_csv_file = f"{prt_name}.trk.csv"
244+
245+
# load mf6 pathline results
246+
mf6_pls = pd.read_csv(prt_ws / prt_track_csv_file, na_filter=False)
247+
248+
if is_in_ci() and "ifort" in environ.get("FC", ""):
249+
return
250+
251+
assert snapshot == mf6_pls.drop("name", axis=1).round(2).to_records(index=False)
252+
253+
254+
def plot_output(idx, test):
255+
name = test.name
256+
gwf_ws = test.workspace / "gwf"
257+
prt_ws = test.workspace / "prt"
258+
gwf_name = get_model_name(name, "gwf")
259+
prt_name = get_model_name(name, "prt")
260+
gwf_sim = test.sims[0]
261+
gwf = gwf_sim.get_model(gwf_name)
262+
mg = gwf.modelgrid
263+
264+
# check mf6 output files exist
265+
gwf_head_file = f"{gwf_name}.hds"
266+
prt_track_csv_file = f"{prt_name}.trk.csv"
267+
268+
# extract head, budget, and specific discharge results from GWF model
269+
hds = HeadFile(gwf_ws / gwf_head_file).get_data()
270+
bud = gwf.output.budget()
271+
spdis = bud.get_data(text="DATA-SPDIS")[0]
272+
qx, qy, qz = flopy.utils.postprocessing.get_specific_discharge(spdis, gwf)
273+
274+
# load mf6 pathline results
275+
mf6_pls = pd.read_csv(prt_ws / prt_track_csv_file, na_filter=False)
276+
277+
# set up plot
278+
fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(10, 10))
279+
ax.set_aspect("equal")
280+
281+
# plot mf6 pathlines in map view
282+
pmv = flopy.plot.PlotMapView(modelgrid=mg, ax=ax)
283+
pmv.plot_grid()
284+
pmv.plot_array(hds[0], alpha=0.1)
285+
pmv.plot_vector(qx, qy, normalize=True, color="white")
286+
mf6_plines = mf6_pls.groupby(["iprp", "irpt", "trelease"])
287+
for ipl, ((iprp, irpt, trelease), pl) in enumerate(mf6_plines):
288+
pl.plot(
289+
title="MF6 pathlines",
290+
kind="line",
291+
x="x",
292+
y="y",
293+
ax=ax,
294+
legend=False,
295+
color=cm.plasma(ipl / len(mf6_plines)),
296+
)
297+
298+
# view/save plot
299+
plt.show()
300+
plt.savefig(prt_ws / f"{name}.png")
301+
302+
import pyvista as pv
303+
304+
pv.set_plot_theme("document")
305+
pv.global_theme.allow_empty_mesh = True
306+
307+
from flopy.export.vtk import Vtk
308+
309+
axes = pv.Axes(show_actor=False, actor_scale=2.0, line_width=5)
310+
vtk = Vtk(model=gwf, binary=False, smooth=False)
311+
vtk.add_model(gwf)
312+
gwf_mesh = vtk.to_pyvista()
313+
314+
p = pv.Plotter(
315+
window_size=[700, 700],
316+
)
317+
p.enable_anti_aliasing()
318+
p.add_mesh(gwf_mesh, opacity=0.1, style="wireframe")
319+
p.add_mesh(
320+
mf6_pls[mf6_pls.iprp == 1][["x", "y", "z"]].to_numpy(),
321+
color="red",
322+
label="pollock's method",
323+
point_size=15,
324+
)
325+
p.add_mesh(
326+
mf6_pls[mf6_pls.iprp == 2][["x", "y", "z"]].to_numpy(),
327+
color="green",
328+
label="ternary method",
329+
point_size=15,
330+
)
331+
p.show()
332+
333+
334+
@pytest.mark.parametrize("idx, name", enumerate(cases))
335+
def test_mf6model(idx, name, function_tmpdir, targets, array_snapshot, plot):
336+
test = TestFramework(
337+
name=name,
338+
workspace=function_tmpdir,
339+
build=lambda t: build_models(idx, t),
340+
check=lambda t: check_output(idx, t, array_snapshot),
341+
plot=lambda t: plot_output(idx, t) if plot else None,
342+
targets=targets,
343+
compare=None,
344+
)
345+
test.run()

doc/ReleaseNotes/develop.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,8 @@ description = "The mf6io.pdf guide for the SFE Package lists the availability of
3232

3333
[[items]]
3434
section = "fixes"
35-
description = "Fixed a variable overflow in the MPI communication for parallel simulations that could cause a memory exception when running a parallel simulation with truly large subdomains (~20M nodes or more)."
35+
description = "Fixed a variable overflow in the MPI communication for parallel simulations that could cause a memory exception when running a parallel simulation with truly large subdomains (~20M nodes or more)."
36+
37+
[[items]]
38+
section = "fixes"
39+
description = "The DISV particle tracking method did not correctly determine whether to pass particles through lateral or vertical subcell faces. It also did not properly ensure that particles' relative z coordinates fell within the unit interval, causing particles to be moved to the cell bottom. These issues have been fixed."

0 commit comments

Comments
 (0)