pyckster 26.1.3__py3-none-any.whl → 26.1.4__py3-none-any.whl

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.
pyckster/core.py CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python
2
2
  # -*- coding: utf-8 -*-
3
3
  """
4
- This script is a PyQt5 GUI for picking seismic traveltimes.
4
+ This script is a PyQt5 GUI for the processing and analysis of active near-surface seismic data
5
5
  Copyright (C) 2026 Sylvain Pasquet
6
6
  Email: sylvain.pasquet@sorbonne-universite.fr
7
7
 
@@ -49,6 +49,7 @@ from PyQt5.QtWidgets import (
49
49
  QFileDialog, QAction, QActionGroup, QLabel, QListWidget, QComboBox, QStatusBar, QScrollArea,QProgressDialog,
50
50
  QPushButton, QDialog, QHBoxLayout, QVBoxLayout, QLineEdit, QCheckBox, QTableWidget, QTableWidgetItem, QMessageBox,
51
51
  QDoubleSpinBox, QFrame, QFormLayout, QDialogButtonBox, QGroupBox, QSpinBox, QGraphicsRectItem, QSlider, QStyle,
52
+ QStackedLayout,
52
53
  QTextEdit,
53
54
  )
54
55
  from PyQt5.QtCore import QLocale, pyqtSignal, Qt
@@ -1896,6 +1897,13 @@ class MainWindow(QMainWindow):
1896
1897
  self.dispersionShowCrosshairCheck.toggled.connect(self.onPickingShowCrosshairChanged)
1897
1898
  dispersionPicksLayout.addWidget(self.dispersionShowCrosshairCheck)
1898
1899
 
1900
+ # Show Aliasing Limit checkbox
1901
+ self.dispersionShowAliasingLimitCheck = QCheckBox("Show Aliasing Limit")
1902
+ self.dispersionShowAliasingLimitCheck.setChecked(getattr(self, 'dispersion_show_aliasing_limit', False))
1903
+ self.dispersionShowAliasingLimitCheck.setToolTip("Show spatial aliasing limits (energy below dispersion)")
1904
+ self.dispersionShowAliasingLimitCheck.toggled.connect(self.onDispersionShowAliasingLimitChanged)
1905
+ dispersionPicksLayout.addWidget(self.dispersionShowAliasingLimitCheck)
1906
+
1899
1907
  dispersionPicksLayout.addStretch()
1900
1908
 
1901
1909
  # Create top view selector controls panel
@@ -2971,7 +2979,8 @@ class MainWindow(QMainWindow):
2971
2979
  for attr in ['topPseudosectionModeSpinBox', 'bottomPseudosectionModeSpinBox',
2972
2980
  'topPseudosectionFreqLambdaCheck', 'bottomPseudosectionFreqLambdaCheck',
2973
2981
  'topPseudosectionInterpCheck', 'bottomPseudosectionInterpCheck',
2974
- 'topPseudosectionInvertYCheck', 'bottomPseudosectionInvertYCheck']:
2982
+ 'topPseudosectionInvertYCheck', 'bottomPseudosectionInvertYCheck',
2983
+ 'topPseudosectionUseSourceXCheck', 'bottomPseudosectionUseSourceXCheck']:
2975
2984
  if hasattr(self, attr):
2976
2985
  setattr(self, attr, None)
2977
2986
  except Exception:
@@ -3473,12 +3482,31 @@ class MainWindow(QMainWindow):
3473
3482
  default_colormap = getattr(self, colormap_attr, 'plasma')
3474
3483
  colormap_combo.setCurrentText(default_colormap)
3475
3484
 
3476
- # Connect handler after setting initial value
3485
+ # Reverse colormap checkbox (created before connecting combo handler)
3486
+ reverse_cmap_check = QCheckBox("Reverse")
3487
+ reverse_cmap_check.setToolTip("Reverse colormap direction (use _r version)")
3488
+
3489
+ # Connect handler with reverse logic
3477
3490
  colormap_handler = f'onTopColormapChanged' if prefix == 'top' else 'onBottomColormapChanged'
3478
- colormap_combo.currentTextChanged.connect(getattr(self, colormap_handler))
3491
+ def on_colormap_changed(cmap_name):
3492
+ # Apply _r suffix if reverse is checked
3493
+ if reverse_cmap_check.isChecked():
3494
+ getattr(self, colormap_handler)(cmap_name + '_r')
3495
+ else:
3496
+ getattr(self, colormap_handler)(cmap_name)
3497
+ colormap_combo.currentTextChanged.connect(on_colormap_changed)
3479
3498
 
3480
3499
  layout.addWidget(colormap_combo)
3481
3500
  setattr(self, f'{prefix}ColormapComboBox', colormap_combo)
3501
+
3502
+ # Reverse checkbox toggle behavior
3503
+ def toggle_reverse_cmap(checked):
3504
+ current = colormap_combo.currentText()
3505
+ # Trigger colormap change with current selection
3506
+ on_colormap_changed(current)
3507
+ reverse_cmap_check.toggled.connect(toggle_reverse_cmap)
3508
+ layout.addWidget(reverse_cmap_check)
3509
+ setattr(self, f'{prefix}ReverseColormapCheck', reverse_cmap_check)
3482
3510
 
3483
3511
  # Traveltimes: no additional dynamic controls beyond colormap
3484
3512
  if view_type == "Traveltimes":
@@ -3522,6 +3550,13 @@ class MainWindow(QMainWindow):
3522
3550
  invert_y_check.toggled.connect(self.onPseudoSectionInvertYToggled)
3523
3551
  layout.addWidget(invert_y_check)
3524
3552
  setattr(self, f'{prefix}PseudosectionInvertYCheck', invert_y_check)
3553
+
3554
+ # Use source position / FFID on X-axis
3555
+ use_source_x_check = QCheckBox("Use source / FFID on X")
3556
+ use_source_x_check.setChecked(getattr(self, 'pseudosection_use_source_x', False))
3557
+ use_source_x_check.toggled.connect(self.onPseudoSectionUseSourceXToggled)
3558
+ layout.addWidget(use_source_x_check)
3559
+ setattr(self, f'{prefix}PseudosectionUseSourceXCheck', use_source_x_check)
3525
3560
 
3526
3561
  add_separator()
3527
3562
  layout.addStretch()
@@ -3625,9 +3660,31 @@ class MainWindow(QMainWindow):
3625
3660
  seismo_colormap_combo = QComboBox()
3626
3661
  seismo_colormap_combo.addItems(['viridis', 'plasma', 'inferno', 'magma', 'cividis', 'turbo', 'hot', 'cool', 'coolwarm', 'seismic', 'RdBu', 'spring', 'summer', 'autumn', 'winter', 'gray', 'bone', 'copper', 'Blues', 'Reds', 'Greens', 'Oranges', 'Purples', 'Greys', 'jet', 'rainbow', 'pink', 'terrain', 'ocean'])
3627
3662
  seismo_colormap_combo.setCurrentText(getattr(self, 'seismogram_colormap_str', 'Greys'))
3628
- seismo_colormap_combo.currentTextChanged.connect(self.onSeismoColormapChanged)
3663
+
3664
+ # Reverse colormap checkbox (created before connecting combo handler)
3665
+ reverse_seismo_cmap_check = QCheckBox("Reverse")
3666
+ reverse_seismo_cmap_check.setToolTip("Reverse colormap direction (use _r version)")
3667
+
3668
+ # Connect handler with reverse logic
3669
+ def on_seismo_colormap_changed(cmap_name):
3670
+ # Apply _r suffix if reverse is checked
3671
+ if reverse_seismo_cmap_check.isChecked():
3672
+ self.onSeismoColormapChanged(cmap_name + '_r')
3673
+ else:
3674
+ self.onSeismoColormapChanged(cmap_name)
3675
+ seismo_colormap_combo.currentTextChanged.connect(on_seismo_colormap_changed)
3676
+
3629
3677
  layout.addWidget(seismo_colormap_combo)
3630
3678
  setattr(self, 'seismoImageColormapCombo', seismo_colormap_combo)
3679
+
3680
+ # Reverse checkbox toggle behavior
3681
+ def toggle_reverse_seismo_cmap(checked):
3682
+ current = seismo_colormap_combo.currentText()
3683
+ # Trigger colormap change with current selection
3684
+ on_seismo_colormap_changed(current)
3685
+ reverse_seismo_cmap_check.toggled.connect(toggle_reverse_seismo_cmap)
3686
+ layout.addWidget(reverse_seismo_cmap_check)
3687
+ setattr(self, 'seismoReverseColormapCheck', reverse_seismo_cmap_check)
3631
3688
 
3632
3689
  interp_check = QCheckBox("Interpolate")
3633
3690
  interp_check.setChecked(getattr(self, 'seismo_image_interpolate', True))
@@ -3756,6 +3813,29 @@ class MainWindow(QMainWindow):
3756
3813
  use_topo_check.toggled.connect(self.onUseTopographyChanged)
3757
3814
  layout.addWidget(use_topo_check)
3758
3815
  setattr(self, 'useTopographyCheck', use_topo_check)
3816
+
3817
+ # Enhance checkbox for better mode visibility
3818
+ enhance_check = QCheckBox("Enhance")
3819
+ enhance_check.setChecked(bool(getattr(self, 'dispersion_enhance', False)))
3820
+ enhance_check.setToolTip("Enhance weak modes via contrast transformation (makes weak modes more visible)")
3821
+ enhance_check.toggled.connect(self.onDispersionEnhanceChanged)
3822
+ layout.addWidget(enhance_check)
3823
+ setattr(self, 'dispersionEnhanceCheck', enhance_check)
3824
+
3825
+ # Saturation slider for clipping bright spots
3826
+ layout.addWidget(QLabel("Saturation:"))
3827
+ saturation_spin = QDoubleSpinBox()
3828
+ saturation_spin.setRange(0.5, 1.5)
3829
+ saturation_spin.setDecimals(2)
3830
+ saturation_spin.setSingleStep(0.01)
3831
+ saturation_spin.setValue(getattr(self, 'dispersion_saturation', 1.0))
3832
+ saturation_spin.setToolTip("Colormap brightness: <1 darkens, 1=normal, >1 brightens (1.25 ≈ MATLAB brighten(0.5))")
3833
+ saturation_spin.setMinimumWidth(60)
3834
+ saturation_spin.setMaximumWidth(70)
3835
+ saturation_spin.valueChanged.connect(self.onDispersionSaturationChanged)
3836
+ layout.addWidget(saturation_spin)
3837
+ setattr(self, 'dispersionSaturationSpin', saturation_spin)
3838
+
3759
3839
 
3760
3840
  # Permute axes button (Spectrogram only)
3761
3841
  if view_type == "Spectrogram":
@@ -3841,14 +3921,15 @@ class MainWindow(QMainWindow):
3841
3921
 
3842
3922
  add_separator()
3843
3923
 
3924
+ # Trace selection for phase-shift using range slider
3925
+ trace_range_container = QHBoxLayout()
3926
+
3844
3927
  batch_window_button = QPushButton("Batch windowing")
3845
3928
  batch_window_button.setToolTip("Set fixed window parameters for all shots")
3846
3929
  batch_window_button.clicked.connect(self.onDispersionBatchWindowing)
3847
3930
  trace_range_container.addWidget(batch_window_button)
3848
3931
  trace_range_container.addStretch()
3849
3932
 
3850
- # Trace selection for phase-shift using range slider
3851
- trace_range_container = QHBoxLayout()
3852
3933
  trace_range_label = QLabel("Trace range:")
3853
3934
  trace_range_container.addWidget(trace_range_label)
3854
3935
 
@@ -3870,6 +3951,32 @@ class MainWindow(QMainWindow):
3870
3951
  layout.addWidget(trace_range_slider)
3871
3952
  # Use view-specific attribute name for the slider itself (top_traceRangeSlider or bottom_traceRangeSlider)
3872
3953
  setattr(self, f'{prefix}_traceRangeSlider', trace_range_slider)
3954
+
3955
+ add_separator()
3956
+
3957
+ # Add stack checkbox for dispersion view (after trace range slider)
3958
+ stack_checkbox_container = QWidget()
3959
+ stack_checkbox_layout = QHBoxLayout(stack_checkbox_container)
3960
+ stack_checkbox_layout.setContentsMargins(0, 0, 0, 0)
3961
+ stack_checkbox_layout.setSpacing(4)
3962
+ stack_checkbox_layout.addStretch()
3963
+
3964
+ # Use view-specific checkbox reference so we can keep state across top/bottom swaps
3965
+ stack_checkbox = QCheckBox("Stack all shots")
3966
+ stack_checkbox.setChecked(getattr(self, 'dispersion_stack_mode', False))
3967
+ stack_checkbox.setToolTip("Sum dispersion images from all shots with traces in current window range")
3968
+ stack_checkbox.stateChanged.connect(self.onDispersionStackToggled)
3969
+ stack_checkbox_layout.addWidget(stack_checkbox)
3970
+ setattr(self, f"{prefix}_dispersionStackCheckbox", stack_checkbox)
3971
+
3972
+ # Stacking parameters button
3973
+ stack_params_button = QPushButton("Stack params")
3974
+ stack_params_button.setToolTip("Set stacking parameters (offset limits, side preference)")
3975
+ stack_params_button.clicked.connect(self.onDispersionStackParamsClicked)
3976
+ stack_checkbox_layout.addWidget(stack_params_button)
3977
+ setattr(self, f"{prefix}_dispersionStackParamsButton", stack_params_button)
3978
+
3979
+ layout.addWidget(stack_checkbox_container)
3873
3980
 
3874
3981
  # Populate trace range if we have data
3875
3982
  if self.streams and self.currentIndex is not None:
@@ -4034,6 +4141,24 @@ class MainWindow(QMainWindow):
4034
4141
  if top_type == "dispersion" or bottom_type == "dispersion":
4035
4142
  self.updatePlots()
4036
4143
 
4144
+ def onDispersionEnhanceChanged(self, state):
4145
+ """Handle enhance checkbox toggle for dispersion images"""
4146
+ self.dispersion_enhance = bool(state)
4147
+ # Replot dispersion if showing
4148
+ top_type = getattr(self, 'topPlotType', '').lower()
4149
+ bottom_type = getattr(self, 'bottomPlotType', '').lower()
4150
+ if top_type == "dispersion" or bottom_type == "dispersion":
4151
+ self.updatePlots()
4152
+
4153
+ def onDispersionSaturationChanged(self, value):
4154
+ """Handle saturation slider change for dispersion images"""
4155
+ self.dispersion_saturation = float(value)
4156
+ # Replot dispersion if showing
4157
+ top_type = getattr(self, 'topPlotType', '').lower()
4158
+ bottom_type = getattr(self, 'bottomPlotType', '').lower()
4159
+ if top_type == "dispersion" or bottom_type == "dispersion":
4160
+ self.updatePlots()
4161
+
4037
4162
  def onSpectrogramPermuteAxes(self):
4038
4163
  """Toggle permutation of frequency and trace axes in spectrogram"""
4039
4164
  self.spectrogram_freq_on_y = not self.spectrogram_freq_on_y
@@ -4054,31 +4179,132 @@ class MainWindow(QMainWindow):
4054
4179
 
4055
4180
  def onDispersionBatchWindowing(self):
4056
4181
  """Configure batch windowing parameters for all shots in dispersion view"""
4057
- parameters = [
4058
- {'label': 'Window length (traces)', 'initial_value': 10, 'type': 'int'},
4059
- {'label': 'Window side', 'initial_value': 'best', 'type': 'combo', 'values': ['left', 'right', 'best']},
4060
- {'label': 'Prefer side (when best)', 'initial_value': 'right', 'type': 'combo', 'values': ['left', 'right']},
4061
- {'label': 'Offset from shot (traces)', 'initial_value': 1, 'type': 'int'}
4062
- ]
4063
-
4064
- dialog = GenericParameterDialog(
4065
- title="Batch Windowing Parameters",
4066
- parameters=parameters,
4067
- parent=self
4068
- )
4069
-
4182
+ class BatchWindowingDialog(QDialog):
4183
+ def __init__(self, parent, default_center=None):
4184
+ super().__init__(parent)
4185
+ self.setWindowTitle("Batch Windowing Parameters")
4186
+ self.setModal(True)
4187
+
4188
+ layout = QVBoxLayout(self)
4189
+
4190
+ # Mode selector
4191
+ mode_row = QHBoxLayout()
4192
+ mode_row.addWidget(QLabel("Mode:"))
4193
+ self.modeCombo = QComboBox()
4194
+ self.modeCombo.addItems(["Constant offset", "Constant xmid"])
4195
+ mode_row.addWidget(self.modeCombo)
4196
+ layout.addLayout(mode_row)
4197
+
4198
+ self.stack = QStackedLayout()
4199
+
4200
+ # Constant offset page (current behaviour)
4201
+ offset_page = QWidget()
4202
+ offset_form = QFormLayout(offset_page)
4203
+ self.offset_window_len = QSpinBox()
4204
+ self.offset_window_len.setRange(1, 100000)
4205
+ self.offset_window_len.setValue(10)
4206
+ offset_form.addRow("Window length (traces)", self.offset_window_len)
4207
+
4208
+ self.window_side_combo = QComboBox()
4209
+ self.window_side_combo.addItems(["left", "right", "best"])
4210
+ self.window_side_combo.setCurrentText("best")
4211
+ offset_form.addRow("Window side", self.window_side_combo)
4212
+
4213
+ self.prefer_side_combo = QComboBox()
4214
+ self.prefer_side_combo.addItems(["left", "right"])
4215
+ self.prefer_side_combo.setCurrentText("right")
4216
+ offset_form.addRow("Prefer side (when best)", self.prefer_side_combo)
4217
+
4218
+ self.offset_from_shot = QSpinBox()
4219
+ self.offset_from_shot.setRange(0, 100000)
4220
+ self.offset_from_shot.setValue(1)
4221
+ offset_form.addRow("Offset from shot (traces)", self.offset_from_shot)
4222
+
4223
+ self.stack.addWidget(offset_page)
4224
+
4225
+ # Constant xmid page (new behaviour)
4226
+ xmid_page = QWidget()
4227
+ xmid_form = QFormLayout(xmid_page)
4228
+ self.xmid_window_len = QSpinBox()
4229
+ self.xmid_window_len.setRange(1, 100000)
4230
+ self.xmid_window_len.setValue(10)
4231
+ xmid_form.addRow("Window length (traces)", self.xmid_window_len)
4232
+
4233
+ self.xmid_center = QDoubleSpinBox()
4234
+ self.xmid_center.setDecimals(2)
4235
+ self.xmid_center.setRange(-1e9, 1e9)
4236
+ if default_center is not None:
4237
+ self.xmid_center.setValue(float(default_center))
4238
+ xmid_form.addRow("Center of window (m)", self.xmid_center)
4239
+
4240
+ self.stack.addWidget(xmid_page)
4241
+
4242
+ stack_container = QWidget()
4243
+ stack_container.setLayout(self.stack)
4244
+ layout.addWidget(stack_container)
4245
+
4246
+ # Buttons
4247
+ buttons = QHBoxLayout()
4248
+ self.okButton = QPushButton("OK")
4249
+ self.cancelButton = QPushButton("Cancel")
4250
+ buttons.addWidget(self.okButton)
4251
+ buttons.addWidget(self.cancelButton)
4252
+ layout.addLayout(buttons)
4253
+
4254
+ self.modeCombo.currentIndexChanged.connect(self.stack.setCurrentIndex)
4255
+ self.okButton.clicked.connect(self.accept)
4256
+ self.cancelButton.clicked.connect(self.reject)
4257
+
4258
+ def getValues(self):
4259
+ mode = 'constant_offset' if self.modeCombo.currentIndex() == 0 else 'constant_xmid'
4260
+ if mode == 'constant_offset':
4261
+ return {
4262
+ 'mode': mode,
4263
+ 'window_length': self.offset_window_len.value(),
4264
+ 'window_side': self.window_side_combo.currentText(),
4265
+ 'prefer_side': self.prefer_side_combo.currentText(),
4266
+ 'offset_from_shot': self.offset_from_shot.value(),
4267
+ 'center_xmid': None
4268
+ }
4269
+ else:
4270
+ return {
4271
+ 'mode': mode,
4272
+ 'window_length': self.xmid_window_len.value(),
4273
+ 'window_side': 'best',
4274
+ 'prefer_side': 'right',
4275
+ 'offset_from_shot': 0,
4276
+ 'center_xmid': self.xmid_center.value()
4277
+ }
4278
+
4279
+ # Default center: use existing xmid if available, otherwise current shot midpoint
4280
+ default_center = None
4281
+ if hasattr(self, 'currentIndex') and self.currentIndex is not None:
4282
+ if hasattr(self, 'xmid_values') and self.currentIndex in self.xmid_values:
4283
+ default_center = self.xmid_values[self.currentIndex]
4284
+ elif self.trace_position and self.currentIndex < len(self.trace_position):
4285
+ positions = self.trace_position[self.currentIndex]
4286
+ if positions is not None and len(positions) > 0:
4287
+ default_center = float(np.mean(positions))
4288
+
4289
+ dialog = BatchWindowingDialog(self, default_center=default_center)
4290
+
4070
4291
  if dialog.exec_():
4071
4292
  values = dialog.getValues()
4072
- window_length = values.get('Window length (traces)', 10)
4073
- window_side = values.get('Window side', 'best')
4074
- prefer_side = values.get('Prefer side (when best)', 'right')
4075
- offset_from_shot = values.get('Offset from shot (traces)', 1)
4293
+ window_length = values.get('window_length', 10)
4294
+ window_side = values.get('window_side', 'best')
4295
+ prefer_side = values.get('prefer_side', 'right')
4296
+ offset_from_shot = values.get('offset_from_shot', 1)
4297
+ mode = values.get('mode', 'constant_offset')
4298
+ center_xmid = values.get('center_xmid', None)
4076
4299
 
4077
4300
  # Apply batch windowing to all shots
4078
- self._applyBatchWindowingToAllShots(window_length, window_side, offset_from_shot, prefer_side)
4301
+ self._applyBatchWindowingToAllShots(window_length, window_side, offset_from_shot, prefer_side, mode, center_xmid)
4079
4302
 
4080
- def _applyBatchWindowingToAllShots(self, window_length, window_side, offset_from_shot, prefer_side='right'):
4081
- """Apply batch windowing parameters to all loaded shots using shot and geophone positions"""
4303
+ def _applyBatchWindowingToAllShots(self, window_length, window_side, offset_from_shot, prefer_side='right', mode='constant_offset', center_xmid=None):
4304
+ """Apply batch windowing parameters to all loaded shots using shot and geophone positions.
4305
+
4306
+ mode: 'constant_offset' (legacy) or 'constant_xmid' (fixed center across shots).
4307
+ """
4082
4308
  if not self.streams:
4083
4309
  QMessageBox.warning(self, "No Data", "No data loaded.")
4084
4310
  return
@@ -4093,6 +4319,11 @@ class MainWindow(QMainWindow):
4093
4319
  self.batch_windowing_params['window_side'] = window_side
4094
4320
  self.batch_windowing_params['offset_from_shot'] = offset_from_shot
4095
4321
  self.batch_windowing_params['prefer_side'] = prefer_side
4322
+ self.batch_windowing_params['mode'] = mode
4323
+ self.batch_windowing_params['center_xmid'] = center_xmid
4324
+
4325
+ applied_count = 0
4326
+ total_shots = len(self.streams)
4096
4327
 
4097
4328
  # Apply to each shot
4098
4329
  for shot_idx, stream in enumerate(self.streams):
@@ -4104,181 +4335,146 @@ class MainWindow(QMainWindow):
4104
4335
 
4105
4336
  shot_pos = self.source_position[shot_idx]
4106
4337
  trace_positions = np.array(self.trace_position[shot_idx])
4338
+ if trace_positions is None or len(trace_positions) == 0:
4339
+ continue
4107
4340
 
4108
- # Calculate mean geophone spacing
4109
- if len(trace_positions) > 1:
4110
- sorted_positions = np.sort(trace_positions)
4111
- mean_dg = np.mean(np.diff(sorted_positions))
4112
- else:
4113
- mean_dg = 1.0 # Default spacing if only one trace
4114
-
4115
- # Calculate offset distance threshold
4116
- offset_distance = offset_from_shot * mean_dg
4117
-
4118
- # Calculate distances from shot (positive = right, negative = left)
4119
- distances = trace_positions - shot_pos
4120
- abs_distances = np.abs(distances)
4121
-
4122
- # Sort traces by distance from shot to determine left/right
4123
- sorted_indices = np.argsort(distances)
4124
- sorted_distances = distances[sorted_indices]
4125
-
4126
- # Find traces on left (negative distance) and right (positive distance)
4127
- left_mask = sorted_distances < 0
4128
- right_mask = sorted_distances > 0
4129
-
4130
- left_indices = sorted_indices[left_mask]
4131
- right_indices = sorted_indices[right_mask]
4132
-
4133
- # Calculate window range based on side preference
4134
4341
  first_trace = None
4135
4342
  last_trace = None
4343
+ selected_traces = None
4136
4344
 
4137
- if window_side == 'left':
4138
- # Use traces to the left of shot, beyond offset_distance
4139
- if len(left_indices) > 0:
4140
- # Filter left traces that are beyond the offset distance
4141
- left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance]
4142
-
4143
- if len(left_beyond_offset) > 0:
4144
- # Sort by absolute distance (closest to shot first, among those beyond offset)
4145
- left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4146
- # Take window_length traces
4147
- selected_traces = left_sorted[:window_length]
4148
- first_trace = int(np.min(selected_traces))
4149
- last_trace = int(np.max(selected_traces))
4150
-
4151
- elif window_side == 'right':
4152
- # Use traces to the right of shot, beyond offset_distance
4153
- if len(right_indices) > 0:
4154
- # Filter right traces that are beyond the offset distance
4155
- right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance]
4156
-
4157
- if len(right_beyond_offset) > 0:
4158
- # Sort by distance (closest to shot first, among those beyond offset)
4159
- right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4160
- # Take window_length traces
4161
- selected_traces = right_sorted[:window_length]
4162
- first_trace = int(np.min(selected_traces))
4163
- last_trace = int(np.max(selected_traces))
4164
-
4165
- elif window_side == 'best':
4166
- # Try preferred side first, fallback to other side if not enough traces
4167
- if prefer_side == 'right':
4168
- # Prefer right side - check if enough traces beyond offset_distance
4169
- right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance] if len(right_indices) > 0 else np.array([])
4170
-
4171
- if len(right_beyond_offset) >= window_length:
4172
- # Enough traces on the right beyond offset
4173
- right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4174
- selected_traces = right_sorted[:window_length]
4175
- first_trace = int(np.min(selected_traces))
4176
- last_trace = int(np.max(selected_traces))
4177
- else:
4178
- # Try left side
4179
- left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance] if len(left_indices) > 0 else np.array([])
4180
- if len(left_beyond_offset) >= window_length:
4345
+ if mode == 'constant_xmid':
4346
+ # Fixed center across shots; pick closest traces to requested xmid
4347
+ if center_xmid is None:
4348
+ continue
4349
+ if len(trace_positions) < 1:
4350
+ continue
4351
+ # Pick nearest window_length traces to the target center
4352
+ idx_sorted = np.argsort(np.abs(trace_positions - center_xmid))
4353
+ if len(idx_sorted) >= window_length:
4354
+ selected_traces = idx_sorted[:window_length]
4355
+ else:
4356
+ # Not enough traces → keep previous window for this shot
4357
+ continue
4358
+ first_trace = int(np.min(selected_traces))
4359
+ last_trace = int(np.max(selected_traces))
4360
+ else:
4361
+ # Legacy constant offset behaviour
4362
+ # Calculate mean geophone spacing
4363
+ if len(trace_positions) > 1:
4364
+ sorted_positions = np.sort(trace_positions)
4365
+ mean_dg = np.mean(np.diff(sorted_positions))
4366
+ else:
4367
+ mean_dg = 1.0 # Default spacing if only one trace
4368
+
4369
+ # Calculate offset distance threshold
4370
+ offset_distance = offset_from_shot * mean_dg
4371
+
4372
+ # Calculate distances from shot (positive = right, negative = left)
4373
+ distances = trace_positions - shot_pos
4374
+ abs_distances = np.abs(distances)
4375
+
4376
+ # Sort traces by distance from shot to determine left/right
4377
+ sorted_indices = np.argsort(distances)
4378
+ sorted_distances = distances[sorted_indices]
4379
+
4380
+ # Find traces on left (negative distance) and right (positive distance)
4381
+ left_mask = sorted_distances < 0
4382
+ right_mask = sorted_distances > 0
4383
+
4384
+ left_indices = sorted_indices[left_mask]
4385
+ right_indices = sorted_indices[right_mask]
4386
+
4387
+ if window_side == 'left':
4388
+ if len(left_indices) > 0:
4389
+ left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance]
4390
+ if len(left_beyond_offset) > 0:
4181
4391
  left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4182
4392
  selected_traces = left_sorted[:window_length]
4183
- first_trace = int(np.min(selected_traces))
4184
- last_trace = int(np.max(selected_traces))
4185
- else:
4186
- # Prefer left side - check if enough traces beyond offset_distance
4187
- left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance] if len(left_indices) > 0 else np.array([])
4188
-
4189
- if len(left_beyond_offset) >= window_length:
4190
- # Enough traces on the left beyond offset
4191
- left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4192
- selected_traces = left_sorted[:window_length]
4193
- first_trace = int(np.min(selected_traces))
4194
- last_trace = int(np.max(selected_traces))
4195
- else:
4196
- # Try right side
4197
- right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance] if len(right_indices) > 0 else np.array([])
4198
- if len(right_beyond_offset) >= window_length:
4393
+ elif window_side == 'right':
4394
+ if len(right_indices) > 0:
4395
+ right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance]
4396
+ if len(right_beyond_offset) > 0:
4199
4397
  right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4200
4398
  selected_traces = right_sorted[:window_length]
4201
- first_trace = int(np.min(selected_traces))
4202
- last_trace = int(np.max(selected_traces))
4203
-
4204
- # If still no valid range, reduce requirements (fewer than window_length traces or closer than offset)
4205
- if first_trace is None or last_trace is None:
4206
- # Use what's available, respecting preference
4399
+ elif window_side == 'best':
4207
4400
  if prefer_side == 'right':
4208
- # Try right side with any available traces beyond offset
4209
4401
  right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance] if len(right_indices) > 0 else np.array([])
4210
- if len(right_beyond_offset) > 0:
4402
+ if len(right_beyond_offset) >= window_length:
4211
4403
  right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4212
- selected_traces = right_sorted
4213
- first_trace = int(np.min(selected_traces))
4214
- last_trace = int(np.max(selected_traces))
4404
+ selected_traces = right_sorted[:window_length]
4215
4405
  else:
4216
- # Try left side with any available traces beyond offset
4217
4406
  left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance] if len(left_indices) > 0 else np.array([])
4218
- if len(left_beyond_offset) > 0:
4407
+ if len(left_beyond_offset) >= window_length:
4219
4408
  left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4220
- selected_traces = left_sorted
4221
- first_trace = int(np.min(selected_traces))
4222
- last_trace = int(np.max(selected_traces))
4223
- elif len(right_indices) > 0:
4224
- # No offset possible, use closest right traces
4225
- right_sorted = right_indices[np.argsort(abs_distances[right_indices])]
4226
- selected_traces = right_sorted[:window_length] if len(right_sorted) >= window_length else right_sorted
4227
- first_trace = int(np.min(selected_traces))
4228
- last_trace = int(np.max(selected_traces))
4229
- elif len(left_indices) > 0:
4230
- # No right traces, use closest left traces
4231
- left_sorted = left_indices[np.argsort(abs_distances[left_indices])]
4232
- selected_traces = left_sorted[:window_length] if len(left_sorted) >= window_length else left_sorted
4233
- first_trace = int(np.min(selected_traces))
4234
- last_trace = int(np.max(selected_traces))
4409
+ selected_traces = left_sorted[:window_length]
4235
4410
  else:
4236
- # Prefer left side
4237
- # Try left side with any available traces beyond offset
4238
4411
  left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance] if len(left_indices) > 0 else np.array([])
4239
- if len(left_beyond_offset) > 0:
4412
+ if len(left_beyond_offset) >= window_length:
4240
4413
  left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4241
- selected_traces = left_sorted
4242
- first_trace = int(np.min(selected_traces))
4243
- last_trace = int(np.max(selected_traces))
4414
+ selected_traces = left_sorted[:window_length]
4244
4415
  else:
4245
- # Try right side with any available traces beyond offset
4416
+ right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance] if len(right_indices) > 0 else np.array([])
4417
+ if len(right_beyond_offset) >= window_length:
4418
+ right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4419
+ selected_traces = right_sorted[:window_length]
4420
+
4421
+ # If still no valid range, relax requirements
4422
+ if selected_traces is None:
4423
+ if prefer_side == 'right':
4246
4424
  right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance] if len(right_indices) > 0 else np.array([])
4247
4425
  if len(right_beyond_offset) > 0:
4248
4426
  right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4249
4427
  selected_traces = right_sorted
4250
- first_trace = int(np.min(selected_traces))
4251
- last_trace = int(np.max(selected_traces))
4252
- elif len(left_indices) > 0:
4253
- # No offset possible, use closest left traces
4254
- left_sorted = left_indices[np.argsort(abs_distances[left_indices])]
4255
- selected_traces = left_sorted[:window_length] if len(left_sorted) >= window_length else left_sorted
4256
- first_trace = int(np.min(selected_traces))
4257
- last_trace = int(np.max(selected_traces))
4258
- elif len(right_indices) > 0:
4259
- # No left traces, use closest right traces
4260
- right_sorted = right_indices[np.argsort(abs_distances[right_indices])]
4261
- selected_traces = right_sorted[:window_length] if len(right_sorted) >= window_length else right_sorted
4262
- first_trace = int(np.min(selected_traces))
4263
- last_trace = int(np.max(selected_traces))
4264
-
4265
- # Final fallback if still no valid range (shouldn't reach here)
4428
+ else:
4429
+ left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance] if len(left_indices) > 0 else np.array([])
4430
+ if len(left_beyond_offset) > 0:
4431
+ left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4432
+ selected_traces = left_sorted
4433
+ elif len(right_indices) > 0:
4434
+ right_sorted = right_indices[np.argsort(abs_distances[right_indices])]
4435
+ selected_traces = right_sorted[:window_length] if len(right_sorted) >= window_length else right_sorted
4436
+ elif len(left_indices) > 0:
4437
+ left_sorted = left_indices[np.argsort(abs_distances[left_indices])]
4438
+ selected_traces = left_sorted[:window_length] if len(left_sorted) >= window_length else left_sorted
4439
+ else:
4440
+ left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance] if len(left_indices) > 0 else np.array([])
4441
+ if len(left_beyond_offset) > 0:
4442
+ left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4443
+ selected_traces = left_sorted
4444
+ else:
4445
+ right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance] if len(right_indices) > 0 else np.array([])
4446
+ if len(right_beyond_offset) > 0:
4447
+ right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4448
+ selected_traces = right_sorted
4449
+ elif len(left_indices) > 0:
4450
+ left_sorted = left_indices[np.argsort(abs_distances[left_indices])]
4451
+ selected_traces = left_sorted[:window_length] if len(left_sorted) >= window_length else left_sorted
4452
+ elif len(right_indices) > 0:
4453
+ right_sorted = right_indices[np.argsort(abs_distances[right_indices])]
4454
+ selected_traces = right_sorted[:window_length] if len(right_sorted) >= window_length else right_sorted
4455
+
4456
+ if selected_traces is None:
4457
+ abs_distances = np.abs(distances)
4458
+ closest_indices = np.argsort(abs_distances)[:min(window_length, n_traces)]
4459
+ selected_traces = closest_indices
4460
+
4461
+ if selected_traces is not None and len(selected_traces) > 0:
4462
+ first_trace = int(np.min(selected_traces))
4463
+ last_trace = int(np.max(selected_traces))
4464
+
4465
+ # If we still don't have a valid window (e.g., constant_xmid with too few traces), keep previous
4266
4466
  if first_trace is None or last_trace is None:
4267
- # Use closest traces to shot position
4268
- abs_distances = np.abs(distances)
4269
- closest_indices = np.argsort(abs_distances)[:min(window_length, n_traces)]
4270
- first_trace = int(np.min(closest_indices))
4271
- last_trace = int(np.max(closest_indices))
4272
-
4467
+ continue
4468
+
4273
4469
  # Ensure valid range
4274
4470
  first_trace = max(0, min(first_trace, n_traces - 1))
4275
4471
  last_trace = max(first_trace, min(last_trace, n_traces - 1))
4276
-
4472
+
4277
4473
  # Store in trace range memory using shot_idx as key (consistent with onFileSelectionChanged)
4278
4474
  file_key = shot_idx
4279
4475
  if file_key not in self.trace_range_memory:
4280
4476
  self.trace_range_memory[file_key] = {}
4281
-
4477
+
4282
4478
  # Store for both views (sync them) using dictionary structure
4283
4479
  self.trace_range_memory[file_key]['top'] = {
4284
4480
  'first': first_trace,
@@ -4298,6 +4494,8 @@ class MainWindow(QMainWindow):
4298
4494
  if not hasattr(self, 'xmid_values'):
4299
4495
  self.xmid_values = {}
4300
4496
  self.xmid_values[shot_idx] = center
4497
+
4498
+ applied_count += 1
4301
4499
 
4302
4500
  # Update the current shot's range slider and attributes
4303
4501
  if self.currentIndex is not None and self.currentIndex < len(self.streams):
@@ -4358,10 +4556,17 @@ class MainWindow(QMainWindow):
4358
4556
  self._plot_target_viewbox = None
4359
4557
 
4360
4558
  # Show confirmation message
4361
- prefer_text = f", Prefer: {prefer_side}" if window_side == 'best' else ""
4362
- QMessageBox.information(self, "Batch Windowing",
4363
- f"Applied batch windowing to {len(self.streams)} shots.\n"
4364
- f"Window: {window_length} traces, Side: {window_side}{prefer_text}")
4559
+ prefer_text = f", Prefer: {prefer_side}" if (mode == 'constant_offset' and window_side == 'best') else ""
4560
+ mode_text = "Constant xmid" if mode == 'constant_xmid' else "Constant offset"
4561
+ QMessageBox.information(
4562
+ self,
4563
+ "Batch Windowing",
4564
+ f"Applied batch windowing to {applied_count}/{total_shots} shots.\n"
4565
+ f"Mode: {mode_text}. Window: {window_length} traces"
4566
+ f"{', Center: %.2f m' % center_xmid if (mode == 'constant_xmid' and center_xmid is not None) else ''}"
4567
+ f"{', Side: ' + window_side if mode == 'constant_offset' else ''}"
4568
+ f"{prefer_text}"
4569
+ )
4365
4570
 
4366
4571
  except Exception as e:
4367
4572
  QMessageBox.critical(self, "Error", f"Failed to apply batch windowing: {str(e)}")
@@ -4694,6 +4899,9 @@ class MainWindow(QMainWindow):
4694
4899
  if not (hasattr(self, 'currentIndex') and self.currentIndex is not None and self.streams):
4695
4900
  return
4696
4901
 
4902
+ # Update dispersion window info in status bar
4903
+ self.updateDispersionWindowInfo()
4904
+
4697
4905
  # Update the trace extent indicator and replot only for the controlled view
4698
4906
  if controlled_view == "top":
4699
4907
  if hasattr(self, 'topPlotType') and self.topPlotType == "dispersion":
@@ -4744,23 +4952,36 @@ class MainWindow(QMainWindow):
4744
4952
  self.streams and self.trace_position and self.currentIndex < len(self.trace_position)):
4745
4953
  self.dispersionWindowLabel.setText("")
4746
4954
  return
4955
+
4956
+ # Only show info when a dispersion view is active
4957
+ top_dispersion = getattr(self, 'topPlotType', None) == 'dispersion'
4958
+ bottom_dispersion = getattr(self, 'bottomPlotType', None) == 'dispersion'
4959
+ if not (top_dispersion or bottom_dispersion):
4960
+ self.dispersionWindowLabel.setText("")
4961
+ return
4747
4962
 
4748
4963
  # Determine which trace range to use - prioritize view-specific ranges
4749
4964
  first_trace = None
4750
4965
  last_trace = None
4751
4966
 
4752
- # Check top view range first
4753
- if hasattr(self, 'top_first_trace') and hasattr(self, 'top_last_trace'):
4754
- first_trace = self.top_first_trace
4755
- last_trace = self.top_last_trace
4756
- # Then check bottom view range
4757
- elif hasattr(self, 'bottom_first_trace') and hasattr(self, 'bottom_last_trace'):
4758
- first_trace = self.bottom_first_trace
4759
- last_trace = self.bottom_last_trace
4760
- # Fallback to default trace range
4761
- elif hasattr(self, 'first_trace') and hasattr(self, 'last_trace'):
4762
- first_trace = self.first_trace
4763
- last_trace = self.last_trace
4967
+ # Prefer the trace range from the view currently showing the dispersion image
4968
+ if top_dispersion:
4969
+ first_trace = getattr(self, 'top_first_trace', None)
4970
+ last_trace = getattr(self, 'top_last_trace', None)
4971
+ if first_trace is None or last_trace is None:
4972
+ first_trace = getattr(self, 'bottom_first_trace', getattr(self, 'first_trace', None))
4973
+ last_trace = getattr(self, 'bottom_last_trace', getattr(self, 'last_trace', None))
4974
+ else:
4975
+ first_trace = getattr(self, 'bottom_first_trace', None)
4976
+ last_trace = getattr(self, 'bottom_last_trace', None)
4977
+ if first_trace is None or last_trace is None:
4978
+ first_trace = getattr(self, 'top_first_trace', getattr(self, 'first_trace', None))
4979
+ last_trace = getattr(self, 'top_last_trace', getattr(self, 'last_trace', None))
4980
+ # Final fallback to unified attributes if everything else is missing
4981
+ if first_trace is None or last_trace is None:
4982
+ if hasattr(self, 'first_trace') and hasattr(self, 'last_trace'):
4983
+ first_trace = self.first_trace
4984
+ last_trace = self.last_trace
4764
4985
 
4765
4986
  # If no trace range is set, don't show anything
4766
4987
  if first_trace is None or last_trace is None:
@@ -4792,10 +5013,16 @@ class MainWindow(QMainWindow):
4792
5013
  self.xmid_values = {}
4793
5014
  self.xmid_values[self.currentIndex] = center
4794
5015
 
5016
+ # Build the status message
5017
+ window_info = f"Disp. Window: First={pos_first:.1f}m, Last={pos_last:.1f}m, Center={center:.1f}m, Width={width:.1f}m ({n_traces_used} tr)"
5018
+
5019
+ # Append stacking info if enabled
5020
+ if getattr(self, 'dispersion_stack_mode', False):
5021
+ shot_count = getattr(self, 'dispersion_stack_shot_count', 0)
5022
+ window_info += f" | Stacked ({shot_count} shots)"
5023
+
4795
5024
  # Update the status bar label
4796
- self.dispersionWindowLabel.setText(
4797
- f"Disp. Window: First={pos_first:.1f}m, Last={pos_last:.1f}m, Center={center:.1f}m, Width={width:.1f}m ({n_traces_used} tr)"
4798
- )
5025
+ self.dispersionWindowLabel.setText(window_info)
4799
5026
 
4800
5027
  def onTopNormPerTraceChanged(self, state):
4801
5028
  """Handle per-trace normalization toggle for top Spectrogram/Dispersion"""
@@ -5418,6 +5645,26 @@ class MainWindow(QMainWindow):
5418
5645
  (hasattr(self, 'bottomPlotType') and self.bottomPlotType == "seismogram"):
5419
5646
  self.updatePlots()
5420
5647
 
5648
+ def onDispersionShowAliasingLimitChanged(self, state):
5649
+ """Handle aliasing limit display toggle for dispersion view"""
5650
+ self.dispersion_show_aliasing_limit = bool(state)
5651
+
5652
+ # Replot dispersion if currently displayed (set correct target widget)
5653
+ if hasattr(self, 'topPlotType') and self.topPlotType == "dispersion":
5654
+ if self.streams and self.currentIndex is not None:
5655
+ self._plot_target_widget = self.topPlotWidget
5656
+ self._plot_target_viewbox = self.topViewBox
5657
+ self.plotDispersion()
5658
+ self._plot_target_widget = None
5659
+ self._plot_target_viewbox = None
5660
+ elif hasattr(self, 'bottomPlotType') and self.bottomPlotType == "dispersion":
5661
+ if self.streams and self.currentIndex is not None:
5662
+ self._plot_target_widget = self.bottomPlotWidget
5663
+ self._plot_target_viewbox = self.bottomViewBox
5664
+ self.plotDispersion()
5665
+ self._plot_target_widget = None
5666
+ self._plot_target_viewbox = None
5667
+
5421
5668
  def onSeismoShowDispWindowChanged(self, state):
5422
5669
  """Handle seismogram show dispersion window toggle"""
5423
5670
  self.seismo_show_disp_window = bool(state)
@@ -5722,15 +5969,24 @@ class MainWindow(QMainWindow):
5722
5969
 
5723
5970
  # Check if any of the dragged files are supported formats
5724
5971
  for url in urls:
5725
- file_path = url.toLocalFile()
5726
- # Check for seismic data formats: .su, .segy, .sgy, .sg2, .dat
5727
- if file_path.lower().endswith(('.su', '.segy', '.sgy', '.sg2', '.dat')):
5728
- accepted = True
5729
- break
5730
- # Check for pick files: .sgt
5731
- if file_path.lower().endswith('.sgt'):
5732
- accepted = True
5733
- break
5972
+ try:
5973
+ file_path = url.toLocalFile()
5974
+ if not file_path:
5975
+ continue
5976
+ # Check for seismic data formats: .su, .segy, .sgy, .sg2, .dat
5977
+ if file_path.lower().endswith(('.su', '.segy', '.sgy', '.sg2', '.dat', '.seg2')):
5978
+ accepted = True
5979
+ break
5980
+ # Check for pick files: .sgt
5981
+ if file_path.lower().endswith('.sgt'):
5982
+ accepted = True
5983
+ break
5984
+ # Check for dispersion picks: .pvc
5985
+ if file_path.lower().endswith('.pvc'):
5986
+ accepted = True
5987
+ break
5988
+ except Exception:
5989
+ continue
5734
5990
 
5735
5991
  if accepted:
5736
5992
  event.acceptProposedAction()
@@ -5745,30 +6001,44 @@ class MainWindow(QMainWindow):
5745
6001
  urls = event.mimeData().urls()
5746
6002
  seismic_files = []
5747
6003
  pick_files = []
6004
+ pvc_files = []
5748
6005
 
5749
6006
  # Categorize the dropped files
5750
6007
  for url in urls:
5751
6008
  file_path = url.toLocalFile()
5752
6009
 
5753
6010
  # Seismic data files
5754
- if file_path.lower().endswith(('.su', '.segy', '.sgy', '.sg2', '.dat')):
6011
+ if file_path.lower().endswith(('.su', '.segy', '.sgy', '.sg2', '.dat', '.seg2')):
5755
6012
  seismic_files.append(file_path)
5756
- # Pick files
6013
+ # Traveltime pick files
5757
6014
  elif file_path.lower().endswith('.sgt'):
5758
6015
  pick_files.append(file_path)
6016
+ # Dispersion pick files
6017
+ elif file_path.lower().endswith('.pvc'):
6018
+ pvc_files.append(file_path)
5759
6019
 
5760
6020
  # Process seismic files
5761
6021
  if seismic_files:
5762
- self.openFile(fileNames_new=seismic_files)
5763
- event.acceptProposedAction()
6022
+ try:
6023
+ self.openFile(fileNames_new=seismic_files)
6024
+ event.acceptProposedAction()
6025
+ except Exception as e:
6026
+ QMessageBox.critical(self, "Load Seismic Error", f"Error loading seismic files:\n{str(e)}")
5764
6027
 
5765
- # Process pick files (load them after seismic files are loaded)
6028
+ # Process traveltime pick files (load them after seismic files are loaded)
5766
6029
  if pick_files and self.streams:
5767
6030
  for pick_file in pick_files:
5768
6031
  try:
5769
6032
  self.loadPicks(pick_file, verbose=False)
5770
6033
  except Exception as e:
5771
6034
  QMessageBox.warning(self, "Load Picks Error", f"Error loading picks from {pick_file}:\n{str(e)}")
6035
+
6036
+ # Process dispersion pick files
6037
+ if pvc_files:
6038
+ try:
6039
+ self.loadDispersionCurvesFromPvc(file_paths=pvc_files)
6040
+ except Exception as e:
6041
+ QMessageBox.warning(self, "Load Dispersion Picks Error", f"Error loading dispersion picks:\n{str(e)}")
5772
6042
 
5773
6043
  def _get_wiggle_amplitude(self, x_pos, y_time):
5774
6044
  """
