Particle Tracking with PRT

Setup

Packages

from pathlib import Path
import flopy as fp
import numpy as np
import geopandas as gpd
import xarray as xr
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib as mpl

import nlmod

Logging

nlmod.util.get_color_logger("INFO")
nlmod.show_versions()
Python version     : 3.11.14
NumPy version      : 2.4.4
Xarray version     : 2026.4.0
Matplotlib version : 3.10.9
Flopy version      : 3.10.0

nlmod version      : 0.11.3dev

Load base model

model_ws = Path("../examples/00_model_from_scratch")
model_name = "from_scratch"

ds = xr.open_dataset(model_ws / f"{model_name}.nc")
ds.attrs["model_ws"] = model_ws

# constants
wells = pd.DataFrame(
    [[100, -50, -5, -10, -100.0], [200, 150, -20, -30, -300.0]],
    columns=["x", "y", "top", "botm", "Q"],
    index=pd.Index([0, 1], name="well no."),
)
xyriv = [
    (250.0, -500.0),
    (300.0, -300.0),
    (275.0, 0.0),
    (200.0, 250.0),
    (175.0, 500.0),
]

PRT

Forward

# create new subdir for PRT model
prt_ws = Path(model_ws) / "prt_fw"

# Create a new Simulation object
simprt = nlmod.sim.sim(ds, sim_ws=prt_ws)
_ = nlmod.sim.tdis(ds, simprt)

# Add PRT model
prt = nlmod.prt.prt(ds, simprt)

# DIS: discretization package
_ = nlmod.prt.dis(ds, prt)

# MIP: Model Input Package
_ = nlmod.prt.mip(ds, prt, porosity=0.3)

# PRP: particle release point package
# define particle release point  every 11th cell in the first layer
pdata = fp.modpath.ParticleData(
    [
        (k, i, j)
        for i in np.arange(0, ds.sizes["y"], 11)
        for j in np.arange(0, ds.sizes["x"], 11)
        for k in [0]
    ],
    structured=True,
)
release_pts = list(pdata.to_prp(prt.modelgrid, global_xy=False))
prp = nlmod.prt.prp(ds, prt, packagedata=release_pts, perioddata={0: ["FIRST"]})

# FMI: flow model interface
fmi = nlmod.prt.fmi(ds, prt)

# OC: output control
trackcsvfile_prt = f"{prt.name}.trk.csv"
oc = nlmod.prt.oc(
    ds,
    prt,
    trackcsv_filerecord=trackcsvfile_prt,
)

# EMS: explicit model solution
ems = nlmod.sim.ems(simprt, model=prt)
INFO:nlmod.sim.sim.sim:creating mf6 SIM
INFO:nlmod.sim.sim.tdis:creating mf6 TDIS
INFO:nlmod.prt.prt.prt:creating mf6 PRT
INFO:nlmod.gwf.gwf._dis:creating mf6 DIS
INFO:nlmod.prt.prt.mip:creating mf6 MIP
INFO:nlmod.prt.prt.prp:creating mf6 PRP
INFO:nlmod.prt.prt.fmi:creating mf6 FMI
INFO:nlmod.prt.prt.oc:creating mf6 OC
simprt.write_simulation(silent=False)
simprt.run_simulation(silent=False);
writing simulation...
  writing simulation name file...
  writing simulation tdis package...
  writing solution package ems...
  writing model from_scratch_prt...
    writing model name file...
    writing package dis...
    writing package mip...
    writing package prp...
    writing package fmi...
    writing package oc...
FloPy is using the following executable to run the model: ../../../../../../envs/latest/lib/python3.11/site-packages/nlmod/bin/mf6
                                   MODFLOW 6
                U.S. GEOLOGICAL SURVEY MODULAR HYDROLOGIC MODEL
                            VERSION 6.6.3 09/29/2025

   MODFLOW 6 compiled Oct 07 2025 22:51:46 with Intel(R) Fortran Intel(R) 64
   Compiler Classic for applications running on Intel(R) 64, Version 2021.7.0
                             Build 20220726_000000

