Skip to content

Commit c71cc5f

Browse files
committed
Clipping detection: sensitivity parameter and distance weighting
Also adds the command line progran `clipping_detection`
1 parent cb7b5aa commit c71cc5f

File tree

5 files changed

+147
-37
lines changed

5 files changed

+147
-37
lines changed

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ Earthquake source parameters from P- or S-wave displacement spectra
2323

2424
### Processing
2525

26-
- New algorithm for clipping detection, not requiring any parameter
26+
- New algorithm for clipping detection, tuned through a sensitivity parameter
27+
(`clipping_sensitivity`) between 0 (no clipping detection) and
28+
5 (max sensitivity)
29+
- The algorithm can also be called from the command line, e.g. for debug
30+
purposes, using the command `clipping_detection`
2731
- Magnitude limits for inversion are now autoset between 90% of the minimum
2832
of the spectral plateau and 110% of its maximum
2933
- Relax noise window requirements if noise weighting is not used.
@@ -66,7 +70,7 @@ Earthquake source parameters from P- or S-wave displacement spectra
6670

6771
- Removed config parameter: `Mw_0_variability`
6872
- Removed config parameter: `clip_max_percent`
69-
- New config parameter: `check_clipping`
73+
- New config parameter: `clipping_sensitivity`
7074
- Config file section `AVERAGES PARAMETERS` renamed to
7175
`SUMMARY STATISTICS PARAMETERS`
7276
- New config parameter: `reference_statistics`

setup.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@
4444
packages=['sourcespec', 'sourcespec.configobj', 'sourcespec.adjustText'],
4545
include_package_data=True,
4646
entry_points={
47-
'console_scripts': ['source_spec = sourcespec.source_spec:main',
48-
'source_model = sourcespec.source_model:main',
49-
'source_residuals = '
50-
'sourcespec.source_residuals:main']
47+
'console_scripts': [
48+
'source_spec = sourcespec.source_spec:main',
49+
'source_model = sourcespec.source_model:main',
50+
'source_residuals = sourcespec.source_residuals:main',
51+
'clipping_detection = sourcespec.clipping_detection:main']
5152
},
5253
version=versioneer.get_version(),
5354
cmdclass=versioneer.get_cmdclass(),

sourcespec/clipping_detection.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# -*- coding: utf-8 -*-
2+
# SPDX-License-Identifier: CECILL-2.1
3+
"""
4+
A trace clipping detector based on kernel density estimation.
5+
6+
:copyright:
7+
2023 Claudio Satriano <[email protected]>
8+
:license:
9+
CeCILL Free Software License Agreement v2.1
10+
(http://www.cecill.info/licences.en.html)
11+
"""
12+
import numpy as np
13+
from scipy.stats import gaussian_kde
14+
from scipy.signal import find_peaks
15+
16+
17+
def is_clipped(trace, sensitivity, debug=False):
18+
"""
19+
Check if a trace is clipped, based on kernel density estimation.
20+
21+
Kernel density estimation is used to find the peaks of the histogram of
22+
the trace data points. The peaks are then weighted by their distance from
23+
the trace average (which should be the most common value).
24+
The peaks with the highest weight are then checked for prominence,
25+
which is a measure of how much higher the peak is than the surrounding
26+
data. The prominence threshold is determined by the sensitivity parameter.
27+
If more than one peak is found, the trace is considered clipped or
28+
distorted.
29+
30+
Parameters
31+
----------
32+
trace : obspy.core.trace.Trace
33+
Trace to check.
34+
sensitivity : int
35+
Sensitivity level, from 1 (least sensitive) to 5 (most sensitive).
36+
debug : bool
37+
If True, plot trace, samples histogram and kernel density.
38+
39+
Returns
40+
-------
41+
bool
42+
True if trace is clipped, False otherwise.
43+
"""
44+
sensitivity = int(sensitivity)
45+
if sensitivity < 1 or sensitivity > 5:
46+
raise ValueError('sensitivity must be between 1 and 5')
47+
trace = trace.copy().detrend('demean')
48+
npts = len(trace.data)
49+
# Compute data histogram with a number of bins equal to 0.5% of data points
50+
nbins = int(npts*0.005)
51+
counts, bins = np.histogram(trace.data, bins=nbins)
52+
counts = counts/np.max(counts)
53+
# Compute gaussian kernel density
54+
kde = gaussian_kde(trace.data, bw_method=0.2)
55+
max_data = np.max(np.abs(trace.data))*1.2
56+
density_points = np.linspace(-max_data, max_data, 100)
57+
density = kde.pdf(density_points)
58+
maxdensity = np.max(density)
59+
density /= maxdensity
60+
# Distance weight, parabolic, between 1 and 5
61+
dist_weight = np.abs(density_points)**2
62+
dist_weight *= 4/dist_weight.max()
63+
dist_weight += 1
64+
density_weight = density*dist_weight
65+
# find peaks with minimum prominence based on clipping sensitivity
66+
min_prominence = [0.1, 0.05, 0.03, 0.02, 0.01]
67+
peaks, _ = find_peaks(
68+
density_weight,
69+
prominence=min_prominence[sensitivity-1]
70+
)
71+
if debug:
72+
import matplotlib.pyplot as plt
73+
fig, ax = plt.subplots(1, 2, figsize=(15, 5), sharey=True)
74+
fig.suptitle(trace.id)
75+
ax[0].plot(trace.times(), trace.data)
76+
ax[0].set_ylim(-max_data, max_data)
77+
ax[0].set_xlabel('Time (s)')
78+
ax[0].set_ylabel('Amplitude')
79+
ax[1].hist(
80+
bins[:-1], bins=len(counts), weights=counts,
81+
orientation='horizontal')
82+
ax[1].plot(density, density_points, label='kernel density')
83+
ax[1].plot(
84+
density_weight, density_points, label='weighted\nkernel density')
85+
ax[1].scatter(
86+
density_weight[peaks], density_points[peaks],
87+
s=100, marker='x', color='red')
88+
ax[1].set_xlabel('Density')
89+
ax[1].legend()
90+
plt.show()
91+
# If more than one peak, then the signal is probably clipped or distorted
92+
if len(peaks) > 1:
93+
return True
94+
else:
95+
return False
96+
97+
98+
def main():
99+
import argparse
100+
from obspy import read
101+
parser = argparse.ArgumentParser(
102+
description='Check if a trace is clipped, '
103+
'based on kernel density estimation of trace samples.')
104+
parser.add_argument(
105+
'infile', type=str,
106+
help='Input file name in any ObsPy supported format')
107+
parser.add_argument(
108+
'-s', '--sensitivity', type=int, default=3,
109+
help='Sensitivity level, from 1 (least sensitive) '
110+
'to 5 (most sensitive)')
111+
parser.add_argument(
112+
'-d', '--debug', action='store_true',
113+
help='If set, plot trace, samples histogram and kernel density')
114+
args = parser.parse_args()
115+
st = read(args.infile)
116+
for tr in st:
117+
print(tr.id, is_clipped(tr, args.sensitivity, args.debug))
118+
119+
120+
if __name__ == '__main__':
121+
import sys
122+
try:
123+
main()
124+
except Exception as msg:
125+
sys.exit(msg)
126+
except KeyboardInterrupt:
127+
sys.exit()

