minires.plotting

Convenient plot functions for reservoir model.

  1"""Convenient plot functions for reservoir model."""
  2
  3from typing import TYPE_CHECKING, Any, Optional, cast
  4
  5import matplotlib as mpl
  6import matplotlib.pyplot as plt
  7import numpy as np
  8from matplotlib.colors import BoundaryNorm
  9from matplotlib.ticker import MaxNLocator, MultipleLocator
 10from mpl_tools import is_inline, place, place_ax
 11from mpl_tools.misc import axprops
 12
 13if TYPE_CHECKING:
 14    from minires import ResSim
 15
 16coord_type = "absolute"
 17"""Define scaling of `Plot2D.plt_field` axes.
 18- "relative": `(0, 1)  x (0, 1)`
 19- "absolute": `(0, Lx) x (0, Ly)`
 20- "index"   : `(0, Ny) x (0, Ny)`
 21"""
 22
 23# Colormap for saturation
 24lin_cm = mpl.colors.LinearSegmentedColormap.from_list
 25cm_ow = lin_cm("", [(0, "#1d9e97"), (0.3, "#b2e0dc"), (1, "#f48974")])
 26# cOil, cWater = "red", "blue"        # Plain
 27# cOil, cWater = "#d8345f", "#01a9b4" # Pastel/neon
 28# cOil, cWater = "#e58a8a", "#086972" # Pastel
 29# ccnvrt = lambda c: np.array(mpl.colors.colorConverter.to_rgb(c))
 30# cMiddle = .3*ccnvrt(cWater) + .7*ccnvrt(cOil)
 31# cm_ow = lin_cm("", [cWater, cMiddle, cOil])
 32
 33
 34styles: dict = dict(
 35    default=dict(
 36        title="",
 37        transf=lambda x: x,
 38        cmap="viridis",
 39        levels=10,
 40        cticks=None,
 41        # Note that providing vmin/vmax (and not a levels list) to mpl
 42        # yields prettier colobar ticks, but destorys the consistency
 43        # of the colorbars from one figure to another.
 44        locator=None,
 45    ),
 46    oil=dict(
 47        title="Oil saturation",
 48        transf=lambda x: 1 - x,
 49        cmap=cm_ow,
 50        levels=np.linspace(0 - 1e-7, 1 + 1e-7, 20),
 51        cticks=np.linspace(0, 1, 6),
 52    ),
 53)
 54"""Default `Plot2D.plt_field` plot styling values."""
 55
 56
 57class Plot2D:
 58    """Plots specialized for 2D fields.
 59
 60    This mixin is not standalone but reads grid and well attributes of the
 61    `ResSim` it gets composed into, rather than re-declaring (avoids stale).
 62    """
 63
 64    def plt_field(
 65        self: "ResSim",
 66        ax: Any,
 67        Z: np.ndarray,
 68        style: str = "default",
 69        wells: Any = True,
 70        argmax: bool = False,
 71        colorbar: Any = True,
 72        labels: bool = True,
 73        grid: bool = False,
 74        finalize: bool = True,
 75        cellwise: bool = False,
 76        **kwargs,
 77    ) -> Any:
 78        """Contour-plot of the (flat) unravelled field `Z`.
 79
 80        `kwargs` falls back to `styles[style]`, which falls back to `styles['defaults']`.
 81
 82        Inactive cells (`ResSim.active`) are masked out (left blank). Note that
 83        `contourf` interpolates between cell *centres*, so it also leaves blank the
 84        half-cell margins around them (as around the domain). `cellwise=True`
 85        instead paints each cell flat (`pcolormesh`), which is exact about the
 86        cells -- the shape of a mask, a fault, the resolution -- at the cost of the
 87        smoothness; the colour levels (hence the colorbar) are the same either way.
 88
 89        `wells` marks the completions (ref `well_scatter`): `True`, `"color"`
 90        (the producers coloured as in `plt_production`), or a `dict` of options
 91        for `well_scatter` -- where `exclude=[names]` hides the wells so named
 92        (an aquifer's ring of contacts, say; ref `minires.wells.aquifer_WI`).
 93        """
 94        # Populate kwargs with fallback style
 95        kwargs = {**styles["default"], **styles[style], **kwargs}
 96        # Pop from kwargs. Remainder goes to countourf
 97        ax.set(**axprops(kwargs))
 98        cticks = kwargs.pop("cticks")
 99