This software has been approved for release by the U.S. Geological 
Survey (USGS). Although the software has been subjected to rigorous 
review, the USGS reserves the right to update the software as needed 
pursuant to further analysis and review. No warranty, expressed or 
implied, is made by the USGS or the U.S. Government as to the 
functionality of the software and related material nor shall the 
fact of release constitute any such warranty. Furthermore, the 
software is released on condition that neither the USGS nor the U.S. 
Government shall be held liable for any damages resulting from its 
authorized or unauthorized use. Also refer to the USGS Water 
Resources Software User Rights Notice for complete use, copyright, 
and distribution information.

 
 MODFLOW runs in SEQUENTIAL mode
 
 Run start date and time (yyyy/mm/dd hh:mm:ss): 2026/05/13 15:17:28
 
 Writing simulation list file: mfsim.lst
 Using Simulation name file: mfsim.nam
    Solving:  Stress period:     1    Time step:     1
 
 Run end date and time (yyyy/mm/dd hh:mm:ss): 2026/05/13 15:17:28
 Elapsed run time:  0.147 Seconds
 
 Normal termination of simulation.

Load pathline data

The read_pathlines function is a wrapper around pandas.read_csv but it adds the particle id for you. Additionallly it is usefull as documentation on the different istatus and ireason types.

nlmod.prt.read_pathlines?
pathlines_fw = nlmod.prt.read_pathlines(prt_ws / trackcsvfile_prt)
pathlines_fw.head()
pid kper kstp imdl iprp irpt ilay icell izone istatus ireason trelease t x y z name
0 0 1 1 1 1 1 0 0 0 1 0 0.0 0.000000e+00 5.000000 995.000000 -5.000000 NaN
1 0 1 1 1 1 1 0 0 0 1 1 0.0 4.954887e+05 1.404571 990.000000 -8.899515 NaN
2 0 1 1 1 1 1 0 100 0 1 1 0.0 5.949531e+05 1.096169 988.379639 -10.000000 NaN
3 0 1 1 1 1 1 1 10100 0 1 1 0.0 1.009588e+06 1.088284 988.279846 -15.000000 NaN
4 0 1 1 1 1 1 2 20100 0 1 1 0.0 1.097697e+06 0.849508 980.000000 -16.053911 NaN

Plot results

f, ax = plt.subplots(figsize=(8, 8))
pmv = fp.plot.PlotMapView(prt, ax=ax)
pmv.plot_grid(lw=0.1, color="k")
pmv.plot_pathline(
    pathlines_fw,
    layer="all",
    colors="0.7",
    lw=1.0,
)
for ilay in [0, 1, 2]:
    mask = pathlines_fw["ilay"] == ilay
    pmv.plot_pathline(
        pathlines_fw.loc[mask],
        layer=ilay - 3,
        lw=0.0,
        marker=".",
        ms=4,
        markercolor=f"C{ilay}",
        markerevery=5,
    )
pmv.plot_endpoint(pathlines_fw, color="k", marker=".", direction="starting", zorder=10)


# plot river and wells
def plot_river_and_wells(ax: plt.Axes):
    ax.plot(
        [xy[0] for xy in xyriv],
        [xy[1] for xy in xyriv],
        "b-",
        lw=2,
        label="river",
    )
    ax.plot(wells["x"].values, wells["y"].values, "rs", ms=5)


plot_river_and_wells(ax=pmv.ax)
handles = [
    pmv.ax.plot([], [], "C0.", ms=5, label="Layer 0")[0],
    pmv.ax.plot([], [], "C1.", ms=5, label="Layer 1")[0],
    pmv.ax.plot([], [], "C2.", ms=5, label="Layer 2")[0],
]
labels = [h.get_label() for h in handles]
pmv.ax.legend(handles, labels, loc=(0, 1), frameon=False, ncol=3)
pmv.ax.set_xlabel("X [m]")
pmv.ax.set_ylabel("Y [m]");
../_images/a0867d0a58c3e622441fb6f9e2dff2c5b2bd64698f6934ac3979992d8adaabe8.png

