This page was generated from ex-gwe-prt.py. It's also available as a notebook.

Thermal Profile along a Particle Path

Using a Voronoi grid, this example demonstrates the combined use of GWF, GWE, and PRT. A steady flow field is established, followed by the establishment of a steady-state temperature field. Two particles are then routed through the model domain and the temperature of the particle is determined along the flow path.

Initial setup

Import dependencies, define the example name and workspace, and read settings from environment variables.

[1]:
import pathlib as pl
from pprint import pformat

import flopy
import git
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from flopy.discretization import VertexGrid
from flopy.mf6 import MFSimulation
from flopy.utils import GridIntersect
from flopy.utils.triangle import Triangle
from flopy.utils.voronoi import VoronoiGrid
from matplotlib.collections import LineCollection
from modflow_devtools.misc import get_env, timed
from scipy.interpolate import CloughTocher2DInterpolator
from shapely.geometry import LineString, Point

[2]:
# Example name and workspace paths. If this example is running
# in the git repository, use the folder structure described in
# the README. Otherwise just use the current working directory.
sim_name = "ex-gwe-prt"
gwf_name = sim_name + "-gwf"
gwe_name = sim_name + "-gwe"
prt_name = sim_name + "-prt"
try:
    root = pl.Path(git.Repo(".", search_parent_directories=True).working_dir)
except:
    root = None
data_path = root / "data" / sim_name if root else pl.Path.cwd()
workspace = root / "examples" if root else pl.Path.cwd()
figs_path = root / "figures" if root else pl.Path.cwd()
sim_ws = workspace / sim_name
gwf_ws = sim_ws / "gwf"
gwe_ws = sim_ws / "gwe"
prt_ws = sim_ws / "prt"
gwf_ws.mkdir(exist_ok=True, parents=True)
gwe_ws.mkdir(exist_ok=True, parents=True)
prt_ws.mkdir(exist_ok=True, parents=True)

# Settings from environment variables
write = get_env("WRITE", True)
run = get_env("RUN", True)
plot = get_env("PLOT", True)
plot_show = get_env("PLOT_SHOW", True)
plot_save = get_env("PLOT_SAVE", True)

Define parameters

Define model units, parameters and other settings.

[3]:
# Model units
length_units = "meters"
time_units = "days"
[4]:
# Model parameters
xmin = 0.0  # Left model domain extent ($m$)
xmax = 2000.0  # Right model domain extent ($m$)
ymin = 0.0  # South model domain extent ($m$)
ymax = 1000.0  # North model domain extent ($m$)
top = 1.0  # Top of the model grid ($m$)
botm = 0.0  # Bottom of the model grid ($m$)
angle_min = 30  # Minimum angle of interior angle of a Voronoi grid cell ($^{\circ}$)
area_max = 1000.0  # Maximum area of a Voronoi grid cell ($m^2$)
nlay = 1  # Number of layers ($-$)
porosity = 0.1  # Porosity ($-$)
strt_temp = 10.0  # Starting temperature of model domain ($^{\circ}C$)
scheme = "TVD"  # Advection scheme ($-$)
alh = 0.0  # Longitudinal mechanical dispersion term ($m$)
ath1 = 0.0  # Transverse mechanical dispersion term ($m$)
ktw = 0.56  # Thermal conductivity of water ($\frac{W}{m \cdot ^{\circ} C}$)
kts = 2.5  # Thermal conductivity of aquifer material ($\frac{W}{m \cdot ^{\circ} C}$)
rhow = 1000  # Density of water ($kg/m^3$)
cpw = 4180.0  # Heat capacity of water ($\frac{J}{kg \cdot ^{\circ} C}$)
rhos = 2650.0  # Density of dry solid aquifer material ($kg/m^3$)
cps = 900.0  # Heat capacity of dry solid aquifer material ($\frac{J}{kg \cdot ^{\circ} C}$)

# Values that do not show up in the table generated for latex
delr = area_max**0.5
ncol = xmax / delr
nrow = ymax / delr
botm = [botm]
nodes = ncol * nrow
lhv = 2500.0
rpts = [[20, i, 0.5] for i in range(1, 999, 20)]