100        # Why extent=(0, Lx, 0, Ly), rather than merely changing ticks?
101        # set_aspect("equal") and mouse hovering (reporting x,y).
102        if "rel" in coord_type:
103            Lx, Ly = 1, 1
104        elif "abs" in coord_type:
105            Lx, Ly = self.Lx, self.Ly
106        elif "ind" in coord_type:
107            Lx, Ly = self.Nx, self.Ny
108        else:
109            raise ValueError(f"Unsupported coord_type: {coord_type}")
110
111        # Apply transform
112        Z = np.asarray(Z)
113        Z = kwargs.pop("transf")(Z)
114
115        # Need to transpose coz orientation is model.shape==(Nx, Ny),
116        # while contour() displays the same orientation as array printing.
117        Z = Z.reshape(self.shape).T
118        # Mask the inactive cells (both `contourf` and `pcolormesh` leave them blank)
119        Z = np.ma.masked_where(~self.active.T, Z)
120
121        # Did we bother to specify set_over/set_under/set_bad ?
122        has_out_of_range = getattr(kwargs["cmap"], "_rgba_over", None) is not None
123        extend = "both" if has_out_of_range else "neither"
124
125        if cellwise:
126            # Discretize the colours by the same levels as `contourf` would
127            levels = kwargs.pop("levels")
128            locator = kwargs.pop("locator")
129            if np.ndim(levels) == 0:
130                locator = locator or MaxNLocator(levels + 1)
131                levels = locator.tick_values(Z.min(), Z.max())
132            cmap = plt.get_cmap(kwargs.pop("cmap"))
133            norm = BoundaryNorm(levels, cmap.N, extend=extend)
134            collections = ax.pcolormesh(
135                np.linspace(0, Lx, self.Nx + 1),
136                np.linspace(0, Ly, self.Ny + 1),
137                Z,
138                cmap=cmap,
139                norm=norm,
140                **kwargs,
141            )
142        else:
143            # Unlike `ax.imshow(Z[::-1])`, `contourf` does not simply fill pixels/cells
144            # (but it does provide nice interpolation!) so there will be whitespace on
145            # the margins. No fix is needed, and anyway it would not be trivial/fast,
146            # ref https://github.com/matplotlib/basemap/issues/406 .
147            collections = ax.contourf(
148                Z,
149                **kwargs,
150                # origin=None,  # ⇒ NB: falsely stretches the field!!!
151                origin="lower",
152                extent=(0, Lx, 0, Ly),
153                extend=extend,
154            )
155
156        # Contourf does not plot (at all) the bad regions. "Fake it" by facecolor
157        if has_out_of_range:
158            ax.set_facecolor(getattr(kwargs["cmap"], "_rgba_bad", "w"))
159
160        # Grid (reflecting the model grid)
161        # NB: If not showing grid, then don't locate ticks on grid, because they're
162        #     generally uglier that mpl's default/automatic tick location. But, it
163        #     should be safe to go with 'g' format instead of 'f'.
164        ax.xaxis.set_major_formatter("{x:g}")
165        ax.yaxis.set_major_formatter("{x:g}")
166        ax.tick_params(which="minor", length=0, color="r")
167        ax.tick_params(which="major", width=1.5, direction="in")
168        if grid:
169            n1 = 10
170            xStep = 1 + self.Nx // n1
171            yStep = 1 + self.Ny // n1
172            ax.xaxis.set_major_locator(MultipleLocator(self.hx * xStep))
173            ax.yaxis.set_major_locator(MultipleLocator(self.hy * yStep))
174            ax.xaxis.set_minor_locator(MultipleLocator(self.hx))
175            ax.yaxis.set_minor_locator(MultipleLocator(self.hy))
176            ax.grid(True, which="both")
177
178        # Axis lims
179        ax.set_xlim((0, Lx))
180        ax.set_ylim((0, Ly))
181        ax.set_aspect("equal")
182        # Axis labels
183        if labels:
184            if "abs" in coord_type:
185                ax.set_xlabel("x")
186                ax.set_ylabel("y")
187            else:
188                ax.set_xlabel(f"x ({coord_type})")
189                ax.set_ylabel(f"y ({coord_type})")
190
191        # Add well markers, grouped (and numbered) by the sign of their rates,
192        # ref `minires.wells.Wells.signs`. The producers come first, so
193        # that their numbers and colors match those of `plt_production`.
194        if wells and self.wells.nComp:
195            sgn = self.wells.signs
196            # Label the completions by their well's name, if there are any
197            names = None
198            if self.wells.names is not None and self.wells.group is not None:
199                names = np.asarray(self.wells.names)[self.wells.group]
200            if wells == "color":
201                # Colors matching `plt_production` of the producers
202                wells = cast(
203                    dict, {"color": [f"C{i}" for i in range(int(np.sum(sgn < 0)))]}
204                )
205            elif wells in [True, 1]:
206                wells = {}
207            else:
208                wells = dict(wells)  # NB: copy -- popped from below
209            # Hide the wells named by `exclude` (an aquifer's ring of contacts, say)
210            shown = np.ones(self.wells.nComp, bool)
211            if (exclude := wells.pop("exclude", None)) is not None:
212                assert names is not None, "`wells['exclude']` needs the wells named."
213                shown = ~np.isin(names, np.ravel(exclude))
214            for s in [-1, +1, 0]:
215                sel = (sgn == s) & shown
216                if np.any(sel):  # NB: skip, lest empty artists upset the layout
217                    kws = dict(wells)  # NB: copy -- the labels are per sign
218                    if names is not None:
219                        kws.setdefault("text", names[sel])
220                    self.well_scatter(ax, self.wells.xy[sel], s, **kws)
221                wells.pop("color", None)  # producers only
222
223        # Add argmax marker
224        if argmax:
225            idx = Z.T.argmax()  # reverse above transpose
226            xy = self.ind2xy(idx)
227            for c, ms in zip(["b", "r", "y"], [10, 6, 3]):
228                ax.plot(*xy, "o", c=c, ms=ms, label="max", zorder=98)
229
230        # Add colorbar
231        if colorbar:
232            if isinstance(colorbar, type(ax)):
233                cax = dict(cax=colorbar)
234            else:
235                cax = dict(ax=ax, shrink=0.8)
236            ax.figure.colorbar(collections, **cax, ticks=cticks)
237
238        tight_show(ax.figure, finalize)
239        return collections
240
241    def plt_faces(
242        self: "ResSim", ax: Any, xy: Any, faces: str = "WESN", **kws: Any
243    ) -> Any:
244        """Stroke the boundary faces of the cells at `xy`, onto a `plt_field`.
245
246        I.e. the faces `minires.wells.boundary_faces` finds -- the contact
247        of an aquifer, say (ref `minires.wells.aquifer_WI`), whose ring of
248        well markers this replaces (hide those with `wells=dict(exclude=...)`).
249        `kws` go to the `LineCollection` (`color`, `lw`, ...).
250        """
251        from matplotlib.collections import LineCollection
252
253        from minires.wells import boundary_faces
254
255        xy = np.asarray(xy, float).reshape((-1, 2))
256        xy = self.sub2xy(*self.xy2sub(*xy.T)).T  # snap to the cell centres
257        hx, hy = self.hx / 2, self.hy / 2
258        # Each face as a segment from the centre: W, E, S, N
259        ends = np.array([[[-hx, -hy], [-hx, +hy]], [[+hx, -hy], [+hx, +hy]],
260                         [[-hx, -hy], [+hx, -hy]], [[-hx, +hy], [+hx, +hy]]])  # fmt: skip
261        segments = (xy[:, None, None, :] + ends)[boundary_faces(self, xy, faces)]
262        # fmt: off
263        if   "rel" in coord_type: s = 1/self.Lx, 1/self.Ly                     # noqa
264        elif "abs" in coord_type: s = 1, 1                                     # noqa
265        elif "ind" in coord_type: s = self.Nx/self.Lx, self.Ny/self.Ly         # noqa
266        else: raise ValueError("Unsupported coordinate type: %s" % coord_type) # noqa
267        # fmt: on
268        opts: dict = dict(color="C0", lw=4, capstyle="projecting", zorder=1.4) | kws
269        lc = LineCollection(segments * s, **opts)
270        ax.add_collection(lc)
271        return lc
272
273    def well_scatter(
274        self: "ResSim",
275        ax: Any,
276        ww: np.ndarray,
277        sgn: int = 1,
278        text: Any = None,
279        color: Any = None,  # e.g. "k", or a list of colors (one per well)
280        size: float = 1,
281    ) -> Any:
282        """Scatter-plot the wells of `ww` onto a `Plot2D.plt_field`.
283
284        The marker reflects `sgn`: injector (`+1`), producer (`-1`),
285        or neutral (`0`, i.e. of undecided sign, ref `minires.wells.Wells.signs`).
286
287        The label, `text`, is either one string for all of them, one *per* well
288        of `ww` (a list), or `False` for none.
289
290        .. note:: The default labels are indices *within* `ww`, not global ones.
291
292            I.e. with `text=None`, and since `plt_field` calls this once per
293            sign, the producers are numbered as `plt_production` numbers them --
294            separately from the injectors, and not as in the unified
295            `minires.wells.Wells.xy`. But `plt_field` supplies the names of
296            `minires.wells.Wells.names`, if the wells have been given any.
297        """
298        # Well coordinates
299        ww = self.sub2xy(*self.xy2sub(*ww.T)).T
300        # NB: make sure ww array data is not overwritten (avoid in-place)
301        # fmt: off
302        if   "rel" in coord_type: s = 1/self.Lx, 1/self.Ly                     # noqa
303        elif "abs" in coord_type: s = 1, 1                                     # noqa
304        elif "ind" in coord_type: s = self.Nx/self.Lx, self.Ny/self.Ly         # noqa
305        else: raise ValueError("Unsupported coordinate type: %s" % coord_type) # noqa
306        # fmt: on
307        ww = ww * s
308
309        # Style
310        if sgn > 0:
311            c = "darkblue"
312            ec = "gray"
313            d = "w"
314            m = "v"
315        elif sgn < 0:
316            c = "k"
317            ec = "gray"
318            d = "w"
319            m = "^"
320        else:
321            c = "lightgray"
322            ec = "gray"
323            d = "k"
324            m = "o"
325
326        if color:
327            c = color
328
329        # Markers
330        sh = ax.plot(*ww.T, "r.", ms=3, clip_on=False)
331        sh = ax.scatter(
332            *ww.T,
333            s=(size * 26) ** 2,
334            c=c,
335            marker=m,
336            ec=ec,
337            clip_on=False,
338            zorder=1.5,  # required on Jupypter
339        )
340
341        # Text labels
342        if text is not False:
343            if text is None:
344                labels: Any = range(len(ww))
345            elif isinstance(text, str):
346                labels = len(ww) * [text]
347            else:
348                labels = text
349            for lbl, w in zip(labels, ww):
350                if sgn < 0:
351                    w[1] -= 0.01
352                ax.text(
353                    *w[:2],
354                    lbl,
355                    color=d,
356                    fontsize=size * 12,
357                    ha="center",
358                    va="center",
359                )
360
361        return sh
362
363    def plt_production(
364        self: "ResSim",
365        ax: Any,
366        production: np.ndarray,
367        obs: np.ndarray | None = None,
368        legend_outside: bool = True,
369        finalize: bool = True,
370        labels: Any = None,
371    ) -> list:
372        """Production time series. Multiple wells in 1 axes => not ensemble compat.
373
374        The curves are labelled by their index in `production`, unless `labels`
375        (e.g. a selection of `minires.wells.Wells.names`) says otherwise.
376        """
377        hh = []
378        tt = 1 + np.arange(len(production))
379        for i, p in enumerate(1 - production.T):
380            hh += ax.plot(tt, p, "-", label=i if labels is None else labels[i])
381
382        if obs is not None:
383            for i, y in enumerate(1 - obs.T):
384                ax.plot(tt, y, "*", c=hh[i].get_color())
385
386        # Add legend
387        if legend_outside:
388            kws = dict(
389                bbox_to_anchor=(1, 1),
390                loc="upper left",
391                ncol=1 + len(production.T) // 10,
392            )
393        else:
394            kws = dict(loc="lower left")
395        ax.legend(title="Well" if labels is not None else "Well #.", **kws)
396
397        ax.set_title("Oil saturation in producers")
398        ax.set_xlabel("Time index")
399        # ax.set_ylim(-0.01, 1.01)
400        ax.axhline(0, c="xkcd:light grey", ls="--", zorder=1.8)
401        ax.axhline(1, c="xkcd:light grey", ls="--", zorder=1.8)
402
403        tight_show(ax.figure, finalize)
404        return hh
405
406    # Note: See note in mpl_setup.py about properly displaying the animation.
407    def anim(
408        self: "ResSim",
409        wsats: np.ndarray,
410        prod: np.ndarray,
411        title: str = "",
412        figsize: tuple = (10, 3.5),
413        pause: int = 200,
414        animate: bool = True,
415        **kwargs,
416    ) -> Any:
417        """Animate the saturation and production time series."""
418
419        # Create figure and axes
420        title = "Animation" + ("-- " + title if title else "")
421        fig, (ax1, ax2) = place.freshfig(
422            title, ncols=2, figsize=figsize, gridspec_kw=dict(width_ratios=(2, 3))
423        )
424        fig.suptitle(title)  # coz animation never (any backend) displays title
425        # Saturations
426        kwargs.update(wells="color", colorbar=True, finalize=False)
427        ax2.cc = self.plt_field(ax2, wsats[-1], "oil", **kwargs)
428        # Production
429        hh = self.plt_production(ax1, prod, legend_outside=False, finalize=False)
430        fig.tight_layout()
431
432        if animate:
433            from matplotlib import animation
434
435            tt = np.arange(len(wsats))
436
437            def update_fig(iT):
438                # Update field.
439                # NB: since mpl 3.8 the `ContourSet` is itself an artist
440                # (it no longer has a `.collections` attribute).
441                try:
442                    ax2.cc.remove()
443                except ValueError:
444                    pass  # occurs when re-running script
445                kwargs.update(wells=False, colorbar=False)
446                ax2.cc = self.plt_field(ax2, wsats[iT], "oil", **kwargs)
447
448                # Update production lines
449                if iT >= 1:
450                    for h, p in zip(hh, prod.T):
451                        h.set_data(tt[1 : 1 + iT], 1 - p[:iT])
452
453            ani = animation.FuncAnimation(
454                fig,
455                update_fig,
456                len(tt),
457                blit=False,
458                interval=pause,
459                # Prevent busy/idle indicator constantly flashing, despite %%capture
460                # and even manually clearing the output of the calling cell.
461                repeat=False,  # flashing stops once the (unshown) animation finishes.
462                # An alternative solution is to do this in the next cell:
463                # animation.event_source.stop()
464                # but it does not work if using "run all", even with time.sleep(1).
465            )
466
467            return ani
468
469
470def tight_show(figure: Any, enabled: bool) -> None:
471    if enabled:
472        figure.tight_layout()
473        plt.show()
474
475
476def _get_ipython() -> Any:
477    """Return the active IPython shell, or `None` (also if IPython isn't installed)."""
478    try:
479        from IPython import get_ipython
480    except ImportError:
481        return None
482    return get_ipython()
483
484
485def _ipython_will_prompt(ip: Any) -> bool:
486    """Whether IPython returns to its (event-loop running) prompt after this script.
487
488    False for `ipython script.py` (no `-i`), which exits immediately.
489    """
490    if ip is None:
491        return False
492    return bool(getattr(ip.parent, "interact", True))
493
494
495def show(block: Optional[bool] = None) -> None:
496    """Display the figures, whether run as script, in IPython, or in a notebook.
497
498    Reasons why a plain `plt.show()` does not suffice:
499
500    - In a script (`python script.py`) it does the right thing (blocks, thereby
501      running the GUI event loop) *only* if interactive mode (`plt.ion()`) is off.
502    - In IPython (e.g. `%run script.py`) the figures stay unpainted until the GUI
503      event loop gets to run, which requires the "input hook" installed by the
504      `%matplotlib` magic (which is why they only appear upon `ctrl-d`/exit).
505      Blocking here would be wrong: it would freeze the prompt.
506    - In Jupyter/Colab with the `inline` backend there is no event loop at all:
507      the (static) figures are rendered by `plt.show()` itself.
508    """
509    ip = _get_ipython()
510
511    if ip is not None and not is_inline():
512        # I.e. the `%matplotlib` magic: activate the event loop ("input hook")
513        # of the current backend. No-op if already active.
514        ip.run_line_magic("matplotlib", "")
515
516    if block is None:
517        # Run the event loop ourselves only if nobody else will.
518        block = not is_inline() and not _ipython_will_prompt(ip)
519
520    plt.show(block=block)
coord_type = 'absolute'

