A Grid class to deal with any finite difference model grid

Prof. dr.ir.T.N.Olsthoorn

Heemstede, Sept. 2016, 24 May 2017

Using a Grid class to handle spatial information regarding the grid

As we saw we often have to compute values of the grid, like the xm, dx, Nx, Ny, shape etc. A much better, more convenient and far less error-prone method of dealing with anything that has to do with the spacial dimensions of the finite difference grid is to define a Grid class, whose instances are created with the grid coordinates like so

gr = Grid(x, y, z)

after which any spatial information can be obtained from the actual Grid instanceuse, called gr in this example.

Requesting values then work like this:

gr.Nx       # int, len(x)-1
gr.shape    # tuple (Nt, Nx, Nz)
gr.xm       # ndarray, (len(x)-1
gr.Xm       # ndarray, (Ny, Nx) of x-coordinates of cell centers
gr.XM       # ndarray, (Nz, Ny, Nx) of x-coordinates of cell centers
gr.YM       # ndarray, (Nz, Ny, Nx) of y-coordinates of cell centers
gr.xm[3:10] # indexing gr.xm
gr.shape    # tuple, (Nz, Ny, Nx)
gr.area     # scalar, total area of the model
gr.Area     # ndarray, (Ny, Nx)
gr.volume   # scalar total volume of the model
gr.Volume   # ndarray, (Nz, Ny, Nx)

A large number of spacial or grid-specific variables, in fact, anything that can be computed from the coordinates can then be obtained.

The Grid class will take care of error checking and house-keeping. It can even be told to interpret the grid as axially symmetric

gr = Grid(x, y, z, axial=True)

It can guarantee a minimum layer thickness like so

gr = Grid(x, y, z, min_dz=0.001)

No matter if z used to invoke the Grid was specified as a vector telling the elevation of the tops and bottoms of uniform layers, or if z is a full-fledged 3D ndarray telling the top and bottom of all cells, Grid will handle it, in any case yielding a full 3D array of tops and bottoms when requested

gr.Z # full 3D array: shape (Ny, Nx, Nz+1)

and any other like grid related quantities that one may think of.

All grid related information is then contained in the grid object, where the z needs not be limited to a vector but may be a full 3D array of the tops and bottoms of all cells, so that each cell column can have elevations different from its neighbors. This approach is definitely much more flexible. Also, the grid can carry out all necessary error checking behing the scene which is effective as well.

The Grid class also has methods,like

A = gr.const(v)

This generates a ndarray of the size of the model with all values v if v is a scalar or with the values cells in layer i the value v[i] if v is a vector of length gr.Nz.

gr.plot(linespec)

will plot itself using the specified linespec, i.e. combination of color and linetype like used in matplotilb.plot.

Additionally, other functions like fdm3 can be adapted to simply accept a Grid object as input instead of individual x, y and z coordinates. This requires less preparation and less clutting insize fdm3, while error checking then is delegated to the Grid object.

You can learn about the Grid class by introspection or by simply loading it in an editor and studying how it was implemented. Simply typing

Grid?

Provides the help from its docstring.

Grid-adapted model fdm3

The module fdm_b of the previous chapter contains the functions unique(), quivdata() and fdm3. This model is copied below but only the functions fdm3 and quivdata have been adapted to deal with the grid object for its grid information.

The new module will be save as fdm_c.py

In [1]:
%%writefile fdm_c.py

import numpy as np
import pdb
import scipy.sparse as sp
from scipy.sparse.linalg import spsolve # to use its short name
from collections import namedtuple

class InputError(Exception):
    pass