sourcespec/configspec.conf

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,9 @@ rmsmin = float(min=0, default=0)
212212
# Time domain S/N ratio min
213213
sn_min = float(min=0, default=0)
214214

215-
# Check for trace clipping
216-
check_clipping = boolean(default=True)
215+
# Sensitivity for clipping detection, integer value
216+
# between 0 (no clipping detection) and 5 (max sensitivity)
217+
clipping_sensitivity = integer(min=0, max=5, default=3)
217218

218219
# Maximum gap length for the whole trace, in seconds
219220
gap_max = float(min=0, default=None)

sourcespec/ssp_process_traces.py

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,12 @@
1717
import logging
1818
import numpy as np
1919
import re
20-
from scipy.ndimage.filters import gaussian_filter
21-
from scipy.signal import find_peaks
2220
from obspy.core import Stream
2321
from obspy.core.util import AttribDict
2422
from sourcespec.ssp_setup import ssp_exit
2523
from sourcespec.ssp_util import remove_instr_response, hypo_dist
2624
from sourcespec.ssp_wave_arrival import add_arrivals_to_trace
25+
from sourcespec.clipping_detection import is_clipped
2726
logger = logging.getLogger(__name__.split('.')[-1])
2827

2928

@@ -73,40 +72,18 @@ def _check_signal_level(config, trace):
7372

7473
def _check_clipping(config, trace):
7574
trace.stats.clipped = False
76-
if not config.check_clipping:
75+
if config.clipping_sensitivity == 0:
7776
return
78-
t1 = trace.stats.arrivals['N1'][1]
77+
# cut the trace between the end of noise window
78+
# and the end of the signal window
79+
t1 = trace.stats.arrivals['N2'][1]
7980
if config.wave_type[0] == 'S':
8081
t2 = trace.stats.arrivals['S2'][1]
8182
elif config.wave_type[0] == 'P':
8283
t2 = trace.stats.arrivals['P2'][1]
8384
t2 = (trace.stats.arrivals['S'][1] + config.win_length)
8485
tr = trace.copy().trim(t1, t2).detrend('demean')
85-
npts = len(tr.data)
86-
# Compute data histogram with a number of bins equal to 0.5% of data points
87-
nbins = int(npts*0.005)
88-
counts, bins = np.histogram(tr.data, bins=nbins)
89-
counts_smooth = gaussian_filter(counts, sigma=1.5)
90-
# Find peaks in counts_smooth which are at least 2% of max peak
91-
maxcounts = np.max(counts_smooth)
92-
peaks, _ = find_peaks(
93-
counts_smooth,
94-
height=0.02*maxcounts,
95-
)
96-
# The following code is for debug purposes
97-
# import matplotlib.pyplot as plt
98-
# fig, ax = plt.subplots(1, 2, figsize=(15, 5), sharey=True)
99-
# fig.suptitle(tr.id)
100-
# ax[0].plot(tr.times(), tr.data)
101-
# ax[1].hist(
102-
# bins[:-1], bins=len(counts), weights=counts,
103-
# orientation='horizontal')
104-
# ax[1].plot(counts_smooth, bins[:-1])
105-
# ax[1].scatter(
106-
# counts_smooth[peaks], bins[peaks], s=100, marker='x', color='red')
107-
# plt.show()
108-
# If more than one peak, then the signal is probably clipped or distorted
109-
if len(peaks) > 1:
86+
if is_clipped(tr, config.clipping_sensitivity):
11087
trace.stats.clipped = True
11188
trace.stats.ignore = True
11289
trace.stats.ignore_reason = 'distorted'

0 commit comments

Comments
 (0)