Skip to content

Commit 6fcaaae

Browse files
fix(xt3d): select upstream cell for Newton saturation from flow direction (MODFLOW-ORG#3006)
With the Newton formulation, XT3D scaled the flow across a horizontal connection by the saturation of the cell with the higher head, and xt3d_fn differentiated with respect to that cell. Because the XT3D flow across a face also depends on the heads of neighboring cells, it is generally not zero when the two cell heads are equal, so the residual jumped whenever the ordering of two nearly equal heads changed; in a large unconfined DISU model one cell flipped between two states on every outer iteration and the first stress period never converged. The upstream cell is now selected from the sign of the saturated XT3D flow, which keeps the product of flow and saturation continuous. A Newton call for the actual area that does not provide the saturated flow now stops with a program error. Adds test_gwf_xt3d_newton_upstream.py, which checks residual continuity through libmf6 and convergence and budget closure on a designed model.
1 parent 96ba08c commit 6fcaaae

3 files changed

Lines changed: 337 additions & 11 deletions

File tree

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
"""
2+
Test the upstream-saturation treatment of the XT3D Newton formulation.
3+
4+
With the Newton formulation, XT3D scales the flow across a horizontal
5+
connection by the saturation of the upstream cell. The flow across an XT3D
6+
connection is generally not zero when the heads of the two cells are equal,
7+
because the flow also depends on the heads of the neighboring cells, so the
8+
upstream cell must be selected from the direction of the flow rather than
9+
from the head difference. Otherwise the product of flow and saturation, and
10+
therefore the discretized residual, jumps when the head ordering of two cells
11+
with different saturations changes. In a large model this caused a single
12+
cell to flip between two states on every outer iteration and prevented
13+
convergence.
14+
15+
Case 0 (xt3dnr_cont) checks the continuity of the residual directly. A
16+
small unconfined model with rotated horizontal anisotropy is loaded through
17+
the MODFLOW API, the head of one cell is swept across the head of its
18+
neighbor, which has a different bottom elevation and therefore a different
19+
saturation, and the discretized residual r = A x - b is evaluated at each
20+
head. The residual of both cells must vary smoothly across the crossing.
21+
22+
Case 1 (xt3dnr_conv) is a small steady-state model in which two rows have
23+
nearly equal transmissivity but very different saturation. It must converge
24+
with the Newton formulation, its budget must close, and its heads must agree
25+
closely with the standard (non-Newton) XT3D formulation.
26+
"""
27+
28+
import re
29+
from pathlib import Path
30+
31+
import flopy
32+
import numpy as np
33+
from framework import TestFramework
34+
from modflow_devtools.markers import requires_pkg
35+
36+
cases = ["xt3dnr_cont", "xt3dnr_conv"]
37+
38+
# case 0 grid: the deep cell n and its shallow east neighbor m in the
39+
# middle row are the pair whose head ordering is swept
40+
nlay, nrow, ncol = 1, 3, 5
41+
delr = delc = 100.0
42+
top = 100.0
43+
n_cell = (0, 1, 2)
44+
m_cell = (0, 1, 3)
45+
h_ref = 80.0
46+
47+
48+
def boundary_heads():
49+
"""Heads with gradients in both directions so the XT3D flow across the
50+
n-m face is not zero when the two cells have the same head."""
51+
yc = (nrow - 1 - np.arange(nrow)) * delc + delc / 2.0
52+
xc = np.arange(ncol) * delr + delr / 2.0
53+
h = np.zeros((nrow, ncol))
54+
for i in range(nrow):
55+
for j in range(ncol):
56+
h[i, j] = h_ref + 0.05 * (yc[i] - yc[1]) + 0.01 * (xc[j] - xc[2])
57+
return h
58+
59+
60+
def build_cont(ws, exe):
61+
botm = np.zeros((nlay, nrow, ncol))
62+
botm[m_cell] = 60.0 # shallow neighbor: saturation 0.5 at h = 80
63+
sim = flopy.mf6.MFSimulation(sim_name=cases[0], sim_ws=ws, exe_name=exe)
64+
flopy.mf6.ModflowTdis(sim, nper=1, perioddata=[(1.0, 1, 1.0)])
65+
flopy.mf6.ModflowIms(
66+
sim,
67+
print_option="SUMMARY",
68+
outer_maximum=500,
69+
outer_dvclose=1e-9,
70+
inner_dvclose=1e-10,
71+
rcloserecord=[1e-8, "STRICT"],
72+
linear_acceleration="BICGSTAB",
73+
no_ptcrecord="ALL",
74+
)
75+
gwf = flopy.mf6.ModflowGwf(
76+
sim, modelname=cases[0], newtonoptions="NEWTON UNDER_RELAXATION"
77+
)
78+
flopy.mf6.ModflowGwfdis(
79+
gwf,
80+
nlay=nlay,
81+
nrow=nrow,
82+
ncol=ncol,
83+
delr=delr,
84+
delc=delc,
85+
top=top,
86+
botm=botm,
87+
)
88+
flopy.mf6.ModflowGwfic(gwf, strt=h_ref)
89+
flopy.mf6.ModflowGwfnpf(
90+
gwf, icelltype=1, k=1.0, k22=0.1, angle1=30.0, xt3doptions=True
91+
)
92+
hb = boundary_heads()
93+
spd = [
94+
[(0, i, j), hb[i, j]]
95+
for i in range(nrow)
96+
for j in range(ncol)
97+
if i in (0, nrow - 1) or j in (0, ncol - 1)
98+
]
99+
flopy.mf6.ModflowGwfchd(gwf, stress_period_data=spd)
100+
flopy.mf6.ModflowGwfoc(
101+
gwf,
102+
head_filerecord=f"{cases[0]}.hds",
103+
budget_filerecord=f"{cases[0]}.cbc",
104+
saverecord=[("HEAD", "ALL"), ("BUDGET", "ALL")],
105+
)
106+
return sim
107+
108+
109+
def build_conv(ws, exe, newton=True):
110+
nrow_b, ncol_b = 4, 10
111+
botm = np.zeros((1, nrow_b, ncol_b))
112+
k = np.ones((1, nrow_b, ncol_b))
113+
# rows 1 and 2 have the same transmissivity at h = 90 (1 * 90 and
114+
# 9 * 10) but saturations of 0.9 and 0.5
115+
botm[0, 2, :] = 80.0
116+
k[0, 2, :] = 9.0
117+
name = cases[1] if newton else cases[1] + "_std"
118+
sim = flopy.mf6.MFSimulation(sim_name=name, sim_ws=ws, exe_name=exe)
119+
flopy.mf6.ModflowTdis(sim, nper=1, perioddata=[(1.0, 1, 1.0)])
120+
flopy.mf6.ModflowIms(
121+
sim,
122+
print_option="SUMMARY",
123+
complexity="COMPLEX",
124+
outer_maximum=100,
125+
outer_dvclose=1e-6,
126+
inner_dvclose=1e-8,
127+
rcloserecord=[1e-6, "STRICT"],
128+
linear_acceleration="BICGSTAB",
129+
)
130+
gwf = flopy.mf6.ModflowGwf(
131+
sim,
132+
modelname=name,
133+
newtonoptions="NEWTON UNDER_RELAXATION" if newton else None,
134+
)
135+
flopy.mf6.ModflowGwfdis(
136+
gwf,
137+
nlay=1,
138+
nrow=nrow_b,
139+
ncol=ncol_b,
140+
delr=100.0,
141+
delc=100.0,
142+
top=100.0,
143+
botm=botm,
144+
)
145+
flopy.mf6.ModflowGwfic(gwf, strt=90.0)
146+
flopy.mf6.ModflowGwfnpf(
147+
gwf, icelltype=1, k=k, k22=0.1, angle1=30.0, xt3doptions=True
148+
)
149+
spd = [[(0, i, 0), 92.0] for i in range(nrow_b)] + [
150+
[(0, i, ncol_b - 1), 88.0] for i in range(nrow_b)
151+
]
152+
flopy.mf6.ModflowGwfchd(gwf, stress_period_data=spd)
153+
flopy.mf6.ModflowGwfoc(
154+
gwf,
155+
head_filerecord=f"{name}.hds",
156+
budget_filerecord=f"{name}.cbc",
157+
saverecord=[("HEAD", "ALL"), ("BUDGET", "ALL")],
158+
)
159+
return sim
160+
161+
162+
def build_models(idx, test):
163+
if idx == 0:
164+
sim = build_cont(test.workspace, "libmf6")
165+
else:
166+
sim = build_conv(test.workspace, "mf6")
167+
return sim, None
168+
169+
170+
def residual_sweep(libmf6, ws):
171+
"""Sweep the head of cell n across the head of cell m and return the
172+
discretized residuals of both cells."""
173+
from modflowapi import ModflowApi
174+
175+
mf6 = ModflowApi(libmf6, working_directory=str(ws))
176+
mf6.initialize()
177+
mf6.prepare_time_step(mf6.get_time_step())
178+
mf6.prepare_solve()
179+
xtag = mf6.get_var_address("X", "SLN_1")
180+
atag = mf6.get_var_address("AMAT", "SLN_1")
181+
btag = mf6.get_var_address("RHS", "SLN_1")
182+
ia = mf6.get_value(mf6.get_var_address("IA", "SLN_1")) - 1
183+
ja = mf6.get_value(mf6.get_var_address("JA", "SLN_1")) - 1
184+
nn = n_cell[1] * ncol + n_cell[2]
185+
mm = m_cell[1] * ncol + m_cell[2]
186+
base = boundary_heads().ravel()
187+
base[mm] = h_ref
188+
deltas = np.linspace(-0.05, 0.05, 41)
189+
res = np.zeros((deltas.size, 2))
190+
for k, d in enumerate(deltas):
191+
x = base.copy()
192+
x[nn] = h_ref + d
193+
mf6.set_value(xtag, x.copy())
194+
# solve() assembles A and b for the heads just set (the Newton
195+
# terms cancel in A x - b) and then overwrites x with the linear
196+
# solution, which is discarded
197+
mf6.solve()
198+
a = mf6.get_value(atag)
199+
b = mf6.get_value(btag)
200+
for irow, icell in ((0, nn), (1, mm)):
201+
cols = ja[ia[icell] : ia[icell + 1]]
202+
res[k, irow] = a[ia[icell] : ia[icell + 1]] @ x[cols] - b[icell]
203+
mf6.finalize_solve()
204+
mf6.finalize_time_step()
205+
mf6.finalize()
206+
return deltas, res
207+
208+
209+
def api_func(exe, idx, model_ws):
210+
"""Check that the residual of both cells is continuous across the point
211+
where the heads of the two cells are equal."""
212+
deltas, res = residual_sweep(exe, model_ws)
213+
ok = True
214+
buff = []
215+
for icell, label in enumerate(("n", "m")):
216+
dr = np.abs(np.diff(res[:, icell]))
217+
ratio = dr.max() / np.median(dr)
218+
buff.append(
219+
f"cell {label}: median step {np.median(dr):.4g}, "
220+
f"largest step {dr.max():.4g}, ratio {ratio:.2f}"
221+
)
222+
# the residual is nearly linear in the swept head, so consecutive
223+
# steps should be nearly equal; a discontinuity at the crossing
224+
# shows up as one step that is orders of magnitude larger
225+
if ratio > 2.0:
226+
ok = False
227+
buff.append(f" residual of cell {label} is discontinuous")
228+
print("\n".join(buff))
229+
return ok, buff
230+
231+
232+
def check_output(idx, test):
233+
ws = Path(test.workspace)
234+
if idx == 0:
235+
return
236+
name = cases[1]
237+
lst = (ws / "mfsim.lst").read_text()
238+
nouter = len(
239+
re.findall(r"^\s+(?:Model|Under-relaxation|Backtracking)\s+\d+", lst, re.M)
240+
)
241+
print(f"Newton XT3D outer iterations: {nouter}")
242+
assert nouter < 60, f"Newton XT3D needed {nouter} outer iterations"
243+
disc = [
244+
abs(float(v))
245+
for v in re.findall(
246+
r"PERCENT DISCREPANCY =\s+(-?\d+\.\d+)", (ws / f"{name}.lst").read_text()
247+
)
248+
]
249+
assert disc and max(disc) < 0.01, f"budget discrepancy {disc}"
250+
# reference: same model with the standard formulation
251+
ws_std = ws / "std"
252+
sim = build_conv(ws_std, test.targets["mf6"], newton=False)
253+
sim.write_simulation(silent=True)
254+
success, _ = sim.run_simulation(silent=True)
255+
assert success, "standard-formulation reference run failed"
256+
h_newton = flopy.utils.HeadFile(ws / f"{name}.hds").get_data()
257+
h_std = flopy.utils.HeadFile(ws_std / f"{name}_std.hds").get_data()
258+
dmax = np.abs(h_newton - h_std).max()
259+
print(f"max |Newton - standard| head difference: {dmax:.4f}")
260+
# the two formulations discretize saturated thickness slightly
261+
# differently, so they agree closely but not exactly
262+
assert dmax < 0.2, f"Newton and standard XT3D heads differ by {dmax}"
263+
264+
265+
@requires_pkg("modflowapi")
266+
def test_residual_continuity(function_tmpdir, targets):
267+
idx = 0
268+
test = TestFramework(
269+
name=cases[idx],
270+
workspace=function_tmpdir,
271+
targets=targets,
272+
build=lambda t: build_models(idx, t),
273+
check=lambda t: check_output(idx, t),
274+
api_func=lambda exe, ws: api_func(exe, idx, ws),
275+
)
276+
test.run()
277+
278+
279+
def test_convergence(function_tmpdir, targets):
280+
idx = 1
281+
test = TestFramework(
282+
name=cases[idx],
283+
workspace=function_tmpdir,
284+
targets=targets,
285+
build=lambda t: build_models(idx, t),
286+
check=lambda t: check_output(idx, t),
287+
)
288+
test.run()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
section = "fixes"
2+
subsection = "internal"
3+
description = "With the Newton formulation, the XT3D method scaled the flow across a horizontal connection by the saturation of the cell with the higher head, as the standard formulation does. Because the XT3D flow across a connection also depends on the heads of neighboring cells and is generally not zero when the two cell heads are equal, the residual could jump when the ordering of two nearly equal heads changed between iterations, and in one large unconfined model a single cell prevented convergence. The upstream cell is now selected from the direction of the saturated XT3D flow, which keeps the product of flow and saturation continuous. Heads computed with the Newton formulation and the XT3D option may change slightly."

0 commit comments

Comments
 (0)