def quivdata(Out, gr, iz=0):
    """Returns coordinates and velocity components to show velocity with quiver

    Compute arrays to display velocity vectors using matplotlib's quiver.
    The quiver is always drawn in the xy-plane for a specific layer. Default iz=0

    Parameters
    ----------
    `Out` : namedtuple holding arrays `Qx`, `Qy`, `Qz` as defined in `fdm3`
        `Qx` : ndarray, shape: (Nz, Ny, Nx-1), [L3/T]
            Interfacial flows in finite difference model in x-direction from `fdm3'
        `Qy` : ndarray, shape: (Nz, Ny-1, Nx), [L3/T]
            Interfacial flows in finite difference model in y-direction from `fdm3`
        `Qz` : ndarray, shape: (Nz-1, Ny, Nx), [L3/T]
            Interfacial flows in finite difference model in z-direction from `fdm3`
    `gr` : `grid_object` generated by Grid
    `iz` : int [-]
            iz is the number of the layer for which the data are requested,
            and all output arrays will be 2D for that layer.
            if iz==None, then all outputs will be full 3D arrays and cover all layers
            simultaneously

    Returns
    -------
    `Xm` : ndarray, shape: (Nz, Ny, Nx), [L]
        x-coordinates of cell centers
    `Ym` : ndarray, shape: (Nz, Ny, Nx), [L]
        y-coodinates of cell centers
    `ZM` : ndarray, shape: (Nz, Ny, Nx), [L]
        `z`-coordinates at cell centers
    `U` : ndarray, shape: (Nz, Ny, Nx), [L3/d]
        Flow in `x`-direction at cell centers
    `V` : ndarray, shape: (Nz, Ny, Nx), [L3/T]
        Flow in `y`-direction at cell centers
    `W` : ndarray, shape: (Nz, Ny, Nx), [L3/T]
        Flow in `z`-direction at cell centers.

    """

    X, Y = np.meshgrid(gr.xm, gr.ym) # coordinates of cell centers

    shp = (gr.Ny, gr.Nx) # 2D tuple to select a single layer

    # Flows at cell centers
    U = np.concatenate((Out.Qx[iz, :, 0].reshape((1, gr.Ny, 1)), \
                        0.5 * (Out.Qx[iz, :, :-1].reshape((1, gr.Ny, gr.Nx-2)) +\
                               Out.Qx[iz, :, 1: ].reshape((1, gr.Ny, gr.Nx-2))), \
                        Out.Qx[iz, :, -1].reshape((1, gr.Ny, 1))), axis=2).reshape(shp)
    V = np.concatenate((Out.Qy[iz, 0, :].reshape((1, 1, gr.Nx)), \
                        0.5 * (Out.Qy[iz, :-1, :].reshape((1, gr.Ny-2, gr.Nx)) +\
                               Out.Qy[iz, 1:,  :].reshape((1, gr.Ny-2, gr.Nx))), \
                        Out.Qy[iz, -1, :].reshape((1, 1, gr.Nx))), axis=1).reshape(shp)
    return X, Y, U, V


def unique(x, tol=0.0001):
    """return sorted unique values of x, keeping ascending or descending direction"""
    if x[0]>x[-1]:  # vector is reversed
        x = np.sort(x)[::-1]  # sort and reverse
        return x[np.hstack((np.diff(x) < -tol, True))]
    else:
        x = np.sort(x)
        return x[np.hstack((np.diff(x) > +tol, True))]


