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

USG1DISU example

This example, ex-gwf-radial, shows how the MODFLOW 6 DISU Package can be used to simulate an axisymmetric radial model.

The example corresponds to the first example described in: Bedekar, V., Scantlebury, L., and Panday, S. (2019). Axisymmetric Modeling Using MODFLOW-USG.Groundwater, 57(5), 772-777.

And the numerical result is compared against the analytical solution presented in Equation 17 of Neuman, S. P. (1974). Effect of partial penetration on flow in unconfined aquifers considering delayed gravity response. Water resources research, 10(2), 303-312

Initial setup

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

[1]:
import os
import pathlib as pl
from math import sqrt

import flopy
import git
import matplotlib.pyplot as plt
import numpy as np
from flopy.plot.styles import styles
from matplotlib.patches import Circle
from modflow_devtools.misc import get_env, timed

# Solve definite integral using Fortran library QUADPACK
from scipy.integrate import quad

# Find a root of a function using Brent's method within a bracketed range
from scipy.optimize import brentq

# Zero Order Bessel Function
from scipy.special import j0, jn_zeros

# 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-gwf-rad-disu"
try:
    root = pl.Path(git.Repo(".", search_parent_directories=True).working_dir)
except:
    root = None
workspace = root / "examples" if root else pl.Path.cwd()
figs_path = root / "figures" if root else pl.Path.cwd()

# 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 some utilities for creating the grid and solving the radial solution

[2]:
# Radial unconfined drawdown solution from Neuman 1974
pi = 3.141592653589793
sin = np.sin
cos = np.cos
sinh = np.sinh
cosh = np.cosh
exp = np.exp


def get_disu_radial_kwargs(
    nlay,
    nradial,
    radius_outer,
    surface_elevation,
    layer_thickness,
    get_vertex=False,
):
    """
    Simple utility for creating radial unstructured elements
    with the disu package.

    Input assumes that each layer contains the same radial band,
    but their thickness can be different.

    Parameters
    ----------
    nlay: number of layers (int)
    nradial: number of radial bands to construct (int)
    radius_outer: Outer radius of each radial band (array-like float with nradial length)
    surface_elevation: Top elevation of layer 1 as either a float or nradial array-like float values.
                       If given as float, then value is replicated for each radial band.
    layer_thickness: Thickness of each layer as either a float or nlay array-like float values.
                     If given as float, then value is replicated for each layer.
    """
    pi = 3.141592653589793

    def get_nn(lay, rad):
        return nradial * lay + rad

    def get_rad_array(var, rep):
        try:
            dim = len(var)
        except:
            dim, var = 1, [var]

        if dim != 1 and dim != rep:
            raise IndexError(
                f"get_rad_array(var): var must be a scalar or have len(var)=={rep}"
            )

        if dim == 1:
            return np.full(rep, var[0], dtype=np.float64)
        else:
            return np.array(var, dtype=np.float64)

    nodes = nlay * nradial
    surf = get_rad_array(surface_elevation, nradial)
    thick = get_rad_array(layer_thickness, nlay)

    iac = np.zeros(nodes, dtype=int)
    ja = []
    ihc = []
    cl12 = []
    hwva = []

    area = np.zeros(nodes, dtype=float)
    top = np.zeros(nodes, dtype=float)
    bot = np.zeros(nodes, dtype=float)

    for lay in range(nlay):
        st = nradial * lay
        sp = nradial * (lay + 1)
        top[st:sp] = surf - thick[:lay].sum()
        bot[st:sp] = surf - thick[: lay + 1].sum()

    for lay in range(nlay):
        for rad in range(nradial):
            # diagonal/self
            n = get_nn(lay, rad)
            ja.append(n)
            iac[n] += 1
            if rad > 0:
                area[n] = pi * (radius_outer[rad] ** 2 - radius_outer[rad - 1] ** 2)
            else:
                area[n] = pi * radius_outer[rad] ** 2
            ihc.append(n + 1)
            cl12.append(n + 1)
            hwva.append(n + 1)
            # up
            if lay > 0:
                ja.append(n - nradial)
                iac[n] += 1
                ihc.append(0)
                cl12.append(0.5 * (top[n] - bot[n]))
                hwva.append(area[n])
            # to center
            if rad > 0:
                ja.append(n - 1)
                iac[n] += 1
                ihc.append(1)
                cl12.append(0.5 * (radius_outer[rad] - radius_outer[rad - 1]))
                hwva.append(2.0 * pi * radius_outer[rad - 1])

            # to outer
            if rad < nradial - 1:
                ja.append(n + 1)
                iac[n] += 1
                ihc.append(1)
                hwva.append(2.0 * pi * radius_outer[rad])
                if rad > 0:
                    cl12.append(0.5 * (radius_outer[rad] - radius_outer[rad - 1]))
                else:
                    cl12.append(radius_outer[rad])
            # bottom
            if lay < nlay - 1:
                ja.append(n + nradial)
                iac[n] += 1
                ihc.append(0)
                cl12.append(0.5 * (top[n] - bot[n]))
                hwva.append(area[n])

    # Build rectangular equivalent of radial coordinates (unwrap radial bands)
    if get_vertex:
        perimeter_outer = np.fromiter(
            (2.0 * pi * rad for rad in radius_outer),
            dtype=float,
            count=nradial,
        )
        xc = 0.5 * radius_outer[0]
        yc = 0.5 * perimeter_outer[-1]
        # all cells have same y-axis cell center; yc is costant
        #
        # cell2d: [icell2d, xc, yc, ncvert, icvert]; first node: cell2d = [[0, xc, yc, [2, 1, 0]]]
        cell2d = []
        for lay in range(nlay):
            n = get_nn(lay, 0)
            cell2d.append([n, xc, yc, 3, 2, 1, 0])
        #
        xv = radius_outer[0]
        # half perimeter is equal to the y shift for vertices
        sh = 0.5 * perimeter_outer[0]
        vertices = [
            [0, 0.0, yc],
            [1, xv, yc - sh],
            [2, xv, yc + sh],
        ]  # vertices: [iv, xv, yv]
        iv = 3
        for r in range(1, nradial):
            # radius_outer[r-1] + 0.5*(radius_outer[r] - radius_outer[r-1])
            xc = 0.5 * (radius_outer[r - 1] + radius_outer[r])
            for lay in range(nlay):
                n = get_nn(lay, r)
                # cell2d: [icell2d, xc, yc, ncvert, icvert]
                cell2d.append([n, xc, yc, 4, iv - 2, iv - 1, iv + 1, iv])

            xv = radius_outer[r]
            # half perimeter is equal to the y shift for vertices
            sh = 0.5 * perimeter_outer[r]
            vertices.append([iv, xv, yc - sh])  # vertices: [iv, xv, yv]
            iv += 1
            vertices.append([iv, xv, yc + sh])  # vertices: [iv, xv, yv]
            iv += 1
        cell2d.sort(key=lambda row: row[0])  # sort by node number

    ja = np.array(ja, dtype=np.int32)
    nja = ja.shape[0]
    hwva = np.array(hwva, dtype=np.float64)
    kw = {}
    kw["nodes"] = nodes
    kw["nja"] = nja
    kw["nvert"] = None
    kw["top"] = top
    kw["bot"] = bot
    kw["area"] = area
    kw["iac"] = iac
    kw["ja"] = ja
    kw["ihc"] = ihc
    kw["cl12"] = cl12
    kw["hwva"] = hwva

    if get_vertex:
        kw["nvert"] = len(vertices)  # = 2*nradial + 1
        kw["vertices"] = vertices
        kw["cell2d"] = cell2d
        kw["angldegx"] = np.zeros(nja, dtype=float)
    else:
        kw["nvert"] = 0

    return kw