Define scaling of Plot2D.plt_field axes.

  • "relative": (0, 1) x (0, 1)
  • "absolute": (0, Lx) x (0, Ly)
  • "index" : (0, Ny) x (0, Ny)
@staticmethod
def lin_cm(name, colors, N=256, gamma=1.0, *, bad=None, under=None, over=None):
1194    @staticmethod
1195    def from_list(name, colors, N=256, gamma=1.0, *, bad=None, under=None, over=None):
1196        """
1197        Create a `LinearSegmentedColormap` from a list of colors.
1198
1199        Parameters
1200        ----------
1201        name : str
1202            The name of the colormap.
1203        colors : list of :mpltype:`color` or list of (value, color)
1204            If only colors are given, they are equidistantly mapped from the
1205            range :math:`[0, 1]`; i.e. 0 maps to ``colors[0]`` and 1 maps to
1206            ``colors[-1]``.
1207            If (value, color) pairs are given, the mapping is from *value*
1208            to *color*. This can be used to divide the range unevenly. The
1209            values must increase monotonically from 0 to 1.
1210        N : int
1211            The number of RGB quantization levels.
1212        gamma : float
1213
1214        bad : :mpltype:`color`, default: transparent
1215            The color for invalid values (NaN or masked).
1216        under : :mpltype:`color`, default: color of the lowest value
1217            The color for low out-of-range values.
1218        over : :mpltype:`color`, default: color of the highest value
1219            The color for high out-of-range values.
1220        """
1221        if not np.iterable(colors):
1222            raise ValueError('colors must be iterable')
1223
1224        try:
1225            # Assume the passed colors are a list of colors
1226            # and not a (value, color) tuple.
1227            r, g, b, a = to_rgba_array(colors).T
1228            vals = np.linspace(0, 1, len(colors))
1229        except Exception as e:
1230            # Assume the passed values are a list of
1231            # (value, color) tuples.
1232            try:
1233                _vals, _colors = itertools.zip_longest(*colors)
1234            except Exception as e2:
1235                raise e2 from e
1236            vals = np.asarray(_vals)
1237            if np.min(vals) < 0 or np.max(vals) > 1 or np.any(np.diff(vals) < 0):
1238                raise ValueError(
1239                    "the values passed in the (value, color) pairs "
1240                    "must increase monotonically from 0 to 1."
1241                )
1242            r, g, b, a = to_rgba_array(_colors).T
1243
1244        cdict = {
1245            "red": np.column_stack([vals, r, r]),
1246            "green": np.column_stack([vals, g, g]),
1247            "blue": np.column_stack([vals, b, b]),
1248            "alpha": np.column_stack([vals, a, a]),
1249        }
1250
1251        return LinearSegmentedColormap(name, cdict, N, gamma,
1252                                       bad=bad, under=under, over=over)

Create a LinearSegmentedColormap from a list of colors.

Parameters

