minires.grid

Tools for working with model grid coordinates.

Most functions here are barely in use, mostly serving as reference. After all, it is surprisingly hard to remember which direction and index is for x and which is for y.

The index ordering is "C-style" (numpy default). This choice means that x is the 1st coord., y is 2nd, and is hardcoded in the reservoir simulator model code (in what takes place between np.ravel and np.reshape, both of which are configured to use row-major index ordering. "F-style" (column-major) indexing implementation is perfectly possible, but would imply an undue amount hassle). Conveniently, it also means that x and y tend to occur in alphabetic order. Thus, in printing a matrix of a field, the x coordinate corresponds to the row index. By contrast, the plotting module depicts x from left to right, y from bottom to top.

  1"""Tools for working with model grid coordinates.
  2
  3Most functions here are barely in use, mostly serving as reference.
  4After all, it is surprisingly hard to remember
  5which direction and index is for x and which is for y.
  6
  7The index ordering is "C-style" (numpy default).
  8This choice means that `x` is the 1st coord., `y` is 2nd,
  9and is hardcoded in the reservoir simulator model code
 10(in what takes place **between** `np.ravel` and `np.reshape`,
 11both of which are configured to use row-major index ordering.
 12"F-style" (column-major) indexing implementation is perfectly possible,
 13but would imply an undue amount hassle).
 14Conveniently, it also means that `x` and `y` tend to occur in alphabetic order.
 15Thus, in printing a matrix of a field, the `x` coordinate corresponds to the row index.
 16By contrast, the plotting module depicts `x` from left to right, `y` from bottom to top.
 17"""
 18
 19from dataclasses import dataclass
 20from typing import NamedTuple, overload
 21
 22import numpy as np
 23import numpy.typing as npt
 24
 25
 26@dataclass
 27class Grid2D:
 28    """Defines a 2D rectangular grid.
 29
 30    Example (2 x-nodes, 5 y-nodes):
 31    >>> grid = Grid2D(Lx=6, Ly=10, Nx=3, Ny=5)
 32
 33    The nodes are centered in the cells:
 34    >>> X, Y = grid.mesh
 35    >>> X
 36    array([[1., 1., 1., 1., 1.],
 37           [3., 3., 3., 3., 3.],
 38           [5., 5., 5., 5., 5.]])
 39
 40    You can compute cell boundaries (i.e. non-central nodes) by adding or subtracting
 41    `hx`/2 and `hy`/2 (i.e. you will miss either boundary at 0 or `Lx` or `Ly`).
 42
 43    Test of round-trip capability of grid mapping computations:
 44    >>> ij = (0, 4)
 45    >>> grid.xy2sub(X[ij], Y[ij]) == ij
 46    array([ True,  True])
 47
 48    >>> grid.sub2xy(*ij) == (X[ij], Y[ij])
 49    array([ True,  True])
 50    """
 51
 52    Lx: float = 1.0
 53    """Physical x-length of domain."""
 54    Ly: float = 1.0
 55    """Physical y-length of domain."""
 56    Nx: int = 32
 57    """Number of grid cells (and their centres) in x dir."""
 58    Ny: int = 32
 59    """Number of grid cells (and their centres) in y dir."""
 60
 61    @property
 62    def shape(self) -> tuple:
 63        """`(Nx, Ny)`"""
 64        return self.Nx, self.Ny
 65
 66    @property
 67    def size(self) -> int:
 68        """Total number of elements."""
 69        return int(np.prod(self.shape))
 70
 71    @property
 72    def domain(self) -> tuple:
 73        """`((0, 0), (Lx, Ly))`"""
 74        return ((0, 0), (self.Lx, self.Ly))
 75
 76    @property
 77    def Nxy(self) -> int:
 78        """`Nx` * `Ny`"""
 79        return int(np.prod(self.shape))
 80
 81    @property
 82    def hx(self) -> float:
 83        """x-length of cells"""
 84        return self.Lx / self.Nx
 85
 86    @property
 87    def hy(self) -> float:
 88        """y-length of cells"""
 89        return self.Ly / self.Ny
 90
 91    @property
 92    def h2(self) -> float:
 93        """`hx` * `hy`"""
 94        return self.hx * self.hy
 95
 96    @property
 97    def mesh(self) -> tuple:
 98        """Generate 2D coordinate grid of cell centres."""
 99        xx = np.linspace(0, self.Lx, self.Nx, endpoint=False) + self.hx / 2
