Skip to content

Commit 1dab4c7

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

3 files changed

Lines changed: 237 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 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: 57 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,61 @@ 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 is earlier than the release time.
1096+
! Terminate it permanently unreleased and show a warning.
1097+
write (warnmsg, '(a,g0,a,g0,a,g0,a)') &
1098+
'Particle release point ', particle%irpt, ' has &
1099+
&release time ', particle%trelease, ' after package &
1100+
&stop time ', particle%tstop, '; particle will not &
1101+
&be released.'
1102+
call store_warning(warnmsg)
1103+
call this%method%terminate(particle, status=TERM_UNRELEASED)
1104+
else
1105+
! The particle was released this time step; emit a
1106+
! release event.
1107+
call this%method%release(particle)
1108+
end if
1109+
end if
1110+
if (particle%istatus <= ACTIVE) then
1111+
! Maximum time is the end of the time step or the particle
1112+
! stop time, whichever comes first, unless it's the final
1113+
! time step and the extend option is on, in which case
1114+
! it's just the particle stop time.
1115+
if (endofsimulation .and. particle%extend) then
1116+
tmax = particle%tstop
1117+
else
1118+
tmax = min(totimc + delt, particle%tstop)
1119+
end if
1120+
! tmax should never be less than the particle's current
1121+
! tracked time: ttrack can't get ahead of totimc, the
1122+
! smaller of the two terms tmax is drawn from, and a
1123+
! release whose time precedes the stop time was already
1124+
! caught above and never reaches this point. If it
1125+
! happens anyway, that's a programmer error: tracking
1126+
! methods assume a nonnegative time interval, and calling
1127+
! apply() with tmax < ttrack sends them a negative one,
1128+
! which corrupts the particle's path or, for large enough
1129+
! |tmax - ttrack|, overflows the analytic exponential
1130+
! formulas and crashes (see GitHub issue #2941).
1131+
if (tmax < particle%ttrack) &
1132+
call pstop(1, 'Programmer error: PRT tracking tmax &
1133+
&precedes particle%ttrack.')
1134+
! Apply the tracking method until the maximum time.
1135+
call this%method%apply(particle, tmax)
1136+
! If the particle timed out, terminate it.
1137+
! "Timed out" means it's still active but
1138+
! - it reached its stop time, or
1139+
! - the simulation is over.
1140+
! We can't detect timeout within the tracking
1141+
! method because the method just receives the
1142+
! maximum time with no context on what it is.
1143+
! TODO maybe think about changing that?
1144+
if (particle%istatus <= ACTIVE .and. &
1145+
(particle%ttrack == particle%tstop .or. endofsimulation)) &
1146+
call this%method%terminate(particle, status=TERM_TIMEOUT)
11011147
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)
11151148
! Return the particle to the staging store
11161149
call packobj%particles_staging%put(particle, np)
11171150
end do

0 commit comments

Comments
 (0)