name : str The name of the colormap. colors : list of :mpltype:color or list of (value, color) If only colors are given, they are equidistantly mapped from the range \( [0, 1] \); i.e. 0 maps to colors[0] and 1 maps to colors[-1]. If (value, color) pairs are given, the mapping is from value to color. This can be used to divide the range unevenly. The values must increase monotonically from 0 to 1. N : int The number of RGB quantization levels. gamma : float

bad : :mpltype:color, default: transparent The color for invalid values (NaN or masked). under : :mpltype:color, default: color of the lowest value The color for low out-of-range values. over : :mpltype:color, default: color of the highest value The color for high out-of-range values.

cm_ow = <matplotlib.colors.LinearSegmentedColormap object>
styles: dict = {'default': {'title': '', 'transf': <function <lambda>>, 'cmap': 'viridis', 'levels': 10, 'cticks': None, 'locator': None}, 'oil': {'title': 'Oil saturation', 'transf': <function <lambda>>, 'cmap': <matplotlib.colors.LinearSegmentedColormap object>, 'levels': array([-1.00000000e-07, 5.26314895e-02, 1.05263079e-01, 1.57894668e-01, 2.10526258e-01, 2.63157847e-01, 3.15789437e-01, 3.68421026e-01, 4.21052616e-01, 4.73684205e-01, 5.26315795e-01, 5.78947384e-01, 6.31578974e-01, 6.84210563e-01, 7.36842153e-01, 7.89473742e-01, 8.42105332e-01, 8.94736921e-01, 9.47368511e-01, 1.00000010e+00]), 'cticks': array([0. , 0.2, 0.4, 0.6, 0.8, 1. ])}}

Default Plot2D.plt_field plot styling values.

class Plot2D:
 58class Plot2D:
 59    """Plots specialized for 2D fields.
 60
 61    This mixin is not standalone but reads grid and well attributes of the
 62    `ResSim` it gets composed into, rather than re-declaring (avoids stale).
 63    """
 64
 65    def plt_field(
 66        self: "ResSim",
 67        ax: Any,
 68        Z: np.ndarray,
 69        style: str = "default",
 70        wells: Any = True,
 71        argmax: bool = False,
 72        colorbar: Any = True,
 73        labels: bool = True,
 74        grid: bool = False,
 75        finalize: bool = True,
 76        cellwise: bool = False,
 77        **kwargs,
 78    ) -> Any:
 79        """Contour-plot of the (flat) unravelled field `Z`.
 80
 81        `kwargs` falls back to `styles[style]`, which falls back to `styles['defaults']`.
 82
 83        Inactive cells (`ResSim.active`) are masked out (left blank). Note that
 84        `contourf` interpolates between cell *centres*, so it also leaves blank the
 85        half-cell margins around them (as around the domain). `cellwise=True`
 86        instead paints each cell flat (`pcolormesh`), which is exact about the
 87        cells -- the shape of a mask, a fault, the resolution -- at the cost of the
 88        smoothness; the colour levels (hence the colorbar) are the same either way.
 89
 90        `wells` marks the completions (ref `well_scatter`): `True`, `"color"`
 91        (the producers coloured as in `plt_production`), or a `dict` of options
 92        for `well_scatter` -- where `exclude=[names]` hides the wells so named
 93        (an aquifer's ring of contacts, say; ref `minires.wells.aquifer_WI`).
 94        """
 95        # Populate kwargs with fallback style
 96        kwargs = {**styles["default"], **styles[style], **kwargs}
 97        # Pop from kwargs. Remainder goes to countourf
 98        ax.set(**axprops(kwargs))
 99        cticks = kwargs.pop("cticks")
