minires.wells

The wells: their configuration, their bookkeeping, and the well model.

Everything here depends on the wells alone, on the geometry they sit in (minires.grid.Grid2D), and -- for the well index -- on the permeability K. What couples the wells to the fluids (the mobilities of minires.fluids.Fluid.RelPerm) or to the linear system (the source field, the BHP contributions) stays with the simulator, which reads the arrays assembled here.

The unit of account is the completion, not the well: a well may span several cells (ref well_path), and it is the completions that the model solves for. Hence Wells.nComp counts the rows of every array here, whereas Wells.nWell counts the wells that Wells.group says they compose -- a distinction that serves the reporting alone (Wells.rates_by_well, the plot labels); the physics never groups.

Spreading a well over its neighbouring cells was implemented, 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).

Theory

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

def aquifer_WI( model: minires.ResSim, xy: Any, faces: str = 'WESN') -> numpy.ndarray:
259def aquifer_WI(model: "ResSim", xy: Any, faces: str = "WESN") -> np.ndarray:
260    """The "well index" of an aquifer contact: the transmissibility of the
261    boundary face(s) of each cell at `xy` -- from its centre out to the face.
262
263    An aquifer -- water-bearing rock beyond the reservoir's boundary, at a
264    pressure $ p_\\mathrm{aq} $ of its own -- feeds each reservoir cell that
265    touches it at the rate $ T \\, (p_\\mathrm{aq} - p) $, which is the law of a
266    BHP-controlled well, $ WI \\, λ_t \\, (p_\\mathrm{bh} - p) $. So an aquifer *is*
267    a BHP-controlled well, completed in every cell it touches, with
268    `bhp = p_aq` and this for `WI` -- and needs nothing else of the model:
269    the influx enters `minires.ResSim.assemble_wells` like any well's,
270    anchors the pressure (so that, if incompressible, a lone producer is fine:
271    the aquifer supplies it), is reported in `Wells.actual_rates`, and is
272    handled by the adjoint, `minires.tlm`, as any BHP well is. `Wells.from_records` applies it to a
273    well given `aquifer=True` (or `aquifer=faces`):
274
275    >>> from minires import ResSim
276    >>> model = ResSim(Lx=1, Ly=1, Nx=4, Ny=4, wells=[
277    ...     dict(name="Aq", xy=[[0, .3], [0, .5], [0, .7]], aquifer=True, bhp=2),
278    ...     dict(name="P1", xy=[1, 1], rate=-1),
279    ... ])
280    >>> model.wells.WI
281    array([ 2.,  2.,  2., nan])
282    >>> SS, PP = model.sim(.1, 3, np.zeros(model.Nxy), pbar=False)
283    >>> model.wells.rates_by_well.round(12)  # the aquifer supplies the producer
284    array([[ 1.,  1.,  1.],
285           [-1., -1., -1.]])
286
287    A cell's *boundary faces* are those across which the neighbour is inactive
288    (ref `minires.ResSim.active`) or outside the grid; it must have at
289    least one, and be active itself. Each contributes the half-cell
290    transmissibility, $ C \\, k \\, h_⊥ / (h_∥ / 2) $ (compare the whole-cell
291    one of `minires.ResSim.TPFA`), so that the aquifer pressure is
292    imposed *at the face* -- a Dirichlet condition, its flux discretized as
293    the interior ones are. A corner cell, with two boundary faces, gets both --
294    unless `faces` (a string of compass directions) leaves one out, as it must
295    when that edge is sealed, or on a 1D strip, whose every cell is a boundary
296    cell to the north and south. On a curved outline, keep them all.
297
298    .. note:: The mobility is the cell's total one, $ λ_t(S) $, as for any well.
299
300        Whereas a boundary face of the TPFA scheme would upwind it from the
301        aquifer side (water, $ S = 1 $). The difference is the injector's
302        well model, no more; the influx is water either way.
303        For an aquifer of a given *strength* -- a productivity index, $ J $,
304        as in the Fetkovich model -- give `WI` directly instead, or scale this.
305    """
306    xy = np.asarray(xy, float).reshape((-1, 2))
307    ix, iy = model.xy2sub(*xy.T)
308    # The number of boundary faces (among those selected) in each direction
309    n = boundary_faces(model, xy, faces).astype(int)  # NB: `bool + bool` is an `or`
310    nx, ny = n[:, :2].sum(1), n[:, 2:].sum(1)
311    assert (nx + ny > 0).all(), (
312        "An aquifer cell must lie on the boundary: have a face to an inactive"
313        " cell, or to outside the grid (ref `aquifer_WI`)."
314    )
315    kx, ky = model.K[0][ix, iy], model.K[1][ix, iy]
316    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.

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.

