-
Notifications
You must be signed in to change notification settings - Fork 1
/
IceWeb.py
401 lines (331 loc) · 16.7 KB
/
IceWeb.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
from obspy.core import read, Stream
import numpy as np
## imports for mimicing obspy spectrograms
from obspy.imaging.spectrogram import _nearest_pow_2
from matplotlib import mlab
import matplotlib as mpl
import matplotlib.pyplot as plt
# colormaps. See https://docs.obspy.org/packages/autogen/obspy.imaging.cm.html
from obspy.imaging.cm import pqlx
# adding colorbar
from mpl_toolkits.axes_grid1 import make_axes_locatable
# for spectrum
from scipy import interpolate
# for wrappers - might split off into their own module
import SDS
import InventoryTools
import os
class icewebSpectrogram:
def __init__(self, stream=None, secsPerFFT=-1):
"""
:type st: Stream
:param st: an ObsPy Stream object. No detrending or filtering is done, so do that before calling this function.
"""
self.stream = Stream()
self.precomputed = False
if isinstance(stream, Stream):
self.stream = stream
count = 0
for tr in self.stream:
if 'spectrogramdata' in tr.stats:
count += 1
if len(self.stream)==count:
self.precomputed = True
def __str__(self):
str = '\n\nicewebSpectrogram:\n'
str += self.stream.__str__()
#str += '\nF: %d 1-D numpy arrays' % len(self.F)
#str += '\nT: %d 1-D numpy arrays' % len(self.T)
#str += '\nS: %d 2-D numpy arrays\n\n' % len(self.S)
return str
def precompute(self, secsPerFFT=None):
"""
For each Trace in self.stream, call compute_spectrogram. T,F and S arrays will be saved in tr.stats.spectrogramdata
:type secsPerFFT: int or float
:param secsPerFFT: Window length for fft in seconds. If this parameter is too
small, the calculation will take forever. If None, it defaults to
ceil(sampling_rate/100.0).
"""
# seconds to use for each FFT. 1 second if the event duration is <= 100 seconds, 6 seconds if it is 10-minutes
if secsPerFFT is None:
secsPerFFT = np.ceil((self.stream[0].stats.delta * self.stream[0].stats.npts)/100)
for tr in self.stream:
[T, F, S] = compute_spectrogram(tr, wlen=secsPerFFT)
tr.stats['spectrogramdata'] = {'T':T, 'F':F, 'S':S}
self.precomputed = True
return self
def plot(self, outfile=None, secsPerFFT=None, fmin=0.5, fmax=20.0, log=False, cmap=pqlx, clim=None, \
equal_scale=False, title=None, add_colorbar=True, precompute=False, dbscale=False, trace_indexes=[] ):
"""
For each Trace in a Stream, plot the seismogram and spectrogram. This results in 2*N subplots
on the figure, where N is the number of Trace objects.
This is modelled after IceWeb spectrograms, which have been part of the real-time monitoring
system at the Alaska Volcano Observatory since March 1998
MATLAB code for this exists in the GISMO toolbox at
https://github.com/geoscience-community-codes/GISMO/blob/master/applications/+iceweb/spectrogram_iceweb.m
:type outfile: str
:param outfile: String for the filename of output file
:type fmin: float
:param fmin: frequency minimum to plot on spectrograms
:type fmax: float
:param fmax: frequency maximum to plot on spectrograms
:type log: bool
:param log: Logarithmic frequency axis if True, linear frequency axis
otherwise.
:type cmap: :class:`matplotlib.colors.Colormap`
:param cmap: Specify a custom colormap instance. If not specified, then the
pqlx colormap is used. viridis_white_r might be worth trying too.
:type clim: [float, float]
:param clim: colormap limits. adjust colormap to clip at lower and/or upper end.
This overrides equal_scale parameter.
:type equal_scale: bool
:param equal_scale: Apply the same colormap limits to each spectrogram if True.
This requires more memory since all spectrograms have to be pre-computed
to determine overall min and max spectral amplitude within [fmin, fmax].
If False (default), each spectrogram is individually scaled, which is best
for seeing details. equal_scale is overridden by clim if given.
:type add_colorbar: bool
:param add_colorbar: Add colorbars for each spectrogram (5% space will be created on RHS)
:type title: str
:param title: String for figure super title
:type dbscale: bool
:param dbscale: If True 20 * log10 of color values is taken.
:type trace_indexes: list of int (or None:default)
:param trace_indexes: Only plot spectrograms for these trace indexes, if set.
"""
if not self.precomputed and precompute:
self = self.precompute(secsPerFFT=secsPerFFT)
st = self.stream
if len(trace_indexes)>0: # same logic as metrics.select_by_index_list(st, chosen)
st = Stream()
for i, tr in enumerate(self.stream):
if i in trace_indexes:
st.append(tr)
N = len(st) # number of channels we are plotting
if N==0:
print('Stream object is empty. Nothing to do')
return
fig, ax = plt.subplots(N*2, 1); # create fig and ax handles with approx positions for now
fig.set_size_inches(5.76, 7.56);
if clim:
if clim[0]<=clim[1]/100000:
print('Warning: Lower clim should be at least 1/10000 of Upper clim. This translates to a 100 dB range in amplitude')
clim[0]=clim[1]/100000
if equal_scale and not clim: # calculate range of spectral amplitudes
if self.precomputed:
Smin, Smax = icewebSpectrogram.get_S_range(self, fmin=fmin, fmax=fmax)
else:
index_min = np.argmin(st.max()) # find the index of largest Trace object
[T, F, S] = compute_spectrogram(st[index_min], wlen=secsPerFFT)
f_indexes = np.intersect1d(np.where(F>=fmin), np.where(F<fmax))
S_filtered = S[f_indexes, :]
Smax = np.nanmax(S_filtered)
S_filtered[S_filtered == 0] = Smax
Smin = np.nanmin(S_filtered)
if Smin<Smax*1e-6: # impose a dynamic range limit of 1,000,000
Smin=Smax*1e-6
clim = (Smin, Smax)
for c, tr in enumerate(st):
if self.precomputed:
T = tr.stats.spectrogramdata.T
F = tr.stats.spectrogramdata.F
S = tr.stats.spectrogramdata.S
else:
[T, F, S] = compute_spectrogram(tr, wlen=secsPerFFT)
# fix the axes positions for this trace and spectrogram, making space for a colorbar at bottom if using a fixed scale
if add_colorbar and clim:
spectrogramPosition, tracePosition = icewebSpectrogram.calculateSubplotPositions(N, c,
frameBottom = 0.17, totalHeight = 0.80)
#cax = fig.add_axes([spectrogramPosition[0], 0.08, spectrogramPosition[2], 0.02])
#cax.set_xticks([])
#cax.set_yticks([])
else:
spectrogramPosition, tracePosition = icewebSpectrogram.calculateSubplotPositions(N, c)
ax[c*2].set_position(tracePosition)
ax[c*2+1].set_position(spectrogramPosition)
# plot the trace
t = tr.times()
ax[c*2].plot(t, tr.data, linewidth=0.5);
ax[c*2].set_yticks(ticks=[]) # turn off yticks
if log:
# Log scaling for frequency values (y-axis)
ax[c*2+1].set_yscale('log')
# Plot spectrogram
vmin = None
vmax = None
if clim:
if dbscale:
vmin, vmax = amp2dB(clim)
else:
vmin, vmax = clim
if dbscale:
S = amp2dB(S)
#print(vmin, vmax)
sgram_handle = ax[c*2+1].pcolormesh(T, F, S, vmin=vmin, vmax=vmax, cmap=cmap );
ax[c*2+1].set_ylim(fmin, fmax)
# turn off xticklabels, except for bottom panel
ax[c*2].set_xticklabels([])
if c<N-1:
ax[c*2+1].set_xticklabels([])
# add a ylabel
ax[c*2+1].set_ylabel(' ' + tr.stats.station + '.' + tr.stats.channel, rotation=80)
# Plot colorbar
if add_colorbar:
if clim: # Scaled. Add a colorbar at the bottom of the figure. Just do it once.
if c==0:
#fig.colorbar(sgram_handle, cax=cax, orientation='horizontal');
norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
#cax = fig.add_axes([0.2, 0.1, 0.6, 0.05])
cax = fig.add_axes([spectrogramPosition[0], 0.08, spectrogramPosition[2], 0.02])
cbar = fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), cax=cax, orientation='horizontal', label='Counts/Hz')
if dbscale:
cax.set_xlabel('dB relative to 1 m/s/Hz')
'''
# Failed attempt to add a second set of labels to top of colorbar
# It just wipes out the original xtick labels
cax2 = cax.twinx()
cax2.xaxis.set_ticks_position("top")
xticks = cax.get_xticks()
xticks = np.power(xticks/20, 10)
cax2.set_xticks(xticks)
cax2.set_xlabel('m/s/Hz')
'''
else:
cax.set_xlabel('m/s/Hz')
else: # Unscaled, so each spectrogram has max resolution. Add a colorbar to the right of each spectrogram.
divider = make_axes_locatable(ax[c*2+1])
cax = divider.append_axes("right", size="5%", pad=0.05)
fig.colorbar(sgram_handle, cax=cax);
# also add space next to Trace
divider2 = make_axes_locatable(ax[c*2])
hide_ax = divider2.append_axes("right", size="5%", pad=0.05, visible=False)
# increment c and go to next trace (if any left)
c += 1
ax[N*2-1].set_xlabel('Time [s]')
if title:
ax[0].set_title(title)
# set the xlimits for each panel from the min and max time values we kept updating
min_t = min([tr.stats.starttime for tr in st])
max_t = max([tr.stats.endtime for tr in st]) - min_t
for c in range(N):
ax[c*2].set_xlim(0, max_t)
ax[c*2].grid(axis='x', linestyle = ':', linewidth=0.5)
ax[c*2+1].set_xlim(0, max_t)
ax[c*2+1].grid(True, linestyle = ':', linewidth=0.5)
# change all font sizes
plt.rcParams.update({'font.size': 8});
# write plot to file
if outfile:
fig.savefig(outfile, dpi=100)
return fig, ax
def get_time_range(self):
min_t = min([tr.stats.starttime for tr in self.stream])
max_t = max([tr.stats.endtime for tr in self.stream])
return min_t, max_t
def get_S_range(self, fmin=0.5, fmax=20.0):
Smin = 999999.9
Smax = -999999.9
for tr in self.stream:
F = tr.stats.spectrogramdata['F']
S = tr.stats.spectrogramdata['S']
# filter S between fmin and fmax and then update Smin and Smax
f_indexes = np.intersect1d(np.where(F>=fmin), np.where(F<fmax))
try:
S_filtered = S[f_indexes, :]
except:
print('S_range failed. F is ',F.shape, ' S is ',S.shape)
else:
Smin = np.nanmin([np.nanmin(S_filtered), Smin])
Smax = np.nanmax([np.nanmax(S_filtered), Smax])
#print('S ranges from %e to %e' % (Smin, Smax))
return Smin, Smax
def calculateSubplotPositions(numchannels, channelNum, frameLeft=0.12, frameBottom=0.08, \
totalWidth = 0.8, totalHeight = 0.88, fractionalSpectrogramHeight = 0.8):
""" Copied from the MATLAB/GISMO function """
channelHeight = totalHeight/numchannels;
spectrogramHeight = fractionalSpectrogramHeight * channelHeight;
traceHeight = channelHeight - spectrogramHeight;
spectrogramBottom = frameBottom + (numchannels - channelNum - 1) * channelHeight;
traceBottom = spectrogramBottom + spectrogramHeight;
spectrogramPosition = [frameLeft, spectrogramBottom, totalWidth, spectrogramHeight];
tracePosition = [frameLeft, traceBottom, totalWidth, traceHeight];
return spectrogramPosition, tracePosition
def compute_amplitude_spectrum(self, compute_bandwidth=False):
for c, tr in enumerate(self.stream):
tr.stats['spectrum'] = dict()
A = np.nanmean(tr.stats.spectrogramdata.S, axis=1)
F = tr.stats.spectrogramdata.F
max_i = np.argmax(A)
tr.stats.spectrum['A'] = A
tr.stats.spectrum['F'] = F
tr.stats.spectrum['peakF'] = F[max_i]
tr.stats.spectrum['peakA'] = max(A)
tr.stats.spectrum['medianF'] = np.sum(np.dot(A, F))/np.sum(A)
if compute_bandwidth:
try:
Athresh = max(A)*0.707
fn = interpolate.interp1d(F, A)
xnew = np.arange(0, max(F), 0.1)
ynew = fn(xnew)
ind = np.argwhere(ynew>Athresh)
tr.stats.spectrum['bw_min'] = xnew[ind[0]]
tr.stats.spectrum['bw_max'] = xnew[ind[-1]]
except:
print('Could not compute bandwidth')
for key in tr.stats.spectrum:
v = tr.stats.spectrum[key]
if np.ndim(v)==1:
if np.size(v)==1:
tr.stats.spectrum[key] = v[0]
def plot_amplitude_spectrum(self):
fig, ax = plt.subplots(len(self.stream), 1);
for c, tr in enumerate(self.stream):
if not 'spectrum' in tr.stats:
continue
A = tr.stats.spectrum.A
F = tr.stats.spectrum.F
ax[c].semilogy(F, A);
ax[c].set_ylabel('Spectral Amplitude:\n%s/Hz' % tr.stats.units)
ax[c].set_xlabel('SSAM bin (Hz?)')
ax[c].set_title(tr.id)
def compute_spectrogram(tr, per_lap=0.9, wlen=None, mult=8.0):
"""
Computes spectrogram of the input data.
Modified from obspy.imaging.spectrogram because we want the plotting part
of that method in a different function to this.
:type tr: Trace
:param tr: Trace object to compute spectrogram for
:type per_lap: float
:param per_lap: Percentage of overlap of sliding window, ranging from 0
to 1. High overlaps take a long time to compute.
:type wlen: int or float
:param wlen: Window length for fft in seconds. If this parameter is too
small, the calculation will take forever. If None, it defaults to
(samp_rate/100.0).
:type mult: float
:param mult: Pad zeros to length mult * wlen. This will make the
spectrogram smoother.
"""
Fs = float(tr.stats.sampling_rate)
y = tr.data
npts = tr.stats.npts
# set wlen from samp_rate if not specified otherwise
if not wlen:
wlen = Fs / 100.0
# nfft needs to be an integer, otherwise a deprecation will be raised
nfft = int(_nearest_pow_2(wlen * Fs))
if nfft > npts:
nfft = int(_nearest_pow_2(npts / 8.0))
if mult is not None:
mult = int(_nearest_pow_2(mult))
mult = mult * nfft
nlap = int(nfft * float(per_lap))
y = y - y.mean()
# Here we do not call plt.specgram as that always produces a plot.
# matplotlib.mlab.specgram should be faster as it computes only the arrays
S, F, T = mlab.specgram(y, Fs=Fs, NFFT=nfft, pad_to=mult, noverlap=nlap, mode='magnitude')
return T, F, S
def amp2dB(X):
return 20 * np.log10(X)
def dB2amp(X):
return np.power(10.0, float(X)/20.0)