Skip to content

Commit 0cfa98c

Browse files
committed
fix(prt): terminate unreleased if stoptime < release time
1 parent 558c722 commit 0cfa98c

3 files changed

Lines changed: 241 additions & 24 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""
2+
Reproduce https://github.com/MODFLOW-ORG/modflow6/issues/2941
3+
4+
If a PRP package's STOPTIME precedes a particle's release time, the particle
5+
should not be released, but terminate immediately (status 8), with a warning.
6+
7+
Two configurations are tested, matching the two models attached to the issue:
8+
- rt: an explicit RELEASETIMES entry falls after STOPTIME
9+
- pd: a PERIOD block release (FIRST) fills forward into a later stress
10+
period whose start time falls after STOPTIME
11+
"""
12+
13+
import flopy
14+
import pandas as pd
15+
import pytest
16+
from framework import TestFramework
17+
from prt_test_utils import FlopyReadmeCase, get_model_name
18+
19+
simname = "prtstpb4"
20+
cases = [f"{simname}rt", f"{simname}pd"]
21+
22+
# release point matching cell (0, 0, 0) in the FlopyReadmeCase grid
23+
releasepts = [[0, 0, 0, 0, 0.1, 9.1, 0.5]]
24+
25+
26+
def build_prt_sim(name, gwf_ws, prt_ws, mf6):
27+
# create simulation
28+
sim = flopy.mf6.MFSimulation(
29+
sim_name=name,
30+
exe_name=mf6,
31+
version="mf6",
32+
sim_ws=prt_ws,
33+
)
34+
35+
# two stress periods so a later-period release time/step can fall
36+
# after STOPTIME, which is set to expire during the first period
37+
flopy.mf6.modflow.mftdis.ModflowTdis(
38+
sim,
39+
pname="tdis",
40+
time_units="DAYS",
41+
nper=2,
42+
perioddata=[
43+
(FlopyReadmeCase.perlen, FlopyReadmeCase.nstp, FlopyReadmeCase.tsmult),
44+
(FlopyReadmeCase.perlen, FlopyReadmeCase.nstp, FlopyReadmeCase.tsmult),
45+
],
46+
)
47+
48+
# create prt model
49+
prt_name = get_model_name(name, "prt")
50+
prt = flopy.mf6.ModflowPrt(sim, modelname=prt_name)
51+
52+
# create prt discretization
53+
flopy.mf6.modflow.mfgwfdis.ModflowGwfdis(
54+
prt,
55+
pname="dis",
56+
nlay=FlopyReadmeCase.nlay,
57+
nrow=FlopyReadmeCase.nrow,
58+
ncol=FlopyReadmeCase.ncol,
59+
top=FlopyReadmeCase.top,
60+
botm=FlopyReadmeCase.botm,
61+
)
62+
63+
# create mip package
64+
flopy.mf6.ModflowPrtmip(prt, pname="mip", porosity=FlopyReadmeCase.porosity)
65+
66+
# create prp package
67+
prp_track_file = f"{prt_name}.prp.trk"
68+
prp_track_csv_file = f"{prt_name}.prp.trk.csv"
69+
70+
if name.endswith("rt"):
71+
# release time (1.5) falls in period 2, after stoptime (0.5)
72+
perioddata = None
73+
nreleasetimes = 1
74+
releasetimes = [(1.5,)]
75+
else:
76+
# FIRST in period 1 releases at t=0.0 (before stoptime), but fills
77+
# forward into period 2, releasing again at t=1.0 (after stoptime)
78+
perioddata = {0: [("FIRST",)]}
79+
nreleasetimes = None
80+
releasetimes = None
81+
82+
flopy.mf6.ModflowPrtprp(
83+
prt,
84+
pname="prp1",
85+
filename=f"{prt_name}_1.prp",
86+
nreleasepts=len(releasepts),
87+
packagedata=releasepts,
88+
perioddata=perioddata,
89+
nreleasetimes=nreleasetimes,
90+
releasetimes=releasetimes,
91+
stoptime=0.5,
92+
track_filerecord=[prp_track_file],
93+
trackcsv_filerecord=[prp_track_csv_file],
94+
print_input=True,
95+
extend_tracking=True,
96+
)
97+
98+
# create output control package
99+
prt_track_file = f"{prt_name}.trk"
100+
prt_track_csv_file = f"{prt_name}.trk.csv"
101+
flopy.mf6.ModflowPrtoc(
102+
prt,
103+
pname="oc",
104+
track_filerecord=[prt_track_file],
105+
trackcsv_filerecord=[prt_track_csv_file],
106+
)
107+
108+
# create the flow model interface
109+
gwf_name = get_model_name(name, "gwf")
110+
gwf_budget_file = gwf_ws / f"{gwf_name}.bud"
111+
gwf_head_file = gwf_ws / f"{gwf_name}.hds"
112+
flopy.mf6.ModflowPrtfmi(
113+
prt,
114+
packagedata=[
115+
("GWFHEAD", gwf_head_file),
116+
("GWFBUDGET", gwf_budget_file),
117+
],
118+
)
119+
120+
# add explicit model solution
121+
ems = flopy.mf6.ModflowEms(
122+
sim,
123+
pname="ems",
124+
filename=f"{prt_name}.ems",
125+
)
126+
sim.register_solution_package(ems, [prt.name])
127+
128+
return sim
129+
130+
131+
def build_models(test):
132+
gwf_sim = FlopyReadmeCase.get_gwf_sim(
133+
test.name, test.workspace, test.targets["mf6"]
134+
)
135+
# GWF sim also needs 2 stress periods to match the PRT model's TDIS
136+
tdis = gwf_sim.get_package("tdis")
137+
tdis.nper = 2
138+
tdis.perioddata = [
139+
(FlopyReadmeCase.perlen, FlopyReadmeCase.nstp, FlopyReadmeCase.tsmult),
140+
(FlopyReadmeCase.perlen, FlopyReadmeCase.nstp, FlopyReadmeCase.tsmult),
141+
]
142+
prt_sim = build_prt_sim(
143+
test.name,
144+
test.workspace,
145+
test.workspace / "prt",
146+
test.targets["mf6"],
147+
)
148+
return gwf_sim, prt_sim
149+
150+
151+
def check_output(test):
152+
# expect warning that particles are unreleased as stop time < release time
153+
lst = " ".join((test.workspace / "prt" / "mfsim.lst").read_text().split())
154+
assert "particle will not be released" in lst
155+
156+
# particles scheduled after the package's stop time (0.5) should have just
157+
# a single event, termination with status 8 (permanently unreleased)
158+
prt_name = get_model_name(test.name, "prt")
159+
trk = pd.read_csv(test.workspace / "prt" / f"{prt_name}.prp.trk.csv")
160+
late = trk[trk["trelease"] > 0.5]
161+
assert len(late) == 1
162+
assert late.iloc[0]["istatus"] == 8
163+
164+
165+
@pytest.mark.parametrize("name", cases)
166+
def test_mf6model(name, function_tmpdir, targets):
167+
test = TestFramework(
168+
name=name,
169+
workspace=function_tmpdir,
170+
build=build_models,
171+
check=check_output,
172+
targets=targets,
173+
compare=None,
174+
)
175+
test.run()

doc/ReleaseNotes/develop.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,8 @@ description = "The PRT model's Particle Release Point (PRP) package performs rel
260260
section = "fixes"
261261
subsection = "model"
262262
description = "The PRT model did not consistently clamp particle coordinates to the extent of the cell before applying the tracking method. This could cause an incorrect exit face or travel time solution, for example due to roundoff in the coordinate transformations computed as the particle moves between cells. This has been corrected; coordinates will now be clamped to the cell boundary in all dimensions. Results may change slightly for models with particle positions lying close to cell boundaries."
263+
264+
[[items]]
265+
section = "fixes"
266+
subsection = "model"
267+
description = "PRT could encounter a floating point exception and crash while applying the tracking algorithm if any PRP package set STOPTIME prior to any of its particles' release times. This is invalid configuration, so terminate particles whose STOPTIME precedes release time immediately with status code 8 (unreleased), and show a warning."

src/Model/ParticleTracking/prt.f90

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ module PrtModule
2323
use ParticleTracksModule, only: ParticleTracksType, &
2424
ParticleTrackFileType, &
2525
add_particle_event
26-
use SimModule, only: count_errors, store_error, store_error_filename
26+
use SimModule, only: count_errors, store_error, store_error_filename, &
27+
store_warning
2728
use MemoryManagerModule, only: mem_allocate
2829
use MethodModule, only: MethodType, LEVEL_FEATURE
2930
use MethodDisModule, only: MethodDisType, create_method_dis
@@ -1046,6 +1047,7 @@ subroutine prt_solve(this, isuppress_output)
10461047
use PrtPrpModule, only: PrtPrpType
10471048
use ParticleModule, only: ACTIVE, TERM_UNRELEASED, TERM_TIMEOUT
10481049
use ParticleEventModule, only: RELEASE, TERMINATE
1050+
use SimVariablesModule, only: warnmsg
10491051
! dummy
10501052
class(PrtModelType) :: this
10511053
integer(I4B), intent(in) :: isuppress_output
@@ -1088,30 +1090,65 @@ subroutine prt_solve(this, isuppress_output)
10881090
end if
10891091
if (particle%istatus > ACTIVE) cycle ! Skip terminated particles
10901092
particle%istatus = ACTIVE ! Set active status in case of release
1091-
! If the particle was released this time step, emit a release event
1092-
if (particle%trelease >= totimc) call this%method%release(particle)
1093-
! Maximum time is the end of the time step or the particle
1094-
! stop time, whichever comes first, unless it's the final
1095-
! time step and the extend option is on, in which case
1096-
! it's just the particle stop time.
1097-
if (endofsimulation .and. particle%extend) then
1098-
tmax = particle%tstop
1099-
else
1100-
tmax = min(totimc + delt, particle%tstop)
1093+
if (particle%trelease >= totimc) then
1094+
if (particle%trelease > particle%tstop) then
1095+
! The package's stop time has already passed by the
1096+
! particle's release time (e.g. STOPTIME precedes
1097+
! RELEASETIMES or a period-block release time). The
1098+
! particle would never actually be tracked, so treat it
1099+
! as permanently unreleased rather than releasing it and
1100+
! immediately terminating it.
1101+
write (warnmsg, '(a,g0,a,g0,a,g0,a)') &
1102+
'Particle release point ', particle%irpt, ' has &
1103+
&release time ', particle%trelease, ' after package &
1104+
&stop time ', particle%tstop, '; particle will not &
1105+
&be released.'
1106+
call store_warning(warnmsg)
1107+
call this%method%terminate(particle, status=TERM_UNRELEASED)
1108+
else
1109+
! The particle was released this time step; emit a
1110+
! release event.
1111+
call this%method%release(particle)
1112+
end if
1113+
end if
1114+
if (particle%istatus <= ACTIVE) then
1115+
! Maximum time is the end of the time step or the particle
1116+
! stop time, whichever comes first, unless it's the final
1117+
! time step and the extend option is on, in which case
1118+
! it's just the particle stop time.
1119+
if (endofsimulation .and. particle%extend) then
1120+
tmax = particle%tstop
1121+
else
1122+
tmax = min(totimc + delt, particle%tstop)
1123+
end if
1124+
! tmax should never be less than the particle's current
1125+
! tracked time: ttrack can't get ahead of totimc, the
1126+
! smaller of the two terms tmax is drawn from, and a
1127+
! release whose time precedes the stop time was already
1128+
! caught above and never reaches this point. If it
1129+
! happens anyway, that's a programmer error: tracking
1130+
! methods assume a nonnegative time interval, and calling
1131+
! apply() with tmax < ttrack sends them a negative one,
1132+
! which corrupts the particle's path or, for large enough
1133+
! |tmax - ttrack|, overflows the analytic exponential
1134+
! formulas and crashes (see GitHub issue #2941).
1135+
if (tmax < particle%ttrack) &
1136+
call pstop(1, 'Programmer error: PRT tracking tmax &
1137+
&precedes particle%ttrack.')
1138+
! Apply the tracking method until the maximum time.
1139+
call this%method%apply(particle, tmax)
1140+
! If the particle timed out, terminate it.
1141+
! "Timed out" means it's still active but
1142+
! - it reached its stop time, or
1143+
! - the simulation is over.
1144+
! We can't detect timeout within the tracking
1145+
! method because the method just receives the
1146+
! maximum time with no context on what it is.
1147+
! TODO maybe think about changing that?
1148+
if (particle%istatus <= ACTIVE .and. &
1149+
(particle%ttrack == particle%tstop .or. endofsimulation)) &
1150+
call this%method%terminate(particle, status=TERM_TIMEOUT)
11011151
end if
1102-
! Apply the tracking method until the maximum time.
1103-
call this%method%apply(particle, tmax)
1104-
! If the particle timed out, terminate it.
1105-
! "Timed out" means it's still active but
1106-
! - it reached its stop time, or
1107-
! - the simulation is over.
1108-
! We can't detect timeout within the tracking
1109-
! method because the method just receives the
1110-
! maximum time with no context on what it is.
1111-
! TODO maybe think about changing that?
1112-
if (particle%istatus <= ACTIVE .and. &
1113-
(particle%ttrack == particle%tstop .or. endofsimulation)) &
1114-
call this%method%terminate(particle, status=TERM_TIMEOUT)
11151152
! Return the particle to the staging store
11161153
call packobj%particles_staging%put(particle, np)
11171154
end do

0 commit comments

Comments
 (0)