def boundary_faces( model: minires.ResSim, xy: Any, faces: str = 'WESN') -> numpy.ndarray:
319def boundary_faces(model: "ResSim", xy: Any, faces: str = "WESN") -> np.ndarray:
320    """Which faces of the cells at `xy` are *boundary* faces: to an inactive
321    cell (ref `minires.ResSim.active`), or to outside the grid.
322
323    Boolean, `(nCells, 4)`, the columns being the directions W, E, S, N --
324    of which `faces` (a string of them) selects the ones considered at all.
325    Serves `aquifer_WI`, and `minires.plotting.Plot2D.plt_faces`.
326
327    >>> from minires import ResSim
328    >>> model = ResSim(Lx=1, Ly=1, Nx=4, Ny=4)
329    >>> boundary_faces(model, [[0, 0], [0, .5], [.5, .5]]).astype(int)
330    array([[1, 0, 1, 0],
331           [1, 0, 0, 0],
332           [0, 0, 0, 0]])
333    """
334    xy = np.asarray(xy, float).reshape((-1, 2))
335    ix, iy = model.xy2sub(*xy.T)
336    act = np.pad(model.active, 1, constant_values=False)  # off-grid ⇒ inactive
337    assert act[ix + 1, iy + 1].all(), "The cells must be active (ref `active`)."
338    ix, iy = ix + 1, iy + 1  # (in the padded mask)
339    nbrs = [act[ix - 1, iy], act[ix + 1, iy], act[ix, iy - 1], act[ix, iy + 1]]
340    selected = np.array([d in faces for d in "WESN"])
341    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]])
@dataclass
class Wells(minires._repr.AlignedRepr):
344@dataclass
345class Wells(AlignedRepr):
346    """The wells of a `minires.ResSim`: the flat, per-completion arrays.
347
348    These arrays *are* the configuration (ref `from_records`), and they are
349    meant to be written to, as an ensemble or optimisation loop does:
350
351    >>> from minires import ResSim
352    >>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16,
353    ...                wells=Wells(xy=[[0, 0], [1, 1]], rates=[[1], [-1]]))
354    >>> model.wells.rates = [[2], [-2]]
355    >>> model.wells.nComp
356    2
357
358    Assigning one normalizes it (`__setattr__`): the positions get snapped onto
359    the grid nodes, the schedules reshaped to `(nComp, nTime)`, and so on.
360    A `Wells` may also be built before it has a grid to snap to -- as above,
361    where it is the assignment to the model that binds (and snaps) it.
362
363    Ref `from_records` for the convenient, well-shaped way to configure them,
364    and `minires.ResSim.wells` for the attribute that holds them.
365    """
366
367    # Dont use dataclass repr
368    __repr__ = AlignedRepr.__repr__
369
370    # NB: the array attributes are typed `Any` since `__setattr__` normalizes
371    # whatever array-like (nested lists, scalars) is assigned to them.
372    xy: Any = None
373    """Array of shape `(nComp, 2)` of x- and y-coords for the completions.
374
375    Values should be betwen `0` and `Lx` or `Ly`. Empty, shape `(0, 2)`, if
376    there are no wells.
377
378    .. warning:: The wells get co-located with grid nodes, ref `xy2sub`.
379
380        This is a design choice, not a mathematical necessity.
381        An alternative would be to distribute them over nearby nodes.
382    """
383    rates: Any = None
384    """Array of shape `(nComp, nTime)` -- or `(nComp, 1)` if constant-in-time.
385    Ref `from_records` for the convenient way to set this (and the other specs).
386
387    **Signed**: a positive rate *injects* (water), a negative one *produces*
388    (at the well cell's fractional flow). There is no other distinction
389    between injectors and producers, anywhere in the model.
390    An **areal** rate, i.e. volumetric *per unit thickness* -- ref
391    `minires.ResSim.cdarcy`.
392
393    .. note:: With `ct == 0` the rates must sum to 0 at each time index.
394
395        This is asserted (unless a well is BHP-controlled), otherwise the model
396        would silently input the deficit from the SW corner.
397
398    .. note:: Prefer `0` as the (ignored) fill value where control is by `bhp`.
399
400        The array being shared by all the wells, an entry must be given there
401        regardless.
402    """
403    bhp: Any = None
404    """Bottom-hole pressures for the wells. `None`, or an array shaped like
405    `rates`, i.e. `(nComp, nTime)` -- or `(nComp, 1)` if constant-in-time.
406
407    A well whose entry is finite is **BHP-controlled** at that time. This is
408    solved *simultaneously* with the pressure field (not lagged by a time step):
409    `minires.ResSim.TPFA` puts $ WI λ_t $ on its diagonal and
410    $ WI λ_t \\, p_\\mathrm{bh} $ on its right-hand side.
411    Only once $ p $ is known is the resulting rate folded into the source field,
412    `_Q`, for the transport step -- ref `minires.ResSim.assemble_wells`
413    and `minires.ResSim.realize_bhp`.
414
415    Entries left as `nan` -- which is the default, for every well -- keep the
416    well rate-controlled, per `rates`. So the two mechanisms can be mixed
417    freely, both across wells and in time.
418
419    Requires `WI` (finite, for the BHP-controlled wells). The realized
420    rates are recorded in `actual_rates`, and the corresponding entries of
421    `rates` are ignored (it may be left as `None`).
422
423    .. note:: BHP control also *anchors the pressure*.
424
425        With `ct == 0` the pressure equation is otherwise a pure-Neumann
426        problem: solvable only up to a constant (which
427        `minires.ResSim.TPFA` pins arbitrarily, ref article p. 13), and
428        only if injection balances production. A single BHP
429        well lifts both restrictions -- the level is then set by
430        $ p_\\mathrm{bh} $, and the voidage by the well model.
431
432    .. warning:: A BHP well's flow direction is *emergent*, not declared.
433
434        It flows whichever way $ p_\\mathrm{bh} $ vs. its cell pressure dictates
435        (ref `minires.ResSim.realize_bhp`) -- and, like any inflow, an
436        inflow through a BHP well injects *water*. Since a reversal mid-`sim`
437        may nonetheless be a surprise, a `UserWarning` is emitted when a BHP
438        well's realized rate flips sign between steps of `sim`.
439        Nor is there native switching of control modes (e.g. rate control with
440        a BHP limit), but `minires.ResSim.well_controls` can approximate it.
441    """
442    WI: Any = None
443    """Well indices: `None`, or an array of shape `(nComp,)`, `nan` allowed.
444
445    Compute $ WI $ with `peaceman_WI`, or set it directly
446    (it need not come from any particular formula).
447    A well whose entry is `nan` (or all of them, if `None`) has no well model:
448    its `actual_bhp` is `nan`, and BHP control (`bhp`) unavailable.
449
450    The well index is the *sub-grid* well model (ref the "Theory" section of
451    `minires.wells`), relating a well's (signed) flow rate to its drawdown,
452    $$ q = WI \\, λ_t \\, (p_\\mathrm{bh} - p_\\mathrm{cell}) \\,,$$
453    with $ λ_t $ the total mobility (ref `minires.fluids.Fluid.RelPerm`) of the
454    well's cell. Re-arranging,
455    $$ p_\\mathrm{bh} = p_\\mathrm{cell} + q / (WI \\, λ_t) \\,, $$
456    which is what `minires.ResSim.bhp` computes.
457
458    .. warning:: The drawdown is *not* a fixed offset.
459
460        It is not to be calibrated away once and for all: being
461        $ q / (WI \\, λ_t) $, it tracks the mobility --
462        which, in a waterflood, dips as the front arrives (by half, for equal
463        viscosities). So the gap doubles at breakthrough: precisely when the
464        well is most interesting.
465    """
466    group: Any = None
467    """Which well each completion belongs to: `None`, or an int array of shape
468    `(nComp,)` whose values index the wells, i.e. `names`.
469
470    The model itself is indifferent to it: the equations are assembled per
471    *completion* (ref `minires.ResSim.assemble_wells`), and the arrays --
472    `xy`, `rates`, `actual_rates`, ... -- are all indexed likewise. The grouping
473    is what lets the *reporting* speak of wells nonetheless: ref
474    `rates_by_well`, and the labels of `minires.plotting.Plot2D.plt_field`.
475    """
476    names: Any = None
477    """Names of wells (*not* completions): `None`, or a list of `nWell` strings."""
478
479    actual_rates: Any = None
480    """The *realized* well rates: array of shape `(nComp, nSteps)`. Signed.
481
482    Mostly used as a diagnostic in case of `bhp`. But even for
483    rate-control it only coincides with `rates` up to broadcasting
484    and assuming `minires.ResSim.well_controls` did not override it.
485    """
486    actual_bhp: Any = None
487    """Like `actual_rates`, but the bottom-hole pressures.
488
489    `nan` wherever the well index (`WI`) is unset.
490    """
491
492    _grid: Any = None
493    """The model these wells are in -- set when assigned to it. Used only for
494    the grid geometry (which is why it is typed as such, ref `_bind`)."""
495
496    def __setattr__(self, key: str, val: Any) -> None:
497        # NB: the single normalization layer for the wells -- `from_records`
498        # routes its assignments through it rather than writing past it.
499        if key == "xy":
500            # Completion positions -- collocate at some node
501            val = (
502                np.zeros((0, 2))
503                if val is None
504                else np.array(val, float).reshape((-1, 2))
505            )
506            if self._grid is not None:
507                for i, (x, y) in enumerate(val):
508                    val[i] = self._grid.ind2xy(self._grid.xy2ind(x, y))
509        elif val is not None:
510            # Rates and/or pressures
511            if key in ["rates", "bhp"]:
512                val = np.array(val, float).reshape((self.nComp, -1))
513            # Well indices
514            elif key == "WI":
515                val = np.broadcast_to(np.asarray(val, float).ravel(), self.nComp).copy()
516            # Completion-to-well map
517            elif key == "group":
518                val = np.asarray(val, int).reshape(self.nComp)
519        super().__setattr__(key, val)
520
521    def _bind(self, grid: Any) -> None:
522        """Attach to `grid` (a `minires.grid.Grid2D`, i.e. the model).
523
524        Whereupon the completions snap onto its nodes -- which an unbound
525        `Wells`, having no grid to snap to, could not do.
526        """
527        self._grid = grid
528        self.xy = self.xy  # re-normalize, now that there is a grid
529
530    nComp = property(lambda self: len(self.xy))
531    """Num. of *completions*, i.e. rows of `xy`, which is what the model
532    actually solves for. Several completions may compose a single well
533    (ref `group`, `well_path`)."""
534
535    nWell = property(
536        lambda self: self.nComp if self.group is None else 1 + int(self.group.max())
537    )
538    """Num. of *wells*, i.e. groups of completions (ref `group`)."""
539
540    @property
541    def rates_by_well(self) -> np.ndarray:
542        """`actual_rates`, summed over each well's completions: `(nWell, nSteps)`."""
543        group = np.arange(self.nComp) if self.group is None else self.group
544        out = np.zeros((self.nWell, self.actual_rates.shape[1]))
545        np.add.at(out, group, self.actual_rates)  # NB: `+=` would skip the dupes
546        return out
547
548    @property
549    def signs(self) -> np.ndarray:
550        """The sign (`+1` inject, `-1` produce, `0` unknown) of each well's rate.
551
552        Read off the *spec*, `rates`, summed over time (`nan` entries --
553        which a BHP-controlled well may well have -- being skipped). Wells left
554        undecided by it, i.e. those with no spec or a vanishing one (as under
555        pure BHP control), fall back on the `actual_rates` of the latest `sim`,
556        if there has been one. Only the truly undecided are then `0`.
557        """
558        sgn = np.zeros(self.nComp, int)
559        for rates in [self.rates, self.actual_rates]:
560            if rates is not None:
561                q = np.nansum(rates, axis=1)
562                sgn = np.where(sgn, sgn, (q > 0).astype(int) - (q < 0))
563        return sgn
564
565    def at_time(self, spec: str, absent: float, k: int) -> np.ndarray:
566        """Lookup the `spec` (`"rates"`/`"bhp"`) at time `k`.
567
568        Allows a constant-in-time (singleton) spec, and an unset (`None`) one
569        (for which `absent`, i.e. `0`/`nan` for rate/bhp-controlled wells
570        respectively, is returned).
571        Avoids broadcast (and potentially stale copies) to `(nComp, nSteps)`,
572        which requires `nSteps`, i.e. `sim()`.
573        """
574        arr = getattr(self, spec)
575        if arr is None:
576            return np.full(self.nComp, absent)
577        assert len(arr) == self.nComp, (
578            f"`wells.{spec}` has {len(arr)} rows, but there are"
579            f" {self.nComp} completions (ref `Wells.xy`)."
580        )
581        # Copy, lest `well_controls` write into the spec itself
582        return np.copy(arr[:, k if arr.shape[1] > 1 else 0])
583
584    @classmethod
585    def from_records(cls, model: "ResSim", wells: Any) -> "Wells":
586        """Assemble the flat, per-completion arrays from one record (`dict`) per well.
587
588        This is the convenient way to configure the wells, and assigning the
589        records to `minires.ResSim.wells` is what applies it. Each record
590        may specify
591
592        - `xy`: the well's position, `[x, y]` -- or positions,
593          `[[x, y], ...]`, for a multi-completion well.
594        - `path`: alternatively, a polyline, `[[x, y], ...]`, to be discretized
595          into one completion per cell it traverses, ref
596          `well_path`. Needs `rw`.
597        - `rate`: the well's (signed, ref `Wells.rates`) rate: a scalar, or a
598          schedule (an array over time). Apportioned among its completions in
599          proportion to their well indices (uniformly, absent those).
600        - `bhp`: alternatively (or, in time, additionally) the bottom-hole
601          pressure, ref `Wells.bhp`. Scalar or schedule. Shared -- as a wellbore
602          does -- by all of the well's completions.
603        - `rw`, `skin`: the wellbore radius and skin, whence the well index, via
604          `peaceman_WI`. Without them (or `WI`) the well has no well model.
605        - `WI`: alternatively, the well index itself, given directly.
606        - `aquifer`: `True` makes the well an aquifer contact: its `WI` is then
607          `aquifer_WI` of its cells (which must lie on the boundary), and its
608          `bhp` the aquifer pressure. A string of compass directions instead
609          (`"W"`, `"NE"`, ...) selects the boundary faces that count. Ref `aquifer_WI`.
610        - `name`: for the reporting. Defaults to the well's index.
611
612        The concise cases stay concise -- a position and a rate is a well. A
613        `dict` of records names them by its keys (as `name` does otherwise), and
614        the constructor takes the same thing:
615
616        >>> from minires import ResSim
617        >>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16, wells={
618        ...     "I1": dict(xy=[0, 0], rate=+1),
619        ...     "P1": dict(xy=[1, 1], rate=-1, rw=1e-3),
620        ... })
621        >>> model.wells.names
622        ['I1', 'P1']
623        >>> model.wells.rates
624        array([[ 1.],
625               [-1.]])
626        >>> model.wells.WI.round(3)  # `P1` alone asked for a well model
627        array([  nan, 2.498])
628
629        A `path` becomes several completions of a single well, whose rate it
630        shares out (ref `well_path`) and whose name they share:
631
632        >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[
633        ...     dict(name="I1", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2),
634        ...     dict(name="P1", xy=[.95, .95], rate=-1),
635        ... ])
636        >>> model.wells.nComp, model.wells.nWell
637        (6, 2)
638        >>> model.wells.group
639        array([0, 0, 0, 0, 0, 1])
640        >>> model.wells.rates.ravel().round(3)
641        array([ 0.125,  0.25 ,  0.25 ,  0.25 ,  0.125, -1.   ])
642
643        Schedules and control modes may be mixed freely across the wells: a
644        constant is broadcast to the length of the longest schedule, while a
645        spec that no well varies in time stays a singleton, which
646        `minires.ResSim.well_controls` reads at any `k`. A BHP-controlled
647        well leaves the (ignored) `0` in `Wells.rates`, and `nan` marks the
648        rate-controlled ones in `Wells.bhp` -- the conventions that the specs,
649        being shared arrays, call for (ref `Wells.rates`).
650
651        .. note:: A well must be given a control (`rate` and/or `bhp`).
652
653            `rate=0` shuts it in. This is deliberate: an uncontrolled well would
654            silently be a shut one.
655
656        .. note:: The records are *not* retained.
657
658            The arrays they produce are the whole of the configuration, so
659            there is nothing to fall out of step with a subsequent edit of them
660            (which is what the `repr` therefore reports).
661        """
662        keys = ("name", "xy", "path", "rate", "bhp", "rw", "skin", "WI", "aquifer")
663        if isinstance(wells, dict):
664            wells = [dict(spec, name=name) for name, spec in wells.items()]
665        names, xy, WI, group, rates, bhp = [], [], [], [], [], []
666        specified: set = set()
667        for i, well in enumerate(wells or []):
668            spec = dict(well)
669            if unknown := set(spec) - set(keys):
670                raise TypeError(
671                    f"Unknown key(s) in the spec of well {i}: {sorted(unknown)}."
672                    f" Valid ones: {list(keys)}."
673                )
674            specified |= set(spec)
675            name = str(spec.pop("name", i))
676            rw, skin = spec.pop("rw", None), spec.pop("skin", 0.0)
677            aquifer = spec.pop("aquifer", False)
678
679            # Completions: their positions, and their well indices
680            if (path := spec.pop("path", None)) is not None:
681                assert "xy" not in spec, (
682                    f"Well '{name}': give it `xy` or `path`, not both."
683                )
684                assert rw is not None, f"Well '{name}': a `path` requires `rw`."
685                assert not aquifer, f"Well '{name}': an aquifer is given by `xy`."
686                _xy, _WI, _ = well_path(model, path, rw, skin)
687            else:
688                assert "xy" in spec, f"Well '{name}': give it an `xy` (or a `path`)."
689                _xy = np.array(spec.pop("xy"), float).reshape((-1, 2))
690                if aquifer:
691                    assert rw is None, f"Well '{name}': an aquifer has no `rw`."
692                    _WI = aquifer_WI(model, _xy, "WESN" if aquifer is True else aquifer)
693                elif rw is not None:
694                    _WI = peaceman_WI(model, _xy, rw, skin)
695                else:
696                    _WI = np.full(len(_xy), np.nan)
697            if (given := spec.pop("WI", None)) is not None:
698                _WI = np.broadcast_to(np.asarray(given, float).ravel(), len(_xy)).copy()
699            nc = len(_xy)
700
701            # Apportion the rate by well index -- the standard, ref `well_path`
702            alloc = np.full(nc, 1 / nc)
703            if nc > 1 and np.isfinite(_WI).all() and _WI.sum() > 0:
704                alloc = _WI / _WI.sum()
705
706            # Controls. NB: the BHP is shared by the completions, the rate split
707            rate, p_bh = spec.pop("rate", None), spec.pop("bhp", None)
708            assert rate is not None or p_bh is not None, (
709                f"Well '{name}' has no control: give it a `rate`"
710                " (`0` shuts it in), or a `bhp`."
711            )
712            rate = 0.0 if rate is None else rate
713            p_bh = np.nan if p_bh is None else p_bh
714            rates.append(np.outer(alloc, np.ravel(rate)))
715            bhp.append(np.outer(np.ones(nc), np.ravel(p_bh)))
716
717            names.append(name)
718            xy.append(_xy)
719            WI.append(_WI)
720            group.append(np.full(nc, i))
721
722        if not names:
723            return cls()
724
725        def stack(specs):
726            """Stack the wells' `(nComp_i, nTime_i)` specs, widening the constants.
727
728            NB: `at_time` broadcasts a *wholly* singleton spec, but the array is
729            shared, so a well held constant beside a scheduled one is widened here.
730            """
731            nTime = max(spec.shape[1] for spec in specs)
732            assert all(spec.shape[1] in [1, nTime] for spec in specs), (
733                "The wells' schedules must be of equal length (or constant):"
734                f" got {sorted({spec.shape[1] for spec in specs})}."
735            )
736            return np.vstack([np.broadcast_to(s, (len(s), nTime)) for s in specs])
737
738        WI = np.concatenate(WI)
739        return cls(
740            # NB: `xy` first -- it is what defines `nComp`, by which the
741            # `__setattr__` normalization shapes the others.
742            xy     = np.vstack(xy),
743            # Leave a spec unset (`None`) if no well made use of it
744            rates  = stack(rates) if "rate" in specified else None,
745            bhp    = stack(bhp) if "bhp" in specified else None,
746            WI     = WI if np.isfinite(WI).any() else None,
747            group  = np.concatenate(group),
748            names  = names,
749        )  # fmt: off

