Skip to content

Commit 3b9a3c9

Browse files
authored
Layered computation using empymod. (#302)
The simulation class takes new the parameters ``layered`` and ``layered_opts``, where the default values are False and None, respectively. If ``layered=True``, there will be no 3D computations. Instead, it will create a local layered (1D) model for each source-receiver pair, and compute the response using the semi-analytical code ``empymod``. In this case an eventual gradient is computed using the finite-difference method, not the adjoint-state method, perturbing each layer slightly. Layered computation is also possible through the CLI, through the new flag ``-l`` or ``--layered``, and a new section ``[layered]`` in the config file.
1 parent 56a87c8 commit 3b9a3c9

10 files changed

+1138
-101
lines changed

CHANGELOG.rst

+18-2
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,24 @@ Changelog
66
""""""""""
77

88

9-
latest
10-
------
9+
v1.8.0 : Layered modelling
10+
--------------------------
11+
12+
**2022-08-31**
13+
14+
The simulation class takes new the parameters ``layered`` and ``layered_opts``,
15+
where the default values are False and None, respectively. If ``layered=True``,
16+
there will be no 3D computations. Instead, it will create a local layered (1D)
17+
model for each source-receiver pair, and compute the response using the
18+
semi-analytical code ``empymod`` (which needs to be installed manually, as it
19+
is a soft dependency). In this case an eventual gradient is computed using the
20+
finite-difference method, not the adjoint-state method, perturbing each layer
21+
slightly. The main purpose of these layered computations is for quick checks,
22+
QC, verifications, etc. Layered computation is also possible through the CLI,
23+
through the new flag ``-l`` or ``--layered``, and a new section ``[layered]``
24+
in the config file.
25+
26+
Other changes (many of them related to the above):
1127

1228
- Model instances have a new attribute ``exctract_1d``, which returns a layered
1329
(1D) model, extracted from the 3D model according the provided parameters;

docs/manual/cli.rst

+14-2
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ remove the comment signs to use them.
5959
# max_workers = 4 # Also via `-n` or `--nproc`.
6060
# gridding = single
6161
# name = MyTestSimulation
62-
# file_dir = None # For file-based comp; absolute or relative path.
62+
# file_dir = None # For file-based comp; absolute or relative path.
6363
# receiver_interpolation = cubic # Set it to <linear> for the gradient.
64+
# layered = False # Also via `-l` or `--layered`.
6465

6566
# Solver options
6667
# --------------
@@ -102,7 +103,6 @@ remove the comment signs to use them.
102103
# center = # list, e.g.: 0, 0, 0
103104
# cell_number = # list, e.g.: 8, 16, 32, 64, 128
104105
# min_width_pps = # list, e.g.: 5, 3, 3
105-
# expand = # list, e.g.: 0.3, 1e8
106106
# domain = # list of lists, e.g.: -10000, 10000; None; None
107107
# distance = # list of lists, e.g., None; None; -10000, 10000
108108
# stretching = # list of lists, e.g.: None; None; 1.05, 1.5
@@ -136,3 +136,15 @@ remove the comment signs to use them.
136136
# receivers = RxEP-01, RxMP-10
137137
# frequencies = f-1, f-3
138138
# remove_empty = False # CLI uses False by default.
139+
140+
# Layered computation
141+
# -------------------
142+
# The following parameters are only used if `-l`/`--layered` is set or the
143+
# simulation section has set `layered` to True.
144+
[layered]
145+
# method = # str
146+
# radius = # float
147+
# factor = # float
148+
# minor = # float
149+
# merge = # bool
150+
# check_foci = # bool

emg3d/_multiprocessing.py

+315-1
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,18 @@
1616
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1717
# License for the specific language governing permissions and limitations under
1818
# the License.
19+
from copy import deepcopy
1920
from concurrent.futures import ProcessPoolExecutor
2021

22+
import numpy as np
23+
2124
try:
2225
import tqdm
2326
import tqdm.contrib.concurrent
2427
except ImportError:
2528
tqdm = None
2629

27-
from emg3d import io, solver
30+
from emg3d import io, solver, utils
2831

2932

3033
def process_map(fn, *iterables, max_workers, **kwargs):
@@ -143,3 +146,314 @@ def solve(inp):
143146
return fname, fname
144147
else:
145148
return efield, info
149+
150+
151+
@utils._requires('empymod')
152+
def layered(inp):
153+
"""Returns response or gradient using layered models; for a `process_map`.
154+
155+
Used within a Simulation to call empymod in parallel for layered models.
156+
Depending on the input it returns either the forward responses or the
157+
finite-difference gradient.
158+
159+
The parameters section describes the content of the input dict.
160+
161+
Parameters
162+
----------
163+
model : Model
164+
The model; a :class:`emg3d.models.Model` instance. Must be isotropic or
165+
VTI.
166+
167+
src : Tx*
168+
Any dipole or point source of the available sources, e.g.,
169+
:class:`emg3d.electrodes.TxElectricDipole`.
170+
171+
receivers : dict of Rx*
172+
Receiver dict (:attr:`emg3d.surveys.Survey.receivers`).
173+
174+
frequencies : dict
175+
Frequency dict (:attr:`emg3d.surveys.Survey.frequencies`).
176+
177+
empymod_opts : dict
178+
Options passed to empymod ({src;rec}pts, {h;f}t, {h;f}targ, xdirect,
179+
loop).
180+
181+
observed : DataArray
182+
Observed data of this source.
183+
184+
layered_opts : dict
185+
Options passed to :attr:`emg3d.models.Model.extract_1d`.
186+
187+
gradient : bool
188+
If False, the electromagnetic responses are returned; if True, the
189+
gradient is returned.
190+
191+
If True, the following things _have_ to be provided: ``observed``,
192+
``weights``, and ``residual``; otherwise a zero gradient is returned.
193+
194+
weights : DataArray, optional
195+
Data weights corresponding to the data; only required if
196+
``gradient=True``.
197+
198+
residual : DataArray
199+
Residuals using the current model; only required if ``gradient=True``.
200+
201+
202+
Returns
203+
-------
204+
out : ndarray
205+
If ``gradient=False``, the output are the electromagnetic responses
206+
(synthetic data) of shape (nrec, nfreq).
207+
208+
If ``gradient=True``, the output is the finite-difference gradient of
209+
shape (3, nx, ny, nz).
210+
211+
"""
212+
213+
# Extract input.
214+
model = inp['model']
215+
src = inp['src']
216+
receivers = inp['receivers']
217+
frequencies = np.array([f for f in inp['frequencies'].values()])
218+
empymod_opts = inp['empymod_opts']
219+
observed = inp['observed']
220+
lopts = deepcopy(inp['layered_opts'])
221+
gradient = inp['gradient']
222+
223+
# Get method and set to return_imat.
224+
method = lopts.pop('method')
225+
lopts['return_imat'] = True
226+
227+
# Collect rec-independent empymod options.
228+
empymod_opts = {
229+
# User input ({src;rec}pts, {h;f}t, {h;f}targ, xdirect, loop).
230+
# Contains also verb from simulation class.
231+
**empymod_opts,
232+
#
233+
# Source properties, same for all receivers.
234+
'src': src.coordinates,
235+
'msrc': src.xtype != 'electric',
236+
'strength': src.strength,
237+
#
238+
# Enforced properties (not implemented).
239+
'signal': None,
240+
'epermV': None,
241+
'mpermV': None,
242+
'squeeze': True,
243+
}
244+
245+
# Create some flags.
246+
epsilon_r = model.epsilon_r is not None
247+
mu_r = model.mu_r is not None
248+
vti = model.case == 'VTI'
249+
250+
# Pre-allocate output array.
251+
if gradient:
252+
# Gradient.
253+
out = np.zeros((3, *model.shape))
254+
255+
# Get weights and residual if the gradient is wanted.
256+
weights = inp.get('weights', None)
257+
residual = inp.get('residual', None)
258+
if weights is None or residual is None or observed is None:
259+
return out
260+
261+
else:
262+
# Responses.
263+
out = np.full((len(receivers), frequencies.size), np.nan+1j*np.nan)
264+
265+
# Loop over receivers.
266+
for i, (rkey, rec) in enumerate(receivers.items()):
267+
268+
# Check observed data, limit to finite values if provided.
269+
if observed is not None:
270+
fi = np.isfinite(observed.loc[rkey, :].data)
271+
if fi.sum() == 0:
272+
continue
273+
freqs = frequencies[fi]
274+
275+
# Generating obs data for all.
276+
else:
277+
fi = np.ones(frequencies.size, dtype=bool)
278+
freqs = frequencies
279+
280+
# Get 1D model.
281+
# Note: if method='source', this would be faster outside the loop.
282+
oned, imat = model.extract_1d(**_get_points(method, src, rec), **lopts)
283+
284+
# Collect input.
285+
empymod_inp = {
286+
**empymod_opts,
287+
'rec': rec.coordinates,
288+
'mrec': rec.xtype != 'electric',
289+
'depth': oned.grid.nodes_z[1:-1],
290+
'freqtime': freqs,
291+
'epermH': None if not epsilon_r else oned.epsilon_r[0, 0, :],
292+
'mpermH': None if not mu_r else oned.mu_r[0, 0, :],
293+
}
294+
295+
# Get horizontal and vertical conductivities.
296+
map2cond = oned.map.backward
297+
cond_h = map2cond(oned.property_x[0, 0, :])
298+
cond_v = None if not vti else map2cond(oned.property_z[0, 0, :])
299+
300+
# Compute gradient.
301+
if gradient:
302+
303+
# Get misfit of this src-rec pair.
304+
obs = observed.loc[rkey, :].data[fi]
305+
wgt = weights.loc[rkey, :].data[fi]
306+
res = residual.loc[rkey, :].data[fi]
307+
misfit = np.sum(wgt*(res.conj()*res)).real/2
308+
309+
# Get horizontal gradient.
310+
out[0, ...] += _fd_gradient(cond_h, cond_v, obs, wgt, misfit,
311+
empymod_inp, imat, vertical=False)
312+
313+
# Get vertical gradient if VTI.
314+
if vti:
315+
out[2, ...] += _fd_gradient(cond_h, cond_v, obs, wgt, misfit,
316+
empymod_inp, imat, vertical=True)
317+
318+
# Compute response.
319+
else:
320+
out[i, fi] = _empymod_fwd(cond_h, cond_v, empymod_inp)
321+
322+
return out
323+
324+
325+
@utils._requires('empymod')
326+
def _empymod_fwd(cond_h, cond_v, empymod_inp):
327+
"""Thin wrapper for empymod.bipole().
328+
329+
Parameters
330+
----------
331+
cond_h, cond_v : ndarray
332+
Horizontal and vertical conductivities (S/m). ``cond_v`` can be None,
333+
in which case an isotropic medium is assumed.
334+
335+
empymod_inp : dict
336+
Passed through to :func:`empymod.model.bipole`. Any parameter except
337+
for ``res`` and ``aniso``.
338+
339+
340+
Returns
341+
-------
342+
resp : EMArray
343+
Electromagnetic field as returned from :func:`empymod.model.bipole`.
344+
345+
346+
"""
347+
from empymod import bipole
348+
aniso = None if cond_v is None else np.sqrt(cond_h/cond_v)
349+
return bipole(res=1/cond_h, aniso=aniso, **empymod_inp)
350+
351+
352+
def _get_points(method, src, rec):
353+
"""Returns correct method and points for model.extract_1d.
354+
355+
Parameters
356+
----------
357+
method : str
358+
All methods accepted by :attr:`emg3d.models.Model.extract_1d` plus
359+
``'source'``, ``'receiver'``.
360+
361+
src, rec : {Tx*, Rx*)
362+
Any of the available point and dipole sources or receivers, e.g.,
363+
:class:`emg3d.electrodes.TxElectricDipole`.
364+
365+
Returns
366+
-------
367+
out : dict
368+
Can be passed directly to :attr:`emg3d.models.Model.extract_1d` for the
369+
parameters ``method``, ``p0``, and ``p1``.
370+
371+
"""
372+
373+
# Get default points.
374+
p0 = src.center[:2]
375+
p1 = rec.center[:2]
376+
377+
# If source or receiver, we re-set one point and rename the method
378+
if method == 'source':
379+
p1 = p0
380+
method = 'midpoint'
381+
382+
elif method == 'receiver':
383+
p0 = p1
384+
method = 'midpoint'
385+
386+
return {'method': method, 'p0': p0, 'p1': p1}
387+
388+
389+
def _fd_gradient(cond_h, cond_v, data, weight, misfit, empymod_inp, imat,
390+
vertical):
391+
"""Computes the finite-difference gradient using empymod.
392+
393+
The finite difference is obtained by adding a relative difference of 0.01 %
394+
to the layer (currently hard-coded).
395+
396+
Parameters
397+
----------
398+
cond_h, cond_v : ndarray
399+
Horizontal and vertical conductivities (S/m). ``cond_v`` can be None,
400+
in which case an isotropic medium is assumed.
401+
402+
data : ndarray
403+
Observed data.
404+
405+
weight : ndarray
406+
Weights corresponding to these data.
407+
408+
misfit : float
409+
Misfit using the current model.
410+
411+
empymod_inp : dict
412+
Passed through to :func:`empymod.model.bipole`. Any parameter except
413+
for ``res`` and ``aniso``.
414+
415+
imat : ndarray
416+
Interpolation matrix as returned by
417+
:attr:`emg3d.models.Model.extract_1d`.
418+
419+
vertical : bool
420+
If True, the gradient for the vertical conductivities is assumed,
421+
otherwise the gradient for the horizontal conductivities.
422+
If ``vertical=True``, ``cond_v`` cannot be None (not checked, will fail
423+
with an AttributeError).
424+
425+
426+
Returns
427+
-------
428+
gradient : ndarray of shape (nx, ny, nz)
429+
Gradient.
430+
431+
432+
"""
433+
# Relative difference fixed to 0.01 %; could be made an input parameter.
434+
rel_diff = 0.0001
435+
436+
# Loop over layers and compute FD gradient for each.
437+
grad = np.zeros(cond_h.size)
438+
for iz in range(cond_h.size):
439+
440+
# Get 1D model.
441+
cond_p = cond_h.copy() if not vertical else cond_v.copy()
442+
443+
# Add relative difference to the layer.
444+
delta = cond_p[iz] * rel_diff
445+
cond_p[iz] += delta
446+
447+
# Call empymod.
448+
if vertical:
449+
response = _empymod_fwd(cond_h, cond_p, empymod_inp)
450+
else:
451+
response = _empymod_fwd(cond_p, cond_v, empymod_inp)
452+
453+
# Calculate gradient and add it.
454+
residual = response - data
455+
fd_misfit = np.sum(weight*(residual.conj()*residual)).real/2
456+
grad[iz] = (fd_misfit - misfit)/delta
457+
458+
# Bring back to full grid and return.
459+
return imat[..., None] * grad[None, :]

0 commit comments

Comments
 (0)