def fdm3(gr, kx, ky, kz, FQ, HI, IBOUND):
    '''Steady state 3D Finite Difference Model returning computed heads and flows.

    Heads and flows are returned as 3D arrays as specified under output parmeters.

    Parameters
    ----------
    'gr' : `grid_object`, generated by gr = Grid(x, y, z, ..)
    `kx`, `ky`, `kz` : ndarray, shape: (Nz, Ny, Nx), [L/T]
        hydraulic conductivities along the three axes, 3D arrays.
    `FQ` : ndarray, shape: (Nz, Ny, Nx), [L3/T]
        prescrived cell flows (injection positive, zero of no inflow/outflow)
    `IH` : ndarray, shape: (Nz, Ny, Nx), [L]
        initial heads. `IH` has the prescribed heads for the cells with prescribed head.
    `IBOUND` : ndarray, shape: (Nz, Ny, Nx) of int
        boundary array like in MODFLOW with values denoting
        * IBOUND>0  the head in the corresponding cells will be computed
        * IBOUND=0  cells are inactive, will be given value NaN
        * IBOUND<0  coresponding cells have prescribed head

    outputs
    -------
    `Out` : namedtuple containing heads and flows:
        `Out.Phi` : ndarray, shape: (Nz, Ny, Nx), [L3/T]
            computed heads. Inactive cells will have NaNs
        `Out.Q`   : ndarray, shape: (Nz, Ny, Nx), [L3/T]
            net inflow in all cells, inactive cells have 0
        `Out.Qx   : ndarray, shape: (Nz, Ny, Nx-1), [L3/T]
            intercell flows in x-direction (parallel to the rows)
        `Out.Qy`  : ndarray, shape: (Nz, Ny-1, Nx), [L3/T]
            intercell flows in y-direction (parallel to the columns)
        `Out.Qz`  : ndarray, shape: (Nz-1, Ny, Nx), [L3/T]
            intercell flows in z-direction (vertially upward postitive)
        the 3D array with the final heads with `NaN` at inactive cells.

    TO 160905
    '''

    # define the named tuple to hold all the output of the model fdm3
    Out = namedtuple('Out',['Phi', 'Q', 'Qx', 'Qy', 'Qz'])
    Out.__doc__ = """fdm3 output, <namedtuple>, containing fields Phi, Qx, Qy and Qz\n \
                    Use Out.Phi, Out.Q, Out.Qx, Out.Qy and Out.Qz"""

    if kx.shape != gr.shape:
        raise AssertionError("shape of kx {0} differs from that of model {1}".format(kx.shape,SHP))
    if ky.shape != gr.shape:
        raise AssertionError("shape of ky {0} differs from that of model {1}".format(ky.shape,SHP))
    if kz.shape != gr.shape:
        raise AssertionError("shape of kz {0} differs from that of model {1}".format(kz.shape,SHP))

    active = (IBOUND > 0).reshape(gr.Nod,)  # boolean vector denoting the active cells
    inact  = (IBOUND ==0).reshape(gr.Nod,) # boolean vector denoting inacive cells
    fxhd   = (IBOUND < 0).reshape(gr.Nod,)  # boolean vector denoting fixed-head cells

    # reshaping shorthands
    rx = lambda a : np.reshape(a, (1, 1, gr.Nx))
    ry = lambda a : np.reshape(a, (1, gr.Ny, 1))
    rz = lambda a : np.reshape(a, (gr.Nz, 1, 1))

    # half cell flow resistances
    Rx = 0.5 * rx(gr.dx) / (ry(gr.dy) * rz(gr.dz)) / kx
    Ry = 0.5 * ry(gr.dy) / (rz(gr.dz) * rx(gr.dx)) / ky
    Rz = 0.5 * rz(gr.dz) / (rx(gr.dx) * ry(gr.dy)) / kz

    # set flow resistance in inactive cells to infinite
    Rx = Rx.reshape(gr.Nod,); Rx[inact] = np.Inf; Rx=Rx.reshape(gr.shape)
    Ry = Ry.reshape(gr.Nod,); Ry[inact] = np.Inf; Ry=Ry.reshape(gr.shape)
    Rz = Rz.reshape(gr.Nod,); Rz[inact] = np.Inf; Rz=Rz.reshape(gr.shape)

    # conductances between adjacent cells
    Cx = 1 / (Rx[:, :, :-1] + Rx[:, :, 1:])
    Cy = 1 / (Ry[:, :-1, :] + Ry[:, 1:, :])
    Cz = 1 / (Rz[:-1, :, :] + Rz[1:, :, :])

    IE = gr.NOD[:, :, 1:]  # east neighbor cell numbers
    IW = gr.NOD[:, :, :-1] # west neighbor cell numbers
    IN = gr.NOD[:, :-1, :] # north neighbor cell numbers
    IS = gr.NOD[:, 1:, :]  # south neighbor cell numbers
    IT = gr.NOD[:-1, :, :] # top neighbor cell numbers
    IB = gr.NOD[1:, :, :]  # bottom neighbor cell numbers

    R = lambda x : x.ravel()  # generate anonymous function R(x) as shorthand for x.ravel()

    # notice the call  csc_matrix( (data, (rowind, coind) ), (M,N))  tuple within tupple
    # also notice that Cij = negative but that Cii will be postive, namely -sum(Cij)
    A = sp.csc_matrix(( -np.concatenate(( R(Cx), R(Cx), R(Cy), R(Cy), R(Cz), R(Cz)) ),\
                        (np.concatenate(( R(IE), R(IW), R(IN), R(IS), R(IB), R(IT)) ),\
                         np.concatenate(( R(IW), R(IE), R(IS), R(IN), R(IT), R(IB)) ),\
                      )),(gr.Nod,gr.Nod))

    # to use the vector of diagonal values in a call of sp.diags() we need to have it aa a
    # standard nondimensional numpy vector.
    # To get this:
    # - first turn the matrix obtained by A.sum(axis=1) into a np.array by np.array( .. )
    # - then take the whole column to loose the array orientation (to get a dimensionless numpy vector)
    adiag = np.array(-A.sum(axis=1))[:,0]

    Adiag = sp.diags(adiag)  # diagonal matrix with a[i,i]

    RHS = FQ.reshape((gr.Nod,1)) - A[:,fxhd].dot(HI.reshape((gr.Nod,1))[fxhd]) # Right-hand side vector

    Out.Phi = HI.flatten() # allocate space to store heads

    Out.Phi[active] = spsolve( (A+Adiag)[active][:,active] ,RHS[active] ) # solve heads at active locations

    # net cell inflow
    Out.Q  = (A+Adiag).dot(Out.Phi).reshape(gr.shape)

    # set inactive cells to NaN
    Out.Phi[inact] = np.NaN # put NaN at inactive locations

    # reshape Phi to shape of grid
    Out.Phi = Out.Phi.reshape(gr.shape)

    #Flows across cell faces
    Out.Qx =  -np.diff( Out.Phi, axis=2) * Cx
    Out.Qy =  +np.diff( Out.Phi, axis=1) * Cy
    Out.Qz =  +np.diff( Out.Phi, axis=0) * Cz

    return Out # all outputs in a named tuple for easy access