The wells of a minires.ResSim: the flat, per-completion arrays.

These arrays are the configuration (ref from_records), and they are meant to be written to, as an ensemble or optimisation loop does:

>>> 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 one normalizes it (__setattr__): the positions get snapped onto the grid nodes, the schedules reshaped to (nComp, nTime), and so on. A Wells may also be built before it has a grid to snap to -- as above, where it is the assignment to the model that binds (and snaps) it.

Ref from_records for the convenient, well-shaped way to configure them, and minires.ResSim.wells for the attribute that holds them.

Wells( xy: Any = None, rates: Any = None, bhp: Any = None, WI: Any = None, group: Any = None, names: Any = None, actual_rates: Any = None, actual_bhp: Any = None, _grid: Any = None)
xy: Any = None

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.

rates: Any = None

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.

bhp: Any = None

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.

WI: Any = None

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.

group: Any = None

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.

names: Any = None

Names of wells (not completions): None, or a list of nWell strings.

actual_rates: Any = None

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.

actual_bhp: Any = None

Like actual_rates, but the bottom-hole pressures.

nan wherever the well index (WI) is unset.

nComp
530    nComp = property(lambda self: len(self.xy))

Num. of completions, i.e. rows of xy, which is what the model actually solves for. Several completions may compose a single well (ref group, well_path).