def _find_hyperbolic_max_value():
    seterr = np.seterr()
    np.seterr(all="ignore")
    inf = np.inf
    x = 10.0
    delt = 1.0
    for i in range(1000000):
        x += delt
        try:
            if inf == sinh(x):
                break
        except:
            break
    np.seterr(**seterr)
    return x - delt


_hyperbolic_max_value = _find_hyperbolic_max_value()


def _find_hyperbolic_equivalent_value():
    x = 10.0
    delt = 0.0001
    for i in range(1000000):
        x += delt
        if x > _hyperbolic_max_value:
            break
        try:
            if sinh(x) == cosh(x):
                return x
        except:
            break
    return x - delt


_hyperbolic_equivalence = _find_hyperbolic_equivalent_value()


class RadialUnconfinedDrawdown:
    """
    Solves the drawdown that occurs from pumping from partial penetration
    in an unconfined, radial aquifer. Uses the method described in:
    Neuman, S. P. (1974). Effect of partial penetration on flow in
    unconfined aquifers considering delayed gravity response.
    Water resources research, 10(2), 303-312.
    """

    hyperbolic_max_value = _hyperbolic_max_value
    hyperbolic_equivalence = _hyperbolic_equivalence

    bottom: float
    Kr: float
    Kz: float
    Ss: float
    Sy: float
    well_top: float
    well_bot: float
    saturated_thickness: float

    _sigma: float
    _beta: float

    def __init__(
        self,
        bottom_elevation,
        hydraulic_conductivity_radial=None,
        hydraulic_conductivity_vertical=None,
        specific_storage=None,
        specific_yield=None,
        well_screen_elevation_top=None,
        well_screen_elevation_bottom=None,
        water_table_elevation=None,
        saturated_thickness=None,
    ):
        """
        Initialize unconfined, radial groundwater model to solve drawdown
        at an observation location in response to pumping at the center of
        the model (that is, the well extracts water at radius = 0).

        Parameters
        ----------
        rad : int
            radial band number (0 to nradial-1)

        bottom_elevation : float
            Elevation of the impermeable base of the model ($L$)
        hydraulic_conductivity_radial : float
            Radial direction hydraulic conductivity of model ($L/T$)
        hydraulic_conductivity_vertical : float
            Vertical (z) direction hydraulic conductivity of model ($L/T$)
        specific_storage : float
            Specific storage of aquifer ($1/T$)
        specific_yield : float
            Specific yield of aquifer ($-$)
        well_screen_elevation_top : float
            Pumping well's top screen elevation ($L$)
        well_screen_elevation_bottom : float
            Pumping well's bottom screen elevation ($L$)
        water_table_elevation : float
            Initial water table elevation. Note, saturated_thickness (b) is
            calculated as $water_table_elevation - bottom_elevation$ ($L$)
        saturated_thickness : float
            Specify the initial saturated thickness of the unconfined aquifer.
            Value is used to calculate the water_table_elevation. If
            water_table_elevation is defined, then saturated_thickness input
            is ignored and set to
            $water_table_elevation - bottom_elevation$ ($L$)
        """

        self.bottom = float(bottom_elevation)
        self.Kr = self._float_or_none(hydraulic_conductivity_radial)
        self.Kz = self._float_or_none(hydraulic_conductivity_vertical)
        self.Ss = self._float_or_none(specific_storage)
        self.Sy = self._float_or_none(specific_yield)
        self.well_top = self._float_or_none(well_screen_elevation_top)
        self.well_bot = self._float_or_none(well_screen_elevation_bottom)

        if water_table_elevation is not None and saturated_thickness is not None:
            raise RuntimeError(
                "RadialUnconfinedDrawdown() must specify only "
                + "water_table_elevation or saturated_thickness, but not "
                + "both at the same time."
            )

        if water_table_elevation is not None:
            self.saturated_thickness = float(water_table_elevation) - self.bottom
        elif saturated_thickness is not None:
            self.saturated_thickness = float(saturated_thickness)
        else:
            self.saturated_thickness = None

    def _prop_check(self):
        error = []
        if self.Kr is None:
            error.append("hydraulic_conductivity_radial")
        if self.Kz is None:
            error.append("hydraulic_conductivity_vertical")
        if self.Ss is None:
            error.append("specific_storage")
        if self.Sy is None:
            error.append("specific_yield")
        if self.well_top is None:
            error.append("well_screen_elevation_top")
        if self.well_bot is None:
            error.append("well_screen_elevation_bottom")
        if error:
            raise RuntimeError(
                "RadialUnconfinedDrawdown: Attempted to solve radial "
                + "groundwater model\nwith the following input not specified\n"
                + "\n".join(error)
            )
        if self.well_top <= self.well_bot:
            raise RuntimeError(
                "RadialUnconfinedDrawdown: "
                + "well_screen_elevation_top <= well_screen_elevation_bottom\n"
                + f"That is: {self.well_top} <= "
                + f"{self.well_bot}"
            )

    def drawdown(
        self,
        pump,
        time,
        radius,
        observation_elevation,
        observation_elevation_bot=None,
        sumrtol=1.0e-6,
        u_n_rtol=1.0e-5,
        epsabs=1.49e-8,
        bessel_loop_limit=5,
        quad_limit=128,
        show_progress=False,
        ty_time=False,
        ts_time=False,
        as_head=False,
    ):
        """
        Solves the radial model's drawdown for a given pumping rate and
        time at a given observation point
        (radius, observation_elevation) or observation well screen interval
        (radius, observation_elevation:observation_elevation_bot).
        This solves drawdown by integrating equation 17 from
        Neuman, S. P. (1974). Effect of partial penetration on flow in
        unconfined aquifers considering delayed gravity response.
        Water resources research, 10(2), 303-312

        Parameters
        ----------
        pump : float
            Pumping rate of well at center of radial model ($L^3/T$)
            Positive values are the water extraction rate.
            Negative or zero values indicate no pumping and result returns
            the dimensionless drawdown instead of regular drawdown.
        time : float or Sequence[float]
            Time that observation is made
        radius : float
            Radius of the observation location (distance from well, $L$)
        observation_elevation : float
            Either the location of the observation point, or the top elevation
            of the observation well screen ($L$)
        observation_elevation_bot : float
            If specified, then represents the bottom elevation of the
            observation well screen. If not specified (or set to None), then
            observation location is treated as a single point, located at
            radius and observation_elevation ($L$)
        sumrtol : float
            Solution involves integration of $y$ variable from 0 to ∞ from
            Equation 17 in:
            Neuman, S. P. (1974). Effect of partial penetration on flow in
            unconfined aquifers considering delayed gravity response.
            Water resources research, 10(2), 303-312.

            The integration is broken into subsections that are spaced around
            bessel function roots. The integration is complete when a
            three sequential subsection solutions are less than
            sumrtol times the largest subsection.
            That is, the last included subsection contributes a
            relatively small value compared to the largest of the sum.
        u_n_rtol : float
            Terminates the solution of the infinite series:
            $\\sum_{n=1}^{\\infty} u_n(y)$
            when
            $u_n(y) < u_n(0) * u_n_rtol$
        epsabs : float or int
            scipy.integrate.quad absolute error tolerance.
            Passed directly to that function's `epsabs` kwarg.
        bessel_loop_limit : int
            the integral is solved along each bessel function root.
            The first 1024 roots are precalculated and automatically increased
            if more are required. The upper limit for calculated roots is
            1024 * 2 ^ bessel_loop_limit
            If this limit is reached, then a warning is raised.
        quad_limit : int
            scipy.integrate.quad upper bound on the number of
            subintervals used in the adaptive algorithm.
            Passed directly to that function's `limit` kwarg.
        show_progress : bool
           if True, then progress is printed to the command prompt in the form:
        ty_time : bool
           if True, then `time` kwarg is dimensionless time with
           respect to Specific Yield
        ts_time : bool
           if True, then `time` kwarg is dimensionless time with
           respect to Specific Storage.
        as_head : bool
            If true, then drawdown result is converted to
            head using the model bottom and initial saturated thickness.
            If pump > 0, then as_head is ignored.

        Returns
        -------
        result : float or list[float]
            If time is float, then result is float.
            If time is Sequence[float], then result is list[float].

            If pump > 0, then result is the drawdown that occurs
            from pump at time and radius at observation point
            observation_elevation or from the observation well
            screen interval observation_elevation to
            observation_elevation_top ($L$).


            If pump <= 0, then result is converted to
            dimensionless drawdown ($-$)
        """
        if not hasattr(time, "strip") and hasattr(time, "__iter__"):
            return self.drawdown_times(
                pump,
                time,
                radius,
                observation_elevation,
                observation_elevation_bot,
                sumrtol,
                u_n_rtol,
                epsabs,
                bessel_loop_limit,
                quad_limit,
                show_progress,
                ty_time,
                ts_time,
                as_head,
            )

        return self.drawdown_times(
            pump,
            [time],
            radius,
            observation_elevation,
            observation_elevation_bot,
            sumrtol,
            u_n_rtol,
            epsabs,
            bessel_loop_limit,
            quad_limit,
            show_progress,
            ty_time,
            ts_time,
            as_head,
        )[0]

    def drawdown_times(
        self,
        pump,
        times,
        radius,
        observation_elevation,
        observation_elevation_bot=None,
        sumrtol=1.0e-6,
        u_n_rtol=1.0e-5,
        epsabs=1.49e-8,
        bessel_loop_limit=5,
        quad_limit=128,
        show_progress=False,
        ty_time=False,
        ts_time=False,
        as_head=False,
    ):
        # Same as self.drawdown, but times is a list[float] of
        # observation times and returns a list[float] drawdowns.

        if bessel_loop_limit < 1:
            bessel_loop_limit = 1

        bessel_roots0 = 1024
        bessel_roots = bessel_roots0
        bessel_root_limit_reached = []

        self._prop_check()
        if ty_time and ts_time:
            raise RuntimeError(
                "RadialUnconfinedDrawdown.drawdown_times "
                + "cannot set both ty_time and ts_time to True."
            )

        r = radius
        b = self.saturated_thickness

        sigma = self.Ss * b / self.Sy
        beta = (r / b) * (r / b) * (self.Kz / self.Kr)
        sqrt_beta = sqrt(beta)

        if np.isnan(pump) or pump <= 0.0:
            # Return dimensionless drawdown
            coef = 1.0
        else:
            coef = pump / (4.0 * pi * b * self.Kr)

        # dimensionless well screen top
        dd = (self.saturated_thickness + self.bottom - self.well_top) / b
        # dimensionless well screen bottom
        ld = (self.saturated_thickness + self.bottom - self.well_bot) / b

        # Solution must be in dimensionless time with respect to Ss;
        # ts = kr*b*t/(Ss*b*r^2)
        if ty_time:
            ts_list = self.ty2ts(times)
        elif ts_time:
            ts_list = times
        else:
            ts_list = self.time2ts(times, r)

        # distance above bottom to observation point or obs screen bottom
        zt = observation_elevation - self.bottom
        if observation_elevation_bot is None:
            # Single Point Observation
            zd = zt / b  # dimensionless elevation of observation point
            neuman1974_integral = self.neuman1974_integral1
            obs_arg = (zd,)
        else:
            # distance above bottom to observation screen top
            zb = observation_elevation_bot - self.bottom
            # dimensionless elevation of observation screen interval
            ztd, zbd = zt / b, zb / b
            # dz = 1 / (zt - zb)  -> implied in the
            #                        modified u0 and uN functions
            neuman1974_integral = self.neuman1974_integral2
            obs_arg = (zbd, ztd)

        s = []  # drawdown, one to one match with times
        nstp = len(ts_list)
        for stp, ts in enumerate(ts_list):
            if show_progress:
                print(
                    f"Solving {stp+1:4d} of {nstp}; " + f"time = {self.ts2time(ts, r)}",
                    end="",
                )

            args = (sigma, beta, sqrt_beta, ld, dd, ts, *obs_arg, u_n_rtol)
            sol = 0.0
            y0, y1 = 0.0, 0.0
            mxdelt = 0.0

            j0_roots = jn_zeros(0, bessel_roots) / sqrt_beta
            jr0 = 0
            jr1 = j0_roots.size

            converged = 0
            bessel_loop_count = 0
            while converged < 3 and bessel_loop_count <= bessel_loop_limit:
                if bessel_loop_count > 0:
                    bessel_roots *= 2
                    j0_roots = jn_zeros(0, bessel_roots) / sqrt_beta
                    jr0, jr1 = jr1, j0_roots.size

                j0_roots_iter = np.nditer(j0_roots[jr0:jr1])
                bessel_loop_count += 1
                # Iterate over two roots to get full cycle
                for j0_root in j0_roots_iter:
                    # First root
                    y0, y1 = y1, j0_root
                    delt1 = quad(
                        neuman1974_integral,
                        y0,
                        y1,
                        args,
                        epsabs=epsabs,
                        limit=quad_limit,
                    )[0]
                    #
                    # Second root
                    y0, y1 = y1, next(j0_roots_iter)
                    delt2 = quad(
                        neuman1974_integral,
                        y0,
                        y1,
                        args,
                        epsabs=epsabs,
                        limit=quad_limit,
                    )[0]

                    if np.isnan(delt1) or np.isnan(delt2):
                        break

                    sol += delt1 + delt2

                    adelt = abs(delt1 + delt2)
                    if adelt > mxdelt:
                        mxdelt = adelt
                    elif adelt < mxdelt * sumrtol:
                        converged += 1  # increment the convergence counter
                        # Converged if three sequential solutions (adelt)
                        # are less than mxdelt*sumrtol
                        if converged >= 3:
                            break
                    else:
                        converged = 0  # reset convergence counter
            if sol < 0.0:
                s.append(0.0)
            else:
                s.append(coef * sol)

            if converged < 3:
                bessel_root_limit_reached.append(stp)

            if show_progress:
                if converged < 3:
                    print(f"\ts = {s[-1]}\tbessel_loop_limit reached")
                else:
                    print(f"\ts = {s[-1]}")

        if pump > 0.0 and as_head:
            initial_head = self.bottom + self.saturated_thickness
            return [initial_head - drawdown for drawdown in s]

        if len(bessel_root_limit_reached) > 0:
            import warnings

            root = j0_roots[-1]
            bad_times = "\n".join([str(times[it]) for it in bessel_root_limit_reached])
            warnings.warn(
                "\n\nRadialUnconfinedDrawdown.drawdown_times failed to "
                + f"meet convergence sumrtol = {sumrtol}"
                + "\nwithin the precalculated Bessel root solutions "
                + "(convergence is evaluated at every second Bessel root).\n\n"
                + "The number of Bessel roots are automatically increased "
                + "up to:\n"
                + f"   {bessel_roots0} * 2^bessel_loop_limit\nwhere:\n"
                + "   bessel_loop_limit = {bessel_loop_limit}\n"
                + f"resulting in {1024*2**bessel_loop_limit} roots evaluated, "
                + "with the last root being {root}\n"
                + f"(That is, the Neuman integral was solved form 0 to {root})"
                + "\n\n"
                + "You can either ignore this warning\n"
                + "or to remove it attempt to increase bessel_loop_limit\n"
                + "or increase sumrtol (reducing accuracy).\n\nThe following "
                + "times are what triggered this warning:\n"
                + bad_times
                + "\n"
            )
        return s

    @staticmethod
    def neuman1974_integral1(y, alpha, beta, sqrt_beta, ld, dd, ts, zd, uN_tol=1.0e-6):
        """
        Solves equation 17 from
        Neuman, S. P. (1974). Effect of partial penetration on flow in
        unconfined aquifers considering delayed gravity response.
        Water resources research, 10(2), 303-312.
        """
        if y == 0.0 or ts == 0.0:
            return 0.0

        u0 = RadialUnconfinedDrawdown.u_0(alpha, beta, zd, ld, dd, ts, y)

        if np.isnan(u0):
            u0 = 0.0

        uN_func = RadialUnconfinedDrawdown.u_n
        mxdelt = 0.0
        uN = 0.0
        for n in range(1, 25001):
            delt = uN_func(alpha, beta, zd, ld, dd, ts, y, n)
            if np.isnan(delt):
                break
            uN += delt
            adelt = abs(delt)
            if adelt > mxdelt:
                mxdelt = adelt
            elif adelt < mxdelt * uN_tol:
                break

        return 4.0 * y * j0(y * sqrt_beta) * (u0 + uN)

    @staticmethod
    def gamma0(g, y, s):
        """
        Gamma0 root function from equation 18 in:
        Neuman, S. P. (1974). Effect of partial penetration on flow in
        unconfined aquifers considering delayed gravity response.
        Water resources research, 10(2), 303-312.
            => Solution must be constrained by g^2 < y^2

        To honor the constraint solution returns the absolute value
         of the solution.
        """
        if g >= _hyperbolic_equivalence:
            # sinh ≈ cosh for large g
            return s * g - (y * y - g * g)

        return s * g * sinh(g) - (y * y - g * g) * cosh(g)

    @staticmethod
    def gammaN(g, y, s):
        """
        GammaN root function from equation 19 in:
        Neuman, S. P. (1974). Effect of partial penetration on flow in
        unconfined aquifers considering delayed gravity response.
        Water resources research, 10(2), 303-312.
            => Solution must be constrained by (2n-1)(π/2)< g < nπ
        """
        return s * g * sin(g) + (y * y + g * g) * cos(g)

    @staticmethod
    def u_0(alpha, beta, z, l, d, ts, y):
        gamma0 = RadialUnconfinedDrawdown.gamma0

        a, b = 0.9 * y, y
        try:
            a, b = RadialUnconfinedDrawdown._get_bracket(gamma0, a, b, (y, alpha))
        except RuntimeError:
            a, b = RadialUnconfinedDrawdown._get_bracket(
                gamma0, 0.0, b, (y, alpha), 1000
            )

        g = brentq(gamma0, a, b, args=(y, alpha), maxiter=500, xtol=1.0e-16)

        # Check for cosh/sinh overflow
        if g > _hyperbolic_max_value:
            return 0.0

        y2 = y * y
        g2 = g * g
        num1 = 1 - exp(-ts * beta * (y2 - g2))
        num2 = cosh(g * z)
        num3 = sinh(g * (1 - d)) - sinh(g * (1 - l))
        den1 = y2 + (1 + alpha) * g2 - ((y2 - g2) ** 2) / alpha
        den2 = cosh(g)
        den3 = (l - d) * sinh(g)
        # num1*num2*num3 / (den1*den2*den3)
        return (num1 / den1) * (num2 / den2) * (num3 / den3)

    @staticmethod
    def u_n(alpha, beta, z, l, d, ts, y, n):
        gammaN = RadialUnconfinedDrawdown.gammaN

        a, b = (2 * n - 1) * (pi / 2.0), n * pi
        try:
            a, b = RadialUnconfinedDrawdown._get_bracket(gammaN, a, b, (y, alpha))
        except RuntimeError:
            a, b = RadialUnconfinedDrawdown._get_bracket(gammaN, a, b, (y, alpha), 1000)

        g = brentq(gammaN, a, b, args=(y, alpha), maxiter=500, xtol=1.0e-16)

        y2 = y * y
        g2 = g * g
        num1 = 1 - exp(-ts * beta * (y2 + g2))
        num2 = cos(g * z)
        num3 = sin(g * (1 - d)) - sin(g * (1 - l))
        den1 = y2 - (1 + alpha) * g2 - ((y2 + g2) ** 2) / alpha
        den2 = cos(g)
        den3 = (l - d) * sin(g)
        return num1 * num2 * num3 / (den1 * den2 * den3)

    @staticmethod
    def neuman1974_integral2(
        y, alpha, beta, sqrt_beta, ld, dd, ts, z1, z2, uN_tol=1.0e-10
    ):
        """
        Solves equation 20 from
        Neuman, S. P. (1974). Effect of partial penetration on flow in
        unconfined aquifers considering delayed gravity response.
        Water resources research, 10(2), 303-312.
        """
        if y == 0.0 or ts == 0.0:
            return 0.0

        u0 = RadialUnconfinedDrawdown.u_0_z1z2(alpha, beta, z1, z2, ld, dd, ts, y)

        uN_func = RadialUnconfinedDrawdown.u_n_z1z2
        mxdelt = 0.0
        uN = 0.0
        for n in range(1, 10001):
            delt = uN_func(alpha, beta, z1, z2, ld, dd, ts, y, n)
            uN += delt
            adelt = abs(delt)
            if adelt > mxdelt:
                mxdelt = adelt
            elif adelt < mxdelt * uN_tol:
                break

        return 4.0 * y * j0(y * sqrt_beta) * (u0 + uN)

    @staticmethod
    def u_0_z1z2(alpha, beta, z1, z2, l, d, ts, y):
        gamma0 = RadialUnconfinedDrawdown.gamma0

        a, b = 0.9 * y, y
        try:
            a, b = RadialUnconfinedDrawdown._get_bracket(gamma0, a, b, (y, alpha))
        except RuntimeError:
            a, b = RadialUnconfinedDrawdown._get_bracket(
                gamma0, 0.0, b, (y, alpha), 1000
            )

        g = brentq(gamma0, a, b, args=(y, alpha), maxiter=500, xtol=1.0e-16)

        # Check for cosh/sinh overflow
        if g > _hyperbolic_max_value:
            return 0.0

        y2 = y * y
        g2 = g * g
        num1 = 1 - exp(-ts * beta * (y2 - g2))
        num2 = sinh(g * z2) - sinh(g * z1)
        num3 = sinh(g * (1 - d)) - sinh(g * (1 - l))
        den1 = (y2 + (1 + alpha) * g2 - ((y2 - g2) ** 2) / alpha) * (z2 - z1) * g
        den2 = cosh(g)
        den3 = (l - d) * sinh(g)
        # num1*num2*num3 / (den1*den2*den3)
        return (num1 / den1) * (num2 / den2) * (num3 / den3)

    @staticmethod
    def u_n_z1z2(alpha, beta, z1, z2, l, d, ts, y, n):
        gammaN = RadialUnconfinedDrawdown.gammaN

        a, b = (2 * n - 1) * (pi / 2.0), n * pi
        try:
            a, b = RadialUnconfinedDrawdown._get_bracket(gammaN, a, b, (y, alpha))
        except RuntimeError:
            a, b = RadialUnconfinedDrawdown._get_bracket(gammaN, a, b, (y, alpha), 1000)

        g = brentq(gammaN, a, b, args=(y, alpha), maxiter=500, xtol=1.0e-16)

        y2 = y * y
        g2 = g * g
        num1 = 1 - exp(-ts * beta * (y2 + g2))
        num2 = sin(g * z2) - sin(g * z1)
        num3 = sin(g * (1 - d)) - sin(g * (1 - l))
        den1 = y2 - (1 + alpha) * g2 - ((y2 + g2) ** 2) / alpha
        den2 = cos(g) * (z2 - z1) * g
        den3 = (l - d) * sin(g)
        return num1 * num2 * num3 / (den1 * den2 * den3)

    def time2ty(self, time, radius):
        # dimensionless time with respect to Sy
        if hasattr(time, "__iter__"):
            # can iterate to get multiple times
            return [
                self.Kr * self.saturated_thickness * t / (self.Sy * radius * radius)
                for t in time
            ]
        return self.Kr * self.saturated_thickness * time / (self.Sy * radius * radius)

    def time2ts(self, time, radius):
        # dimensionless time with respect to Ss
        if hasattr(time, "__iter__"):
            # can iterate to get multiple times
            return [self.Kr * t / (self.Ss * radius * radius) for t in time]
        return self.Kr * time / (self.Ss * radius * radius)

    def ty2time(self, ty, radius):
        # dimensionless time with respect to Sy
        if hasattr(ty, "__iter__"):
            # can iterate to get multiple times
            return [
                t * self.Sy * radius * radius / (self.Kr * self.saturated_thickness)
                for t in ty
            ]
        return ty * self.Sy * radius * radius / (self.Kr * self.saturated_thickness)

    def ts2time(self, ts, radius):  # dimensionless time with respect to Ss
        if hasattr(ts, "__iter__"):  # can iterate to get multiple times
            return [t * self.Ss * radius * radius / self.Kr for t in ts]
        return ts * self.Ss * radius * radius / self.Kr

    def ty2ts(self, ty):
        if hasattr(ty, "__iter__"):
            # can iterate to get multiple times
            return [t * self.Sy / (self.Ss * self.saturated_thickness) for t in ty]
        return ty * self.Sy / (self.Ss * self.saturated_thickness)

    def drawdown2unitless(self, s, pump):
        # dimensionless drawdown
        return 4 * pi * self.Kr * self.saturated_thickness * s / pump

    def unitless2drawdown(self, s, pump):
        # drawdown
        return pump * s / (4 * pi * self.Kr * self.saturated_thickness)

    @staticmethod
    def _float_or_none(val):
        if val is not None:
            return float(val)
        return None

    @staticmethod
    def _get_bracket(func, a, b, arg=(), internal_search_split=100):
        """
        Given initial range [a, b], search within the range for
        root finding brackets.
        That is, return [a, b] that results in f(a) * f(b) < 0.
        """
        if a > b:
            a, b = b, a

        f1 = func(a, *arg)
        f2 = func(b, *arg)

        if f1 * f2 <= 0.0:
            return a, b

        # same sign, search within for sign change
        delt = abs(b - a) / internal_search_split
        a -= delt
        for _ in range(internal_search_split):
            a += delt
            f1 = func(a, *arg)
            if f1 * f2 <= 0.0:
                return a, b

        raise RuntimeError(
            "get_bracket: failed to find bracket interval with opposite "
            + f"signs, that is: f(a)*f(b) < 0 for func: {func}"
        )


