diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 5e5163f5ce..8da6d1b1f1 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -124,14 +124,27 @@ jobs: run: | pip install --upgrade pip pip install . - pip install ".[test,optional]" - - - name: Install Modflow executables + pip install ".[test, optional]" + + - name: Install executables uses: modflowpy/install-modflow-action@v1 - - - name: Smoke test - working-directory: autotest - run: pytest -v -n=auto --smoke --cov=flopy --cov-report=xml --durations=0 --keep-failed=.failed + + # todo restore below before merging + - name: Install MODFLOW 6 PRT build + uses: modflowpy/install-modflow-action@v1 + with: + # repo: modflow6-nightly-build + owner: aprovost-usgs + repo: modflow6 + + - name: Update FloPy packages + if: runner.os != 'Windows' + # run: python -c 'import flopy; flopy.mf6.utils.generate_classes(ref="develop", backup=False)' + run: python -c 'import flopy; flopy.mf6.utils.generate_classes(owner="aprovost-usgs", ref="PRT", backup=False)' + + - name: Run smoke tests + working-directory: ./autotest + run: pytest -v -n=auto --smoke --durations=0 --keep-failed=.failed env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -150,7 +163,10 @@ jobs: test: name: Test - needs: smoke + needs: + - build + - lint + - smoke runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -205,22 +221,27 @@ jobs: pip install xmipy pip install . - - name: Install Modflow-related executables + - name: Install executables uses: modflowpy/install-modflow-action@v1 - - name: Install Modflow dev build executables + # todo restore below when prt merges to mf6 develop + - name: Install MODFLOW 6 PRT build uses: modflowpy/install-modflow-action@v1 with: - repo: modflow6-nightly-build + # repo: modflow6-nightly-build + owner: aprovost-usgs + repo: modflow6 - name: Update FloPy packages if: runner.os != 'Windows' - run: python -m flopy.mf6.utils.generate_classes --ref develop --no-backup + # run: python -c 'import flopy; flopy.mf6.utils.generate_classes(ref="develop", backup=False)' + run: python -c 'import flopy; flopy.mf6.utils.generate_classes(owner="aprovost-usgs", ref="PRT", backup=False)' - name: Update FloPy packages if: runner.os == 'Windows' shell: bash -l {0} - run: python -m flopy.mf6.utils.generate_classes --ref develop --no-backup + # run: python -c 'import flopy; flopy.mf6.utils.generate_classes(ref="develop", backup=False)' + run: python -c 'import flopy; flopy.mf6.utils.generate_classes(owner="aprovost-usgs", ref="PRT", backup=False)' - name: Run tests if: runner.os != 'Windows' diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index fe9b014b3b..53f3101377 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -53,7 +53,7 @@ jobs: bash powershell - - name: Install extra Python dependencies + - name: Install Python dependencies if: runner.os == 'Windows' shell: bash -l {0} run: | diff --git a/.github/workflows/mf6.yml b/.github/workflows/mf6.yml index e1709ba687..ca1508c9c8 100644 --- a/.github/workflows/mf6.yml +++ b/.github/workflows/mf6.yml @@ -42,6 +42,7 @@ jobs: pip install https://github.com/modflowpy/pymake/zipball/master pip install https://github.com/Deltares/xmipy/zipball/develop pip install https://github.com/MODFLOW-USGS/modflowapi/zipball/develop + pip install . pip install .[test,optional] - name: Install gfortran @@ -50,32 +51,28 @@ jobs: - name: Checkout MODFLOW 6 uses: actions/checkout@v4 with: - repository: MODFLOW-USGS/modflow6 + repository: aprovost-usgs/modflow6 path: modflow6 + ref: PRT - name: Update flopy MODFLOW 6 classes working-directory: modflow6/autotest - run: | - python update_flopy.py + run: python update_flopy.py - name: Install meson - run: | - pip3 install meson ninja + run: pip3 install meson ninja - name: Setup modflow working-directory: modflow6 - run: | - meson setup builddir --buildtype=debugoptimized --prefix=$(pwd) --libdir=bin + run: meson setup builddir --buildtype=debugoptimized --prefix=$(pwd) --libdir=bin - name: Build modflow working-directory: modflow6 - run: | - meson compile -C builddir + run: meson compile -C builddir - name: Install modflow working-directory: modflow6 - run: | - meson install -C builddir + run: meson install -C builddir - name: Get executables working-directory: modflow6/autotest @@ -91,12 +88,10 @@ jobs: - name: Print coverage report before upload working-directory: ./modflow6/autotest - run: | - coverage report + run: coverage report - name: Upload coverage to Codecov - if: - github.repository_owner == 'modflowpy' && (github.event_name == 'push' || github.event_name == 'pull_request') + if: github.repository_owner == 'modflowpy' && (github.event_name == 'push' || github.event_name == 'pull_request') uses: codecov/codecov-action@v3 with: files: ./modflow6/autotest/coverage.xml diff --git a/.github/workflows/rtd.yml b/.github/workflows/rtd.yml index b092e4b16e..c28206acc8 100644 --- a/.github/workflows/rtd.yml +++ b/.github/workflows/rtd.yml @@ -49,7 +49,9 @@ jobs: run: python -m pip install --upgrade pip - name: Install flopy and dependencies - run: pip install ".[test, doc, optional]" + run: | + pip install . + pip install ".[test, doc, optional]" - name: Workaround OpenGL issue on Linux if: runner.os == 'Linux' @@ -77,6 +79,18 @@ jobs: - name: Install MODFLOW executables uses: modflowpy/install-modflow-action@v1 + # todo restore below before merging + - name: Install MODFLOW 6 PRT build + uses: modflowpy/install-modflow-action@v1 + with: + owner: aprovost-usgs + repo: modflow6 + + - name: Update FloPy + run: | + python -c 'from flopy.mf6.utils import generate_classes; generate_classes(owner="aprovost-usgs", ref="PRT", backup=False)' + pip install . + - name: Run tutorial and example notebooks working-directory: autotest run: pytest -v -n auto test_notebooks.py diff --git a/autotest/test_binaryfile.py b/autotest/test_binaryfile.py index 2a27183289..9f2ad3f1e0 100644 --- a/autotest/test_binaryfile.py +++ b/autotest/test_binaryfile.py @@ -1,4 +1,3 @@ -import os from itertools import repeat import numpy as np @@ -14,7 +13,6 @@ CellBudgetFile, HeadFile, HeadUFile, - UcnFile, Util2d, ) from flopy.utils.binaryfile import ( diff --git a/autotest/test_mbase.py b/autotest/test_mbase.py index 8a2eddcadc..8e3ee5d828 100644 --- a/autotest/test_mbase.py +++ b/autotest/test_mbase.py @@ -10,7 +10,6 @@ from flopy.mbase import resolve_exe from flopy.utils.flopy_io import relpath_safe - _system = system() diff --git a/autotest/test_particledata.py b/autotest/test_particledata.py index 1f5bdc85fd..8840ef42da 100644 --- a/autotest/test_particledata.py +++ b/autotest/test_particledata.py @@ -1,8 +1,51 @@ +from functools import reduce +from itertools import chain + +import matplotlib.pyplot as plt import numpy as np +import pandas as pd +import pytest +from autotest.test_grid_cases import GridCases + +import flopy +from flopy.discretization import StructuredGrid +from flopy.mf6.modflow.mfsimulation import MFSimulation +from flopy.modflow.mf import Modflow +from flopy.modflow.mfdis import ModflowDis +from flopy.modpath import ( + CellDataType, + FaceDataType, + LRCParticleData, + Modpath7, + Modpath7Bas, + Modpath7Sim, + NodeParticleData, + ParticleData, + ParticleGroupNodeTemplate, +) +from flopy.utils.modpathfile import PathlineFile + +# utilities + + +def get_nn(grid: StructuredGrid, k, i, j): + return k * grid.nrow * grid.ncol + i * grid.ncol + j + + +def flatten(a): + return [ + [ + *chain.from_iterable( + xx if isinstance(xx, tuple) else [xx] for xx in x + ) + ] + for x in a + ] + + +# test constructors -from flopy.modpath import ParticleData -structured_plocs = [(1, 1, 1), (1, 1, 2)] structured_dtype = np.dtype( [ ("k", " Iterator[tuple]: + """ + Compute global particle coordinates on the given grid. + + Parameters + ---------- + grid : The grid on which to locate particle release points. + + Returns + ------- + Generates coordinate tuples (x, y, z) + """ + + def cvt_xy(p, vs): + mn, mx = min(vs), max(vs) + span = mx - mn + return mn + span * p + + if grid.grid_type == "structured": + if not hasattr(self.particledata, "k"): + raise ValueError( + f"Particle representation is not structured but grid is" + ) + + def cvt_z(p, k, i, j): + mn, mx = ( + grid.botm[k, i, j], + grid.top[i, j] if k == 0 else grid.botm[k - 1, i, j], + ) + span = mx - mn + return mn + span * p + + def convert(row) -> Tuple[float, float, float]: + verts = grid.get_cell_vertices(row.i, row.j) + xs, ys = list(zip(*verts)) + return [ + cvt_xy(row.localx, xs), + cvt_xy(row.localy, ys), + cvt_z(row.localz, row.k, row.i, row.j), + ] + + else: + if hasattr(self.particledata, "k"): + raise ValueError( + f"Particle representation is structured but grid is not" + ) + + def cvt_z(p, nn): + k, j = grid.get_lni([nn])[0] + mn, mx = ( + grid.botm[k, j], + grid.top[j] if k == 0 else grid.botm[k - 1, j], + ) + span = mx - mn + return mn + span * p + + def convert(row) -> Tuple[float, float, float]: + verts = grid.get_cell_vertices(row.node) + xs, ys = list(zip(*verts)) + return [ + cvt_xy(row.localx, xs), + cvt_xy(row.localy, ys), + cvt_z(row.localz, row.node), + ] + + for t in self.particledata.itertuples(): + yield convert(t) + + def to_prp(self, grid) -> Iterator[tuple]: + """ + Convert particle data to PRT particle release point (PRP) + package data entries for the given grid. A model grid is + required because MODPATH supports several ways to specify + particle release locations by cell ID and subdivision info + or local coordinates, but PRT expects global coordinates. + + Parameters + ---------- + grid : The grid on which to locate particle release points. + + Returns + ------- + Generates PRT particle release point (PRP) package + data tuples: release point index, k, [i,] j, x, y, z. + If the grid is not structured, i is omitted and j is + the within-layer cell index for vertex grids. + """ + + for i, (t, c) in enumerate( + zip( + self.particledata.itertuples(index=False), + self.to_coords(grid), + ) + ): + row = [i] # release point index (irpt) + if "node" in self.particledata: + k, j = grid.get_lni([t.node])[0] + row.extend([(k, j)]) + else: + row.extend([t.k, t.i, t.j]) + row.extend(c) + yield tuple(row) def _get_dtype(self, structured, particleid): """ @@ -421,7 +521,7 @@ def _fmt_string(self): """ fmts = [] - for field in self.particledata.dtype.descr: + for field in self.dtype.descr: vtype = field[1][1].lower() if vtype == "i" or vtype == "b": fmts.append("{:9d}") @@ -542,7 +642,6 @@ def __init__( self.columndivisions5 = columndivisions5 self.rowdivisions6 = rowdivisions6 self.columndivisions6 = columndivisions6 - return def write(self, f=None): """ @@ -637,7 +736,6 @@ def __init__( self.columncelldivisions = columncelldivisions self.rowcelldivisions = rowcelldivisions self.layercelldivisions = layercelldivisions - return def write(self, f=None): """ @@ -668,7 +766,177 @@ def write(self, f=None): ) f.write(line) - return + +def get_release_points(subdivisiondata, grid, k=None, i=None, j=None, nn=None): + if nn is None and (k is None or i is None or j is None): + raise ValueError( + f"A cell (node) must be specified by indices (for structured grids) or node number (for vertex/unstructured)" + ) + + rpts = [] + template = [k, i, j] if nn is None else [nn] + + # get cell coords and span in each dimension + if not (k is None or i is None or j is None): + verts = grid.get_cell_vertices(i, j) + minz, maxz = grid.botm[k, i, j], grid.top[i, j] + elif nn is not None: + verts = grid.get_cell_vertices(nn) + if grid.grid_type == "structured": + k, i, j = grid.get_lrc([nn])[0] + minz, maxz = ( + grid.botm[k, i, j], + grid.top[i, j] if k == 0 else grid.botm[k - 1, i, j], + ) + else: + k, j = grid.get_lni([nn])[0] + minz, maxz = ( + grid.botm[k, j], + grid.top[j] if k == 0 else grid.botm[k - 1, j], + ) + else: + raise ValueError( + f"A cell (node) must be specified by indices (for structured grids) or node number (for vertex/unstructured)" + ) + xs, ys = list(zip(*verts)) + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + xspan = maxx - minx + yspan = maxy - miny + zspan = maxz - minz + + if isinstance(subdivisiondata, FaceDataType): + # x1 (west) + if ( + subdivisiondata.verticaldivisions1 > 0 + and subdivisiondata.horizontaldivisions1 > 0 + ): + yincr = yspan / subdivisiondata.horizontaldivisions1 + ylocs = [ + (miny + (yincr * 0.5) + (yincr * d)) + for d in range(subdivisiondata.horizontaldivisions1) + ] + zincr = zspan / subdivisiondata.verticaldivisions1 + zlocs = [ + (minz + (zincr * 0.5) + (zincr * d)) + for d in range(subdivisiondata.verticaldivisions1) + ] + prod = list(product(*[ylocs, zlocs])) + rpts = rpts + [template + [minx, p[0], p[1]] for p in prod] + + # x2 (east) + if ( + subdivisiondata.verticaldivisions2 > 0 + and subdivisiondata.horizontaldivisions2 > 0 + ): + yincr = yspan / subdivisiondata.horizontaldivisions2 + ylocs = [ + (miny + (yincr * 0.5) + (yincr * d)) + for d in range(subdivisiondata.horizontaldivisions2) + ] + zincr = zspan / subdivisiondata.verticaldivisions2 + zlocs = [ + (minz + (zincr * 0.5) + (zincr * d)) + for d in range(subdivisiondata.verticaldivisions2) + ] + prod = list(product(*[ylocs, zlocs])) + rpts = rpts + [template + [maxx, p[0], p[1]] for p in prod] + + # y1 (south) + if ( + subdivisiondata.verticaldivisions3 > 0 + and subdivisiondata.horizontaldivisions3 > 0 + ): + xincr = xspan / subdivisiondata.horizontaldivisions3 + xlocs = [ + (minx + (xincr * 0.5) + (xincr * rd)) + for rd in range(subdivisiondata.horizontaldivisions3) + ] + zincr = zspan / subdivisiondata.verticaldivisions3 + zlocs = [ + (minz + (zincr * 0.5) + (zincr * d)) + for d in range(subdivisiondata.verticaldivisions3) + ] + prod = list(product(*[xlocs, zlocs])) + rpts = rpts + [template + [p[0], miny, p[1]] for p in prod] + + # y2 (north) + if ( + subdivisiondata.verticaldivisions4 > 0 + and subdivisiondata.horizontaldivisions4 > 0 + ): + xincr = xspan / subdivisiondata.horizontaldivisions4 + xlocs = [ + (minx + (xincr * 0.5) + (xincr * rd)) + for rd in range(subdivisiondata.horizontaldivisions4) + ] + zincr = zspan / subdivisiondata.verticaldivisions4 + zlocs = [ + (minz + (zincr * 0.5) + (zincr * d)) + for d in range(subdivisiondata.verticaldivisions4) + ] + prod = list(product(*[xlocs, zlocs])) + rpts = rpts + [template + [p[0], maxy, p[1]] for p in prod] + + # z1 (bottom) + if ( + subdivisiondata.rowdivisions5 > 0 + and subdivisiondata.columndivisions5 > 0 + ): + xincr = xspan / subdivisiondata.columndivisions5 + xlocs = [ + (minx + (xincr * 0.5) + (xincr * rd)) + for rd in range(subdivisiondata.columndivisions5) + ] + yincr = yspan / subdivisiondata.rowdivisions5 + ylocs = [ + (miny + (yincr * 0.5) + (yincr * rd)) + for rd in range(subdivisiondata.rowdivisions5) + ] + prod = list(product(*[xlocs, ylocs])) + rpts = rpts + [template + [p[0], p[1], minz] for p in prod] + + # z2 (top) + if ( + subdivisiondata.rowdivisions6 > 0 + and subdivisiondata.columndivisions6 > 0 + ): + xincr = xspan / subdivisiondata.columndivisions6 + xlocs = [ + (minx + (xincr * 0.5) + (xincr * rd)) + for rd in range(subdivisiondata.columndivisions6) + ] + yincr = yspan / subdivisiondata.rowdivisions6 + ylocs = [ + (miny + (yincr * 0.5) + (yincr * rd)) + for rd in range(subdivisiondata.rowdivisions6) + ] + prod = list(product(*[xlocs, ylocs])) + rpts = rpts + [template + [p[0], p[1], maxz] for p in prod] + elif isinstance(subdivisiondata, CellDataType): + xincr = xspan / subdivisiondata.columncelldivisions + xlocs = [ + (minx + (xincr * 0.5) + (xincr * rd)) + for rd in range(subdivisiondata.columncelldivisions) + ] + yincr = yspan / subdivisiondata.rowcelldivisions + ylocs = [ + (miny + (yincr * 0.5) + (yincr * d)) + for d in range(subdivisiondata.rowcelldivisions) + ] + zincr = zspan / subdivisiondata.layercelldivisions + zlocs = [ + (minz + (zincr * 0.5) + (zincr * d)) + for d in range(subdivisiondata.layercelldivisions) + ] + prod = list(product(*[xlocs, ylocs, zlocs])) + rpts = rpts + [template + [p[0], p[1], p[2]] for p in prod] + else: + raise ValueError( + f"Unsupported subdivision data type: {type(subdivisiondata)}" + ) + + return rpts class LRCParticleData: @@ -686,7 +954,7 @@ class LRCParticleData: CellDataTypes that are used to create one or more particle templates in a particle group. If subdivisiondata is None, a default CellDataType with 27 particles per cell will be created (default is None). - lrcregions : list of lists tuples or np.ndarrays + lrcregions : list of lists or tuples Layer, row, column (zero-based) regions with particles created using the specified template parameters. A region is defined as a list/tuple of minlayer, minrow, mincolumn, maxlayer, maxrow, maxcolumn values. @@ -699,7 +967,7 @@ class LRCParticleData: -------- >>> import flopy - >>> pg = flopy.modpath.LRCParticleData(lrcregions=[0, 0, 0, 3, 10, 10]) + >>> pg = flopy.modpath.LRCParticleData(lrcregions=[[0, 0, 0, 3, 10, 10]]) """ @@ -780,7 +1048,6 @@ def __init__(self, subdivisiondata=None, lrcregions=None): self.totalcellregioncount = totalcellregioncount self.subdivisiondata = subdivisiondata self.lrcregions = lrcregions - return def write(self, f=None): """ @@ -805,24 +1072,95 @@ def write(self, f=None): # item 2 f.write(f"{self.particletemplatecount} {self.totalcellregioncount}\n") - for sd, lrcregion in zip(self.subdivisiondata, self.lrcregions): + for sd, region in zip(self.subdivisiondata, self.lrcregions): # item 3 f.write( - f"{sd.templatesubdivisiontype} {lrcregion.shape[0]} {sd.drape}\n" + f"{sd.templatesubdivisiontype} {region.shape[0]} {sd.drape}\n" ) # item 4 or 5 sd.write(f) # item 6 - for row in lrcregion: + for row in region: line = "" for lrc in row: line += f"{lrc + 1} " line += "\n" f.write(line) - return + def to_coords(self, grid) -> Iterator[tuple]: + """ + Compute global particle coordinates on the given grid. + + Parameters + ---------- + grid : The grid on which to locate particle release points. + + Returns + ------- + Generator of coordinate tuples (x, y, z) + """ + + for region in self.lrcregions: + for row in region: + mink, mini, minj, maxk, maxi, maxj = row + for k in range(mink, maxk + 1): + for i in range(mini, maxi + 1): + for j in range(minj, maxj + 1): + for sd in self.subdivisiondata: + for rpt in get_release_points( + sd, grid, k, i, j + ): + yield (rpt[3], rpt[4], rpt[5]) + + def to_prp(self, grid) -> Iterator[tuple]: + """ + Convert particle data to PRT particle release point (PRP) + package data entries for the given grid. A model grid is + required because MODPATH supports several ways to specify + particle release locations by cell ID and subdivision info + or local coordinates, but PRT expects global coordinates. + + Parameters + ---------- + grid : The grid on which to locate particle release points. + + Returns + ------- + Generates PRT particle release point (PRP) package + data tuples: release point index, k, i, j, x, y, z + """ + + if grid.grid_type != "structured": + raise ValueError( + f"Particle representation is structured but grid is not" + ) + + irpt_offset = 0 + for region in self.lrcregions: + for row in region: + mink, mini, minj, maxk, maxi, maxj = row + for k in range(mink, maxk + 1): + for i in range(mini, maxi + 1): + for j in range(minj, maxj + 1): + for sd in self.subdivisiondata: + for irpt, rpt in enumerate( + get_release_points(sd, grid, k, i, j) + ): + assert rpt[0] == k + assert rpt[1] == i + assert rpt[2] == j + yield ( + irpt_offset + irpt, + k, + i, + j, + rpt[3], + rpt[4], + rpt[5], + ) + irpt_offset += irpt + 1 class NodeParticleData: @@ -939,7 +1277,6 @@ def __init__(self, subdivisiondata=None, nodes=None): self.totalcellcount = totalcellcount self.subdivisiondata = subdivisiondata self.nodedata = nodes - return def write(self, f=None): """ @@ -985,4 +1322,53 @@ def write(self, f=None): line += "\n" f.write(line) - return + def to_coords(self, grid) -> Iterator[tuple]: + """ + Compute global particle coordinates on the given grid. + + Parameters + ---------- + grid : The grid on which to locate particle release points. + + Returns + ------- + Generator of coordinate tuples (x, y, z) + """ + + for sd in self.subdivisiondata: + for nd in self.nodedata: + rpts = get_release_points(sd, grid, nn=int(nd[0])) + for i, rpt in enumerate(rpts): + yield (rpt[1], rpt[2], rpt[3]) + + def to_prp(self, grid) -> Iterator[tuple]: + """ + Convert particle data to PRT particle release point (PRP) + package data entries for the given grid. A model grid is + required because MODPATH supports several ways to specify + particle release locations by cell ID and subdivision info + or local coordinates, but PRT expects global coordinates. + + Parameters + ---------- + grid : The grid on which to locate particle release points. + + Returns + ------- + Generator of PRT particle release point (PRP) package + data tuples: release point index, k, j, x, y, z + """ + + for sd in self.subdivisiondata: + for nd in self.nodedata: + rpts = get_release_points(sd, grid, nn=int(nd[0])) + for irpt, rpt in enumerate(rpts): + row = [irpt] + if grid.grid_type == "structured": + k, i, j = grid.get_lrc([rpt[0]])[0] + row.extend([k, i, j]) + else: + k, j = grid.get_lni([rpt[0]])[0] + row.extend([(k, j)]) + row.extend([rpt[1], rpt[2], rpt[3]]) + yield tuple(row) diff --git a/flopy/plot/crosssection.py b/flopy/plot/crosssection.py index 1e50ee69e8..8e870aa748 100644 --- a/flopy/plot/crosssection.py +++ b/flopy/plot/crosssection.py @@ -6,10 +6,12 @@ import numpy as np import pandas as pd from matplotlib.patches import Polygon +from numpy.lib.recfunctions import stack_arrays from ..utils import geometry, import_optional_dependency from ..utils.geospatial_utils import GeoSpatialUtil from . import plotutil +from .plotutil import to_mp7_endpoints, to_mp7_pathlines warnings.simplefilter("always", PendingDeprecationWarning) @@ -1054,15 +1056,27 @@ def plot_pathline( self, pl, travel_time=None, method="cell", head=None, **kwargs ): """ - Plot the MODPATH pathlines + Plot particle pathlines. Compatible with MODFLOW 6 PRT particle track + data format, or MODPATH 6 or 7 pathline data format. Parameters ---------- - pl : list of rec arrays or a single rec array - rec array or list of rec arrays is data returned from - modpathfile PathlineFile get_data() or get_alldata() - methods. Data in rec array is 'x', 'y', 'z', 'time', - 'k', and 'particleid'. + pl : list of recarrays or dataframes, or a single recarray or dataframe + Particle pathline data. If a list of recarrays or dataframes, + each must contain the path of only a single particle. If just + one recarray or dataframe, it should contain the paths of all + particles. The flopy.utils.modpathfile.PathlineFile.get_data() + or get_alldata() return value may be passed directly as this + argument. + + For MODPATH 6 or 7 pathlines, columns must include 'x', 'y', 'z', + 'time', 'k', and 'particleid'. Additional columns are ignored. + + For MODFLOW 6 PRT pathlines, columns must include 'x', 'y', 'z', + 't', 'trelease', 'imdl', 'iprp', 'irpt', and 'ilay'. Additional + columns are ignored. Note that MODFLOW 6 PRT does not assign to + particles a unique ID, but infers particle identity from 'imdl', + 'iprp', 'irpt', and 'trelease' combos (i.e. via composite key). travel_time : float or str travel_time is a travel time selection for the displayed pathlines. If a float is passed then pathlines with times @@ -1086,32 +1100,67 @@ def plot_pathline( Returns ------- lc : matplotlib.collections.LineCollection - + The pathlines added to the plot. """ from matplotlib.collections import LineCollection - # make sure pathlines is a list + # make sure pl is a list if not isinstance(pl, list): - pids = np.unique(pl["particleid"]) - if len(pids) > 1: - pl = [pl[pl["particleid"] == pid] for pid in pids] - else: - pl = [pl] + if not isinstance(pl, (np.ndarray, pd.DataFrame)): + raise TypeError( + "Pathline data must be a list of recarrays or dataframes, " + f"or a single recarray or dataframe, got {type(pl)}" + ) + pl = [pl] - # make sure each element in pl is a recarray + # convert dataframes to recarrays if needed pl = [ p.to_records(index=False) if isinstance(p, pd.DataFrame) else p for p in pl ] + def convert(p): + mp7_names = ["x", "y", "z", "time", "k", "particleid"] + prt_names = [ + "x", + "y", + "z", + "t", + "trelease", + "imdl", + "iprp", + "irpt", + "ilay", + ] + + # check format + if not ( + all(n in p.dtype.names for n in mp7_names) + or all(n in p.dtype.names for n in prt_names) + ): + raise ValueError( + "Pathline data must contain all of the following columns: " + f"{mp7_names} for MODPATH 6-7, or {prt_names} for MODFLOW 6 PRT" + ) + + return to_mp7_pathlines(p) if "t" in p.dtype.names else p + + # convert prt to mp7 format if needed + pl = [convert(p) for p in pl] + + # merge pathlines then split on particleid + pls = stack_arrays(pl, asrecarray=True, usemask=False) + pids = np.unique(pls["particleid"]) + pl = [pls[pls["particleid"] == pid] for pid in pids] + + # configure plot settings marker = kwargs.pop("marker", None) markersize = kwargs.pop("markersize", None) markersize = kwargs.pop("ms", markersize) markercolor = kwargs.pop("markercolor", None) markerevery = kwargs.pop("markerevery", 1) ax = kwargs.pop("ax", self.ax) - if "colors" not in kwargs: kwargs["colors"] = "0.5" @@ -1176,7 +1225,7 @@ def plot_timeseries( self, ts, travel_time=None, method="cell", head=None, **kwargs ): """ - Plot the MODPATH timeseries. + Plot the MODPATH timeseries. Not compatible with MODFLOW 6 PRT. Parameters ---------- @@ -1221,22 +1270,64 @@ def plot_endpoint( **kwargs, ): """ + Plot particle endpoints. Compatible with MODFLOW 6 PRT particle + track data format, or MODPATH 6 or 7 endpoint data format. Parameters ---------- - + ep : recarray or dataframe + A numpy recarray with the endpoint particle data from the + MODPATH endpoint file. + + For MODFLOW 6 PRT pathlines, columns must include 'x', 'y', 'z', + 't', 'trelease', 'imdl', 'iprp', 'irpt', and 'ilay'. Additional + columns are ignored. Note that MODFLOW 6 PRT does not assign to + particles a unique ID, but infers particle identity from 'imdl', + 'iprp', 'irpt', and 'trelease' combos (i.e. via composite key). + direction : str + String defining if starting or ending particle locations should be + considered. (default is 'ending') + selection : tuple + tuple that defines the zero-base layer, row, column location + (l, r, c) to use to make a selection of particle endpoints. + The selection could be a well location to determine capture zone + for the well. If selection is None, all particle endpoints for + the user-sepcified direction will be plotted. (default is None) + selection_direction : str + String defining is a selection should be made on starting or + ending particle locations. If selection is not None and + selection_direction is None, the selection direction will be set + to the opposite of direction. (default is None) + + kwargs : ax, c, s or size, colorbar, colorbar_label, shrink. The + remaining kwargs are passed into the matplotlib scatter + method. If colorbar is True a colorbar will be added to the plot. + If colorbar_label is passed in and colorbar is True then + colorbar_label will be passed to the colorbar set_label() + method. If shrink is passed in and colorbar is True then + the colorbar size will be set using shrink. Returns ------- + sp : matplotlib.collections.PathCollection + The PathCollection added to the plot. """ - ax = kwargs.pop("ax", self.ax) - # colorbar kwargs + # convert ep to recarray if needed + if isinstance(ep, pd.DataFrame): + ep = ep.to_records(index=False) + + # convert ep from prt to mp7 format if needed + if "t" in ep.dtype.names: + from .plotutil import to_mp7_endpoints + + ep = to_mp7_endpoints(ep) + + # configure plot settings + ax = kwargs.pop("ax", self.ax) createcb = kwargs.pop("colorbar", False) colorbar_label = kwargs.pop("colorbar_label", "Endpoint Time") shrink = float(kwargs.pop("shrink", 1.0)) - - # marker kwargs s = kwargs.pop("s", np.sqrt(50)) s = float(kwargs.pop("size", s)) ** 2.0 diff --git a/flopy/plot/map.py b/flopy/plot/map.py index 98ecdd44d8..2bef039da7 100644 --- a/flopy/plot/map.py +++ b/flopy/plot/map.py @@ -6,9 +6,11 @@ import pandas as pd from matplotlib.collections import LineCollection, PathCollection from matplotlib.path import Path +from numpy.lib.recfunctions import stack_arrays from ..utils import geometry from . import plotutil +from .plotutil import to_mp7_endpoints, to_mp7_pathlines warnings.simplefilter("always", PendingDeprecationWarning) @@ -696,7 +698,8 @@ def plot_vector( def plot_pathline(self, pl, travel_time=None, **kwargs): """ - Plot MODPATH pathlines. + Plot particle pathlines. Compatible with MODFLOW 6 PRT particle track + data format, or MODPATH 6 or 7 pathline data format. Parameters ---------- @@ -704,11 +707,18 @@ def plot_pathline(self, pl, travel_time=None, **kwargs): Particle pathline data. If a list of recarrays or dataframes, each must contain the path of only a single particle. If just one recarray or dataframe, it should contain the paths of all - particles. Pathline data returned from PathlineFile.get_data() - or get_alldata() can be passed directly as this argument. Data - columns should be 'x', 'y', 'z', 'time', 'k', and 'particleid' - at minimum. Additional columns are ignored. The 'particleid' - column must be unique to each particle path. + particles. The flopy.utils.modpathfile.PathlineFile.get_data() + or get_alldata() return value may be passed directly as this + argument. + + For MODPATH 6 or 7 pathlines, columns must include 'x', 'y', 'z', + 'time', 'k', and 'particleid'. Additional columns are ignored. + + For MODFLOW 6 PRT pathlines, columns must include 'x', 'y', 'z', + 't', 'trelease', 'imdl', 'iprp', 'irpt', and 'ilay'. Additional + columns are ignored. Note that MODFLOW 6 PRT does not assign to + particles a unique ID, but infers particle identity from 'imdl', + 'iprp', 'irpt', and 'trelease' combos (i.e. via composite key). travel_time : float or str Travel time selection. If a float, then pathlines with total time less than or equal to the given value are plotted. If a @@ -729,19 +739,56 @@ def plot_pathline(self, pl, travel_time=None, **kwargs): from matplotlib.collections import LineCollection - # make sure pathlines is a list + # make sure pl is a list if not isinstance(pl, list): - pids = np.unique(pl["particleid"]) - if len(pids) > 1: - pl = [pl[pl["particleid"] == pid] for pid in pids] - else: - pl = [pl] + if not isinstance(pl, (np.ndarray, pd.DataFrame)): + raise TypeError( + "Pathline data must be a list of recarrays or dataframes, " + f"or a single recarray or dataframe, got {type(pl)}" + ) + pl = [pl] + # convert dataframes to recarrays if needed pl = [ p.to_records(index=False) if isinstance(p, pd.DataFrame) else p for p in pl ] + def convert(p): + mp7_names = ["x", "y", "z", "time", "k", "particleid"] + prt_names = [ + "x", + "y", + "z", + "t", + "trelease", + "imdl", + "iprp", + "irpt", + "ilay", + ] + + # check format + if not ( + all(n in p.dtype.names for n in mp7_names) + or all(n in p.dtype.names for n in prt_names) + ): + raise ValueError( + "Pathline data must contain all of the following columns: " + f"{mp7_names} for MODPATH 6-7, or {prt_names} for MODFLOW 6 PRT" + ) + + return to_mp7_pathlines(p) if "t" in p.dtype.names else p + + # convert prt to mp7 format if needed + pl = [convert(p) for p in pl] + + # merge pathlines then split on particleid + pls = stack_arrays(pl, asrecarray=True, usemask=False) + pids = np.unique(pls["particleid"]) + pl = [pls[pls["particleid"] == pid] for pid in pids] + + # configure layer if "layer" in kwargs: kon = kwargs.pop("layer") if isinstance(kon, bytes): @@ -754,19 +801,21 @@ def plot_pathline(self, pl, travel_time=None, **kwargs): else: kon = self.layer + # configure plot settings marker = kwargs.pop("marker", None) markersize = kwargs.pop("markersize", None) markersize = kwargs.pop("ms", markersize) markercolor = kwargs.pop("markercolor", None) markerevery = kwargs.pop("markerevery", 1) ax = kwargs.pop("ax", self.ax) - if "colors" not in kwargs: kwargs["colors"] = "0.5" + # compose pathlines linecol = [] markers = [] for p in pl: + # filter by travel time tp = plotutil.filter_modpath_by_travel_time(p, travel_time) # transform data! @@ -777,8 +826,10 @@ def plot_pathline(self, pl, travel_time=None, **kwargs): self.mg.yoffset, self.mg.angrot_radians, ) + # build polyline array arr = np.vstack((x0r, y0r)).T + # select based on layer if kon >= 0: kk = p["k"].copy().reshape(p.shape[0], 1) @@ -786,7 +837,8 @@ def plot_pathline(self, pl, travel_time=None, **kwargs): arr = np.ma.masked_where((kk != kon), arr) else: arr = np.ma.asarray(arr) - # append line to linecol if there is some unmasked segment + + # append pathline if there are any unmasked segments if not arr.mask.all(): linecol.append(arr) if not arr.mask.all(): @@ -795,6 +847,7 @@ def plot_pathline(self, pl, travel_time=None, **kwargs): for xy in arr[::markerevery]: if not np.all(xy.mask): markers.append(xy) + # create line collection lc = None if len(linecol) > 0: @@ -811,13 +864,15 @@ def plot_pathline(self, pl, travel_time=None, **kwargs): ms=markersize, ) + # set axis limits ax.set_xlim(self.extent[0], self.extent[1]) ax.set_ylim(self.extent[2], self.extent[3]) + return lc def plot_timeseries(self, ts, travel_time=None, **kwargs): """ - Plot MODPATH timeseries. + Plot MODPATH 6 or 7 timeseries. Incompatible with MODFLOW 6 PRT. Parameters ---------- @@ -861,13 +916,20 @@ def plot_endpoint( **kwargs, ): """ - Plot MODPATH endpoints. + Plot particle endpoints. Compatible with MODFLOW 6 PRT particle + track data format, or MODPATH 6 or 7 endpoint data format. Parameters ---------- ep : recarray or dataframe A numpy recarray with the endpoint particle data from the - MODPATH endpoint file + MODPATH endpoint file. + + For MODFLOW 6 PRT pathlines, columns must include 'x', 'y', 'z', + 't', 'trelease', 'imdl', 'iprp', 'irpt', and 'ilay'. Additional + columns are ignored. Note that MODFLOW 6 PRT does not assign to + particles a unique ID, but infers particle identity from 'imdl', + 'iprp', 'irpt', and 'trelease' combos (i.e. via composite key). direction : str String defining if starting or ending particle locations should be considered. (default is 'ending') @@ -898,6 +960,17 @@ def plot_endpoint( """ + # convert ep to recarray if needed + if isinstance(ep, pd.DataFrame): + ep = ep.to_records(index=False) + + # convert ep from prt to mp7 format if needed + if "t" in ep.dtype.names: + from .plotutil import to_mp7_endpoints + + ep = to_mp7_endpoints(ep) + + # parse selection options ax = kwargs.pop("ax", self.ax) tep, _, xp, yp = plotutil.parse_modpath_selection_options( ep, direction, selection, selection_direction diff --git a/flopy/plot/plotutil.py b/flopy/plot/plotutil.py index 52001cbd87..192edcda73 100644 --- a/flopy/plot/plotutil.py +++ b/flopy/plot/plotutil.py @@ -10,6 +10,7 @@ import matplotlib.pyplot as plt import numpy as np +import pandas as pd from ..datbase import DataInterface, DataType from ..utils import Util3d, import_optional_dependency @@ -2591,3 +2592,218 @@ def parse_modpath_selection_options( tep = ep.copy() return tep, istart, xp, yp + + +def to_mp7_pathlines(data) -> pd.DataFrame: + """ + Convert MODFLOW 6 PRT pathline data to MODPATH 7 pathline format. + + Parameters + ---------- + data : np.recarray or pd.DataFrame + MODFLOW 6 PRT pathline data + + Returns + ------- + pd.DataFrame + """ + + # convert to dataframe if needed + if isinstance(data, np.recarray): + data = pd.DataFrame(data) + + # assign a unique particle index column incrementing an integer + # for each unique combination of irpt, iprp, imdl, and trelease + data = data.sort_values(["imdl", "iprp", "irpt", "trelease"]) + particles = data.groupby(["imdl", "iprp", "irpt", "trelease"]) + seqn_key = "sequencenumber" + data[seqn_key] = particles.ngroup() + + # convert to recarray + data = data.to_records(index=False) + + # define mp7 dtype + mp7_dtypes = np.dtype( + [ + ("particleid", np.int32), # same as sequencenumber + ("particlegroup", np.int32), + ( + seqn_key, + np.int32, + ), # mp7 sequencenumber (globally unique auto-generated ID) + ( + "particleidloc", + np.int32, + ), # mp7 particle ID (unique within a group, user-assigned or autogenerated) + ("time", np.float32), + ("x", np.float32), + ("y", np.float32), + ("z", np.float32), + ("k", np.int32), + ("node", np.int32), + ("xloc", np.float32), + ("yloc", np.float32), + ("zloc", np.float32), + ("stressperiod", np.int32), + ("timestep", np.int32), + ] + ) + + # build mp7 format dataframe + return pd.DataFrame( + np.core.records.fromarrays( + [ + data[seqn_key], + data["iprp"], + data[seqn_key], + data["irpt"], + data["t"], + data["x"], + data["y"], + data["z"], + data["ilay"], + data["icell"], + # todo local coords (xloc, yloc, zloc) + np.zeros(data.shape[0]), + np.zeros(data.shape[0]), + np.zeros(data.shape[0]), + data["kper"], + data["kstp"], + ], + dtype=mp7_dtypes, + ) + ) + + +def to_mp7_endpoints(data) -> pd.DataFrame: + """ + Convert MODFLOW 6 PRT pathline data to MODPATH 7 endpoint format. + + Parameters + ---------- + data : np.recarray or pd.DataFrame + MODFLOW 6 PRT pathline data + + Returns + ------- + pd.DataFrame + """ + + # convert to dataframe if needed + if isinstance(data, np.recarray): + data = pd.DataFrame(data) + + # assign a unique particle index column incrementing an integer + # for each unique combination of irpt, iprp, imdl, and trelease + data = data.sort_values(["imdl", "iprp", "irpt", "trelease"]) + particles = data.groupby(["imdl", "iprp", "irpt", "trelease"]) + seqn_key = "sequencenumber" + data[seqn_key] = particles.ngroup() + + # select startpoints and endpoints, sort by sequencenumber + startpts = ( + data.sort_values("t").groupby(seqn_key).head(1).sort_values(seqn_key) + ) + endpts = ( + data.sort_values("t").groupby(seqn_key).tail(1).sort_values(seqn_key) + ) + + # add new columns for: + # - initial coordinates + # - initial zone + # - initial node number + # - initial layer + starttimes = startpts["t"].to_numpy() + startxs = startpts["x"].to_numpy() + startys = startpts["y"].to_numpy() + startzs = startpts["z"].to_numpy() + startzones = startpts["izone"].to_numpy() + startnodes = startpts["icell"].to_numpy() + startlayers = startpts["ilay"].to_numpy() + conditions = [ + startpts[seqn_key].eq(row[seqn_key]) for i, row in startpts.iterrows() + ] + endpts["x0"] = np.select(conditions, startxs) + endpts["y0"] = np.select(conditions, startys) + endpts["z0"] = np.select(conditions, startzs) + endpts["zone0"] = np.select(conditions, startzones) + endpts["node0"] = np.select(conditions, startnodes) + endpts["k0"] = np.select(conditions, startlayers) + + # convert to recarray + endpts = endpts.to_records(index=False) + + # define mp7 dtype + mp7_dtype = np.dtype( + [ + ( + "particleid", + np.int32, + ), # mp7 sequencenumber (globally unique auto-generated ID) + ("particlegroup", np.int32), + ( + "particleidloc", + np.int32, + ), # mp7 particle ID (unique within a group, user-assigned or autogenerated) + ("status", np.int32), + ("time0", np.float32), + ("time", np.float32), + ("node0", np.int32), + ("k0", np.int32), + ("xloc0", np.float32), + ("yloc0", np.float32), + ("zloc0", np.float32), + ("x0", np.float32), + ("y0", np.float32), + ("z0", np.float32), + ("zone0", np.int32), + ("initialcellface", np.int32), + ("node", np.int32), + ("k", np.int32), + ("xloc", np.float32), + ("yloc", np.float32), + ("zloc", np.float32), + ("x", np.float32), + ("y", np.float32), + ("z", np.float32), + ("zone", np.int32), + ("cellface", np.int32), + ] + ) + + # build mp7 format dataframe + return pd.DataFrame( + np.core.records.fromarrays( + [ + endpts["sequencenumber"], + endpts["iprp"], + endpts["irpt"], + endpts["istatus"], + endpts["trelease"], + endpts["t"], + endpts["node0"], + endpts["k0"], + # todo initial local coords (xloc0, yloc0, zloc0) + np.zeros(endpts.shape[0]), + np.zeros(endpts.shape[0]), + np.zeros(endpts.shape[0]), + endpts["x0"], + endpts["y0"], + endpts["z0"], + endpts["zone0"], + np.zeros(endpts.shape[0]), # todo initial cell face? + endpts["icell"], + endpts["ilay"], + # todo local coords (xloc, yloc, zloc) + np.zeros(endpts.shape[0]), + np.zeros(endpts.shape[0]), + np.zeros(endpts.shape[0]), + endpts["x"], + endpts["y"], + endpts["z"], + endpts["izone"], + np.zeros(endpts.shape[0]), # todo cell face? + ], + dtype=mp7_dtype, + ) + ) diff --git a/flopy/utils/binaryfile.py b/flopy/utils/binaryfile.py index cf384fa359..875cda8944 100644 --- a/flopy/utils/binaryfile.py +++ b/flopy/utils/binaryfile.py @@ -11,7 +11,7 @@ import os import warnings from pathlib import Path -from typing import Optional, Union +from typing import List, Optional, Union import numpy as np @@ -1580,7 +1580,7 @@ def get_data( text=None, paknam=None, full3D=False, - ): + ) -> Union[List, np.ndarray]: """ Get data from the binary budget file.