nWell
536        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).

rates_by_well: numpy.ndarray
540    @property
541    def rates_by_well(self) -> np.ndarray:
542        """`actual_rates`, summed over each well's completions: `(nWell, nSteps)`."""
543        group = np.arange(self.nComp) if self.group is None else self.group
544        out = np.zeros((self.nWell, self.actual_rates.shape[1]))
545        np.add.at(out, group, self.actual_rates)  # NB: `+=` would skip the dupes
546        return out

actual_rates, summed over each well's completions: (nWell, nSteps).

signs: numpy.ndarray
548    @property
549    def signs(self) -> np.ndarray:
550        """The sign (`+1` inject, `-1` produce, `0` unknown) of each well's rate.
551
552        Read off the *spec*, `rates`, summed over time (`nan` entries --
553        which a BHP-controlled well may well have -- being skipped). Wells left
554        undecided by it, i.e. those with no spec or a vanishing one (as under
555        pure BHP control), fall back on the `actual_rates` of the latest `sim`,
556        if there has been one. Only the truly undecided are then `0`.
557        """
558        sgn = np.zeros(self.nComp, int)
559        for rates in [self.rates, self.actual_rates]:
560            if rates is not None:
561                q = np.nansum(rates, axis=1)
562                sgn = np.where(sgn, sgn, (q > 0).astype(int) - (q < 0))
563        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.