def get_radial_node(rad, lay, nradial):
    """
    Given nradial dimension (bands per layer),
    returns the 0-based disu node number for given 0-based radial
    band and layer

    Parameters
    ----------
    rad : int
        radial band number (0 to nradial-1)
    lay : float or ndarray
        layer number (0 to nlay-1)
    nradial : int
        total number of radial bands

    Returns
    -------
    result : int
        0-based disu node number located at rad and lay

    """

    return nradial * lay + rad


def get_radius_lay_from_node(node, nradial):
    """
    Given nradial dimension (bands per layer),
    returns 0-based layer and radial band indices for given disu node number

    Parameters
    ----------
    node : int
        disu node number
    nradial : int
        total number of radial bands

    Returns
    -------
    result : int
        0-based disu node number located at rad and lay

    """
    #
    lay = node // nradial
    rad = node - (lay * nradial)
    return rad, lay

Define parameters

Define model units, parameters and other settings.

[3]:
# Model units
length_units = "feet"
time_units = "days"

# Model parameters
nper = 1  # Number of periods
_ = 24  # Number of time steps
_ = "10"  # Simulation total time ($day$)
nlay = 25  # Number of layers
nradial = 22  # Number of radial direction cells (radial bands)
initial_head = 50.0  # Initial water table elevation ($ft$)
surface_elevation = 50.0  # Top of the radial model ($ft$)
_ = 0.0  # Base of the radial model ($ft$)
layer_thickness = 2.0  # Thickness of each radial layer ($ft$)
_ = "0.25 to 2000"  # Outer radius of each radial band ($ft$)
k11 = 20.0  # Horizontal hydraulic conductivity ($ft/day$)
k33 = 20.0  # Vertical hydraulic conductivity ($ft/day$)
ss = 1.0e-5  # Specific storage ($1/day$)
sy = 0.1  # Specific yield (unitless)
_ = "0.0 to 10"  # Well screen elevation ($ft$)
_ = "1"  # Well radial band location (unitless)
_ = "-4000.0"  # Well pumping rate ($ft^3/day$)
_ = "40"  # Observation distance from well ($ft$)
_ = "1"  # ``Top'' observation elevation ($ft$)
_ = "25"  # ``Middle'' observation depth ($ft$)
_ = "49"  # ``Bottom'' observation depth ($ft$)