Backward and refined

Refine and run gwf model

refinement_extent = [0.0, 300.0, -100.0, 200.0]
refinement_features = (
    [
        nlmod.util.extent_to_polygon(refinement_extent),
    ],
    "polygon",  # type of feature
    1,  # refinement level
)
dsr = nlmod.grid.refine(ds, refinement_features=[refinement_features])
INFO:nlmod.dims.grid.refine:create vertex grid using gridgen
INFO:nlmod.dims.grid.ds_to_gridprops:resample model Dataset to vertex modelgrid
dsr.attrs["model_name"] = f"{ds.attrs['model_name']}_ref"
dsr.attrs["model_ws"] = f"./{ds.attrs['model_ws']}_ref"
sim = nlmod.sim.sim(dsr)
tdis = nlmod.sim.tdis(dsr, sim)
ims = nlmod.sim.ims(sim, complexity="SIMPLE")
gwf = nlmod.gwf.gwf(dsr, sim)
disv = nlmod.gwf.disv(dsr, gwf)
npf = nlmod.gwf.npf(
    dsr, gwf, save_flows=True, save_specific_discharge=True, save_saturation=True
)
ic = nlmod.gwf.ic(dsr, gwf, starting_head=1.0)
oc = nlmod.gwf.oc(dsr, gwf, save_head=True)
wel = nlmod.gwf.wells.wel_from_df(wells, gwf)

riv_layer = 0  # add to first layer
bed_resistance = 0.1  # days
riv_stage = 1.0  # m NAP
riv_botm = -3.0  # m NAP
riv_data = nlmod.gwf.surface_water.rivdata_from_xylist(
    gwf, xyriv, riv_layer, riv_stage, bed_resistance, riv_botm
)


def get_riv_cond_from_cid(icell2d: int, ds: xr.Dataset, bed_resistance: float) -> float:
    """Get river conductance from cell id."""
    area = ds.area.sel(icell2d=icell2d).values
    # calculate conductance
    return area / bed_resistance


riv_datac = [
    [x[0], x[1], get_riv_cond_from_cid(x[0][1], dsr, x[2]), x[3]] for x in riv_data
]
riv = fp.mf6.ModflowGwfriv(gwf, stress_period_data={0: riv_datac})

nlmod.sim.write_and_run(sim, dsr, silent=False)
INFO:nlmod.sim.sim.sim:creating mf6 SIM
INFO:nlmod.sim.sim.tdis:creating mf6 TDIS
INFO:nlmod.sim.sim.ims:creating mf6 IMS
INFO:nlmod.gwf.gwf.gwf:creating mf6 GWF
INFO:nlmod.gwf.gwf._disv:creating mf6 DISV
INFO:nlmod.gwf.gwf.npf:creating mf6 NPF
INFO:nlmod.gwf.gwf.ic:creating mf6 IC
INFO:nlmod.gwf.gwf.ic:adding 'starting_head' data array to ds
INFO:nlmod.gwf.gwf.oc:creating mf6 OC
INFO:nlmod.sim.sim.write_and_run:write model dataset to cache
INFO:nlmod.sim.sim.write_and_run:write modflow files to model workspace
writing simulation...
  writing simulation name file...
  writing simulation tdis package...
  writing solution package ims...
  writing model from_scratch_ref...
    writing model name file...
    writing package disv...
    writing package npf...
    writing package ic...
    writing package oc...
    writing package wel_0...
INFORMATION: maxbound in ('', 'wel', 'dimensions') changed to 2 based on size of stress_period_data
    writing package riv_0...
