From 51fcddb563698e56960c260a92bacb83d3f36c9a Mon Sep 17 00:00:00 2001 From: Hannah Mark Date: Tue, 21 May 2024 20:16:27 -0400 Subject: [PATCH 01/17] rebase sans no-traces --- rf/imaging.py | 64 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/rf/imaging.py b/rf/imaging.py index 0846b55..bb297c9 100644 --- a/rf/imaging.py +++ b/rf/imaging.py @@ -28,6 +28,7 @@ def plot_rf(stream, fname=None, fig_width=7., trace_height=0.5, scale=1, fillcolors=(None, None), trim=None, info=(('back_azimuth', u'baz (°)', 'C0'), ('distance', u'dist (°)', 'C3')), + show_traces=True, show_vlines=False): """ Plot receiver functions. @@ -49,6 +50,10 @@ def plot_rf(stream, fname=None, fig_width=7., trace_height=0.5, info can be None. In this case no additional axes is plotted. :param show_vlines: If True, show vertical alignment grid lines on plot at positions of the major x-tick marks. + :param show_traces: If True, plot the individual traces in the stream + in an additional set of axes below the plot of the stacked trace. If + False, info will also be set to None and the only thing plotted + is the stacked trace. """ if len(stream) == 0: @@ -81,7 +86,7 @@ def plot_rf(stream, fname=None, fig_width=7., trace_height=0.5, fw3 = FW3 / FW # init figure and axes fig = plt.figure(figsize=(FW, FH), dpi=dpi) - ax1 = fig.add_axes([fl, fb, fw2, h * (N + 2)]) + if show_traces: ax1 = fig.add_axes([fl, fb, fw2, h * (N + 2)]) if info: ax3 = fig.add_axes( [1 - fr - fw3, fb, fw3, h * (N + 2)], sharey=ax1) @@ -104,29 +109,36 @@ def _plot(ax, t, d, i): for i, tr in enumerate(stream): times = tr.times(reftime=tr.stats.onset) xlim = (min(xlim[0], times[0]), max(xlim[1], times[-1])) - _plot(ax1, times, tr.data / max_ * scale, i + 1) + if show_traces: + if scale > 0: # scale to all-trace max + _plot(ax1, times, tr.data / max_ * scale, i + 1) + elif scale < 0: # scale trace by trace + max_ = max(np.abs(tr.data)) + _plot(ax1, times, tr.data / max_ * abs(scale), i + 1) # plot right axes with header information - for ax, header, label, color in info: - data = [tr.stats[header] for tr in stream] - ax.plot(data, 1 + np.arange(len(stream)), '.' + color, mec=color) - ax.set_xlabel(label, color=color, size='small') - if header == 'back_azimuth': - ax.set_xticks(np.arange(5) * 90) - ax.set_xticklabels(['0', '', '180', '', '360'], size='small') - else: - ax.xaxis.set_major_locator(MaxNLocator(4)) - for l in ax.get_xticklabels(): - l.set_fontsize('small') - ax.xaxis.set_minor_locator(AutoMinorLocator()) - # set x and y limits - ax1.set_xlim(*xlim) - ax1.set_ylim(-0.5, N + 1.5) - ax1.set_yticklabels('') - ax1.set_xlabel('time (s)') - ax1.xaxis.set_minor_locator(AutoMinorLocator()) - aligner_color = "#a0a0a080" - if show_vlines: - ax1.xaxis.grid(True, color=aligner_color, linestyle=':') + if info: + for ax, header, label, color in info: + data = [tr.stats[header] for tr in stream] + ax.plot(data, 1 + np.arange(len(stream)), '.' + color, mec=color) + ax.set_xlabel(label, color=color, size='small') + if header == 'back_azimuth': + ax.set_xticks(np.arange(5) * 90) + ax.set_xticklabels(['0', '', '180', '', '360'], size='small') + else: + ax.xaxis.set_major_locator(MaxNLocator(4)) + for l in ax.get_xticklabels(): + l.set_fontsize('small') + ax.xaxis.set_minor_locator(AutoMinorLocator()) + if show_traces: + # set x and y limits + ax1.set_xlim(*xlim) + ax1.set_ylim(-0.5, N + 1.5) + ax1.set_yticklabels('') + ax1.set_xlabel('time (s)') + ax1.xaxis.set_minor_locator(AutoMinorLocator()) + aligner_color = "#a0a0a080" + if show_vlines: + ax1.xaxis.grid(True, color=aligner_color, linestyle=':') # plot stack try: @@ -139,8 +151,10 @@ def _plot(ax, t, d, i): warnings.warn('Different stations or channels in one RF plot. ' + 'Do not plot stack.') elif len(stack) == 1: - ax2 = fig.add_axes([fl, 1 - ft - hs, fw2, hs], sharex=ax1) + if show_traces: ax2 = fig.add_axes([fl, 1 - ft - hs, fw2, hs], sharex=ax1) + if not show_traces: ax2 = fig.add_axes([fl, 1 - ft - hs, fw2, hs]) _plot(ax2, times, stack[0].data, 0) + if not show_traces: ax2.set_xlim(*xlim) for l in ax2.get_xticklabels(): l.set_visible(False) ax2.yaxis.set_major_locator(MaxNLocator(4)) @@ -151,7 +165,7 @@ def _plot(ax, t, d, i): # annotate plot with seed id bbox = dict(boxstyle='round', facecolor='white', alpha=0.8, lw=0) title = '%s traces %s' % (len(stream), _label(stream)) - ax1.annotate(title, (1 - 0.5 * fr, 1 - 0.5 * ft), + if show_traces: ax1.annotate(title, (1 - 0.5 * fr, 1 - 0.5 * ft), xycoords='figure fraction', va='top', ha='right', bbox=bbox, clip_on=False) # save plot From b7142cd16a96f4bc9f11d05e1f70fd18d1b18d64 Mon Sep 17 00:00:00 2001 From: Hannah Mark Date: Fri, 5 Apr 2024 12:58:52 -0400 Subject: [PATCH 02/17] add harmonic decomposition + harmonics plotting --- rf/harmonics.py | 178 ++++++++++++++++++++++++++++++++++++++++++++++++ rf/imaging.py | 81 ++++++++++++++++++++++ rf/rfstream.py | 11 +++ 3 files changed, 270 insertions(+) create mode 100644 rf/harmonics.py diff --git a/rf/harmonics.py b/rf/harmonics.py new file mode 100644 index 0000000..1a3dab4 --- /dev/null +++ b/rf/harmonics.py @@ -0,0 +1,178 @@ +# Copyright 2013-2019 Tom Eulenfeld, MIT license +""" +Harmonic decomposition +""" +from copy import copy +import numpy as np +import warnings +from rf.util import _add_processing_info + +@_add_processing_info +def harmonics(stream, first_comp='R',second_comp=None, azim=0, method='time', **kwargs): + """ + Perform harmonic decomposition of PRFs. + + This implements the method described in Bianchi et al 2010, + doi:10.1029/2009JB007061 (equations in supplemental material) and in + Park and Levin 2016, doi:10.1093/gji/ggw323 (equations 44/45 and 47). + + .. The equations in the two papers are equivalent, just written + out slightly differently. Park and Levin have some sign errors. + + The harmonic components are returned in an array with rows for + constant, cos, sin, cos2, and sin2 terms. + The stats dictionaries of the traces in the input stream must have a + 'back_azimuth' entry and an 'event_time' entry. + + .. 'event_time' is used for trace sorting, under the assumption that + it's a unique key and can be used to make sure that different + components (e.g. R and T) are indexed in a common order. + + :param stream: RFStream including components to be decomposed + :param first_comp: name of primary component to use in decomposition; + 'R' or 'Q' for PRFs + :param second_comp: name of secondary component, if using; 'T' or None + :param azim: azimuth along which to decompose the RFs (default: 0) + :param method: domain for harmonic decomposition. Options are + 'time' -> time domain (Bianchi et al. 2010) + 'freq' -> frequency domain (Park and Levin 2016) + :param \*\*kwargs: other kwargs are passed to underlying functions for + building jacobians + :return: harmonic components in a Stream + + .. note:: + The number of traces returned depends on the number of components. + Two-component decomposition returns 10 harmonics: 5 modeled, 5 + unmodeled. One-component decomposition returns only 5 modeled harmonics + for that single component. + """ + # various checks for components and inputs + components = first_comp + second_comp if second_comp else first_comp + if components not in ['RT','R','Q','QT','T']: # TODO L/Z for SRFs? + raise NotImplementedError('Component choice %s not supported' % components) + for c in components: + nrf = len(stream.select(channel='*%s' % c)) + if nrf == 0: + raise ValueError('Component %s not in stream' % c) + if nrf < 4: # we probably shouldn't try to fit sinusoids with very few points + warnings.warn('Not enough %s RFs for robust azimuthal fit' % c) + if method not in ['time','freq']: + raise NotImplementedError('Supported methods are time and freq') + + # short names to save some typing + R = first_comp; T = False + if second_comp: + T = True # and the second is T + + # get back azimuths (in degrees) with evt time as unique key + baz = np.array([tr.stats.back_azimuth for tr in \ + stream.select(channel='*%s' % R).sort(['event_time'])]) + + # get the RF trace data + rfR = np.array([tr.data for tr in stream.select(channel='*%s' % R).sort(['event_time'])]) + if T: rfT = np.array([tr.data for tr in stream.select(channel='*T').sort(['event_time'])]) + + # transform if working in frequency domain + if method == 'freq': + rfR = np.array([np.fft.fft(rfR[i]) for i in range(len(rfR))]) + if T: rfT = np.array([np.fft.fft(rfT[i]) for i in range(len(rfT))]) + + # use methods to get the jacobian + if T: + jac = decomp_two(baz, azim=azim, **kwargs) + else: + jac = decomp_one(baz, azim=azim, **kwargs) + + # set up array for outputs + npts = stream[0].stats.npts; nharm = int(len(components)*5) + if method == 'time': hd = np.zeros((nharm,npts)) + if method == 'freq': hd = np.zeros((nharm,npts),dtype=complex) + + # loop time points and fit at each one + for i in range(npts): + if not T: rfv = rfR[:,i] # make data vector + if T: rfv = np.hstack((rfR[:,i],rfT[:,i])) + hd[:,i],_,_,_ = np.linalg.lstsq(jac,rfv,rcond=None) + + if method == 'freq': # transform back if needed + for i in range(nharm): + hd[i] = np.fft.ifft(hd[i]) + hd = np.real(hd) + + # make the stream to return, with enough header info to get times later on + out = stream[:1].copy() # start a new stream with one trace + for k in ['processing','event_id','event_depth','event_latitude','event_longitude',\ + 'event_magnitude','inclination','slowness','event_time','back_azimuth']: + try: # try to clean out some stats that no longer apply + _ = out[0].stats.pop(k) + except KeyError: + pass + out[0].stats['type'] = 'harmonic' + out[0].data = hd[0] # place harmonics in traces + for i in range(1,nharm): + new_trace = out[0].copy() + new_trace.data = hd[i] + out.append(new_trace) + terms = ['constant','cos','sin','cos2','sin2'] + for i in range(5): # add some metadata, replace channel and loc for sorting later + out[i].stats['channel'] = str(i) + out[i].stats['term'] = terms[i] + out[i].stats['location'] = 'mod' + out[i].stats['components'] = components + if T: + for i in range(5,10): + out[i].stats['channel'] = str(i-5) + out[i].stats['term'] = terms[i-5] + out[i].stats['location'] = 'unmod' + out[i].stats['components'] = components + return out + +def decomp_two(baz, azim=0, scalars=(3,0.3)): + """ Build jacobian for harmonic decomposition with two components of RFs. + + :param baz: array of back azimuths for RFs in degrees + :param azim: azimuth along which to decompose the RFs. + This can be used with some kind of optimization minimize components + :param scalars: scalar multipliers for R/Q and T constant terms. + Park and Levin (2016) recommend 3 and 0.3 to bias toward radial component + conversions, especially for datasets with patchy back-azimuthal coverage. + (1,1) would weight Q/R and T equally in the regression. + :return: jacobian matrix as np.ndarray, 10xN where N is the time series length + """ + jacr = np.array([scalars[0]*np.ones(len(baz)), + np.cos(np.radians(baz-azim)), + np.sin(np.radians(baz-azim)), + np.cos(2*np.radians(baz-azim)), + np.sin(2*np.radians(baz-azim)), + np.zeros(len(baz)), + np.cos(np.radians(baz-azim)), + np.sin(np.radians(baz-azim)), + np.cos(2*np.radians(baz-azim)), + np.sin(2*np.radians(baz-azim))]) + jact = np.array([np.zeros(len(baz)), + -np.sin(np.radians(baz-azim)), + np.cos(np.radians(baz-azim)), + -np.sin(2*np.radians(baz-azim)), + np.cos(2*np.radians(baz-azim)), + scalars[1]*np.ones(len(baz)), + np.sin(np.radians(baz-azim)), + -np.cos(np.radians(baz-azim)), + np.sin(2*np.radians(baz-azim)), + -np.cos(2*np.radians(baz-azim))]) + jac = np.hstack((jacr,jact)) + return jac.T + +def decomp_one(baz, azim=0): + """ Build jacobian for harmonic decomposition for one RF component. + + :param baz: array of back azimuths for RFs in degrees + :param azim: azimuth along which to decompose the RFs. + This can be used with some kind of optimization minimize components + :return: jacobian matrix as np.ndarray, 5xN where N is time series length + """ + jac = np.array([np.ones(len(baz)), + np.cos(np.radians(baz-azim)), + np.sin(np.radians(baz-azim)), + np.cos(2*np.radians(baz-azim)), + np.sin(2*np.radians(baz-azim))]) + return jac.T diff --git a/rf/imaging.py b/rf/imaging.py index bb297c9..48a8309 100644 --- a/rf/imaging.py +++ b/rf/imaging.py @@ -366,3 +366,84 @@ def plot_profile(profile, fname=None, figsize=None, dpi=None, plt.close(fig) else: return fig + +def plot_harmonics(hd, hd2=None, fillcolors=('b','r'), trim=None): + """Plot components from harmonic decomposition. + + The plot will have two panels. In all cases the left panel will show + the modeled components of hd. + If hd2 is supplied, the right panel will show the modeled components of hd2. + If hd2 is None and hd has unmodeled components, those will be on the right. + If hd2 is None and hd does not have unmodeled components, the right panel will + be empty. + + :param hd: RFStream of harmonics, returned from rf.harmonics.harmonics() + The stream should contain modeled components, and may also include + unmodeled components + :param hd2: Optional second RFStream of harmonics. If used, it should + contain modeled components + :param fillcolors: fill colors for positive and negative wiggles + :param trim: trim stream relative to onset before plotting using + `~.rfstream.RFStream.slice2()` + """ + if trim: + try: + hd = hd.slice2(*trim, reftime='onset') + if hd2: + hd2 = hd2.slice2(*trim, reftime='onset') + except AttributeError: # if it's obspy.Stream() instead of RFStream() + pass # we might still be ok + ref0 = max(abs(hd.select(channel='0',location='mod')[0].data)) + mod = hd.select(location='mod') + if hd2: + ref1 = max(abs(hd2.select(channel='0',location='mod')[0].data)) + unmod = hd2.select(location='mod') + else: + ref1 = ref0 + unmod = hd.select(location='unmod') + + fig = plt.figure(constrained_layout=True) + gs = fig.add_gridspec(1,2) + ax_mod = fig.add_subplot(gs[:,0]) + ax_unmod = fig.add_subplot(gs[:,1],sharey=ax_mod,sharex=ax_mod) + + def _plot(ax, t, d, i): + c1, c2 = fillcolors + if c1: + ax.fill_between(t, d + i, i, where=d >= 0, lw=0., facecolor=c1) + if c2: + ax.fill_between(t, d + i, i, where=d < 0, lw=0., facecolor=c2) + ax.plot(t, d + i, 'k') + + for i in range(5): + tr = mod[i] + t = tr.times(reftime=tr.stats.onset) + j = 4 - int(tr.stats['channel']) + wiggle = tr.data/ref0 + _plot(ax_mod, t, wiggle, j) + if len(unmod) > 0: + tr = unmod[i] + t = tr.times(reftime=tr.stats.onset) + j = 4 - int(tr.stats['channel']) + wiggle = tr.data/ref1 + _plot(ax_unmod, t, wiggle, j) + + ax_mod.set_title('anisotropy/dip %s' % mod[0].stats.components) + if not hd2 and len(unmod) > 0: + ax_unmod.set_title('unmodeled %s' % mod[0].stats.components) + if hd2: + ax_unmod.set_title('anisotropy/dip %s' % unmod[0].stats.components) + + ax_mod.set_xlabel('Delay time [s]') + ax_unmod.set_xlabel('Delay time [s]') + + ax_mod.yaxis.set_ticks(np.arange(5)) + ax_mod.set_yticklabels(['sin($2\\theta$)','cos($2\\theta$)',\ + 'sin($\\theta$)','cos($\\theta$)','constant']) + ax_unmod.yaxis.tick_right() + if trim: + ax_mod.set_xlim(trim) # fallback if not trimmed as requested + + fig.suptitle('Harmonics: %s' % (mod[0].stats.station)) + + return fig diff --git a/rf/rfstream.py b/rf/rfstream.py index 00a7a55..362d282 100644 --- a/rf/rfstream.py +++ b/rf/rfstream.py @@ -14,6 +14,7 @@ from obspy.geodetics import gps2dist_azimuth from obspy.taup import TauPyModel from rf.deconvolve import deconvolve +from rf.harmonics import harmonics from rf.simple_model import load_model from rf.util import DEG2KM, IterMultipleComponents, _add_processing_info @@ -428,6 +429,16 @@ def stack(self): traces.append(tr2) return self.__class__(traces) + def harmonics(self, *args, **kwargs): + """ + Perform harmonic decomposition on stream. + + All args and kwargs are passed to the function + `~rf.harmonics.harmonic()`. + """ + hd = harmonics(self, *args, **kwargs) + return hd + def profile(self, *args, **kwargs): """ Return profile of receiver functions in the stream. From 4be3780f32c3b7ada2cf8332da0560e4df74e7c1 Mon Sep 17 00:00:00 2001 From: Hannah Mark Date: Wed, 17 Apr 2024 10:24:56 -0400 Subject: [PATCH 03/17] simplify components argument(s) --- rf/harmonics.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/rf/harmonics.py b/rf/harmonics.py index 1a3dab4..881feb1 100644 --- a/rf/harmonics.py +++ b/rf/harmonics.py @@ -8,7 +8,7 @@ from rf.util import _add_processing_info @_add_processing_info -def harmonics(stream, first_comp='R',second_comp=None, azim=0, method='time', **kwargs): +def harmonics(stream, components='R', azim=0, method='time', **kwargs): """ Perform harmonic decomposition of PRFs. @@ -29,9 +29,8 @@ def harmonics(stream, first_comp='R',second_comp=None, azim=0, method='time', ** components (e.g. R and T) are indexed in a common order. :param stream: RFStream including components to be decomposed - :param first_comp: name of primary component to use in decomposition; - 'R' or 'Q' for PRFs - :param second_comp: name of secondary component, if using; 'T' or None + :param components: names of components to use in decomposition; + can be R, RT, Q, QT, or T (for PRFs) :param azim: azimuth along which to decompose the RFs (default: 0) :param method: domain for harmonic decomposition. Options are 'time' -> time domain (Bianchi et al. 2010) @@ -47,7 +46,6 @@ def harmonics(stream, first_comp='R',second_comp=None, azim=0, method='time', ** for that single component. """ # various checks for components and inputs - components = first_comp + second_comp if second_comp else first_comp if components not in ['RT','R','Q','QT','T']: # TODO L/Z for SRFs? raise NotImplementedError('Component choice %s not supported' % components) for c in components: @@ -60,8 +58,8 @@ def harmonics(stream, first_comp='R',second_comp=None, azim=0, method='time', ** raise NotImplementedError('Supported methods are time and freq') # short names to save some typing - R = first_comp; T = False - if second_comp: + R = components[0]; T = False + if len(components)==2: T = True # and the second is T # get back azimuths (in degrees) with evt time as unique key From 3af8061db84fd2c4d942ef7103d039acb5f870b8 Mon Sep 17 00:00:00 2001 From: Hannah Mark Date: Wed, 17 Apr 2024 10:25:22 -0400 Subject: [PATCH 04/17] clarify trace scaling, add a warning for harmonics plotting when onset is not available --- rf/imaging.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/rf/imaging.py b/rf/imaging.py index 48a8309..25bb5b8 100644 --- a/rf/imaging.py +++ b/rf/imaging.py @@ -25,7 +25,7 @@ def _label(stream): def plot_rf(stream, fname=None, fig_width=7., trace_height=0.5, stack_height=0.5, dpi=None, - scale=1, fillcolors=(None, None), trim=None, + trace_scale=False, scale_factor=1, fillcolors=(None, None), trim=None, info=(('back_azimuth', u'baz (°)', 'C0'), ('distance', u'dist (°)', 'C3')), show_traces=True, @@ -40,7 +40,10 @@ def plot_rf(stream, fname=None, fig_width=7., trace_height=0.5, :param trace_height: height of one trace in inches :param stack_height: height of stack axes in inches :param dpi: dots per inch for the created figure - :param scale: scale for individual traces + :param trace_scale: if True, scale each trace individually; if false, + scale traces by global max amplitude + :param scale_factor: factor for scaling traces, either individually + or globally. A value of 1 generally works well. :param fillcolors: fill colors for positive and negative wiggles :param trim: trim stream relative to onset before plotting using `~.rfstream.RFStream.slice2()` @@ -105,16 +108,14 @@ def _plot(ax, t, d, i): ax.fill_between(t, d + i, i, where=d < 0, lw=0., facecolor=c2) ax.plot(t, d + i, 'k') xlim = (0, 0) - max_ = max(np.max(np.abs(tr.data)) for tr in stream) + max_ = max(np.max(np.abs(tr.data)) for tr in stream) # for scaling, if not trace_scale for i, tr in enumerate(stream): times = tr.times(reftime=tr.stats.onset) xlim = (min(xlim[0], times[0]), max(xlim[1], times[-1])) if show_traces: - if scale > 0: # scale to all-trace max - _plot(ax1, times, tr.data / max_ * scale, i + 1) - elif scale < 0: # scale trace by trace + if trace_scale: # scale trace by trace (otherwise, we scale by global trace max) max_ = max(np.abs(tr.data)) - _plot(ax1, times, tr.data / max_ * abs(scale), i + 1) + _plot(ax1, times, tr.data / max_ * scale_factor, i + 1) # plot right axes with header information if info: for ax, header, label, color in info: @@ -151,8 +152,10 @@ def _plot(ax, t, d, i): warnings.warn('Different stations or channels in one RF plot. ' + 'Do not plot stack.') elif len(stack) == 1: - if show_traces: ax2 = fig.add_axes([fl, 1 - ft - hs, fw2, hs], sharex=ax1) - if not show_traces: ax2 = fig.add_axes([fl, 1 - ft - hs, fw2, hs]) + if show_traces: + ax2 = fig.add_axes([fl, 1 - ft - hs, fw2, hs], sharex=ax1) + elif not show_traces: + ax2 = fig.add_axes([fl, 1 - ft - hs, fw2, hs]) _plot(ax2, times, stack[0].data, 0) if not show_traces: ax2.set_xlim(*xlim) for l in ax2.get_xticklabels(): @@ -391,8 +394,8 @@ def plot_harmonics(hd, hd2=None, fillcolors=('b','r'), trim=None): hd = hd.slice2(*trim, reftime='onset') if hd2: hd2 = hd2.slice2(*trim, reftime='onset') - except AttributeError: # if it's obspy.Stream() instead of RFStream() - pass # we might still be ok + except AttributeError: # if it's obspy.Stream() instead of RFStream(), might still work + warnings.warn('Warning: onset is not in trace stats, so this may not work as expected') ref0 = max(abs(hd.select(channel='0',location='mod')[0].data)) mod = hd.select(location='mod') if hd2: From e88eef45ec1450fc5b4800040fc765768792f0ad Mon Sep 17 00:00:00 2001 From: Hannah Mark Date: Thu, 9 May 2024 14:05:00 -0400 Subject: [PATCH 05/17] better scaling for harmonics plots --- rf/imaging.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rf/imaging.py b/rf/imaging.py index 25bb5b8..d86bece 100644 --- a/rf/imaging.py +++ b/rf/imaging.py @@ -397,9 +397,15 @@ def plot_harmonics(hd, hd2=None, fillcolors=('b','r'), trim=None): except AttributeError: # if it's obspy.Stream() instead of RFStream(), might still work warnings.warn('Warning: onset is not in trace stats, so this may not work as expected') ref0 = max(abs(hd.select(channel='0',location='mod')[0].data)) + for tr in hd: + if max(abs(tr.data)) > ref0: + ref0 = max(abs(tr.data)) mod = hd.select(location='mod') if hd2: ref1 = max(abs(hd2.select(channel='0',location='mod')[0].data)) + for tr in hd2.select(location='mod'): + if max(abs(tr.data)) > ref0: + ref1 = max(abs(tr.data)) unmod = hd2.select(location='mod') else: ref1 = ref0 From cbd01f9ee2bea562f8f5200407f00cb02763d178 Mon Sep 17 00:00:00 2001 From: Hannah Mark Date: Thu, 9 May 2024 14:09:32 -0400 Subject: [PATCH 06/17] update docs for harmonics --- docs/index.rst | 5 +++++ rf/__init__.py | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index af4463a..a533bfd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -32,6 +32,11 @@ API Documentation .. automodule:: rf.simple_model +:mod:`!harmonics` Module +--------------------------- + +.. automodule:: rf.harmonics + :mod:`!util` Module ------------------------- diff --git a/rf/__init__.py b/rf/__init__.py index 30e2e07..9afde64 100644 --- a/rf/__init__.py +++ b/rf/__init__.py @@ -190,7 +190,9 @@ Please see `.RFStream.rf()` for a more detailed description. RFStream provides the possibility to perform moveout correction, -piercing point calculation and profile stacking. +piercing point calculation and profile stacking. +There are also functions included for calculating back-azimuthal harmonics +from an RFStream. Tutorials --------- @@ -203,6 +205,7 @@ to create a profile (notebook2_) 3. Calculate and compare receiver functions calculated with different deconvolution methods (notebook3_) + 4. Harmonic deconvolution with synthetics - minimal example (notebook4_) Command line tool for batch processing -------------------------------------- @@ -264,6 +267,7 @@ .. _notebook1: http://nbviewer.jupyter.org/github/trichter/notebooks/blob/master/receiver_function_minimal_example.ipynb .. _notebook2: http://nbviewer.jupyter.org/github/trichter/notebooks/blob/master/receiver_function_profile_chile.ipynb .. _notebook3: https://nbviewer.jupyter.org/github/hfmark/notebooks/blob/main/rf_comparison.ipynb +.. _notebook4: https://nbviewer.jupyter.org/github/hfmark/notebooks/blob/main/rf_harmonics.ipynb .. _GitHub: https://github.com/trichter/rf/ """ From 8eaefbccd3531312b1528bc6d182ff888839bdc5 Mon Sep 17 00:00:00 2001 From: Hannah Mark Date: Thu, 9 May 2024 14:47:26 -0400 Subject: [PATCH 07/17] tests for harmonics calc+plot --- rf/tests/test_harmonics.py | 45 ++++++++++++++++++++++++++++++++++++++ rf/tests/test_imaging.py | 9 ++++++++ 2 files changed, 54 insertions(+) create mode 100644 rf/tests/test_harmonics.py diff --git a/rf/tests/test_harmonics.py b/rf/tests/test_harmonics.py new file mode 100644 index 0000000..a433829 --- /dev/null +++ b/rf/tests/test_harmonics.py @@ -0,0 +1,45 @@ +# Copyright 2013-2016 Tom Eulenfeld, MIT license +""" +Tests for harmonics module. +""" +import unittest + +from rf.rfstream import read_rf, rfstats +from rf.harmonics import harmonics +import warnings + +class HarmonicsTestCase(unittest.TestCase): + def test_harmonics_RT(self): + stream = read_rf() + rfstats(stream) + stream.filter('bandpass',freqmin=0.33,freqmax=1) + stream.trim2(-5,15) + stream.rf(deconvolve='multitaper',rotate='NE->RT',\ + gauss=0.5,K=3,tband=4,T=10,olap=0.75,normalize=0) + with warnings.catch_warnings(): # warns for sparse data; generally bad but ok for test + warnings.simplefilter("ignore") + harm = harmonics(stream,components='RT',scalars=(1,1),method='time') + trtest = harm.select(location='mod',channel='0')[0] + self.assertEqual(trtest.data.argmax(),100) + self.assertEqual(trtest.data.max().round(4),1.1839) + + def test_harmonics_R(self): + stream = read_rf() + rfstats(stream) + stream.filter('bandpass',freqmin=0.33,freqmax=1) + stream.trim2(-5,15) + stream.rf(deconvolve='multitaper',rotate='NE->RT',\ + gauss=0.5,K=3,tband=4,T=10,olap=0.75,normalize=0) + with warnings.catch_warnings(): # warns for sparse data; generally bad but ok for test + warnings.simplefilter("ignore") + harm = harmonics(stream,components='R',method='time') + trtest = harm.select(location='mod',channel='0')[0] + self.assertEqual(trtest.data.argmax(),100) + self.assertEqual(trtest.data.max().round(4),1.7725) + +def suite(): + return unittest.makeSuite(HarmonicsTestCase, 'test') + +if __name__ == '__main__': + unittest.main(defaultTest='suite') + diff --git a/rf/tests/test_imaging.py b/rf/tests/test_imaging.py index aaf5d7f..82d0e9e 100644 --- a/rf/tests/test_imaging.py +++ b/rf/tests/test_imaging.py @@ -35,6 +35,15 @@ def test_plot_ppoints(self): stream = minimal_example_rf() plot_ppoints(stream.ppoints(50), inventory=stream) + def test_plot_harmonics(self): + from rf.imaging import plot_harmonics + from rf.harmonics import harmonics + stream = minimal_example_rf() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + harm = harmonics(stream,components='QT',scalars=(1,1)) + plot_harmonics(harm) + def suite(): return unittest.makeSuite(ImagingTestCase, 'test') From b8e701bfa9196c7d31e106d0ef04859aefea4a30 Mon Sep 17 00:00:00 2001 From: Hannah Mark Date: Tue, 21 May 2024 20:17:46 -0400 Subject: [PATCH 08/17] fix no-trace plot --- rf/imaging.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rf/imaging.py b/rf/imaging.py index d86bece..82e603b 100644 --- a/rf/imaging.py +++ b/rf/imaging.py @@ -65,6 +65,9 @@ def plot_rf(stream, fname=None, fig_width=7., trace_height=0.5, stream = stream.slice2(*trim, reftime='onset') if info is None: info = () + if not show_traces: + info=None + trace_height=0 N = len(stream) # calculate axes and figure dimensions # big letters: inches, small letters: figure fraction From 739678c8ca146acecf2d096b5be0022d5209f8ed Mon Sep 17 00:00:00 2001 From: trichter Date: Wed, 22 May 2024 23:34:36 +0200 Subject: [PATCH 09/17] pep8, remove unused import --- rf/harmonics.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/rf/harmonics.py b/rf/harmonics.py index 881feb1..6081257 100644 --- a/rf/harmonics.py +++ b/rf/harmonics.py @@ -2,7 +2,6 @@ """ Harmonic decomposition """ -from copy import copy import numpy as np import warnings from rf.util import _add_processing_info @@ -12,23 +11,23 @@ def harmonics(stream, components='R', azim=0, method='time', **kwargs): """ Perform harmonic decomposition of PRFs. - This implements the method described in Bianchi et al 2010, - doi:10.1029/2009JB007061 (equations in supplemental material) and in + This implements the method described in Bianchi et al 2010, + doi:10.1029/2009JB007061 (equations in supplemental material) and in Park and Levin 2016, doi:10.1093/gji/ggw323 (equations 44/45 and 47). .. The equations in the two papers are equivalent, just written out slightly differently. Park and Levin have some sign errors. - The harmonic components are returned in an array with rows for + The harmonic components are returned in an array with rows for constant, cos, sin, cos2, and sin2 terms. - The stats dictionaries of the traces in the input stream must have a + The stats dictionaries of the traces in the input stream must have a 'back_azimuth' entry and an 'event_time' entry. .. 'event_time' is used for trace sorting, under the assumption that it's a unique key and can be used to make sure that different components (e.g. R and T) are indexed in a common order. - :param stream: RFStream including components to be decomposed + :param stream: RFStream including components to be decomposed :param components: names of components to use in decomposition; can be R, RT, Q, QT, or T (for PRFs) :param azim: azimuth along which to decompose the RFs (default: 0) From f954a259517d240ca5e07552f7f17c050d41a43f Mon Sep 17 00:00:00 2001 From: trichter Date: Wed, 22 May 2024 23:42:03 +0200 Subject: [PATCH 10/17] skip harmonics test if multitaper is not installed --- rf/tests/test_harmonics.py | 45 +++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/rf/tests/test_harmonics.py b/rf/tests/test_harmonics.py index a433829..5f9ede2 100644 --- a/rf/tests/test_harmonics.py +++ b/rf/tests/test_harmonics.py @@ -3,43 +3,52 @@ Tests for harmonics module. """ import unittest +import warnings +try: + import multitaper +except ImportError: + multitaper = None from rf.rfstream import read_rf, rfstats from rf.harmonics import harmonics -import warnings + class HarmonicsTestCase(unittest.TestCase): + @unittest.skipIf(multitaper is None, 'multitaper package not installed') def test_harmonics_RT(self): stream = read_rf() rfstats(stream) - stream.filter('bandpass',freqmin=0.33,freqmax=1) - stream.trim2(-5,15) - stream.rf(deconvolve='multitaper',rotate='NE->RT',\ - gauss=0.5,K=3,tband=4,T=10,olap=0.75,normalize=0) + stream.filter('bandpass', freqmin=0.33, freqmax=1) + stream.trim2(-5, 15) + stream.rf(deconvolve='multitaper', rotate='NE->RT', + gauss=0.5, K=3, tband=4, T=10, olap=0.75, normalize=0) with warnings.catch_warnings(): # warns for sparse data; generally bad but ok for test warnings.simplefilter("ignore") - harm = harmonics(stream,components='RT',scalars=(1,1),method='time') - trtest = harm.select(location='mod',channel='0')[0] - self.assertEqual(trtest.data.argmax(),100) - self.assertEqual(trtest.data.max().round(4),1.1839) + harm = harmonics(stream, components='RT', + scalars=(1, 1), method='time') + trtest = harm.select(location='mod', channel='0')[0] + self.assertEqual(trtest.data.argmax(), 100) + self.assertEqual(trtest.data.max().round(4), 1.1839) + @unittest.skipIf(multitaper is None, 'multitaper package not installed') def test_harmonics_R(self): stream = read_rf() rfstats(stream) - stream.filter('bandpass',freqmin=0.33,freqmax=1) - stream.trim2(-5,15) - stream.rf(deconvolve='multitaper',rotate='NE->RT',\ - gauss=0.5,K=3,tband=4,T=10,olap=0.75,normalize=0) + stream.filter('bandpass', freqmin=0.33, freqmax=1) + stream.trim2(-5, 15) + stream.rf(deconvolve='multitaper', rotate='NE->RT', + gauss=0.5, K=3, tband=4, T=10, olap=0.75, normalize=0) with warnings.catch_warnings(): # warns for sparse data; generally bad but ok for test warnings.simplefilter("ignore") - harm = harmonics(stream,components='R',method='time') - trtest = harm.select(location='mod',channel='0')[0] - self.assertEqual(trtest.data.argmax(),100) - self.assertEqual(trtest.data.max().round(4),1.7725) + harm = harmonics(stream, components='R', method='time') + trtest = harm.select(location='mod', channel='0')[0] + self.assertEqual(trtest.data.argmax(), 100) + self.assertEqual(trtest.data.max().round(4), 1.7725) + def suite(): return unittest.makeSuite(HarmonicsTestCase, 'test') + if __name__ == '__main__': unittest.main(defaultTest='suite') - From a9e500b91438443e1fc5e6a43ba348b5c713906d Mon Sep 17 00:00:00 2001 From: trichter Date: Wed, 22 May 2024 23:57:11 +0200 Subject: [PATCH 11/17] apply autopep8 --- rf/harmonics.py | 96 +++++++++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 43 deletions(-) diff --git a/rf/harmonics.py b/rf/harmonics.py index 6081257..e23a947 100644 --- a/rf/harmonics.py +++ b/rf/harmonics.py @@ -6,6 +6,7 @@ import warnings from rf.util import _add_processing_info + @_add_processing_info def harmonics(stream, components='R', azim=0, method='time', **kwargs): """ @@ -45,7 +46,7 @@ def harmonics(stream, components='R', azim=0, method='time', **kwargs): for that single component. """ # various checks for components and inputs - if components not in ['RT','R','Q','QT','T']: # TODO L/Z for SRFs? + if components not in ['RT', 'R', 'Q', 'QT', 'T']: # TODO L/Z for SRFs? raise NotImplementedError('Component choice %s not supported' % components) for c in components: nrf = len(stream.select(channel='*%s' % c)) @@ -53,26 +54,28 @@ def harmonics(stream, components='R', azim=0, method='time', **kwargs): raise ValueError('Component %s not in stream' % c) if nrf < 4: # we probably shouldn't try to fit sinusoids with very few points warnings.warn('Not enough %s RFs for robust azimuthal fit' % c) - if method not in ['time','freq']: + if method not in ['time', 'freq']: raise NotImplementedError('Supported methods are time and freq') # short names to save some typing - R = components[0]; T = False - if len(components)==2: + R = components[0] + T = False + if len(components) == 2: T = True # and the second is T # get back azimuths (in degrees) with evt time as unique key - baz = np.array([tr.stats.back_azimuth for tr in \ + baz = np.array([tr.stats.back_azimuth for tr in stream.select(channel='*%s' % R).sort(['event_time'])]) - # get the RF trace data rfR = np.array([tr.data for tr in stream.select(channel='*%s' % R).sort(['event_time'])]) - if T: rfT = np.array([tr.data for tr in stream.select(channel='*T').sort(['event_time'])]) + if T: + rfT = np.array([tr.data for tr in stream.select(channel='*T').sort(['event_time'])]) # transform if working in frequency domain if method == 'freq': rfR = np.array([np.fft.fft(rfR[i]) for i in range(len(rfR))]) - if T: rfT = np.array([np.fft.fft(rfT[i]) for i in range(len(rfT))]) + if T: + rfT = np.array([np.fft.fft(rfT[i]) for i in range(len(rfT))]) # use methods to get the jacobian if T: @@ -81,15 +84,20 @@ def harmonics(stream, components='R', azim=0, method='time', **kwargs): jac = decomp_one(baz, azim=azim, **kwargs) # set up array for outputs - npts = stream[0].stats.npts; nharm = int(len(components)*5) - if method == 'time': hd = np.zeros((nharm,npts)) - if method == 'freq': hd = np.zeros((nharm,npts),dtype=complex) + npts = stream[0].stats.npts + nharm = int(len(components)*5) + if method == 'time': + hd = np.zeros((nharm, npts)) + if method == 'freq': + hd = np.zeros((nharm, npts), dtype=complex) # loop time points and fit at each one for i in range(npts): - if not T: rfv = rfR[:,i] # make data vector - if T: rfv = np.hstack((rfR[:,i],rfT[:,i])) - hd[:,i],_,_,_ = np.linalg.lstsq(jac,rfv,rcond=None) + if not T: + rfv = rfR[:, i] # make data vector + if T: + rfv = np.hstack((rfR[:, i], rfT[:, i])) + hd[:, i], _, _, _ = np.linalg.lstsq(jac, rfv, rcond=None) if method == 'freq': # transform back if needed for i in range(nharm): @@ -98,33 +106,34 @@ def harmonics(stream, components='R', azim=0, method='time', **kwargs): # make the stream to return, with enough header info to get times later on out = stream[:1].copy() # start a new stream with one trace - for k in ['processing','event_id','event_depth','event_latitude','event_longitude',\ - 'event_magnitude','inclination','slowness','event_time','back_azimuth']: + for k in ['processing', 'event_id', 'event_depth', 'event_latitude', 'event_longitude', + 'event_magnitude', 'inclination', 'slowness', 'event_time', 'back_azimuth']: try: # try to clean out some stats that no longer apply _ = out[0].stats.pop(k) except KeyError: pass out[0].stats['type'] = 'harmonic' out[0].data = hd[0] # place harmonics in traces - for i in range(1,nharm): + for i in range(1, nharm): new_trace = out[0].copy() new_trace.data = hd[i] out.append(new_trace) - terms = ['constant','cos','sin','cos2','sin2'] + terms = ['constant', 'cos', 'sin', 'cos2', 'sin2'] for i in range(5): # add some metadata, replace channel and loc for sorting later out[i].stats['channel'] = str(i) out[i].stats['term'] = terms[i] out[i].stats['location'] = 'mod' out[i].stats['components'] = components if T: - for i in range(5,10): + for i in range(5, 10): out[i].stats['channel'] = str(i-5) out[i].stats['term'] = terms[i-5] out[i].stats['location'] = 'unmod' out[i].stats['components'] = components return out -def decomp_two(baz, azim=0, scalars=(3,0.3)): + +def decomp_two(baz, azim=0, scalars=(3, 0.3)): """ Build jacobian for harmonic decomposition with two components of RFs. :param baz: array of back azimuths for RFs in degrees @@ -137,28 +146,29 @@ def decomp_two(baz, azim=0, scalars=(3,0.3)): :return: jacobian matrix as np.ndarray, 10xN where N is the time series length """ jacr = np.array([scalars[0]*np.ones(len(baz)), - np.cos(np.radians(baz-azim)), - np.sin(np.radians(baz-azim)), - np.cos(2*np.radians(baz-azim)), - np.sin(2*np.radians(baz-azim)), - np.zeros(len(baz)), - np.cos(np.radians(baz-azim)), - np.sin(np.radians(baz-azim)), - np.cos(2*np.radians(baz-azim)), - np.sin(2*np.radians(baz-azim))]) + np.cos(np.radians(baz-azim)), + np.sin(np.radians(baz-azim)), + np.cos(2*np.radians(baz-azim)), + np.sin(2*np.radians(baz-azim)), + np.zeros(len(baz)), + np.cos(np.radians(baz-azim)), + np.sin(np.radians(baz-azim)), + np.cos(2*np.radians(baz-azim)), + np.sin(2*np.radians(baz-azim))]) jact = np.array([np.zeros(len(baz)), - -np.sin(np.radians(baz-azim)), - np.cos(np.radians(baz-azim)), - -np.sin(2*np.radians(baz-azim)), - np.cos(2*np.radians(baz-azim)), - scalars[1]*np.ones(len(baz)), - np.sin(np.radians(baz-azim)), - -np.cos(np.radians(baz-azim)), - np.sin(2*np.radians(baz-azim)), - -np.cos(2*np.radians(baz-azim))]) - jac = np.hstack((jacr,jact)) + -np.sin(np.radians(baz-azim)), + np.cos(np.radians(baz-azim)), + -np.sin(2*np.radians(baz-azim)), + np.cos(2*np.radians(baz-azim)), + scalars[1]*np.ones(len(baz)), + np.sin(np.radians(baz-azim)), + -np.cos(np.radians(baz-azim)), + np.sin(2*np.radians(baz-azim)), + -np.cos(2*np.radians(baz-azim))]) + jac = np.hstack((jacr, jact)) return jac.T + def decomp_one(baz, azim=0): """ Build jacobian for harmonic decomposition for one RF component. @@ -168,8 +178,8 @@ def decomp_one(baz, azim=0): :return: jacobian matrix as np.ndarray, 5xN where N is time series length """ jac = np.array([np.ones(len(baz)), - np.cos(np.radians(baz-azim)), - np.sin(np.radians(baz-azim)), - np.cos(2*np.radians(baz-azim)), - np.sin(2*np.radians(baz-azim))]) + np.cos(np.radians(baz-azim)), + np.sin(np.radians(baz-azim)), + np.cos(2*np.radians(baz-azim)), + np.sin(2*np.radians(baz-azim))]) return jac.T From af549e0793cbde4d39f7a8048162f7c916dbf576 Mon Sep 17 00:00:00 2001 From: trichter Date: Thu, 23 May 2024 00:00:14 +0200 Subject: [PATCH 12/17] remove obsolete code in __init__.py --- rf/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/rf/__init__.py b/rf/__init__.py index 9afde64..7e56f5d 100644 --- a/rf/__init__.py +++ b/rf/__init__.py @@ -190,7 +190,7 @@ Please see `.RFStream.rf()` for a more detailed description. RFStream provides the possibility to perform moveout correction, -piercing point calculation and profile stacking. +piercing point calculation and profile stacking. There are also functions included for calculating back-azimuthal harmonics from an RFStream. @@ -205,7 +205,7 @@ to create a profile (notebook2_) 3. Calculate and compare receiver functions calculated with different deconvolution methods (notebook3_) - 4. Harmonic deconvolution with synthetics - minimal example (notebook4_) + 4. Harmonic deconvolution with synthetics - minimal example (notebook4_) Command line tool for batch processing -------------------------------------- @@ -277,6 +277,3 @@ from rf.profile import get_profile_boxes from rf.rfstream import read_rf, RFStream, rfstats from rf.util import iter_event_data, IterMultipleComponents - -if 'dev' not in __version__: # get image for correct version from travis-ci - __doc__ = __doc__.replace('branch=master', 'branch=v%s' % __version__) From 58248967f48c4b71b9db61ca6294bf4677a6824e Mon Sep 17 00:00:00 2001 From: trichter Date: Thu, 23 May 2024 00:08:38 +0200 Subject: [PATCH 13/17] apply autopep8 to new plotting code --- rf/imaging.py | 57 +++++++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/rf/imaging.py b/rf/imaging.py index 82e603b..033ac14 100644 --- a/rf/imaging.py +++ b/rf/imaging.py @@ -54,7 +54,7 @@ def plot_rf(stream, fname=None, fig_width=7., trace_height=0.5, :param show_vlines: If True, show vertical alignment grid lines on plot at positions of the major x-tick marks. :param show_traces: If True, plot the individual traces in the stream - in an additional set of axes below the plot of the stacked trace. If + in an additional set of axes below the plot of the stacked trace. If False, info will also be set to None and the only thing plotted is the stacked trace. """ @@ -66,8 +66,8 @@ def plot_rf(stream, fname=None, fig_width=7., trace_height=0.5, if info is None: info = () if not show_traces: - info=None - trace_height=0 + info = None + trace_height = 0 N = len(stream) # calculate axes and figure dimensions # big letters: inches, small letters: figure fraction @@ -92,7 +92,8 @@ def plot_rf(stream, fname=None, fig_width=7., trace_height=0.5, fw3 = FW3 / FW # init figure and axes fig = plt.figure(figsize=(FW, FH), dpi=dpi) - if show_traces: ax1 = fig.add_axes([fl, fb, fw2, h * (N + 2)]) + if show_traces: + ax1 = fig.add_axes([fl, fb, fw2, h * (N + 2)]) if info: ax3 = fig.add_axes( [1 - fr - fw3, fb, fw3, h * (N + 2)], sharey=ax1) @@ -111,12 +112,14 @@ def _plot(ax, t, d, i): ax.fill_between(t, d + i, i, where=d < 0, lw=0., facecolor=c2) ax.plot(t, d + i, 'k') xlim = (0, 0) - max_ = max(np.max(np.abs(tr.data)) for tr in stream) # for scaling, if not trace_scale + max_ = max(np.max(np.abs(tr.data)) + for tr in stream) # for scaling, if not trace_scale for i, tr in enumerate(stream): times = tr.times(reftime=tr.stats.onset) xlim = (min(xlim[0], times[0]), max(xlim[1], times[-1])) - if show_traces: - if trace_scale: # scale trace by trace (otherwise, we scale by global trace max) + if show_traces: + # scale trace by trace (otherwise, we scale by global trace max) + if trace_scale: max_ = max(np.abs(tr.data)) _plot(ax1, times, tr.data / max_ * scale_factor, i + 1) # plot right axes with header information @@ -155,12 +158,13 @@ def _plot(ax, t, d, i): warnings.warn('Different stations or channels in one RF plot. ' + 'Do not plot stack.') elif len(stack) == 1: - if show_traces: + if show_traces: ax2 = fig.add_axes([fl, 1 - ft - hs, fw2, hs], sharex=ax1) - elif not show_traces: + elif not show_traces: ax2 = fig.add_axes([fl, 1 - ft - hs, fw2, hs]) _plot(ax2, times, stack[0].data, 0) - if not show_traces: ax2.set_xlim(*xlim) + if not show_traces: + ax2.set_xlim(*xlim) for l in ax2.get_xticklabels(): l.set_visible(False) ax2.yaxis.set_major_locator(MaxNLocator(4)) @@ -171,9 +175,10 @@ def _plot(ax, t, d, i): # annotate plot with seed id bbox = dict(boxstyle='round', facecolor='white', alpha=0.8, lw=0) title = '%s traces %s' % (len(stream), _label(stream)) - if show_traces: ax1.annotate(title, (1 - 0.5 * fr, 1 - 0.5 * ft), - xycoords='figure fraction', va='top', ha='right', - bbox=bbox, clip_on=False) + if show_traces: + ax1.annotate(title, (1 - 0.5 * fr, 1 - 0.5 * ft), + xycoords='figure fraction', va='top', ha='right', + bbox=bbox, clip_on=False) # save plot if fname: fig.savefig(fname, dpi=dpi) @@ -373,13 +378,14 @@ def plot_profile(profile, fname=None, figsize=None, dpi=None, else: return fig -def plot_harmonics(hd, hd2=None, fillcolors=('b','r'), trim=None): + +def plot_harmonics(hd, hd2=None, fillcolors=('b', 'r'), trim=None): """Plot components from harmonic decomposition. The plot will have two panels. In all cases the left panel will show - the modeled components of hd. - If hd2 is supplied, the right panel will show the modeled components of hd2. - If hd2 is None and hd has unmodeled components, those will be on the right. + the modeled components of hd. + If hd2 is supplied, the right panel will show the modeled components of hd2. + If hd2 is None and hd has unmodeled components, those will be on the right. If hd2 is None and hd does not have unmodeled components, the right panel will be empty. @@ -398,14 +404,15 @@ def plot_harmonics(hd, hd2=None, fillcolors=('b','r'), trim=None): if hd2: hd2 = hd2.slice2(*trim, reftime='onset') except AttributeError: # if it's obspy.Stream() instead of RFStream(), might still work - warnings.warn('Warning: onset is not in trace stats, so this may not work as expected') - ref0 = max(abs(hd.select(channel='0',location='mod')[0].data)) + warnings.warn( + 'Warning: onset is not in trace stats, so this may not work as expected') + ref0 = max(abs(hd.select(channel='0', location='mod')[0].data)) for tr in hd: if max(abs(tr.data)) > ref0: ref0 = max(abs(tr.data)) mod = hd.select(location='mod') if hd2: - ref1 = max(abs(hd2.select(channel='0',location='mod')[0].data)) + ref1 = max(abs(hd2.select(channel='0', location='mod')[0].data)) for tr in hd2.select(location='mod'): if max(abs(tr.data)) > ref0: ref1 = max(abs(tr.data)) @@ -415,9 +422,9 @@ def plot_harmonics(hd, hd2=None, fillcolors=('b','r'), trim=None): unmod = hd.select(location='unmod') fig = plt.figure(constrained_layout=True) - gs = fig.add_gridspec(1,2) - ax_mod = fig.add_subplot(gs[:,0]) - ax_unmod = fig.add_subplot(gs[:,1],sharey=ax_mod,sharex=ax_mod) + gs = fig.add_gridspec(1, 2) + ax_mod = fig.add_subplot(gs[:, 0]) + ax_unmod = fig.add_subplot(gs[:, 1], sharey=ax_mod, sharex=ax_mod) def _plot(ax, t, d, i): c1, c2 = fillcolors @@ -450,8 +457,8 @@ def _plot(ax, t, d, i): ax_unmod.set_xlabel('Delay time [s]') ax_mod.yaxis.set_ticks(np.arange(5)) - ax_mod.set_yticklabels(['sin($2\\theta$)','cos($2\\theta$)',\ - 'sin($\\theta$)','cos($\\theta$)','constant']) + ax_mod.set_yticklabels(['sin($2\\theta$)', 'cos($2\\theta$)', + 'sin($\\theta$)', 'cos($\\theta$)', 'constant']) ax_unmod.yaxis.tick_right() if trim: ax_mod.set_xlim(trim) # fallback if not trimmed as requested From 43063ad24e19e4fcd3cbd23d2c9d29fd8184d406 Mon Sep 17 00:00:00 2001 From: trichter Date: Thu, 23 May 2024 00:08:56 +0200 Subject: [PATCH 14/17] add notebook4 to markdown readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c70567b..107572c 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,18 @@ 1. Calculate receiver functions - minimal example ([notebook][nb1]) 2. Calculate receiver functions and stack them by common conversion points to create a profile ([notebook][nb2]) 3. Compare different deconvolution methods ([notebook][nb3]) + 4. Harmonic deconvolution with synthetics - minimal example ([notebook][nb4]) [nb1]: https://nbviewer.jupyter.org/github/trichter/notebooks/blob/master/receiver_function_minimal_example.ipynb [nb2]: https://nbviewer.jupyter.org/github/trichter/notebooks/blob/master/receiver_function_profile_chile.ipynb [nb3]: https://nbviewer.jupyter.org/github/hfmark/notebooks/blob/main/rf_comparison.ipynb +[nb4]: https://nbviewer.jupyter.org/github/hfmark/notebooks/blob/main/rf_harmonics.ipynb ##### Get help and discuss: [ObsPy Related Projects category](https://discourse.obspy.org/c/obspy-related-projects/rf/14) in the ObsPy forum ##### Contribute: All contributions are welcome ... e.g. report bugs, discuss or add new features. -For example, the package could profit from more advanced deconvolution techniques. -New deconvolution functions can be tested by just passing them to deconvolve method (see docs). ##### Citation: From 724244cc3aee42e5bce6396a37bf1301f9f1294f Mon Sep 17 00:00:00 2001 From: trichter Date: Thu, 23 May 2024 00:13:11 +0200 Subject: [PATCH 15/17] add changelog --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 0f458d6..af71b32 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +dev: + * add calculation of harmonic decomposition and corresponding plot (see #48) + * update multitaper deconvolution to use the multitaper package instead of outdated mtspec (see #49) + * add option to only plot stacked receiver function (see #48) 1.0.3: * fix: use ax.sharey instead of join method which is unsupported in recent mpl versions 1.0.2: From dc8e517f308ed18f0cf1f6e66b886bf700716df1 Mon Sep 17 00:00:00 2001 From: trichter Date: Thu, 23 May 2024 00:28:22 +0200 Subject: [PATCH 16/17] fix doc links --- rf/imaging.py | 9 ++++++--- rf/rfstream.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/rf/imaging.py b/rf/imaging.py index 033ac14..a03af3a 100644 --- a/rf/imaging.py +++ b/rf/imaging.py @@ -380,7 +380,8 @@ def plot_profile(profile, fname=None, figsize=None, dpi=None, def plot_harmonics(hd, hd2=None, fillcolors=('b', 'r'), trim=None): - """Plot components from harmonic decomposition. + """ + Plot components from harmonic decomposition. The plot will have two panels. In all cases the left panel will show the modeled components of hd. @@ -389,14 +390,16 @@ def plot_harmonics(hd, hd2=None, fillcolors=('b', 'r'), trim=None): If hd2 is None and hd does not have unmodeled components, the right panel will be empty. - :param hd: RFStream of harmonics, returned from rf.harmonics.harmonics() + :param hd: RFStream of harmonics, returned from `rf.harmonics.harmonics()` + or the corresponding `~rf.rfstream.RFStream.harmonics()` method. The stream should contain modeled components, and may also include unmodeled components :param hd2: Optional second RFStream of harmonics. If used, it should contain modeled components :param fillcolors: fill colors for positive and negative wiggles :param trim: trim stream relative to onset before plotting using - `~.rfstream.RFStream.slice2()` + `~rf.rfstream.RFStream.slice2()` + """ if trim: try: diff --git a/rf/rfstream.py b/rf/rfstream.py index 362d282..9e113d0 100644 --- a/rf/rfstream.py +++ b/rf/rfstream.py @@ -434,7 +434,7 @@ def harmonics(self, *args, **kwargs): Perform harmonic decomposition on stream. All args and kwargs are passed to the function - `~rf.harmonics.harmonic()`. + `~rf.harmonics.harmonics()`. """ hd = harmonics(self, *args, **kwargs) return hd From 0cc0ffa20d15fecc3eefafd6e109caae4d9a1242 Mon Sep 17 00:00:00 2001 From: trichter Date: Thu, 23 May 2024 00:37:00 +0200 Subject: [PATCH 17/17] update license --- LICENSE | 4 ++-- rf/harmonics.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index 5b540f9..9627bde 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013-2019 Tom Eulenfeld +Copyright (c) 2013-2024 Tom Eulenfeld, Hannah Mark Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in @@ -17,4 +17,4 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/rf/harmonics.py b/rf/harmonics.py index e23a947..0e121dc 100644 --- a/rf/harmonics.py +++ b/rf/harmonics.py @@ -1,4 +1,4 @@ -# Copyright 2013-2019 Tom Eulenfeld, MIT license +# Copyright 2024 Hannah Mark, MIT license """ Harmonic decomposition """