def at_time(self, spec: str, absent: float, k: int) -> numpy.ndarray:
565    def at_time(self, spec: str, absent: float, k: int) -> np.ndarray:
566        """Lookup the `spec` (`"rates"`/`"bhp"`) at time `k`.
567
568        Allows a constant-in-time (singleton) spec, and an unset (`None`) one
569        (for which `absent`, i.e. `0`/`nan` for rate/bhp-controlled wells
570        respectively, is returned).
571        Avoids broadcast (and potentially stale copies) to `(nComp, nSteps)`,
572        which requires `nSteps`, i.e. `sim()`.
573        """
574        arr = getattr(self, spec)
575        if arr is None:
576            return np.full(self.nComp, absent)
577        assert len(arr) == self.nComp, (
578            f"`wells.{spec}` has {len(arr)} rows, but there are"
579            f" {self.nComp} completions (ref `Wells.xy`)."
580        )
581        # Copy, lest `well_controls` write into the spec itself
582        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().

@classmethod
def from_records(cls, model: minires.ResSim, wells: Any) -> Wells:
584    @classmethod
585    def from_records(cls, model: "ResSim", wells: Any) -> "Wells":
586        """Assemble the flat, per-completion arrays from one record (`dict`) per well.
587
588        This is the convenient way to configure the wells, and assigning the
589        records to `minires.ResSim.wells` is what applies it. Each record
590        may specify
591
592        - `xy`: the well's position, `[x, y]` -- or positions,
593          `[[x, y], ...]`, for a multi-completion well.
594        - `path`: alternatively, a polyline, `[[x, y], ...]`, to be discretized
595          into one completion per cell it traverses, ref
596          `well_path`. Needs `rw`.
597        - `rate`: the well's (signed, ref `Wells.rates`) rate: a scalar, or a
598          schedule (an array over time). Apportioned among its completions in
599          proportion to their well indices (uniformly, absent those).
600        - `bhp`: alternatively (or, in time, additionally) the bottom-hole
601          pressure, ref `Wells.bhp`. Scalar or schedule. Shared -- as a wellbore
602          does -- by all of the well's completions.
603        - `rw`, `skin`: the wellbore radius and skin, whence the well index, via
604          `peaceman_WI`. Without them (or `WI`) the well has no well model.
605        - `WI`: alternatively, the well index itself, given directly.
606        - `aquifer`: `True` makes the well an aquifer contact: its `WI` is then
607          `aquifer_WI` of its cells (which must lie on the boundary), and its
608          `bhp` the aquifer pressure. A string of compass directions instead
609          (`"W"`, `"NE"`, ...) selects the boundary faces that count. Ref `aquifer_WI`.
610        - `name`: for the reporting. Defaults to the well's index.
611
612        The concise cases stay concise -- a position and a rate is a well. A
613        `dict` of records names them by its keys (as `name` does otherwise), and
614        the constructor takes the same thing:
615
616        >>> from minires import ResSim
617        >>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16, wells={
618        ...     "I1": dict(xy=[0, 0], rate=+1),
619        ...     "P1": dict(xy=[1, 1], rate=-1, rw=1e-3),
620        ... })
621        >>> model.wells.names
622        ['I1', 'P1']
623        >>> model.wells.rates
624        array([[ 1.],
625               [-1.]])
626        >>> model.wells.WI.round(3)  # `P1` alone asked for a well model
627        array([  nan, 2.498])
628
629        A `path` becomes several completions of a single well, whose rate it
630        shares out (ref `well_path`) and whose name they share:
631
632        >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[
633        ...     dict(name="I1", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2),
634        ...     dict(name="P1", xy=[.95, .95], rate=-1),
635        ... ])
636        >>> model.wells.nComp, model.wells.nWell
637        (6, 2)
638        >>> model.wells.group
639        array([0, 0, 0, 0, 0, 1])
640        >>> model.wells.rates.ravel().round(3)
641        array([ 0.125,  0.25 ,  0.25 ,  0.25 ,  0.125, -1.   ])
642
643        Schedules and control modes may be mixed freely across the wells: a
644        constant is broadcast to the length of the longest schedule, while a
645        spec that no well varies in time stays a singleton, which
646        `minires.ResSim.well_controls` reads at any `k`. A BHP-controlled
647        well leaves the (ignored) `0` in `Wells.rates`, and `nan` marks the
648        rate-controlled ones in `Wells.bhp` -- the conventions that the specs,
649        being shared arrays, call for (ref `Wells.rates`).
650
651        .. note:: A well must be given a control (`rate` and/or `bhp`).
652
653            `rate=0` shuts it in. This is deliberate: an uncontrolled well would
654            silently be a shut one.
655
656        .. note:: The records are *not* retained.
657
658            The arrays they produce are the whole of the configuration, so
659            there is nothing to fall out of step with a subsequent edit of them
660            (which is what the `repr` therefore reports).
661        """
662        keys = ("name", "xy", "path", "rate", "bhp", "rw", "skin", "WI", "aquifer")
663        if isinstance(wells, dict):
664            wells = [dict(spec, name=name) for name, spec in wells.items()]
665        names, xy, WI, group, rates, bhp = [], [], [], [], [], []
666        specified: set = set()
667        for i, well in enumerate(wells or []):
668            spec = dict(well)
669            if unknown := set(spec) - set(keys):
670                raise TypeError(
671                    f"Unknown key(s) in the spec of well {i}: {sorted(unknown)}."
672                    f" Valid ones: {list(keys)}."
673                )
674            specified |= set(spec)
675            name = str(spec.pop("name", i))
676            rw, skin = spec.pop("rw", None), spec.pop("skin", 0.0)
677            aquifer = spec.pop("aquifer", False)
678
679            # Completions: their positions, and their well indices
680            if (path := spec.pop("path", None)) is not None:
681                assert "xy" not in spec, (
682                    f"Well '{name}': give it `xy` or `path`, not both."
683                )
684                assert rw is not None, f"Well '{name}': a `path` requires `rw`."
685                assert not aquifer, f"Well '{name}': an aquifer is given by `xy`."
686                _xy, _WI, _ = well_path(model, path, rw, skin)
687            else:
688                assert "xy" in spec, f"Well '{name}': give it an `xy` (or a `path`)."
689                _xy = np.array(spec.pop("xy"), float).reshape((-1, 2))
690                if aquifer:
691                    assert rw is None, f"Well '{name}': an aquifer has no `rw`."
692                    _WI = aquifer_WI(model, _xy, "WESN" if aquifer is True else aquifer)
693                elif rw is not None:
694                    _WI = peaceman_WI(model, _xy, rw, skin)
695                else:
696                    _WI = np.full(len(_xy), np.nan)
697            if (given := spec.pop("WI", None)) is not None:
698                _WI = np.broadcast_to(np.asarray(given, float).ravel(), len(_xy)).copy()
699            nc = len(_xy)
700
701            # Apportion the rate by well index -- the standard, ref `well_path`
702            alloc = np.full(nc, 1 / nc)
703            if nc > 1 and np.isfinite(_WI).all() and _WI.sum() > 0:
704                alloc = _WI / _WI.sum()
705
706            # Controls. NB: the BHP is shared by the completions, the rate split
707            rate, p_bh = spec.pop("rate", None), spec.pop("bhp", None)
708            assert rate is not None or p_bh is not None, (
709                f"Well '{name}' has no control: give it a `rate`"
710                " (`0` shuts it in), or a `bhp`."
711            )
712            rate = 0.0 if rate is None else rate
713            p_bh = np.nan if p_bh is None else p_bh
714            rates.append(np.outer(alloc, np.ravel(rate)))
715            bhp.append(np.outer(np.ones(nc), np.ravel(p_bh)))
716
717            names.append(name)
718            xy.append(_xy)
719            WI.append(_WI)
720            group.append(np.full(nc, i))
721
722        if not names:
723            return cls()
724
725        def stack(specs):
726            """Stack the wells' `(nComp_i, nTime_i)` specs, widening the constants.
727
728            NB: `at_time` broadcasts a *wholly* singleton spec, but the array is
729            shared, so a well held constant beside a scheduled one is widened here.
730            """
731            nTime = max(spec.shape[1] for spec in specs)
732            assert all(spec.shape[1] in [1, nTime] for spec in specs), (
733                "The wells' schedules must be of equal length (or constant):"
734                f" got {sorted({spec.shape[1] for spec in specs})}."
735            )
736            return np.vstack([np.broadcast_to(s, (len(s), nTime)) for s in specs])
737
738        WI = np.concatenate(WI)
739        return cls(
740            # NB: `xy` first -- it is what defines `nComp`, by which the
741            # `__setattr__` normalization shapes the others.
742            xy     = np.vstack(xy),
743            # Leave a spec unset (`None`) if no well made use of it
744            rates  = stack(rates) if "rate" in specified else None,
745            bhp    = stack(bhp) if "bhp" in specified else None,
746            WI     = WI if np.isfinite(WI).any() else None,
747            group  = np.concatenate(group),
748            names  = names,
749        )  # 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, ref well_path. Needs rw.
  • rate: the well's (signed, ref Wells.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, ref Wells.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, via peaceman_WI. Without them (or WI) the well has no well model.
  • WI: alternatively, the well index itself, given directly.
  • aquifer: True makes the well an aquifer contact: its WI is then aquifer_WI of its cells (which must lie on the boundary), and its bhp the aquifer pressure. A string of compass directions instead ("W", "NE", ...) selects the boundary faces that count. Ref aquifer_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.

The records are not retained.

The arrays they produce are the whole of the configuration, so there is nothing to fall out of step with a subsequent edit of them (which is what the repr therefore reports).