INFORMATION: maxbound in ('', 'riv', 'dimensions') changed to 139 based on size of stress_period_data
INFO:nlmod.sim.sim.write_and_run:run model
FloPy is using the following executable to run the model: ../../../../../envs/latest/lib/python3.11/site-packages/nlmod/bin/mf6
                                   MODFLOW 6
                U.S. GEOLOGICAL SURVEY MODULAR HYDROLOGIC MODEL
                            VERSION 6.6.3 09/29/2025

   MODFLOW 6 compiled Oct 07 2025 22:51:46 with Intel(R) Fortran Intel(R) 64
   Compiler Classic for applications running on Intel(R) 64, Version 2021.7.0
                             Build 20220726_000000

This software has been approved for release by the U.S. Geological 
Survey (USGS). Although the software has been subjected to rigorous 
review, the USGS reserves the right to update the software as needed 
pursuant to further analysis and review. No warranty, expressed or 
implied, is made by the USGS or the U.S. Government as to the 
functionality of the software and related material nor shall the 
fact of release constitute any such warranty. Furthermore, the 
software is released on condition that neither the USGS nor the U.S. 
Government shall be held liable for any damages resulting from its 
authorized or unauthorized use. Also refer to the USGS Water 
Resources Software User Rights Notice for complete use, copyright, 
and distribution information.

 
 MODFLOW runs in SEQUENTIAL mode
 
 Run start date and time (yyyy/mm/dd hh:mm:ss): 2026/05/13 15:17:34
 
 Writing simulation list file: mfsim.lst
 Using Simulation name file: mfsim.nam
 
    Solving:  Stress period:     1    Time step:     1
 
 Run end date and time (yyyy/mm/dd hh:mm:ss): 2026/05/13 15:17:35
 Elapsed run time:  0.463 Seconds
 
 Normal termination of simulation.
Intersecting with grid:   0%|          | 0/2 [00:00<?, ?it/s]
Intersecting with grid: 100%|██████████| 2/2 [00:00<00:00, 283.25it/s]

Reverse heads and cellbudget files

hds = nlmod.gwf.get_headfile(ds=dsr, gwf=gwf, tdis=tdis)
hds_bw_path = hds.filename.parent / f"{hds.filename.stem}_bkwd{hds.filename.suffix}"
hds.reverse(hds_bw_path)

cbc = nlmod.gwf.get_cellbudgetfile(ds=dsr, gwf=gwf, tdis=tdis)
cbc_bw_path = cbc.filename.parent / f"{cbc.filename.stem}_bkwd{cbc.filename.suffix}"
cbc.reverse(cbc_bw_path)

Create particle starts list

Start a particle in each (active) cell in refined extent

dsr["z"] = (dsr["top"] + dsr["botm"]) / 2
dsr["idomain"] = (
    ("layer", "icell2d"),
    disv.idomain.array,
)  # make sure to get idomain otherwhise you might start particles in inactive cells and everything crashes #beentheredonethat

# map xy to cellids
nodes = dsr[["z", "idomain"]].where(
    (dsr.x >= refinement_extent[0])
    & (dsr.x <= refinement_extent[1])
    & (dsr.y >= refinement_extent[2])
    & (dsr.y <= refinement_extent[3]),
    drop=True,
)
ix = fp.utils.GridIntersect(gwf.modelgrid)
spatial_join = gpd.GeoDataFrame(
    geometry=gpd.points_from_xy(nodes.x, nodes.y),
).sjoin(
    gpd.GeoDataFrame(
        {"cellid": ix.cellids},
        geometry=ix.geoms,
    ),
    how="left",
)

