# http://pyrocko.org - GPLv3
#
# The Pyrocko Developers, 21st Century
# ---|P------/S----------~Lg----------
'''
Fomosto backend for the GEMINI program system.
GEMINI (Green's function of the Earth by MINor integration) by Jörg Dalkolmo
and Wolfgang Friederich computes complete synthetic seismograms for a
spherically symmetric, transversely isotropic, anelastic earth by numerical
integration of the equations of motion in the frequency domain [Friederich
& Dalkolmo 1995]. It consists of three programs, which this backend expects to
be installed as ``fomosto_gemini_Gemini_2_2``, ``fomosto_gemini_Dispec_2_2``
and ``fomosto_gemini_Totido_2_2``, e.g. with the ``fomosto-gemini`` package:
* ``Gemini`` computes the Green's basis solutions in the frequency-degree
domain for a given source depth.
* ``Dispec`` combines them with a moment tensor source and sums over the
spherical harmonics, giving spectra at the receivers.
* ``Totido`` transforms the spectra to the time domain.
The earth model, which may be transversely isotropic, is given in GEMINI's own
polynomial format in the ``earthmodel`` entry of the store's ``extra/gemini``
file. The ``earthmodel_1d`` in the store's ``config`` is an isotropic
approximation of it, used for travel time tables. If the ``earthmodel`` entry
is omitted, it is fitted to ``earthmodel_1d`` when the store is built.
Models can be converted between the formats on the command line::
python -m pyrocko.fomosto.gemini nd-to-gemini IN OUT
python -m pyrocko.fomosto.gemini gemini-to-nd IN OUT
Receivers must be at the surface. Components are computed in source-centred
coordinates, with the source at the north pole and the receivers on the 180°
meridian, so that any epicentral distance between 0° and 180° can be used.
.. rubric:: References
Friederich, W. and Dalkolmo, J. (1995): Complete synthetic seismograms for a
spherically symmetric earth by a numerical computation of the Green's function
in the frequency domain. Geophysical Journal International, 122, 537-550.
'''
import asyncio
import contextlib
import hashlib
import heapq
import itertools
import logging
import math
import os
import shutil
import signal
import time
from tempfile import mkdtemp
from os.path import join as pjoin
import numpy as num
from numpy.polynomial import Polynomial
from pyrocko import trace, cake, gf, util
from pyrocko.fomosto import check_variant
from pyrocko.guts import Object, Float, Int, Bool, Tuple, String
from pyrocko.guts import TBase, literal
from pyrocko.moment_tensor import MomentTensor, symmat6
guts_prefix = 'pf'
logger = logging.getLogger('pyrocko.fomosto.gemini')
km = 1000.
# how to call the programs, for each GEMINI version
program_bins = {
'2.2': {
'gemini': 'fomosto_gemini_Gemini_2_2',
'dispec': 'fomosto_gemini_Dispec_2_2',
'totido': 'fomosto_gemini_Totido_2_2',
},
}
# variants usable with ``fomosto init`` and the executables they need
variants = dict(
(version, list(bins.values())) for (version, bins) in program_bins.items())
# Array limits compiled into the Fortran programs.
gemini_nlayer = 20 # Gemini: nlayer, max number of model layers
gemini_ncoefs = 4 # Gemini: max number of polynomial coefficients
gemini_lmx = 10000 # Gemini, Dispec: lmx, max harmonic degree
dispec_nfr = 4095 # Dispec: nfr, max number of frequencies
dispec_nseimx = 149 # Dispec: nseimx - 1, max number of receivers
dispec_ntmx = 200 # Dispec: ntpmx, max length of degree taper
totido_nfmax = 16384 # Totido: nfmax, max number of samples
# Depth ranges [m] in which discontinuities are named when converting GEMINI
# models to cake models.
discontinuity_depth_ranges = {
'moho': (0.*km, 100.*km),
'cmb': (2591.*km, 3191.*km),
'icb': (4850.*km, 5450.*km),
}
# Default bulk attenuation, used where it is undefined or infinite.
qkappa_default = 57823.
# The CMT file holds the moment tensor in units of 10**cmt_exponent dyne cm,
# here 1e15 Nm, keeping the output of Dispec and Totido at moderate values.
cmt_exponent = 22
cmt_moment = 10.0**cmt_exponent * 1e-7 # [Nm]
# Dispec/Totido give nanometers for moments in Nm.
nm = 1e-9
[docs]
def have_backend():
'''
Get the GEMINI versions for which all programs are available.
'''
return set(
version for (version, bins) in program_bins.items()
if all(shutil.which(exe) for exe in bins.values()))
class GeminiError(gf.store.StoreError):
pass
class Interrupted(gf.store.StoreError):
def __str__(self):
return 'Interrupted.'
def _fortran_floats(tokens):
return [float(tok.replace('D', 'E').replace('d', 'e')) for tok in tokens]
[docs]
class GeminiLayer(object):
'''
Layer of a GEMINI earth model.
:param r_bottom: radius of the bottom of the layer [km]
:param coefs: polynomial coefficients, array of shape ``(ncoefs, 6)``.
The columns are density [g/cm^3], vertical and horizontal P-wave
velocity, vertical and horizontal S-wave velocity [km/s], and the
anisotropy parameter eta. Rows are the coefficients of increasing
powers of the normalized radius ``x = r / r_surface``.
:param qmu: shear quality factor, ``<= 0`` for a fluid layer
:param qkappa: bulk quality factor
'''
def __init__(self, r_bottom, coefs, qmu, qkappa):
self.r_bottom = float(r_bottom)
self.coefs = num.array(coefs, dtype=float).reshape(-1, 6)
self.qmu = float(qmu)
self.qkappa = float(qkappa)
@property
def is_fluid(self):
return self.qmu <= 0.0
[docs]
def evaluate(self, x):
'''
Evaluate the polynomials at normalized radii ``x``.
:returns: array of shape ``(6, len(x))`` with the rows density,
vpv, vph, vsv, vsh, eta
'''
x = num.atleast_1d(num.asarray(x, dtype=float))
result = num.zeros((6, x.size))
for coefs in self.coefs[::-1]:
result = result * x[num.newaxis, :] + coefs[:, num.newaxis]
return result
[docs]
class GeminiModel(object):
'''
Spherically symmetric, possibly transversely isotropic GEMINI earth model.
Densities are in [g/cm^3], velocities in [km/s] and radii in [km], as in
the native file format of GEMINI. Layers are ordered from the centre to
the surface.
'''
def __init__(
self, layers, r_surface,
reference_frequency=1.0,
transversely_isotropic=False,
comments=None):
self.layers = list(layers)
self.r_surface = float(r_surface)
self.reference_frequency = float(reference_frequency)
self.transversely_isotropic = bool(transversely_isotropic)
self.comments = list(comments or [])
[docs]
@classmethod
def from_string(cls, s):
'''
Parse earth model in GEMINI's native file format.
This reads the format the same way as GEMINI's ``gtmanis.f`` does.
'''
lines = s.splitlines()
state = dict(iline=0)
def next_line():
iline = state['iline']
if iline >= len(lines):
raise GeminiError(
'Unexpected end of input in GEMINI earth model.')
state['iline'] += 1
return lines[iline]
def read_values(n):
values = []
while len(values) < n:
values.extend(next_line().split())
try:
return _fortran_floats(values[:n])
except ValueError:
raise GeminiError(
'Invalid number in line %i of GEMINI earth model.'
% state['iline'])
comments = []
while state['iline'] < len(lines) \
and lines[state['iline']].startswith('#'):
comment = next_line()[1:].rstrip()
# marker line, added by to_string()
if 'Do not insert any comments below' not in comment:
comments.append(comment)
try:
nlayers = int(next_line()[:2])
line = next_line()
ncoefs = [int(line[2*i:2*i+2]) for i in range(nlayers)]
except ValueError:
raise GeminiError(
'Cannot read number of layers or number of polynomial '
'coefficients of GEMINI earth model (fixed format, 2 columns '
'per value).')
reference_frequency, = read_values(1)
ifanis, = read_values(1)
for _ in range(3):
next_line()
layers = []
for ilayer in range(nlayers):
r, rho, vpv, vph, vsv, vsh, qmu, qkappa, eta = read_values(9)
coefs = [[rho, vpv, vph, vsv, vsh, eta]]
for _ in range(ncoefs[ilayer] - 1):
coefs.append(read_values(6))
next_line()
layers.append(GeminiLayer(r, coefs, qmu, qkappa))
r_surface, = read_values(1)
return cls(
layers, r_surface,
reference_frequency=reference_frequency,
transversely_isotropic=int(ifanis) == 1,
comments=comments)
[docs]
def to_string(self):
'''
Get earth model in GEMINI's native file format.
'''
def fmt(values):
return ' '.join('%19.13g' % v for v in values)
nlayers = len(self.layers)
lines = ['#' + comment for comment in self.comments]
lines.extend([
'# +++++++ Do not insert any comments below this line !! '
'+++++++++++++++',
'%2i Number of layers' % nlayers,
''.join('%2i' % layer.coefs.shape[0] for layer in self.layers)
+ ' Number of polynomial coefficients in each layer',
'%g Reference frequency of quality factors (Hertz)'
% self.reference_frequency,
'%i Transversely isotropic? 1=y, else=n'
% int(self.transversely_isotropic),
'-' * 79,
'Radius Density V-Pv V-Ph V-Sv V-Sh Qmu Qk Eta',
'-' * 79])
for layer in self.layers:
rho, vpv, vph, vsv, vsh, eta = layer.coefs[0]
lines.append(fmt([
layer.r_bottom, rho, vpv, vph, vsv, vsh,
layer.qmu, layer.qkappa, eta]))
for coefs in layer.coefs[1:]:
lines.append(' '*20 + fmt(coefs))
# separator, any content is skipped by gtmanis.f
lines.append('')
lines.append(fmt([self.r_surface]))
lines.append('-' * 79)
return '\n'.join(lines) + '\n'
def validate(self, nlayers_max=gemini_nlayer):
if len(self.layers) == 0:
raise GeminiError('GEMINI earth model has no layers.')
if len(self.layers) > nlayers_max:
raise GeminiError(
'GEMINI earth model has %i layers, GEMINI supports at most '
'%i.' % (len(self.layers), nlayers_max))
if self.layers[0].r_bottom != 0.0:
raise GeminiError(
'GEMINI earth model must extend to the centre of the earth.')
for layer in self.layers:
if not (1 <= layer.coefs.shape[0] <= gemini_ncoefs):
raise GeminiError(
'GEMINI earth model layers must have 1 to %i polynomial '
'coefficients.' % gemini_ncoefs)
radii = [layer.r_bottom for layer in self.layers] + [self.r_surface]
if any(r2 <= r1 for (r1, r2) in zip(radii[:-1], radii[1:])):
raise GeminiError(
'GEMINI earth model layer radii must be increasing.')
if self.layers[-1].is_fluid:
raise GeminiError(
'GEMINI does not support a fluid top layer (ocean).')
def layer_bounds(self):
radii = [layer.r_bottom for layer in self.layers] + [self.r_surface]
return list(zip(self.layers, radii[:-1], radii[1:]))
[docs]
def isotropic_properties(self, layer, r):
'''
Get isotropic approximation of the material properties of a layer.
For a transversely isotropic model the equivalent isotropic Voigt
average moduli are used, as by GEMINI (after Dziewonski & Anderson
1980). For an isotropic model only the vertical velocities are used,
as by GEMINI.
:param layer: :py:class:`GeminiLayer` of this model
:param r: radii [km]
:returns: ``(vp, vs, rho, qp, qs)``, arrays in SI units
'''
rho, vpv, vph, vsv, vsh, eta = layer.evaluate(
num.asarray(r) / self.r_surface)
if layer.is_fluid:
vs = num.zeros_like(vpv)
vp = vpv
elif self.transversely_isotropic:
a = rho * vph**2
c = rho * vpv**2
n = rho * vsh**2
l_ = rho * vsv**2
f = eta * (a - 2.0*l_)
kappa = (4.0*(a + f - n) + c) / 9.0
mu = (a + c - 2.0*f + 5.0*n + 6.0*l_) / 15.0
vp = num.sqrt((kappa + 4.0/3.0 * mu) / rho)
vs = num.sqrt(mu / rho)
else:
vp = vpv
vs = vsv
if layer.is_fluid:
qs = num.zeros_like(vp)
qp = num.full_like(vp, layer.qkappa)
else:
s = (4.0/3.0) * (vs/vp)**2
qs = num.full_like(vp, layer.qmu)
qp = 1.0 / (s/layer.qmu + (1.0 - s)/layer.qkappa)
return vp*km, vs*km, rho*km, qp, qs
[docs]
def to_cake(self, rtol=5e-4):
'''
Get isotropic approximation as :py:class:`pyrocko.cake.LayeredModel`.
Each polynomial layer is approximated by gradient layers, with nodes
inserted until linear interpolation deviates by less than ``rtol``
(relative) from the polynomials.
Discontinuities are named, as needed for standard phase definitions,
if they are roughly at the expected depth (see
``discontinuity_depth_ranges``): the top of a fluid layer is
named ``cmb``, its bottom ``icb``, and the shallowest discontinuity
with a P-wave velocity increasing across 7.5 km/s is named ``moho``.
'''
def props(layer, r):
return num.array(self.isotropic_properties(layer, r))
def refine(layer, r_top, r_bottom, p_top, p_bottom, level=0):
# Nodes from top to bottom.
rm = 0.5 * (r_top + r_bottom)
pm = props(layer, [rm])[:, 0]
pm_lin = 0.5 * (p_top + p_bottom)
scale = num.maximum(num.abs(pm), num.abs(pm_lin))
scale[scale == 0.0] = 1.0
if level < 12 and num.any(num.abs(pm - pm_lin) > rtol * scale):
return refine(layer, r_top, rm, p_top, pm, level+1)[:-1] \
+ refine(layer, rm, r_bottom, pm, p_bottom, level+1)
return [(r_top, p_top), (r_bottom, p_bottom)]
r_surface = self.r_surface
lines = []
moho_found = False
for layer, r_bottom, r_top in self.layer_bounds()[::-1]:
p_top, p_bottom = props(layer, [r_top, r_bottom]).T
nodes = refine(layer, r_top, r_bottom, p_top, p_bottom)
name = None
if lines:
_, m_above, _ = lines[-1]
vp_below = nodes[0][1][0]
if m_above.vs != 0.0 and layer.is_fluid:
name = 'cmb'
elif m_above.vs == 0.0 and not layer.is_fluid:
name = 'icb'
elif not moho_found and m_above.vp < 7500. <= vp_below:
name = 'moho'
moho_found = True
if name is not None:
zmin, zmax = discontinuity_depth_ranges[name]
if not (zmin <= (r_surface - r_top)*km <= zmax):
name = None
if name is None and num.allclose(
nodes[0][1], m_above.astuple(), rtol=1e-6, atol=0.):
nodes = nodes[1:]
for r, p in nodes:
lines.append(((r_surface - r)*km, cake.Material(*p), name))
name = None
return cake.LayeredModel.from_scanlines(lines)
[docs]
@classmethod
def from_cake(
cls, mod,
nlayers_max=gemini_nlayer,
rtol_velocity=2e-3,
rtol_q=0.1,
nsamples_per_layer=20):
'''
Approximate :py:class:`pyrocko.cake.LayeredModel` by GEMINI layers.
The model is split at its discontinuities. The material properties in
each part are fitted with polynomials of up to third order in the
normalized radius, the quality factors are averaged. As long as the
total number of layers stays at most ``nlayers_max``, the worst
fitting part is split further, at the node minimizing the misfit. The
misfit is the maximum of the relative misfit of velocities and density
divided by ``rtol_velocity`` and the relative deviation of the quality
factors from the layer's constant value divided by ``rtol_q``. A
warning is issued if it exceeds 1 in the end.
'''
sublayers = [
layer for layer in mod.layers() if layer.zbot > layer.ztop]
if not sublayers:
raise GeminiError('Cannot convert empty earth model.')
r_surface = cake.earthradius / km
# cake models usually stop 1 m above the centre
if abs(sublayers[-1].zbot / km - r_surface) > 0.1:
raise GeminiError(
'Earth model must extend to the centre of the earth (to a '
'depth of %g km) to be used with GEMINI.' % r_surface)
if sublayers[0].ztop != 0.0:
raise GeminiError('Earth model must start at the surface.')
if sublayers[0].mtop.vs == 0.0:
raise GeminiError(
'GEMINI does not support a fluid top layer (ocean).')
# Group the cake layers into parts between discontinuities.
parts = []
for layer in sublayers:
if parts and parts[-1][-1].zbot == layer.ztop and \
parts[-1][-1].mbot.astuple() == layer.mtop.astuple():
parts[-1].append(layer)
else:
parts.append([layer])
if len(parts) > nlayers_max:
raise GeminiError(
'Earth model has %i discontinuities, more than GEMINI\'s '
'limit of %i layers.' % (len(parts)-1, nlayers_max))
fits = [cls._fit_part(
part, r_surface, rtol_velocity, rtol_q, nsamples_per_layer)
for part in parts]
while len(fits) < nlayers_max:
imax = max(range(len(fits)), key=lambda i: fits[i]['misfit'])
fit = fits[imax]
if fit['misfit'] == 0.0 or len(fit['part']) < 2:
break
best = None
for isplit in range(1, len(fit['part'])):
fits_split = [
cls._fit_part(
part, r_surface, rtol_velocity, rtol_q,
nsamples_per_layer)
for part in (fit['part'][:isplit], fit['part'][isplit:])]
misfit = max(f['misfit'] for f in fits_split)
if best is None or misfit < best[0]:
best = misfit, fits_split
fits[imax:imax+1] = best[1]
misfit = max(fit['misfit'] for fit in fits)
if misfit > 1.0:
logger.warning(
'GEMINI earth model approximation exceeds tolerances by a '
'factor of %g, consider increasing the number of layers or '
'the tolerances.' % misfit)
layers = [fit['layer'] for fit in fits[::-1]]
layers[0].r_bottom = 0.0
return cls(layers, r_surface, reference_frequency=1.0, comments=[
' Isotropic earth model for GEMINI, approximating a layered model',
' by polynomials of the normalized radius x = r / r_surface.'])
@staticmethod
def _fit_part(part, r_surface, rtol_velocity, rtol_q, nsamples_per_layer):
z = num.unique(num.concatenate([
num.linspace(layer.ztop, layer.zbot, nsamples_per_layer+1)
for layer in part]))
materials = []
for zz in z:
for layer in part:
if layer.ztop <= zz <= layer.zbot:
materials.append(layer.material(zz))
break
vp, vs, rho, qp, qs = num.array(
[m.astuple() for m in materials]).T
vp, vs, rho = vp/km, vs/km, rho/km
x = (r_surface - z/km) / r_surface
is_fluid = num.all(vs == 0.0)
nkinks = sum(
1 for layer in part
if isinstance(layer, cake.GradientLayer))
degree = min(gemini_ncoefs - 1, nkinks)
def fit(values):
if degree == 0 or num.all(values == values[0]):
return num.array([values.mean()]), \
num.full_like(values, values.mean())
# Fit in scaled domain for numerical stability, then convert to
# coefficients of powers of x.
p = Polynomial.fit(x, values, degree)
coefs = p.convert().coef
coefs = num.concatenate(
[coefs, num.zeros(degree + 1 - coefs.size)])
return coefs, Polynomial(coefs)(x)
misfits = []
columns = []
for values in (rho, vp, vs):
coefs, values_fit = fit(values)
columns.append(coefs)
scale = max(num.abs(values).max(), 1e-3)
misfits.append(
num.abs(values_fit - values).max() / scale / rtol_velocity)
ncoefs = max(c.size for c in columns)
coefs = num.zeros((ncoefs, 6))
crho, cvp, cvs = [
num.concatenate([c, num.zeros(ncoefs - c.size)]) for c in columns]
coefs[:, 0] = crho
coefs[:, 1] = coefs[:, 2] = cvp
if not is_fluid:
coefs[:, 3] = coefs[:, 4] = cvs
coefs[0, 5] = 1.0
# Constant quality factors, averaging the attenuation 1/Q.
qkappas = num.array([m.qk() for m in materials])
qkappas = num.where(
num.isfinite(qkappas) & (qkappas > 0.0),
num.minimum(qkappas, qkappa_default),
qkappa_default)
qkappa = 1.0 / num.mean(1.0/qkappas)
if is_fluid:
qmu = -1.0
else:
qmu = 1.0 / num.mean(1.0/qs)
misfits.append(num.abs(qmu/qs - 1.0).max() / rtol_q)
layer = GeminiLayer(r_surface - part[-1].zbot/km, coefs, qmu, qkappa)
return dict(part=part, layer=layer, misfit=max(misfits))
[docs]
def max_deviation(self, mod, ddepth=10.*km):
'''
Get maximum relative deviation from an isotropic cake model.
Compares the isotropic approximation of this model to ``mod`` in
terms of P- and S-wave velocities and density, at regularly sampled
depths, avoiding the immediate vicinity of discontinuities.
:returns: maximum relative deviation and depth [m] where it occurs
'''
dev_max = 0.0
z_dev_max = 0.0
for layer, r_bottom, r_top in self.layer_bounds():
z_top = (self.r_surface - r_top) * km
z_bottom = (self.r_surface - r_bottom) * km
n = max(2, int(math.ceil((z_bottom - z_top) / ddepth)))
eps = 1e-3 * (z_bottom - z_top)
z = num.linspace(z_top + eps, z_bottom - eps, n+1)
props = num.array(self.isotropic_properties(
layer, self.r_surface - z/km))
for zz, (vp, vs, rho) in zip(z, props[:3].T):
m = mod.material(zz)
for a, b in ((vp, m.vp), (vs, m.vs), (rho, m.rho)):
# floor: 1 km/s or 1 g/cm^3 (shear velocity is 0 in
# fluids)
dev = abs(a - b) / max(abs(b), 1000.)
if dev > dev_max:
dev_max = dev
z_dev_max = zz
return dev_max, z_dev_max
[docs]
class GeminiEarthmodel(Object):
'''
Earth model in GEMINI's native file format.
'''
dummy_for = GeminiModel
dummy_for_description = 'pyrocko.fomosto.gemini.GeminiModel'
class __T(TBase):
def regularize_extra(self, val):
if isinstance(val, str):
val = GeminiModel.from_string(val)
return val
def to_save(self, val):
return literal(val.to_string())
[docs]
class GeminiConfig(Object):
'''
Configuration of the GEMINI backend, stored in ``extra/gemini``.
'''
gemini_version = String.T(
optional=True,
help='Version of the GEMINI programs to use (required, set by '
'``fomosto init``).')
earthmodel = GeminiEarthmodel.T(
optional=True,
help='Earth model in GEMINI\'s native format (radius in [km], density '
'in [g/cm^3], velocities in [km/s], coefficients of polynomials '
'in the normalized radius r/r_surface), possibly transversely '
'isotropic. If not given, it is fitted to the isotropic '
'``earthmodel_1d`` of the store\'s config.')
time_region = Tuple.T(
2, gf.Timing.T(),
default=(gf.Timing('begin-50'), gf.Timing('end+100')),
help='Time window to be covered by the computed seismograms.')
cut = Tuple.T(
2, gf.Timing.T(),
optional=True,
help='Time window to which the Green\'s functions are cut before '
'they are stored.')
time_window_factor = Float.T(
default=2.0,
help='Factor to enlarge the length of the FFT time window relative '
'to ``time_region`` (it is further enlarged to a power of two '
'samples), to keep energy arriving after the time window from '
'wrapping around into it.')
damping_time = Float.T(
optional=True,
help='Damping time of the complex frequency [s], to suppress '
'time-domain aliasing. Default is a fifth of the FFT time window '
'length.')
frequency_max = Float.T(
optional=True,
help='Maximum frequency [Hz]. Default is just below the Nyquist '
'frequency of the store.')
slowness_max = Float.T(
default=0.6,
help='Maximum horizontal slowness [s/km], determining the maximum '
'harmonic degree to compute at each frequency. Truncation of the '
'harmonic degree range produces an artificial arrival with this '
'slowness, so it should be above the slowness of the slowest '
'phases of interest and arrive after the end of the time '
'window.')
harmonic_degree_max = Int.T(
default=gemini_lmx,
help='Absolute limit for the harmonic degree.')
degree_taper_length = Int.T(
default=40,
help='Length of the cosine taper applied at the upper end of the '
'harmonic degree range by Dispec. The degree range is '
'enlarged by this amount.')
include_physical_dispersion = Bool.T(
default=True,
help='Whether to use anelastic, frequency-dependent, complex moduli '
'(at the reference frequency of the model). If false, GEMINI '
'computes purely elastic, unattenuated seismograms.')
accuracy = Float.T(
default=1e-4,
help='Accuracy of the Bulirsch-Stoer integration in GEMINI.')
lowpass_order = Int.T(
default=0,
help='Order of Butterworth low-pass filter to apply (0: no '
'filter).')
lowpass_corner = Float.T(
default=1.0,
help='Corner frequency of Butterworth low-pass filter [Hz].')
nlayers_max = Int.T(
default=gemini_nlayer,
help='Maximum number of earth model layers supported by the GEMINI '
'executable.')
keep_basis_solutions = Bool.T(
default=False,
help='Whether to keep the files with basis solutions computed by '
'GEMINI (in the store\'s ``gemini_green`` directory) after the '
'store has been built.')
def _cmt_lines(m6_use, depth):
'''
Source file in Harvard CMT format, as read by Dispec's ``readcmt.f``.
The moment tensor components are given in units of 1e15 Nm, the source is
located at the north pole.
'''
mrr, mss, mee, mrs, mre, mse = m6_use
lat, lon = 90.0, 0.0
if depth > 9999.9:
raise GeminiError('Source depth too large for CMT file format.')
for v in m6_use:
if abs(v) >= 1000.:
raise GeminiError('Moment tensor too large for CMT file format.')
return [
'%-8s %2s/%2s/%2s %2s:%2s:%4.1f%7.2f%8.2f%6.1f%3.1f%3.1f%-24s' % (
'FOMOSTO', '01', '01', '00', '00', '00', 0.0, lat, lon, depth,
0.0, 0.0, 'FOMOSTO GEMINI'),
'PDE BW:%2i%3i%4i MW:%2i%3i%4i DT=%6.1f%4.1f%7.2f%5.2f%8.2f%5.2f'
'%6.1f%5.1f' % (
0, 0, 0, 0, 0, 0, 0.0, 0.0, lat, 0.0, lon, 0.0, depth, 0.0),
' DUR%4.1f EX %2i' % (0.0, cmt_exponent) + ''.join(
'%6.2f%5.2f' % (v, 0.0) for v in (mrr, mss, mee, mrs, mre, mse)),
'%7.2f%3i%4i' % (0.0, 0, 0) * 3 + '%7.2f' % 0.0
+ '%4i%3i%5i' % (0, 0, 0) * 2]
def _station_lines(distances):
'''
Station file in IRIS format for receivers on the 180 degree meridian.
With the source at the north pole, Dispec measures the epicentral
distance as the receiver's colatitude and the azimuth as 180 degrees minus
the receiver's longitude, which is then zero.
'''
lines = [
'Station file written by fomosto',
'',
' year station network lat lon elev site']
for i, distance in enumerate(distances):
lat = 90.0 - distance * cake.m2d
lines.append(
'00 R%04i XX %.6f %.6f 0.0 fomosto' % (i, lat, 180.0))
return lines
def _window_lines(frequency_max, slowness_max, r_surface, l_add, l_max):
'''
Degree limits in frequency-degree domain, as read by ``gtllim.f``.
The maximum degree is ``2 pi f r_surface slowness_max + l_add``, limited
to ``l_max``. ``gtllim.f`` interpolates linearly between the given
frequencies.
'''
lines = ['2']
for f in (0.0, frequency_max):
lmax = min(
l_max,
int(math.ceil(2.0*math.pi * f * r_surface * slowness_max)) + l_add)
lines.append('%.10g 0 %i' % (f*1000., lmax))
return lines
def _read_seismogr(fn, nreceivers):
'''
Read Totido's ASCII output, as written by ``writeAscii.f``.
:returns: list of arrays of shape ``(3, nsamples)`` with the columns
Z, NS, EW (which are r, theta, phi with Dispec's default of
source-centred coordinates).
'''
with open(fn, 'r') as f:
lines = f.read().splitlines()
try:
toks = lines[1].split()
nsamples = int(toks[1])
samprat = float(toks[3])
timeshift = float(toks[5])
iline = 2
data = []
for ireceiver in range(nreceivers):
assert lines[iline].startswith('RECLat')
block = lines[iline+2:iline+2+nsamples]
assert len(block) == nsamples
data.append(num.array(
[_fortran_floats(line.split()[1:4]) for line in block],
dtype=float).T)
iline += 2 + nsamples
except (IndexError, ValueError, AssertionError):
raise GeminiError('Could not read Totido output file: %s' % fn)
return samprat, timeshift, data
class _Slots(object):
'''
Limits the number of jobs running in parallel.
Waiting jobs get a free slot in order of priority (lowest first), and
in the order of their requests for equal priority.
'''
def __init__(self, nslots):
self._free = nslots
self._waiting = []
self._counter = itertools.count()
@contextlib.asynccontextmanager
async def __call__(self, priority=0):
if self._free > 0 and not self._waiting:
self._free -= 1
else:
future = asyncio.get_running_loop().create_future()
heapq.heappush(
self._waiting, (priority, next(self._counter), future))
try:
await future
except asyncio.CancelledError:
if future.done() and not future.cancelled():
# slot was handed over just before the cancellation
self._release()
raise
try:
yield
finally:
self._release()
def _release(self):
while self._waiting:
_, _, future = heapq.heappop(self._waiting)
if not future.done():
future.set_result(None)
return
self._free += 1
_program_names = {
'gemini': 'Gemini',
'dispec': 'Dispec',
'totido': 'Totido'}
class _Runner(object):
'''
Runs the GEMINI programs as asyncio subprocesses.
At most ``nworkers`` jobs run in parallel. A job holds one of the slots
given by :py:meth:`slot` and may run several programs one after the other.
The programs are started in their own sessions, so that they do not
receive the terminal's signals, and are terminated by the runner when the
coroutine running them is cancelled.
'''
def __init__(self, nworkers, bins):
self._bins = bins
self.slot = _Slots(nworkers)
self._procs = {}
def kill_all(self):
for proc in list(self._procs):
try:
proc.kill()
except ProcessLookupError:
pass
async def _terminate(self, proc, timeout=5.0):
try:
proc.terminate()
except ProcessLookupError:
return
try:
await asyncio.wait_for(proc.wait(), timeout)
except asyncio.TimeoutError:
logger.warning(
'Killing "%s" (pid %i), which did not terminate on SIGTERM.'
% (self._procs.get(proc, 'program'), proc.pid))
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()
async def run(
self, program, input_str, cwd, expected_outputs, on_line=None,
on_start=None):
'''
Run program. The caller must hold a slot.
``on_start`` is called once the process has been started,
``on_line`` for every line of its standard output.
'''
exe = self._bins[program]
try:
proc = await asyncio.create_subprocess_exec(
exe,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
start_new_session=True)
except OSError as e:
raise GeminiError('''Could not start %s executable "%s" (%s).
Available fomosto backends and download links to the modelling codes are listed
on
https://pyrocko.org/docs/current/apps/fomosto/backends.html
''' % (_program_names[program], exe, e))
self._procs[proc] = exe
if on_start:
on_start()
stderr_task = asyncio.ensure_future(proc.stderr.read())
try:
proc.stdin.write(input_str.encode('ascii'))
try:
await proc.stdin.drain()
proc.stdin.close()
except (BrokenPipeError, ConnectionResetError):
pass
output = []
async for line in proc.stdout:
line = line.decode('ascii', errors='replace')
output.append(line)
if on_line:
on_line(line)
error_str = (await stderr_task).decode(
'ascii', errors='replace')
await proc.wait()
finally:
if proc.returncode is None:
await self._terminate(proc)
if not stderr_task.done():
stderr_task.cancel()
await asyncio.wait([stderr_task])
del self._procs[proc]
output_str = ''.join(output)
name = _program_names[program]
logger.debug(
'===== begin %s output (%s) =====\n%s===== end %s output ====='
% (program, cwd, output_str, program))
errmess = []
if proc.returncode != 0:
errmess.append(
'%s had a non-zero exit state (%i).'
% (name, proc.returncode))
if any(line.startswith(('STOP', 'Fortran runtime error'))
for line in error_str.splitlines()):
errmess.append('%s stopped with an error.' % name)
if output_str.find('An error occured') != -1:
errmess.append(
'The string "An error occured" appeared in %s output.'
% name)
for fn in expected_outputs:
if not os.path.exists(pjoin(cwd, fn)):
errmess.append(
'%s did not produce output file "%s".' % (name, fn))
if errmess:
raise GeminiError('''
===== begin %s input =====
%s===== end %s input =====
===== begin %s output =====
%s===== end %s output =====
===== begin %s error =====
%s===== end %s error =====
%s
%s has been invoked as "%s" in directory "%s".'''.lstrip() % (
program, input_str, program,
program, output_str, program,
program, error_str, program,
'\n'.join(errmess),
name, exe, cwd))
return output_str
async def _gather(aws):
'''
Run awaitables concurrently, all or nothing.
If one of them fails, or if this coroutine is cancelled, the others are
cancelled, and their cleanup is waited for, before the error is raised.
'''
tasks = [asyncio.ensure_future(aw) for aw in aws]
if not tasks:
return []
try:
await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
finally:
for task in tasks:
task.cancel()
await asyncio.wait(tasks)
for task in tasks:
if not task.cancelled() and task.exception() is not None:
raise task.exception()
return [task.result() for task in tasks]
class _Tempdir(object):
def __init__(self, prefix, dir=None, keep=False):
self.prefix = prefix
self.dir = dir
self.keep = keep
def __enter__(self):
self.path = mkdtemp(prefix=self.prefix, dir=self.dir)
return self.path
def __exit__(self, *args):
if self.keep:
logger.warning(
'Not removing temporary directory: %s' % self.path)
else:
shutil.rmtree(self.path)
[docs]
class GeminiGFBuilder(object):
'''
Builds a GF store with GEMINI.
Step 1 runs ``Gemini`` for each source depth (one block per depth). Step 2
runs ``Dispec`` and ``Totido`` for each of the elementary moment tensor
sources (one block per source depth and range of up to 149 distances) and
stores the Green's functions.
'''
nsteps = 2
seismogram_types = {
'displacement': 'd',
'velocity': 'v',
'acceleration': 'a'}
def __init__(self, store_dir, force=False, tmp=None, keep_tmp=False):
if tmp is None:
tmp = gf.builder.store_tmp_dir(store_dir)
self.store_dir = store_dir
self.force = force
self.tmp = tmp
self.keep_tmp = keep_tmp
self.store = gf.store.Store(store_dir, 'w')
try:
self._setup()
except Exception:
self.store.close()
raise
def close(self):
self.store.close()
def _setup(self):
store_config = self.store.config
self.conf = conf = self.store.get_extra('gemini')
if conf.gemini_version is None:
raise GeminiError(
'"gemini_version" must be set in "extra/gemini", use one of: '
'%s' % ', '.join(sorted(variants)))
if conf.gemini_version not in program_bins:
raise GeminiError(
'Unsupported GEMINI version: %s' % conf.gemini_version)
if not isinstance(store_config, gf.meta.ConfigTypeA):
raise GeminiError(
'GEMINI backend only supports stores of type A (receivers '
'at the surface).')
if store_config.receiver_depth != 0.0:
raise GeminiError(
'GEMINI only computes Green\'s functions for receivers at '
'the surface, "receiver_depth" must be 0.')
component_scheme = store_config.component_scheme
stored_quantity = store_config.effective_stored_quantity
if component_scheme not in ('elastic10', 'elastic8'):
raise GeminiError(
'GEMINI backend does not support component scheme "%s".'
% component_scheme)
if stored_quantity not in self.seismogram_types:
raise GeminiError(
'GEMINI backend does not support stored quantity "%s".'
% stored_quantity)
self.seismogram_type = self.seismogram_types[stored_quantity]
# Dispec's output components (r, theta, phi) are up, away from the
# source and anticlockwise around the source. Fomosto's (N, E, D)
# with the receiver north of the source are away from the source,
# clockwise around the source and down.
self.gfmapping = [
(MomentTensor(m=symmat6(1, 0, 0, 1, 0, 0)),
{'N': 0, 'E': 3, 'D': 5}),
(MomentTensor(m=symmat6(0, 0, 0, 0, 1, 1)),
{'N': 1, 'E': 4, 'D': 6}),
(MomentTensor(m=symmat6(0, 0, 1, 0, 0, 0)),
{'N': 2, 'D': 7}),
(MomentTensor(m=symmat6(0, 1, 0, 0, 0, 0)),
{'N': 8, 'D': 9}),
]
if component_scheme == 'elastic8':
self.gfmapping = self.gfmapping[:3]
if conf.earthmodel is None:
logger.info(
'No GEMINI earth model given in "extra/gemini", fitting it to '
'the store\'s "earthmodel_1d".')
model = GeminiModel.from_cake(
store_config.earthmodel_1d, nlayers_max=conf.nlayers_max)
else:
model = conf.earthmodel
dev, z = model.max_deviation(store_config.earthmodel_1d)
if dev > 0.02:
logger.warning(
'The isotropic approximation of the GEMINI earth model '
'in "extra/gemini" deviates from the store\'s '
'"earthmodel_1d" by up to %.1f%% (at %g km depth), which '
'is used for travel time tables. To fix, use the output '
'of\n\n python -m pyrocko.fomosto.gemini gemini-to-nd '
'%s\n\nas "earthmodel_1d" in the store\'s config, and run '
'"fomosto ttt" again.'
% (dev*100., z/km,
pjoin(self.store_dir, 'extra', 'gemini')))
model.validate(nlayers_max=conf.nlayers_max)
self.model = model
if abs(model.r_surface - cake.earthradius/km) > 1e-3:
logger.warning(
'Surface radius of GEMINI earth model (%g km) differs from '
'the radius used to convert surface distances to angles '
'(%g km).' % (model.r_surface, cake.earthradius/km))
if conf.degree_taper_length > dispec_ntmx:
raise GeminiError(
'"degree_taper_length" must be at most %i.' % dispec_ntmx)
self._setup_timing()
self.window_str = '\n'.join(_window_lines(
self.frequency_max, conf.slowness_max, model.r_surface,
conf.degree_taper_length + 10,
min(gemini_lmx, conf.harmonic_degree_max))) + '\n'
self.model_str = model.to_string()
self.gf_directory = pjoin(self.store_dir, 'gemini_green')
util.ensuredir(self.gf_directory)
ndistances = store_config.ndistances
nblocks = (ndistances - 1) // dispec_nseimx + 1
nper = (ndistances - 1) // nblocks + 1
self.distance_blocks = [
(i*nper, min((i+1)*nper, ndistances)) for i in range(nblocks)]
def _setup_timing(self):
conf = self.conf
deltat = self.store.config.deltat
d = self.store.make_timing_params(
conf.time_region[0], conf.time_region[1], force=self.force)
tmin = math.floor(d['tmin'] / deltat) * deltat
tmax = math.ceil(d['tmax'] / deltat) * deltat
nsamples_out = int(round((tmax - tmin) / deltat)) + 1
nsamples = trace.nextpow2(
max(4, int(math.ceil(nsamples_out * conf.time_window_factor))))
nsamples_out = min(nsamples, nsamples_out)
tlen = nsamples * deltat
nend_max = nsamples // 2 - 1
if conf.frequency_max is None:
nend = nend_max
else:
nend = int(round(conf.frequency_max * tlen))
if nend > nend_max:
raise GeminiError(
'"frequency_max" (%g Hz) must be below the Nyquist '
'frequency (%g Hz) of the store.' % (
conf.frequency_max, 0.5 / deltat))
if nend < 1:
raise GeminiError('"frequency_max" is too low.')
# Totido extends the spectrum to 2**n samples and zero-pads by
# another factor 2**zero_padding, giving 2*nzh samples.
nzh = trace.nextpow2(nend + 1)
zero_padding = int(round(math.log2(nsamples // (2*nzh))))
assert 2 * nzh * 2**zero_padding == nsamples
if nsamples > totido_nfmax:
raise GeminiError(
'Time window needs %i samples, Totido supports at most %i. '
'Reduce "time_region" or the store\'s sampling rate.' % (
nsamples, totido_nfmax))
if nend > dispec_nfr:
raise GeminiError(
'Calculation needs %i frequencies, Dispec supports at most '
'%i. Reduce "time_region" or "frequency_max".'
% (nend, dispec_nfr))
self.tmin = tmin
self.nsamples_out = nsamples_out
self.tlen = tlen
self.nend = nend
self.frequency_max = nend / tlen
self.zero_padding = zero_padding
self.damping_time = conf.damping_time or tlen / 5.0
logger.info(
'GEMINI time window length: %g s, %i samples, starting at %g s, '
'%i frequencies up to %g Hz, damping time: %g s.'
% (tlen, nsamples, tmin, nend, self.frequency_max,
self.damping_time))
def depths(self):
return self.store.config.coords[0]
def nblocks(self, step):
ndepths = len(self.depths())
if step == 0:
return ndepths
else:
return ndepths * len(self.distance_blocks)
def step2_block(self, iblock):
idepth, idistblock = divmod(iblock, len(self.distance_blocks))
return idepth, idistblock
def gemini_input(self, depth):
conf = self.conf
return '\n'.join(str(x) for x in [
3,
0,
'%.10g' % self.tlen,
'%.10g' % self.damping_time,
0,
'%.10g' % (self.frequency_max * 1000.),
int(conf.include_physical_dispersion),
0,
min(gemini_lmx, conf.harmonic_degree_max),
1,
'%.10g' % (depth / km),
'%g' % conf.accuracy,
'earthmodel',
'window',
'basis',
1]) + '\n'
def basis_path(self, depth):
return pjoin(self.gf_directory, 'basis_%gkm' % (depth / km))
def basis_signature(self, depth):
h = hashlib.sha1()
for s in (self.gemini_input(depth), self.model_str, self.window_str):
h.update(s.encode('ascii'))
return h.hexdigest()
def have_basis(self, depth):
fn = self.basis_path(depth)
try:
with open(fn + '.signature', 'r') as f:
signature = f.read().strip()
except OSError:
return False
return os.path.exists(fn) and signature == self.basis_signature(depth)
def remove_basis(self, depth):
fn = self.basis_path(depth)
for path in (fn, fn + '.signature'):
if os.path.exists(path):
os.unlink(path)
async def run_gemini(self, runner, depth, label):
if self.have_basis(depth):
logger.info(
'%s: skipping, basis solutions exist already.' % label)
return
self.remove_basis(depth)
tbeg = [None]
tlast = [None]
def on_start():
tbeg[0] = tlast[0] = time.time()
logger.info('%s: starting Gemini...' % label)
def on_line(line):
toks = line.split()
if len(toks) == 6 and time.time() - tlast[0] > 30.:
try:
nw = int(toks[1])
except ValueError:
return
tlast[0] = time.time()
logger.info('%s: Gemini at frequency %i / %i.' % (
label, nw, self.nend))
async with runner.slot(priority=1):
with _Tempdir('tmp-', dir=self.gf_directory) as rundir:
for fn, s in [
('earthmodel', self.model_str),
('window', self.window_str)]:
with open(pjoin(rundir, fn), 'w') as f:
f.write(s)
await runner.run(
'gemini', self.gemini_input(depth), rundir, ['basis'],
on_line=on_line, on_start=on_start)
fn = self.basis_path(depth)
os.replace(pjoin(rundir, 'basis'), fn)
with open(fn + '.signature', 'w') as f:
f.write(self.basis_signature(depth) + '\n')
logger.info('%s: done with Gemini, wallclock time: %.0f s.' % (
label, time.time() - tbeg[0]))
def dispec_input(self):
return '\n'.join(str(x) for x in [
'basis',
'source',
'm',
2,
'window',
self.conf.degree_taper_length,
'stations',
3,
'all',
0]) + '\n'
def totido_input(self):
conf = self.conf
if conf.lowpass_order > 0:
lowpass = '1 %i %.10g' % (conf.lowpass_order, conf.lowpass_corner)
else:
lowpass = '0'
return '\n'.join(str(x) for x in [
'spec3k',
'',
'%.10g' % self.tmin,
lowpass,
'0',
self.zero_padding,
self.seismogram_type,
'%.10g' % ((self.nsamples_out + 0.5) * self.store.config.deltat),
'a']) + '\n'
async def run_dispec_totido(
self, runner, depth, distances, mt, on_start=None):
# Elementary moment tensor in units of cmt_moment.
m6 = mt.m6_up_south_east()
# Dispec and Totido run in the same slot, so that at most one Dispec
# output file per slot exists. Step 2 jobs have precedence over step
# 1 jobs, to keep the number of unprocessed basis solutions low.
async with runner.slot(priority=0):
util.ensuredir(self.tmp)
with _Tempdir(
'geminirun-', dir=self.tmp, keep=self.keep_tmp) as rundir:
os.symlink(
os.path.abspath(self.basis_path(depth)),
pjoin(rundir, 'basis'))
for fn, lines in [
('window', [self.window_str]),
('source', _cmt_lines(m6, depth/km)),
('stations', _station_lines(distances))]:
with open(pjoin(rundir, fn), 'w') as f:
f.write('\n'.join(lines).rstrip('\n') + '\n')
await runner.run(
'dispec', self.dispec_input(), rundir, ['spec3k'],
on_start=on_start)
await runner.run(
'totido', self.totido_input(), rundir, ['seismogr'])
samprat, timeshift, data = _read_seismogr(
pjoin(rundir, 'seismogr'), len(distances))
deltat = self.store.config.deltat
if abs(1.0 / samprat - deltat) > 1e-4 * deltat:
raise GeminiError(
'Totido output sampling rate (%g Hz) does not match the '
'store\'s sampling rate (%g Hz).'
% (samprat, self.store.config.sample_rate))
if abs(timeshift - self.tmin) > 1e-3 * deltat + 1e-6 * abs(self.tmin):
raise GeminiError('Totido output has unexpected time shift.')
factor = nm / cmt_moment
traces = []
for distance, (ur, utheta, uphi) in zip(distances, data):
for comp, ydata in [
('N', utheta),
('E', -uphi),
('D', -ur)]:
traces.append(trace.Trace(
'', '', '', comp,
tmin=self.tmin,
deltat=deltat,
ydata=ydata * factor,
meta=dict(distance=distance)))
return traces
async def run_block(self, runner, iblock, label):
idepth, idistblock = self.step2_block(iblock)
depth = self.depths()[idepth]
ia, ib = self.distance_blocks[idistblock]
distances = self.store.config.coords[1][ia:ib]
if not self.have_basis(depth):
raise GeminiError(
'Basis solutions for source depth %g km are missing, run '
'step 1 first.' % (depth / km))
tbeg = []
def on_start():
# first of the programs of this block has been started
if not tbeg:
tbeg.append(time.time())
logger.info('%s: starting Dispec/Totido...' % label)
results = await _gather(
self.run_dispec_totido(runner, depth, distances, mt, on_start)
for (mt, _) in self.gfmapping)
self.put_traces(depth, results)
logger.info('%s: done with Dispec/Totido, wallclock time: %.0f s.' % (
label, time.time() - tbeg[0]))
def put_traces(self, depth, results):
store = self.store
conf = self.conf
skipped = 0
duplicate_inserts = 0
store.lock()
try:
for (_, gfmap), traces in zip(self.gfmapping, results):
for tr in traces:
if tr.channel not in gfmap:
continue
args = (depth, tr.meta['distance'], gfmap[tr.channel])
if conf.cut:
tmin = store.t(conf.cut[0], args[:-1])
tmax = store.t(conf.cut[1], args[:-1])
if None in (tmin, tmax):
skipped += 1
continue
tr.chop(tmin, tmax)
try:
store.put(args, gf.store.GFTrace.from_trace(tr))
except gf.store.DuplicateInsert:
duplicate_inserts += 1
finally:
store.unlock()
if skipped:
logger.warning(
'Could not determine cut time window for %i traces, not '
'stored.' % skipped)
if duplicate_inserts:
logger.warning(
'Skipped %i duplicate insertions.' % duplicate_inserts)
def block_label(self, step, iblock):
return 'Step %i / %i, block %i / %i' % (
step+1, self.nsteps, iblock+1, self.nblocks(step))
[docs]
async def run(self, runner, jobs, on_done, remove_basis=False):
'''
Run the given jobs, ``(step, iblock)`` pairs.
Step 2 blocks wait for the step 1 block of their source depth, if it
is among the jobs. With ``remove_basis``, the basis solutions of a
source depth are removed when its step 2 jobs are done. This is only
safe if the jobs include all of its step 2 blocks not done yet.
'''
jobs = set(jobs)
ndistblocks = len(self.distance_blocks)
async def depth_pipeline(idepth):
depth = self.depths()[idepth]
if (0, idepth) in jobs:
label = self.block_label(0, idepth)
await self.run_gemini(runner, depth, label)
on_done(0, idepth)
iblocks = [
ib for ib in range(
idepth*ndistblocks, (idepth+1)*ndistblocks)
if (1, ib) in jobs]
async def block(ib):
await self.run_block(runner, ib, self.block_label(1, ib))
on_done(1, ib)
await _gather(block(ib) for ib in iblocks)
if remove_basis:
self.remove_basis(depth)
idepths = sorted(set(
iblock if step == 0 else self.step2_block(iblock)[0]
for (step, iblock) in jobs))
await _gather(depth_pipeline(idepth) for idepth in idepths)
async def _build_async(builder, jobs, on_done, nworkers, remove_basis):
loop = asyncio.get_running_loop()
main_task = asyncio.current_task()
runner = _Runner(nworkers, program_bins[builder.conf.gemini_version])
received = []
def handler(signum):
name = signal.Signals(signum).name
if not received:
logger.warning(
'Received %s, terminating running programs and cleaning up.'
% name)
main_task.cancel()
else:
logger.warning(
'Received %s again, killing running programs.' % name)
runner.kill_all()
received.append(signum)
signums = [signal.SIGINT, signal.SIGTERM, signal.SIGHUP]
for signum in signums:
loop.add_signal_handler(signum, handler, signum)
try:
await builder.run(runner, jobs, on_done, remove_basis)
except asyncio.CancelledError:
if received:
raise Interrupted()
raise
finally:
for signum in signums:
loop.remove_signal_handler(signum)
[docs]
def build(
store_dir,
force=False,
nworkers=None,
continue_=False,
step=None,
iblock=None):
'''
Build GF store with GEMINI.
Follows the conventions of the ``build`` method of
:py:class:`pyrocko.gf.builder.Builder` regarding the ``continue_``,
``step`` and ``iblock`` arguments. Step 1 computes the basis solutions
with ``Gemini`` (one block per source depth), step 2 computes and stores
the Green's functions with ``Dispec`` and ``Totido``. Without ``step``,
both steps run as a pipeline.
'''
nsteps = GeminiGFBuilder.nsteps
if step is not None and not (0 <= step < nsteps):
raise gf.store.StoreError('Invalid step: %i' % (step+1))
if iblock is not None and step is None:
raise gf.store.StoreError('Option "--step" must be given.')
if not continue_ and iblock in (None, -1) and step in (None, 0):
gf.store.Store.create_dependants(store_dir, force)
status_fn = pjoin(store_dir, '.status')
done = set()
if iblock is None:
if not continue_:
with open(status_fn, 'w'):
pass
else:
try:
with open(status_fn, 'r') as status:
for line in status:
done.add(tuple(int(x) for x in line.split()))
except IOError:
raise gf.store.StoreError('Nothing to continue.')
builder = GeminiGFBuilder(store_dir, force=force)
try:
steps = list(range(nsteps)) if step is None else [step]
jobs = []
for istep in steps:
nblocks = builder.nblocks(istep)
if iblock in (None, -1):
jobs.extend(
(istep, i) for i in range(nblocks)
if (istep, i) not in done)
else:
if not (0 <= iblock < nblocks):
raise gf.store.StoreError(
'Invalid block index: %i' % (iblock+1))
jobs.append((istep, iblock))
if iblock == -1:
for istep, i in jobs:
c = ['fomosto', 'build']
if not os.path.samefile(store_dir, '.'):
c.append("'%s'" % store_dir)
c.append('--step=%i' % (istep+1))
c.append('--block=%i' % (i+1))
print(' '.join(c))
return
def on_done(istep, i):
if iblock is None:
with open(status_fn, 'a') as status:
status.write('%i %i\n' % (istep, i))
done.add((istep, i))
remove_basis = step is None and iblock is None \
and not builder.conf.keep_basis_solutions
asyncio.run(_build_async(
builder, jobs, on_done, nworkers or os.cpu_count() or 1,
remove_basis))
if step is None and iblock is None:
# basis solutions may be left over from interrupted builds
if not builder.conf.keep_basis_solutions:
for depth in builder.depths():
builder.remove_basis(depth)
try:
os.rmdir(builder.gf_directory)
except OSError:
pass
finally:
builder.close()
if iblock is None:
os.remove(status_fn)
gf.builder.remove_store_tmp_dir(store_dir)
[docs]
def init(store_dir, variant, config_params=None, earthmodel=None):
'''
Initialize GF store for GEMINI.
:param config_params: dict with parameters to override in the store's
config
:param earthmodel: :py:class:`GeminiModel`, possibly transversely
isotropic, to be used. If given, the store's ``earthmodel_1d`` is
set to its isotropic approximation, unless explicitly given in
``config_params``. Otherwise, the GEMINI earth model is fitted to
``earthmodel_1d``.
'''
check_variant('gemini', variant, variants)
store_id = os.path.basename(os.path.realpath(store_dir))
d = dict(
id=store_id,
ncomponents=10,
component_scheme='elastic10',
stored_quantity=None,
sample_rate=0.2,
receiver_depth=0*km,
source_depth_min=10*km,
source_depth_max=20*km,
source_depth_delta=10*km,
distance_min=100*km,
distance_max=1000*km,
distance_delta=10*km,
earthmodel_1d=cake.load_model(),
modelling_code_id='gemini.%s' % variant,
tabulated_phases=[
gf.meta.TPDef(
id='begin',
definition='p,P,p\\,P\\,Pv_(cmb)p'),
gf.meta.TPDef(
id='end',
definition='2.5'),
gf.meta.TPDef(
id='P',
definition='!P'),
gf.meta.TPDef(
id='S',
definition='!S'),
gf.meta.TPDef(
id='p',
definition='!p'),
gf.meta.TPDef(
id='s',
definition='!s')])
if earthmodel is not None:
earthmodel.validate()
d['earthmodel_1d'] = earthmodel.to_cake()
if config_params is not None:
d.update(config_params)
config = gf.meta.ConfigTypeA(**d)
config.validate()
if earthmodel is None:
earthmodel = GeminiModel.from_cake(config.earthmodel_1d)
gemini = GeminiConfig(gemini_version=variant, earthmodel=earthmodel)
gemini.cut = gemini.time_region
return gf.store.Store.create_editables(
store_dir,
config=config,
extra={'gemini': gemini})
def _load_gemini_model(fn):
with open(fn, 'r') as f:
s = f.read()
if s.startswith('--- !pf.GeminiConfig'):
from pyrocko.guts import load_string
conf = load_string(s)
if conf.earthmodel is None:
raise GeminiError('No earth model given in file: %s' % fn)
return conf.earthmodel
return GeminiModel.from_string(s)
[docs]
def main(args=None):
'''
Command line tool to convert earth models between formats.
'''
import sys
from pyrocko.cli import PyrockoArgumentParser
def add_help(parser):
parser.add_argument(
'--help', '-h',
action='help',
help='Show this help message and exit.')
parser = PyrockoArgumentParser(
add_help=False,
prog='python -m pyrocko.fomosto.gemini',
description='Convert earth models between Pyrocko\'s .nd format and '
'GEMINI\'s native polynomial format.')
add_help(parser)
subparsers = parser.add_subparsers(
dest='command', required=True, metavar='SUBCOMMAND',
title='Subcommands')
p = subparsers.add_parser(
'nd-to-gemini',
add_help=False,
help='Fit GEMINI model to layered earth model.',
description='Approximate a layered earth model by polynomial layers '
'for GEMINI. The result can be used as ``earthmodel`` in '
'a store\'s ``extra/gemini`` file.')
p.add_argument(
'input', metavar='IN',
help='Earth model in ``.nd`` format, or name of an earth model '
'built into Pyrocko, e.g. ```ak135-f-continental.m```.')
p.add_argument(
'output', metavar='OUT', nargs='?',
help='Output file. Default: standard output.')
p.add_argument(
'--nlayers-max', type=int, default=gemini_nlayer, metavar='N',
help='Maximum number of layers. Default: %(default)s.')
add_help(p)
p = subparsers.add_parser(
'gemini-to-nd',
add_help=False,
help='Get isotropic approximation of GEMINI model.',
description='Get the isotropic approximation of a, possibly '
'transversely isotropic, GEMINI earth model in ``.nd`` '
'format. The result can be used as ``earthmodel_1d`` in a '
'store\'s ``config`` file.')
p.add_argument(
'input', metavar='IN',
help='Earth model in GEMINI\'s native format, or a store\'s '
'``extra/gemini`` file.')
p.add_argument(
'output', metavar='OUT', nargs='?',
help='Output file. Default: standard output.')
add_help(p)
args = parser.parse_args(args)
util.setup_logging('gemini', 'warning')
try:
if args.command == 'nd-to-gemini':
try:
mod = cake.load_model(args.input)
except ValueError:
raise GeminiError(
'Cannot read "%s" as earth model in .nd format.'
% args.input)
s = GeminiModel.from_cake(
mod, nlayers_max=args.nlayers_max).to_string()
else:
s = cake.write_nd_model_str(
_load_gemini_model(args.input).to_cake())
except (GeminiError, OSError, cake.CakeError) as e:
parser.exit(1, '%s: error: %s\n' % (parser.prog, e))
if args.output:
with open(args.output, 'w') as f:
f.write(s)
else:
sys.stdout.write(s)
if __name__ == '__main__':
main()