# Convert kts and ktw units to use days
ktw = ktw * 86400
kts = kts * 86400

[5]:
# ### Solver Parameters
nouter = 1000
ninner = 200
hclose = 1e-6
rclose = 1e-6
relax = 1.0
[6]:
def get_grid(workspace):
    workspace.mkdir(exist_ok=True, parents=True)
    tri = Triangle(
        maximum_area=area_max,
        angle=angle_min,
        model_ws=workspace,
        exe_name="triangle",
    )
    poly = np.array(((xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)))
    tri.add_polygon(poly)
    tri.build(verbose=False)
    return VoronoiGrid(tri)
[7]:
def buildout_grid(ws):
    grid = get_grid(ws / "grid")
    vgrid = VertexGrid(**grid.get_gridprops_vertexgrid(), nlay=1)
    gi = GridIntersect(vgrid)

    return grid, vgrid, gi
[8]:
def build_gwf_sim(name):
    global cell2d

    # create grid
    grid, vgrid, gi = buildout_grid(gwf_ws)

    # identify cells on left edge
    line = LineString([(xmin, ymin), (xmin, ymax)])
    cells_left = gi.intersect(line)["cellids"]
    cells_left = np.array(list(cells_left))

    # identify cells on right edge
    line = LineString([(xmax, ymin), (xmax, ymax)])
    cells_right = gi.intersect(line)["cellids"]
    cells_right = np.array(list(cells_right))

    # identify cells on bottom edge
    line = LineString([(xmin, ymin), (xmax, ymin)])
    cells_bottom = gi.intersect(line)["cellids"]
    cells_bottom = np.array(list(cells_bottom))

    # identify well cell
    points = [Point((1200, 500)), Point((700, 200)), Point((1600, 700))]
    well_cells = [vgrid.intersect(p.x, p.y) for p in points]

    # create simulation
    sim = flopy.mf6.MFSimulation(
        sim_name=name, version="mf6", exe_name="mf6", sim_ws=gwf_ws
    )
    flopy.mf6.ModflowTdis(sim, time_units=time_units, perioddata=[[1.0, 1, 1.0]])
    gwf = flopy.mf6.ModflowGwf(sim, modelname=gwf_name, save_flows=True)
    flopy.mf6.ModflowIms(
        sim,
        print_option="SUMMARY",
        complexity="complex",
        outer_dvclose=1.0e-8,
        inner_dvclose=1.0e-8,
        filename=f"{gwf_name}.ims",
    )
    flopy.mf6.ModflowGwfdisv(
        gwf, nlay=nlay, **grid.get_disv_gridprops(), top=top, botm=botm
    )

    # k, j, q
    wells = [  # if 5.0 is changed to 0.05 TDIS must be 1e6 250 1.01
        [0, c, -0.05, 80.0] for c in well_cells
    ]
    wells[0][0] *= 100
    wells[2][-2] *= -100
    flopy.mf6.ModflowGwfwel(
        gwf,
        auxiliary="TEMPERATURE",
        maxbound=len(wells),
        save_flows=True,
        pname="WEL",
        stress_period_data={0: wells},
        filename=f"{gwf_name}.wel",
    )

    flopy.mf6.ModflowGwfnpf(
        gwf,
        xt3doptions=[(True)],
        k=10.0,
        save_saturation=True,
        save_specific_discharge=True,
        filename=f"{gwf_name}.npf",
    )
    flopy.mf6.ModflowGwfsto(
        gwf,
        ss=0,
        sy=0,
        steady_state={0: True},
        pname="STO",
        filename="{}.sto".format(gwf_name),
    )

    flopy.mf6.ModflowGwfic(gwf, strt=1.0, pname="IC", filename="{}.ic".format(gwf_name))

    flopy.mf6.ModflowGwfoc(
        gwf,
        budget_filerecord=f"{gwf_name}.cbc",
        head_filerecord=f"{gwf_name}.hds",
        saverecord=[("HEAD", "ALL"), ("BUDGET", "ALL")],
        printrecord=[("HEAD", "LAST"), ("BUDGET", "LAST")],
        filename="{}.oc".format(gwf_name),
    )

    chdlist = []
    icpl_seen = []
    cell2d = grid.get_disv_gridprops()["cell2d"]
    for scl, icpl in enumerate(cells_left):
        y_coord = cell2d[icpl][2]
        chdlist.append([(0, icpl), 2.0, 100.0 * y_coord / ymax])
        icpl_seen.append(icpl)
    for icpl in cells_right:
        chdlist.append([(0, icpl), 1.0, 0.0])
        icpl_seen.append(icpl)
    for icpl in cells_bottom:
        if icpl in icpl_seen:
            continue
        chdlist.append([(0, icpl), 1.8, 80.0])
    flopy.mf6.ModflowGwfchd(
        gwf,
        auxiliary="TEMPERATURE",
        stress_period_data=chdlist,
        pname="CHD",
        filename="{}.chd".format(gwf_name),
    )

    return sim