100        yy = np.linspace(0, self.Ly, self.Ny, endpoint=False) + self.hy / 2
101        return np.meshgrid(xx, yy, indexing="ij")
102
103    def sub2ind(
104        self, ix: int | np.ndarray, iy: int | np.ndarray
105    ) -> np.intp | np.ndarray:
106        """Convert index `(ix, iy)` to index in flattened array."""
107        idx = np.ravel_multi_index((ix, iy), self.shape)
108        return idx
109
110    def ind2sub(self, ind: int | np.intp | np.ndarray) -> np.ndarray:
111        """Inv. of `self.sub2ind`."""
112        ix, iy = np.unravel_index(ind, self.shape)
113        return np.asarray([ix, iy])
114
115    def xy2sub(self, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray:
116        """Convert physical coordinate tuple to `(ix, iy)`, ix ∈ {0, ..., Nx-1}.
117
118        .. warning:: `xy2sub` and `xy2ind` *round* to nearest cell center.
119
120            I.e. they are not injective.
121            The alternative would be to return some kind
122            of interpolation weights distributing `(x, y)` over multiple nodes.
123            This was tried and rejected (not worth it), ref "Missing features"
124            section of docs.
125        """
126        x = np.asarray(x)
127        y = np.asarray(y)
128        # Don't silence errors! Validation is useful in optimisation (e.g.)
129        assert np.all(x <= self.Lx)
130        assert np.all(y <= self.Ly)
131        # Set upper border values to slightly interior.
132        # NB: the nudge is *relative*, `Lx` being of whatever magnitude the
133        # units imply (ref `minires.ResSim.cdarcy`).
134        x = x.clip(max=self.Lx * (1 - 1e-12))
135        y = y.clip(max=self.Ly * (1 - 1e-12))
136        ix = np.floor(x / self.Lx * self.Nx).astype(int)
137        iy = np.floor(y / self.Ly * self.Ny).astype(int)
138        return np.asarray([ix, iy])
139
140    # Overloaded so that the (array-valued) well lookups of `ResSim` type-check
141    @overload
142    def xy2ind(self, x: float, y: float) -> np.intp: ...
143    @overload
144    def xy2ind(self, x: np.ndarray, y: np.ndarray) -> np.ndarray: ...
145
146    def xy2ind(self, x: npt.ArrayLike, y: npt.ArrayLike) -> np.intp | np.ndarray:
147        """Convert physical coord to flat indx."""
148        i, j = self.xy2sub(x, y)
149        return self.sub2ind(i, j)
150
151    def sub2xy(self, ix: npt.ArrayLike, iy: npt.ArrayLike) -> np.ndarray:
152        """Inverse of `self.xy2sub`."""
153        x = (np.asarray(ix) + 0.5) * self.hx
154        y = (np.asarray(iy) + 0.5) * self.hy
155        return np.asarray([x, y])
156
157    def ind2xy(self, ind: int | np.intp | np.ndarray) -> np.ndarray:
158        """Inverse of `self.xy2ind`."""
159        i, j = self.ind2sub(ind)
160        return self.sub2xy(i, j)
161
162    def _crossings(self, p0: np.ndarray, d: np.ndarray) -> np.ndarray:
163        """Parameters $t ∈ [0, 1]$ at which `p0 + t*d` crosses a cell boundary."""
164        ts = [0.0, 1.0]
165        for ax, h in enumerate([self.hx, self.hy]):
166            if d[ax] == 0:
167                continue
168            lo, hi = sorted([p0[ax], p0[ax] + d[ax]])
169            ts += [
170                (i * h - p0[ax]) / d[ax]
171                for i in range(int(np.floor(lo / h)) + 1, int(np.ceil(hi / h)))
172            ]
173        return np.unique(np.clip(ts, 0, 1))  # NB: `unique` also sorts
174
175
176class Fluxes(NamedTuple):
177    """Data container with dot (attr) access for cell face fluxes. Mimicks matlab code.
178
179    Positive is in the direction of increasing index. The fluxes through the
180    *boundary* faces are `0`: the reservoir is closed (no-flow) all around.
181    """
182
183    x: np.ndarray
184    """Fluxes through the x-normal faces. Shape `(Nx+1, Ny)`."""
185    y: np.ndarray
186    """Fluxes through the y-normal faces. Shape `(Nx, Ny+1)`."""
@dataclass
class Grid2D:
 27@dataclass
 28class Grid2D:
 29    """Defines a 2D rectangular grid.
 30
 31    Example (2 x-nodes, 5 y-nodes):
 32    >>> grid = Grid2D(Lx=6, Ly=10, Nx=3, Ny=5)
 33
 34    The nodes are centered in the cells:
 35    >>> X, Y = grid.mesh
 36    >>> X
 37    array([[1., 1., 1., 1., 1.],
 38           [3., 3., 3., 3., 3.],
 39           [5., 5., 5., 5., 5.]])
 40
 41    You can compute cell boundaries (i.e. non-central nodes) by adding or subtracting
 42    `hx`/2 and `hy`/2 (i.e. you will miss either boundary at 0 or `Lx` or `Ly`).
 43
 44    Test of round-trip capability of grid mapping computations:
 45    >>> ij = (0, 4)
 46    >>> grid.xy2sub(X[ij], Y[ij]) == ij
 47    array([ True,  True])
 48
 49    >>> grid.sub2xy(*ij) == (X[ij], Y[ij])
 50    array([ True,  True])
 51    """
 52
 53    Lx: float = 1.0
 54    """Physical x-length of domain."""
 55    Ly: float = 1.0
 56    """Physical y-length of domain."""
 57    Nx: int = 32
 58    """Number of grid cells (and their centres) in x dir."""
 59    Ny: int = 32
 60    """Number of grid cells (and their centres) in y dir."""
 61
 62    @property
 63    def shape(self) -> tuple:
 64        """`(Nx, Ny)`"""
 65        return self.Nx, self.Ny
 66
 67    @property
 68    def size(self) -> int:
 69        """Total number of elements."""
 70        return int(np.prod(self.shape))
 71
 72    @property
 73    def domain(self) -> tuple:
 74        """`((0, 0), (Lx, Ly))`"""
 75        return ((0, 0), (self.Lx, self.Ly))
 76
 77    @property
 78    def Nxy(self) -> int:
 79        """`Nx` * `Ny`"""
 80        return int(np.prod(self.shape))
 81
 82    @property
 83    def hx(self) -> float:
 84        """x-length of cells"""
 85        return self.Lx / self.Nx
 86
 87    @property
 88    def hy(self) -> float:
 89        """y-length of cells"""
 90        return self.Ly / self.Ny
 91
 92    @property
 93    def h2(self) -> float:
 94        """`hx` * `hy`"""
 95        return self.hx * self.hy
 96
 97    @property
 98    def mesh(self) -> tuple:
 99        """Generate 2D coordinate grid of cell centres."""
100        xx = np.linspace(0, self.Lx, self.Nx, endpoint=False) + self.hx / 2
101        yy = np.linspace(0, self.Ly, self.Ny, endpoint=False) + self.hy / 2
102        return np.meshgrid(xx, yy, indexing="ij")
103
104    def sub2ind(
105        self, ix: int | np.ndarray, iy: int | np.ndarray
106    ) -> np.intp | np.ndarray:
107        """Convert index `(ix, iy)` to index in flattened array."""
108        idx = np.ravel_multi_index((ix, iy), self.shape)
109        return idx
110
111    def ind2sub(self, ind: int | np.intp | np.ndarray) -> np.ndarray:
112        """Inv. of `self.sub2ind`."""
113        ix, iy = np.unravel_index(ind, self.shape)
114        return np.asarray([ix, iy])
115
116    def xy2sub(self, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray:
117        """Convert physical coordinate tuple to `(ix, iy)`, ix ∈ {0, ..., Nx-1}.
118
119        .. warning:: `xy2sub` and `xy2ind` *round* to nearest cell center.
120
121            I.e. they are not injective.
122            The alternative would be to return some kind
123            of interpolation weights distributing `(x, y)` over multiple nodes.
124            This was tried and rejected (not worth it), ref "Missing features"
125            section of docs.
126        """
127        x = np.asarray(x)
128        y = np.asarray(y)
129        # Don't silence errors! Validation is useful in optimisation (e.g.)
130        assert np.all(x <= self.Lx)
131        assert np.all(y <= self.Ly)
132        # Set upper border values to slightly interior.
133        # NB: the nudge is *relative*, `Lx` being of whatever magnitude the
134        # units imply (ref `minires.ResSim.cdarcy`).
135        x = x.clip(max=self.Lx * (1 - 1e-12))
136        y = y.clip(max=self.Ly * (1 - 1e-12))
137        ix = np.floor(x / self.Lx * self.Nx).astype(int)
138        iy = np.floor(y / self.Ly * self.Ny).astype(int)
139        return np.asarray([ix, iy])
140
141    # Overloaded so that the (array-valued) well lookups of `ResSim` type-check
142    @overload
143    def xy2ind(self, x: float, y: float) -> np.intp: ...
144    @overload
145    def xy2ind(self, x: np.ndarray, y: np.ndarray) -> np.ndarray: ...
146
147    def xy2ind(self, x: npt.ArrayLike, y: npt.ArrayLike) -> np.intp | np.ndarray:
148        """Convert physical coord to flat indx."""
149        i, j = self.xy2sub(x, y)
150        return self.sub2ind(i, j)
151
152    def sub2xy(self, ix: npt.ArrayLike, iy: npt.ArrayLike) -> np.ndarray:
153        """Inverse of `self.xy2sub`."""
154        x = (np.asarray(ix) + 0.5) * self.hx
155        y = (np.asarray(iy) + 0.5) * self.hy
156        return np.asarray([x, y])
157
158    def ind2xy(self, ind: int | np.intp | np.ndarray) -> np.ndarray:
159        """Inverse of `self.xy2ind`."""
160        i, j = self.ind2sub(ind)
161        return self.sub2xy(i, j)
162
163    def _crossings(self, p0: np.ndarray, d: np.ndarray) -> np.ndarray:
164        """Parameters $t ∈ [0, 1]$ at which `p0 + t*d` crosses a cell boundary."""
165        ts = [0.0, 1.0]
166        for ax, h in enumerate([self.hx, self.hy]):
167            if d[ax] == 0:
168                continue
169            lo, hi = sorted([p0[ax], p0[ax] + d[ax]])
170            ts += [
171                (i * h - p0[ax]) / d[ax]
172                for i in range(int(np.floor(lo / h)) + 1, int(np.ceil(hi / h)))
173            ]
174        return np.unique(np.clip(ts, 0, 1))  # NB: `unique` also sorts

Defines a 2D rectangular grid.

Example (2 x-nodes, 5 y-nodes):

>>> grid = Grid2D(Lx=6, Ly=10, Nx=3, Ny=5)

The nodes are centered in the cells:

>>> X, Y = grid.mesh
>>> X
array([[1., 1., 1., 1., 1.],
       [3., 3., 3., 3., 3.],
       [5., 5., 5., 5., 5.]])

You can compute cell boundaries (i.e. non-central nodes) by adding or subtracting hx/2 and hy/2 (i.e. you will miss either boundary at 0 or Lx or Ly).

Test of round-trip capability of grid mapping computations:

>>> ij = (0, 4)
>>> grid.xy2sub(X[ij], Y[ij]) == ij
array([ True,  True])
>>> grid.sub2xy(*ij) == (X[ij], Y[ij])
array([ True,  True])
Grid2D(Lx: float = 1.0, Ly: float = 1.0, Nx: int = 32, Ny: int = 32)
Lx: float = 1.0

Physical x-length of domain.

Ly: float = 1.0

Physical y-length of domain.

Nx: int = 32

Number of grid cells (and their centres) in x dir.

Ny: int = 32

Number of grid cells (and their centres) in y dir.

shape: tuple
62    @property
63    def shape(self) -> tuple:
64        """`(Nx, Ny)`"""
65        return self.Nx, self.Ny

(Nx, Ny)

size: int
67    @property
68    def size(self) -> int:
69        """Total number of elements."""
70        return int(np.prod(self.shape))

Total number of elements.

domain: tuple
72    @property
73    def domain(self) -> tuple:
74        """`((0, 0), (Lx, Ly))`"""
75        return ((0, 0), (self.Lx, self.Ly))

((0, 0), (Lx, Ly))

Nxy: int
77    @property
78    def Nxy(self) -> int:
79        """`Nx` * `Ny`"""
80        return int(np.prod(self.shape))

Nx * Ny

hx: float
82    @property
83    def hx(self) -> float:
84        """x-length of cells"""
85        return self.Lx / self.Nx

x-length of cells

hy: float
87    @property
88    def hy(self) -> float:
89        """y-length of cells"""
90        return self.Ly / self.Ny

y-length of cells

h2: float
92    @property
93    def h2(self) -> float:
94        """`hx` * `hy`"""
95        return self.hx * self.hy

hx * hy

mesh: tuple
 97    @property
 98    def mesh(self) -> tuple:
 99        """Generate 2D coordinate grid of cell centres."""
100        xx = np.linspace(0, self.Lx, self.Nx, endpoint=False) + self.hx / 2
101        yy = np.linspace(0, self.Ly, self.Ny, endpoint=False) + self.hy / 2
102        return np.meshgrid(xx, yy, indexing="ij")

Generate 2D coordinate grid of cell centres.

def sub2ind( self, ix: int | numpy.ndarray, iy: int | numpy.ndarray) -> numpy.int64 | numpy.ndarray:
104    def sub2ind(
105        self, ix: int | np.ndarray, iy: int | np.ndarray
106    ) -> np.intp | np.ndarray:
107        """Convert index `(ix, iy)` to index in flattened array."""
108        idx = np.ravel_multi_index((ix, iy), self.shape)
109        return idx

Convert index (ix, iy) to index in flattened array.

def ind2sub(self, ind: int | numpy.int64 | numpy.ndarray) -> numpy.ndarray:
111    def ind2sub(self, ind: int | np.intp | np.ndarray) -> np.ndarray:
112        """Inv. of `self.sub2ind`."""
113        ix, iy = np.unravel_index(ind, self.shape)
114        return np.asarray([ix, iy])

Inv. of self.sub2ind.

def xy2sub(self, x: ArrayLike, y: ArrayLike) -> numpy.ndarray:
116    def xy2sub(self, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray:
117        """Convert physical coordinate tuple to `(ix, iy)`, ix ∈ {0, ..., Nx-1}.
118
119        .. warning:: `xy2sub` and `xy2ind` *round* to nearest cell center.
120
121            I.e. they are not injective.
122            The alternative would be to return some kind
123            of interpolation weights distributing `(x, y)` over multiple nodes.
124            This was tried and rejected (not worth it), ref "Missing features"
125            section of docs.
126        """
127        x = np.asarray(x)
128        y = np.asarray(y)
129        # Don't silence errors! Validation is useful in optimisation (e.g.)
130        assert np.all(x <= self.Lx)
131        assert np.all(y <= self.Ly)
132        # Set upper border values to slightly interior.
133        # NB: the nudge is *relative*, `Lx` being of whatever magnitude the
134        # units imply (ref `minires.ResSim.cdarcy`).
135        x = x.clip(max=self.Lx * (1 - 1e-12))
136        y = y.clip(max=self.Ly * (1 - 1e-12))
137        ix = np.floor(x / self.Lx * self.Nx).astype(int)
138        iy = np.floor(y / self.Ly * self.Ny).astype(int)
139        return np.asarray([ix, iy])

Convert physical coordinate tuple to (ix, iy), ix ∈ {0, ..., Nx-1}.

xy2sub and xy2ind round to nearest cell center.

I.e. they are not injective. The alternative would be to return some kind of interpolation weights distributing (x, y) over multiple nodes. This was tried and rejected (not worth it), ref "Missing features" section of docs.

def xy2ind(self, x: ArrayLike, y: ArrayLike) -> numpy.int64 | numpy.ndarray:
147    def xy2ind(self, x: npt.ArrayLike, y: npt.ArrayLike) -> np.intp | np.ndarray:
148        """Convert physical coord to flat indx."""
149        i, j = self.xy2sub(x, y)
150        return self.sub2ind(i, j)

Convert physical coord to flat indx.

def sub2xy(self, ix: ArrayLike, iy: ArrayLike) -> numpy.ndarray:
152    def sub2xy(self, ix: npt.ArrayLike, iy: npt.ArrayLike) -> np.ndarray:
153        """Inverse of `self.xy2sub`."""
154        x = (np.asarray(ix) + 0.5) * self.hx
155        y = (np.asarray(iy) + 0.5) * self.hy
156        return np.asarray([x, y])

Inverse of self.xy2sub.

def ind2xy(self, ind: int | numpy.int64 | numpy.ndarray) -> numpy.ndarray:
158    def ind2xy(self, ind: int | np.intp | np.ndarray) -> np.ndarray:
159        """Inverse of `self.xy2ind`."""
160        i, j = self.ind2sub(ind)
161        return self.sub2xy(i, j)

Inverse of self.xy2ind.

class Fluxes(typing.NamedTuple):
177class Fluxes(NamedTuple):
178    """Data container with dot (attr) access for cell face fluxes. Mimicks matlab code.
179
180    Positive is in the direction of increasing index. The fluxes through the
181    *boundary* faces are `0`: the reservoir is closed (no-flow) all around.
182    """
183
184    x: np.ndarray
185    """Fluxes through the x-normal faces. Shape `(Nx+1, Ny)`."""
186    y: np.ndarray
187    """Fluxes through the y-normal faces. Shape `(Nx, Ny+1)`."""

Data container with dot (attr) access for cell face fluxes. Mimicks matlab code.

Positive is in the direction of increasing index. The fluxes through the boundary faces are 0: the reservoir is closed (no-flow) all around.

Fluxes(x: numpy.ndarray, y: numpy.ndarray)

Create new instance of Fluxes(x, y)

x: numpy.ndarray

Fluxes through the x-normal faces. Shape (Nx+1, Ny).

y: numpy.ndarray

Fluxes through the y-normal faces. Shape (Nx, Ny+1).