@@ -6168,6 +6438,129 @@ class MainWindow(QMainWindow):
6168
6438
  if amp is not None:
6169
6439
  amp_text = f", Amplitude: {amp:.4f}"
6170
6440
  self.coordinatesLabel.setText(f"{x_label}: {x_value}, {y_label}: {y_value}{amp_text}")
6441
+
6442
+ elif self.bottomPlotType == 'pseudosection':
6443
+ # Pseudo-section: X = source position/FFID or window center, Y = frequency or wavelength
6444
+ use_source_x = getattr(self, 'pseudosection_use_source_x', False)
6445
+ use_wavelength = getattr(self, 'pseudosection_use_wavelength', False)
6446
+
6447
+ # Y-axis: frequency or wavelength
6448
+ if use_wavelength:
6449
+ y_label = "Wavelength"
6450
+ y_value = f"{view_coords.y():.1f} m"
6451
+ else:
6452
+ y_label = "Frequency"
6453
+ y_value = f"{view_coords.y():.1f} Hz"
6454
+
6455
+ # X-axis: depends on pseudosection_use_source_x
6456
+ if use_source_x:
6457
+ # Match layout Y-axis label (source position or FFID)
6458
+ plot_type_y = getattr(self, 'plotTypeY', 'source_position')
6459
+ if plot_type_y == 'ffid':
6460
+ x_label = "FFID"
6461
+ x_value = f"{view_coords.x():.0f}"
6462
+ elif plot_type_y == 'source_position':
6463
+ x_label = "Source Position"
6464
+ x_value = f"{view_coords.x():.1f} m"
6465
+ else:
6466
+ x_label = "X"
6467
+ x_value = f"{view_coords.x():.2f}"
6468
+ else:
6469
+ # Window center: position or trace number depending on plotTypeX
6470
+ plot_type_x = getattr(self, 'plotTypeX', 'trace_position')
6471
+ if plot_type_x == 'trace_position':
6472
+ x_label = "Window Center"
6473
+ x_value = f"{view_coords.x():.1f} m"
6474
+ else:
6475
+ # Trace number modes (shot_trace_number, file_trace_number, unique_trace_number)
6476
+ x_label = "Window Center"
6477
+ x_value = f"{view_coords.x():.1f}"
6478
+
6479
+ # Try to find nearby pick and show phase velocity
6480
+ phase_vel_text = ""
6481
+ try:
6482
+ if self.dispersion_curves is not None:
6483
+ show_interp = getattr(self, 'pseudosection_show_interpolated', False)
6484
+ selected_mode = getattr(self, 'pseudosection_mode', 0)
6485
+
6486
+ # Collect all pick points with their phase velocities
6487
+ pick_x_coords = []
6488
+ pick_y_coords = []
6489
+ pick_velocities = []
6490
+
6491
+ for shot_idx, shot_curves in enumerate(self.dispersion_curves):
6492
+ if shot_curves is None or 'modes' not in shot_curves:
6493
+ continue
6494
+ if selected_mode not in shot_curves['modes']:
6495
+ continue
6496
+ mode_data = shot_curves['modes'][selected_mode]
6497
+
6498
+ # Get frequencies and velocities
6499
+ freqs = None
6500
+ vels = None
6501
+
6502
+ if show_interp and 'frequencies' in mode_data:
6503
+ freqs = np.array(mode_data.get('frequencies', []))
6504
+ vels = np.array(mode_data.get('velocities', []))
6505
+ elif 'picked_points' in mode_data and mode_data['picked_points']:
6506
+ points = mode_data['picked_points']
6507
+ freqs = np.array([p[0] for p in points])
6508
+ vels = np.array([p[1] for p in points])
6509
+
6510
+ if freqs is not None and len(freqs) > 0:
6511
+ # Determine X coordinate for this shot
6512
+ if use_source_x:
6513
+ plot_type_y = getattr(self, 'plotTypeY', 'source_position')
6514
+ if plot_type_y == 'ffid' and hasattr(self, 'ffid') and shot_idx < len(self.ffid):
6515
+ try:
6516
+ x_coord = float(self.ffid[shot_idx])
6517
+ except Exception:
6518
+ continue
6519
+ elif shot_idx < len(self.source_position):
6520
+ x_coord = self.source_position[shot_idx]
6521
+ else:
6522
+ continue
6523
+ else:
6524
+ if shot_idx not in getattr(self, 'xmid_values', {}):
6525
+ continue
6526
+ x_coord = self.xmid_values[shot_idx]
6527
+
6528
+ # Add all points for this shot
6529
+ for freq, vel in zip(freqs, vels):
6530
+ if use_wavelength:
6531
+ wavelength = vel / freq if freq > 0 else 0
6532
+ pick_y_coords.append(wavelength)
6533
+ else:
6534
+ pick_y_coords.append(freq)
6535
+ pick_x_coords.append(x_coord)
6536
+ pick_velocities.append(vel)
6537
+
6538
+ # Find closest pick
6539
+ if len(pick_x_coords) > 0:
6540
+ pick_distances = []
6541
+ for i in range(len(pick_x_coords)):
6542
+ dx = pick_x_coords[i] - view_coords.x()
6543
+ dy = pick_y_coords[i] - view_coords.y()
6544
+ distance = (dx**2 + dy**2)**0.5
6545
+ pick_distances.append(distance)
6546
+
6547
+ min_distance = min(pick_distances)
6548
+
6549
+ # Define tolerance based on view range (about 2% of the view range)
6550
+ x_range = self.bottomViewBox.viewRange()[0]
6551
+ y_range = self.bottomViewBox.viewRange()[1]
6552
+ x_tolerance = abs(x_range[1] - x_range[0]) * 0.02
6553
+ y_tolerance = abs(y_range[1] - y_range[0]) * 0.02
6554
+ tolerance = (x_tolerance**2 + y_tolerance**2)**0.5
6555
+
6556
+ if min_distance < tolerance:
6557
+ closest_idx = pick_distances.index(min_distance)
6558
+ phase_vel = pick_velocities[closest_idx]
6559
+ phase_vel_text = f", Phase Velocity: {phase_vel:.1f} m/s"
6560
+ except Exception:
6561
+ pass
6562
+
6563
+ self.coordinatesLabel.setText(f"{x_label}: {x_value}, {y_label}: {y_value}{phase_vel_text}")
6171
6564
 