100
101        # Why extent=(0, Lx, 0, Ly), rather than merely changing ticks?
102        # set_aspect("equal") and mouse hovering (reporting x,y).
103        if "rel" in coord_type:
104            Lx, Ly = 1, 1
105        elif "abs" in coord_type:
106            Lx, Ly = self.Lx, self.Ly
107        elif "ind" in coord_type:
108            Lx, Ly = self.Nx, self.Ny
109        else:
110            raise ValueError(f"Unsupported coord_type: {coord_type}")
111
112        # Apply transform
113        Z = np.asarray(Z)
114        Z = kwargs.pop("transf")(Z)
115
116        # Need to transpose coz orientation is model.shape==(Nx, Ny),
117        # while contour() displays the same orientation as array printing.
118        Z = Z.reshape(self.shape).T
119        # Mask the inactive cells (both `contourf` and `pcolormesh` leave them blank)
120        Z = np.ma.masked_where(~self.active.T, Z)
121
122        # Did we bother to specify set_over/set_under/set_bad ?
123        has_out_of_range = getattr(kwargs["cmap"], "_rgba_over", None) is not None
124        extend = "both" if has_out_of_range else "neither"
125
126        if cellwise:
127            # Discretize the colours by the same levels as `contourf` would
128            levels = kwargs.pop("levels")
129            locator = kwargs.pop("locator")
130            if np.ndim(levels) == 0:
131                locator = locator or MaxNLocator(levels + 1)
132                levels = locator.tick_values(Z.min(), Z.max())
133            cmap = plt.get_cmap(kwargs.pop("cmap"))
134            norm = BoundaryNorm(levels, cmap.N, extend=extend)
135            collections = ax.pcolormesh(
136                np.linspace(0, Lx, self.Nx + 1),
137                np.linspace(0, Ly, self.Ny + 1),
138                Z,
139                cmap=cmap,
140                norm=norm,
141                **kwargs,
142            )
143        else:
144            # Unlike `ax.imshow(Z[::-1])`, `contourf` does not simply fill pixels/cells
145            # (but it does provide nice interpolation!) so there will be whitespace on
146            # the margins. No fix is needed, and anyway it would not be trivial/fast,
147            # ref https://github.com/matplotlib/basemap/issues/406 .
148            collections = ax.contourf(
149                Z,
150                **kwargs,
151                # origin=None,  # ⇒ NB: falsely stretches the field!!!
152                origin="lower",
153                extent=(0, Lx, 0, Ly),
154                extend=extend,
155            )
156
157        # Contourf does not plot (at all) the bad regions. "Fake it" by facecolor
158        if has_out_of_range:
159            ax.set_facecolor(getattr(kwargs["cmap"], "_rgba_bad", "w"))
160
161        # Grid (reflecting the model grid)
162        # NB: If not showing grid, then don't locate ticks on grid, because they're
163        #     generally uglier that mpl's default/automatic tick location. But, it
164        #     should be safe to go with 'g' format instead of 'f'.
165        ax.xaxis.set_major_formatter("{x:g}")
166        ax.yaxis.set_major_formatter("{x:g}")
167        ax.tick_params(which="minor", length=0, color="r")
168        ax.tick_params(which="major", width=1.5, direction="in")
169        if grid:
170            n1 = 10
171            xStep = 1 + self.Nx // n1
172            yStep = 1 + self.Ny // n1
173            ax.xaxis.set_major_locator(MultipleLocator(self.hx * xStep))
174            ax.yaxis.set_major_locator(MultipleLocator(self.hy * yStep))
175            ax.xaxis.set_minor_locator(MultipleLocator(self.hx))
176            ax.yaxis.set_minor_locator(MultipleLocator(self.hy))
177            ax.grid(True, which="both")
178
179        # Axis lims
180        ax.set_xlim((0, Lx))
181        ax.set_ylim((0, Ly))
182        ax.set_aspect("equal")
183        # Axis labels
184        if labels:
185            if "abs" in coord_type:
186                ax.set_xlabel("x")
187                ax.set_ylabel("y")
188            else:
189                ax.set_xlabel(f"x ({coord_type})")
190                ax.set_ylabel(f"y ({coord_type})")
191
192        # Add well markers, grouped (and numbered) by the sign of their rates,
193        # ref `minires.wells.Wells.signs`. The producers come first, so
194        # that their numbers and colors match those of `plt_production`.
195        if wells and self.wells.nComp:
196            sgn = self.wells.signs
197            # Label the completions by their well's name, if there are any
198            names = None
199            if self.wells.names is not None and self.wells.group is not None:
200                names = np.asarray(self.wells.names)[self.wells.group]
201            if wells == "color":
202                # Colors matching `plt_production` of the producers
203                wells = cast(
204                    dict, {"color": [f"C{i}" for i in range(int(np.sum(sgn < 0)))]}
205                )
206            elif wells in [True, 1]:
207                wells = {}
208            else:
209                wells = dict(wells)  # NB: copy -- popped from below
210            # Hide the wells named by `exclude` (an aquifer's ring of contacts, say)
211            shown = np.ones(self.wells.nComp, bool)
212            if (exclude := wells.pop("exclude", None)) is not None:
213                assert names is not None, "`wells['exclude']` needs the wells named."
214                shown = ~np.isin(names, np.ravel(exclude))
215            for s in [-1, +1, 0]:
216                sel = (sgn == s) & shown
217                if np.any(sel):  # NB: skip, lest empty artists upset the layout
218                    kws = dict(wells)  # NB: copy -- the labels are per sign
219                    if names is not None:
220                        kws.setdefault("text", names[sel])
221                    self.well_scatter(ax, self.wells.xy[sel], s, **kws)
222                wells.pop("color", None)  # producers only
223
224        # Add argmax marker
225        if argmax:
226            idx = Z.T.argmax()  # reverse above transpose
227            xy = self.ind2xy(idx)
228            for c, ms in zip(["b", "r", "y"], [10, 6, 3]):
229                ax.plot(*xy, "o", c=c, ms=ms, label="max", zorder=98)
230
231        # Add colorbar
232        if colorbar:
233            if isinstance(colorbar, type(ax)):
234                cax = dict(cax=colorbar)
235            else:
236                cax = dict(ax=ax, shrink=0.8)
237            ax.figure.colorbar(collections, **cax, ticks=cticks)
238
239        tight_show(ax.figure, finalize)
240        return collections
241
242    def plt_faces(
243        self: "ResSim", ax: Any, xy: Any, faces: str = "WESN", **kws: Any
244    ) -> Any:
245        """Stroke the boundary faces of the cells at `xy`, onto a `plt_field`.
246
247        I.e. the faces `minires.wells.boundary_faces` finds -- the contact
248        of an aquifer, say (ref `minires.wells.aquifer_WI`), whose ring of
249        well markers this replaces (hide those with `wells=dict(exclude=...)`).
250        `kws` go to the `LineCollection` (`color`, `lw`, ...).
251        """
252        from matplotlib.collections import LineCollection
253
254        from minires.wells import boundary_faces
255
256        xy = np.asarray(xy, float).reshape((-1, 2))
257        xy = self.sub2xy(*self.xy2sub(*xy.T)).T  # snap to the cell centres
258        hx, hy = self.hx / 2, self.hy / 2
259        # Each face as a segment from the centre: W, E, S, N
260        ends = np.array([[[-hx, -hy], [-hx, +hy]], [[+hx, -hy], [+hx, +hy]],
261                         [[-hx, -hy], [+hx, -hy]], [[-hx, +hy], [+hx, +hy]]])  # fmt: skip
262        segments = (xy[:, None, None, :] + ends)[boundary_faces(self, xy, faces)]
263        # fmt: off
264        if   "rel" in coord_type: s = 1/self.Lx, 1/self.Ly                     # noqa
265        elif "abs" in coord_type: s = 1, 1                                     # noqa
266        elif "ind" in coord_type: s = self.Nx/self.Lx, self.Ny/self.Ly         # noqa
267        else: raise ValueError("Unsupported coordinate type: %s" % coord_type) # noqa
268        # fmt: on
269        opts: dict = dict(color="C0", lw=4, capstyle="projecting", zorder=1.4) | kws
270        lc = LineCollection(segments * s, **opts)
271        ax.add_collection(lc)
272        return lc
273
274    def well_scatter(
275        self: "ResSim",
276        ax: Any,
277        ww: np.ndarray,
278        sgn: int = 1,
279        text: Any = None,
280        color: Any = None,  # e.g. "k", or a list of colors (one per well)
281        size: float = 1,
282    ) -> Any:
283        """Scatter-plot the wells of `ww` onto a `Plot2D.plt_field`.
284
285        The marker reflects `sgn`: injector (`+1`), producer (`-1`),
286        or neutral (`0`, i.e. of undecided sign, ref `minires.wells.Wells.signs`).
287
288        The label, `text`, is either one string for all of them, one *per* well
289        of `ww` (a list), or `False` for none.
290
291        .. note:: The default labels are indices *within* `ww`, not global ones.
292
293            I.e. with `text=None`, and since `plt_field` calls this once per
294            sign, the producers are numbered as `plt_production` numbers them --
295            separately from the injectors, and not as in the unified
296            `minires.wells.Wells.xy`. But `plt_field` supplies the names of
297            `minires.wells.Wells.names`, if the wells have been given any.
298        """
299        # Well coordinates
300        ww = self.sub2xy(*self.xy2sub(*ww.T)).T
301        # NB: make sure ww array data is not overwritten (avoid in-place)
302        # fmt: off
303        if   "rel" in coord_type: s = 1/self.Lx, 1/self.Ly                     # noqa
304        elif "abs" in coord_type: s = 1, 1                                     # noqa
305        elif "ind" in coord_type: s = self.Nx/self.Lx, self.Ny/self.Ly         # noqa
306        else: raise ValueError("Unsupported coordinate type: %s" % coord_type) # noqa
307        # fmt: on
308        ww = ww * s
309
310        # Style
311        if sgn > 0:
312            c = "darkblue"
313            ec = "gray"
314            d = "w"
315            m = "v"
316        elif sgn < 0:
317            c = "k"
318            ec = "gray"
319            d = "w"
320            m = "^"
321        else:
322            c = "lightgray"
323            ec = "gray"
324            d = "k"
325            m = "o"
326
327        if color:
328            c = color
329
330        # Markers
331        sh = ax.plot(*ww.T, "r.", ms=3, clip_on=False)
332        sh = ax.scatter(
333            *ww.T,
334            s=(size * 26) ** 2,
335            c=c,
336            marker=m,
337            ec=ec,
338            clip_on=False,
339            zorder=1.5,  # required on Jupypter
340        )
341
342        # Text labels
343        if text is not False:
344            if text is None:
345                labels: Any = range(len(ww))
346            elif isinstance(text, str):
347                labels = len(ww) * [text]
348            else:
349                labels = text
350            for lbl, w in zip(labels, ww):
351                if sgn < 0:
352                    w[1] -= 0.01
353                ax.text(
354                    *w[:2],
355                    lbl,
356                    color=d,
357                    fontsize=size * 12,
358                    ha="center",
359                    va="center",
360                )
361
362        return sh
363
364    def plt_production(
365        self: "ResSim",
366        ax: Any,
367        production: np.ndarray,
368        obs: np.ndarray | None = None,
369        legend_outside: bool = True,
370        finalize: bool = True,
371        labels: Any = None,
372    ) -> list:
373        """Production time series. Multiple wells in 1 axes => not ensemble compat.
374
375        The curves are labelled by their index in `production`, unless `labels`
376        (e.g. a selection of `minires.wells.Wells.names`) says otherwise.
377        """
378        hh = []
379        tt = 1 + np.arange(len(production))
380        for i, p in enumerate(1 - production.T):
381            hh += ax.plot(tt, p, "-", label=i if labels is None else labels[i])
382
383        if obs is not None:
384            for i, y in enumerate(1 - obs.T):
385                ax.plot(tt, y, "*", c=hh[i].get_color())
386
387        # Add legend
388        if legend_outside:
389            kws = dict(
390                bbox_to_anchor=(1, 1),
391                loc="upper left",
392                ncol=1 + len(production.T) // 10,
393            )
394        else:
395            kws = dict(loc="lower left")
396        ax.legend(title="Well" if labels is not None else "Well #.", **kws)
397
398        ax.set_title("Oil saturation in producers")
399        ax.set_xlabel("Time index")
400        # ax.set_ylim(-0.01, 1.01)
401        ax.axhline(0, c="xkcd:light grey", ls="--", zorder=1.8)
402        ax.axhline(1, c="xkcd:light grey", ls="--", zorder=1.8)
403
404        tight_show(ax.figure, finalize)
405        return hh
406
407    # Note: See note in mpl_setup.py about properly displaying the animation.
408    def anim(
409        self: "ResSim",
410        wsats: np.ndarray,
411        prod: np.ndarray,
412        title: str = "",
413        figsize: tuple = (10, 3.5),
414        pause: int = 200,
415        animate: bool = True,
416        **kwargs,
417    ) -> Any:
418        """Animate the saturation and production time series."""
419
420        # Create figure and axes
421        title = "Animation" + ("-- " + title if title else "")
422        fig, (ax1, ax2) = place.freshfig(
423            title, ncols=2, figsize=figsize, gridspec_kw=dict(width_ratios=(2, 3))
424        )
425        fig.suptitle(title)  # coz animation never (any backend) displays title
426        # Saturations
427        kwargs.update(wells="color", colorbar=True, finalize=False)
428        ax2.cc = self.plt_field(ax2, wsats[-1], "oil", **kwargs)
429        # Production
430        hh = self.plt_production(ax1, prod, legend_outside=False, finalize=False)
431        fig.tight_layout()
432
433        if animate:
434            from matplotlib import animation
435
436            tt = np.arange(len(wsats))
437
438            def update_fig(iT):
439                # Update field.
440                # NB: since mpl 3.8 the `ContourSet` is itself an artist
441                # (it no longer has a `.collections` attribute).
442                try:
443                    ax2.cc.remove()
444                except ValueError:
445                    pass  # occurs when re-running script
446                kwargs.update(wells=False, colorbar=False)
447                ax2.cc = self.plt_field(ax2, wsats[iT], "oil", **kwargs)
448
449                # Update production lines
450                if iT >= 1:
451                    for h, p in zip(hh, prod.T):
452                        h.set_data(tt[1 : 1 + iT], 1 - p[:iT])
453
454            ani = animation.FuncAnimation(
455                fig,
456                update_fig,
457                len(tt),
458                blit=False,
459                interval=pause,
460                # Prevent busy/idle indicator constantly flashing, despite %%capture
461                # and even manually clearing the output of the calling cell.
462                repeat=False,  # flashing stops once the (unshown) animation finishes.
463                # An alternative solution is to do this in the next cell:
464                # animation.event_source.stop()
465                # but it does not work if using "run all", even with time.sleep(1).
466            )
467
468            return ani

