TPFA_ResSim.wells

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

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

rw must be given in the same length unit as Lx.
def well_path( model: TPFA_ResSim.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: `TPFA_ResSim.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 TPFA_ResSim 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 `TPFA_ResSim.ResSim.TPFA` (Listing 1) is not set up for. Use
223        `TPFA_ResSim.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: TPFA_ResSim.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 TPFA_ResSim 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 TPFA_ResSim.ResSim.TPFA (Listing 1) is not set up for. Use TPFA_ResSim.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.

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

The wells of a TPFA_ResSim.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 TPFA_ResSim 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 TPFA_ResSim.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 TPFA_ResSim.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): TPFA_ResSim.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 TPFA_ResSim.ResSim.assemble_wells and TPFA_ResSim.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 TPFA_ResSim.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 TPFA_ResSim.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 TPFA_ResSim.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 TPFA_ResSim.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 TPFA_ResSim.ResSim.RelPerm) of the well's cell. Re-arranging, $$ p_\mathrm{bh} = p_\mathrm{cell} + q / (WI \, λ_t) \,, $$ which is what TPFA_ResSim.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 TPFA_ResSim.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 TPFA_ResSim.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 TPFA_ResSim.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
445    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
451        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
455    @property
456    def rates_by_well(self) -> np.ndarray:
457        """`actual_rates`, summed over each well's completions: `(nWell, nSteps)`."""
458        group = np.arange(self.nComp) if self.group is None else self.group
459        out = np.zeros((self.nWell, self.actual_rates.shape[1]))
460        np.add.at(out, group, self.actual_rates)  # NB: `+=` would skip the dupes
461        return out

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