6172
6565
  else: # 'layout' plot
6173
6566
  # Layout plot: show pick time if available, otherwise show coordinates
@@ -6309,6 +6702,10 @@ class MainWindow(QMainWindow):
6309
6702
  def initializeAttributes(self):
6310
6703
  # Initialize the attributes
6311
6704
 
6705
+ # Initialize core data structure lists (MUST be first, used by openFile)
6706
+ self.fileNames = []
6707
+ self.streams = []
6708
+
6312
6709
  self.currentFileName = None
6313
6710
  self.currentIndex = None
6314
6711
  self.streamIndex = None
@@ -6368,6 +6765,15 @@ class MainWindow(QMainWindow):
6368
6765
  # Dispersion-specific normalization settings (independent from spectrogram)
6369
6766
  self.dispersion_norm_per_trace = True
6370
6767
  self.dispersion_norm_per_freq = True
6768
+
6769
+ # Dispersion stacking parameters (used when stacking shots)
6770
+ self.dispersion_stack_min_offset = 0.0 # Minimum offset to window edge (m)
6771
+ self.dispersion_stack_max_offset = None # Maximum offset to window edge (m); None = no limit
6772
+ self.dispersion_stack_side = 'Both sides' # Side preference: 'Both sides', 'Left only', 'Right only'
6773
+
6774
+ # Dispersion display enhancement parameters
6775
+ self.dispersion_enhance = False # Enhance weak modes using 1/(1-x) transformation
6776
+ self.dispersion_saturation = 1.0 # Saturation factor (0.5-1.5): 1.0=no change
6371
6777
 
