pyckster 25.10.1__tar.gz → 25.12.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {pyckster-25.10.1 → pyckster-25.12.1}/CHANGES.md +4 -0
  2. {pyckster-25.10.1/pyckster.egg-info → pyckster-25.12.1}/PKG-INFO +1 -1
  3. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/__init__.py +1 -1
  4. pyckster-25.12.1/pyckster/auto_picking.py +407 -0
  5. pyckster-25.12.1/pyckster/core.py +16416 -0
  6. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/inversion_app.py +381 -8
  7. pyckster-25.12.1/pyckster/ipython_console.py +261 -0
  8. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/obspy_utils.py +75 -6
  9. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/pyqtgraph_utils.py +12 -1
  10. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/sw_utils.py +10 -5
  11. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/visualization_utils.py +203 -38
  12. {pyckster-25.10.1 → pyckster-25.12.1/pyckster.egg-info}/PKG-INFO +1 -1
  13. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster.egg-info/SOURCES.txt +2 -0
  14. pyckster-25.10.1/pyckster/core.py +0 -7817
  15. {pyckster-25.10.1 → pyckster-25.12.1}/LICENCE +0 -0
  16. {pyckster-25.10.1 → pyckster-25.12.1}/MANIFEST.in +0 -0
  17. {pyckster-25.10.1 → pyckster-25.12.1}/README.md +0 -0
  18. {pyckster-25.10.1 → pyckster-25.12.1}/images/pyckster.png +0 -0
  19. {pyckster-25.10.1 → pyckster-25.12.1}/images/pyckster.svg +0 -0
  20. {pyckster-25.10.1 → pyckster-25.12.1}/images/screenshot_01.png +0 -0
  21. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/__main__.py +0 -0
  22. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/bayesian_inversion.py +0 -0
  23. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/inversion_manager.py +0 -0
  24. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/inversion_visualizer.py +0 -0
  25. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/pac_inversion.py +0 -0
  26. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/surface_wave_analysis.py +0 -0
  27. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/surface_wave_profiling.py +0 -0
  28. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster/tab_factory.py +0 -0
  29. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster.egg-info/dependency_links.txt +0 -0
  30. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster.egg-info/entry_points.txt +0 -0
  31. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster.egg-info/not-zip-safe +0 -0
  32. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster.egg-info/requires.txt +0 -0
  33. {pyckster-25.10.1 → pyckster-25.12.1}/pyckster.egg-info/top_level.txt +0 -0
  34. {pyckster-25.10.1 → pyckster-25.12.1}/setup.cfg +0 -0
  35. {pyckster-25.10.1 → pyckster-25.12.1}/setup.py +0 -0
@@ -1,3 +1,7 @@
1
+ v25.12.1, Dec. 2025 -- Major UI modification, improve loading picks logic, debug ipython consol, fix trace editing
2
+
3
+ v25.11.1, Nov. 2025 -- Add autopicking, add spectro and dispersion views, minor fixes and improvements
4
+
1
5
  v25.10.1, Oct. 2025 -- Add bottom menu and coord display, fix picks loading (partly - still behaves weirdly with roll alongs), other small fixes
2
6
 
3
7
  v25.9.4, Sep. 2025 -- Fix batch import and edit, fix view, add help, try icons
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyckster
3
- Version: 25.10.1
3
+ Version: 25.12.1
4
4
  Summary: A PyQt5-based GUI for picking seismic traveltimes
5
5
  Home-page: https://gitlab.in2p3.fr/metis-geophysics/pyckster
6
6
  Author: Sylvain Pasquet
@@ -15,7 +15,7 @@ except ImportError:
15
15
  pass # matplotlib not available, that's fine
16
16
 
17
17
  # Define version and metadata in one place
18
- __version__ = "25.10.1"
18
+ __version__ = "25.12.1"
19
19
  __author__ = "Sylvain Pasquet"
20
20
  __email__ = "sylvain.pasquet@sorbonne-universite.fr"
21
21
  __license__ = "GPLv3"