[9]:
def build_gwe_sim(name):
    grid, dummy, gi = buildout_grid(gwe_ws)

    sim = flopy.mf6.MFSimulation(sim_name=name, sim_ws=gwe_ws, exe_name="mf6")

    flopy.mf6.ModflowTdis(sim, time_units=time_units, perioddata=[[1.0e6, 1000, 1.003]])

    gwe = flopy.mf6.MFModel(
        sim,
        model_type="gwe6",
        modelname=gwe_name,
        model_nam_file="{}.nam".format(gwe_name),
    )
    imsgwe = flopy.mf6.ModflowIms(
        sim,
        print_option="SUMMARY",
        outer_dvclose=hclose,
        outer_maximum=nouter,
        under_relaxation="NONE",
        inner_maximum=ninner,
        inner_dvclose=hclose,
        rcloserecord=rclose,
        linear_acceleration="BICGSTAB",
        scaling_method="NONE",
        reordering_method="NONE",
        relaxation_factor=relax,
        filename=f"{gwe_name}.ims",
    )
    sim.register_ims_package(imsgwe, [gwe.name])

    flopy.mf6.ModflowGwedisv(
        gwe,
        nlay=nlay,
        **grid.get_disv_gridprops(),
        top=top,
        botm=botm,
        pname="DISV",
        filename="{}.disv".format(gwe_name),
    )
    flopy.mf6.ModflowGweic(
        gwe, strt=strt_temp, pname="IC", filename="{}.ic".format(gwe_name)
    )
    flopy.mf6.ModflowGweadv(
        gwe, scheme=scheme, pname="ADV", filename="{}.adv".format(gwe_name)
    )
    if ktw != 0:
        flopy.mf6.ModflowGwecnd(
            gwe,
            alh=alh,
            ath1=ath1,
            ktw=ktw,
            kts=kts,
            pname="CND-e",
            filename="{}.cnd".format(gwe_name),
        )

    flopy.mf6.ModflowGweest(
        gwe,
        porosity=porosity,
        cps=cps,
        rhos=rhos,
        packagedata=[cpw, rhow, lhv],
        pname="EST-e",
        filename="{}.est".format(gwe_name),
    )

    sourcerecarray = [
        ("WEL", "AUX", "TEMPERATURE"),
        ("CHD", "AUX", "TEMPERATURE"),
    ]
    flopy.mf6.ModflowGwessm(
        gwe, sources=sourcerecarray, pname="SSM-e", filename="{}.ssm".format(gwe_name)
    )
    flopy.mf6.ModflowGweoc(
        gwe,
        budget_filerecord="{}.cbc".format(gwe_name),
        temperature_filerecord="{}.ucn".format(gwe_name),
        temperatureprintrecord=[("COLUMNS", 10, "WIDTH", 15, "DIGITS", 6, "GENERAL")],
        saverecord={
            0: [("TEMPERATURE", "ALL"), ("BUDGET", "ALL")],
        },
        printrecord=[("TEMPERATURE", "LAST"), ("BUDGET", "LAST")],
    )

    pd = [
        (
            "GWFHEAD",
            pl.Path(f"../{gwf_ws.name}/{gwf_name}.hds"),
            None,
        ),
        (
            "GWFBUDGET",
            pl.Path(f"../{gwf_ws.name}/{gwf_name}.cbc"),
            None,
        ),
    ]
    flopy.mf6.ModflowGwefmi(gwe, packagedata=pd)

    return sim
