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,45 +766,206 @@ 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: """ - Layer, row, column particle data template class to create MODPATH 7 - particle location input style 2 on cell faces (templatesubdivisiontype = 1) - and/or in cells (templatesubdivisiontype = 2). Particle locations for this - template are specified by layer, row, column regions. + MODPATH 7 particle release location template class for particle input style 2. + Assigns particles to locations on cell faces (templatesubdivisiontype=1) and/or + in cells (templatesubdivisiontype=2) for cells specified by (layer, row, column). Parameters ---------- - subdivisiondata : FaceDataType, CellDataType or list of FaceDataType - and/or CellDataType types - FaceDataType, CellDataType, or a list of FaceDataType and/or - 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 - 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. - If subdivisiondata is a list, a list/tuple or array of layer, row, - column regions with the same length as subdivision data must be - provided. If lrcregions is None, particles will be placed in - the first model cell (default is None). + subdivisiondata : FaceDataType, CellDataType or array-like of such, optional + Particle template(s) defining how particles are arranged within each cell. + If None, defaults to CellDataType with 27 particles per cell (default is None). + lrcregions : array-like of array-like, optional + 0-based regions (minlayer, minrow, mincolumn, maxlayer, maxrow, maxcolumn). + If subdivisiondata is array-like, regions must be the same length. + If None, particles are placed in the first model cell (default is None). Examples -------- >>> import flopy - >>> pg = flopy.modpath.LRCParticleData(lrcregions=[0, 0, 0, 3, 10, 10]) + >>> pg = flopy.modpath.LRCParticleData(lrcregions=[[0, 0, 0, 3, 10, 10]]) """ def __init__(self, subdivisiondata=None, lrcregions=None): """ Class constructor - """ self.name = "LRCParticleData" @@ -729,7 +988,7 @@ def __init__(self, subdivisiondata=None, lrcregions=None): ) # validate lrcregions data - if isinstance(lrcregions, (list, tuple)): + if isinstance(lrcregions, (list, tuple, np.ndarray)): # determine if the list or tuple contains lists or tuples alllsttup = all( isinstance(el, (list, tuple, np.ndarray)) for el in lrcregions @@ -780,7 +1039,6 @@ def __init__(self, subdivisiondata=None, lrcregions=None): self.totalcellregioncount = totalcellregioncount self.subdivisiondata = subdivisiondata self.lrcregions = lrcregions - return def write(self, f=None): """ @@ -805,46 +1063,112 @@ 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: """ - Node particle data template class to create MODPATH 7 particle location - input style 3 on cell faces (templatesubdivisiontype = 1) and/or in cells - (templatesubdivisiontype = 2). Particle locations for this template are - specified by nodes. + MODPATH 7 particle release location template class for particle input style 3. + Assigns particles to locations on cell faces (templatesubdivisiontype=1) and/or + in cells (templatesubdivisiontype=2) for cells specified by node number Parameters ---------- - subdivisiondata : FaceDataType, CellDataType or list of FaceDataType - and/or CellDataType types - FaceDataType, CellDataType, or a list of FaceDataType and/or - 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). - nodes : int, list of ints, tuple of ints, or np.ndarray - Nodes (zero-based) with particles created using the specified template - parameters. If subdivisiondata is a list, a list of nodes with the same - length as subdivision data must be provided. If nodes is None, - particles will be placed in the first model cell (default is None). + subdivisiondata : FaceDataType, CellDataType array-like of such, optional + Particle template(s) defining how particles are arranged within each cell. + If None, defaults to CellDataType with 27 particles per cell (default is None). + nodes : int or array-like of ints, optional + 0-based node numbers. If subdivisiondata is array-like, nodes must be array- + like of the same length. If None, particles are placed in the first model cell + (default is None). Examples -------- @@ -892,10 +1216,10 @@ def __init__(self, subdivisiondata=None, nodes=None): if len(nodes.shape) == 1: nodes = nodes.reshape(1, nodes.shape[0]) # convert to a list of numpy arrays - t = [] - for idx in range(nodes.shape[0]): - t.append(np.array(nodes[idx, :], dtype=np.int32)) - nodes = t + nodes = [ + np.array(nodes[i, :], dtype=np.int32) + for i in range(nodes.shape[0]) + ] elif isinstance(nodes, (list, tuple)): # convert a single list/tuple to a list of tuples if only one # entry in subdivisiondata @@ -939,7 +1263,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 +1308,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..e510f33629 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,40 @@ 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 prt to mp7 format pl = [ - p.to_records(index=False) if isinstance(p, pd.DataFrame) else p + to_mp7_pathlines( + p.to_records(index=False) if isinstance(p, pd.DataFrame) else 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 +1198,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 +1243,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 27a7ab76db..30d278c10d 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) @@ -700,7 +702,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 ---------- @@ -708,11 +711,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 @@ -733,19 +743,29 @@ 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 prt to mp7 format pl = [ - p.to_records(index=False) if isinstance(p, pd.DataFrame) else p + to_mp7_pathlines( + p.to_records(index=False) if isinstance(p, pd.DataFrame) else 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): @@ -758,19 +778,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! @@ -781,8 +803,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) @@ -790,7 +814,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(): @@ -799,6 +824,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: @@ -815,13 +841,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 ---------- @@ -865,13 +893,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') @@ -902,6 +937,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..1776916779 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,347 @@ def parse_modpath_selection_options( tep = ep.copy() return tep, istart, xp, yp + + +# define minimum expected fields +_mp7_pathline_fields = ["x", "y", "z", "time", "k", "particleid"] +_prt_pathline_fields = [ + "x", + "y", + "z", + "t", + "trelease", + "imdl", + "iprp", + "irpt", + "ilay", +] + + +def to_mp7_pathlines( + data: Union[np.recarray, pd.DataFrame] +) -> Union[np.recarray, 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 + ------- + np.recarray or pd.DataFrame (consistent with input type) + """ + + # determine return type + ret_type = type(data) + + # convert to dataframe if needed + if not isinstance(data, pd.DataFrame): + data = pd.DataFrame(data) + + # check format + dt = data.dtypes + if not ( + all(n in dt for n in _mp7_pathline_fields) + or all(n in dt for n in _prt_pathline_fields) + ): + raise ValueError( + "Pathline data must contain all of the following columns: " + f"{_mp7_pathline_fields} for MODPATH 7, or " + f"{_prt_pathline_fields} for MODFLOW 6 PRT" + ) + + # return early if already in MP7 format + if "t" not in dt: + return ( + data if ret_type == pd.DataFrame else data.to_records(index=False) + ) + + # 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 recarray + ret = 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, + ) + + return pd.DataFrame(ret) if ret_type == pd.DataFrame else ret + + +def to_mp7_endpoints( + data: Union[np.recarray, pd.DataFrame] +) -> Union[np.recarray, 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 + ------- + np.recarray or pd.DataFrame (consistent with input type) + """ + + # determine return type + ret_type = type(data) + + # 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, sorting 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 columns for + pairings = [ + # initial coordinates + ("x0", "x"), + ("y0", "y"), + ("z0", "z"), + # initial zone + ("zone0", "izone"), + # initial node number + ("node0", "icell"), + # initial layer + ("k0", "ilay"), + ] + conditions = [ + startpts[seqn_key].eq(row[seqn_key]) for _, row in startpts.iterrows() + ] + for fl, fr in pairings: + endpts[fl] = np.select(conditions, startpts[fr].to_numpy()) + + # 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 recarray + ret = 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, + ) + + return pd.DataFrame(ret) if ret_type == pd.DataFrame else ret + + +def to_prt_pathlines( + data: Union[np.recarray, pd.DataFrame] +) -> Union[np.recarray, pd.DataFrame]: + """ + Convert MODPATH 7 pathline or endpoint data to MODFLOW 6 PRT pathline format. + + Parameters + ---------- + data : np.recarray or pd.DataFrame + MODPATH 7 pathline or endpoint data + + Returns + ------- + np.recarray or pd.DataFrame (consistent with input type) + """ + + # determine return type + ret_type = type(data) + + # convert to dataframe if needed + if isinstance(data, np.recarray): + data = pd.DataFrame(data) + + # check format + dt = data.dtypes + if not ( + all(n in dt for n in _mp7_pathline_fields) + or all(n in dt for n in _prt_pathline_fields) + ): + raise ValueError( + "Pathline data must contain all of the following columns: " + f"{_mp7_pathline_fields} for MODPATH 7, or " + f"{_prt_pathline_fields} for MODFLOW 6 PRT" + ) + + # return early if already in PRT format + if "t" in dt: + return ( + data if ret_type == pd.DataFrame else data.to_records(index=False) + ) + + # convert to recarray + data = data.to_records(index=False) + + # define prt dtype + prt_dtypes = np.dtype( + [ + ("kper", np.int32), + ("kstp", np.int32), + ("imdl", np.int32), + ("iprp", np.int32), + ("irpt", np.int32), + ("ilay", np.int32), + ("icell", np.int32), + ("izone", np.int32), + ("istatus", np.int32), + ("ireason", np.int32), + ("trelease", np.float32), + ("t", np.float32), + ("x", np.float32), + ("y", np.float32), + ("z", np.float32), + ] + ) + + # build prt format recarray + ret = np.core.records.fromarrays( + [ + data["stressperiod"], + data["timestep"], + np.zeros(data.shape[0]), + np.zeros(data.shape[0]), + data["particlegroup"], + data["particleid"], + data["k"], + data["node"], + np.zeros(data.shape[0]), # todo k + np.zeros(data.shape[0]), # todo trelease? + data["t"], + data["x"], + data["y"], + data["z"], + ], + dtype=prt_dtypes, + ) + + return pd.DataFrame(ret) if ret_type == pd.DataFrame else ret 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.