Plots specialized for 2D fields.

This mixin is not standalone but reads grid and well attributes of the ResSim it gets composed into, rather than re-declaring (avoids stale).

def plt_field( self: minires.ResSim, ax: Any, Z: numpy.ndarray, style: str = 'default', wells: Any = True, argmax: bool = False, colorbar: Any = True, labels: bool = True, grid: bool = False, finalize: bool = True, cellwise: bool = False, **kwargs) -> Any:
 65    def plt_field(
 66        self: "ResSim",
 67        ax: Any,
 68        Z: np.ndarray,
 69        style: str = "default",
 70        wells: Any = True,
 71        argmax: bool = False,
 72        colorbar: Any = True,
 73        labels: bool = True,
 74        grid: bool = False,
 75        finalize: bool = True,
 76        cellwise: bool = False,
 77        **kwargs,
 78    ) -> Any:
 79        """Contour-plot of the (flat) unravelled field `Z`.
 80
 81        `kwargs` falls back to `styles[style]`, which falls back to `styles['defaults']`.
 82
 83        Inactive cells (`ResSim.active`) are masked out (left blank). Note that
 84        `contourf` interpolates between cell *centres*, so it also leaves blank the
 85        half-cell margins around them (as around the domain). `cellwise=True`
 86        instead paints each cell flat (`pcolormesh`), which is exact about the
 87        cells -- the shape of a mask, a fault, the resolution -- at the cost of the
 88        smoothness; the colour levels (hence the colorbar) are the same either way.
 89
 90        `wells` marks the completions (ref `well_scatter`): `True`, `"color"`
 91        (the producers coloured as in `plt_production`), or a `dict` of options
 92        for `well_scatter` -- where `exclude=[names]` hides the wells so named
 93        (an aquifer's ring of contacts, say; ref `minires.wells.aquifer_WI`).
 94        """
 95        # Populate kwargs with fallback style
 96        kwargs = {**styles["default"], **styles[style], **kwargs}
 97        # Pop from kwargs. Remainder goes to countourf
 98        ax.set(**axprops(kwargs))
 99        cticks = kwargs.pop("cticks")
100
101        # Why extent=(0, Lx, 0, Ly), rather than merely changing ticks?
102        # set_aspect("equal") and mouse hovering (reporting x,y).
103        if "rel" in coord_type:
104            Lx, Ly = 1, 1
105        elif "abs" in coord_type:
106            Lx, Ly = self.Lx, self.Ly
107        elif "ind" in coord_type:
108            Lx, Ly = self.Nx, self.Ny
109        else:
110            raise ValueError(f"Unsupported coord_type: {coord_type}")
111
112        # Apply transform
113        Z = np.asarray(Z)
114        Z = kwargs.pop("transf")(Z)
115
116        # Need to transpose coz orientation is model.shape==(Nx, Ny),
117        # while contour() displays the same orientation as array printing.
118        Z = Z.reshape(self.shape).T
119        # Mask the inactive cells (both `contourf` and `pcolormesh` leave them blank)
120        Z = np.ma.masked_where(~self.active.T, Z)
121
122        # Did we bother to specify set_over/set_under/set_bad ?
123        has_out_of_range = getattr(kwargs["cmap"], "_rgba_over", None) is not None
124        extend = "both" if has_out_of_range else "neither"
125
126        if cellwise:
127            # Discretize the colours by the same levels as `contourf` would
128            levels = kwargs.pop("levels")
129            locator = kwargs.pop("locator")
130            if np.ndim(levels) == 0:
131                locator = locator or MaxNLocator(levels + 1)
132                levels = locator.tick_values(Z.min(), Z.max())
133            cmap = plt.get_cmap(kwargs.pop("cmap"))
134            norm = BoundaryNorm(levels, cmap.N, extend=extend)
135            collections = ax.pcolormesh(
136                np.linspace(0, Lx, self.Nx + 1),
137                np.linspace(0, Ly, self.Ny + 1),
138                Z,
139                cmap=cmap,
140                norm=norm,
141                **kwargs,
142            )
143        else:
144            # Unlike `ax.imshow(Z[::-1])`, `contourf` does not simply fill pixels/cells
145            # (but it does provide nice interpolation!) so there will be whitespace on
146            # the margins. No fix is needed, and anyway it would not be trivial/fast,
147            # ref https://github.com/matplotlib/basemap/issues/406 .
148            collections = ax.contourf(
149                Z,
150                **kwargs,
151                # origin=None,  # ⇒ NB: falsely stretches the field!!!
152                origin="lower",
153                extent=(0, Lx, 0, Ly),
154                extend=extend,
155            )
156
157        # Contourf does not plot (at all) the bad regions. "Fake it" by facecolor
158        if has_out_of_range:
159            ax.set_facecolor(getattr(kwargs["cmap"], "_rgba_bad", "w"))
160
161        # Grid (reflecting the model grid)
162        # NB: If not showing grid, then don't locate ticks on grid, because they're
163        #     generally uglier that mpl's default/automatic tick location. But, it
164        #     should be safe to go with 'g' format instead of 'f'.
165        ax.xaxis.set_major_formatter("{x:g}")
166        ax.yaxis.set_major_formatter("{x:g}")
167        ax.tick_params(which="minor", length=0, color="r")
168        ax.tick_params(which="major", width=1.5, direction="in")
169        if grid:
170            n1 = 10
171            xStep = 1 + self.Nx // n1
172            yStep = 1 + self.Ny // n1
173            ax.xaxis.set_major_locator(MultipleLocator(self.hx * xStep))
174            ax.yaxis.set_major_locator(MultipleLocator(self.hy * yStep))
175            ax.xaxis.set_minor_locator(MultipleLocator(self.hx))
176            ax.yaxis.set_minor_locator(MultipleLocator(self.hy))
177            ax.grid(True, which="both")
178
179        # Axis lims
180        ax.set_xlim((0, Lx))
181        ax.set_ylim((0, Ly))
182        ax.set_aspect("equal")
183        # Axis labels
184        if labels:
185            if "abs" in coord_type:
186                ax.set_xlabel("x")
187                ax.set_ylabel("y")
188            else:
189                ax.set_xlabel(f"x ({coord_type})")
190                ax.set_ylabel(f"y ({coord_type})")
191
192        # Add well markers, grouped (and numbered) by the sign of their rates,
193        # ref `minires.wells.Wells.signs`. The producers come first, so
194        # that their numbers and colors match those of `plt_production`.
195        if wells and self.wells.nComp:
196            sgn = self.wells.signs
197            # Label the completions by their well's name, if there are any
198            names = None
199            if self.wells.names is not None and self.wells.group is not None:
200                names = np.asarray(self.wells.names)[self.wells.group]
201            if wells == "color":
202                # Colors matching `plt_production` of the producers
203                wells = cast(
204                    dict, {"color": [f"C{i}" for i in range(int(np.sum(sgn < 0)))]}
205                )
206            elif wells in [True, 1]:
207                wells = {}
208            else:
209                wells = dict(wells)  # NB: copy -- popped from below
210            # Hide the wells named by `exclude` (an aquifer's ring of contacts, say)
211            shown = np.ones(self.wells.nComp, bool)
212            if (exclude := wells.pop("exclude", None)) is not None:
213                assert names is not None, "`wells['exclude']` needs the wells named."
214                shown = ~np.isin(names, np.ravel(exclude))
215            for s in [-1, +1, 0]:
216                sel = (sgn == s) & shown
217                if np.any(sel):  # NB: skip, lest empty artists upset the layout
218                    kws = dict(wells)  # NB: copy -- the labels are per sign
219                    if names is not None:
220                        kws.setdefault("text", names[sel])
221                    self.well_scatter(ax, self.wells.xy[sel], s, **kws)
222                wells.pop("color", None)  # producers only
223
224        # Add argmax marker
225        if argmax:
226            idx = Z.T.argmax()  # reverse above transpose
227            xy = self.ind2xy(idx)
228            for c, ms in zip(["b", "r", "y"], [10, 6, 3]):
229                ax.plot(*xy, "o", c=c, ms=ms, label="max", zorder=98)
230
231        # Add colorbar
232        if colorbar:
233            if isinstance(colorbar, type(ax)):
234                cax = dict(cax=colorbar)
235            else:
236                cax = dict(ax=ax, shrink=0.8)
237            ax.figure.colorbar(collections, **cax, ticks=cticks)
238
239        tight_show(ax.figure, finalize)
240        return collections