[10]:
def build_prt_sim(name):
    # create grid
    grid, vgrid, gi = buildout_grid(prt_ws)

    # create simulation
    sim = flopy.mf6.MFSimulation(
        sim_name=name, version="mf6", exe_name="mf6", sim_ws=prt_ws
    )
    flopy.mf6.ModflowTdis(sim, time_units=time_units, perioddata=[[1.0, 1, 1.0]])
    prt = flopy.mf6.ModflowPrt(sim, modelname=prt_name)
    flopy.mf6.ModflowGwfdisv(
        prt, nlay=nlay, **grid.get_disv_gridprops(), top=top, botm=botm
    )
    flopy.mf6.ModflowPrtmip(prt, pname="mip", porosity=porosity)

    prpdata = [
        # index, (layer, cell index), x, y, z
        (i, (0, vgrid.intersect(p[0], p[1])), p[0], p[1], p[2])
        for i, p in enumerate([rpts[23], rpts[32]])  # first release point crashes
    ]
    prp_track_file = f"{prt_name}.prp.trk"
    prp_track_csv_file = f"{prt_name}.prp.trk.csv"
    flopy.mf6.ModflowPrtprp(
        prt,
        pname="prp1",
        filename=f"{prt_name}_1.prp",
        nreleasepts=len(prpdata),
        packagedata=prpdata,
        perioddata={0: ["FIRST"]},
        track_filerecord=[prp_track_file],
        trackcsv_filerecord=[prp_track_csv_file],
        boundnames=True,
        stop_at_weak_sink=True,  # currently required for this problem
        exit_solve_tolerance=1e-10,
    )
    prt_track_file = f"{prt_name}.trk"
    prt_track_csv_file = f"{prt_name}.trk.csv"
    flopy.mf6.ModflowPrtoc(
        prt,
        pname="oc",
        track_filerecord=[prt_track_file],
        trackcsv_filerecord=[prt_track_csv_file],
    )

    pd = [
        (
            "GWFHEAD",
            pl.Path(f"../{gwf_ws.name}/{gwf_name}.hds"),
            None,
        ),
        (
            "GWFBUDGET",
            pl.Path(f"../{gwf_ws.name}/{gwf_name}.cbc"),
            None,
        ),
    ]

    flopy.mf6.ModflowPrtfmi(
        prt,
        packagedata=pd,
    )
    ems = flopy.mf6.ModflowEms(
        sim,
        pname="ems",
        filename=f"{prt_name}.ems",
    )
    sim.register_solution_package(ems, [prt.name])

    return sim
[11]:
def build_models():
    gwf_sim = build_gwf_sim(sim_name)
    gwe_sim = build_gwe_sim(sim_name)
    prt_sim = build_prt_sim(sim_name)
    return gwf_sim, gwe_sim, prt_sim
[12]:
def write_models(*sims, silent=False):
    for sim in sims:
        if isinstance(sim, MFSimulation):
            sim.write_simulation(silent=silent)
        else:
            sim.write_input()
[13]:
@timed
def run_models(*sims, silent=False):
    for sim in sims:
        if isinstance(sim, MFSimulation):
            success, buff = sim.run_simulation(silent=silent, report=True)
        else:
            success, buff = sim.run_model(silent=silent, report=True)
        assert success, pformat(buff)
[14]:
def temp_interp(pls, temperatures):
    X = []
    Y = []
    Z = []
    # Match up X, Y locations with the state-steady temperature field
    for grdcell in cell2d:
        cellid = grdcell[0]
        X.append(grdcell[1])
        Y.append(grdcell[2])
        Z.append(temperatures[cellid])

    interp = CloughTocher2DInterpolator(list(zip(X, Y)), Z)
    partIDs = pls.name.unique()
    part1 = pls[pls["name"] == partIDs[0]].copy()
    part2 = pls[pls["name"] == partIDs[1]].copy()
    for index, row in part1.iterrows():
        x_prt = row["x"]
        y_prt = row["y"]
        therm = interp(x_prt, y_prt)
        part1.loc[index, "therm"] = therm

    for index, row in part2.iterrows():
        x_prt = row["x"]
        y_prt = row["y"]
        therm = interp(x_prt, y_prt)
        part2.loc[index, "therm"] = therm

    thermDF = pd.concat([part1, part2], ignore_index=True)

    return thermDF
