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