6372
6778
  # Unified analytical view parameter defaults (same for top/bottom)
6373
6779
  self.fmin = None # Default fmin (auto -> 0 for Spectrogram/Phase-Shift)
@@ -6414,6 +6820,7 @@ class MainWindow(QMainWindow):
6414
6820
  self.update_pick_flag = False
6415
6821
  self.update_file_flag = False
6416
6822
  self.pick_file = ""
6823
+ self.geophone_mapping = None # Maps (shot_idx, trace_idx) -> sgt_geophone_number for order matching
6417
6824
  self.refrac_manager = None
6418
6825
  self.output_format = 'SEGY'
6419
6826
  self.headers_modified = False # Track if headers have been modified (includes trace edits)
@@ -6425,6 +6832,7 @@ class MainWindow(QMainWindow):
6425
6832
  self.pseudosection_colormap_str = 'plasma' # Colormap for velocity encoding
6426
6833
  self.pseudosection_invert_y = True # Default: Y-axis pointing downward
6427
6834
  self.pseudosection_mode = 0 # Selected mode for pseudo-section display (M0, M1, M2, ...)
6835
+ self.pseudosection_use_source_x = False # False = window center (trace pos), True = source/FFID
6428
6836
 
6429
6837
  # Dispersion curve picking data structures (per view: top/bottom)
6430
6838
  # Format: {view: {'picked_points': [], 'picked_point_items': [], 'curve_line': None, 'current_dispersion_data': None}}
@@ -7055,6 +7463,13 @@ class MainWindow(QMainWindow):
7055
7463
  freq_min = self.dispersionAutoPickFreqMinSpin.value()
7056
7464
  freq_max = self.dispersionAutoPickFreqMaxSpin.value()
7057
7465
 
7466
+ # Get trace spacing for aliasing limit calculation
7467
+ dx = None
7468
+ if self.trace_position and self.currentIndex < len(self.trace_position):
7469
+ trace_pos = self.trace_position[self.currentIndex]
7470
+ if len(trace_pos) >= 2:
7471
+ dx = np.mean(np.abs(np.diff(trace_pos)))
7472
+
7058
7473
  # Filter frequencies within range
7059
7474
  freq_mask = (frequencies >= freq_min) & (frequencies <= freq_max)
7060
7475
 
@@ -7070,12 +7485,32 @@ class MainWindow(QMainWindow):
7070
7485
  # Compute max per frequency (full velocity range) within frequency range
7071
7486
  picked_points = []
7072
7487
  picked_items = []
7073
- for f_idx, vel_idx in enumerate(np.argmax(FV, axis=1)):
7488
+ for f_idx in range(len(frequencies)):
7074
7489
  freq = frequencies[f_idx]
7075
7490
  # Only pick if frequency is in range
7076
7491
  if not freq_mask[f_idx]:
7077
7492
  continue
7493
+
7494
+ # Get dispersion slice at this frequency
7495
+ dispersion_slice = FV[f_idx, :]
7496
+
7497
+ # Apply aliasing limit: only consider velocities above first-order aliasing boundary
7498
+ # Formula: vi = f/(( f/v0) + (i/dx)), for i=1 the boundary is approximately v > f*dx
7499
+ if dx is not None and dx > 0:
7500
+ v_alias = freq * dx # First-order aliasing boundary
7501
+ aliasing_mask = velocities >= v_alias
7502
+ if not aliasing_mask.any():
7503
+ # No valid velocities above aliasing limit, skip this frequency
7504
+ continue
7505
+ # Mask out velocities below aliasing limit
7506
+ masked_slice = np.where(aliasing_mask, dispersion_slice, -np.inf)
7507
+ else:
7508
+ masked_slice = dispersion_slice
7509
+
7510
+ # Find maximum in the valid (non-aliased) region
7511
+ vel_idx = np.argmax(masked_slice)
7078
7512
  vel = velocities[vel_idx]
7513
+
7079
7514
  picked_points.append((freq, vel))