[15]:
def plot_results(gwf_sim, gwe_sim, prt_sim, silent=True):
    # get gwf output
    gwf = gwf_sim.get_model()
    gwe = gwe_sim.get_model()
    temperatures = gwe.output.temperature().get_data()
    bdobj = gwf.output.budget()
    spdis = bdobj.get_data(text="DATA-SPDIS")[0]
    qx, qy, qz = flopy.utils.postprocessing.get_specific_discharge(spdis, gwf)

    # get prt output
    prt_track_csv_file = f"{prt_name}.trk.csv"
    pls = pd.read_csv(prt_ws / prt_track_csv_file, na_filter=False)

    pls = temp_interp(pls, temperatures[0, 0])
    plot_2d = True
    if plot_2d:
        fig, axes = plt.subplots(
            3,
            1,
            figsize=(6, 7),
            tight_layout=True,
            gridspec_kw={"height_ratios": [3, 1, 1]},
        )
        ax = axes[0]
        ax.set_xlim([0, 2000])
        ax.set_ylim([0, 1000])

        pmv = flopy.plot.PlotMapView(model=gwf, ax=ax)
        ax.set_aspect("equal")
        pmv.plot_grid(alpha=0.25)
        pmv.plot_ibound(alpha=0.5)
        tempmesh = pmv.plot_array(temperatures, alpha=0.7)
        cv = pmv.contour_array(temperatures, levels=np.linspace(0, 80, 9))
        plt.clabel(cv, colors="k")
        plt.colorbar(
            tempmesh,
            shrink=0.5,
            ax=ax,
            label="Temperature",
            location="bottom",
            fraction=0.1,
        )
        handles = [
            mpl.lines.Line2D(
                [0],
                [0],
                marker=">",
                linestyle="",
                label="Specific discharge",
                color="grey",
                markerfacecolor="gray",
            ),
        ]

        handles.append(
            mpl.lines.Line2D(
                [0],
                [0],
                marker="o",
                linestyle="",
                label="Well",
                markerfacecolor="red",
            ),
        )
        ax.legend(
            handles=handles,
            loc="lower right",
        )
        pmv.plot_vector(qx, qy, normalize=True, alpha=0.25)
        pmv.plot_bc(ftype="WEL")
        mf6_plines = pls.groupby(["iprp", "irpt", "trelease"])
        for ipl, ((iprp, irpt, trelease), pl) in enumerate(mf6_plines):
            pl.plot(
                # title=title,
                kind="line",
                linestyle="--",
                marker="o",
                markersize=0,
                x="x",
                y="y",
                xlabel="X",
                ylabel="Y",
                ax=ax,
                legend=False,
                color="blue",
            )
            if ipl == 0:
                ax.annotate(
                    "Particle 1",
                    xy=(1050, 380),
                    xycoords="data",
                    xytext=(30, -20),
                    textcoords="offset points",
                    bbox=dict(boxstyle="round", fc="1.0", alpha=0.66),
                    arrowprops=dict(
                        arrowstyle="->",
                        shrinkA=0,
                        shrinkB=5,
                        connectionstyle="angle,angleA=0,angleB=135,rad=40",
                    ),
                )
            else:
                ax.annotate(
                    "Particle 2",
                    xy=(1050, 610),
                    xycoords="data",
                    xytext=(-75, 10),
                    textcoords="offset points",
                    bbox=dict(boxstyle="round", fc="1.0", alpha=0.66),
                    arrowprops=dict(
                        arrowstyle="->",
                        shrinkA=0,
                        shrinkB=5,
                        connectionstyle="angle,angleA=0,angleB=135,rad=30",
                    ),
                )

        # Setup 2 additional plots relating the particle's x-position with temperature
        # and alternatively the total travel time with temperature.
        part1 = pls[pls["name"] == pls.name.unique()[0]][["x", "therm"]]
        part2 = pls[pls["name"] == pls.name.unique()[1]][["x", "therm"]]
        part1t = pls[pls["name"] == pls.name.unique()[0]][["t", "therm"]]
        part2t = pls[pls["name"] == pls.name.unique()[1]][["t", "therm"]]

        # Create a set of line segments so that they may be colored individually
        # This creates the points as a N x 1 x 2 array so that we can stack points
        # together easily to get the segments. The segments array for line collection
        # needs to be (numlines) x (points per line) x 2 (for x and y)
        points1 = np.array([part1["x"], part1["therm"]]).T.reshape(-1, 1, 2)
        points1t = np.array([part1t["t"], part1["therm"]]).T.reshape(-1, 1, 2)
        points2 = np.array([part2["x"], part2["therm"]]).T.reshape(-1, 1, 2)
        points2t = np.array([part2t["t"], part2["therm"]]).T.reshape(-1, 1, 2)
        segments1 = np.concatenate([points1[:-1], points1[1:]], axis=1)
        segments1t = np.concatenate([points1t[:-1], points1t[1:]], axis=1)
        segments2 = np.concatenate([points2[:-1], points2[1:]], axis=1)
        segments2t = np.concatenate([points2t[:-1], points2t[1:]], axis=1)

        # Create a continuous norm to map from data points to colors
        pad = 5
        norm = plt.Normalize(
            np.min([part1["therm"].min(), part2["therm"].min()]) - pad,
            np.max([part1["therm"].max(), part2["therm"].max()]) + pad,
        )
        lc1 = LineCollection(segments1, norm=norm)
        lc1t = LineCollection(segments1t, norm=norm)
        lc2 = LineCollection(segments2, norm=norm)
        lc2t = LineCollection(segments2t, norm=norm)

        # Set the values used for colormapping
        lc1.set_array(part1["therm"])
        lc1t.set_array(part1t["therm"])
        lc2.set_array(part2["therm"])
        lc2t.set_array(part2t["therm"])
        lc1.set_linewidth(3)
        lc1t.set_linewidth(3)
        lc2.set_linewidth(3)
        lc2t.set_linewidth(3)
        line1 = axes[1].add_collection(lc1)
        line2 = axes[1].add_collection(lc2)
        axes[1].annotate("Particle 2", xy=(400, 68), xycoords="data")
        axes[1].annotate("Particle 1", xy=(400, 50), xycoords="data")
        axes[1].set_xlabel("X ($m$)")
        axes[1].set_xlim(0, 2000)
        xticks = np.arange(0, 2100, 250)
        axes[1].set_xticks(xticks)
        axes[1].set_ylabel(r"Temperature, $^{\circ}C$")
        axes[1].set_ylim(40, 80.0)
        yticks = np.arange(40, 81, 10)
        axes[1].set_yticks(yticks)

        # Third plot - particle travel time vs temperature
        line1t = axes[2].add_collection(lc1t)
        line2t = axes[2].add_collection(lc2t)
        axes[2].annotate("Particle 2", xy=(15000, 68), xycoords="data")
        axes[2].annotate("Particle 1", xy=(15000, 50), xycoords="data")
        axes[2].set_xlabel("Time ($days$)")
        axes[2].set_xlim(0, np.max([part1t["t"].max(), part2t["t"].max()]))
        axes[2].set_ylabel(r"Temperature, $^{\circ}C$")
        axes[2].set_ylim(40.0, 80.0)
        axes[2].set_yticks(yticks)

        # show and/or save figure
        if plot_show:
            plt.show()
        if plot_save:
            fpth = figs_path / "{}{}".format(sim_name, ".png")
            fig.savefig(fpth, dpi=600)

Running the example

Define a function to run the example scenarios and plot results.

[16]:
def scenario(silent=False):
    gwf_sim, gwe_sim, prt_sim = build_models()
    if write:
        write_models(gwf_sim, gwe_sim, prt_sim, silent=silent)
    if run:
        run_models(gwf_sim, gwe_sim, prt_sim, silent=silent)
    if plot:
        plot_results(gwf_sim, gwe_sim, prt_sim, silent=silent)

Run the scenario.

[17]:
scenario(silent=True)
run_models took 30024.48 ms
../_images/_notebooks_ex-gwe-prt_22_1.png