# create release points
# define particle release point for every cell within refined extent
pid = 0
release_pts = []
for ilay in range(nodes.sizes["layer"]):
    nodes_lay = nodes.isel(layer=ilay)
    active = (nodes_lay["idomain"] > 0).values
    j = spatial_join["cellid"].values.astype(int)[active]
    k = np.full_like(j, ilay, dtype=int)
    x = nodes_lay["x"].values[active]
    y = nodes_lay["y"].values[active]
    z = nodes_lay["z"].values[active]
    pids = np.arange(pid, pid + len(k), dtype=int)
    pid += len(k)
    cellid = tuple(zip(k, j))
    release_ptsi = list(zip(pids, cellid, x, y, z))
    release_pts += release_ptsi

print(f"Number of release points: {len(release_pts)}")
Number of release points: 10800
# create new subdir for PRT backwards model
prt_ws_bw = Path(dsr.attrs["model_ws"]) / "prt_bw"

# Create a new Simulation object
simprt_bw = nlmod.sim.sim(dsr, sim_ws=prt_ws_bw)
_ = nlmod.sim.tdis(dsr, simprt_bw)

# Add PRT model
prt_bw = nlmod.prt.prt(dsr, simprt_bw, modelname=dsr.model_name)

# Add DISV model
_ = nlmod.prt.disv(dsr, prt_bw)

# MIP: Model Input Package
_ = nlmod.prt.mip(dsr, prt_bw, porosity=0.3)

# PRP: particle release package
_ = nlmod.prt.prp(
    dsr,
    prt_bw,
    packagedata=release_pts,
    perioddata={0: ["FIRST"]},
)

# OC: output control
trackcsvfile_prt_bw = f"{prt_bw.name}bw.trk.csv"
_ = nlmod.prt.oc(dsr, prt_bw, trackcsv_filerecord=trackcsvfile_prt_bw)

# FMI: flow model interface
fmi_bw_packagedata = [
    ("GWFHEAD", hds_bw_path),
    ("GWFBUDGET", cbc_bw_path),
]
_ = nlmod.prt.fmi(dsr, prt_bw, packagedata=fmi_bw_packagedata)

# EMS: explicit model solution
_ = nlmod.sim.ems(simprt_bw, model=prt_bw)

# Write and run the simulation
simprt_bw.write_simulation()
success_bw_prt, _ = simprt_bw.run_simulation()
INFO:nlmod.sim.sim.sim:creating mf6 SIM
INFO:nlmod.sim.sim.tdis:creating mf6 TDIS
INFO:nlmod.prt.prt.prt:creating mf6 PRT
INFO:nlmod.gwf.gwf._disv:creating mf6 DISV
INFO:nlmod.prt.prt.mip:creating mf6 MIP
INFO:nlmod.prt.prt.prp:creating mf6 PRP
INFO:nlmod.prt.prt.oc:creating mf6 OC
INFO:nlmod.prt.prt.fmi:creating mf6 FMI
writing simulation...
  writing simulation name file...
  writing simulation tdis package...
  writing solution package ems...
  writing model from_scratch_ref...
    writing model name file...
    writing package disv...
    writing package mip...
    writing package prp...
    writing package oc...
    writing package fmi...
FloPy is using the following executable to run the model: ../../../../../../envs/latest/lib/python3.11/site-packages/nlmod/bin/mf6
                                   MODFLOW 6
                U.S. GEOLOGICAL SURVEY MODULAR HYDROLOGIC MODEL
                            VERSION 6.6.3 09/29/2025

   MODFLOW 6 compiled Oct 07 2025 22:51:46 with Intel(R) Fortran Intel(R) 64
   Compiler Classic for applications running on Intel(R) 64, Version 2021.7.0
                             Build 20220726_000000