7080
7515
  point_item = pqg.ScatterPlotItem(
7081
7516
  x=[freq], y=[vel],
@@ -7093,9 +7528,28 @@ class MainWindow(QMainWindow):
7093
7528
  picked_items.append(point_item)
7094
7529
  plot_widget.addItem(point_item)
7095
7530
 
7096
- # Persist
7531
+ # Persist to mode data
7097
7532
  mode_data['picked_points'] = picked_points
7098
7533
  mode_data['picked_point_items'] = picked_items
7534
+
7535
+ # Store picked points to dispersion_curves for persistence across shot switches
7536
+ if self.currentIndex is not None and self.currentIndex < len(self.dispersion_curves):
7537
+ if self.dispersion_curves[self.currentIndex] is None:
7538
+ self.dispersion_curves[self.currentIndex] = {'modes': {}}
7539
+ if 'modes' not in self.dispersion_curves[self.currentIndex]:
7540
+ self.dispersion_curves[self.currentIndex]['modes'] = {}
7541
+
7542
+ self.dispersion_curves[self.currentIndex]['modes'][state['current_mode']] = {
7543
+ 'picked_points': picked_points.copy()
7544
+ }
7545
+
7546
+ # Sync current mode data to other view
7547
+ other_view = 'bottom' if view == 'top' else 'top'
7548
+ other_state = self.dispersion_picking_state[other_view]
7549
+ self._ensure_mode_exists(other_view, state['current_mode'])
7550
+ other_mode_data = other_state['modes'][state['current_mode']]
7551
+ other_mode_data['picked_points'] = picked_points.copy()
7552
+ other_mode_data['picked_point_items'] = [None] * len(picked_points)
7099
7553
 
7100
7554
  def _refreshDispersionPicksDisplay(self):
7101
7555
  """Refresh display of all modes' picks and curves on dispersion plots"""
@@ -7470,6 +7924,13 @@ class MainWindow(QMainWindow):
7470
7924
  is_auto_mode = self.dispersionSemiAutoCheckbox.isChecked()
7471
7925
  vel_window = getattr(self, 'dispersion_auto_vel_window', 0.0)
7472
7926
 
7927
+ # Get trace spacing for aliasing limit
7928
+ dx = None
7929
+ if self.trace_position and self.currentIndex < len(self.trace_position):
7930
+ trace_pos = self.trace_position[self.currentIndex]
7931
+ if len(trace_pos) >= 2:
7932
+ dx = np.mean(np.abs(np.diff(trace_pos)))
7933
+
7473
7934
  if is_auto_mode:
7474
7935
  # Auto mode: find maximum at the clicked frequency, within optional velocity window
7475
7936
  freq_idx = np.argmin(np.abs(frequencies - clicked_freq))
@@ -7478,15 +7939,27 @@ class MainWindow(QMainWindow):
7478
7939
  # Get the dispersion values at this frequency
7479
7940
  dispersion_slice = FV[freq_idx, :]
7480
7941
 
7942
+ # Apply aliasing limit: exclude velocities below first-order aliasing boundary
7943
+ # Formula: vi = f/((f/v0) + (i/dx)), for i=1 the boundary is approximately v > f*dx
7944
+ aliasing_mask = np.ones(len(velocities), dtype=bool)
7945
+ if dx is not None and dx > 0:
7946
+ v_alias = actual_freq * dx # First-order aliasing boundary
7947
+ aliasing_mask = velocities >= v_alias
7948
+
7481
7949
  # Apply velocity window if specified (>0): search only around clicked velocity
7482
7950
  if vel_window > 0:
7483
7951
  vel_mask = np.abs(velocities - clicked_velocity) <= vel_window
7484
- if vel_mask.any():
7485
- masked_slice = np.where(vel_mask, dispersion_slice, -np.inf)
7952
+ combined_mask = vel_mask & aliasing_mask
7953
+ if combined_mask.any():
7954
+ masked_slice = np.where(combined_mask, dispersion_slice, -np.inf)
7486
7955
  else:
7487
7956
  masked_slice = dispersion_slice # fallback to full range if window empty
7488
7957
  else:
7489
- masked_slice = dispersion_slice
7958
+ # Just apply aliasing mask
7959
+ if aliasing_mask.any():
7960
+ masked_slice = np.where(aliasing_mask, dispersion_slice, -np.inf)
7961
+ else:
7962
+ masked_slice = dispersion_slice
7490
7963
 
7491
7964
  max_vel_idx = np.argmax(masked_slice)
7492
7965
  picked_velocity = velocities[max_vel_idx]
@@ -8116,12 +8589,17 @@ class MainWindow(QMainWindow):
8116
8589
  if view:
8117
8590
  self.interpolateDispersionCurve(view)
8118
8591
 
8119
- def loadDispersionCurvesFromPvc(self):
8120
- """Load dispersion curves from .pvc files and match to shots by Xmid"""
8121
- # Open file dialog to select .pvc files
8122
- file_paths, _ = QFileDialog.getOpenFileNames(
8123
- self, "Select .pvc Files", "", "PVC Files (*.pvc);;All Files (*)"
8124
- )
8592
+ def loadDispersionCurvesFromPvc(self, file_paths=None):
8593
+ """Load dispersion curves from .pvc files and match to shots by Xmid
8594
+
8595
+ Args:
8596
+ file_paths: Optional list of file paths. If None, opens file dialog.
8597
+ """
8598
+ # Open file dialog if no paths provided
8599
+ if file_paths is None:
8600
+ file_paths, _ = QFileDialog.getOpenFileNames(
8601
+ self, "Select .pvc Files", "", "PVC Files (*.pvc);;All Files (*)"
8602
+ )
8125
8603
 
8126
8604
  if not file_paths:
8127
8605
  return
@@ -8364,8 +8842,6 @@ class MainWindow(QMainWindow):
8364
8842
  unmatch_label.setWordWrap(True)
8365
8843
  unmatch_label.setStyleSheet("color: #ff6f00;")
8366
8844
  content_layout.addWidget(unmatch_label)
8367
-
8368
- # Debug info removed - was too verbose for normal operation
8369
8845
 
8370
8846
  content_layout.addStretch()
8371
8847
  scroll_area.setWidget(content_widget)
@@ -8633,6 +9109,22 @@ class MainWindow(QMainWindow):
8633
9109
 
8634
9110
  def _on_dispersion_live_interpolate_changed(self, state):
8635
9111
  """Handler for live interpolate checkbox state change"""
9112
+ # Synchronize with pseudo-section interpolate checkboxes
9113
+ is_checked = (state == Qt.Checked)
9114
+ if hasattr(self, 'topPseudosectionInterpCheck'):
9115
+ self.topPseudosectionInterpCheck.blockSignals(True)
9116
+ self.topPseudosectionInterpCheck.setChecked(is_checked)
9117
+ self.topPseudosectionInterpCheck.blockSignals(False)
9118
+ if hasattr(self, 'bottomPseudosectionInterpCheck'):
9119
+ self.bottomPseudosectionInterpCheck.blockSignals(True)
9120
+ self.bottomPseudosectionInterpCheck.setChecked(is_checked)
9121
+ self.bottomPseudosectionInterpCheck.blockSignals(False)
9122
+ self.pseudosection_show_interpolated = is_checked
9123
+
9124
+ # Interpolate for pseudo-section if enabling
9125
+ if is_checked:
9126
+ self._interpolateAllShotsForPseudoSection()
9127
+
8636
9128
  view = self._get_active_dispersion_view()
8637
9129
  if view:
8638
9130
  if state == Qt.Checked:
@@ -8684,6 +9176,86 @@ class MainWindow(QMainWindow):
8684
9176
  if len(mode_data['picked_points']) >= 2:
8685
9177
  self.interpolateDispersionCurve(view)
8686
9178
 
9179
+ def onDispersionStackToggled(self, state):
9180
+ """Toggle stacking of dispersion images from all shots in current window range"""
9181
+ self.dispersion_stack_mode = bool(state)
9182
+ # Keep both top/bottom checkboxes in sync
9183
+ for attr in ("top_dispersionStackCheckbox", "bottom_dispersionStackCheckbox"):
9184
+ cb = getattr(self, attr, None)
9185
+ if cb is not None and cb.isChecked() != self.dispersion_stack_mode:
9186
+ cb.blockSignals(True)
9187
+ cb.setChecked(self.dispersion_stack_mode)
9188
+ cb.blockSignals(False)
9189
+ # Replot dispersion with stacking enabled/disabled
9190
+ if getattr(self, 'bottomPlotType', None) == 'dispersion':
9191
+ # Ensure we render into the bottom plot when it owns the dispersion view
9192
+ self._plot_target_widget = self.bottomPlotWidget
9193
+ self._plot_target_viewbox = self.bottomViewBox
9194
+ self.plotDispersion()
9195
+ self._plot_target_widget = None
9196
+ self._plot_target_viewbox = None
9197
+ if getattr(self, 'topPlotType', None) == 'dispersion':
9198
+ # Ensure we render into the top plot when it owns the dispersion view
9199
+ self._plot_target_widget = self.topPlotWidget
9200
+ self._plot_target_viewbox = self.topViewBox
9201
+ self.plotDispersion()
9202
+ self._plot_target_widget = None
9203
+ self._plot_target_viewbox = None
9204
+
9205
+ def onDispersionStackParamsClicked(self):
9206
+ """Open a dialog to configure shot stacking parameters for dispersion."""
9207
+ # Current parameter values
9208
+ min_off = getattr(self, 'dispersion_stack_min_offset', 0.0)
9209
+ max_off = getattr(self, 'dispersion_stack_max_offset', None)
9210
+ side_pref = getattr(self, 'dispersion_stack_side', 'Both sides')
9211
+
9212
+ parameters = [
9213
+ {
9214
+ 'label': 'Min offset from window edge (m)',
9215
+ 'initial_value': min_off,
9216
+ 'type': 'float'
9217
+ },
9218
+ {
9219
+ 'label': 'Max offset from window edge (m)',
9220
+ 'initial_value': '' if max_off is None else max_off,
9221
+ 'type': 'float_or_empty'
9222
+ },
9223
+ {
9224
+ 'label': 'Use shots from side',
9225
+ 'initial_value': side_pref,
9226
+ 'type': 'combo',
9227
+ 'values': ['Both sides', 'Left only', 'Right only']
9228
+ }
9229
+ ]
9230
+
9231
+ dialog = GenericParameterDialog(title="Stacking Parameters", parameters=parameters, add_checkbox=False, parent=self)
9232
+ if dialog.exec_():
9233
+ values = dialog.getValues()
9234
+ # Update internal parameters
9235
+ self.dispersion_stack_min_offset = values.get('Min offset from window edge (m)', 0.0) or 0.0
9236
+ self.dispersion_stack_max_offset = values.get('Max offset from window edge (m)', None)
9237
+ self.dispersion_stack_side = values.get('Use shots from side', 'Both sides') or 'Both sides'
9238
+
9239
+ # Replot dispersion in whichever view(s) currently show it
9240
+ if getattr(self, 'bottomPlotType', None) == 'dispersion':
9241
+ self._plot_target_widget = self.bottomPlotWidget
9242
+ self._plot_target_viewbox = self.bottomViewBox
9243
+ try:
9244
+ self.plotDispersion()
9245
+ except Exception:
9246
+ pass
9247
+ self._plot_target_widget = None
9248
+ self._plot_target_viewbox = None
9249
+ if getattr(self, 'topPlotType', None) == 'dispersion':
9250
+ self._plot_target_widget = self.topPlotWidget
9251
+ self._plot_target_viewbox = self.topViewBox
9252
+ try:
9253
+ self.plotDispersion()
9254
+ except Exception:
9255
+ pass
9256
+ self._plot_target_widget = None
9257
+ self._plot_target_viewbox = None
9258
+
8687
9259
  def _calculateDispersionErrors(self, view, mode_data=None):
8688
9260
  """Calculate Lorentzian errors for dispersion picks
8689
9261
 
@@ -9332,7 +9904,10 @@ class MainWindow(QMainWindow):
9332
9904
  flat_plot_data_x = [item for sublist in plot_data_x for item in sublist]
9333
9905
 
9334
9906
  # Access the appropriate attribute - flatten picks from ALL streams (like Layout View)
9335
- flat_plot_data_y = [item for sublist in self.picks for item in sublist]
9907
+ if self.picks is not None:
9908
+ flat_plot_data_y = [item for sublist in self.picks for item in sublist]
9909
+ else:
9910
+ flat_plot_data_y = []
9336
9911
 
9337
9912
  # Get unique traces and times from list of list of traces array that are not None
9338
9913
  traces = [trace for trace in flat_plot_data_x if trace is not None]
@@ -9806,6 +10381,8 @@ class MainWindow(QMainWindow):
9806
10381
  self.plotSpectrogram()
9807
10382
  elif self.topPlotType == "dispersion":
9808
10383
  self.plotDispersion()
10384
+ elif self.topPlotType == "pseudosection":
10385
+ self.plotPseudoSection()
9809
10386
 
9810
10387
  # Reset target widget after plotting
9811
10388
  self._plot_target_widget = None
@@ -9828,12 +10405,23 @@ class MainWindow(QMainWindow):
9828
10405
  self.plotSpectrogram()
9829
10406
  elif self.bottomPlotType == "dispersion":
9830
10407
  self.plotDispersion()
10408
+ elif self.bottomPlotType == "pseudosection":
10409
+ self.plotPseudoSection()
9831
10410
 
9832
10411
  # Reset target widget after plotting
9833
10412
  self._plot_target_widget = None
9834
10413
  self._plot_target_viewbox = None
9835
10414
 
9836
- # Update dispersion window info in status bar
10415
+ # Update FFID/source in status bar for all views
10416
+ if hasattr(self, 'ffid') and self.ffid and self.currentIndex < len(self.ffid):
10417
+ try:
10418
+ ffid_val = self.ffid[self.currentIndex]
10419
+ src_val = self.source_position[self.currentIndex] if self.source_position else 'N/A'
10420
+ self.ffidLabel.setText(f'FFID: {ffid_val} | Source at {src_val} m')
10421
+ except Exception:
10422
+ pass
10423
+
10424
+ # Update dispersion window info in status bar
9837
10425
  self.updateDispersionWindowInfo()
9838
10426
 
9839
10427
  def navigateToPreviousFile(self):
@@ -10051,10 +10639,6 @@ class MainWindow(QMainWindow):
10051
10639
  # Optionally subtract the mean to center around zero
10052
10640
  # data_matrix = data_matrix - np.mean(data_matrix)
10053
10641
 
10054
- print(f"DEBUG: Matrix shape: {data_matrix.shape}")
10055
- print(f"DEBUG: Value range: {np.min(data_matrix)} to {np.max(data_matrix)}")
10056
- print(f"DEBUG: Data type: {data_matrix.dtype}")
10057
-
10058
10642
  # Convert to ObsPy stream
10059
10643
  stream = self.ascii_to_obspy_stream(data_matrix, params)
10060
10644
 
@@ -16055,11 +16639,20 @@ class MainWindow(QMainWindow):
16055
16639
  self.tracePositionAction.setChecked(False)
16056
16640
  # Disable air wave checkbox when not plotting by position (but keep it checked)
16057
16641
  if hasattr(self, 'airWaveWiggleCheck'):
16058
- self.airWaveWiggleCheck.setEnabled(False)
16642
+ try:
16643
+ self.airWaveWiggleCheck.setEnabled(False)
16644
+ except RuntimeError:
16645
+ pass
16059
16646
  if hasattr(self, 'showAirWaveAction'):
16060
- self.showAirWaveAction.setEnabled(False)
16647
+ try:
16648
+ self.showAirWaveAction.setEnabled(False)
16649
+ except RuntimeError:
16650
+ pass
16061
16651
  if hasattr(self, 'seismoShowAirWaveCheck'):
16062
- self.seismoShowAirWaveCheck.setEnabled(False)
16652
+ try:
16653
+ self.seismoShowAirWaveCheck.setEnabled(False)
16654
+ except RuntimeError:
16655
+ pass
16063
16656
  self.mean_dg = 1
16064
16657
  self.x_label = 'Trace Number (Original Field Record)'
16065
16658
  if self.streams:
@@ -17316,6 +17909,16 @@ class MainWindow(QMainWindow):
17316
17909
  self.update_pick_flag = False
17317
17910
  self.update_file_flag = False
17318
17911
 
17912
+ # Update status bar FFID/source when dispersion or pseudo-section is active (ensure it stays current when navigating shots)
17913
+ if (self.bottomPlotType in ('pseudosection', 'dispersion') or
17914
+ self.topPlotType in ('pseudosection', 'dispersion')) and self.currentIndex is not None:
17915
+ try:
17916
+ ffid_val = self.ffid[self.currentIndex] if hasattr(self, 'ffid') and self.ffid else 'N/A'
17917
+ src_val = self.source_position[self.currentIndex] if self.source_position else 'N/A'
17918
+ self.ffidLabel.setText(f'FFID: {ffid_val} | Source at {src_val} m')
17919
+ except Exception:
17920
+ pass
17921
+
17319
17922
  # Draw/update trace extent indicator (only shown on seismogram when dispersion is active)
17320
17923
  self.drawTraceExtentIndicator()
17321
17924
 
@@ -17841,76 +18444,212 @@ class MainWindow(QMainWindow):
17841
18444
  first_trace = max(0, min(first_trace, n_traces - 1))
17842
18445
  last_trace = max(first_trace + 1, min(last_trace, n_traces - 1)) # Ensure at least 2 traces
17843
18446
 
17844
- # Select traces within the specified range
17845
- trace_indices = np.arange(first_trace, last_trace + 1)
17846
- offsets_all = np.array([self.offset[self.currentIndex][i] for i in trace_indices])
17847
- trace_pos_all = np.array([self.trace_position[self.currentIndex][i] for i in trace_indices])
17848
- source_pos = self.source_position[self.currentIndex]
17849
-
17850
- # Compute distances (absolute distance from source)
17851
- distances_all = np.abs(offsets_all)
17852
-
17853
- # Apply topography correction if enabled
17854
- if getattr(self, 'use_topography_correction', False):
17855
- # Get elevation data for selected traces and source
17856
- trace_elevations = np.array([self.trace_elevation[self.currentIndex][i] for i in trace_indices])
17857
- source_elevation = self.source_elevation[self.currentIndex]
18447
+ # Check if stacking is enabled
18448
+ use_stacking = getattr(self, 'dispersion_stack_mode', False)
18449
+
18450
+ if use_stacking:
18451
+ # Stack dispersion from all shots with traces in current window range
18452
+ # Get common parameters
18453
+ vmin = getattr(self, 'vmin', 10.0)
18454
+ vmax = getattr(self, 'vmax', 1500.0)
18455
+ dv = getattr(self, 'dv', 5.0)
18456
+ dt_ref = self.sample_interval[self.currentIndex]
18457
+ nyquist = 1.0 / (2 * dt_ref)
18458
+ fmin_cfg = self.fmin if getattr(self, 'fmin', None) is not None else 0.0
18459
+ fmax_default = min(100.0, nyquist)
18460
+ fmax_cfg = self.fmax if getattr(self, 'fmax', None) is not None else fmax_default
18461
+ fmin = max(0.0, min(fmin_cfg, nyquist))
18462
+ fmax = max(0.0, min(fmax_cfg, nyquist))
18463
+
18464
+ stacked_FV = None
18465
+ fs_ref = None
18466
+ vs_ref = None
18467
+ shot_count = 0
18468
+
18469
+ for shot_idx, stream_shot in enumerate(self.streams):
18470
+ n_traces_shot = len(stream_shot)
18471
+ if n_traces_shot <= first_trace:
18472
+ continue
18473
+
18474
+ last_trace_actual = min(last_trace, n_traces_shot - 1)
18475
+ trace_indices_shot = np.arange(first_trace, last_trace_actual + 1)
18476
+
18477
+ # Exclude shots whose source lies inside the current trace window
18478
+ if (self.trace_position and shot_idx < len(self.trace_position) and
18479
+ self.source_position and shot_idx < len(self.source_position)):
18480
+ trace_positions_window = [self.trace_position[shot_idx][i] for i in trace_indices_shot]
18481
+ source_pos = self.source_position[shot_idx]
18482
+ window_min = min(trace_positions_window)
18483
+ window_max = max(trace_positions_window)
18484
+
18485
+ # Skip this shot if source is inside the window
18486
+ if window_min <= source_pos <= window_max:
18487
+ continue
18488
+
18489
+ # Apply side preference and offset constraints relative to window edges
18490
+ side_pref = getattr(self, 'dispersion_stack_side', 'Both sides')
18491
+ min_off = getattr(self, 'dispersion_stack_min_offset', 0.0) or 0.0
18492
+ max_off = getattr(self, 'dispersion_stack_max_offset', None)
18493
+
18494
+ # Determine which side the shot is on and its edge offset
18495
+ if source_pos < window_min:
18496
+ shot_side = 'Left only'
18497
+ edge_offset = window_min - source_pos
18498
+ elif source_pos > window_max:
18499
+ shot_side = 'Right only'
18500
+ edge_offset = source_pos - window_max
18501
+ else:
18502
+ # Already excluded above; keep for safety
18503
+ continue
18504
+
18505
+ # Enforce side preference
18506
+ if side_pref == 'Left only' and shot_side != 'Left only':
18507
+ continue
18508
+ if side_pref == 'Right only' and shot_side != 'Right only':
18509
+ continue
18510
+
18511
+ # Enforce offset constraints
18512
+ if edge_offset < min_off:
18513
+ continue
18514
+ if (max_off is not None) and (edge_offset > max_off):
18515
+ continue
18516
+
18517
+ try:
18518
+ offsets_all_shot = np.array([self.offset[shot_idx][i] for i in trace_indices_shot])
18519
+ distances_all_shot = np.abs(offsets_all_shot)
18520
+
18521
+ if getattr(self, 'use_topography_correction', False):
18522
+ trace_elev = np.array([self.trace_elevation[shot_idx][i] for i in trace_indices_shot])
18523
+ source_elev = self.source_elevation[shot_idx]
18524
+ dz = source_elev - trace_elev
18525
+ distances_all_shot = np.sqrt(distances_all_shot**2 + dz**2)
18526
+
18527
+ sorted_order = np.argsort(offsets_all_shot)
18528
+ distances_sorted = distances_all_shot[sorted_order]
18529
+ trace_indices_shot_sorted = trace_indices_shot[sorted_order]
18530
+
18531
+ data_shot = np.array([stream_shot[i].data for i in trace_indices_shot_sorted])
18532
+ dt_shot = self.sample_interval[shot_idx]
18533
+
18534
+ fs, vs, FV_shot = phase_shift(data_shot, dt_shot, distances_sorted, vmin, vmax, dv, fmax, fmin)
18535
+
18536
+ eps = 1e-12
18537
+ # Discard amplitudes below aliasing boundary for this shot
18538
+ dx_alias = None
18539
+ if len(distances_sorted) >= 2:
18540
+ dx_alias = np.mean(np.abs(np.diff(distances_sorted)))
18541
+ if dx_alias and dx_alias > 0:
18542
+ v_alias = 2.0 * fs[:, None] * dx_alias
18543
+ alias_mask = vs[None, :] >= v_alias
18544
+ FV_shot = np.where(alias_mask, FV_shot, 0.0)
18545
+
18546
+ if getattr(self, 'dispersion_norm_per_freq', False):
18547
+ max_per_f = np.maximum(np.max(FV_shot, axis=1, keepdims=True), eps)
18548
+ FV_shot = FV_shot / max_per_f
18549
+
18550
+ if stacked_FV is None:
18551
+ stacked_FV = FV_shot.copy()
18552
+ fs_ref = fs
18553
+ vs_ref = vs
18554
+ else:
18555
+ stacked_FV += FV_shot
18556
+
18557
+ shot_count += 1
18558
+ except (IndexError, ValueError, KeyError):
18559
+ continue
17858
18560
 
17859
- # Calculate elevation differences
17860
- dz = trace_elevations - source_elevation
18561
+ if stacked_FV is None or shot_count == 0:
18562
+ QMessageBox.warning(self, "No Data", "No shots found in current window range.")
18563
+ return
17861
18564
 
17862
- # Calculate slant distance: sqrt(horizontal_distance^2 + elevation_difference^2)
17863
- distances_all = np.sqrt(distances_all**2 + dz**2)
17864
-
17865
- # Apply topography correction if enabled
17866
- if getattr(self, 'use_topography_correction', False):
17867
- # Get elevation data for selected traces and source
17868
- trace_elevations = np.array([self.trace_elevation[self.currentIndex][i] for i in trace_indices])
17869
- source_elevation = self.source_elevation[self.currentIndex]
18565
+ # Store the number of shots used in stacking for status bar display
18566
+ self.dispersion_stack_shot_count = shot_count
17870
18567
 
17871
- # Calculate elevation differences
17872
- dz = trace_elevations - source_elevation
18568
+ FV = stacked_FV / shot_count
18569
+ fs = fs_ref
18570
+ vs = vs_ref
18571
+ else:
18572
+ # Original single-shot logic
18573
+ # Skip computation if source is within the current trace window
18574
+ trace_indices = np.arange(first_trace, last_trace + 1)
18575
+ trace_pos_all = np.array([self.trace_position[self.currentIndex][i] for i in trace_indices])
18576
+ source_pos = self.source_position[self.currentIndex]
18577
+ window_min = np.min(trace_pos_all)
18578
+ window_max = np.max(trace_pos_all)
18579
+
18580
+ if window_min <= source_pos <= window_max:
18581
+ # Source is within window, skip computation silently
18582
+ return
17873
18583
 
17874
- # Calculate slant distance: sqrt(horizontal_distance^2 + elevation_difference^2)
17875
- distances_all = np.sqrt(distances_all**2 + dz**2)
17876
-
17877
- # Sort traces by offset value (left to right spatially)
17878
- sorted_order = np.argsort(offsets_all)
17879
- trace_indices = trace_indices[sorted_order]
17880
- trace_pos_sorted = trace_pos_all[sorted_order]
17881
- offsets_sorted = offsets_all[sorted_order]
17882
- distances_sorted = distances_all[sorted_order]
17883
-
17884
- # Get data in sorted order
17885
- data = np.array([stream[i].data for i in trace_indices])
17886
- offsets = distances_sorted # Pass distances (absolute) to phase_shift, not offsets
17887
-
17888
- # Get parameters
17889
- dt = self.sample_interval[self.currentIndex]
17890
-
17891
- # Parameters for phase-shift analysis (use UI values if provided)
17892
- vmin = getattr(self, 'vmin', 10.0)
17893
- vmax = getattr(self, 'vmax', 1500.0)
17894
- dv = getattr(self, 'dv', 5.0)
17895
- nyquist = 1.0 / (2 * dt)
17896
- # Use configured defaults; if None provided by UI, fall back to safe bounds
17897
- fmin_cfg = self.fmin if getattr(self, 'fmin', None) is not None else 0.0
17898
- fmax_default = min(100.0, nyquist)
17899
- fmax_cfg = self.fmax if getattr(self, 'fmax', None) is not None else fmax_default
17900
- # Clamp to [0, Nyquist] defensively
17901
- fmin = max(0.0, min(fmin_cfg, nyquist))
17902
- fmax = max(0.0, min(fmax_cfg, nyquist))
17903
-
17904
- try:
18584
+ offsets_all = np.array([self.offset[self.currentIndex][i] for i in trace_indices])
18585
+
18586
+ # Compute distances (absolute distance from source)
18587
+ distances_all = np.abs(offsets_all)
18588
+
18589
+ # Apply topography correction if enabled
18590
+ if getattr(self, 'use_topography_correction', False):
18591
+ # Get elevation data for selected traces and source
18592
+ trace_elevations = np.array([self.trace_elevation[self.currentIndex][i] for i in trace_indices])
18593
+ source_elevation = self.source_elevation[self.currentIndex]
18594
+
18595
+ # Calculate elevation differences
18596
+ dz = trace_elevations - source_elevation
18597
+
18598
+ # Calculate slant distance: sqrt(horizontal_distance^2 + elevation_difference^2)
18599
+ distances_all = np.sqrt(distances_all**2 + dz**2)
18600
+
18601
+ # Sort traces by offset value (left to right spatially)
18602
+ sorted_order = np.argsort(offsets_all)
18603
+ trace_indices = trace_indices[sorted_order]
18604
+ trace_pos_sorted = trace_pos_all[sorted_order]
18605
+ offsets_sorted = offsets_all[sorted_order]
18606
+ distances_sorted = distances_all[sorted_order]
18607
+
18608
+ # Get data in sorted order
18609
+ data = np.array([stream[i].data for i in trace_indices])
18610
+ offsets = distances_sorted # Pass distances (absolute) to phase_shift, not offsets
18611
+
18612
+ # Get parameters
18613
+ dt = self.sample_interval[self.currentIndex]
18614
+
18615
+ # Parameters for phase-shift analysis (use UI values if provided)
18616
+ vmin = getattr(self, 'vmin', 10.0)
18617
+ vmax = getattr(self, 'vmax', 1500.0)
18618
+ dv = getattr(self, 'dv', 5.0)
18619
+ nyquist = 1.0 / (2 * dt)
18620
+ # Use configured defaults; if None provided by UI, fall back to safe bounds
18621
+ fmin_cfg = self.fmin if getattr(self, 'fmin', None) is not None else 0.0
18622
+ fmax_default = min(100.0, nyquist)
18623
+ fmax_cfg = self.fmax if getattr(self, 'fmax', None) is not None else fmax_default
18624
+ # Clamp to [0, Nyquist] defensively
18625
+ fmin = max(0.0, min(fmin_cfg, nyquist))
18626
+ fmax = max(0.0, min(fmax_cfg, nyquist))
17905
18627
  # Compute phase-shift transform
17906
18628
  fs, vs, FV = phase_shift(data, dt, offsets, vmin, vmax, dv, fmax, fmin)
17907
18629
 
18630
+ # Store raw FV for potential inversion transformation
18631
+ FV_raw = FV.copy()
18632
+
18633
+ # Discard amplitudes below aliasing boundary before normalization
18634
+ # v_alias = 2 * f * dx ; zero out energy below that
18635
+ dx_alias = None
18636
+ if self.trace_position and self.currentIndex < len(self.trace_position):
18637
+ trace_pos = self.trace_position[self.currentIndex]
18638
+ if len(trace_pos) >= 2:
18639
+ dx_alias = np.mean(np.abs(np.diff(trace_pos)))
18640
+ if dx_alias and dx_alias > 0:
18641
+ v_alias = 2.0 * fs[:, None] * dx_alias # shape (nfreq,1)
18642
+ alias_mask = vs[None, :] >= v_alias
18643
+ FV = np.where(alias_mask, FV, 0.0)
18644
+
17908
18645
  # Normalize based on user selection (Dispersion only uses per-frequency normalization)
17909
18646
  eps = 1e-12
17910
18647
  if getattr(self, 'dispersion_norm_per_freq', False):
17911
- # Per-velocity normalization for Phase-Shift (normalize each velocity independently)
17912
- max_per_v = np.maximum(np.max(FV, axis=0, keepdims=True), eps)
17913
- FV = FV / max_per_v
18648
+ # Per-frequency normalization for Phase-Shift (max of 1 at each frequency)
18649
+ max_per_f = np.maximum(np.max(FV, axis=1, keepdims=True), eps)
18650
+ FV = FV / max_per_f
18651
+
18652
+ try:
17914
18653
 
17915
18654
  # Apply frequency and velocity limits (clamp to Nyquist for safety)
17916
18655
  fmin = getattr(self, 'fmin', None)
@@ -17944,9 +18683,57 @@ class MainWindow(QMainWindow):
17944
18683
  vs = vs[vmask]
17945
18684
  FV = FV[:, vmask]
17946
18685
 
18686
+ # Apply display enhancement transformations
18687
+ FV_display = FV.copy()
18688
+
18689
+ # Apply enhancement transformation if enabled
18690
+ # IMPORTANT: Work on raw unnormalized data for proper dynamic range
18691
+ if getattr(self, 'dispersion_enhance', False):
18692
+ # Use raw data for enhancement to avoid issues with normalized [0,1] range
18693
+ FV_for_enhance = FV_raw.copy()
18694
+
18695
+ # Apply enhancement formula: 1/(max-x) to amplify weak modes
18696
+ max_val = np.max(FV_for_enhance)
18697
+ epsilon = max_val * 0.01 # 1% of max to prevent division by zero
18698
+ FV_display = 1.0 / (max_val - FV_for_enhance + epsilon)
18699
+
18700
+ # Normalize to [0, 1] range
18701
+ vmin, vmax = np.min(FV_display), np.max(FV_display)
18702
+ if vmax > vmin:
18703
+ FV_display = (FV_display - vmin) / (vmax - vmin)
18704
+ else:
18705
+ FV_display = np.zeros_like(FV_display)
18706
+
17947
18707
  # Create image item with colormap (rotated: freq on x-axis, velocity on y-axis)
17948
18708
  colormap = getattr(self, 'dispersion_colormap_str', 'plasma')
17949
- image_item = createImageItem(FV.T, fs, vs, colormap=colormap)
18709
+ image_item = createImageItem(FV_display.T, fs, vs, colormap=colormap)
18710
+
18711
+ # Apply MATLAB-like brighten to the colormap instead of data (beta in [-1,1])
18712
+ saturation_factor = getattr(self, 'dispersion_saturation', 1.0)
18713
+ beta = (saturation_factor - 1.0) / 0.5 # 1.0->0, 0.5->-1 (darken), 1.5->+1 (brighten)
18714
+ beta = max(-1.0, min(1.0, beta))
18715
+ if abs(beta) > 1e-6:
18716
+ try:
18717
+ cmap_obj = image_item.getColorMap()
18718
+ if cmap_obj is None:
18719
+ cmap_obj = pqg.colormap.get(colormap, source='matplotlib')
18720
+ if cmap_obj is not None:
18721
+ lut = cmap_obj.getLookupTable(nPts=256, alpha=True)
18722
+ lut = lut.astype(float)
18723
+ if lut.max() > 1.0:
18724
+ lut = lut / 255.0
18725
+ tol = np.sqrt(np.finfo(float).eps)
18726
+ if beta > 0:
18727
+ gamma = 1.0 - min(1.0 - tol, beta)
18728
+ else:
18729
+ gamma = 1.0 / (1.0 + max(-1.0 + tol, beta))
18730
+ lut[:, :3] = np.clip(np.power(lut[:, :3], gamma), 0.0, 1.0)
18731
+ lut_out = (lut * 255).astype(np.ubyte)
18732
+ positions = np.linspace(0.0, 1.0, lut_out.shape[0])
18733
+ bright_cmap = pqg.ColorMap(positions, lut_out)
18734
+ image_item.setColorMap(bright_cmap)
18735
+ except Exception:
18736
+ pass
17950
18737
 
17951
18738
  # Store image data and coordinates for amplitude lookup
17952
18739
  self.dispersionImageData = FV.T
@@ -18009,11 +18796,18 @@ class MainWindow(QMainWindow):
18009
18796
  pass
18010
18797
  plot_widget.scene().sigMouseClicked.connect(lambda ev: self.onDispersionClick(ev, view_key))
18011
18798
 
18799
+ # Add aliasing limit curve if enabled
18800
+ if getattr(self, 'dispersion_show_aliasing_limit', False):
18801
+ self._plotAliasingLimit(plot_widget, fs, vs) # Spatial aliasing (below surface-wave energy)
18802
+
18012
18803
  # Update error bars if show errors is enabled
18013
18804
  self._updateDispersionErrorBars(view_key)
18014
18805
 
18015
18806
  # Update interpolated error lines if show interpolated errors is enabled
18016
18807
  self._updateDispersionInterpErrors(view_key)
18808
+
18809
+ # Update status bar with dispersion window info and stacking info
18810
+ self.updateDispersionWindowInfo()
18017
18811
 
18018
18812
  except ValueError as e:
18019
18813
  QMessageBox.warning(self, "Phase-Shift Error",
@@ -18022,6 +18816,44 @@ class MainWindow(QMainWindow):
18022
18816
  QMessageBox.warning(self, "Computation Error",
18023
18817
  f"An error occurred:\n{str(e)}")
18024
18818
 
18819
+ def _plotAliasingLimit(self, plot_widget, frequencies, velocities):
18820
+ """Plot the spatial aliasing limits on the dispersion image.
18821
+
18822
+ Energy below surface-wave caused by spatial undersampling.
18823
+
18824
+ Spatial aliasing occurs when wavelength < 2*dx
18825
+ Phase velocity v = f*lambda, so aliasing when v < f*2*dx
18826
+ First-order aliasing boundary: v_alias = 2 * f * dx
18827
+ """
18828
+ try:
18829
+ # Get trace spacing for the current shot
18830
+ if not (self.trace_position and self.currentIndex < len(self.trace_position)):
18831
+ return
18832
+
18833
+ trace_pos = self.trace_position[self.currentIndex]
18834
+ if len(trace_pos) < 2:
18835
+ return
18836
+
18837
+ # Calculate mean trace spacing
18838
+ dx = np.mean(np.abs(np.diff(trace_pos)))
18839
+ if dx <= 0:
18840
+ return
18841
+
18842
+ f_array = np.array(frequencies)
18843
+ v_max = np.max(velocities)
18844
+ v_min = np.min(velocities)
18845
+
18846
+ # Plot first-order aliasing band only (wavelength < 2*dx)
18847
+ v_alias_boundary = 2.0 * f_array * dx
18848
+ v_alias_boundary = np.clip(v_alias_boundary, v_min, v_max)
18849
+ pen = pqg.mkPen(color=(255, 0, 0, 200), width=2, style=Qt.DashLine)
18850
+ curve = pqg.PlotCurveItem(f_array, v_alias_boundary, pen=pen)
18851
+ plot_widget.addItem(curve)
18852
+
18853
+ except Exception as e:
18854
+ # Silently skip if aliasing limit cannot be computed
18855
+ pass
18856
+
18025
18857
  def plotPseudoSection(self):
18026
18858
  """Plot pseudo-section: phase velocity as 2D image (xmid vs frequency/wavelength)"""
18027
18859
  plot_widget, viewbox = self._getBottomPlotWidgets()
@@ -18033,24 +18865,82 @@ class MainWindow(QMainWindow):
18033
18865
  self._safelyclearPlot(plot_widget)
18034
18866
  self.removeLegend()
18035
18867
  self.removeColorBar()
18868
+
18869
+ # Update status bar with current FFID and source info so it stays fresh when pseudo-section is active
18870
+ if self.currentIndex is not None:
18871
+ try:
18872
+ ffid_val = self.ffid[self.currentIndex] if hasattr(self, 'ffid') and self.ffid else 'N/A'
18873
+ src_val = self.source_position[self.currentIndex] if self.source_position else 'N/A'
18874
+ self.ffidLabel.setText(f'FFID: {ffid_val} | Source at {src_val} m')
18875
+ except Exception:
18876
+ pass
18036
18877
 
18037
18878
  # Collect all shots with dispersion picks
18038
- xmid_list = [] # list of (shot_idx, xmid)
18879
+ xcoord_list = [] # list of (shot_idx, x_coord)
18039
18880
  shot_freqs = [] # list of frequency arrays per shot
18040
18881
  shot_vels = [] # list of velocity arrays per shot
18041
18882
 
18042
18883
  use_wavelength = getattr(self, 'pseudosection_use_wavelength', False)
18043
18884
  show_interp = getattr(self, 'pseudosection_show_interpolated', False)
18044
18885
  selected_mode = getattr(self, 'pseudosection_mode', 0)
18886
+ use_source_x = getattr(self, 'pseudosection_use_source_x', False)
18045
18887
 
18046
18888
  for shot_idx, shot_curves in enumerate(self.dispersion_curves):
18047
18889
  if shot_curves is None or 'modes' not in shot_curves:
18048
18890
  continue
18049
18891
 
18050
- # Get xmid for this shot
18051
- if shot_idx not in self.xmid_values:
18052
- continue
18053
- xmid = self.xmid_values[shot_idx]
18892
+ # Determine X coordinate for this shot
18893
+ if use_source_x:
18894
+ x_coord = None
18895
+ # Check plotTypeY to determine whether to use FFID or source position
18896
+ plot_type_y = getattr(self, 'plotTypeY', 'source_position')
18897
+ if plot_type_y == 'ffid' and hasattr(self, 'ffid') and shot_idx < len(self.ffid):
18898
+ # Use FFID
18899
+ try:
18900
+ x_coord = float(self.ffid[shot_idx])
18901
+ except Exception:
18902
+ x_coord = None
18903
+ elif shot_idx < len(self.source_position):
18904
+ # Use source position
18905
+ x_coord = self.source_position[shot_idx]
18906
+ elif hasattr(self, 'ffid') and shot_idx < len(self.ffid):
18907
+ # Fallback to FFID if source position not available
18908
+ try:
18909
+ x_coord = float(self.ffid[shot_idx])
18910
+ except Exception:
18911
+ x_coord = None
18912
+ if x_coord is None and shot_idx in getattr(self, 'xmid_values', {}):
18913
+ x_coord = self.xmid_values[shot_idx]
18914
+ else:
18915
+ if shot_idx not in self.xmid_values:
18916
+ continue
18917
+ xmid_pos = self.xmid_values[shot_idx] # Window center position in meters
18918
+
18919
+ # Determine if we're using trace numbers or positions
18920
+ plot_type_x = getattr(self, 'plotTypeX', 'trace_position')
18921
+ if plot_type_x == 'trace_position':
18922
+ # Use window center position directly
18923
+ x_coord = xmid_pos
18924
+ else:
18925
+ # Convert window center position to theoretical trace number
18926
+ # Interpolate from trace positions to trace numbers for this shot
18927
+ if shot_idx < len(self.plotTypeDict.get(plot_type_x, [])) and shot_idx < len(self.trace_position):
18928
+ trace_nums = self.plotTypeDict[plot_type_x][shot_idx]
18929
+ trace_positions = self.trace_position[shot_idx]
18930
+ if len(trace_nums) > 0 and len(trace_positions) > 0:
18931
+ # Interpolate to find trace number at window center position
18932
+ from scipy.interpolate import interp1d
18933
+ try:
18934
+ f = interp1d(trace_positions, trace_nums, kind='linear',
18935
+ bounds_error=False, fill_value='extrapolate')
18936
+ x_coord = float(f(xmid_pos))
18937
+ except Exception:
18938
+ # Fallback to position if interpolation fails
18939
+ x_coord = xmid_pos
18940
+ else:
18941
+ x_coord = xmid_pos
18942
+ else:
18943
+ x_coord = xmid_pos
18054
18944
 
18055
18945
  # Collect selected mode picks
18056
18946
  if selected_mode not in shot_curves['modes']:
@@ -18071,22 +18961,33 @@ class MainWindow(QMainWindow):
18071
18961
  freqs = np.array([p[0] for p in points])
18072
18962
  vels = np.array([p[1] for p in points])
18073
18963
 
18074
- if freqs is not None and len(freqs) > 0:
18075
- xmid_list.append((shot_idx, xmid))
18964
+ if freqs is not None and len(freqs) > 0 and x_coord is not None:
18965
+ xcoord_list.append((shot_idx, x_coord))
18076
18966
  shot_freqs.append(freqs)
18077
18967
  shot_vels.append(vels)
18078
18968
 
18079
- if not xmid_list:
18969
+ if not xcoord_list:
18970
+ # Set labels even when no data - use same logic as main plotting
18971
+ if use_source_x:
18972
+ x_label = getattr(self, 'y_label', 'Source Position (m)')
18973
+ else:
18974
+ plot_type_x = getattr(self, 'plotTypeX', 'trace_position')
18975
+ if plot_type_x == 'trace_position':
18976
+ x_label = 'Window center (m)'
18977
+ else:
18978
+ # Trace number modes
18979
+ x_label = 'Window center (trace no)'
18080
18980
  plot_widget.setLabel('left', 'Frequency (Hz)' if not use_wavelength else 'Wavelength (m)')
18081
- plot_widget.setLabel('bottom', 'Position (m)')
18981
+ plot_widget.setLabel('top', x_label)
18982
+ plot_widget.setLabel('bottom', '')
18082
18983
  return
18083
18984
 
18084
- # Sort by xmid
18085
- sorted_idx = np.argsort([x[1] for x in xmid_list])
18086
- xmid_list = [xmid_list[i] for i in sorted_idx]
18985
+ # Sort by X coordinate
18986
+ sorted_idx = np.argsort([x[1] for x in xcoord_list])
18987
+ xcoord_list = [xcoord_list[i] for i in sorted_idx]
18087
18988
  shot_freqs = [shot_freqs[i] for i in sorted_idx]
18088
18989
  shot_vels = [shot_vels[i] for i in sorted_idx]
18089
- xmids = np.array([x[1] for x in xmid_list])
18990
+ xcoords = np.array([x[1] for x in xcoord_list])
18090
18991
 
18091
18992
  # Create 2D grid: freq/λ vs xmid
18092
18993
  # For simplicity, create a scatter plot with colormap encoding velocity
@@ -18095,9 +18996,9 @@ class MainWindow(QMainWindow):
18095
18996
  plot_data_vel = []
18096
18997
 
18097
18998
  for i, (freqs, vels) in enumerate(zip(shot_freqs, shot_vels)):
18098
- xmid = xmids[i]
18999
+ x_val = xcoords[i]
18099
19000
  for freq, vel in zip(freqs, vels):
18100
- plot_data_x.append(xmid)
19001
+ plot_data_x.append(x_val)
18101
19002
  if use_wavelength:
18102
19003
  # λ = v / f
18103
19004
  wavelength = vel / freq if freq > 0 else 0
@@ -18133,33 +19034,99 @@ class MainWindow(QMainWindow):
18133
19034
  size=6, pen=None, brush=brush_list)
18134
19035
  plot_widget.addItem(scatter)
18135
19036
 
18136
- # Add vertical red lines around the current shot's xmid
18137
- if self.currentIndex is not None and self.currentIndex in self.xmid_values:
18138
- current_xmid = self.xmid_values[self.currentIndex]
18139
-
18140
- # Calculate mean spacing between shots
18141
- if len(xmids) > 1:
18142
- mean_dxmid = np.mean(np.abs(np.diff(xmids)))
18143
- else:
18144
- mean_dxmid = self.mean_dg # fallback to trace spacing
18145
-
18146
- # Y-axis range for the vertical lines
18147
- y_min_line, y_max_line = plot_data_y.min(), plot_data_y.max()
18148
-
18149
- # Draw two vertical lines at xmid ± mean_dxmid/2
18150
- x_line_1 = current_xmid - mean_dxmid/2
18151
- x_line_2 = current_xmid + mean_dxmid/2
18152
- y_line = [y_min_line, y_max_line]
18153
-
18154
- line1 = pqg.PlotDataItem([x_line_1, x_line_1], y_line, pen=pqg.mkPen('r', width=2))
18155
- line2 = pqg.PlotDataItem([x_line_2, x_line_2], y_line, pen=pqg.mkPen('r', width=2))
18156
- plot_widget.addItem(line1)
18157
- plot_widget.addItem(line2)
19037
+ # Add vertical red lines around the current shot's x position
19038
+ if self.currentIndex is not None:
19039
+ current_x = None
19040
+ if use_source_x:
19041
+ # Match the x-axis choice: use FFID when plotTypeY is ffid, else source position
19042
+ plot_type_y = getattr(self, 'plotTypeY', 'source_position')
19043
+ if plot_type_y == 'ffid' and hasattr(self, 'ffid') and self.currentIndex < len(self.ffid):
19044
+ try:
19045
+ current_x = float(self.ffid[self.currentIndex])
19046
+ except Exception:
19047
+ current_x = None
19048
+ elif self.currentIndex < len(self.source_position):
19049
+ current_x = self.source_position[self.currentIndex]
19050
+ elif hasattr(self, 'ffid') and self.currentIndex < len(self.ffid):
19051
+ try:
19052
+ current_x = float(self.ffid[self.currentIndex])
19053
+ except Exception:
19054
+ current_x = None
19055
+ if current_x is None and self.currentIndex in getattr(self, 'xmid_values', {}):
19056
+ current_x = self.xmid_values[self.currentIndex]
19057
+ elif self.currentIndex in self.xmid_values:
19058
+ # Use window center - convert to trace number if needed
19059
+ xmid_pos = self.xmid_values[self.currentIndex]
19060
+ plot_type_x = getattr(self, 'plotTypeX', 'trace_position')
19061
+ if plot_type_x == 'trace_position':
19062
+ current_x = xmid_pos
19063
+ else:
19064
+ # Convert window center position to theoretical trace number
19065
+ if self.currentIndex < len(self.plotTypeDict.get(plot_type_x, [])) and self.currentIndex < len(self.trace_position):
19066
+ trace_nums = self.plotTypeDict[plot_type_x][self.currentIndex]
19067
+ trace_positions = self.trace_position[self.currentIndex]
19068
+ if len(trace_nums) > 0 and len(trace_positions) > 0:
19069
+ from scipy.interpolate import interp1d
19070
+ try:
19071
+ f = interp1d(trace_positions, trace_nums, kind='linear',
19072
+ bounds_error=False, fill_value='extrapolate')
19073
+ current_x = float(f(xmid_pos))
19074
+ except Exception:
19075
+ current_x = xmid_pos
19076
+ else:
19077
+ current_x = xmid_pos
19078
+ else:
19079
+ current_x = xmid_pos
19080
+
19081
+ if current_x is not None:
19082
+ # Calculate spacing between guide lines based on axis mode
19083
+ if use_source_x:
19084
+ plot_type_y = getattr(self, 'plotTypeY', 'source_position')
19085
+ # Use 1 for FFID mode, otherwise calculate from actual source spacing
19086
+ if plot_type_y == 'ffid':
19087
+ mean_dx = 1.0
19088
+ elif plot_type_y == 'source_position' and len(self.source_position) > 1:
19089
+ # Use actual spacing between all source positions from the data
19090
+ mean_dx = np.mean(np.abs(np.diff(self.source_position)))
19091
+ else:
19092
+ mean_dx = getattr(self, 'mean_dg', 1.0)
19093
+ else:
19094
+ plot_type_x = getattr(self, 'plotTypeX', 'trace_position')
19095
+ # Use mean_dg for trace positions, 1 for trace number/ffid modes
19096
+ # Don't refine - window centers (xmid) can overlap between shots
19097
+ mean_dx = getattr(self, 'mean_dg', 1.0) if plot_type_x == 'trace_position' else 1.0
19098
+
19099
+ # Y-axis range for the vertical lines
19100
+ y_min_line, y_max_line = plot_data_y.min(), plot_data_y.max()
19101
+
19102
+ # Draw two vertical lines at x ± mean_dx/2
19103
+ x_line_1 = current_x - mean_dx/2
19104
+ x_line_2 = current_x + mean_dx/2
19105
+ y_line = [y_min_line, y_max_line]
19106
+
19107
+ line1 = pqg.PlotDataItem([x_line_1, x_line_1], y_line, pen=pqg.mkPen('r', width=2))
19108
+ line2 = pqg.PlotDataItem([x_line_2, x_line_2], y_line, pen=pqg.mkPen('r', width=2))
19109
+ plot_widget.addItem(line1)
19110
+ plot_widget.addItem(line2)
18158
19111
 
18159
19112
  # Set axis labels
18160
19113
  y_label = 'Wavelength (m)' if use_wavelength else 'Frequency (Hz)'
19114
+ # Determine X-axis label based on checkbox and current plot type
19115
+ if use_source_x:
19116
+ # Use the same label as layout view's Y-axis (depends on plotTypeY)
19117
+ x_label = getattr(self, 'y_label', 'Source Position (m)')
19118
+ else:
19119
+ # When not using source position, we're using window centers (xmid)
19120
+ plot_type_x = getattr(self, 'plotTypeX', 'trace_position')
19121
+ if plot_type_x == 'trace_position':
19122
+ # Window center as position in meters
19123
+ x_label = 'Window center (m)'
19124
+ else:
19125
+ # Window center as theoretical trace number
19126
+ x_label = 'Window center (trace no)'
18161
19127
  plot_widget.setLabel('left', y_label)
18162
- plot_widget.setLabel('bottom', 'Position (m)')
19128
+ plot_widget.setLabel('top', x_label)
19129
+ plot_widget.setLabel('bottom', '')
18163
19130
 
18164
19131
  # Create colormap and colorbar (mirror layout view behavior)
18165
19132
  try:
@@ -18177,43 +19144,72 @@ class MainWindow(QMainWindow):
18177
19144
  plot_widget.plotItem.layout.addItem(self.colorbar_title_label, 2, 6)
18178
19145
 
18179
19146
  plot_widget.showAxis('left')
18180
- plot_widget.showAxis('bottom')
18181
19147
  plot_widget.showAxis('top')
18182
19148
  plot_widget.showAxis('right')
19149
+ plot_widget.showAxis('bottom')
18183
19150
  plot_widget.getAxis('right').setStyle(showValues=False)
19151
+ plot_widget.getAxis('bottom').setLabel('')
19152
+ plot_widget.getAxis('top').setStyle(showValues=True)
18184
19153
 
18185
19154
  # Apply Y-axis invert setting
18186
19155
  invert_y = getattr(self, 'pseudosection_invert_y', True)
18187
19156
  viewbox.invertY(invert_y)
18188
19157
 
18189
- # Set limits - use same x-axis range as layout view for consistency
19158
+ # Set limits
18190
19159
  if len(plot_data_x) > 0:
18191
- # Get layout view x-axis range (all trace positions across all shots)
18192
- self.updatePlotTypeDict()
18193
- plot_data_x_layout = self.plotTypeDict.get(self.plotTypeX, [])
18194
- flat_plot_data_x = [item for sublist in plot_data_x_layout for item in sublist]
18195
- traces = [trace for trace in flat_plot_data_x if trace is not None]
18196
-
18197
19160
  # Y-axis uses data from pseudo-section
18198
19161
  y_min, y_max = plot_data_y.min(), plot_data_y.max()
18199
19162
  y_margin = (y_max - y_min) * 0.05 if y_max > y_min else 1.0
18200
19163
 
18201
- # X-axis matches layout view range
18202
- if traces:
18203
- x_min_layout = min(traces) - self.mean_dg
18204
- x_max_layout = max(traces) + self.mean_dg
18205
- viewbox.setLimits(xMin=x_min_layout, xMax=x_max_layout,
18206
- yMin=y_min-y_margin, yMax=y_max+y_margin)
18207
- viewbox.setXRange(x_min_layout, x_max_layout, padding=0)
18208
- viewbox.setYRange(y_min-y_margin, y_max+y_margin, padding=0)
18209
- else:
18210
- # Fallback to data-based range if layout info unavailable
18211
- x_min, x_max = plot_data_x.min(), plot_data_x.max()
19164
+ # X-axis range depends on checkbox state
19165
+ if use_source_x:
19166
+ # Use full range of source position / FFID coordinates (all shots, not just those with picks)
19167
+ plot_type_y = getattr(self, 'plotTypeY', 'source_position')
19168
+ if plot_type_y == 'ffid' and hasattr(self, 'ffid') and self.ffid:
19169
+ # Use FFID range
19170
+ try:
19171
+ ffid_values = [float(f) for f in self.ffid if f is not None]
19172
+ if ffid_values:
19173
+ x_min, x_max = min(ffid_values), max(ffid_values)
19174
+ else:
19175
+ x_min, x_max = xcoords.min(), xcoords.max()
19176
+ except (ValueError, TypeError):
19177
+ x_min, x_max = xcoords.min(), xcoords.max()
19178
+ elif self.source_position:
19179
+ # Use source position range
19180
+ x_min, x_max = min(self.source_position), max(self.source_position)
19181
+ else:
19182
+ # Fallback to picked shots only
19183
+ x_min, x_max = xcoords.min(), xcoords.max()
19184
+
18212
19185
  x_margin = (x_max - x_min) * 0.05 if x_max > x_min else 1.0
18213
19186
  viewbox.setLimits(xMin=x_min-x_margin, xMax=x_max+x_margin,
18214
19187
  yMin=y_min-y_margin, yMax=y_max+y_margin)
18215
19188
  viewbox.setXRange(x_min-x_margin, x_max+x_margin, padding=0)
18216
19189
  viewbox.setYRange(y_min-y_margin, y_max+y_margin, padding=0)
19190
+ else:
19191
+ # X-axis matches layout view range (window centers based on plotTypeX)
19192
+ self.updatePlotTypeDict()
19193
+ plot_data_x_layout = self.plotTypeDict.get(self.plotTypeX, [])
19194
+ flat_plot_data_x = [item for sublist in plot_data_x_layout for item in sublist]
19195
+ traces = [trace for trace in flat_plot_data_x if trace is not None]
19196
+
19197
+ if traces:
19198
+ x_min_layout = min(traces) - self.mean_dg
19199
+ x_max_layout = max(traces) + self.mean_dg
19200
+ viewbox.setLimits(xMin=x_min_layout, xMax=x_max_layout,
19201
+ yMin=y_min-y_margin, yMax=y_max+y_margin)
19202
+ viewbox.setXRange(x_min_layout, x_max_layout, padding=0)
19203
+ viewbox.setYRange(y_min-y_margin, y_max+y_margin, padding=0)
19204
+ else:
19205
+ # Fallback to data-based range if layout info unavailable
19206
+ x_min, x_max = plot_data_x.min(), plot_data_x.max()
19207
+ x_margin = (x_max - x_min) * 0.05 if x_max > x_min else 1.0
19208
+ viewbox.setLimits(xMin=x_min-x_margin, xMax=x_max+x_margin,
19209
+ yMin=y_min-y_margin, yMax=y_max+y_margin)
19210
+ viewbox.setXRange(x_min-x_margin, x_max+x_margin, padding=0)
19211
+ viewbox.setYRange(y_min-y_margin, y_max+y_margin, padding=0)
19212
+
18217
19213
 
18218
19214
  def onPseudoSectionFreqLambdaToggled(self, state):
18219
19215
  """Toggle between frequency and wavelength display"""
@@ -18235,6 +19231,51 @@ class MainWindow(QMainWindow):
18235
19231
  def onPseudoSectionInterpToggled(self, state):
18236
19232
  """Toggle between picked and interpolated curve display"""
18237
19233
  self.pseudosection_show_interpolated = bool(state)
19234
+
19235
+ # Synchronize with dispersion view interpolate checkbox
19236
+ if hasattr(self, 'dispersionLiveInterpolateCheckbox'):
19237
+ self.dispersionLiveInterpolateCheckbox.blockSignals(True)
19238
+ self.dispersionLiveInterpolateCheckbox.setChecked(bool(state))
19239
+ self.dispersionLiveInterpolateCheckbox.blockSignals(False)
19240
+
19241
+ # If enabling interpolation, interpolate all shots for pseudo-section
19242
+ if bool(state):
19243
+ self._interpolateAllShotsForPseudoSection()
19244
+
19245
+ # Also trigger dispersion interpolation for active view
19246
+ view = self._get_active_dispersion_view()
19247
+ if view:
19248
+ if self.dispersionShowAllModesCheckbox.isChecked():
19249
+ self.interpolateAllDispersionCurves(view)
19250
+ else:
19251
+ mode_data = self._get_current_mode_data(view)
19252
+ if len(mode_data['picked_points']) >= 2:
19253
+ self.interpolateDispersionCurve(view)
19254
+ else:
19255
+ # Remove interpolated curves from dispersion view when disabling
19256
+ view = self._get_active_dispersion_view()
19257
+ if view:
19258
+ plot_widget = self.plotWidget if view == 'top' else self.bottomPlotWidget
19259
+ state_dict = self.dispersion_picking_state[view]
19260
+
19261
+ for mode_num, mode_data in state_dict['modes'].items():
19262
+ if mode_data['curve_line'] is not None:
19263
+ try:
19264
+ plot_widget.removeItem(mode_data['curve_line'])
19265
+ except:
19266
+ pass
19267
+ mode_data['curve_line'] = None
19268
+ if mode_data['interp_error_fill'] is not None:
19269
+ try:
19270
+ items = mode_data['interp_error_fill'] if isinstance(mode_data['interp_error_fill'], list) else [mode_data['interp_error_fill']]
19271
+ for item in items:
19272
+ if item is not None:
19273
+ plot_widget.removeItem(item)
19274
+ except:
19275
+ pass
19276
+ mode_data['interp_error_fill'] = None
19277
+ mode_data['curve_data'] = None
19278
+
18238
19279
  # Replot whichever view is showing pseudo-section
18239
19280
  if getattr(self, 'bottomPlotType', None) == "pseudosection":
18240
19281
  self._plot_target_widget = self.bottomPlotWidget
@@ -18283,6 +19324,89 @@ class MainWindow(QMainWindow):
18283
19324
  self._plot_target_widget = None
18284
19325
  self._plot_target_viewbox = None
18285
19326
 
19327
+ def onPseudoSectionUseSourceXToggled(self, state):
19328
+ """Toggle use of source position/FFID for pseudo-section X-axis"""
19329
+ self.pseudosection_use_source_x = bool(state)
19330
+ if getattr(self, 'bottomPlotType', None) == "pseudosection":
19331
+ self._plot_target_widget = self.bottomPlotWidget
19332
+ self._plot_target_viewbox = self.bottomViewBox
19333
+ self.plotPseudoSection()
19334
+ self._plot_target_widget = None
19335
+ self._plot_target_viewbox = None
19336
+ if getattr(self, 'topPlotType', None) == "pseudosection":
19337
+ self._plot_target_widget = self.plotWidget
19338
+ self._plot_target_viewbox = self.viewBox
19339
+ self.plotPseudoSection()
19340
+ self._plot_target_widget = None
19341
+ self._plot_target_viewbox = None
19342
+
19343
+ def _interpolateAllShotsForPseudoSection(self):
19344
+ """Interpolate dispersion curves for all shots that have picks.
19345
+ This is used when toggling interpolation in pseudo-section view."""
19346
+ if not self.dispersion_curves:
19347
+ return
19348
+
19349
+ # Get interpolation method from combo box
19350
+ method_text = self.dispersionInterpMethodCombo.currentText()
19351
+ min_points_required = {
19352
+ "Linear": 2,
19353
+ "Quadratic": 3,
19354
+ "Cubic": 4,
19355
+ "Spline": 4
19356
+ }
19357
+ min_points = min_points_required.get(method_text, 2)
19358
+
19359
+ # Process each shot
19360
+ for shot_idx, shot_curves in enumerate(self.dispersion_curves):
19361
+ if shot_curves is None or 'modes' not in shot_curves:
19362
+ continue
19363
+
19364
+ # Process each mode in this shot
19365
+ for mode_num, mode_data in shot_curves['modes'].items():
19366
+ if 'picked_points' not in mode_data or len(mode_data['picked_points']) < min_points:
19367
+ continue
19368
+
19369
+ try:
19370
+ # Sort points by frequency
19371
+ sorted_points = sorted(mode_data['picked_points'])
19372
+ frequencies = np.array([p[0] for p in sorted_points])
19373
+ velocities = np.array([p[1] for p in sorted_points])
19374
+
19375
+ # Create interpolated curve
19376
+ freq_min, freq_max = frequencies[0], frequencies[-1]
19377
+ # Use a reasonable frequency step (10 Hz or 1/100th of range)
19378
+ freq_range = freq_max - freq_min
19379
+ freq_step = min(10.0, freq_range / 100.0) if freq_range > 0 else 1.0
19380
+ interp_frequencies = np.arange(freq_min, freq_max + freq_step/2, freq_step/2)
19381
+
19382
+ method_map = {
19383
+ "Cubic": "cubic",
19384
+ "Linear": "linear",
19385
+ "Quadratic": "quadratic",
19386
+ "Spline": "cubic"
19387
+ }
19388
+ kind = method_map.get(method_text, "cubic")
19389
+
19390
+ # Interpolate using selected method
19391
+ from scipy.interpolate import interp1d, UnivariateSpline
19392
+
19393
+ if method_text == "Spline":
19394
+ spline = UnivariateSpline(frequencies, velocities, s=len(frequencies)*0.1, k=3)
19395
+ interp_velocities = spline(interp_frequencies)
19396
+ else:
19397
+ interp_func = interp1d(frequencies, velocities, kind=kind,
19398
+ bounds_error=False, fill_value='extrapolate')
19399
+ interp_velocities = interp_func(interp_frequencies)
19400
+
19401
+ # Store interpolated curve in dispersion_curves
19402
+ mode_data['frequencies'] = interp_frequencies.tolist()
19403
+ mode_data['velocities'] = interp_velocities.tolist()
19404
+ mode_data['interpolated'] = True
19405
+
19406
+ except Exception:
19407
+ # Skip this mode if interpolation fails
19408
+ continue
19409
+
18286
19410
  #######################################
18287
19411
  # Topo functions
18288
19412
  #######################################
@@ -18640,7 +19764,7 @@ class MainWindow(QMainWindow):
18640
19764
  def handleFreehandPick(self, drag_path, view="top"):
18641
19765
  """
18642
19766
  Called when a Ctrl+left mouse drag finishes (freehand pick).
18643
- Adds picks along the drag path.
19767
+ Adds picks along the drag path - picks ALL crossed traces (like dispersion).
18644
19768
 
18645
19769
  Args:
18646
19770
  drag_path: List of points from the drag operation
@@ -18668,20 +19792,75 @@ class MainWindow(QMainWindow):
18668
19792
  else:
18669
19793
  viewBox = self.viewBox
18670
19794
 
18671
- trace_indices = []
19795
+ # Convert drag path to view coordinates
19796
+ path_coords = []
18672
19797
  for pt in drag_path:
18673
19798
  mousePoint = viewBox.mapSceneToView(pt)
18674
- x = mousePoint.x()
18675
- y = mousePoint.y()
19799
+ path_coords.append((mousePoint.x(), mousePoint.y()))
19800
+
19801
+ if len(path_coords) < 2:
19802
+ return
19803
+
19804
+ # Get trace position range covered by the drag
19805
+ x_positions = [coord[0] for coord in path_coords]
19806
+ min_x = min(x_positions)
19807
+ max_x = max(x_positions)
19808
+
19809
+ # Get all trace positions for current file
19810
+ plot_data_x = self.plotTypeDict.get(self.plotTypeX, [])
19811
+ if not plot_data_x or self.currentIndex >= len(plot_data_x):
19812
+ return
19813
+
19814
+ trace_positions = np.array(plot_data_x[self.currentIndex])
19815
+
19816
+ # Find all traces that fall within the crossed range
19817
+ trace_mask = (trace_positions >= min_x) & (trace_positions <= max_x)
19818
+ crossed_trace_indices = np.where(trace_mask)[0]
19819
+
19820
+ if len(crossed_trace_indices) == 0:
19821
+ return
19822
+
19823
+ # Sort path once for interpolation
19824
+ sorted_path = sorted(path_coords, key=lambda c: c[0])
19825
+
19826
+ # Map trace_idx -> time (dedup per trace index)
19827
+ trace_to_time = {}
19828
+ for trace_idx in crossed_trace_indices:
19829
+ trace_x = trace_positions[trace_idx]
18676
19830
 
18677
- # Store trace index
18678
- plot_data_x = self.plotTypeDict.get(self.plotTypeX, [])
18679
- x_distance = np.array(plot_data_x[self.currentIndex]) - x
18680
- trace_idx = np.argmin(np.abs(x_distance))
18681
- trace_indices.append(trace_idx)
19831
+ # Find time at this trace by linear interpolation through drag path
19832
+ time_value = None
19833
+ for i in range(len(sorted_path) - 1):
19834
+ x1, t1 = sorted_path[i]
19835
+ x2, t2 = sorted_path[i + 1]
19836
+
19837
+ if x1 <= trace_x <= x2:
19838
+ # Linear interpolation
19839
+ if x2 != x1:
19840
+ s = (trace_x - x1) / (x2 - x1)
19841
+ time_value = t1 + s * (t2 - t1)
19842
+ else:
19843
+ time_value = t1
19844
+ break
18682
19845
 
18683
- # Add pick at coordinates
18684
- self._add_pick_at(x, y)
19846
+ # If trace is outside the path range, use nearest endpoint
19847
+ if time_value is None:
19848
+ if trace_x < sorted_path[0][0]:
19849
+ time_value = sorted_path[0][1]
19850
+ else:
19851
+ time_value = sorted_path[-1][1]
19852
+
19853
+ trace_to_time[trace_idx] = time_value
19854
+
19855
+ # Add picks for all crossed traces
19856
+ picked_traces = []
19857
+ for trace_idx in sorted(trace_to_time.keys()):
19858
+ time_value = trace_to_time[trace_idx]
19859
+ trace_x = trace_positions[trace_idx]
19860
+
19861
+ # Add pick at this trace/time
19862
+ self._add_pick_at(trace_x, time_value)
19863
+ picked_traces.append(trace_idx)
18685
19864
 
18686
19865
  self.update_pick_flag = True
18687
19866
  self.picks_modified = True # Mark picks as modified
@@ -18690,25 +19869,25 @@ class MainWindow(QMainWindow):
18690
19869
  if view == "bottom":
18691
19870
  # Picking in bottom seismogram - update bottom
18692
19871
  self._plot_target_widget = self.bottomPlotWidget
18693
- for trace_idx in trace_indices:
19872
+ for trace_idx in picked_traces:
18694
19873
  self._update_seismo_pick_display(trace_idx)
18695
19874
  self._plot_target_widget = None
18696
19875
  # Also update top seismogram if it's showing one
18697
19876
  if self.topPlotType == "seismogram":
18698
19877
  self._plot_target_widget = self.plotWidget
18699
- for trace_idx in trace_indices:
19878
+ for trace_idx in picked_traces:
18700
19879
  self._update_seismo_pick_display(trace_idx)
18701
19880
  self._plot_target_widget = None
18702
19881
  else: # view == "top"
18703
19882
  # Picking in top seismogram - update top
18704
19883
  self._plot_target_widget = self.plotWidget
18705
- for trace_idx in trace_indices:
19884
+ for trace_idx in picked_traces:
18706
19885
  self._update_seismo_pick_display(trace_idx)
18707
19886
  self._plot_target_widget = None
18708
19887
  # Also update bottom seismogram if it's showing one
18709
19888
  if self.bottomPlotType == "seismogram":
18710
19889
  self._plot_target_widget = self.bottomPlotWidget
18711
- for trace_idx in trace_indices:
19890
+ for trace_idx in picked_traces:
18712
19891
  self._update_seismo_pick_display(trace_idx)
18713
19892
  self._plot_target_widget = None
18714
19893
 
@@ -19240,7 +20419,8 @@ class MainWindow(QMainWindow):
19240
20419
  self.source_position,
19241
20420
  self.source_elevation,
19242
20421
  self.picks,
19243
- self.error
20422
+ self.error,
20423
+ geophone_mapping=self.geophone_mapping
19244
20424
  )