Contour-plot of the (flat) unravelled field Z.

kwargs falls back to styles[style], which falls back to styles['defaults'].

Inactive cells (ResSim.active) are masked out (left blank). Note that contourf interpolates between cell centres, so it also leaves blank the half-cell margins around them (as around the domain). cellwise=True instead paints each cell flat (pcolormesh), which is exact about the cells -- the shape of a mask, a fault, the resolution -- at the cost of the smoothness; the colour levels (hence the colorbar) are the same either way.

wells marks the completions (ref well_scatter): True, "color" (the producers coloured as in plt_production), or a dict of options for well_scatter -- where exclude=[names] hides the wells so named (an aquifer's ring of contacts, say; ref minires.wells.aquifer_WI).

def plt_faces( self: minires.ResSim, ax: Any, xy: Any, faces: str = 'WESN', **kws: Any) -> Any:
242    def plt_faces(
243        self: "ResSim", ax: Any, xy: Any, faces: str = "WESN", **kws: Any
244    ) -> Any:
245        """Stroke the boundary faces of the cells at `xy`, onto a `plt_field`.
246
247        I.e. the faces `minires.wells.boundary_faces` finds -- the contact
248        of an aquifer, say (ref `minires.wells.aquifer_WI`), whose ring of
249        well markers this replaces (hide those with `wells=dict(exclude=...)`).
250        `kws` go to the `LineCollection` (`color`, `lw`, ...).
251        """
252        from matplotlib.collections import LineCollection
253
254        from minires.wells import boundary_faces
255
256        xy = np.asarray(xy, float).reshape((-1, 2))
257        xy = self.sub2xy(*self.xy2sub(*xy.T)).T  # snap to the cell centres
258        hx, hy = self.hx / 2, self.hy / 2
259        # Each face as a segment from the centre: W, E, S, N
260        ends = np.array([[[-hx, -hy], [-hx, +hy]], [[+hx, -hy], [+hx, +hy]],
261                         [[-hx, -hy], [+hx, -hy]], [[-hx, +hy], [+hx, +hy]]])  # fmt: skip
262        segments = (xy[:, None, None, :] + ends)[boundary_faces(self, xy, faces)]
263        # fmt: off
264        if   "rel" in coord_type: s = 1/self.Lx, 1/self.Ly                     # noqa
265        elif "abs" in coord_type: s = 1, 1                                     # noqa
266        elif "ind" in coord_type: s = self.Nx/self.Lx, self.Ny/self.Ly         # noqa
267        else: raise ValueError("Unsupported coordinate type: %s" % coord_type) # noqa
268        # fmt: on
269        opts: dict = dict(color="C0", lw=4, capstyle="projecting", zorder=1.4) | kws
270        lc = LineCollection(segments * s, **opts)
271        ax.add_collection(lc)
272        return lc

Stroke the boundary faces of the cells at xy, onto a plt_field.

I.e. the faces minires.wells.boundary_faces finds -- the contact of an aquifer, say (ref minires.wells.aquifer_WI), whose ring of well markers this replaces (hide those with wells=dict(exclude=...)). kws go to the LineCollection (color, lw, ...).

def well_scatter( self: minires.ResSim, ax: Any, ww: numpy.ndarray, sgn: int = 1, text: Any = None, color: Any = None, size: float = 1) -> Any:
274    def well_scatter(
275        self: "ResSim",
276        ax: Any,
277        ww: np.ndarray,
278        sgn: int = 1,
279        text: Any = None,
280        color: Any = None,  # e.g. "k", or a list of colors (one per well)
281        size: float = 1,
282    ) -> Any:
283        """Scatter-plot the wells of `ww` onto a `Plot2D.plt_field`.
284
285        The marker reflects `sgn`: injector (`+1`), producer (`-1`),
286        or neutral (`0`, i.e. of undecided sign, ref `minires.wells.Wells.signs`).
287
288        The label, `text`, is either one string for all of them, one *per* well
289        of `ww` (a list), or `False` for none.
290
291        .. note:: The default labels are indices *within* `ww`, not global ones.
292
293            I.e. with `text=None`, and since `plt_field` calls this once per
294            sign, the producers are numbered as `plt_production` numbers them --
295            separately from the injectors, and not as in the unified
296            `minires.wells.Wells.xy`. But `plt_field` supplies the names of
297            `minires.wells.Wells.names`, if the wells have been given any.
298        """
299        # Well coordinates
300        ww = self.sub2xy(*self.xy2sub(*ww.T)).T
301        # NB: make sure ww array data is not overwritten (avoid in-place)
302        # fmt: off
303        if   "rel" in coord_type: s = 1/self.Lx, 1/self.Ly                     # noqa
304        elif "abs" in coord_type: s = 1, 1                                     # noqa
305        elif "ind" in coord_type: s = self.Nx/self.Lx, self.Ny/self.Ly         # noqa
306        else: raise ValueError("Unsupported coordinate type: %s" % coord_type) # noqa
307        # fmt: on
308        ww = ww * s
309
310        # Style
311        if sgn > 0:
312            c = "darkblue"
313            ec = "gray"
314            d = "w"
315            m = "v"
316        elif sgn < 0:
317            c = "k"
318            ec = "gray"
319            d = "w"
320            m = "^"
321        else:
322            c = "lightgray"
323            ec = "gray"
324            d = "k"
325            m = "o"
326
327        if color:
328            c = color
329
330        # Markers
331        sh = ax.plot(*ww.T, "r.", ms=3, clip_on=False)
332        sh = ax.scatter(
333            *ww.T,
334            s=(size * 26) ** 2,
335            c=c,
336            marker=m,
337            ec=ec,
338            clip_on=False,
339            zorder=1.5,  # required on Jupypter
340        )
341
342        # Text labels
343        if text is not False:
344            if text is None:
345                labels: Any = range(len(ww))
346            elif isinstance(text, str):
347                labels = len(ww) * [text]
348            else:
349                labels = text
350            for lbl, w in zip(labels, ww):
351                if sgn < 0:
352                    w[1] -= 0.01
353                ax.text(
354                    *w[:2],
355                    lbl,
356                    color=d,
357                    fontsize=size * 12,
358                    ha="center",
359                    va="center",
360                )
361
362        return sh

Scatter-plot the wells of ww onto a Plot2D.plt_field.

The marker reflects sgn: injector (+1), producer (-1), or neutral (0, i.e. of undecided sign, ref minires.wells.Wells.signs).

The label, text, is either one string for all of them, one per well of ww (a list), or False for none.

The default labels are indices within ww, not global ones.

I.e. with text=None, and since plt_field calls this once per sign, the producers are numbered as plt_production numbers them -- separately from the injectors, and not as in the unified minires.wells.Wells.xy. But plt_field supplies the names of minires.wells.Wells.names, if the wells have been given any.

def plt_production( self: minires.ResSim, ax: Any, production: numpy.ndarray, obs: numpy.ndarray | None = None, legend_outside: bool = True, finalize: bool = True, labels: Any = None) -> list:
364    def plt_production(
365        self: "ResSim",
366        ax: Any,
367        production: np.ndarray,
368        obs: np.ndarray | None = None,
369        legend_outside: bool = True,
370        finalize: bool = True,
371        labels: Any = None,
372    ) -> list:
373        """Production time series. Multiple wells in 1 axes => not ensemble compat.
374
375        The curves are labelled by their index in `production`, unless `labels`
376        (e.g. a selection of `minires.wells.Wells.names`) says otherwise.
377        """
378        hh = []
379        tt = 1 + np.arange(len(production))
380        for i, p in enumerate(1 - production.T):
381            hh += ax.plot(tt, p, "-", label=i if labels is None else labels[i])
382
383        if obs is not None:
384            for i, y in enumerate(1 - obs.T):
385                ax.plot(tt, y, "*", c=hh[i].get_color())
386
387        # Add legend
388        if legend_outside:
389            kws = dict(
390                bbox_to_anchor=(1, 1),
391                loc="upper left",
392                ncol=1 + len(production.T) // 10,
393            )
394        else:
395            kws = dict(loc="lower left")
396        ax.legend(title="Well" if labels is not None else "Well #.", **kws)
397
398        ax.set_title("Oil saturation in producers")
399        ax.set_xlabel("Time index")
400        # ax.set_ylim(-0.01, 1.01)
401        ax.axhline(0, c="xkcd:light grey", ls="--", zorder=1.8)
402        ax.axhline(1, c="xkcd:light grey", ls="--", zorder=1.8)
403
404        tight_show(ax.figure, finalize)
405        return hh

Production time series. Multiple wells in 1 axes => not ensemble compat.

The curves are labelled by their index in production, unless labels (e.g. a selection of minires.wells.Wells.names) says otherwise.

def anim( self: minires.ResSim, wsats: numpy.ndarray, prod: numpy.ndarray, title: str = '', figsize: tuple = (10, 3.5), pause: int = 200, animate: bool = True, **kwargs) -> Any:
408    def anim(
409        self: "ResSim",
410        wsats: np.ndarray,
411        prod: np.ndarray,
412        title: str = "",
413        figsize: tuple = (10, 3.5),
414        pause: int = 200,
415        animate: bool = True,
416        **kwargs,
417    ) -> Any:
418        """Animate the saturation and production time series."""
419
420        # Create figure and axes
421        title = "Animation" + ("-- " + title if title else "")
422        fig, (ax1, ax2) = place.freshfig(
423            title, ncols=2, figsize=figsize, gridspec_kw=dict(width_ratios=(2, 3))
424        )
425        fig.suptitle(title)  # coz animation never (any backend) displays title
426        # Saturations
427        kwargs.update(wells="color", colorbar=True, finalize=False)
428        ax2.cc = self.plt_field(ax2, wsats[-1], "oil", **kwargs)
429        # Production
430        hh = self.plt_production(ax1, prod, legend_outside=False, finalize=False)
431        fig.tight_layout()
432
433        if animate:
434            from matplotlib import animation
435
436            tt = np.arange(len(wsats))
437
438            def update_fig(iT):
439                # Update field.
440                # NB: since mpl 3.8 the `ContourSet` is itself an artist
441                # (it no longer has a `.collections` attribute).
442                try:
443                    ax2.cc.remove()
444                except ValueError:
445                    pass  # occurs when re-running script
446                kwargs.update(wells=False, colorbar=False)
447                ax2.cc = self.plt_field(ax2, wsats[iT], "oil", **kwargs)
448
449                # Update production lines
450                if iT >= 1:
451                    for h, p in zip(hh, prod.T):
452                        h.set_data(tt[1 : 1 + iT], 1 - p[:iT])
453
454            ani = animation.FuncAnimation(
455                fig,
456                update_fig,
457                len(tt),
458                blit=False,
459                interval=pause,
460                # Prevent busy/idle indicator constantly flashing, despite %%capture
461                # and even manually clearing the output of the calling cell.
462                repeat=False,  # flashing stops once the (unshown) animation finishes.
463                # An alternative solution is to do this in the next cell:
464                # animation.event_source.stop()
465                # but it does not work if using "run all", even with time.sleep(1).
466            )
467
468            return ani

Animate the saturation and production time series.

def tight_show(figure: Any, enabled: bool) -> None:
471def tight_show(figure: Any, enabled: bool) -> None:
472    if enabled:
473        figure.tight_layout()
474        plt.show()
def show(block: Optional[bool] = None) -> None:
496def show(block: Optional[bool] = None) -> None:
497    """Display the figures, whether run as script, in IPython, or in a notebook.
498
499    Reasons why a plain `plt.show()` does not suffice:
500
501    - In a script (`python script.py`) it does the right thing (blocks, thereby
502      running the GUI event loop) *only* if interactive mode (`plt.ion()`) is off.
503    - In IPython (e.g. `%run script.py`) the figures stay unpainted until the GUI
504      event loop gets to run, which requires the "input hook" installed by the
505      `%matplotlib` magic (which is why they only appear upon `ctrl-d`/exit).
506      Blocking here would be wrong: it would freeze the prompt.
507    - In Jupyter/Colab with the `inline` backend there is no event loop at all:
508      the (static) figures are rendered by `plt.show()` itself.
509    """
510    ip = _get_ipython()
511
512    if ip is not None and not is_inline():
513        # I.e. the `%matplotlib` magic: activate the event loop ("input hook")
514        # of the current backend. No-op if already active.
515        ip.run_line_magic("matplotlib", "")
516
517    if block is None:
518        # Run the event loop ourselves only if nobody else will.
519        block = not is_inline() and not _ipython_will_prompt(ip)
520
521    plt.show(block=block)

Display the figures, whether run as script, in IPython, or in a notebook.

Reasons why a plain plt.show() does not suffice:

  • In a script (python script.py) it does the right thing (blocks, thereby running the GUI event loop) only if interactive mode (plt.ion()) is off.
  • In IPython (e.g. %run script.py) the figures stay unpainted until the GUI event loop gets to run, which requires the "input hook" installed by the %matplotlib magic (which is why they only appear upon ctrl-d/exit). Blocking here would be wrong: it would freeze the prompt.
  • In Jupyter/Colab with the inline backend there is no event loop at all: the (static) figures are rendered by plt.show() itself.