signs: numpy.ndarray
463    @property
464    def signs(self) -> np.ndarray:
465        """The sign (`+1` inject, `-1` produce, `0` unknown) of each well's rate.
466
467        Read off the *spec*, `rates`, summed over time (`nan` entries --
468        which a BHP-controlled well may well have -- being skipped). Wells left
469        undecided by it, i.e. those with no spec or a vanishing one (as under
470        pure BHP control), fall back on the `actual_rates` of the latest `sim`,
471        if there has been one. Only the truly undecided are then `0`.
472        """
473        sgn = np.zeros(self.nComp, int)
474        for rates in [self.rates, self.actual_rates]:
475            if rates is not None:
476                q = np.nansum(rates, axis=1)
477                sgn = np.where(sgn, sgn, (q > 0).astype(int) - (q < 0))
478        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:
480    def at_time(self, spec: str, absent: float, k: int) -> np.ndarray:
481        """Lookup the `spec` (`"rates"`/`"bhp"`) at time `k`.
482
483        Allows a constant-in-time (singleton) spec, and an unset (`None`) one
484        (for which `absent`, i.e. `0`/`nan` for rate/bhp-controlled wells
485        respectively, is returned).
486        Avoids broadcast (and potentially stale copies) to `(nComp, nSteps)`,
487        which requires `nSteps`, i.e. `sim()`.
488        """
489        arr = getattr(self, spec)
490        if arr is None:
491            return np.full(self.nComp, absent)
492        assert len(arr) == self.nComp, (
493            f"`wells.{spec}` has {len(arr)} rows, but there are"
494            f" {self.nComp} completions (ref `Wells.xy`)."
495        )
496        # Copy, lest `well_controls` write into the spec itself
497        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: TPFA_ResSim.ResSim, wells: Any) -> Wells:
499    @classmethod
500    def from_records(cls, model: "ResSim", wells: Any) -> "Wells":
501        """Assemble the flat, per-completion arrays from one record (`dict`) per well.
502
503        This is the convenient way to configure the wells, and assigning the
504        records to `TPFA_ResSim.ResSim.wells` is what applies it. Each record
505        may specify
506
507        - `xy`: the well's position, `[x, y]` -- or positions,
508          `[[x, y], ...]`, for a multi-completion well.
509        - `path`: alternatively, a polyline, `[[x, y], ...]`, to be discretized
510          into one completion per cell it traverses, ref
511          `well_path`. Needs `rw`.
512        - `rate`: the well's (signed, ref `Wells.rates`) rate: a scalar, or a
513          schedule (an array over time). Apportioned among its completions in
514          proportion to their well indices (uniformly, absent those).
515        - `bhp`: alternatively (or, in time, additionally) the bottom-hole
516          pressure, ref `Wells.bhp`. Scalar or schedule. Shared -- as a wellbore
517          does -- by all of the well's completions.
518        - `rw`, `skin`: the wellbore radius and skin, whence the well index, via
519          `peaceman_WI`. Without them (or `WI`) the well has no well model.
520        - `WI`: alternatively, the well index itself, given directly.
521        - `name`: for the reporting. Defaults to the well's index.
522
523        The concise cases stay concise -- a position and a rate is a well. A
524        `dict` of records names them by its keys (as `name` does otherwise), and
525        the constructor takes the same thing:
526
527        >>> from TPFA_ResSim import ResSim
528        >>> model = ResSim(Lx=1, Ly=1, Nx=16, Ny=16, wells={
529        ...     "I1": dict(xy=[0, 0], rate=+1),
530        ...     "P1": dict(xy=[1, 1], rate=-1, rw=1e-3),
531        ... })
532        >>> model.wells.names
533        ['I1', 'P1']
534        >>> model.wells.rates
535        array([[ 1.],
536               [-1.]])
537        >>> model.wells.WI.round(3)  # `P1` alone asked for a well model
538        array([  nan, 2.498])
539
540        A `path` becomes several completions of a single well, whose rate it
541        shares out (ref `well_path`) and whose name they share:
542
543        >>> model = ResSim(Lx=1, Ly=1, Nx=10, Ny=10, wells=[
544        ...     dict(name="I1", path=[[.05, .05], [.45, .05]], rate=+1, rw=1e-2),
545        ...     dict(name="P1", xy=[.95, .95], rate=-1),
546        ... ])
547        >>> model.wells.nComp, model.wells.nWell
548        (6, 2)
549        >>> model.wells.group
550        array([0, 0, 0, 0, 0, 1])
551        >>> model.wells.rates.ravel().round(3)
552        array([ 0.125,  0.25 ,  0.25 ,  0.25 ,  0.125, -1.   ])
553
554        Schedules and control modes may be mixed freely across the wells: a
555        constant is broadcast to the length of the longest schedule, while a
556        spec that no well varies in time stays a singleton, which
557        `TPFA_ResSim.ResSim.well_controls` reads at any `k`. A BHP-controlled
558        well leaves the (ignored) `0` in `Wells.rates`, and `nan` marks the
559        rate-controlled ones in `Wells.bhp` -- the conventions that the specs,
560        being shared arrays, call for (ref `Wells.rates`).
561
562        .. note:: A well must be given a control (`rate` and/or `bhp`).
563
564            `rate=0` shuts it in. This is deliberate: an uncontrolled well would
565            silently be a shut one.
566
567        .. note:: The records are *not* retained.
568
569            The arrays they produce are the whole of the configuration, so
570            there is nothing to fall out of step with a subsequent edit of them
571            (which is what the `repr` therefore reports).
572        """
573        keys = ("name", "xy", "path", "rate", "bhp", "rw", "skin", "WI")
574        if isinstance(wells, dict):
575            wells = [dict(spec, name=name) for name, spec in wells.items()]
576        names, xy, WI, group, rates, bhp = [], [], [], [], [], []
577        specified: set = set()
578        for i, well in enumerate(wells or []):
579            spec = dict(well)
580            if unknown := set(spec) - set(keys):
581                raise TypeError(
582                    f"Unknown key(s) in the spec of well {i}: {sorted(unknown)}."
583                    f" Valid ones: {list(keys)}."
584                )
585            specified |= set(spec)
586            name = str(spec.pop("name", i))
587            rw, skin = spec.pop("rw", None), spec.pop("skin", 0.0)
588
589            # Completions: their positions, and their well indices
590            if (path := spec.pop("path", None)) is not None:
591                assert "xy" not in spec, (
592                    f"Well '{name}': give it `xy` or `path`, not both."
593                )
594                assert rw is not None, f"Well '{name}': a `path` requires `rw`."
595                _xy, _WI, _ = well_path(model, path, rw, skin)
596            else:
597                assert "xy" in spec, f"Well '{name}': give it an `xy` (or a `path`)."
598                _xy = np.array(spec.pop("xy"), float).reshape((-1, 2))
599                _WI = (
600                    np.full(len(_xy), np.nan)
601                    if rw is None
602                    else peaceman_WI(model, _xy, rw, skin)
603                )
604            if (given := spec.pop("WI", None)) is not None:
605                _WI = np.broadcast_to(np.asarray(given, float).ravel(), len(_xy)).copy()
606            nc = len(_xy)
607
608            # Apportion the rate by well index -- the standard, ref `well_path`
609            alloc = np.full(nc, 1 / nc)
610            if nc > 1 and np.isfinite(_WI).all() and _WI.sum() > 0:
611                alloc = _WI / _WI.sum()
612
613            # Controls. NB: the BHP is shared by the completions, the rate split
614            rate, p_bh = spec.pop("rate", None), spec.pop("bhp", None)
615            assert rate is not None or p_bh is not None, (
616                f"Well '{name}' has no control: give it a `rate`"
617                " (`0` shuts it in), or a `bhp`."
618            )
619            rate = 0.0 if rate is None else rate
620            p_bh = np.nan if p_bh is None else p_bh
621            rates.append(np.outer(alloc, np.ravel(rate)))
622            bhp.append(np.outer(np.ones(nc), np.ravel(p_bh)))
623
624            names.append(name)
625            xy.append(_xy)
626            WI.append(_WI)
627            group.append(np.full(nc, i))
628
629        if not names:
630            return cls()
631
632        def stack(specs):
633            """Stack the wells' `(nComp_i, nTime_i)` specs, widening the constants.
634
635            NB: `at_time` broadcasts a *wholly* singleton spec, but the array is
636            shared, so a well held constant beside a scheduled one is widened here.
637            """
638            nTime = max(spec.shape[1] for spec in specs)
639            assert all(spec.shape[1] in [1, nTime] for spec in specs), (
640                "The wells' schedules must be of equal length (or constant):"
641                f" got {sorted({spec.shape[1] for spec in specs})}."
642            )
643            return np.vstack([np.broadcast_to(s, (len(s), nTime)) for s in specs])
644
645        WI = np.concatenate(WI)
646        return cls(
647            # NB: `xy` first -- it is what defines `nComp`, by which the
648            # `__setattr__` normalization shapes the others.
649            xy     = np.vstack(xy),
650            # Leave a spec unset (`None`) if no well made use of it
651            rates  = stack(rates) if "rate" in specified else None,
652            bhp    = stack(bhp) if "bhp" in specified else None,
653            WI     = WI if np.isfinite(WI).any() else None,
654            group  = np.concatenate(group),
655            names  = names,
656        )  # 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 TPFA_ResSim.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.
  • 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 TPFA_ResSim 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 TPFA_ResSim.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).