19245
20425
 
19246
20426
  self.picks_modified = False # Reset flag after successful save
@@ -19290,6 +20470,7 @@ class MainWindow(QMainWindow):
19290
20470
  matched_sgt_picks: set of (source_index, geophone_index) tuples that have been matched
19291
20471
  Returns tuple: (number of picks matched, number of sources with picks, max pick time)
19292
20472
  """
20473
+
19293
20474
  picks_matched = 0
19294
20475
  skipped_already_assigned = 0
19295
20476
  skipped_no_trace_found = 0
@@ -19305,7 +20486,7 @@ class MainWindow(QMainWindow):
19305
20486
  plot_data_x = self.plotTypeDict.get(self.plotTypeX, [])
19306
20487
 
19307
20488
  # Create progress dialog for alternative matching
19308
- mode_label = {"exact_x": "Exact X", "nearest_x": "Nearest X", "nearest_xz": "Nearest XZ"}
20489
+ mode_label = {"exact_x": "Exact X", "nearest_x": "Nearest X", "nearest_xz": "Nearest XZ", "match_order": "Order Matching"}
19309
20490
  progress = QProgressDialog(f"Alternative matching ({mode_label.get(matching_mode, matching_mode)})...", "Cancel", 0, len(self.fileNames), self)
19310
20491
  progress.setWindowTitle("Alternative Pick Matching")
19311
20492
  progress.setMinimumDuration(0)
@@ -19314,7 +20495,81 @@ class MainWindow(QMainWindow):
19314
20495
  progress.show()
19315
20496
  QApplication.processEvents()
19316
20497
 
19317
- # Loop over all shots/sources
20498
+ # For order-based matching, create mappings from actual data and SGT data
20499
+ source_order_map = None
20500
+ trace_order_maps = None
20501
+
20502
+ if matching_mode == "match_order":
20503
+ # Get all unique positions from headers (sources + traces)
20504
+ unique_header_positions = set()
20505
+
20506
+ for i in range(len(self.fileNames)):
20507
+ if self.source_position[i] is not None:
20508
+ unique_header_positions.add(self.source_position[i])
20509
+
20510
+ for shot_idx in range(len(self.fileNames)):
20511
+ if self.trace_position[shot_idx] is not None:
20512
+ for trace_pos in self.trace_position[shot_idx]:
20513
+ unique_header_positions.add(trace_pos)
20514
+
20515
+ sorted_header_positions = sorted(list(unique_header_positions))
20516
+
20517
+ # Get all station positions from SGT file
20518
+ sgt_station_positions = []
20519
+ for idx, sgt_station in enumerate(uploaded_stations):
20520
+ sgt_x = sgt_station[0]
20521
+ sgt_station_positions.append((sgt_x, idx + 1))
20522
+
20523
+ sgt_station_positions.sort(key=lambda x: x[0])
20524
+ sorted_sgt_positions = [pos[0] for pos in sgt_station_positions]
20525
+
20526
+ # Create position-to-index mapping for headers
20527
+ header_pos_map = {}
20528
+
20529
+ for shot_idx in range(len(self.fileNames)):
20530
+ if self.source_position[shot_idx] is not None:
20531
+ pos = self.source_position[shot_idx]
20532
+ if pos not in header_pos_map:
20533
+ header_pos_map[pos] = {'sources': [], 'traces': []}
20534
+ header_pos_map[pos]['sources'].append(shot_idx)
20535
+
20536
+ for shot_idx in range(len(self.fileNames)):
20537
+ if self.trace_position[shot_idx] is not None:
20538
+ for trace_idx, trace_pos in enumerate(self.trace_position[shot_idx]):
20539
+ if trace_pos not in header_pos_map:
20540
+ header_pos_map[trace_pos] = {'sources': [], 'traces': []}
20541
+ header_pos_map[trace_pos]['traces'].append((shot_idx, trace_idx))
20542
+
20543
+ # Create position-to-station-index mapping for SGT
20544
+ sgt_pos_to_stations = {}
20545
+ for sgt_x, station_idx_1based in sgt_station_positions:
20546
+ if sgt_x not in sgt_pos_to_stations:
20547
+ sgt_pos_to_stations[sgt_x] = []
20548
+ sgt_pos_to_stations[sgt_x].append(station_idx_1based)
20549
+
20550
+ # Match positions by sorted order
20551
+ source_order_map = {}
20552
+ trace_order_maps = {i: {} for i in range(len(self.fileNames))}
20553
+
20554
+ for order_idx in range(min(len(sorted_header_positions), len(sorted_sgt_positions))):
20555
+ header_pos = sorted_header_positions[order_idx]
20556
+ sgt_pos = sorted_sgt_positions[order_idx]
20557
+
20558
+ header_data = header_pos_map.get(header_pos, {'sources': [], 'traces': []})
20559
+ sgt_stations = sgt_pos_to_stations.get(sgt_pos, [])
20560
+
20561
+ # Map header sources to first SGT station at this order position
20562
+ if header_data['sources'] and sgt_stations:
20563
+ for shot_idx in header_data['sources']:
20564
+ source_order_map[shot_idx] = sgt_stations[0]
20565
+
20566
+ # Map header traces to SGT station(s) at this order position
20567
+ if header_data['traces'] and sgt_stations:
20568
+ for shot_idx, trace_idx in header_data['traces']:
20569
+ if shot_idx not in trace_order_maps:
20570
+ trace_order_maps[shot_idx] = {}
20571
+ trace_order_maps[shot_idx][trace_idx] = sgt_stations[0]
20572
+
19318
20573
  for i in range(len(self.fileNames)):
19319
20574
  # Update progress
19320
20575
  progress.setValue(i)
@@ -19332,7 +20587,11 @@ class MainWindow(QMainWindow):
19332
20587
  # Find matching source in SGT file based on mode
19333
20588
  source_index_in_sgt = None
19334
20589
 
19335
- if matching_mode == "exact_x":
20590
+ if matching_mode == "match_order":
20591
+ # Match by order - use the pre-computed mapping
20592
+ if source_order_map is not None and i in source_order_map:
20593
+ source_index_in_sgt = source_order_map[i]
20594
+ elif matching_mode == "exact_x":
19336
20595
  # Exact match by X only
19337
20596
  min_source_x_dist = float('inf')
19338
20597
  best_sgt_idx = None
@@ -19408,7 +20667,16 @@ class MainWindow(QMainWindow):
19408
20667
  # Find matching trace based on mode
19409
20668
  trace_index_in_shot = None
19410
20669
 
19411
- if matching_mode == "exact_x":
20670
+ if matching_mode == "match_order":
20671
+ # Match by order - use the pre-computed mapping
20672
+ # We need to find which trace corresponds to this SGT geophone
20673
+ if trace_order_maps is not None and i in trace_order_maps:
20674
+ # reverse lookup: find the header trace index that maps to sgt_geophone_number
20675
+ for header_trace_idx, sgt_geo_idx in trace_order_maps[i].items():
20676
+ if sgt_geo_idx == sgt_geophone_number:
20677
+ trace_index_in_shot = header_trace_idx
20678
+ break
20679
+ elif matching_mode == "exact_x":
19412
20680
  # Exact match by X only
19413
20681
  min_trace_x_dist = float('inf')
19414
20682
  best_trace_x = None
@@ -19423,12 +20691,7 @@ class MainWindow(QMainWindow):
19423
20691
  if x_dist <= tol_x:
19424
20692
  trace_index_in_shot = trace_idx
19425
20693
  break
19426
- if trace_index_in_shot is None and verbose:
19427
- print(
19428
- f" Trace X-only exact: SGT geophone X={geophone_x:.3f}, Z={(geophone_z if geophone_z is not None else float('nan')):.3f}; "
19429
- f"nearest trace X={(best_trace_x if best_trace_x is not None else float('nan')):.3f}, Z={(best_trace_z if best_trace_z is not None else float('nan')):.3f}; "
19430
- f"deltaX={min_trace_x_dist:.4f} m (tol={tol_x} m)"
19431
- )
20694
+ pass
19432
20695
  else:
19433
20696
  # Nearest neighbor matching
19434
20697
  min_dist = float('inf')
@@ -19474,11 +20737,19 @@ class MainWindow(QMainWindow):
19474
20737
  self.picks[i][trace_index_in_shot] = pick_time
19475
20738
  self.error[i][trace_index_in_shot] = error
19476
20739
 
20740
+ # Store geophone mapping for order matching
20741
+ if matching_mode == "match_order":
20742
+ if self.geophone_mapping is None:
20743
+ self.geophone_mapping = {}
20744
+ self.geophone_mapping[(i, trace_index_in_shot)] = sgt_geophone_number
20745
+
19477
20746
  # Track this SGT pick as successfully matched
19478
20747
  matched_sgt_picks.add((source_index_in_sgt, sgt_geophone_number))
19479
20748
 
19480
20749
  if not was_overwritten:
19481
20750
  picks_matched += 1
20751
+ if verbose and matching_mode == "match_order":
20752
+ print(f"[ORDER MATCHING] ✓ Matched pick: Shot {i}, SGT Source {source_index_in_sgt}, Geophone {sgt_geophone_number} -> Trace {trace_index_in_shot}, Time={pick_time:.4f}s")
19482
20753
 
19483
20754
  # Track this source as having picks and update max pick time
19484
20755
  sources_with_picks.add(i)
@@ -19489,48 +20760,20 @@ class MainWindow(QMainWindow):
19489
20760
  print(f" Matched pick: Shot {i+1}, SGT geophone #{sgt_geophone_number} (X={geophone_x:.2f}) -> Trace {trace_index_in_shot}, Time={pick_time:.3f}s")
19490
20761
  else:
19491
20762
  skipped_no_trace_found += 1
19492
- if verbose:
19493
- print(f" NO MATCH: Shot {i+1}, SGT geophone #{sgt_geophone_number} (X={geophone_x:.2f}) - could not find trace")
19494
-
19495
- # Print summary (only when verbose)
19496
- if verbose:
19497
- total_attempts = picks_matched + skipped_already_assigned + skipped_no_trace_found
19498
- print(f"\nMatching summary ({matching_mode}):")
19499
- print(f" ✓ Successfully matched: {picks_matched} picks for {len(sources_with_picks)} sources")
19500
- if skipped_already_assigned > 0:
19501
- print(f" ⊗ Skipped (trace already has pick): {skipped_already_assigned}")
19502
- if skipped_no_trace_found > 0:
19503
- print(f" ✗ Skipped (no matching trace found): {skipped_no_trace_found}")
19504
- if overwritten_count > 0:
19505
- print(f" ↻ Overwritten existing picks: {overwritten_count}")
19506
-
19507
- # Global diagnostics for exact X-only when few or no matches
19508
- if verbose and matching_mode == "exact_x" and picks_matched == 0:
19509
- try:
19510
- sgt_xs = [st[0] for st in uploaded_stations]
19511
- header_xs = []
19512
- for arr in self.trace_position:
19513
- if arr is not None:
19514
- header_xs.extend(list(arr))
19515
- if len(sgt_xs) > 0 and len(header_xs) > 0:
19516
- # Compute nearest header X for each SGT X
19517
- import math
19518
- nearest_dxs = []
19519
- # Use a simple O(N*M) for diagnostics; fast enough for typical sizes
19520
- for sx in sgt_xs:
19521
- md = min(abs(hx - sx) for hx in header_xs)
19522
- nearest_dxs.append(md)
19523
- within_tol = sum(1 for d in nearest_dxs if d <= tol_x)
19524
- min_dx = min(nearest_dxs)
19525
- max_dx = max(nearest_dxs)
19526
- med_dx = sorted(nearest_dxs)[len(nearest_dxs)//2]
19527
- print(" Exact-X diagnostics across all stations/traces:")
19528
- print(f" Stations: {len(sgt_xs)}, Traces: {len(header_xs)}")
19529
- print(f" Nearest deltaX stats: min={min_dx:.4f} m, median={med_dx:.4f} m, max={max_dx:.4f} m, within_tol({tol_x} m)={within_tol}")
19530
- except Exception as e:
19531
- if verbose:
19532
- print(f" Diagnostics error: {e}")
19533
20763
 
20764
+ # Show final summary dialog for order matching
20765
+ if matching_mode == "match_order":
20766
+ summary_text = f"""Order-Based Pick Matching Summary
20767
+
20768
+ Source mapping entries: {len(source_order_map) if source_order_map else 0}
20769
+ Trace mapping entries: {len(trace_order_maps) if trace_order_maps else 0}
20770
+
20771
+ Total SGT picks processed: {len(uploaded_picks)}
20772
+ Successfully matched: {picks_matched}
20773
+ Failed to match: {skipped_no_trace_found}"""
20774
+
20775
+ QMessageBox.information(None, "Matching Complete", summary_text)
20776
+
19534
20777
  # Close progress dialog
19535
20778
  progress.setValue(len(self.fileNames))
19536
20779
  progress.close()
@@ -19719,10 +20962,10 @@ class MainWindow(QMainWindow):
19719
20962
  if 'err' in best_fields:
19720
20963
  err_ind = best_fields.index('err')
19721
20964
  if verbose:
19722
- print(f"[DEBUG] Detected pick columns order: {best_fields} -> indices s={s_ind}, g={g_ind}, t={t_ind}, err={err_ind}")
20965
+ print(f"Detected pick columns order: {best_fields} -> indices s={s_ind}, g={g_ind}, t={t_ind}, err={err_ind}")
19723
20966
  except Exception as e:
19724
20967
  if verbose:
19725
- print(f"[DEBUG] Failed to apply header order, using defaults. Error: {e}")
20968
+ print(f"Failed to apply header order, using defaults. Error: {e}")
19726
20969
 
19727
20970
  # Read source index, trace index, pick time, pick error
19728
20971
  uploaded_picks = []
@@ -19766,7 +21009,7 @@ class MainWindow(QMainWindow):
19766
21009
  else:
19767
21010
  print(f" s={s_idx:4d}, g={g_idx:4d} -> INVALID geophone index (outside stations list)")
19768
21011
  except Exception as e:
19769
- print(f"[DEBUG] Failed to print SGT geometry/picks: {e}")
21012
+ pass
19770
21013
 
19771
21014
  if self.currentFileName is not None:
19772
21015
  # SGT files can be organized by shot order or by station order
@@ -20062,6 +21305,7 @@ class MainWindow(QMainWindow):
20062
21305
  btn_exact_x = msg.addButton("Exact match by X only", QMessageBox.YesRole)
20063
21306
  btn_nearest_x = msg.addButton("Nearest match by X only", QMessageBox.YesRole)
20064
21307
  btn_nearest_xz = msg.addButton("Nearest match by X and Z", QMessageBox.YesRole)
21308
+ btn_match_order = msg.addButton("Match by source/geophone order", QMessageBox.YesRole)
20065
21309
  btn_accept = msg.addButton("Accept as is", QMessageBox.AcceptRole)
20066
21310
  btn_cancel = msg.addButton("Cancel", QMessageBox.RejectRole)
20067
21311
 
@@ -20086,10 +21330,14 @@ class MainWindow(QMainWindow):
20086
21330
  matching_mode = "nearest_x"
20087
21331
  if verbose:
20088
21332
  print("\nAttempting nearest neighbor matching by X only...")
20089
- else: # btn_nearest_xz
21333
+ elif clicked_button == btn_nearest_xz:
20090
21334
  matching_mode = "nearest_xz"
20091
21335
  if verbose:
20092
21336
  print("\nAttempting nearest neighbor matching by X and Z...")
21337
+ else: # btn_match_order
21338
+ matching_mode = "match_order"
21339
+ if verbose:
21340
+ print("\nAttempting order-based matching of source/geophone positions...")
20093
21341
 
20094
21342
  # Perform the selected matching
20095
21343
  picks_matched_this_round, sources_matched_this_round, max_time_this_round = self._match_sgt_picks_alternative(
@@ -20112,8 +21360,8 @@ class MainWindow(QMainWindow):
20112
21360
  n_sources_total = len(sources_with_matched_picks)
20113
21361
 
20114
21362
  if verbose:
20115
- print(f"[DEBUG] matched_sgt_picks has {len(matched_sgt_picks)} entries")
20116
- print(f"[DEBUG] prev_n_picks_total={prev_n_picks_total}, n_picks_total={n_picks_total}")
21363
+ print(f"matched_sgt_picks has {len(matched_sgt_picks)} entries")
21364
+ print(f"prev_n_picks_total={prev_n_picks_total}, n_picks_total={n_picks_total}")
20117
21365
 
20118
21366
  if max_time_this_round > max_picked_time:
20119
21367
  max_picked_time = max_time_this_round