This software has been approved for release by the U.S. Geological 
Survey (USGS). Although the software has been subjected to rigorous 
review, the USGS reserves the right to update the software as needed 
pursuant to further analysis and review. No warranty, expressed or 
implied, is made by the USGS or the U.S. Government as to the 
functionality of the software and related material nor shall the 
fact of release constitute any such warranty. Furthermore, the 
software is released on condition that neither the USGS nor the U.S. 
Government shall be held liable for any damages resulting from its 
authorized or unauthorized use. Also refer to the USGS Water 
Resources Software User Rights Notice for complete use, copyright, 
and distribution information.

 
 MODFLOW runs in SEQUENTIAL mode
 
 Run start date and time (yyyy/mm/dd hh:mm:ss): 2026/05/13 15:17:36
 
 Writing simulation list file: mfsim.lst
 Using Simulation name file: mfsim.nam
 
    Solving:  Stress period:     1    Time step:     1
 
 Run end date and time (yyyy/mm/dd hh:mm:ss): 2026/05/13 15:17:42
 Elapsed run time:  5.590 Seconds
 
 Normal termination of simulation.

Load pathline data

pathlines_bw = nlmod.prt.read_pathlines(
    prt_ws_bw / trackcsvfile_prt_bw, icell2d=dsr.icell2d.size
)

Get pathlines of particles that started in a well (backward)

pathlines_bw0 = pathlines_bw.query("ireason==0")
well_pids = []
for _, row in wel.stress_period_data.dataframe[0].iterrows():
    cellid_layer = row["cellid_layer"]
    icell2d = row["cellid_cell"]
    well_pids.append(
        pathlines_bw0.query(f"ilay=={cellid_layer} and icell2d=={icell2d}")
        .loc[:, "pid"]
        .to_numpy()[0]
    )
pathlines_bww = pathlines_bw[pathlines_bw["pid"].isin(well_pids)]

Plot results

f, ax = plt.subplots(figsize=(8, 8))
for pid, gr in pathlines_bww.groupby("pid"):
    for ilay, gr_lay in gr.groupby("ilay"):
        ax.plot(gr_lay["x"], gr_lay["y"], lw=1.0, color=f"C{ilay}", zorder=1)

nlmod.plot.modelgrid(dsr, ax=ax, linewidth=0.1)
plot_river_and_wells(ax=ax)
ax.set_xlim(ds.extent[0], ds.extent[1])
ax.set_ylim(ds.extent[2], ds.extent[3])
ax.set_aspect("equal")
ax.set_xlabel("X [m]")
_ = ax.set_ylabel("Y [m]")
../_images/765a936f3f8933589dc8fdee1f5e7505d9b366256d4db35577024bbc47dc6210.png
f, axes = plt.subplots(3, 1, figsize=(8, 6), sharex=True, sharey=True)

for pid, gr in pathlines_bw.groupby("pid"):
    t0 = gr["t"].idxmin()
    x0, y0, z0, ilay0 = gr.loc[t0, ["x", "y", "z", "ilay"]]
    distance = np.sqrt((gr["x"] - x0) ** 2 + (gr["y"] - y0) ** 2 + (gr["z"] - z0) ** 2)
    if pid in well_pids:
        plot_kwargs = {"color": "k", "alpha": 1.0, "zorder": 10, "label": f"well {pid}"}
    else:
        plot_kwargs = {"color": f"C{ilay0}", "alpha": 0.01}
    axes[ilay0].plot(gr["t"] / 365.25, distance, **plot_kwargs)

for i, ax in enumerate(axes):
    ax.xaxis.set_major_locator(mpl.ticker.MultipleLocator(250))
    ax.yaxis.set_major_locator(mpl.ticker.MultipleLocator(100))
    ax.set_ylabel("distance [m]")
    ax.set_title(f"Layer {i}")
    ax.grid(True)
    handles, labels = ax.get_legend_handles_labels()
    if labels:
        ax.legend(handles, labels, loc="lower right", ncol=1, fontsize=8, frameon=True)
    ax.set_xlim(0.0, 3000.0)
    ax.set_ylim(0.0, 700.0)
_ = axes[-1].set_xlabel("time [year]")
../_images/327715c4e1e516edf6c93c66dd5e59eafd5120fb323fed3a8a1281cd0b9d4300.png