# Outer Radius for each radial band

radius_outer = [
    0.25,
    0.75,
    1.5,
    2.5,
    4.0,
    6.0,
    9.0,
    13.0,
    18.0,
    23.0,
    33.0,
    47.0,
    65.0,
    90.0,
    140.0,
    200.0,
    300.0,
    400.0,
    600.0,
    1000.0,
    1500.0,
    2000.0,
]  # Outer radius of each radial band ($ft$)

# Well boundary conditions
# Well must be located on central radial band (rad = 0)
# and have a contiguous screen interval and constant pumping rate.
# This example has the well screen interval from
# layer 20 to 24 (zero-based index)
wel_spd = {
    sp: [[(get_radial_node(0, lay, nradial),), -800.0] for lay in range(20, 25)]
    for sp in range(nper)
}

# Static temporal data used by TDIS file
# Simulation has 1 ten-day stress period with 24 time steps.
# The multiplier for the length of successive time steps is 1.62

tdis_ds = ((10.0, 24, 1.62),)

# Setup observation location and times

obslist = [
    ["h_top", "head", (get_radial_node(11, 0, nradial),)],
    ["h_mid", "head", (get_radial_node(11, (nlay - 1) // 2, nradial),)],
    ["h_bot", "head", (get_radial_node(11, nlay - 1, nradial),)],
]
obsdict = {"{}.obs.head.csv".format(sim_name): obslist}

# Solver parameters

nouter = 500
ninner = 300
hclose = 1e-4
rclose = 1e-4

Model setup

Define functions to build models, write input files, and run the simulation.

[4]:
def build_models(name):
    sim_ws = os.path.join(workspace, name)
    sim = flopy.mf6.MFSimulation(sim_name=name, sim_ws=sim_ws, exe_name="mf6")
    flopy.mf6.ModflowTdis(sim, nper=nper, perioddata=tdis_ds, time_units=time_units)
    flopy.mf6.ModflowIms(
        sim,
        print_option="summary",
        complexity="complex",
        outer_maximum=nouter,
        outer_dvclose=hclose,
        inner_maximum=ninner,
        inner_dvclose=hclose,
    )

    gwf = flopy.mf6.ModflowGwf(sim, modelname=name, save_flows=True)

    disukwargs = get_disu_radial_kwargs(
        nlay,
        nradial,
        radius_outer,
        surface_elevation,
        layer_thickness,
        get_vertex=True,
    )

    disu = flopy.mf6.ModflowGwfdisu(gwf, length_units=length_units, **disukwargs)

    npf = flopy.mf6.ModflowGwfnpf(
        gwf,
        k=k11,
        k33=k33,
        save_flows=True,
        save_specific_discharge=True,
    )

    flopy.mf6.ModflowGwfsto(
        gwf,
        iconvert=1,
        sy=sy,
        ss=ss,
        save_flows=True,
    )

    flopy.mf6.ModflowGwfic(gwf, strt=initial_head)

    flopy.mf6.ModflowGwfwel(gwf, stress_period_data=wel_spd, save_flows=True)

    flopy.mf6.ModflowGwfoc(
        gwf,
        budget_filerecord=f"{name}.cbc",
        head_filerecord=f"{name}.hds",
        headprintrecord=[("COLUMNS", nradial, "WIDTH", 15, "DIGITS", 6, "GENERAL")],
        saverecord=[("HEAD", "ALL"), ("BUDGET", "ALL")],
        printrecord=[("HEAD", "ALL"), ("BUDGET", "ALL")],
        filename=f"{name}.oc",
    )

    flopy.mf6.ModflowUtlobs(gwf, print_input=False, continuous=obsdict)
    return sim


def write_models(sim, silent=True):
    sim.write_simulation(silent=silent)


@timed
def run_models(sim, silent=True):
    success, buff = sim.run_simulation(silent=silent, report=True)
    assert success, buff

Plotting results

Define functions to plot model results.

[5]:
# Set default figure properties
figure_size = (6, 6)


def solve_analytical(obs2ana, times=None, no_solve=False):
    """Solve Axisymmetric model using analytical equation."""
    # obs2ana = {obsdict[file][0] : analytical_name}
    disukwargs = get_disu_radial_kwargs(
        nlay, nradial, radius_outer, surface_elevation, layer_thickness
    )
    model_bottom = disukwargs["bot"][get_radial_node(0, nlay - 1, nradial)]
    sat_thick = initial_head - model_bottom

    key = next(iter(wel_spd))
    nodes = []
    rates = []
    for nod, rat in wel_spd[key]:
        nodes.append(nod[0])
        rates.append(rat)
    nodes.sort()
    well_top = disukwargs["top"][nodes[0]]
    well_bot = disukwargs["bot"][nodes[-1]]
    pump = abs(sum(rates))

    ana_model = RadialUnconfinedDrawdown(
        bottom_elevation=model_bottom,
        hydraulic_conductivity_radial=k11,
        hydraulic_conductivity_vertical=k33,
        specific_storage=ss,
        specific_yield=sy,
        well_screen_elevation_top=well_top,
        well_screen_elevation_bottom=well_bot,
        saturated_thickness=sat_thick,
    )

    build_times = times is None
    if build_times:
        totim = 0.0
        for pertim in tdis_ds:
            totim += pertim[0]
        times_sy_base = np.logspace(-3, max([np.log10(totim), 2]), 100)

    analytical = {}
    prop = {}
    if not no_solve:
        print("Solving Analytical Model (Very Slow)")
    for file in obsdict:
        for row in obsdict[file]:
            obs = row[0].upper()
            if obs not in obs2ana:
                continue
            ana = obs2ana[obs]
            nod = row[2][0]
            obs_top = disukwargs["top"][nod]
            obs_bot = disukwargs["bot"][nod]

            rad, lay = get_radius_lay_from_node(nod, nradial)

            if lay == 0:
                # Uppermost layer has obs elevation at top,
                # otherwise cell center
                obs_el = obs_top
            else:
                obs_el = 0.5 * (obs_top + obs_bot)

            if rad == 0:
                obs_rad = 0.0
            else:
                # radius_outer[r-1] + 0.5*(radius_outer[r] - radius_outer[r-1])
                obs_rad = 0.5 * (radius_outer[rad - 1] + radius_outer[rad])

            if build_times:
                times_sy = times_sy_base
                times = [
                    ty * sy * obs_rad * obs_rad / (k11 * sat_thick)
                    for ty in times_sy_base
                ]
            else:
                times_sy = [
                    ty * k11 * sat_thick / (sy * obs_rad * obs_rad) for ty in times
                ]

            times_ss = [ty * k11 / (ss * obs_rad * obs_rad) for ty in times]

            if not no_solve:
                print(f"Solving {ana}")
                analytical[ana] = ana_model.drawdown_times(
                    pump,
                    times,
                    obs_rad,
                    obs_el,
                    sumrtol=1.0e-6,
                    u_n_rtol=1.0e-5,
                )
            prop[ana] = [
                times,
                times_sy,
                times_ss,
                pump,
                obs_rad,
                sat_thick,
                model_bottom,
            ]

    return analytical, prop


# Function to plot the Axisymmetric model results.


def plot_ts(sim, verbose=False, solve_analytical_solution=False):
    pi = 3.141592653589793
    gwf = sim.get_model(sim_name)
    obs_csv_name = gwf.obs.output.obs_names[0]
    obs_csv_file = gwf.obs.output.obs(f=obs_csv_name)

    tsdata = obs_csv_file.data
    fmt = {
        "H_TOP": "og",
        "H_MID": "or",
        "H_BOT": "ob",
        "a_top": "-g",
        "a_mid": "-r",
        "a_bot": "-b",
    }
    obsnames = {
        "H_TOP": "MF6 (Top)",
        "H_MID": "MF6 (Middle)",
        "H_BOT": "MF6 (Bottom)",
        "a_top": "Analytical (Top)",
        "a_mid": "Analytical (Middle)",
        "a_bot": "Analytical (Bottom)",
    }

    obs2ana = {"H_TOP": "a_top", "H_MID": "a_mid", "H_BOT": "a_bot"}

    if solve_analytical_solution:
        analytical, ana_prop = solve_analytical(obs2ana)
        analytical_time = []
    else:
        analytical, ana_prop = solve_analytical(obs2ana, no_solve=True)
        analytical_time = [
            0.00016,
            0.000179732,
            0.000201897,
            0.000226796,
            0.000254765,
            0.000286184,
            0.000321477,
            0.000361123,
            0.000405658,
            0.000455686,
            0.000511883,
            0.00057501,
            0.000645923,
            0.000725581,
            0.000815062,
            0.000915579,
            0.001028492,
            0.001155329,
            0.001297809,
            0.00145786,
            0.00163765,
            0.001839611,
            0.002066479,
            0.002321326,
            0.002607601,
            0.002929181,
            0.00329042,
            0.003696208,
            0.004152039,
            0.004664085,
            0.005239279,
            0.005885408,
            0.00661122,
            0.007426542,
            0.008342413,
            0.009371233,
            0.010526932,
            0.011825155,
            0.013283481,
            0.014921654,
            0.016761852,
            0.018828991,
            0.021151058,
            0.023759492,
            0.026689609,
            0.029981079,
            0.033678466,
            0.037831831,
            0.042497405,
            0.047738356,
            0.053625642,
            0.060238973,
            0.067667886,
            0.076012963,
            0.085387188,
            0.09591748,
            0.107746411,
            0.121034132,
            0.13596055,
            0.152727753,
            0.171562756,
            0.192720566,
            0.216487644,
            0.243185773,
            0.273176424,
            0.306865642,
            0.34470955,
            0.387220522,
            0.434974119,
            0.488616881,
            0.548875086,
            0.616564575,
            0.692601805,
            0.778016253,
            0.873964355,
            0.981745164,
            1.102817937,
            1.238821892,
            1.391598404,
            1.563215932,
            1.755998025,
            1.972554783,
            2.215818194,
            2.48908183,
            2.79604544,
            3.14086504,
            3.528209184,
            3.96332217,
            4.452095044,
            5.00114536,
            5.617906775,
            6.310729695,
            7.088994332,
            7.963237703,
            8.945296292,
            10.04846631,
            11.2876837,
            12.67972637,
            14.24344137,
            16,
        ]

        analytical["a_top"] = [
            9.14e-06,
            1.48e-05,
            2.32e-05,
            3.51e-05,
            5.16e-05,
            7.39e-05,
            0.000103386,
            0.000141564,
            0.000190115,
            0.000250872,
            0.000325813,
            0.000417056,
            0.00052686,
            0.000657611,
            0.000811829,
            0.000992179,
            0.001201482,
            0.001442755,
            0.001719253,
            0.002034538,
            0.002392557,
            0.002797739,
            0.003255095,
            0.003770324,
            0.004349925,
            0.005001299,
            0.005732852,
            0.006554096,
            0.007475752,
            0.008509865,
            0.009669933,
            0.010971051,
            0.01243007,
            0.014065786,
            0.015899134,
            0.017953402,
            0.020254465,
            0.022831026,
            0.025714866,
            0.028941104,
            0.032548456,
            0.03657948,
            0.041080814,
            0.046103371,
            0.051702489,
            0.057938,
            0.064874212,
            0.072579742,
            0.081127182,
            0.090592554,
            0.101054493,
            0.112593132,
            0.125288641,
            0.139219391,
            0.154459742,
            0.171077492,
            0.189131034,
            0.208666373,
            0.229714163,
            0.252287007,
            0.276377285,
            0.301955794,
            0.328971456,
            0.357352252,
            0.387007461,
            0.417831084,
            0.449706211,
            0.482509951,
            0.516118451,
            0.550411552,
            0.585276682,
            0.620611726,
            0.656326766,
            0.692344745,
            0.72860122,
            0.765043446,
            0.801629049,
            0.838324511,
            0.875103648,
            0.911946208,
            0.94883664,
            0.985763066,
            1.022716447,
            1.059689924,
            1.0966783,
            1.133677645,
            1.170684993,
            1.207698108,
            1.24471531,
            1.281735341,
            1.318757264,
            1.355780385,
            1.392804192,
            1.429828316,
            1.466852493,
            1.503876535,
            1.540900317,
            1.577923757,
            1.614946805,
            1.651969438,
        ]

        analytical["a_mid"] = [
            0.042573248,
            0.053215048,
            0.065047371,
            0.077900907,
            0.091562277,
            0.10578645,
            0.120308736,
            0.134861006,
            0.149179898,
            0.163017203,
            0.176149026,
            0.188382628,
            0.199562503,
            0.209575346,
            0.218353885,
            0.22587733,
            0.232171831,
            0.237306589,
            0.241387585,
            0.244548545,
            0.246940521,
            0.24872049,
            0.250040497,
            0.25103848,
            0.251831919,
            0.252514802,
            0.253157938,
            0.253811816,
            0.254511215,
            0.255280058,
            0.256135833,
            0.257093057,
            0.258165369,
            0.259366956,
            0.260713168,
            0.262220999,
            0.263909262,
            0.265798794,
            0.267912645,
            0.270276262,
            0.272917675,
            0.27586768,
            0.279160018,
            0.28283153,
            0.286922294,
            0.291475727,
            0.296538626,
            0.302161152,
            0.308396727,
            0.31530182,
            0.322935596,
            0.331359479,
            0.340636352,
            0.350829851,
            0.362003212,
            0.374217985,
            0.387532593,
            0.402000693,
            0.417669407,
            0.434577538,
            0.452753827,
            0.472215504,
            0.492966953,
            0.514999008,
            0.538288679,
            0.562799606,
            0.588483025,
            0.615279515,
            0.643120962,
            0.671933085,
            0.701637983,
            0.732156526,
            0.763410706,
            0.795325415,
            0.827829825,
            0.860858334,
            0.894351053,
            0.928253957,
            0.962518747,
            0.997102451,
            1.031967356,
            1.067080013,
            1.102411044,
            1.137934722,
            1.17362841,
            1.209472238,
            1.245448747,
            1.281542585,
            1.317740238,
            1.354029805,
            1.390400789,
            1.426843932,
            1.463351049,
            1.499914938,
            1.53652918,
            1.573188127,
            1.609886766,
            1.64662066,
            1.683385875,
            1.720178921,
        ]

        analytical["a_bot"] = [
            0.086684154,
            0.103649206,
            0.12188172,
            0.141163805,
            0.16124535,
            0.181846061,
            0.202667182,
            0.223392774,
            0.243699578,
            0.263275092,
            0.281824849,
            0.299088528,
            0.31485153,
            0.328955429,
            0.341304221,
            0.351868481,
            0.360684432,
            0.367848506,
            0.373508853,
            0.377853369,
            0.381094083,
            0.383450902,
            0.385136877,
            0.386345028,
            0.387238663,
            0.387947536,
            0.388568938,
            0.389170336,
            0.389796684,
            0.390476869,
            0.39123089,
            0.392073081,
            0.393015962,
            0.39407278,
            0.395256673,
            0.396583156,
            0.398068337,
            0.399731151,
            0.401591476,
            0.403672105,
            0.405997893,
            0.408596189,
            0.411497001,
            0.414733164,
            0.418340479,
            0.422357833,
            0.426826999,
            0.431793796,
            0.437306073,
            0.443415589,
            0.450176656,
            0.457646089,
            0.46588273,
            0.47494648,
            0.484898814,
            0.495799471,
            0.507707617,
            0.520679174,
            0.534765662,
            0.550012634,
            0.566458073,
            0.584130854,
            0.60304937,
            0.623219948,
            0.644638018,
            0.667285046,
            0.691130674,
            0.716132499,
            0.742239678,
            0.76939134,
            0.797520861,
            0.826557513,
            0.85642935,
            0.887062398,
            0.918386519,
            0.950334816,
            0.982842103,
            1.015851071,
            1.049306941,
            1.083160734,
            1.117368294,
            1.151890127,
            1.186690934,
            1.221739246,
            1.257007172,
            1.292471077,
            1.32810677,
            1.363895877,
            1.399822391,
            1.435868577,
            1.472023401,
            1.508273605,
            1.544607161,
            1.581017341,
            1.617494414,
            1.654030939,
            1.690620311,
            1.72725511,
            1.763933202,
            1.800648435,
        ]

    with styles.USGSPlot() as fs:
        obs_fig = "obs-head"
        fig = plt.figure(figsize=(5, 3))
        ax = fig.add_subplot()
        ax.set_xlabel("time (d)")
        ax.set_ylabel("head (ft)")
        for name in tsdata.dtype.names[1:]:
            ax.plot(
                tsdata["totim"],
                tsdata[name],
                fmt[name],
                label=obsnames[name],
                markerfacecolor="none",
            )
            # , markersize=3

        for name in analytical:
            n = len(analytical[name])
            if solve_analytical_solution:
                ana_times = ana_prop[name][0]
            else:
                ana_times = analytical_time

            ax.plot(
                ana_times[:n],
                [50.0 - h for h in analytical[name]],
                fmt[name],
                label=obsnames[name],
            )

        styles.graph_legend(ax)

        fig.tight_layout()

        if plot_save:
            fpth = figs_path / "{}-{}{}".format(sim_name, obs_fig, ".png")
            fig.savefig(fpth)

        obs_fig = "obs-dimensionless"
        fig = plt.figure(figsize=(5, 3))
        fig.tight_layout()
        ax = fig.add_subplot()
        ax.set_xlim(0.001, 100.0)
        ax.set_ylim(0.001, 100.0)
        ax.grid(visible=True, which="major", axis="both")
        ax.set_ylabel("Dimensionless Drawdown, $s_d$")
        ax.set_xlabel("Dimensionless Time, $t_y$")
        for name in tsdata.dtype.names[1:]:
            q = ana_prop[obs2ana[name]][3]
            r = ana_prop[obs2ana[name]][4]
            b = ana_prop[obs2ana[name]][5]
            ax.loglog(
                [k11 * b * ts / (sy * r * r) for ts in tsdata["totim"]],
                [4 * pi * k11 * b * (initial_head - h) / q for h in tsdata[name]],
                fmt[name],
                label=obsnames[name],
                markerfacecolor="none",
            )

        for name in analytical:
            q = ana_prop[name][3]
            b = ana_prop[name][5]  # [pump, radius, sat_thick, model_bottom]
            if solve_analytical_solution:
                ana_times = ana_prop[name][0]
            else:
                ana_times = analytical_time

            n = len(analytical[name])
            time_sy = [k11 * b * ts / (sy * r * r) for ts in ana_times[:n]]
            ana = [4 * pi * k11 * b * s / q for s in analytical[name]]
            ax.plot(time_sy, ana, fmt[name], label=obsnames[name])

        styles.graph_legend(ax)

        fig.tight_layout()

        if plot_save:
            fpth = figs_path / "{}-{}{}".format(sim_name, obs_fig, ".png")
            fig.savefig(fpth)


# Function to plot the model radial bands.


def plot_grid(verbose=False):
    with styles.USGSMap():
        # Print all radial bands
        fig, axs = plt.subplots(nrows=1, ncols=2, figsize=(6.4, 3.1))
        # fig, axs = plt.subplots(nrows=1, ncols=2, figsize=(10, 4.5))
        ax = axs[0]

        max_rad = radius_outer[-1]
        max_rad = max_rad + (max_rad * 0.1)
        ax.set_xlim(-max_rad, max_rad)
        ax.set_ylim(-max_rad, max_rad)
        ax.set_aspect("equal", adjustable="box")

        circle_center = (0.0, 0.0)
        for r in radius_outer:
            circle = Circle(circle_center, r, color="black", fill=False, lw=0.3)
            ax.add_artist(circle)

        ax.set_xlabel("x-position (ft)")
        ax.set_ylabel("y-position (ft)")
        ax.annotate(
            "A",
            (-0.11, 1.02),
            xycoords="axes fraction",
            fontweight="black",
            fontsize="xx-large",
        )

        # Print first 5 radial bands
        nband = 5
        ax = axs[1]

        radius_subset = radius_outer[:nband]
        max_rad = radius_subset[-1]
        max_rad = max_rad + (max_rad * 0.3)

        ax.set_xlim(-max_rad, max_rad)
        ax.set_ylim(-max_rad, max_rad)
        ax.set_aspect("equal", adjustable="box")

        circle_center = (0.0, 0.0)

        r = radius_subset[0]
        circle = Circle(circle_center, r, color="red", label="Well")
        ax.add_artist(circle)
        for r in radius_subset:
            circle = Circle(circle_center, r, color="black", lw=1, fill=False)
            ax.add_artist(circle)

        ax.set_xlabel("x-position (ft)")
        ax.set_ylabel("y-position (ft)")

        ax.annotate(
            "B",
            (-0.06, 1.02),
            xycoords="axes fraction",
            fontweight="black",
            fontsize="xx-large",
        )

        styles.graph_legend(ax)

        fig.tight_layout()

        if plot_show:
            plt.show()
        if plot_save:
            fpth = figs_path / "{}-grid{}".format(sim_name, ".png")
            fig.savefig(fpth)


# Function to plot the model results.


def plot_results(silent=True):
    if not plot:
        return

    if silent:
        verbosity_level = 0
    else:
        verbosity_level = 1

    sim_ws = os.path.join(workspace, sim_name)
    sim = flopy.mf6.MFSimulation.load(
        sim_name=sim_name, sim_ws=sim_ws, verbosity_level=verbosity_level
    )

    verbose = not silent
    # If True, solves the Neuman 1974 analytical model (very slow)
    # else uses stored results from solving the Neuman 1974 analytical model
    analytical = False

    plot_grid(verbose)
    plot_ts(sim, verbose, solve_analytical_solution=analytical)

Running the example

Define and invoke a function to run the example scenario, then plot results.

[6]:
def scenario(silent=True):
    # key = list(parameters.keys())[idx]
    # params = parameters[key].copy()
    sim = build_models(sim_name)
    if write:
        write_models(sim, silent=silent)
    if run:
        run_models(sim, silent=silent)


# MF6 Axisymmetric Model
scenario()

if plot:
    # Solve analytical and plot results with MF6 results
    plot_results()
run_models took 123.71 ms
../_images/_notebooks_ex-gwf-radial_12_1.png
../_images/_notebooks_ex-gwf-radial_12_2.png
../_images/_notebooks_ex-gwf-radial_12_3.png