Overwriting fdm_c.py
In [2]:
import fdm_c
from importlib import reload
reload(fdm_c)
Out[2]:
<module 'fdm_c' from '/Users/Theo/GRWMODELS/Python_projects/FDM_course/fdm_c.py'>

Example

We use the same 3D example as before but now apply the grid object.

In [9]:
# Make sure that your modules like grid are in the sys.path
import sys

path2modules = './modules/'

if not path2modules in sys.path:
    sys.path.append(path2modules)

In [11]:
reload(mfgrid)
Out[11]:
<module 'mfgrid' from '/Users/Theo/GRWMODELS/Python_projects/FDM_course/mfgrid.py'>
In [12]:
gr.z
Out[12]:
array([  20.,    0.,  -10., -100.])
In [13]:
%matplotlib notebook
import matplotlib.pyplot as plt # combines namespace of numpy and pyplot

import numpy as np
import mfgrid
Grid = mfgrid.Grid

# specify a rectangular grid
x = np.arange(-1000.,  1000.,  25.)
y = np.arange( 1000., -1000., -25.)
z = np.array([20, 0 ,-10, -100.])

gr = Grid(x, y, z) # generating a grid object for this model

k = 10.0 # m/d uniform conductivity
kx = k * gr.const(k) # using gr.const(value) to generate a full 3D array of the correct shape
ky = k * gr.const(k)
kz = k * gr.const(k)

IBOUND = gr.const(1)
IBOUND[:, -1, :] = -1  # last row of model heads are prescribed
IBOUND[:, 40:45, 20:70]=0 # inactive

FQ = gr.const(0.)    # all flows zero. Note SHP is the shape of the model grid
FQ[2, 30, 25] = -1200  # extraction in this cell

HI = gr.const(0.)

Out = fdm_c.fdm3(gr, kx, ky, kz, FQ, HI, IBOUND)

layer = 2 # contours for this layer
nc = 50   # number of contours in total

plt.xlabel('x [m]')
plt.ylabel('y [m]')
plt.title("Contours (%d in total) of the head in layer %d with inactive section" % (nc, layer))
plt.contour(gr.xm, gr.ym, Out.Phi[:,:,layer], nc) # using gr here also

#plt.quiver(X, Y, U, V) # show velocity vectors
#X, Y, U, V = fdm_c.quivdata(Out, gr, iz=0) # use function in fdm_c
X, Y, U, V = gr.quivdata(Out, iz=0) # use method in Grid
plt.quiver(X, Y, U, V)
Out[13]:
<matplotlib.quiver.Quiver at 0x10eb88048>

Conclusion

Managing and providing spatial information with respect to the grid of our finite difference models can be effectively delegated to a Grid object, which is a instance of the Grid class.

We have adapted our finite differnce model fmd3 and the function quivdata to make use of the grid. We then used the grid to more effectively setup a the same model as before and handle its spatial informaion.

We finally applied the adapte function quivdata to show the quiver. We can alternatively use this function or the method quivdata implemented in the Grid class.

In [ ]: