minires.wells
The wells: their configuration, their bookkeeping, and the well model.
From the perspective of the simulator, only the the completion matters,
not the well.
Wells (composing several completions, typically spanning multiple cells)
are a setup and reporting (Wells.rates_by_well) concept only.
Theory
A well is either an injector or a producer.
In the governing equations implemented in core.py, however,
the only thing distinguishing sources and sinks are their sign.
Reflecting this unity, injectors and producers are deliberately
not distinct here except by the sign of its rate (ref minires.wells.Wells.rates),
and under BHP control not even by that, the direction being left to the pressures
(ref minires.wells.Wells.bhp). However, a common practice is for users
themselves to manually keep track of which wells are producers/injectors.
A well's completion is the equipment that connects the wellbore to the rock,
whose interface is the sandface; it may be open hole, or cased and
perforated. A well may have several completions, e.g. one per layer,
or (here) one per grid cell traversed by the well path -- which the model
assembles individually, grouping them back into wells only for the reporting
(ref minires.ResSim.wells).
Because a wellbore (radius $r_w \sim 0.1$ m) is orders of magnitude smaller than a grid block, its pressure is not resolved by the grid: the radial solution $p \sim \ln r$ spends most of its variation within the well's own cell. The bottom-hole pressure (BHP), $p_\mathrm{bh}$, is the pressure at the sandface, which is thus a sub-grid quantity; measured at the surface instead, it is the tubing head pressure (THP), the difference being hydrostatic and friction losses in the tubing -- neither of which a 2D areal model has, which is why BHP is where this model stops. The drawdown is the pressure difference driving the flow, $p_\mathrm{cell} - p_\mathrm{bh}$: positive for a producer (whence its name), while for an injector it is negative, being an overpressure.
The productivity index (PI) is the resulting constant of proportionality, $q = \mathrm{PI} \cdot \Delta p$ (the injectivity index for injectors), familiar from well testing. Its counterpart in a simulator is the well index (WI), which is the same relation with the fluid factored out, $q = \mathrm{WI} \, \lambda_t \, \Delta p$, so that $\mathrm{WI}$ depends only on geometry and rock, and the mobility $\lambda_t$ carries the (time-varying) fluid dependence. It is the well's analogue of the transmissibility $t_{ij}$ of eqn. (10). Two things enter it, beyond $r_w$ and $\mathbf{K}$:
- The skin, $S$, is a dimensionless lumping of all near-wellbore effects that the model does not resolve. It is positive for damage (drilling mud invasion, fines migration, scale) and negative for stimulation (acidizing, or hydraulic fracturing), and enters additively to a logarithm, so a skin of $5$ is a lot.
- The equivalent radius, $r_e$, is the purely numerical ingredient: the radius at which the analytic radial pressure equals the numerical cell pressure, $r_e \approx 0.2 h$ for the 5-point stencil of TPFA. Beware that the same symbol is used, in well testing, for the (physical) drainage radius.
Ref minires.wells.peaceman_WI for the formula combining these.
A well is controlled either by prescribing its rate, or its BHP,
the other then being an outcome (ref minires.wells.Wells.bhp).
Reality is closer to the latter -- one sets a pump speed or a choke opening,
and the reservoir decides the rate -- but the rate is what is usually planned for.
Field practice is therefore rate control subject to a BHP constraint
(from the fracture pressure of an injector, or the lift capacity, or the bubble
point, of a producer), switching control mode whenever the constraint binds.
A well producing at a rate too low to be worthwhile is shut in;
if it cannot flow unaided it needs artificial lift (gas lift, or a downhole pump).
The water cut of a producer is the water fraction of what it produces, i.e. the $f(s)$ of its cell; breakthrough is when the injected water first arrives, after which the water cut climbs and the well eventually becomes uneconomic. How much of the oil the flood has contacted by then is the sweep efficiency, which is governed by the mobility ratio, $M = \lambda_w / \lambda_o$ evaluated behind and ahead of the front. $M > 1$ is unfavourable: the (less viscous) water outruns the oil in viscous fingering, and even more so along the high-permeability channels -- the motivation for polymer injection, which fixes $M$ by thickening the water. The corresponding vertical phenomenon (which a 2D areal model cannot see) is coning, of water up, or gas down, into the completion.
Wells are drilled in repeated patterns, of which the five-spot (a producer
at the centre of four injectors, or vice-versa if inverted) is the classic;
by symmetry it suffices to simulate the quarter five-spot, as in the examples here.
They need not be vertical: deviated, horizontal and multilateral wells
contact more rock per well, at the price of an allocation problem,
namely how the total rate distributes itself among the completions
(ref minires.wells.well_path).
Later interventions to restore or improve a well are workovers,
and drilling extra wells between the existing ones is infill drilling.
1"""The wells: their configuration, their bookkeeping, and the well model. 2 3From the perspective of the simulator, only the the **completion** matters, 4not the well. 5Wells (composing several completions, typically spanning multiple cells) 6are a setup and *reporting* (`Wells.rates_by_well`) concept only. 7 8## Theory 9 10A well is either an **injector** or a **producer**. 11In the governing equations implemented in `core.py`, however, 12the only thing distinguishing sources and sinks are their sign. 13Reflecting this unity, injectors and producers are deliberately 14not distinct here except by the *sign* of its rate (ref `minires.wells.Wells.rates`), 15and under BHP control not even by that, the direction being left to the pressures 16(ref `minires.wells.Wells.bhp`). However, a common practice is for users 17themselves to manually keep track of which wells are producers/injectors. 18 19A well's **completion** is the equipment that connects the **wellbore** to the rock, 20whose interface is the **sandface**; it may be *open hole*, or cased and 21**perforated**. A well may have several completions, e.g. one per layer, 22or (here) one per grid cell traversed by the well path -- which the model 23assembles individually, grouping them back into wells only for the reporting 24(ref `minires.ResSim.wells`). 25 26Because a wellbore (radius $r_w \\sim 0.1$ m) is orders of magnitude smaller than a 27grid block, its pressure is not resolved by the grid: the radial solution 28$p \\sim \\ln r$ spends most of its variation within the well's own cell. 29The **bottom-hole pressure** (BHP), $p_\\mathrm{bh}$, is the pressure at the 30sandface, which is thus a *sub-grid* quantity; 31measured at the surface instead, it is the *tubing head pressure* (THP), the 32difference being hydrostatic and friction losses in the tubing -- neither of which 33a 2D areal model has, which is why BHP is where this model stops. 34The **drawdown** is the pressure difference driving the flow, 35$p_\\mathrm{cell} - p_\\mathrm{bh}$: positive for a producer (whence its name), 36while for an injector it is negative, being an *overpressure*. 37 38The **productivity index** (PI) is the resulting constant of proportionality, 39$q = \\mathrm{PI} \\cdot \\Delta p$ (the *injectivity index* for injectors), 40familiar from well testing. Its counterpart in a simulator is the 41**well index** (WI), which is the same relation with the fluid factored out, 42$q = \\mathrm{WI} \\, \\lambda_t \\, \\Delta p$, 43so that $\\mathrm{WI}$ depends only on geometry and rock, 44and the mobility $\\lambda_t$ carries the (time-varying) fluid dependence. 45It is the well's analogue of the transmissibility $t_{ij}$ of eqn. (10). 46Two things enter it, beyond $r_w$ and $\\mathbf{K}$: 47 48- The **skin**, $S$, is a dimensionless lumping of all *near-wellbore* effects that 49 the model does not resolve. It is *positive* for damage (drilling mud invasion, 50 fines migration, scale) and *negative* for stimulation (acidizing, or hydraulic 51 **fracturing**), and enters additively to a logarithm, so a skin of $5$ is a lot. 52- The **equivalent radius**, $r_e$, is the purely *numerical* ingredient: the radius 53 at which the analytic radial pressure equals the numerical *cell* pressure, 54 $r_e \\approx 0.2 h$ for the 5-point stencil of TPFA. Beware that the same symbol 55 is used, in well testing, for the (physical) *drainage radius*. 56 57Ref `minires.wells.peaceman_WI` for the formula combining these. 58 59A well is **controlled** either by prescribing its rate, or its BHP, 60the other then being an outcome (ref `minires.wells.Wells.bhp`). 61Reality is closer to the latter -- one sets a pump speed or a **choke** opening, 62and the reservoir decides the rate -- but the *rate* is what is usually planned for. 63Field practice is therefore rate control subject to a BHP *constraint* 64(from the fracture pressure of an injector, or the lift capacity, or the bubble 65point, of a producer), **switching** control mode whenever the constraint binds. 66A well producing at a rate too low to be worthwhile is **shut in**; 67if it cannot flow unaided it needs **artificial lift** (gas lift, or a downhole pump). 68 69The **water cut** of a producer is the water fraction of what it produces, 70i.e. the $f(s)$ of its cell; **breakthrough** is when the injected water first 71arrives, after which the water cut climbs and the well eventually becomes uneconomic. 72How much of the oil the flood has contacted by then is the **sweep efficiency**, 73which is governed by the **mobility ratio**, $M = \\lambda_w / \\lambda_o$ 74evaluated behind and ahead of the front. $M > 1$ is *unfavourable*: 75the (less viscous) water outruns the oil in **viscous fingering**, 76and even more so along the high-permeability *channels* -- the motivation for 77polymer injection, which fixes $M$ by thickening the water. 78The corresponding vertical phenomenon (which a 2D areal model cannot see) 79is **coning**, of water up, or gas down, into the completion. 80 81Wells are drilled in repeated **patterns**, of which the *five-spot* (a producer 82at the centre of four injectors, or vice-versa if *inverted*) is the classic; 83by symmetry it suffices to simulate the *quarter five-spot*, as in the examples here. 84They need not be vertical: *deviated*, *horizontal* and *multilateral* wells 85contact more rock per well, at the price of an **allocation** problem, 86namely how the total rate distributes itself among the completions 87(ref `minires.wells.well_path`). 88Later interventions to restore or improve a well are **workovers**, 89and drilling extra wells between the existing ones is **infill drilling**. 90 91""" 92 93from dataclasses import dataclass 94from fnmatch import fnmatchcase 95from typing import TYPE_CHECKING, Any 96 97import numpy as np 98from struct_tools import AlignedRepr 99 100if TYPE_CHECKING: 101 from minires import ResSim 102 103 104@dataclass(repr=False) 105class Wells(AlignedRepr): 106 """The wells of a `minires.ResSim`: the flat, per-completion arrays. 107 108 Contains arrays which specify the well (completions) and may be written to. 109 However, the constructor offers a more convenient API, via `from_records`, 110 but these records themselves are *not* retained (and cannot go stale). 111 112 A `Wells` may also be built before it has a grid to snap to -- as below, 113 where it is the assignment to the model that binds (and snaps) it. 114 115 >>> from minires import ResSim 116 >>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16, 117 ... wells=Wells(xy=[[0, 0], [1, 1]], rates=[[1], [-1]])) 118 >>> model.wells.rates = [[2], [-2]] 119 >>> model.wells.nComp 120 2 121 122 Assigning to `minires.ResSim.wells` normalizes it (`__setattr__`): positions 123 get snapped onto the grid nodes, schedules reshaped to `(nComp, nTime)`, etc. 124 """ 125 126 # NB: the array attributes are typed `Any` since `__setattr__` normalizes 127 # whatever array-like (nested lists, scalars) is assigned to them. 128 xy: Any = None 129 """Array of shape `(nComp, 2)` of x- and y-coords for the completions. 130 131 Values should be betwen `0` and `Lx` or `Ly`. Empty, shape `(0, 2)`, if 132 there are no wells. 133 134 .. warning:: The wells get co-located with grid nodes, ref `xy2sub`. 135 136 This is a design choice, not a mathematical necessity. An alternative 137 would be to distribute them over nearby nodes -- ref the note below. 138 139 .. note:: Spreading a well over its neighbouring cells was tried, then omitted. 140 141 Snapped to a cell centre, a well has its every effect a staircase function 142 of its coordinates -- constant within a cell -- which leaves an 143 optimisation over well *positions* with no gradient to work with until the 144 well crosses into the next cell (as in the EnOpt of 145 [HistoryMatching](https://github.com/patnr/HistoryMatching)). Distributing 146 it over the 4 surrounding cells by bilinear weights -- a *mollified* rather 147 than a rounded delta -- fixes that, and is not merely cosmetic: the 148 position dependence so obtained tracks that of a 3-times-refined grid to 149 within that grid's own discretization spread, i.e. some 5 times closer than 150 rounding manages. It does require the well index to be corrected for the 151 cells dividing the load, Peaceman's equivalent radius being derived for the 152 whole source in one cell: a well divided 4 ways under-reports its drawdown 153 by 23%, non-convergently, unless the weighted geometric mean of the 154 intercell distances is substituted for $r_e$ (which recovers 0.3%). 155 156 It was nonetheless judged not to earn its complexity -- a stencil threaded 157 through the well assembly, the well model and the plotting, for a 158 convenience of the optimiser rather than a fidelity of the simulator. The 159 work is preserved on the branch `well-spread` (`ResSim.spread_wells`, 160 `Grid2D.xy2stencil`, `wells._share_WI`, and `tests/test_spread.py`, which 161 pins each of the measurements quoted above). 162 """ 163 rates: Any = None 164 """Array of shape `(nComp, nTime)` -- or `(nComp, 1)` if constant-in-time. 165 Ref `from_records` for the convenient way to set this (and the other specs). 166 167 **Signed**: a positive rate *injects* (water), a negative one *produces* 168 (at the well cell's fractional flow). There is no other distinction 169 between injectors and producers, anywhere in the model. 170 An **areal** rate, i.e. volumetric *per unit thickness* -- ref 171 `minires.ResSim.cdarcy`. 172 173 .. note:: With `ct == 0` the rates must sum to 0 at each time index. 174 175 This is asserted (unless a well is BHP-controlled), otherwise the model 176 would silently input the deficit from the SW corner. 177 178 .. note:: Prefer `0` as the (ignored) fill value where control is by `bhp`. 179 180 The array being shared by all the wells, an entry must be given there 181 regardless. 182 """ 183 bhp: Any = None 184 """Bottom-hole pressures for the wells. `None`, or an array shaped like 185 `rates`, i.e. `(nComp, nTime)` -- or `(nComp, 1)` if constant-in-time. 186 187 A well whose entry is finite is **BHP-controlled** at that time. This is 188 solved *simultaneously* with the pressure field (not lagged by a time step): 189 `minires.ResSim.TPFA` puts $ WI λ_t $ on its diagonal and 190 $ WI λ_t \\, p_\\mathrm{bh} $ on its right-hand side. 191 Only once $ p $ is known is the resulting rate folded into the source field, 192 `_Q`, for the transport step -- ref `minires.ResSim.assemble_wells` 193 and `minires.ResSim.realize_bhp`. 194 195 Entries left as `nan` -- which is the default, for every well -- keep the 196 well rate-controlled, per `rates`. So the two mechanisms can be mixed 197 freely, both across wells and in time. 198 199 Requires `WI` (finite, for the BHP-controlled wells). The realized 200 rates are recorded in `actual_rates`, and the corresponding entries of 201 `rates` are ignored (it may be left as `None`). 202 203 .. note:: BHP control also *anchors the pressure*. 204 205 With `ct == 0` the pressure equation is otherwise a pure-Neumann 206 problem: solvable only up to a constant (which 207 `minires.ResSim.TPFA` pins arbitrarily, ref article p. 13), and 208 only if injection balances production. A single BHP 209 well lifts both restrictions -- the level is then set by 210 $ p_\\mathrm{bh} $, and the voidage by the well model. 211 212 .. warning:: A BHP well's flow direction is *emergent*, not declared. 213 214 It flows whichever way $ p_\\mathrm{bh} $ vs. its cell pressure dictates 215 (ref `minires.ResSim.realize_bhp`) -- and, like any inflow, an 216 inflow through a BHP well injects *water*. Since a reversal mid-`sim` 217 may nonetheless be a surprise, a `UserWarning` is emitted when a BHP 218 well's realized rate flips sign between steps of `sim`. 219 Nor is there native switching of control modes (e.g. rate control with 220 a BHP limit), but `minires.ResSim.well_controls` can approximate it. 221 """ 222 WI: Any = None 223 """Well indices: `None`, or an array of shape `(nComp,)`, `nan` allowed. 224 225 Compute $ WI $ with `peaceman_WI`, or set it directly 226 (it need not come from any particular formula). 227 A well whose entry is `nan` (or all of them, if `None`) has no well model: 228 its `actual_bhp` is `nan`, and BHP control (`bhp`) unavailable. 229 230 The well index is the *sub-grid* well model (ref the "Theory" section of 231 `minires.wells`), relating a well's (signed) flow rate to its drawdown, 232 $$ q = WI \\, λ_t \\, (p_\\mathrm{bh} - p_\\mathrm{cell}) \\,,$$ 233 with $ λ_t $ the total mobility (ref `minires.fluids.Fluid.RelPerm`) of the 234 well's cell. Re-arranging, 235 $$ p_\\mathrm{bh} = p_\\mathrm{cell} + q / (WI \\, λ_t) \\,, $$ 236 which is what `minires.ResSim.bhp` computes. 237 238 .. warning:: The drawdown is *not* a fixed offset. 239 240 It is not to be calibrated away once and for all: being 241 $ q / (WI \\, λ_t) $, it tracks the mobility -- 242 which, in a waterflood, dips as the front arrives (by half, for equal 243 viscosities). So the gap doubles at breakthrough: precisely when the 244 well is most interesting. 245 """ 246 group: Any = None 247 """Which well each completion belongs to: `None`, or an int array of shape 248 `(nComp,)` whose values index the wells, i.e. `names`. 249 250 The model itself is indifferent to it: the equations are assembled per 251 *completion* (ref `minires.ResSim.assemble_wells`), and the arrays -- 252 `xy`, `rates`, `actual_rates`, ... -- are all indexed likewise. The grouping 253 is what lets the *reporting* speak of wells nonetheless: ref 254 `rates_by_well`, and the labels of `minires.plotting.Plot2D.plt_field`. 255 """ 256 names: Any = None 257 """Names of wells (*not* completions): `None`, or a list of `nWell` strings.""" 258 259 actual_rates: Any = None 260 """The *realized* well rates: array of shape `(nComp, nSteps)`. Signed. 261 262 Mostly used as a diagnostic in case of `bhp`. But even for 263 rate-control it only coincides with `rates` up to broadcasting 264 and assuming `minires.ResSim.well_controls` did not override it. 265 """ 266 actual_bhp: Any = None 267 """Like `actual_rates`, but the bottom-hole pressures. 268 269 `nan` wherever the well index (`WI`) is unset. 270 """ 271 272 _grid: Any = None 273 """The model these wells are in -- set when assigned to it. Used only for 274 the grid geometry (which is why it is typed as such, ref `_bind`).""" 275 276 def __setattr__(self, key: str, val: Any) -> None: 277 # NB: the single normalization layer for the wells -- `from_records` 278 # routes its assignments through it rather than writing past it. 279 if key == "xy": 280 # Completion positions -- collocate at some node 281 val = ( 282 np.zeros((0, 2)) 283 if val is None 284 else np.array(val, float).reshape((-1, 2)) 285 ) 286 if self._grid is not None: 287 for i, (x, y) in enumerate(val): 288 val[i] = self._grid.ind2xy(self._grid.xy2ind(x, y)) 289 elif val is not None: 290 # Rates and/or pressures 291 if key in ["rates", "bhp"]: 292 val = np.array(val, float).reshape((self.nComp, -1)) 293 # Well indices 294 elif key == "WI": 295 val = np.broadcast_to(np.asarray(val, float).ravel(), self.nComp).copy() 296 # Completion-to-well map 297 elif key == "group": 298 val = np.asarray(val, int).reshape(self.nComp) 299 super().__setattr__(key, val) 300 301 def _bind(self, grid: Any) -> None: 302 """Attach to `grid` (a `minires.grid.Grid2D`, i.e. the model). 303 304 Whereupon the completions snap onto its nodes -- which an unbound 305 `Wells`, having no grid to snap to, could not do. 306 """ 307 self._grid = grid 308 self.xy = self.xy # re-normalize, now that there is a grid 309 310 nComp = property(lambda self: len(self.xy)) 311 """Num. of *completions*, i.e. rows of `xy`, which is what the model 312 actually solves for. Several completions may compose a single well 313 (ref `group`, `well_path`).""" 314 315 nWell = property( 316 lambda self: self.nComp if self.group is None else 1 + int(self.group.max()) 317 ) 318 """Num. of *wells*, i.e. groups of completions (ref `group`).""" 319 320 @property 321 def rates_by_well(self) -> np.ndarray: 322 """`actual_rates`, summed over each well's completions: `(nWell, nSteps)`.""" 323 group = np.arange(self.nComp) if self.group is None else self.group 324 out = np.zeros((self.nWell, self.actual_rates.shape[1])) 325 np.add.at(out, group, self.actual_rates) # NB: `+=` would skip the dupes 326 return out 327 328 @property 329 def signs(self) -> np.ndarray: 330 """The sign (`+1` inject, `-1` produce, `0` unknown) of each well's rate. 331 332 Read off the *spec*, `rates`, summed over time (`nan` entries -- 333 which a BHP-controlled well may well have -- being skipped). Wells left 334 undecided by it, i.e. those with no spec or a vanishing one (as under 335 pure BHP control), fall back on the `actual_rates` of the latest `sim`, 336 if there has been one. Only the truly undecided are then `0`. 337 338 PS: A safer approach to tracking injectors and producers is `.which("name*")`. 339 """ 340 sgn = np.zeros(self.nComp, int) 341 for rates in [self.rates, self.actual_rates]: 342 if rates is not None: 343 q = np.nansum(rates, axis=1) 344 sgn = np.where(sgn, sgn, (q > 0).astype(int) - (q < 0)) 345 return sgn 346 347 def which(self, *patterns: str) -> np.ndarray: 348 """Indices of the *completions* of the wells whose `names` match a pattern. 349 350 The patterns are `fnmatch` globs (`"Prd*"`, `"P?"`), or plain names. 351 Since the names are per *well*, while the arrays -- `xy`, `rates`, 352 `actual_rates`, ... -- are per completion, the match is expanded through 353 `group`. This is therefore how to address a *group* of wells in them: 354 355 >>> from minires import ResSim 356 >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[ 357 ... dict(name="Inj", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2), 358 ... dict(name="Prd1", xy=[.95, .95], rate=-.5), 359 ... dict(name="Prd2", xy=[.95, .05], rate=-.5), 360 ... ]) 361 >>> model.wells.which("Prd*") 362 array([5, 6]) 363 >>> model.wells.which("Inj") # a multi-completion well: all of its rows 364 array([0, 1, 2, 3, 4]) 365 >>> model.wells.rates[model.wells.which("Prd*")].ravel() 366 array([-0.5, -0.5]) 367 368 The selection being by name, it is unaffected by the controls -- unlike 369 `signs`, which reads `0` for a well shut over the whole horizon -- and 370 does not require the group to be contiguous. 371 """ 372 names = self.names if self.names is not None else range(self.nWell) 373 sel = [ 374 i 375 for i, nm in enumerate(names) 376 if any(fnmatchcase(str(nm), p) for p in patterns) 377 ] 378 group = np.arange(self.nComp) if self.group is None else self.group 379 return np.flatnonzero(np.isin(group, sel)) 380 381 def at_time(self, spec: str, absent: float, k: int) -> np.ndarray: 382 """Lookup the `spec` (`"rates"`/`"bhp"`) at time `k`. 383 384 Allows a constant-in-time (singleton) spec, and an unset (`None`) one 385 (for which `absent`, i.e. `0`/`nan` for rate/bhp-controlled wells 386 respectively, is returned). 387 Avoids broadcast (and potentially stale copies) to `(nComp, nSteps)`, 388 which requires `nSteps`, i.e. `sim()`. 389 """ 390 arr = getattr(self, spec) 391 if arr is None: 392 return np.full(self.nComp, absent) 393 assert len(arr) == self.nComp, ( 394 f"`wells.{spec}` has {len(arr)} rows, but there are" 395 f" {self.nComp} completions (ref `Wells.xy`)." 396 ) 397 # Copy, lest `well_controls` write into the spec itself 398 return np.copy(arr[:, k if arr.shape[1] > 1 else 0]) 399 400 @classmethod 401 def from_records(cls, model: "ResSim", wells: Any) -> "Wells": 402 """Assemble the flat, per-completion arrays from one record (`dict`) per well. 403 404 This is the convenient way to configure the wells, and assigning the 405 records to `minires.ResSim.wells` is what applies it. Each record 406 may specify 407 408 - `xy`: the well's position, `[x, y]` -- or positions, 409 `[[x, y], ...]`, for a multi-completion well. 410 - `path`: alternatively, a polyline, `[[x, y], ...]`, to be discretized 411 into one completion per cell it traverses, ref 412 `well_path`. Needs `rw`. 413 - `rate`: the well's (signed, ref `Wells.rates`) rate: a scalar, or a 414 schedule (an array over time). Apportioned among its completions in 415 proportion to their well indices (uniformly, absent those). 416 - `bhp`: alternatively (or, in time, additionally) the bottom-hole 417 pressure, ref `Wells.bhp`. Scalar or schedule. Shared -- as a wellbore 418 does -- by all of the well's completions. 419 - `rw`, `skin`: the wellbore radius and skin, whence the well index, via 420 `peaceman_WI`. Without them (or `WI`) the well has no well model. 421 - `WI`: alternatively, the well index itself, given directly. 422 - `aquifer`: `True` makes the well an aquifer contact: its `WI` is then 423 `aquifer_WI` of its cells (which must lie on the boundary), and its 424 `bhp` the aquifer pressure. A string of compass directions instead 425 (`"W"`, `"NE"`, ...) selects the boundary faces that count. Ref `aquifer_WI`. 426 - `name`: for the reporting. Defaults to the well's index. 427 428 The concise cases stay concise -- a position and a rate is a well. A 429 `dict` of records names them by its keys (as `name` does otherwise), and 430 the constructor takes the same thing: 431 432 >>> from minires import ResSim 433 >>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16, wells={ 434 ... "I1": dict(xy=[0, 0], rate=+1), 435 ... "P1": dict(xy=[1, 1], rate=-1, rw=1e-3), 436 ... }) 437 >>> model.wells.names 438 ['I1', 'P1'] 439 >>> model.wells.rates 440 array([[ 1.], 441 [-1.]]) 442 >>> model.wells.WI.round(3) # `P1` alone asked for a well model 443 array([ nan, 2.498]) 444 445 A `path` becomes several completions of a single well, whose rate it 446 shares out (ref `well_path`) and whose name they share: 447 448 >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[ 449 ... dict(name="I1", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2), 450 ... dict(name="P1", xy=[.95, .95], rate=-1), 451 ... ]) 452 >>> model.wells.nComp, model.wells.nWell 453 (6, 2) 454 >>> model.wells.group 455 array([0, 0, 0, 0, 0, 1]) 456 >>> model.wells.rates.ravel().round(3) 457 array([ 0.125, 0.25 , 0.25 , 0.25 , 0.125, -1. ]) 458 459 Schedules and control modes may be mixed freely across the wells: a 460 constant is broadcast to the length of the longest schedule, while a 461 spec that no well varies in time stays a singleton, which 462 `minires.ResSim.well_controls` reads at any `k`. A BHP-controlled 463 well leaves the (ignored) `0` in `Wells.rates`, and `nan` marks the 464 rate-controlled ones in `Wells.bhp` -- the conventions that the specs, 465 being shared arrays, call for (ref `Wells.rates`). 466 467 .. note:: A well must be given a control (`rate` and/or `bhp`). 468 469 `rate=0` shuts it in. This is deliberate: an uncontrolled well would 470 silently be a shut one. 471 """ 472 keys = ("name", "xy", "path", "rate", "bhp", "rw", "skin", "WI", "aquifer") 473 if isinstance(wells, dict): 474 wells = [dict(spec, name=name) for name, spec in wells.items()] 475 names, xy, WI, group, rates, bhp = [], [], [], [], [], [] 476 specified: set = set() 477 for i, well in enumerate(wells or []): 478 spec = dict(well) 479 if unknown := set(spec) - set(keys): 480 raise TypeError( 481 f"Unknown key(s) in the spec of well {i}: {sorted(unknown)}." 482 f" Valid ones: {list(keys)}." 483 ) 484 specified |= set(spec) 485 name = str(spec.pop("name", i)) 486 rw, skin = spec.pop("rw", None), spec.pop("skin", 0.0) 487 aquifer = spec.pop("aquifer", False) 488 489 # Completions: their positions, and their well indices 490 if (path := spec.pop("path", None)) is not None: 491 assert "xy" not in spec, ( 492 f"Well '{name}': give it `xy` or `path`, not both." 493 ) 494 assert rw is not None, f"Well '{name}': a `path` requires `rw`." 495 assert not aquifer, f"Well '{name}': an aquifer is given by `xy`." 496 _xy, _WI, _ = well_path(model, path, rw, skin) 497 else: 498 assert "xy" in spec, f"Well '{name}': give it an `xy` (or a `path`)." 499 _xy = np.array(spec.pop("xy"), float).reshape((-1, 2)) 500 if aquifer: 501 assert rw is None, f"Well '{name}': an aquifer has no `rw`." 502 _WI = aquifer_WI(model, _xy, "WESN" if aquifer is True else aquifer) 503 elif rw is not None: 504 _WI = peaceman_WI(model, _xy, rw, skin) 505 else: 506 _WI = np.full(len(_xy), np.nan) 507 if (given := spec.pop("WI", None)) is not None: 508 _WI = np.broadcast_to(np.asarray(given, float).ravel(), len(_xy)).copy() 509 nc = len(_xy) 510 511 # Apportion the rate by well index -- the standard, ref `well_path` 512 alloc = np.full(nc, 1 / nc) 513 if nc > 1 and np.isfinite(_WI).all() and _WI.sum() > 0: 514 alloc = _WI / _WI.sum() 515 516 # Controls. NB: the BHP is shared by the completions, the rate split 517 rate, p_bh = spec.pop("rate", None), spec.pop("bhp", None) 518 assert rate is not None or p_bh is not None, ( 519 f"Well '{name}' has no control: give it a `rate`" 520 " (`0` shuts it in), or a `bhp`." 521 ) 522 rate = 0.0 if rate is None else rate 523 p_bh = np.nan if p_bh is None else p_bh 524 rates.append(np.outer(alloc, np.ravel(rate))) 525 bhp.append(np.outer(np.ones(nc), np.ravel(p_bh))) 526 527 names.append(name) 528 xy.append(_xy) 529 WI.append(_WI) 530 group.append(np.full(nc, i)) 531 532 if not names: 533 return cls() 534 535 def stack(specs): 536 """Stack the wells' `(nComp_i, nTime_i)` specs, widening the constants. 537 538 NB: `at_time` broadcasts a *wholly* singleton spec, but the array is 539 shared, so a well held constant beside a scheduled one is widened here. 540 """ 541 nTime = max(spec.shape[1] for spec in specs) 542 assert all(spec.shape[1] in [1, nTime] for spec in specs), ( 543 "The wells' schedules must be of equal length (or constant):" 544 f" got {sorted({spec.shape[1] for spec in specs})}." 545 ) 546 return np.vstack([np.broadcast_to(s, (len(s), nTime)) for s in specs]) 547 548 WI = np.concatenate(WI) 549 return cls( 550 # NB: `xy` first -- it is what defines `nComp`, by which the 551 # `__setattr__` normalization shapes the others. 552 xy = np.vstack(xy), 553 # Leave a spec unset (`None`) if no well made use of it 554 rates = stack(rates) if "rate" in specified else None, 555 bhp = stack(bhp) if "bhp" in specified else None, 556 WI = WI if np.isfinite(WI).any() else None, 557 group = np.concatenate(group), 558 names = names, 559 ) # fmt: off 560 561 562def peaceman_WI(model: "ResSim", xy: Any, rw: float, skin: float = 0.0) -> np.ndarray: 563 """Peaceman's well index for wells at `xy`, of radius `rw`, in `model`. 564 565 Applied for you to a well of `Wells.from_records` given an `rw`, 566 which is the convenient way to use it. 567 568 $$ WI = \\frac{2 π \\sqrt{k_x k_y}}{\\ln(r_e / r_w) + \\mathrm{skin}} $$ 569 570 where the *equivalent radius*, $ r_e $, is the distance from the well at 571 which the (analytic, radial) pressure equals the (numerical) pressure of 572 the well's cell: 573 $$ r_e = 0.28 \\, 574 \\frac{\\sqrt{\\sqrt{k_y/k_x} \\, h_x^2 575 + \\sqrt{k_x/k_y} \\, h_y^2}} 576 {(k_y/k_x)^{1/4} + (k_x/k_y)^{1/4}} \\,, $$ 577 which reduces to the familiar $ r_e = 0.198 \\, h $ on an isotropic, 578 square grid. That constant is not a fudge factor: it is a property of 579 the 5-point stencil that `minires.ResSim.TPFA` assembles, and this model 580 reproduces it (`tests/test_wells.py` recovers $ r_e / h → 0.198 $ from the 581 simulated drawdown, and thereby the analytic, radial well pressure to 582 within 0.2%, on grids from 16² to 64²). 583 584 >>> from minires import ResSim 585 >>> model = ResSim(Lx=1, Ly=1, Nx=32, Ny=32) 586 >>> peaceman_WI(model, [[.5, .5]], rw=1e-3).round(4) 587 array([3.4476]) 588 589 .. note:: `model` is used for its grid and its `K` alone. 590 591 Which is why this is a free function, not a method: the well index is a 592 property of a *location*, not of the well configuration, and it is 593 evaluated once, when asked for -- so a later edit of `K` does not 594 retroactively change a `Wells.WI` computed from it. 595 596 .. note:: $ WI \\, λ_t \\, Δp $ comes out as a rate *per unit thickness*. 597 598 Ref. `minires.ResSim.cdarcy`. 599 600 .. note:: `rw` must be given in the same length unit as `Lx`. 601 """ 602 xy = np.asarray(xy, float).reshape((-1, 2)) 603 ix, iy = model.xy2sub(*xy.T) 604 kx, ky = model.K[0][ix, iy], model.K[1][ix, iy] 605 # fmt: off 606 a, b = np.sqrt(ky/kx), np.sqrt(kx/ky) 607 r_e = .28 * np.sqrt(a*model.hx**2 + b*model.hy**2) / (a**.5 + b**.5) 608 return model.cdarcy * 2*np.pi*np.sqrt(kx*ky) / (np.log(r_e/rw) + skin) 609 # fmt: on 610 611 612def well_path(model: "ResSim", vertices: Any, rw: float, skin: float = 0.0) -> tuple: 613 """Discretize a well *path* (a polyline): 1 weighted completion per cell. 614 615 Applied for you to a well of `Wells.from_records` given a `path`, which is 616 the convenient way to use it: the three returned arrays then need not be 617 assembled (with those of the other wells) by hand. 618 619 Returns `(xy, WI, alloc)`: 620 621 - `xy`: centres of the cells that the path traverses -- i.e. a value for 622 `Wells.xy`. Several completions act as a single well simply by 623 being several wells: `minires.ResSim.assemble_wells` superimposes them. 624 - `WI`: their well indices, i.e. a value for `Wells.WI`. Each is 625 `peaceman_WI` for its cell, scaled by the fraction of 626 that cell which the path actually traverses (so a cell merely clipped 627 by the path contributes proportionally less). 628 - `alloc`: `WI / WI.sum()`, for apportioning the rate among its completions: 629 `rates = rate * alloc[:, None]` (the rate signed as usual). 630 This is the standard (static) allocation -- proportional to the well index, 631 hence to both the contacted length and the local permeability. 632 633 >>> from minires import ResSim 634 >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10) 635 >>> xy, WI, alloc = well_path(model, [[.05, .05], [.45, .05]], rw=1e-2) 636 >>> xy.T[0] # the traversed cells, in x 637 array([0.05, 0.15, 0.25, 0.35, 0.45]) 638 >>> alloc # the end cells, entered mid-way, get half the rate 639 array([0.125, 0.25 , 0.25 , 0.25 , 0.125]) 640 641 .. note:: `alloc` is exact under BHP control, approximate under rate control. 642 643 Under BHP control (assuming 0 gravity and friction) the completions 644 simply share a `p_bh`. Under *rate* control, the allocation holds only 645 if the cell pressures are equal. Solving for it would make 646 $ p_\\mathrm{bh} $ an extra 647 unknown, i.e. a bordered linear system -- which the 5-diagonal 648 assembly of `minires.ResSim.TPFA` (Listing 1) is not set up for. Use 649 `minires.ResSim.well_controls` to reallocate per step, if it matters. 650 651 .. warning:: The completions are treated as independent *vertical* wells. 652 653 Which seems reasonable in a 2D areal model. Thus they count towards 654 `Wells.nComp`, not `Wells.nWell` -- ref `Wells.group`, which 655 `Wells.from_records` sets for you. 656 """ 657 V = np.asarray(vertices, float).reshape((-1, 2)) 658 assert len(V) >= 2, "A well path needs at least 2 vertices." 659 # Walk the polyline, accumulating traversed length per cell 660 lengths: dict = {} 661 for p0, p1 in zip(V[:-1], V[1:]): 662 d = p1 - p0 663 L = float(np.hypot(*d)) 664 if L == 0: 665 continue 666 ts = model._crossings(p0, d) 667 mids = p0 + np.outer((ts[:-1] + ts[1:]) / 2, d) 668 for mid, dt in zip(mids, np.diff(ts)): 669 sub = tuple(int(i) for i in model.xy2sub(*mid)) 670 lengths[sub] = lengths.get(sub, 0.0) + L * dt 671 # Discard the slivers left by corner crossings 672 total = sum(lengths.values()) 673 lengths = {k: v for k, v in lengths.items() if v > 1e-9 * total} 674 675 subs = np.array(list(lengths)) 676 xy = model.sub2xy(*subs.T).T 677 # Scale each WI by how much of its cell the path traverses, relative 678 # to the cell size -- so an axis-aligned full crossing scores exactly 1 679 # (and a diagonal one √2, it contacting that much more rock). 680 frac = np.array(list(lengths.values())) / np.sqrt(model.h2) 681 WI = frac * peaceman_WI(model, xy, rw, skin) 682 return xy, WI, WI / WI.sum() 683 684 685def aquifer_WI(model: "ResSim", xy: Any, faces: str = "WESN") -> np.ndarray: 686 """The "well index" of an aquifer contact: the transmissibility of the 687 boundary face(s) of each cell at `xy` -- from its centre out to the face. 688 689 An aquifer -- water-bearing rock beyond the reservoir's boundary, at a 690 pressure $ p_\\mathrm{aq} $ of its own -- feeds each reservoir cell that 691 touches it at the rate $ T \\, (p_\\mathrm{aq} - p) $, which is the law of a 692 BHP-controlled well, $ WI \\, λ_t \\, (p_\\mathrm{bh} - p) $. So an aquifer *is* 693 a BHP-controlled well, completed in every cell it touches, with 694 `bhp = p_aq` and this for `WI` -- and needs nothing else of the model: 695 the influx enters `minires.ResSim.assemble_wells` like any well's, 696 anchors the pressure (so that, if incompressible, a lone producer is fine: 697 the aquifer supplies it), is reported in `Wells.actual_rates`, and is 698 handled by the adjoint, `minires.tlm`, as any BHP well is. `Wells.from_records` applies it to a 699 well given `aquifer=True` (or `aquifer=faces`): 700 701 >>> from minires import ResSim 702 >>> model = ResSim(Lx=1, Ly=1, Nx=4, Ny=4, wells=[ 703 ... dict(name="Aq", xy=[[0, .3], [0, .5], [0, .7]], aquifer=True, bhp=2), 704 ... dict(name="P1", xy=[1, 1], rate=-1), 705 ... ]) 706 >>> model.wells.WI 707 array([ 2., 2., 2., nan]) 708 >>> SS, PP = model.sim(.1, 3, np.zeros(model.Nxy), pbar=False) 709 >>> model.wells.rates_by_well.round(12) # the aquifer supplies the producer 710 array([[ 1., 1., 1.], 711 [-1., -1., -1.]]) 712 713 A cell's *boundary faces* are those across which the neighbour is inactive 714 (ref `minires.ResSim.active`) or outside the grid; it must have at 715 least one, and be active itself. Each contributes the half-cell 716 transmissibility, $ C \\, k \\, h_⊥ / (h_∥ / 2) $ (compare the whole-cell 717 one of `minires.ResSim.TPFA`), so that the aquifer pressure is 718 imposed *at the face* -- a Dirichlet condition, its flux discretized as 719 the interior ones are. A corner cell, with two boundary faces, gets both -- 720 unless `faces` (a string of compass directions) leaves one out, as it must 721 when that edge is sealed, or on a 1D strip, whose every cell is a boundary 722 cell to the north and south. On a curved outline, keep them all. 723 724 **Aquifers** are beneficial in reservoirs as they act as pressure compensators. 725 Oil production ⇒ pressure decrease ⇒ aquifers expansion ⇒ pressure compensation. 726 Despite consisting of water, the expansion is generally significant 727 because the base volume is so big, 728 or the aquifer might even be connected to the ocean -- 729 which is the *infinite* aquifer that a constant `bhp` here posits. 730 (The top of the water-bearing zone is the *water table*.) 731 732 .. note:: The mobility is the cell's total one, $ λ_t(S) $, as for any well. 733 734 Whereas a boundary face of the TPFA scheme would upwind it from the 735 aquifer side (water, $ S = 1 $). The difference is the injector's 736 well model, no more; the influx is water either way. 737 For an aquifer of a given *strength* -- a productivity index, $ J $, 738 as in the Fetkovich model -- give `WI` directly instead, or scale this. 739 """ 740 xy = np.asarray(xy, float).reshape((-1, 2)) 741 ix, iy = model.xy2sub(*xy.T) 742 # The number of boundary faces (among those selected) in each direction 743 n = boundary_faces(model, xy, faces).astype(int) # NB: `bool + bool` is an `or` 744 nx, ny = n[:, :2].sum(1), n[:, 2:].sum(1) 745 assert (nx + ny > 0).all(), ( 746 "An aquifer cell must lie on the boundary: have a face to an inactive" 747 " cell, or to outside the grid (ref `aquifer_WI`)." 748 ) 749 kx, ky = model.K[0][ix, iy], model.K[1][ix, iy] 750 return model.cdarcy * 2 * (nx * kx * model.hy / model.hx + ny * ky * model.hx / model.hy) 751 752 753def boundary_faces(model: "ResSim", xy: Any, faces: str = "WESN") -> np.ndarray: 754 """Which faces of the cells at `xy` are *boundary* faces: to an inactive 755 cell (ref `minires.ResSim.active`), or to outside the grid. 756 757 Boolean, `(nCells, 4)`, the columns being the directions W, E, S, N -- 758 of which `faces` (a string of them) selects the ones considered at all. 759 Serves `aquifer_WI`, and `minires.plotting.Plot2D.plt_faces`. 760 761 >>> from minires import ResSim 762 >>> model = ResSim(Lx=1, Ly=1, Nx=4, Ny=4) 763 >>> boundary_faces(model, [[0, 0], [0, .5], [.5, .5]]).astype(int) 764 array([[1, 0, 1, 0], 765 [1, 0, 0, 0], 766 [0, 0, 0, 0]]) 767 """ 768 xy = np.asarray(xy, float).reshape((-1, 2)) 769 ix, iy = model.xy2sub(*xy.T) 770 act = np.pad(model.active, 1, constant_values=False) # off-grid ⇒ inactive 771 assert act[ix + 1, iy + 1].all(), "The cells must be active (ref `active`)." 772 ix, iy = ix + 1, iy + 1 # (in the padded mask) 773 nbrs = [act[ix - 1, iy], act[ix + 1, iy], act[ix, iy - 1], act[ix, iy + 1]] 774 selected = np.array([d in faces for d in "WESN"]) 775 return ~np.stack(nbrs, -1) & selected
105@dataclass(repr=False) 106class Wells(AlignedRepr): 107 """The wells of a `minires.ResSim`: the flat, per-completion arrays. 108 109 Contains arrays which specify the well (completions) and may be written to. 110 However, the constructor offers a more convenient API, via `from_records`, 111 but these records themselves are *not* retained (and cannot go stale). 112 113 A `Wells` may also be built before it has a grid to snap to -- as below, 114 where it is the assignment to the model that binds (and snaps) it. 115 116 >>> from minires import ResSim 117 >>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16, 118 ... wells=Wells(xy=[[0, 0], [1, 1]], rates=[[1], [-1]])) 119 >>> model.wells.rates = [[2], [-2]] 120 >>> model.wells.nComp 121 2 122 123 Assigning to `minires.ResSim.wells` normalizes it (`__setattr__`): positions 124 get snapped onto the grid nodes, schedules reshaped to `(nComp, nTime)`, etc. 125 """ 126 127 # NB: the array attributes are typed `Any` since `__setattr__` normalizes 128 # whatever array-like (nested lists, scalars) is assigned to them. 129 xy: Any = None 130 """Array of shape `(nComp, 2)` of x- and y-coords for the completions. 131 132 Values should be betwen `0` and `Lx` or `Ly`. Empty, shape `(0, 2)`, if 133 there are no wells. 134 135 .. warning:: The wells get co-located with grid nodes, ref `xy2sub`. 136 137 This is a design choice, not a mathematical necessity. An alternative 138 would be to distribute them over nearby nodes -- ref the note below. 139 140 .. note:: Spreading a well over its neighbouring cells was tried, then omitted. 141 142 Snapped to a cell centre, a well has its every effect a staircase function 143 of its coordinates -- constant within a cell -- which leaves an 144 optimisation over well *positions* with no gradient to work with until the 145 well crosses into the next cell (as in the EnOpt of 146 [HistoryMatching](https://github.com/patnr/HistoryMatching)). Distributing 147 it over the 4 surrounding cells by bilinear weights -- a *mollified* rather 148 than a rounded delta -- fixes that, and is not merely cosmetic: the 149 position dependence so obtained tracks that of a 3-times-refined grid to 150 within that grid's own discretization spread, i.e. some 5 times closer than 151 rounding manages. It does require the well index to be corrected for the 152 cells dividing the load, Peaceman's equivalent radius being derived for the 153 whole source in one cell: a well divided 4 ways under-reports its drawdown 154 by 23%, non-convergently, unless the weighted geometric mean of the 155 intercell distances is substituted for $r_e$ (which recovers 0.3%). 156 157 It was nonetheless judged not to earn its complexity -- a stencil threaded 158 through the well assembly, the well model and the plotting, for a 159 convenience of the optimiser rather than a fidelity of the simulator. The 160 work is preserved on the branch `well-spread` (`ResSim.spread_wells`, 161 `Grid2D.xy2stencil`, `wells._share_WI`, and `tests/test_spread.py`, which 162 pins each of the measurements quoted above). 163 """ 164 rates: Any = None 165 """Array of shape `(nComp, nTime)` -- or `(nComp, 1)` if constant-in-time. 166 Ref `from_records` for the convenient way to set this (and the other specs). 167 168 **Signed**: a positive rate *injects* (water), a negative one *produces* 169 (at the well cell's fractional flow). There is no other distinction 170 between injectors and producers, anywhere in the model. 171 An **areal** rate, i.e. volumetric *per unit thickness* -- ref 172 `minires.ResSim.cdarcy`. 173 174 .. note:: With `ct == 0` the rates must sum to 0 at each time index. 175 176 This is asserted (unless a well is BHP-controlled), otherwise the model 177 would silently input the deficit from the SW corner. 178 179 .. note:: Prefer `0` as the (ignored) fill value where control is by `bhp`. 180 181 The array being shared by all the wells, an entry must be given there 182 regardless. 183 """ 184 bhp: Any = None 185 """Bottom-hole pressures for the wells. `None`, or an array shaped like 186 `rates`, i.e. `(nComp, nTime)` -- or `(nComp, 1)` if constant-in-time. 187 188 A well whose entry is finite is **BHP-controlled** at that time. This is 189 solved *simultaneously* with the pressure field (not lagged by a time step): 190 `minires.ResSim.TPFA` puts $ WI λ_t $ on its diagonal and 191 $ WI λ_t \\, p_\\mathrm{bh} $ on its right-hand side. 192 Only once $ p $ is known is the resulting rate folded into the source field, 193 `_Q`, for the transport step -- ref `minires.ResSim.assemble_wells` 194 and `minires.ResSim.realize_bhp`. 195 196 Entries left as `nan` -- which is the default, for every well -- keep the 197 well rate-controlled, per `rates`. So the two mechanisms can be mixed 198 freely, both across wells and in time. 199 200 Requires `WI` (finite, for the BHP-controlled wells). The realized 201 rates are recorded in `actual_rates`, and the corresponding entries of 202 `rates` are ignored (it may be left as `None`). 203 204 .. note:: BHP control also *anchors the pressure*. 205 206 With `ct == 0` the pressure equation is otherwise a pure-Neumann 207 problem: solvable only up to a constant (which 208 `minires.ResSim.TPFA` pins arbitrarily, ref article p. 13), and 209 only if injection balances production. A single BHP 210 well lifts both restrictions -- the level is then set by 211 $ p_\\mathrm{bh} $, and the voidage by the well model. 212 213 .. warning:: A BHP well's flow direction is *emergent*, not declared. 214 215 It flows whichever way $ p_\\mathrm{bh} $ vs. its cell pressure dictates 216 (ref `minires.ResSim.realize_bhp`) -- and, like any inflow, an 217 inflow through a BHP well injects *water*. Since a reversal mid-`sim` 218 may nonetheless be a surprise, a `UserWarning` is emitted when a BHP 219 well's realized rate flips sign between steps of `sim`. 220 Nor is there native switching of control modes (e.g. rate control with 221 a BHP limit), but `minires.ResSim.well_controls` can approximate it. 222 """ 223 WI: Any = None 224 """Well indices: `None`, or an array of shape `(nComp,)`, `nan` allowed. 225 226 Compute $ WI $ with `peaceman_WI`, or set it directly 227 (it need not come from any particular formula). 228 A well whose entry is `nan` (or all of them, if `None`) has no well model: 229 its `actual_bhp` is `nan`, and BHP control (`bhp`) unavailable. 230 231 The well index is the *sub-grid* well model (ref the "Theory" section of 232 `minires.wells`), relating a well's (signed) flow rate to its drawdown, 233 $$ q = WI \\, λ_t \\, (p_\\mathrm{bh} - p_\\mathrm{cell}) \\,,$$ 234 with $ λ_t $ the total mobility (ref `minires.fluids.Fluid.RelPerm`) of the 235 well's cell. Re-arranging, 236 $$ p_\\mathrm{bh} = p_\\mathrm{cell} + q / (WI \\, λ_t) \\,, $$ 237 which is what `minires.ResSim.bhp` computes. 238 239 .. warning:: The drawdown is *not* a fixed offset. 240 241 It is not to be calibrated away once and for all: being 242 $ q / (WI \\, λ_t) $, it tracks the mobility -- 243 which, in a waterflood, dips as the front arrives (by half, for equal 244 viscosities). So the gap doubles at breakthrough: precisely when the 245 well is most interesting. 246 """ 247 group: Any = None 248 """Which well each completion belongs to: `None`, or an int array of shape 249 `(nComp,)` whose values index the wells, i.e. `names`. 250 251 The model itself is indifferent to it: the equations are assembled per 252 *completion* (ref `minires.ResSim.assemble_wells`), and the arrays -- 253 `xy`, `rates`, `actual_rates`, ... -- are all indexed likewise. The grouping 254 is what lets the *reporting* speak of wells nonetheless: ref 255 `rates_by_well`, and the labels of `minires.plotting.Plot2D.plt_field`. 256 """ 257 names: Any = None 258 """Names of wells (*not* completions): `None`, or a list of `nWell` strings.""" 259 260 actual_rates: Any = None 261 """The *realized* well rates: array of shape `(nComp, nSteps)`. Signed. 262 263 Mostly used as a diagnostic in case of `bhp`. But even for 264 rate-control it only coincides with `rates` up to broadcasting 265 and assuming `minires.ResSim.well_controls` did not override it. 266 """ 267 actual_bhp: Any = None 268 """Like `actual_rates`, but the bottom-hole pressures. 269 270 `nan` wherever the well index (`WI`) is unset. 271 """ 272 273 _grid: Any = None 274 """The model these wells are in -- set when assigned to it. Used only for 275 the grid geometry (which is why it is typed as such, ref `_bind`).""" 276 277 def __setattr__(self, key: str, val: Any) -> None: 278 # NB: the single normalization layer for the wells -- `from_records` 279 # routes its assignments through it rather than writing past it. 280 if key == "xy": 281 # Completion positions -- collocate at some node 282 val = ( 283 np.zeros((0, 2)) 284 if val is None 285 else np.array(val, float).reshape((-1, 2)) 286 ) 287 if self._grid is not None: 288 for i, (x, y) in enumerate(val): 289 val[i] = self._grid.ind2xy(self._grid.xy2ind(x, y)) 290 elif val is not None: 291 # Rates and/or pressures 292 if key in ["rates", "bhp"]: 293 val = np.array(val, float).reshape((self.nComp, -1)) 294 # Well indices 295 elif key == "WI": 296 val = np.broadcast_to(np.asarray(val, float).ravel(), self.nComp).copy() 297 # Completion-to-well map 298 elif key == "group": 299 val = np.asarray(val, int).reshape(self.nComp) 300 super().__setattr__(key, val) 301 302 def _bind(self, grid: Any) -> None: 303 """Attach to `grid` (a `minires.grid.Grid2D`, i.e. the model). 304 305 Whereupon the completions snap onto its nodes -- which an unbound 306 `Wells`, having no grid to snap to, could not do. 307 """ 308 self._grid = grid 309 self.xy = self.xy # re-normalize, now that there is a grid 310 311 nComp = property(lambda self: len(self.xy)) 312 """Num. of *completions*, i.e. rows of `xy`, which is what the model 313 actually solves for. Several completions may compose a single well 314 (ref `group`, `well_path`).""" 315 316 nWell = property( 317 lambda self: self.nComp if self.group is None else 1 + int(self.group.max()) 318 ) 319 """Num. of *wells*, i.e. groups of completions (ref `group`).""" 320 321 @property 322 def rates_by_well(self) -> np.ndarray: 323 """`actual_rates`, summed over each well's completions: `(nWell, nSteps)`.""" 324 group = np.arange(self.nComp) if self.group is None else self.group 325 out = np.zeros((self.nWell, self.actual_rates.shape[1])) 326 np.add.at(out, group, self.actual_rates) # NB: `+=` would skip the dupes 327 return out 328 329 @property 330 def signs(self) -> np.ndarray: 331 """The sign (`+1` inject, `-1` produce, `0` unknown) of each well's rate. 332 333 Read off the *spec*, `rates`, summed over time (`nan` entries -- 334 which a BHP-controlled well may well have -- being skipped). Wells left 335 undecided by it, i.e. those with no spec or a vanishing one (as under 336 pure BHP control), fall back on the `actual_rates` of the latest `sim`, 337 if there has been one. Only the truly undecided are then `0`. 338 339 PS: A safer approach to tracking injectors and producers is `.which("name*")`. 340 """ 341 sgn = np.zeros(self.nComp, int) 342 for rates in [self.rates, self.actual_rates]: 343 if rates is not None: 344 q = np.nansum(rates, axis=1) 345 sgn = np.where(sgn, sgn, (q > 0).astype(int) - (q < 0)) 346 return sgn 347 348 def which(self, *patterns: str) -> np.ndarray: 349 """Indices of the *completions* of the wells whose `names` match a pattern. 350 351 The patterns are `fnmatch` globs (`"Prd*"`, `"P?"`), or plain names. 352 Since the names are per *well*, while the arrays -- `xy`, `rates`, 353 `actual_rates`, ... -- are per completion, the match is expanded through 354 `group`. This is therefore how to address a *group* of wells in them: 355 356 >>> from minires import ResSim 357 >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[ 358 ... dict(name="Inj", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2), 359 ... dict(name="Prd1", xy=[.95, .95], rate=-.5), 360 ... dict(name="Prd2", xy=[.95, .05], rate=-.5), 361 ... ]) 362 >>> model.wells.which("Prd*") 363 array([5, 6]) 364 >>> model.wells.which("Inj") # a multi-completion well: all of its rows 365 array([0, 1, 2, 3, 4]) 366 >>> model.wells.rates[model.wells.which("Prd*")].ravel() 367 array([-0.5, -0.5]) 368 369 The selection being by name, it is unaffected by the controls -- unlike 370 `signs`, which reads `0` for a well shut over the whole horizon -- and 371 does not require the group to be contiguous. 372 """ 373 names = self.names if self.names is not None else range(self.nWell) 374 sel = [ 375 i 376 for i, nm in enumerate(names) 377 if any(fnmatchcase(str(nm), p) for p in patterns) 378 ] 379 group = np.arange(self.nComp) if self.group is None else self.group 380 return np.flatnonzero(np.isin(group, sel)) 381 382 def at_time(self, spec: str, absent: float, k: int) -> np.ndarray: 383 """Lookup the `spec` (`"rates"`/`"bhp"`) at time `k`. 384 385 Allows a constant-in-time (singleton) spec, and an unset (`None`) one 386 (for which `absent`, i.e. `0`/`nan` for rate/bhp-controlled wells 387 respectively, is returned). 388 Avoids broadcast (and potentially stale copies) to `(nComp, nSteps)`, 389 which requires `nSteps`, i.e. `sim()`. 390 """ 391 arr = getattr(self, spec) 392 if arr is None: 393 return np.full(self.nComp, absent) 394 assert len(arr) == self.nComp, ( 395 f"`wells.{spec}` has {len(arr)} rows, but there are" 396 f" {self.nComp} completions (ref `Wells.xy`)." 397 ) 398 # Copy, lest `well_controls` write into the spec itself 399 return np.copy(arr[:, k if arr.shape[1] > 1 else 0]) 400 401 @classmethod 402 def from_records(cls, model: "ResSim", wells: Any) -> "Wells": 403 """Assemble the flat, per-completion arrays from one record (`dict`) per well. 404 405 This is the convenient way to configure the wells, and assigning the 406 records to `minires.ResSim.wells` is what applies it. Each record 407 may specify 408 409 - `xy`: the well's position, `[x, y]` -- or positions, 410 `[[x, y], ...]`, for a multi-completion well. 411 - `path`: alternatively, a polyline, `[[x, y], ...]`, to be discretized 412 into one completion per cell it traverses, ref 413 `well_path`. Needs `rw`. 414 - `rate`: the well's (signed, ref `Wells.rates`) rate: a scalar, or a 415 schedule (an array over time). Apportioned among its completions in 416 proportion to their well indices (uniformly, absent those). 417 - `bhp`: alternatively (or, in time, additionally) the bottom-hole 418 pressure, ref `Wells.bhp`. Scalar or schedule. Shared -- as a wellbore 419 does -- by all of the well's completions. 420 - `rw`, `skin`: the wellbore radius and skin, whence the well index, via 421 `peaceman_WI`. Without them (or `WI`) the well has no well model. 422 - `WI`: alternatively, the well index itself, given directly. 423 - `aquifer`: `True` makes the well an aquifer contact: its `WI` is then 424 `aquifer_WI` of its cells (which must lie on the boundary), and its 425 `bhp` the aquifer pressure. A string of compass directions instead 426 (`"W"`, `"NE"`, ...) selects the boundary faces that count. Ref `aquifer_WI`. 427 - `name`: for the reporting. Defaults to the well's index. 428 429 The concise cases stay concise -- a position and a rate is a well. A 430 `dict` of records names them by its keys (as `name` does otherwise), and 431 the constructor takes the same thing: 432 433 >>> from minires import ResSim 434 >>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16, wells={ 435 ... "I1": dict(xy=[0, 0], rate=+1), 436 ... "P1": dict(xy=[1, 1], rate=-1, rw=1e-3), 437 ... }) 438 >>> model.wells.names 439 ['I1', 'P1'] 440 >>> model.wells.rates 441 array([[ 1.], 442 [-1.]]) 443 >>> model.wells.WI.round(3) # `P1` alone asked for a well model 444 array([ nan, 2.498]) 445 446 A `path` becomes several completions of a single well, whose rate it 447 shares out (ref `well_path`) and whose name they share: 448 449 >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[ 450 ... dict(name="I1", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2), 451 ... dict(name="P1", xy=[.95, .95], rate=-1), 452 ... ]) 453 >>> model.wells.nComp, model.wells.nWell 454 (6, 2) 455 >>> model.wells.group 456 array([0, 0, 0, 0, 0, 1]) 457 >>> model.wells.rates.ravel().round(3) 458 array([ 0.125, 0.25 , 0.25 , 0.25 , 0.125, -1. ]) 459 460 Schedules and control modes may be mixed freely across the wells: a 461 constant is broadcast to the length of the longest schedule, while a 462 spec that no well varies in time stays a singleton, which 463 `minires.ResSim.well_controls` reads at any `k`. A BHP-controlled 464 well leaves the (ignored) `0` in `Wells.rates`, and `nan` marks the 465 rate-controlled ones in `Wells.bhp` -- the conventions that the specs, 466 being shared arrays, call for (ref `Wells.rates`). 467 468 .. note:: A well must be given a control (`rate` and/or `bhp`). 469 470 `rate=0` shuts it in. This is deliberate: an uncontrolled well would 471 silently be a shut one. 472 """ 473 keys = ("name", "xy", "path", "rate", "bhp", "rw", "skin", "WI", "aquifer") 474 if isinstance(wells, dict): 475 wells = [dict(spec, name=name) for name, spec in wells.items()] 476 names, xy, WI, group, rates, bhp = [], [], [], [], [], [] 477 specified: set = set() 478 for i, well in enumerate(wells or []): 479 spec = dict(well) 480 if unknown := set(spec) - set(keys): 481 raise TypeError( 482 f"Unknown key(s) in the spec of well {i}: {sorted(unknown)}." 483 f" Valid ones: {list(keys)}." 484 ) 485 specified |= set(spec) 486 name = str(spec.pop("name", i)) 487 rw, skin = spec.pop("rw", None), spec.pop("skin", 0.0) 488 aquifer = spec.pop("aquifer", False) 489 490 # Completions: their positions, and their well indices 491 if (path := spec.pop("path", None)) is not None: 492 assert "xy" not in spec, ( 493 f"Well '{name}': give it `xy` or `path`, not both." 494 ) 495 assert rw is not None, f"Well '{name}': a `path` requires `rw`." 496 assert not aquifer, f"Well '{name}': an aquifer is given by `xy`." 497 _xy, _WI, _ = well_path(model, path, rw, skin) 498 else: 499 assert "xy" in spec, f"Well '{name}': give it an `xy` (or a `path`)." 500 _xy = np.array(spec.pop("xy"), float).reshape((-1, 2)) 501 if aquifer: 502 assert rw is None, f"Well '{name}': an aquifer has no `rw`." 503 _WI = aquifer_WI(model, _xy, "WESN" if aquifer is True else aquifer) 504 elif rw is not None: 505 _WI = peaceman_WI(model, _xy, rw, skin) 506 else: 507 _WI = np.full(len(_xy), np.nan) 508 if (given := spec.pop("WI", None)) is not None: 509 _WI = np.broadcast_to(np.asarray(given, float).ravel(), len(_xy)).copy() 510 nc = len(_xy) 511 512 # Apportion the rate by well index -- the standard, ref `well_path` 513 alloc = np.full(nc, 1 / nc) 514 if nc > 1 and np.isfinite(_WI).all() and _WI.sum() > 0: 515 alloc = _WI / _WI.sum() 516 517 # Controls. NB: the BHP is shared by the completions, the rate split 518 rate, p_bh = spec.pop("rate", None), spec.pop("bhp", None) 519 assert rate is not None or p_bh is not None, ( 520 f"Well '{name}' has no control: give it a `rate`" 521 " (`0` shuts it in), or a `bhp`." 522 ) 523 rate = 0.0 if rate is None else rate 524 p_bh = np.nan if p_bh is None else p_bh 525 rates.append(np.outer(alloc, np.ravel(rate))) 526 bhp.append(np.outer(np.ones(nc), np.ravel(p_bh))) 527 528 names.append(name) 529 xy.append(_xy) 530 WI.append(_WI) 531 group.append(np.full(nc, i)) 532 533 if not names: 534 return cls() 535 536 def stack(specs): 537 """Stack the wells' `(nComp_i, nTime_i)` specs, widening the constants. 538 539 NB: `at_time` broadcasts a *wholly* singleton spec, but the array is 540 shared, so a well held constant beside a scheduled one is widened here. 541 """ 542 nTime = max(spec.shape[1] for spec in specs) 543 assert all(spec.shape[1] in [1, nTime] for spec in specs), ( 544 "The wells' schedules must be of equal length (or constant):" 545 f" got {sorted({spec.shape[1] for spec in specs})}." 546 ) 547 return np.vstack([np.broadcast_to(s, (len(s), nTime)) for s in specs]) 548 549 WI = np.concatenate(WI) 550 return cls( 551 # NB: `xy` first -- it is what defines `nComp`, by which the 552 # `__setattr__` normalization shapes the others. 553 xy = np.vstack(xy), 554 # Leave a spec unset (`None`) if no well made use of it 555 rates = stack(rates) if "rate" in specified else None, 556 bhp = stack(bhp) if "bhp" in specified else None, 557 WI = WI if np.isfinite(WI).any() else None, 558 group = np.concatenate(group), 559 names = names, 560 ) # fmt: off
The wells of a minires.ResSim: the flat, per-completion arrays.
Contains arrays which specify the well (completions) and may be written to.
However, the constructor offers a more convenient API, via from_records,
but these records themselves are not retained (and cannot go stale).
A Wells may also be built before it has a grid to snap to -- as below,
where it is the assignment to the model that binds (and snaps) it.
>>> from minires import ResSim
>>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16,
... wells=Wells(xy=[[0, 0], [1, 1]], rates=[[1], [-1]]))
>>> model.wells.rates = [[2], [-2]]
>>> model.wells.nComp
2
Assigning to minires.ResSim.wells normalizes it (__setattr__): positions
get snapped onto the grid nodes, schedules reshaped to (nComp, nTime), etc.
Array of shape (nComp, 2) of x- and y-coords for the completions.
Values should be betwen 0 and Lx or Ly. Empty, shape (0, 2), if
there are no wells.
The wells get co-located with grid nodes, ref xy2sub.
This is a design choice, not a mathematical necessity. An alternative would be to distribute them over nearby nodes -- ref the note below.
Spreading a well over its neighbouring cells was tried, then omitted.
Snapped to a cell centre, a well has its every effect a staircase function of its coordinates -- constant within a cell -- which leaves an optimisation over well positions with no gradient to work with until the well crosses into the next cell (as in the EnOpt of HistoryMatching). Distributing it over the 4 surrounding cells by bilinear weights -- a mollified rather than a rounded delta -- fixes that, and is not merely cosmetic: the position dependence so obtained tracks that of a 3-times-refined grid to within that grid's own discretization spread, i.e. some 5 times closer than rounding manages. It does require the well index to be corrected for the cells dividing the load, Peaceman's equivalent radius being derived for the whole source in one cell: a well divided 4 ways under-reports its drawdown by 23%, non-convergently, unless the weighted geometric mean of the intercell distances is substituted for $r_e$ (which recovers 0.3%).
It was nonetheless judged not to earn its complexity -- a stencil threaded
through the well assembly, the well model and the plotting, for a
convenience of the optimiser rather than a fidelity of the simulator. The
work is preserved on the branch well-spread (ResSim.spread_wells,
Grid2D.xy2stencil, wells._share_WI, and tests/test_spread.py, which
pins each of the measurements quoted above).
Array of shape (nComp, nTime) -- or (nComp, 1) if constant-in-time.
Ref from_records for the convenient way to set this (and the other specs).
Signed: a positive rate injects (water), a negative one produces
(at the well cell's fractional flow). There is no other distinction
between injectors and producers, anywhere in the model.
An areal rate, i.e. volumetric per unit thickness -- ref
minires.ResSim.cdarcy.
With ct == 0 the rates must sum to 0 at each time index.
This is asserted (unless a well is BHP-controlled), otherwise the model would silently input the deficit from the SW corner.
Prefer 0 as the (ignored) fill value where control is by bhp.
The array being shared by all the wells, an entry must be given there regardless.
Bottom-hole pressures for the wells. None, or an array shaped like
rates, i.e. (nComp, nTime) -- or (nComp, 1) if constant-in-time.
A well whose entry is finite is BHP-controlled at that time. This is
solved simultaneously with the pressure field (not lagged by a time step):
minires.ResSim.TPFA puts $ WI λ_t $ on its diagonal and
$ WI λ_t \, p_\mathrm{bh} $ on its right-hand side.
Only once $ p $ is known is the resulting rate folded into the source field,
_Q, for the transport step -- ref minires.ResSim.assemble_wells
and minires.ResSim.realize_bhp.
Entries left as nan -- which is the default, for every well -- keep the
well rate-controlled, per rates. So the two mechanisms can be mixed
freely, both across wells and in time.
Requires WI (finite, for the BHP-controlled wells). The realized
rates are recorded in actual_rates, and the corresponding entries of
rates are ignored (it may be left as None).
BHP control also anchors the pressure.
With ct == 0 the pressure equation is otherwise a pure-Neumann
problem: solvable only up to a constant (which
minires.ResSim.TPFA pins arbitrarily, ref article p. 13), and
only if injection balances production. A single BHP
well lifts both restrictions -- the level is then set by
$ p_\mathrm{bh} $, and the voidage by the well model.
A BHP well's flow direction is emergent, not declared.
It flows whichever way $ p_\mathrm{bh} $ vs. its cell pressure dictates
(ref minires.ResSim.realize_bhp) -- and, like any inflow, an
inflow through a BHP well injects water. Since a reversal mid-sim
may nonetheless be a surprise, a UserWarning is emitted when a BHP
well's realized rate flips sign between steps of sim.
Nor is there native switching of control modes (e.g. rate control with
a BHP limit), but minires.ResSim.well_controls can approximate it.
Well indices: None, or an array of shape (nComp,), nan allowed.
Compute $ WI $ with peaceman_WI, or set it directly
(it need not come from any particular formula).
A well whose entry is nan (or all of them, if None) has no well model:
its actual_bhp is nan, and BHP control (bhp) unavailable.
The well index is the sub-grid well model (ref the "Theory" section of
minires.wells), relating a well's (signed) flow rate to its drawdown,
$$ q = WI \, λ_t \, (p_\mathrm{bh} - p_\mathrm{cell}) \,,$$
with $ λ_t $ the total mobility (ref minires.fluids.Fluid.RelPerm) of the
well's cell. Re-arranging,
$$ p_\mathrm{bh} = p_\mathrm{cell} + q / (WI \, λ_t) \,, $$
which is what minires.ResSim.bhp computes.
The drawdown is not a fixed offset.
It is not to be calibrated away once and for all: being $ q / (WI \, λ_t) $, it tracks the mobility -- which, in a waterflood, dips as the front arrives (by half, for equal viscosities). So the gap doubles at breakthrough: precisely when the well is most interesting.
Which well each completion belongs to: None, or an int array of shape
(nComp,) whose values index the wells, i.e. names.
The model itself is indifferent to it: the equations are assembled per
completion (ref minires.ResSim.assemble_wells), and the arrays --
xy, rates, actual_rates, ... -- are all indexed likewise. The grouping
is what lets the reporting speak of wells nonetheless: ref
rates_by_well, and the labels of minires.plotting.Plot2D.plt_field.
The realized well rates: array of shape (nComp, nSteps). Signed.
Mostly used as a diagnostic in case of bhp. But even for
rate-control it only coincides with rates up to broadcasting
and assuming minires.ResSim.well_controls did not override it.
Like actual_rates, but the bottom-hole pressures.
nan wherever the well index (WI) is unset.
317 lambda self: self.nComp if self.group is None else 1 + int(self.group.max())
Num. of wells, i.e. groups of completions (ref group).
321 @property 322 def rates_by_well(self) -> np.ndarray: 323 """`actual_rates`, summed over each well's completions: `(nWell, nSteps)`.""" 324 group = np.arange(self.nComp) if self.group is None else self.group 325 out = np.zeros((self.nWell, self.actual_rates.shape[1])) 326 np.add.at(out, group, self.actual_rates) # NB: `+=` would skip the dupes 327 return out
actual_rates, summed over each well's completions: (nWell, nSteps).
329 @property 330 def signs(self) -> np.ndarray: 331 """The sign (`+1` inject, `-1` produce, `0` unknown) of each well's rate. 332 333 Read off the *spec*, `rates`, summed over time (`nan` entries -- 334 which a BHP-controlled well may well have -- being skipped). Wells left 335 undecided by it, i.e. those with no spec or a vanishing one (as under 336 pure BHP control), fall back on the `actual_rates` of the latest `sim`, 337 if there has been one. Only the truly undecided are then `0`. 338 339 PS: A safer approach to tracking injectors and producers is `.which("name*")`. 340 """ 341 sgn = np.zeros(self.nComp, int) 342 for rates in [self.rates, self.actual_rates]: 343 if rates is not None: 344 q = np.nansum(rates, axis=1) 345 sgn = np.where(sgn, sgn, (q > 0).astype(int) - (q < 0)) 346 return sgn
The sign (+1 inject, -1 produce, 0 unknown) of each well's rate.
Read off the spec, rates, summed over time (nan entries --
which a BHP-controlled well may well have -- being skipped). Wells left
undecided by it, i.e. those with no spec or a vanishing one (as under
pure BHP control), fall back on the actual_rates of the latest sim,
if there has been one. Only the truly undecided are then 0.
PS: A safer approach to tracking injectors and producers is .which("name*").
348 def which(self, *patterns: str) -> np.ndarray: 349 """Indices of the *completions* of the wells whose `names` match a pattern. 350 351 The patterns are `fnmatch` globs (`"Prd*"`, `"P?"`), or plain names. 352 Since the names are per *well*, while the arrays -- `xy`, `rates`, 353 `actual_rates`, ... -- are per completion, the match is expanded through 354 `group`. This is therefore how to address a *group* of wells in them: 355 356 >>> from minires import ResSim 357 >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[ 358 ... dict(name="Inj", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2), 359 ... dict(name="Prd1", xy=[.95, .95], rate=-.5), 360 ... dict(name="Prd2", xy=[.95, .05], rate=-.5), 361 ... ]) 362 >>> model.wells.which("Prd*") 363 array([5, 6]) 364 >>> model.wells.which("Inj") # a multi-completion well: all of its rows 365 array([0, 1, 2, 3, 4]) 366 >>> model.wells.rates[model.wells.which("Prd*")].ravel() 367 array([-0.5, -0.5]) 368 369 The selection being by name, it is unaffected by the controls -- unlike 370 `signs`, which reads `0` for a well shut over the whole horizon -- and 371 does not require the group to be contiguous. 372 """ 373 names = self.names if self.names is not None else range(self.nWell) 374 sel = [ 375 i 376 for i, nm in enumerate(names) 377 if any(fnmatchcase(str(nm), p) for p in patterns) 378 ] 379 group = np.arange(self.nComp) if self.group is None else self.group 380 return np.flatnonzero(np.isin(group, sel))
Indices of the completions of the wells whose names match a pattern.
The patterns are fnmatch globs ("Prd*", "P?"), or plain names.
Since the names are per well, while the arrays -- xy, rates,
actual_rates, ... -- are per completion, the match is expanded through
group. This is therefore how to address a group of wells in them:
>>> from minires import ResSim
>>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[
... dict(name="Inj", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2),
... dict(name="Prd1", xy=[.95, .95], rate=-.5),
... dict(name="Prd2", xy=[.95, .05], rate=-.5),
... ])
>>> model.wells.which("Prd*")
array([5, 6])
>>> model.wells.which("Inj") # a multi-completion well: all of its rows
array([0, 1, 2, 3, 4])
>>> model.wells.rates[model.wells.which("Prd*")].ravel()
array([-0.5, -0.5])
The selection being by name, it is unaffected by the controls -- unlike
signs, which reads 0 for a well shut over the whole horizon -- and
does not require the group to be contiguous.
382 def at_time(self, spec: str, absent: float, k: int) -> np.ndarray: 383 """Lookup the `spec` (`"rates"`/`"bhp"`) at time `k`. 384 385 Allows a constant-in-time (singleton) spec, and an unset (`None`) one 386 (for which `absent`, i.e. `0`/`nan` for rate/bhp-controlled wells 387 respectively, is returned). 388 Avoids broadcast (and potentially stale copies) to `(nComp, nSteps)`, 389 which requires `nSteps`, i.e. `sim()`. 390 """ 391 arr = getattr(self, spec) 392 if arr is None: 393 return np.full(self.nComp, absent) 394 assert len(arr) == self.nComp, ( 395 f"`wells.{spec}` has {len(arr)} rows, but there are" 396 f" {self.nComp} completions (ref `Wells.xy`)." 397 ) 398 # Copy, lest `well_controls` write into the spec itself 399 return np.copy(arr[:, k if arr.shape[1] > 1 else 0])
Lookup the spec ("rates"/"bhp") at time k.
Allows a constant-in-time (singleton) spec, and an unset (None) one
(for which absent, i.e. 0/nan for rate/bhp-controlled wells
respectively, is returned).
Avoids broadcast (and potentially stale copies) to (nComp, nSteps),
which requires nSteps, i.e. sim().
401 @classmethod 402 def from_records(cls, model: "ResSim", wells: Any) -> "Wells": 403 """Assemble the flat, per-completion arrays from one record (`dict`) per well. 404 405 This is the convenient way to configure the wells, and assigning the 406 records to `minires.ResSim.wells` is what applies it. Each record 407 may specify 408 409 - `xy`: the well's position, `[x, y]` -- or positions, 410 `[[x, y], ...]`, for a multi-completion well. 411 - `path`: alternatively, a polyline, `[[x, y], ...]`, to be discretized 412 into one completion per cell it traverses, ref 413 `well_path`. Needs `rw`. 414 - `rate`: the well's (signed, ref `Wells.rates`) rate: a scalar, or a 415 schedule (an array over time). Apportioned among its completions in 416 proportion to their well indices (uniformly, absent those). 417 - `bhp`: alternatively (or, in time, additionally) the bottom-hole 418 pressure, ref `Wells.bhp`. Scalar or schedule. Shared -- as a wellbore 419 does -- by all of the well's completions. 420 - `rw`, `skin`: the wellbore radius and skin, whence the well index, via 421 `peaceman_WI`. Without them (or `WI`) the well has no well model. 422 - `WI`: alternatively, the well index itself, given directly. 423 - `aquifer`: `True` makes the well an aquifer contact: its `WI` is then 424 `aquifer_WI` of its cells (which must lie on the boundary), and its 425 `bhp` the aquifer pressure. A string of compass directions instead 426 (`"W"`, `"NE"`, ...) selects the boundary faces that count. Ref `aquifer_WI`. 427 - `name`: for the reporting. Defaults to the well's index. 428 429 The concise cases stay concise -- a position and a rate is a well. A 430 `dict` of records names them by its keys (as `name` does otherwise), and 431 the constructor takes the same thing: 432 433 >>> from minires import ResSim 434 >>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16, wells={ 435 ... "I1": dict(xy=[0, 0], rate=+1), 436 ... "P1": dict(xy=[1, 1], rate=-1, rw=1e-3), 437 ... }) 438 >>> model.wells.names 439 ['I1', 'P1'] 440 >>> model.wells.rates 441 array([[ 1.], 442 [-1.]]) 443 >>> model.wells.WI.round(3) # `P1` alone asked for a well model 444 array([ nan, 2.498]) 445 446 A `path` becomes several completions of a single well, whose rate it 447 shares out (ref `well_path`) and whose name they share: 448 449 >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[ 450 ... dict(name="I1", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2), 451 ... dict(name="P1", xy=[.95, .95], rate=-1), 452 ... ]) 453 >>> model.wells.nComp, model.wells.nWell 454 (6, 2) 455 >>> model.wells.group 456 array([0, 0, 0, 0, 0, 1]) 457 >>> model.wells.rates.ravel().round(3) 458 array([ 0.125, 0.25 , 0.25 , 0.25 , 0.125, -1. ]) 459 460 Schedules and control modes may be mixed freely across the wells: a 461 constant is broadcast to the length of the longest schedule, while a 462 spec that no well varies in time stays a singleton, which 463 `minires.ResSim.well_controls` reads at any `k`. A BHP-controlled 464 well leaves the (ignored) `0` in `Wells.rates`, and `nan` marks the 465 rate-controlled ones in `Wells.bhp` -- the conventions that the specs, 466 being shared arrays, call for (ref `Wells.rates`). 467 468 .. note:: A well must be given a control (`rate` and/or `bhp`). 469 470 `rate=0` shuts it in. This is deliberate: an uncontrolled well would 471 silently be a shut one. 472 """ 473 keys = ("name", "xy", "path", "rate", "bhp", "rw", "skin", "WI", "aquifer") 474 if isinstance(wells, dict): 475 wells = [dict(spec, name=name) for name, spec in wells.items()] 476 names, xy, WI, group, rates, bhp = [], [], [], [], [], [] 477 specified: set = set() 478 for i, well in enumerate(wells or []): 479 spec = dict(well) 480 if unknown := set(spec) - set(keys): 481 raise TypeError( 482 f"Unknown key(s) in the spec of well {i}: {sorted(unknown)}." 483 f" Valid ones: {list(keys)}." 484 ) 485 specified |= set(spec) 486 name = str(spec.pop("name", i)) 487 rw, skin = spec.pop("rw", None), spec.pop("skin", 0.0) 488 aquifer = spec.pop("aquifer", False) 489 490 # Completions: their positions, and their well indices 491 if (path := spec.pop("path", None)) is not None: 492 assert "xy" not in spec, ( 493 f"Well '{name}': give it `xy` or `path`, not both." 494 ) 495 assert rw is not None, f"Well '{name}': a `path` requires `rw`." 496 assert not aquifer, f"Well '{name}': an aquifer is given by `xy`." 497 _xy, _WI, _ = well_path(model, path, rw, skin) 498 else: 499 assert "xy" in spec, f"Well '{name}': give it an `xy` (or a `path`)." 500 _xy = np.array(spec.pop("xy"), float).reshape((-1, 2)) 501 if aquifer: 502 assert rw is None, f"Well '{name}': an aquifer has no `rw`." 503 _WI = aquifer_WI(model, _xy, "WESN" if aquifer is True else aquifer) 504 elif rw is not None: 505 _WI = peaceman_WI(model, _xy, rw, skin) 506 else: 507 _WI = np.full(len(_xy), np.nan) 508 if (given := spec.pop("WI", None)) is not None: 509 _WI = np.broadcast_to(np.asarray(given, float).ravel(), len(_xy)).copy() 510 nc = len(_xy) 511 512 # Apportion the rate by well index -- the standard, ref `well_path` 513 alloc = np.full(nc, 1 / nc) 514 if nc > 1 and np.isfinite(_WI).all() and _WI.sum() > 0: 515 alloc = _WI / _WI.sum() 516 517 # Controls. NB: the BHP is shared by the completions, the rate split 518 rate, p_bh = spec.pop("rate", None), spec.pop("bhp", None) 519 assert rate is not None or p_bh is not None, ( 520 f"Well '{name}' has no control: give it a `rate`" 521 " (`0` shuts it in), or a `bhp`." 522 ) 523 rate = 0.0 if rate is None else rate 524 p_bh = np.nan if p_bh is None else p_bh 525 rates.append(np.outer(alloc, np.ravel(rate))) 526 bhp.append(np.outer(np.ones(nc), np.ravel(p_bh))) 527 528 names.append(name) 529 xy.append(_xy) 530 WI.append(_WI) 531 group.append(np.full(nc, i)) 532 533 if not names: 534 return cls() 535 536 def stack(specs): 537 """Stack the wells' `(nComp_i, nTime_i)` specs, widening the constants. 538 539 NB: `at_time` broadcasts a *wholly* singleton spec, but the array is 540 shared, so a well held constant beside a scheduled one is widened here. 541 """ 542 nTime = max(spec.shape[1] for spec in specs) 543 assert all(spec.shape[1] in [1, nTime] for spec in specs), ( 544 "The wells' schedules must be of equal length (or constant):" 545 f" got {sorted({spec.shape[1] for spec in specs})}." 546 ) 547 return np.vstack([np.broadcast_to(s, (len(s), nTime)) for s in specs]) 548 549 WI = np.concatenate(WI) 550 return cls( 551 # NB: `xy` first -- it is what defines `nComp`, by which the 552 # `__setattr__` normalization shapes the others. 553 xy = np.vstack(xy), 554 # Leave a spec unset (`None`) if no well made use of it 555 rates = stack(rates) if "rate" in specified else None, 556 bhp = stack(bhp) if "bhp" in specified else None, 557 WI = WI if np.isfinite(WI).any() else None, 558 group = np.concatenate(group), 559 names = names, 560 ) # fmt: off
Assemble the flat, per-completion arrays from one record (dict) per well.
This is the convenient way to configure the wells, and assigning the
records to minires.ResSim.wells is what applies it. Each record
may specify
xy: the well's position,[x, y]-- or positions,[[x, y], ...], for a multi-completion well.path: alternatively, a polyline,[[x, y], ...], to be discretized into one completion per cell it traverses, refwell_path. Needsrw.rate: the well's (signed, refWells.rates) rate: a scalar, or a schedule (an array over time). Apportioned among its completions in proportion to their well indices (uniformly, absent those).bhp: alternatively (or, in time, additionally) the bottom-hole pressure, refWells.bhp. Scalar or schedule. Shared -- as a wellbore does -- by all of the well's completions.rw,skin: the wellbore radius and skin, whence the well index, viapeaceman_WI. Without them (orWI) the well has no well model.WI: alternatively, the well index itself, given directly.aquifer:Truemakes the well an aquifer contact: itsWIis thenaquifer_WIof its cells (which must lie on the boundary), and itsbhpthe aquifer pressure. A string of compass directions instead ("W","NE", ...) selects the boundary faces that count. Refaquifer_WI.name: for the reporting. Defaults to the well's index.
The concise cases stay concise -- a position and a rate is a well. A
dict of records names them by its keys (as name does otherwise), and
the constructor takes the same thing:
>>> from minires import ResSim
>>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16, wells={
... "I1": dict(xy=[0, 0], rate=+1),
... "P1": dict(xy=[1, 1], rate=-1, rw=1e-3),
... })
>>> model.wells.names
['I1', 'P1']
>>> model.wells.rates
array([[ 1.],
[-1.]])
>>> model.wells.WI.round(3) # `P1` alone asked for a well model
array([ nan, 2.498])
A path becomes several completions of a single well, whose rate it
shares out (ref well_path) and whose name they share:
>>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[
... dict(name="I1", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2),
... dict(name="P1", xy=[.95, .95], rate=-1),
... ])
>>> model.wells.nComp, model.wells.nWell
(6, 2)
>>> model.wells.group
array([0, 0, 0, 0, 0, 1])
>>> model.wells.rates.ravel().round(3)
array([ 0.125, 0.25 , 0.25 , 0.25 , 0.125, -1. ])
Schedules and control modes may be mixed freely across the wells: a
constant is broadcast to the length of the longest schedule, while a
spec that no well varies in time stays a singleton, which
minires.ResSim.well_controls reads at any k. A BHP-controlled
well leaves the (ignored) 0 in Wells.rates, and nan marks the
rate-controlled ones in Wells.bhp -- the conventions that the specs,
being shared arrays, call for (ref Wells.rates).
A well must be given a control (rate and/or bhp).
rate=0 shuts it in. This is deliberate: an uncontrolled well would
silently be a shut one.
563def peaceman_WI(model: "ResSim", xy: Any, rw: float, skin: float = 0.0) -> np.ndarray: 564 """Peaceman's well index for wells at `xy`, of radius `rw`, in `model`. 565 566 Applied for you to a well of `Wells.from_records` given an `rw`, 567 which is the convenient way to use it. 568 569 $$ WI = \\frac{2 π \\sqrt{k_x k_y}}{\\ln(r_e / r_w) + \\mathrm{skin}} $$ 570 571 where the *equivalent radius*, $ r_e $, is the distance from the well at 572 which the (analytic, radial) pressure equals the (numerical) pressure of 573 the well's cell: 574 $$ r_e = 0.28 \\, 575 \\frac{\\sqrt{\\sqrt{k_y/k_x} \\, h_x^2 576 + \\sqrt{k_x/k_y} \\, h_y^2}} 577 {(k_y/k_x)^{1/4} + (k_x/k_y)^{1/4}} \\,, $$ 578 which reduces to the familiar $ r_e = 0.198 \\, h $ on an isotropic, 579 square grid. That constant is not a fudge factor: it is a property of 580 the 5-point stencil that `minires.ResSim.TPFA` assembles, and this model 581 reproduces it (`tests/test_wells.py` recovers $ r_e / h → 0.198 $ from the 582 simulated drawdown, and thereby the analytic, radial well pressure to 583 within 0.2%, on grids from 16² to 64²). 584 585 >>> from minires import ResSim 586 >>> model = ResSim(Lx=1, Ly=1, Nx=32, Ny=32) 587 >>> peaceman_WI(model, [[.5, .5]], rw=1e-3).round(4) 588 array([3.4476]) 589 590 .. note:: `model` is used for its grid and its `K` alone. 591 592 Which is why this is a free function, not a method: the well index is a 593 property of a *location*, not of the well configuration, and it is 594 evaluated once, when asked for -- so a later edit of `K` does not 595 retroactively change a `Wells.WI` computed from it. 596 597 .. note:: $ WI \\, λ_t \\, Δp $ comes out as a rate *per unit thickness*. 598 599 Ref. `minires.ResSim.cdarcy`. 600 601 .. note:: `rw` must be given in the same length unit as `Lx`. 602 """ 603 xy = np.asarray(xy, float).reshape((-1, 2)) 604 ix, iy = model.xy2sub(*xy.T) 605 kx, ky = model.K[0][ix, iy], model.K[1][ix, iy] 606 # fmt: off 607 a, b = np.sqrt(ky/kx), np.sqrt(kx/ky) 608 r_e = .28 * np.sqrt(a*model.hx**2 + b*model.hy**2) / (a**.5 + b**.5) 609 return model.cdarcy * 2*np.pi*np.sqrt(kx*ky) / (np.log(r_e/rw) + skin) 610 # fmt: on
Peaceman's well index for wells at xy, of radius rw, in model.
Applied for you to a well of Wells.from_records given an rw,
which is the convenient way to use it.
$$ WI = \frac{2 π \sqrt{k_x k_y}}{\ln(r_e / r_w) + \mathrm{skin}} $$
where the equivalent radius, $ r_e $, is the distance from the well at
which the (analytic, radial) pressure equals the (numerical) pressure of
the well's cell:
$$ r_e = 0.28 \,
\frac{\sqrt{\sqrt{k_y/k_x} \, h_x^2
+ \sqrt{k_x/k_y} \, h_y^2}}
{(k_y/k_x)^{1/4} + (k_x/k_y)^{1/4}} \,, $$
which reduces to the familiar $ r_e = 0.198 \, h $ on an isotropic,
square grid. That constant is not a fudge factor: it is a property of
the 5-point stencil that minires.ResSim.TPFA assembles, and this model
reproduces it (tests/test_wells.py recovers $ r_e / h → 0.198 $ from the
simulated drawdown, and thereby the analytic, radial well pressure to
within 0.2%, on grids from 16² to 64²).
>>> from minires import ResSim
>>> model = ResSim(Lx=1, Ly=1, Nx=32, Ny=32)
>>> peaceman_WI(model, [[.5, .5]], rw=1e-3).round(4)
array([3.4476])
model is used for its grid and its K alone.
Which is why this is a free function, not a method: the well index is a
property of a location, not of the well configuration, and it is
evaluated once, when asked for -- so a later edit of K does not
retroactively change a Wells.WI computed from it.
$ WI \, λ_t \, Δp $ comes out as a rate per unit thickness.
Ref. minires.ResSim.cdarcy.
rw must be given in the same length unit as Lx.
613def well_path(model: "ResSim", vertices: Any, rw: float, skin: float = 0.0) -> tuple: 614 """Discretize a well *path* (a polyline): 1 weighted completion per cell. 615 616 Applied for you to a well of `Wells.from_records` given a `path`, which is 617 the convenient way to use it: the three returned arrays then need not be 618 assembled (with those of the other wells) by hand. 619 620 Returns `(xy, WI, alloc)`: 621 622 - `xy`: centres of the cells that the path traverses -- i.e. a value for 623 `Wells.xy`. Several completions act as a single well simply by 624 being several wells: `minires.ResSim.assemble_wells` superimposes them. 625 - `WI`: their well indices, i.e. a value for `Wells.WI`. Each is 626 `peaceman_WI` for its cell, scaled by the fraction of 627 that cell which the path actually traverses (so a cell merely clipped 628 by the path contributes proportionally less). 629 - `alloc`: `WI / WI.sum()`, for apportioning the rate among its completions: 630 `rates = rate * alloc[:, None]` (the rate signed as usual). 631 This is the standard (static) allocation -- proportional to the well index, 632 hence to both the contacted length and the local permeability. 633 634 >>> from minires import ResSim 635 >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10) 636 >>> xy, WI, alloc = well_path(model, [[.05, .05], [.45, .05]], rw=1e-2) 637 >>> xy.T[0] # the traversed cells, in x 638 array([0.05, 0.15, 0.25, 0.35, 0.45]) 639 >>> alloc # the end cells, entered mid-way, get half the rate 640 array([0.125, 0.25 , 0.25 , 0.25 , 0.125]) 641 642 .. note:: `alloc` is exact under BHP control, approximate under rate control. 643 644 Under BHP control (assuming 0 gravity and friction) the completions 645 simply share a `p_bh`. Under *rate* control, the allocation holds only 646 if the cell pressures are equal. Solving for it would make 647 $ p_\\mathrm{bh} $ an extra 648 unknown, i.e. a bordered linear system -- which the 5-diagonal 649 assembly of `minires.ResSim.TPFA` (Listing 1) is not set up for. Use 650 `minires.ResSim.well_controls` to reallocate per step, if it matters. 651 652 .. warning:: The completions are treated as independent *vertical* wells. 653 654 Which seems reasonable in a 2D areal model. Thus they count towards 655 `Wells.nComp`, not `Wells.nWell` -- ref `Wells.group`, which 656 `Wells.from_records` sets for you. 657 """ 658 V = np.asarray(vertices, float).reshape((-1, 2)) 659 assert len(V) >= 2, "A well path needs at least 2 vertices." 660 # Walk the polyline, accumulating traversed length per cell 661 lengths: dict = {} 662 for p0, p1 in zip(V[:-1], V[1:]): 663 d = p1 - p0 664 L = float(np.hypot(*d)) 665 if L == 0: 666 continue 667 ts = model._crossings(p0, d) 668 mids = p0 + np.outer((ts[:-1] + ts[1:]) / 2, d) 669 for mid, dt in zip(mids, np.diff(ts)): 670 sub = tuple(int(i) for i in model.xy2sub(*mid)) 671 lengths[sub] = lengths.get(sub, 0.0) + L * dt 672 # Discard the slivers left by corner crossings 673 total = sum(lengths.values()) 674 lengths = {k: v for k, v in lengths.items() if v > 1e-9 * total} 675 676 subs = np.array(list(lengths)) 677 xy = model.sub2xy(*subs.T).T 678 # Scale each WI by how much of its cell the path traverses, relative 679 # to the cell size -- so an axis-aligned full crossing scores exactly 1 680 # (and a diagonal one √2, it contacting that much more rock). 681 frac = np.array(list(lengths.values())) / np.sqrt(model.h2) 682 WI = frac * peaceman_WI(model, xy, rw, skin) 683 return xy, WI, WI / WI.sum()
Discretize a well path (a polyline): 1 weighted completion per cell.
Applied for you to a well of Wells.from_records given a path, which is
the convenient way to use it: the three returned arrays then need not be
assembled (with those of the other wells) by hand.
Returns (xy, WI, alloc):
xy: centres of the cells that the path traverses -- i.e. a value forWells.xy. Several completions act as a single well simply by being several wells:minires.ResSim.assemble_wellssuperimposes them.WI: their well indices, i.e. a value forWells.WI. Each ispeaceman_WIfor its cell, scaled by the fraction of that cell which the path actually traverses (so a cell merely clipped by the path contributes proportionally less).alloc:WI / WI.sum(), for apportioning the rate among its completions:rates = rate * alloc[:, None](the rate signed as usual). This is the standard (static) allocation -- proportional to the well index, hence to both the contacted length and the local permeability.
>>> from minires import ResSim
>>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10)
>>> xy, WI, alloc = well_path(model, [[.05, .05], [.45, .05]], rw=1e-2)
>>> xy.T[0] # the traversed cells, in x
array([0.05, 0.15, 0.25, 0.35, 0.45])
>>> alloc # the end cells, entered mid-way, get half the rate
array([0.125, 0.25 , 0.25 , 0.25 , 0.125])
alloc is exact under BHP control, approximate under rate control.
Under BHP control (assuming 0 gravity and friction) the completions
simply share a p_bh. Under rate control, the allocation holds only
if the cell pressures are equal. Solving for it would make
$ p_\mathrm{bh} $ an extra
unknown, i.e. a bordered linear system -- which the 5-diagonal
assembly of minires.ResSim.TPFA (Listing 1) is not set up for. Use
minires.ResSim.well_controls to reallocate per step, if it matters.
The completions are treated as independent vertical wells.
Which seems reasonable in a 2D areal model. Thus they count towards
Wells.nComp, not Wells.nWell -- ref Wells.group, which
Wells.from_records sets for you.
686def aquifer_WI(model: "ResSim", xy: Any, faces: str = "WESN") -> np.ndarray: 687 """The "well index" of an aquifer contact: the transmissibility of the 688 boundary face(s) of each cell at `xy` -- from its centre out to the face. 689 690 An aquifer -- water-bearing rock beyond the reservoir's boundary, at a 691 pressure $ p_\\mathrm{aq} $ of its own -- feeds each reservoir cell that 692 touches it at the rate $ T \\, (p_\\mathrm{aq} - p) $, which is the law of a 693 BHP-controlled well, $ WI \\, λ_t \\, (p_\\mathrm{bh} - p) $. So an aquifer *is* 694 a BHP-controlled well, completed in every cell it touches, with 695 `bhp = p_aq` and this for `WI` -- and needs nothing else of the model: 696 the influx enters `minires.ResSim.assemble_wells` like any well's, 697 anchors the pressure (so that, if incompressible, a lone producer is fine: 698 the aquifer supplies it), is reported in `Wells.actual_rates`, and is 699 handled by the adjoint, `minires.tlm`, as any BHP well is. `Wells.from_records` applies it to a 700 well given `aquifer=True` (or `aquifer=faces`): 701 702 >>> from minires import ResSim 703 >>> model = ResSim(Lx=1, Ly=1, Nx=4, Ny=4, wells=[ 704 ... dict(name="Aq", xy=[[0, .3], [0, .5], [0, .7]], aquifer=True, bhp=2), 705 ... dict(name="P1", xy=[1, 1], rate=-1), 706 ... ]) 707 >>> model.wells.WI 708 array([ 2., 2., 2., nan]) 709 >>> SS, PP = model.sim(.1, 3, np.zeros(model.Nxy), pbar=False) 710 >>> model.wells.rates_by_well.round(12) # the aquifer supplies the producer 711 array([[ 1., 1., 1.], 712 [-1., -1., -1.]]) 713 714 A cell's *boundary faces* are those across which the neighbour is inactive 715 (ref `minires.ResSim.active`) or outside the grid; it must have at 716 least one, and be active itself. Each contributes the half-cell 717 transmissibility, $ C \\, k \\, h_⊥ / (h_∥ / 2) $ (compare the whole-cell 718 one of `minires.ResSim.TPFA`), so that the aquifer pressure is 719 imposed *at the face* -- a Dirichlet condition, its flux discretized as 720 the interior ones are. A corner cell, with two boundary faces, gets both -- 721 unless `faces` (a string of compass directions) leaves one out, as it must 722 when that edge is sealed, or on a 1D strip, whose every cell is a boundary 723 cell to the north and south. On a curved outline, keep them all. 724 725 **Aquifers** are beneficial in reservoirs as they act as pressure compensators. 726 Oil production ⇒ pressure decrease ⇒ aquifers expansion ⇒ pressure compensation. 727 Despite consisting of water, the expansion is generally significant 728 because the base volume is so big, 729 or the aquifer might even be connected to the ocean -- 730 which is the *infinite* aquifer that a constant `bhp` here posits. 731 (The top of the water-bearing zone is the *water table*.) 732 733 .. note:: The mobility is the cell's total one, $ λ_t(S) $, as for any well. 734 735 Whereas a boundary face of the TPFA scheme would upwind it from the 736 aquifer side (water, $ S = 1 $). The difference is the injector's 737 well model, no more; the influx is water either way. 738 For an aquifer of a given *strength* -- a productivity index, $ J $, 739 as in the Fetkovich model -- give `WI` directly instead, or scale this. 740 """ 741 xy = np.asarray(xy, float).reshape((-1, 2)) 742 ix, iy = model.xy2sub(*xy.T) 743 # The number of boundary faces (among those selected) in each direction 744 n = boundary_faces(model, xy, faces).astype(int) # NB: `bool + bool` is an `or` 745 nx, ny = n[:, :2].sum(1), n[:, 2:].sum(1) 746 assert (nx + ny > 0).all(), ( 747 "An aquifer cell must lie on the boundary: have a face to an inactive" 748 " cell, or to outside the grid (ref `aquifer_WI`)." 749 ) 750 kx, ky = model.K[0][ix, iy], model.K[1][ix, iy] 751 return model.cdarcy * 2 * (nx * kx * model.hy / model.hx + ny * ky * model.hx / model.hy)
The "well index" of an aquifer contact: the transmissibility of the
boundary face(s) of each cell at xy -- from its centre out to the face.
An aquifer -- water-bearing rock beyond the reservoir's boundary, at a
pressure $ p_\mathrm{aq} $ of its own -- feeds each reservoir cell that
touches it at the rate $ T \, (p_\mathrm{aq} - p) $, which is the law of a
BHP-controlled well, $ WI \, λ_t \, (p_\mathrm{bh} - p) $. So an aquifer is
a BHP-controlled well, completed in every cell it touches, with
bhp = p_aq and this for WI -- and needs nothing else of the model:
the influx enters minires.ResSim.assemble_wells like any well's,
anchors the pressure (so that, if incompressible, a lone producer is fine:
the aquifer supplies it), is reported in Wells.actual_rates, and is
handled by the adjoint, minires.tlm, as any BHP well is. Wells.from_records applies it to a
well given aquifer=True (or aquifer=faces):
>>> from minires import ResSim
>>> model = ResSim(Lx=1, Ly=1, Nx=4, Ny=4, wells=[
... dict(name="Aq", xy=[[0, .3], [0, .5], [0, .7]], aquifer=True, bhp=2),
... dict(name="P1", xy=[1, 1], rate=-1),
... ])
>>> model.wells.WI
array([ 2., 2., 2., nan])
>>> SS, PP = model.sim(.1, 3, np.zeros(model.Nxy), pbar=False)
>>> model.wells.rates_by_well.round(12) # the aquifer supplies the producer
array([[ 1., 1., 1.],
[-1., -1., -1.]])
A cell's boundary faces are those across which the neighbour is inactive
(ref minires.ResSim.active) or outside the grid; it must have at
least one, and be active itself. Each contributes the half-cell
transmissibility, $ C \, k \, h_⊥ / (h_∥ / 2) $ (compare the whole-cell
one of minires.ResSim.TPFA), so that the aquifer pressure is
imposed at the face -- a Dirichlet condition, its flux discretized as
the interior ones are. A corner cell, with two boundary faces, gets both --
unless faces (a string of compass directions) leaves one out, as it must
when that edge is sealed, or on a 1D strip, whose every cell is a boundary
cell to the north and south. On a curved outline, keep them all.
Aquifers are beneficial in reservoirs as they act as pressure compensators.
Oil production ⇒ pressure decrease ⇒ aquifers expansion ⇒ pressure compensation.
Despite consisting of water, the expansion is generally significant
because the base volume is so big,
or the aquifer might even be connected to the ocean --
which is the infinite aquifer that a constant bhp here posits.
(The top of the water-bearing zone is the water table.)
The mobility is the cell's total one, $ λ_t(S) $, as for any well.
Whereas a boundary face of the TPFA scheme would upwind it from the
aquifer side (water, $ S = 1 $). The difference is the injector's
well model, no more; the influx is water either way.
For an aquifer of a given strength -- a productivity index, $ J $,
as in the Fetkovich model -- give WI directly instead, or scale this.
754def boundary_faces(model: "ResSim", xy: Any, faces: str = "WESN") -> np.ndarray: 755 """Which faces of the cells at `xy` are *boundary* faces: to an inactive 756 cell (ref `minires.ResSim.active`), or to outside the grid. 757 758 Boolean, `(nCells, 4)`, the columns being the directions W, E, S, N -- 759 of which `faces` (a string of them) selects the ones considered at all. 760 Serves `aquifer_WI`, and `minires.plotting.Plot2D.plt_faces`. 761 762 >>> from minires import ResSim 763 >>> model = ResSim(Lx=1, Ly=1, Nx=4, Ny=4) 764 >>> boundary_faces(model, [[0, 0], [0, .5], [.5, .5]]).astype(int) 765 array([[1, 0, 1, 0], 766 [1, 0, 0, 0], 767 [0, 0, 0, 0]]) 768 """ 769 xy = np.asarray(xy, float).reshape((-1, 2)) 770 ix, iy = model.xy2sub(*xy.T) 771 act = np.pad(model.active, 1, constant_values=False) # off-grid ⇒ inactive 772 assert act[ix + 1, iy + 1].all(), "The cells must be active (ref `active`)." 773 ix, iy = ix + 1, iy + 1 # (in the padded mask) 774 nbrs = [act[ix - 1, iy], act[ix + 1, iy], act[ix, iy - 1], act[ix, iy + 1]] 775 selected = np.array([d in faces for d in "WESN"]) 776 return ~np.stack(nbrs, -1) & selected
Which faces of the cells at xy are boundary faces: to an inactive
cell (ref minires.ResSim.active), or to outside the grid.
Boolean, (nCells, 4), the columns being the directions W, E, S, N --
of which faces (a string of them) selects the ones considered at all.
Serves aquifer_WI, and minires.plotting.Plot2D.plt_faces.
>>> from minires import ResSim
>>> model = ResSim(Lx=1, Ly=1, Nx=4, Ny=4)
>>> boundary_faces(model, [[0, 0], [0, .5], [.5, .5]]).astype(int)
array([[1, 0, 1, 0],
[1, 0, 0, 0],
[0, 0, 0, 0]])