@@ -0,0 +1,407 @@
1
+ import numpy as np
2
+ from scipy import signal, stats
3
+ import matplotlib.pyplot as plt
4
+ from obspy import Stream, Trace
5
+
6
+ from .obspy_utils import read_seismic_file
7
+
8
+ def mnw_picker(trace, Td, beta=0.005):
9
+ """
10
+ Implements the Multi-Nested Windows (MNW) method to detect first arrivals.
11
+
12
+ Args:
13
+ trace (obspy.Trace): Seismic trace.
14
+ Td (float): Dominant period of the P-wave (in seconds).
15
+ beta (float): Constant to avoid numerical instability.
16
+
17
+ Returns:
18
+ tuple: (BPZ, tP1, tE1)
19
+ - BPZ (float): Beginning of the potential zone (in seconds).
20
+ - tP1 (float): Potential arrival time (in seconds).
21
+ - tE1 (float): Associated error (in seconds).
22
+ """
23
+ data = trace.data
24
+ fs = trace.stats.sampling_rate
25
+ N = len(data)
26
+
27
+ # Window lengths in samples
28
+ Lb = int(4 * Td * fs)
29
+ La = int(Td * fs)
30
+ d = int(0.6 * Td * fs)
31
+ Ld = int((1 - 0.6) * Td * fs)
32
+
33
+ # Initialize arrays for BEA, AEA, DEA
34
+ BEA = np.zeros(N)
35
+ AEA = np.zeros(N)
36
+ DEA = np.zeros(N)
37
+
38
+ # Calculate energies
39
+ for t in range(N):
40
+ # BEA calculation with bounds checking
41
+ if (t - Lb) >= 0:
42
+ window = data[max(0, t - Lb):t]
43
+ else:
44
+ window = data[:t]
45
+
46
+ if len(window) > 0:
47
+ BEA[t] = np.mean(window ** 2)
48
+ else:
49
+ BEA[t] = beta
50
+
51
+ # AEA calculation
52
+ aea_window = data[t:min(t + La, N)]
53
+ if len(aea_window) > 0:
54
+ AEA[t] = np.mean(aea_window ** 2)
55
+ else:
56
+ AEA[t] = beta
57
+
58
+ # DEA calculation
59
+ if (t + d) < N:
60
+ dea_window = data[min(t + d, N):min(t + d + Ld, N)]
61
+ if len(dea_window) > 0:
62
+ DEA[t] = np.mean(dea_window ** 2)
63
+ else:
64
+ DEA[t] = 0
65
+ else:
66
+ DEA[t] = 0
67
+
68
+ # Avoid division by zero
69
+ BEA[BEA == 0] = beta
70
+
71
+ # Calculate energy ratios
72
+ ER1 = AEA / (BEA + beta)
73
+ ER2 = DEA / (BEA + beta)
74
+ CF_mnw = ER1 + ER2
75
+
76
+ # Smooth CF_mnw
77
+ window_length = int(0.5 * Td * fs)
78
+ if window_length % 2 == 0:
79
+ window_length += 1 # Ensure odd length for savgol_filter
80
+
81
+ # Ensure window length is valid
82
+ window_length = max(3, min(window_length, len(CF_mnw)))
83
+ if window_length % 2 == 0:
84
+ window_length -= 1
85
+
86
+ if window_length >= 3 and len(CF_mnw) >= window_length:
87
+ SCF_mnw = signal.savgol_filter(CF_mnw, window_length, 2)
88
+ else:
89
+ # Fallback to simple smoothing if savgol_filter can't be applied
90
+ SCF_mnw = CF_mnw.copy()
91
+
92
+ # Dynamic threshold
93
+ sigma = np.std(CF_mnw[:int(0.5 * N)])
94
+ Thr = 2 + 3 * sigma
95
+
96
+ # Detect BPZ (beginning of the potential zone)
97
+ BPZ_indices = np.where(SCF_mnw > Thr)[0]
98
+ BPZ = BPZ_indices[0] / fs if len(BPZ_indices) > 0 else 0
99
+
100
+ # Search for local maxima after BPZ
101
+ search_window = int(1.5 * Td * fs)
102
+ start_search = int(BPZ * fs)
103
+ end_search = min(start_search + search_window, N)
104
+ local_max_indices = signal.argrelextrema(SCF_mnw[start_search:end_search], np.greater)[0]
105
+ local_max_values = SCF_mnw[start_search:end_search][local_max_indices]
106
+
107
+ if len(local_max_values) > 0:
108
+ tP1_index = start_search + local_max_indices[np.argmax(local_max_values)]
109
+ tP1 = tP1_index / fs
110
+ else:
111
+ tP1 = BPZ
112
+
113
+ # Estimate error
114
+ if len(local_max_indices) > 1:
115
+ tE1 = max(
116
+ abs(BPZ - (start_search + local_max_indices[0]) / fs),
117
+ abs((start_search + local_max_indices[0] - start_search + local_max_indices[1]) / fs),
118
+ )
119
+ else:
120
+ tE1 = abs(BPZ - tP1)
121
+
122
+ return BPZ, tP1, tE1
123
+
124
+ def hos_picker(trace, tP1, tE1, Td):
125
+ """
126
+ Implements the Higher Order Statistics (HOS) method using kurtosis to refine picking.
127
+
128
+ Args:
129
+ trace (obspy.Trace): Seismic trace.
130
+ tP1 (float): Potential arrival time from MNW (in seconds).
131
+ tE1 (float): Error associated with tP1 (in seconds).
132
+ Td (float): Dominant period of the P-wave (in seconds).
133
+
134
+ Returns:
135
+ tuple: (tP2, tE2)
136
+ - tP2 (float): Potential arrival time (in seconds).
137
+ - tE2 (float): Associated error (in seconds).
138
+ """
139
+ data = trace.data
140
+ fs = trace.stats.sampling_rate
141
+
142
+ # Window size for kurtosis
143
+ Nk = max(int(2 * tE1 * fs), 1)
144
+ Nk = min(Nk, int(2 * Td * fs))
145
+
146
+ # Calculate kurtosis
147
+ CF_k = np.zeros(len(data))
148
+ for t in range(Nk, len(data)):
149
+ window = data[t - Nk : t]
150
+ if len(window) >= 3: # Need at least 3 points for meaningful kurtosis
151
+ try:
152
+ CF_k[t] = stats.kurtosis(window, fisher=True)
153
+ # Handle potential NaN or infinite values
154
+ if not np.isfinite(CF_k[t]):
155
+ CF_k[t] = 0.0
156
+ except Exception:
157
+ CF_k[t] = 0.0
158
+ else:
159
+ CF_k[t] = 0.0
160
+
161
+ # Smooth CF_k
162
+ window_length = int(0.5 * Td * fs)
163
+ if window_length % 2 == 0:
164
+ window_length += 1
165
+
166
+ # Ensure window length is valid
167
+ window_length = max(3, min(window_length, len(CF_k)))
168
+ if window_length % 2 == 0:
169
+ window_length -= 1
170
+
171
+ if window_length >= 3 and len(CF_k) >= window_length:
172
+ SCF_k = signal.savgol_filter(CF_k, window_length, 2)
173
+ else:
174
+ SCF_k = CF_k.copy()
175
+
176
+ # Baillard transformation
177
+ SCF_Bk = -SCF_k
178
+
179
+ # Search for local minimum around tP1
180
+ search_window = int(tE1 * fs + Td * fs)
181
+ start_idx = max(0, int((tP1 - tE1) * fs))
182
+ end_idx = min(len(data), int((tP1 + Td) * fs))
183
+
184
+ # Ensure valid search window
185
+ if start_idx >= end_idx or end_idx > len(SCF_Bk):
186
+ tP2 = tP1 # Fallback to original pick
187
+ tE2 = tE1
188
+ return tP2, tE2
189
+
190
+ search_window_data = SCF_Bk[start_idx:end_idx]
191
+ if len(search_window_data) == 0:
192
+ tP2 = tP1
193
+ tE2 = tE1
194
+ return tP2, tE2
195
+
196
+ local_min_index = start_idx + np.argmin(search_window_data)
197
+ tP2 = local_min_index / fs
198
+
199
+ # Calculate error more robustly
200
+ try:
201
+ cf_search_window = CF_k[start_idx:end_idx]
202
+ if len(cf_search_window) > 0:
203
+ max_idx = np.argmax(cf_search_window)
204
+ tE2 = abs(max_idx / fs - (local_min_index - start_idx) / fs)
205
+ else:
206
+ tE2 = tE1
207
+ except Exception:
208
+ tE2 = tE1
209
+
210
+ return tP2, tE2
211
+
212
+ def aic_picker(trace, tP1, tP2, tE1, tE2):
213
+ """
214
+ Implements the Akaike Information Criterion (AIC) method to refine picking.
215
+
216
+ Args:
217
+ trace (obspy.Trace): Seismic trace.
218
+ tP1 (float): Potential arrival time from MNW (in seconds).
219
+ tP2 (float): Potential arrival time from HOS (in seconds).
220
+ tE1 (float): Error associated with tP1 (in seconds).
221
+ tE2 (float): Error associated with tP2 (in seconds).
222
+
223
+ Returns:
224
+ tuple: (tP3, tE3)
225
+ - tP3 (float): Potential arrival time (in seconds).
226
+ - tE3 (float): Associated error (in seconds).
227
+ """
228
+ data = trace.data
229
+ fs = trace.stats.sampling_rate
230
+
231
+ # Search window for AIC
232
+ SW3 = 2 * max(tE1, tE2)
233
+ center = 0.5 * (tP1 + tP2)
234
+ start_idx = max(0, int((center - SW3 / 2) * fs))
235
+ end_idx = min(len(data), int((center + SW3 / 2) * fs))
236
+
237
+ # Calculate AIC function
238
+ CF_aic = np.zeros(len(data))
239
+ for t in range(1, len(data) - 1):
240
+ if t > 0 and t < len(data): # Ensure valid indices
241
+ var1 = np.var(data[:t])
242
+ var2 = np.var(data[t:])
243
+
244
+ # Add small epsilon to avoid log(0) and handle constant signals
245
+ epsilon = 1e-10
246
+ var1 = max(var1, epsilon)
247
+ var2 = max(var2, epsilon)
248
+
249
+ CF_aic[t] = t * np.log(var1) + (len(data) - t) * np.log(var2)
250
+
251
+ # Normalize Akaike weights
252
+ if start_idx >= end_idx or end_idx > len(CF_aic):
253
+ # Invalid range, return center of search window
254
+ tP3 = center
255
+ tE3 = SW3 / 4 # Default error
256
+ return tP3, tE3
257
+
258
+ aic_window = CF_aic[start_idx:end_idx]
259
+ if len(aic_window) == 0:
260
+ tP3 = center
261
+ tE3 = SW3 / 4
262
+ return tP3, tE3
263
+
264
+ min_CF_aic = np.min(aic_window)
265
+ Delta = aic_window - min_CF_aic
266
+
267
+ # Handle potential numerical issues
268
+ exp_values = np.exp(-Delta / 2)
269
+ sum_exp = np.sum(exp_values)
270
+
271
+ if sum_exp == 0 or not np.isfinite(sum_exp):
272
+ # Fallback to uniform weights
273
+ AW = np.ones(len(aic_window)) / len(aic_window)
274
+ else:
275
+ AW = exp_values / sum_exp
276
+
277
+ # Calculate weighted average arrival time
278
+ indices = np.arange(start_idx, end_idx)
279
+ if len(indices) == 0 or len(AW) == 0:
280
+ tP3 = center
281
+ tE3 = SW3 / 4
282
+ return tP3, tE3
283
+
284
+ sum_weights = np.sum(AW)
285
+ if sum_weights == 0:
286
+ tP3 = center
287
+ tE3 = SW3 / 4
288
+ return tP3, tE3
289
+
290
+ tP3 = np.sum(AW * indices / fs) / sum_weights
291
+
292
+ # Estimate error
293
+ threshold = 0.1 * np.max(AW)
294
+ error_indices = np.where(AW > threshold)[0]
295
+ tE3 = (error_indices[-1] - error_indices[0]) / fs if len(error_indices) > 0 else 0
296
+
297
+ return tP3, tE3
298
+
299
+ def calculate_snr(trace, pick_time, Td):
300
+ """
301
+ Calculates the signal-to-noise ratio (SNR) around an arrival time.
302
+
303
+ Args:
304
+ trace (obspy.Trace): Seismic trace.
305
+ pick_time (float): Arrival time (in seconds).
306
+ Td (float): Dominant period of the P-wave (in seconds).
307
+
308
+ Returns:
309
+ float: Signal-to-noise ratio in dB.
310
+ """
311
+ fs = trace.stats.sampling_rate
312
+ noise_window_start = max(0, int((pick_time - 3 * Td) * fs))
313
+ noise_window_end = int(pick_time * fs)
314
+ signal_window_start = int(pick_time * fs)
315
+ signal_window_end = min(len(trace.data), int((pick_time + Td) * fs))
316
+
317
+ # Ensure valid windows
318
+ if noise_window_end <= noise_window_start:
319
+ noise_window_end = noise_window_start + max(1, int(0.1 * Td * fs))
320
+
321
+ if signal_window_end <= signal_window_start:
322
+ signal_window_end = signal_window_start + max(1, int(0.1 * Td * fs))
323
+
324
+ # Extract windows and calculate RMS
325
+ noise_data = trace.data[noise_window_start:min(noise_window_end, len(trace.data))]
326
+ signal_data = trace.data[signal_window_start:min(signal_window_end, len(trace.data))]
327
+
328
+ if len(noise_data) == 0 or len(signal_data) == 0:
329
+ return 0 # Cannot calculate SNR
330
+
331
+ noise_rms = np.sqrt(np.mean(noise_data ** 2))
332
+ signal_rms = np.sqrt(np.mean(signal_data ** 2))
333
+
334
+ # Add small epsilon to avoid division by zero
335
+ epsilon = 1e-12
336
+ noise_rms = max(noise_rms, epsilon)
337
+
338
+ if signal_rms > 0:
339
+ return 20 * np.log10(signal_rms / noise_rms)
340
+ else:
341
+ return 0
342
+
343
+ def adaptive_picker(trace, Td):
344
+ """
345
+ Integrates the three methods (MNW, HOS, AIC) for adaptive picking.
346
+
347
+ Args:
348
+ trace (obspy.Trace): Seismic trace.
349
+ Td (float): Dominant period of the P-wave (in seconds).
350
+
351
+ Returns:
352
+ tuple: (tP_final, tE_final)
353
+ - tP_final (float): Final arrival time (in seconds).
354
+ - tE_final (float): Associated error (in seconds).
355
+ """
356
+ # Validate input
357
+ if trace is None or len(trace.data) == 0 or Td <= 0:
358
+ return None, None
359
+
360
+ try:
361
+ BPZ, tP1, tE1 = mnw_picker(trace, Td)
362
+ tP2, tE2 = hos_picker(trace, tP1, tE1, Td)
363
+ tP3, tE3 = aic_picker(trace, tP1, tP2, tE1, tE2)
364
+ except Exception:
365
+ # If any picker fails, return None
366
+ return None, None
367
+
368
+ # Check for NaN or invalid values
369
+ picks = [tP1, tP2, tP3]
370
+ valid_picks = []
371
+ valid_Qs = []
372
+
373
+ for i, pick in enumerate(picks):
374
+ if (pick is not None and not np.isnan(pick) and
375
+ np.isfinite(pick) and pick >= 0 and
376
+ pick < len(trace.data) / trace.stats.sampling_rate):
377
+ try:
378
+ Q = calculate_snr(trace, pick, Td)
379
+ if Q > -50 and np.isfinite(Q): # Reasonable SNR range
380
+ valid_picks.append(pick)
381
+ valid_Qs.append(max(0.1, Q)) # Ensure positive weight
382
+ except Exception:
383
+ continue
384
+
385
+ if len(valid_picks) == 0:
386
+ return None, None
387
+
388
+ # Calculate weighted average with robust error handling
389
+ try:
390
+ if len(valid_Qs) > 0 and all(w > 0 for w in valid_Qs):
391
+ tP_final = np.average(valid_picks, weights=valid_Qs)
392
+ else:
393
+ tP_final = np.mean(valid_picks)
394
+
395
+ if len(valid_picks) > 1:
396
+ tE_final = np.std(valid_picks)
397
+ else:
398
+ tE_final = Td / 4 # Default error based on dominant period
399
+
400
+ # Validate final result
401
+ if not np.isfinite(tP_final) or tP_final < 0:
402
+ return None, None
403
+
404
+ except Exception:
405
+ return None, None
406
+
407
+ return tP_final, tE_final