pyckster 26.1.1__tar.gz → 26.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {pyckster-26.1.1 → pyckster-26.1.2}/CHANGES.md +2 -0
  2. {pyckster-26.1.1/pyckster.egg-info → pyckster-26.1.2}/PKG-INFO +1 -1
  3. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/__init__.py +1 -1
  4. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/core.py +593 -112
  5. {pyckster-26.1.1 → pyckster-26.1.2/pyckster.egg-info}/PKG-INFO +1 -1
  6. {pyckster-26.1.1 → pyckster-26.1.2}/LICENCE +0 -0
  7. {pyckster-26.1.1 → pyckster-26.1.2}/MANIFEST.in +0 -0
  8. {pyckster-26.1.1 → pyckster-26.1.2}/README.md +0 -0
  9. {pyckster-26.1.1 → pyckster-26.1.2}/images/pyckster.png +0 -0
  10. {pyckster-26.1.1 → pyckster-26.1.2}/images/pyckster.svg +0 -0
  11. {pyckster-26.1.1 → pyckster-26.1.2}/images/screenshot_01.png +0 -0
  12. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/__main__.py +0 -0
  13. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/auto_picking.py +0 -0
  14. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/bayesian_inversion.py +0 -0
  15. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/inversion_app.py +0 -0
  16. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/inversion_manager.py +0 -0
  17. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/inversion_visualizer.py +0 -0
  18. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/ipython_console.py +0 -0
  19. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/mpl_export.py +0 -0
  20. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/obspy_utils.py +0 -0
  21. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/pac_inversion.py +0 -0
  22. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/pick_io.py +0 -0
  23. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/pyqtgraph_utils.py +0 -0
  24. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/surface_wave_analysis.py +0 -0
  25. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/surface_wave_profiling.py +0 -0
  26. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/sw_utils.py +0 -0
  27. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/tab_factory.py +0 -0
  28. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster/visualization_utils.py +0 -0
  29. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster.egg-info/SOURCES.txt +0 -0
  30. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster.egg-info/dependency_links.txt +0 -0
  31. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster.egg-info/entry_points.txt +0 -0
  32. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster.egg-info/not-zip-safe +0 -0
  33. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster.egg-info/requires.txt +0 -0
  34. {pyckster-26.1.1 → pyckster-26.1.2}/pyckster.egg-info/top_level.txt +0 -0
  35. {pyckster-26.1.1 → pyckster-26.1.2}/setup.cfg +0 -0
  36. {pyckster-26.1.1 → pyckster-26.1.2}/setup.py +0 -0
@@ -1,3 +1,5 @@
1
+ v26.1.2, Jan. 2026 -- Batch dispersion windowing, minor fixes
2
+
1
3
  v26.1.1, Jan. 2026 -- Code cleanup, minor bug fixes and improvements, add dispersion picking feature
2
4
 
3
5
  v25.12.4, Dec. 2025 -- Minor dependency fix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyckster
3
- Version: 26.1.1
3
+ Version: 26.1.2
4
4
  Summary: A PyQt5-based GUI for picking seismic traveltimes
5
5
  Home-page: https://gitlab.in2p3.fr/metis-geophysics/pyckster
6
6
  Author: Sylvain Pasquet
@@ -15,7 +15,7 @@ except ImportError:
15
15
  pass # matplotlib not available, that's fine
16
16
 
17
17
  # Define version and metadata in one place
18
- __version__ = "26.1.1"
18
+ __version__ = "26.1.2"
19
19
  __author__ = "Sylvain Pasquet"
20
20
  __email__ = "sylvain.pasquet@sorbonne-universite.fr"
21
21
  __license__ = "GPLv3"
@@ -709,9 +709,9 @@ class GenericParameterDialog(QDialog):
709
709
  combo = QComboBox(self)
710
710
  # Fill the combo box with the provided choices (expect the parameter to include a key 'values')
711
711
  for choice in param.get('values', []):
712
- combo.addItem(f"'{choice}'")
712
+ combo.addItem(choice)
713
713
  # Set current value if provided
714
- combo.setCurrentText(f"'{initial_value}'")
714
+ combo.setCurrentText(initial_value)
715
715
  self.lineEdits[label_text] = (combo, param_type)
716
716
  self.layout.addLayout(self.createFormItem(label_text, combo))
717
717
  elif param_type == 'slider':
@@ -851,9 +851,6 @@ class GenericParameterDialog(QDialog):
851
851
  elif param_type == 'combo':
852
852
  # For dropdown lists, get the current text
853
853
  value = lineEdit.currentText()
854
- # Optionally remove surrounding quotes if present
855
- if value.startswith("'") and value.endswith("'"):
856
- value = value[1:-1]
857
854
  values[label] = value
858
855
  elif param_type == 'slider':
859
856
  # For sliders, get the scaled value
@@ -3587,29 +3584,42 @@ class MainWindow(QMainWindow):
3587
3584
 
3588
3585
  # Analytical views: Normalization toggles (Spectrogram, Dispersion)
3589
3586
  if view_type in ("Spectrogram", "Dispersion"):
3590
- norm_per_trace_attr = 'norm_per_trace'
3591
- norm_per_freq_attr = 'norm_per_freq'
3592
- norm_per_trace_handler = 'onNormPerTraceChanged'
3593
- norm_per_freq_handler = 'onNormPerFreqChanged'
3594
-
3595
- norm_per_trace_check = QCheckBox()
3596
- norm_per_trace_check.setChecked(bool(getattr(self, norm_per_trace_attr, True)))
3597
- norm_per_trace_check.toggled.connect(getattr(self, norm_per_trace_handler))
3587
+ # Use view-specific normalization attributes
3588
+ view_prefix = view_type.lower()
3598
3589
 
3599
3590
  if view_type == "Spectrogram":
3591
+ # Spectrogram: both per-trace and per-freq normalization options
3592
+ norm_per_trace_attr = f'{view_prefix}_norm_per_trace'
3593
+ norm_per_freq_attr = f'{view_prefix}_norm_per_freq'
3594
+ norm_per_trace_handler = f'onNormPerTraceChanged_{view_type}'
3595
+ norm_per_freq_handler = f'onNormPerFreqChanged_{view_type}'
3596
+
3597
+ norm_per_trace_check = QCheckBox()
3598
+ norm_per_trace_check.setChecked(bool(getattr(self, norm_per_trace_attr, True)))
3599
+ norm_per_trace_check.toggled.connect(getattr(self, norm_per_trace_handler))
3600
3600
  norm_per_trace_check.setText("Norm. per trace")
3601
+
3601
3602
  norm_per_freq_check = QCheckBox()
3602
3603
  norm_per_freq_check.setChecked(bool(getattr(self, norm_per_freq_attr, False)))
3603
3604
  norm_per_freq_check.toggled.connect(getattr(self, norm_per_freq_handler))
3604
3605
  norm_per_freq_check.setText("Norm. per freq")
3606
+
3605
3607
  layout.addWidget(norm_per_trace_check)
3606
3608
  layout.addWidget(norm_per_freq_check)
3607
- setattr(self, 'normPerTraceCheck', norm_per_trace_check)
3608
- setattr(self, 'normPerFreqCheck', norm_per_freq_check)
3609
+ setattr(self, f'{prefix}NormPerTraceCheck_spectrogram', norm_per_trace_check)
3610
+ setattr(self, f'{prefix}NormPerFreqCheck_spectrogram', norm_per_freq_check)
3609
3611
  else:
3610
- norm_per_trace_check.setText("Norm.")
3611
- layout.addWidget(norm_per_trace_check)
3612
- setattr(self, 'normPerTraceCheck', norm_per_trace_check)
3612
+ # Dispersion: only per-freq normalization option
3613
+ norm_per_freq_attr = f'{view_prefix}_norm_per_freq'
3614
+ norm_per_freq_handler = f'onNormPerFreqChanged_{view_type}'
3615
+
3616
+ norm_per_freq_check = QCheckBox()
3617
+ norm_per_freq_check.setChecked(bool(getattr(self, norm_per_freq_attr, False)))
3618
+ norm_per_freq_check.toggled.connect(getattr(self, norm_per_freq_handler))
3619
+ norm_per_freq_check.setText("Norm.")
3620
+
3621
+ layout.addWidget(norm_per_freq_check)
3622
+ setattr(self, f'{prefix}NormPerFreqCheck_dispersion', norm_per_freq_check)
3613
3623
 
3614
3624
  # Topography correction checkbox (Dispersion only)
3615
3625
  if view_type == "Dispersion":
@@ -3619,6 +3629,14 @@ class MainWindow(QMainWindow):
3619
3629
  layout.addWidget(use_topo_check)
3620
3630
  setattr(self, 'useTopographyCheck', use_topo_check)
3621
3631
 
3632
+ # Permute axes button (Spectrogram only)
3633
+ if view_type == "Spectrogram":
3634
+ permute_button = QPushButton("Permute axes")
3635
+ permute_button.setToolTip("Swap frequency and trace axes")
3636
+ permute_button.clicked.connect(self.onSpectrogramPermuteAxes)
3637
+ layout.addWidget(permute_button)
3638
+ setattr(self, 'spectrogramPermuteButton', permute_button)
3639
+
3622
3640
  add_separator()
3623
3641
 
3624
3642
  # Frequency controls (Spectrogram, Dispersion)
@@ -3636,7 +3654,9 @@ class MainWindow(QMainWindow):
3636
3654
  fmin_spin.setMaximumWidth(70)
3637
3655
  fmin_spin.valueChanged.connect(getattr(self, fmin_handler))
3638
3656
  layout.addWidget(fmin_spin)
3657
+ # Store both generic and view-specific names
3639
3658
  setattr(self, 'fminSpin', fmin_spin)
3659
+ setattr(self, f'{prefix}_fminSpin', fmin_spin)
3640
3660
 
3641
3661
  fmax_spin = QDoubleSpinBox()
3642
3662
  fmax_spin.setRange(0.0, 1e4)
@@ -3647,7 +3667,9 @@ class MainWindow(QMainWindow):
3647
3667
  fmax_spin.setMaximumWidth(70)
3648
3668
  fmax_spin.valueChanged.connect(getattr(self, fmax_handler))
3649
3669
  layout.addWidget(fmax_spin)
3670
+ # Store both generic and view-specific names
3650
3671
  setattr(self, 'fmaxSpin', fmax_spin)
3672
+ setattr(self, f'{prefix}_fmaxSpin', fmax_spin)
3651
3673
 
3652
3674
  add_separator()
3653
3675
 
@@ -3692,7 +3714,21 @@ class MainWindow(QMainWindow):
3692
3714
  add_separator()
3693
3715
 
3694
3716
  # Trace selection for phase-shift using range slider
3695
- layout.addWidget(QLabel("Trace range:"))
3717
+ trace_range_container = QHBoxLayout()
3718
+ trace_range_label = QLabel("Trace range:")
3719
+ trace_range_container.addWidget(trace_range_label)
3720
+
3721
+ batch_window_button = QPushButton("Batch windowing")
3722
+ batch_window_button.setToolTip("Set fixed window parameters for all shots")
3723
+ batch_window_button.clicked.connect(self.onDispersionBatchWindowing)
3724
+ trace_range_container.addWidget(batch_window_button)
3725
+ trace_range_container.addStretch()
3726
+
3727
+ # Add the horizontal layout as a widget to the main layout
3728
+ trace_range_widget = QWidget()
3729
+ trace_range_widget.setLayout(trace_range_container)
3730
+ layout.addWidget(trace_range_widget)
3731
+
3696
3732
  trace_range_slider = RangeSlider()
3697
3733
  # Store which view this slider controls
3698
3734
  trace_range_slider.controlled_view = prefix # "top" or "bottom"
@@ -3770,45 +3806,50 @@ class MainWindow(QMainWindow):
3770
3806
  if hasattr(self, 'topDynamicControlsLayout'):
3771
3807
  self._clear_layout(self.topDynamicControlsLayout)
3772
3808
 
3773
- def onNormPerTraceChanged(self, state):
3774
- """Handle per-trace normalization toggle (unified for all views/positions)"""
3775
- self.norm_per_trace = bool(state)
3809
+ def onNormPerTraceChanged_Spectrogram(self, state):
3810
+ """Handle per-trace normalization toggle for Spectrogram view"""
3811
+ self.spectrogram_norm_per_trace = bool(state)
3776
3812
  if not (hasattr(self, 'currentIndex') and self.currentIndex is not None and self.streams):
3777
3813
  return
3778
- # Replot both top and bottom if they use this setting
3779
- if hasattr(self, 'topPlotType'):
3780
- if self.topPlotType == "spectrogram":
3781
- self._plot_target_widget = self.plotWidget
3782
- self._plot_target_viewbox = self.viewBox
3783
- self.plotSpectrogram()
3784
- self._plot_target_widget = None
3785
- self._plot_target_viewbox = None
3786
- elif self.topPlotType == "dispersion":
3787
- self._plot_target_widget = self.plotWidget
3788
- self._plot_target_viewbox = self.viewBox
3789
- self.plotDispersion()
3790
- self._plot_target_widget = None
3791
- self._plot_target_viewbox = None
3792
- if hasattr(self, 'bottomPlotType'):
3793
- if self.bottomPlotType == "spectrogram":
3794
- self._plot_target_widget = self.bottomPlotWidget
3795
- self._plot_target_viewbox = self.bottomViewBox
3796
- self.plotSpectrogram()
3797
- self._plot_target_widget = None
3798
- self._plot_target_viewbox = None
3799
- elif self.bottomPlotType == "dispersion":
3800
- self._plot_target_widget = self.bottomPlotWidget
3801
- self._plot_target_viewbox = self.bottomViewBox
3802
- self.plotDispersion()
3803
- self._plot_target_widget = None
3804
- self._plot_target_viewbox = None
3814
+ # Replot both top and bottom if they use spectrogram
3815
+ if hasattr(self, 'topPlotType') and self.topPlotType == "spectrogram":
3816
+ self._plot_target_widget = self.plotWidget
3817
+ self._plot_target_viewbox = self.viewBox
3818
+ self.plotSpectrogram()
3819
+ self._plot_target_widget = None
3820
+ self._plot_target_viewbox = None
3821
+ if hasattr(self, 'bottomPlotType') and self.bottomPlotType == "spectrogram":
3822
+ self._plot_target_widget = self.bottomPlotWidget
3823
+ self._plot_target_viewbox = self.bottomViewBox
3824
+ self.plotSpectrogram()
3825
+ self._plot_target_widget = None
3826
+ self._plot_target_viewbox = None
3805
3827
 
3806
- def onNormPerFreqChanged(self, state):
3807
- """Handle per-freq normalization toggle (unified for Spectrogram)"""
3808
- self.norm_per_freq = bool(state)
3828
+ def onNormPerTraceChanged_Dispersion(self, state):
3829
+ """Handle per-trace normalization toggle for Dispersion view"""
3830
+ self.dispersion_norm_per_trace = bool(state)
3831
+ if not (hasattr(self, 'currentIndex') and self.currentIndex is not None and self.streams):
3832
+ return
3833
+ # Replot both top and bottom if they use dispersion
3834
+ if hasattr(self, 'topPlotType') and self.topPlotType == "dispersion":
3835
+ self._plot_target_widget = self.plotWidget
3836
+ self._plot_target_viewbox = self.viewBox
3837
+ self.plotDispersion()
3838
+ self._plot_target_widget = None
3839
+ self._plot_target_viewbox = None
3840
+ if hasattr(self, 'bottomPlotType') and self.bottomPlotType == "dispersion":
3841
+ self._plot_target_widget = self.bottomPlotWidget
3842
+ self._plot_target_viewbox = self.bottomViewBox
3843
+ self.plotDispersion()
3844
+ self._plot_target_widget = None
3845
+ self._plot_target_viewbox = None
3846
+
3847
+ def onNormPerFreqChanged_Spectrogram(self, state):
3848
+ """Handle per-freq normalization toggle for Spectrogram view"""
3849
+ self.spectrogram_norm_per_freq = bool(state)
3809
3850
  if not (hasattr(self, 'currentIndex') and self.currentIndex is not None and self.streams):
3810
3851
  return
3811
- # Replot both top and bottom if they use this setting
3852
+ # Replot both top and bottom if they use spectrogram
3812
3853
  if hasattr(self, 'topPlotType') and self.topPlotType == "spectrogram":
3813
3854
  self._plot_target_widget = self.plotWidget
3814
3855
  self._plot_target_viewbox = self.viewBox
@@ -3821,7 +3862,39 @@ class MainWindow(QMainWindow):
3821
3862
  self.plotSpectrogram()
3822
3863
  self._plot_target_widget = None
3823
3864
  self._plot_target_viewbox = None
3824
-
3865
+
3866
+ def onNormPerFreqChanged_Dispersion(self, state):
3867
+ """Handle per-freq normalization toggle for Dispersion view"""
3868
+ self.dispersion_norm_per_freq = bool(state)
3869
+ if not (hasattr(self, 'currentIndex') and self.currentIndex is not None and self.streams):
3870
+ return
3871
+ # Replot both top and bottom if they use dispersion
3872
+ if hasattr(self, 'topPlotType') and self.topPlotType == "dispersion":
3873
+ self._plot_target_widget = self.plotWidget
3874
+ self._plot_target_viewbox = self.viewBox
3875
+ self.plotDispersion()
3876
+ self._plot_target_widget = None
3877
+ self._plot_target_viewbox = None
3878
+ if hasattr(self, 'bottomPlotType') and self.bottomPlotType == "dispersion":
3879
+ self._plot_target_widget = self.bottomPlotWidget
3880
+ self._plot_target_viewbox = self.bottomViewBox
3881
+ self.plotDispersion()
3882
+ self._plot_target_widget = None
3883
+ self._plot_target_viewbox = None
3884
+
3885
+ # Deprecated methods kept for backward compatibility
3886
+ def onNormPerTraceChanged(self, state):
3887
+ """Deprecated: use view-specific handlers instead"""
3888
+ self.norm_per_trace = bool(state)
3889
+ # Delegate to spectrogram handler for backward compatibility
3890
+ self.onNormPerTraceChanged_Spectrogram(state)
3891
+
3892
+ def onNormPerFreqChanged(self, state):
3893
+ """Deprecated: use view-specific handlers instead"""
3894
+ self.norm_per_freq = bool(state)
3895
+ # Delegate to spectrogram handler for backward compatibility
3896
+ self.onNormPerFreqChanged_Spectrogram(state)
3897
+
3825
3898
  def onUseTopographyChanged(self, state):
3826
3899
  """Handle topography correction checkbox toggle for phase-shift analysis"""
3827
3900
  self.use_topography_correction = bool(state)
@@ -3831,50 +3904,406 @@ class MainWindow(QMainWindow):
3831
3904
  if top_type == "dispersion" or bottom_type == "dispersion":
3832
3905
  self.updatePlots()
3833
3906
 
3907
+ def onSpectrogramPermuteAxes(self):
3908
+ """Toggle permutation of frequency and trace axes in spectrogram"""
3909
+ self.spectrogram_freq_on_y = not self.spectrogram_freq_on_y
3910
+
3911
+ # Replot both top and bottom if they use spectrogram
3912
+ if hasattr(self, 'topPlotType') and self.topPlotType == "spectrogram":
3913
+ self._plot_target_widget = self.plotWidget
3914
+ self._plot_target_viewbox = self.viewBox
3915
+ self.plotSpectrogram()
3916
+ self._plot_target_widget = None
3917
+ self._plot_target_viewbox = None
3918
+ if hasattr(self, 'bottomPlotType') and self.bottomPlotType == "spectrogram":
3919
+ self._plot_target_widget = self.bottomPlotWidget
3920
+ self._plot_target_viewbox = self.bottomViewBox
3921
+ self.plotSpectrogram()
3922
+ self._plot_target_widget = None
3923
+ self._plot_target_viewbox = None
3924
+
3925
+ def onDispersionBatchWindowing(self):
3926
+ """Configure batch windowing parameters for all shots in dispersion view"""
3927
+ parameters = [
3928
+ {'label': 'Window length (traces)', 'initial_value': 10, 'type': 'int'},
3929
+ {'label': 'Window side', 'initial_value': 'best', 'type': 'combo', 'values': ['left', 'right', 'best']},
3930
+ {'label': 'Prefer side (when best)', 'initial_value': 'right', 'type': 'combo', 'values': ['left', 'right']},
3931
+ {'label': 'Offset from shot (traces)', 'initial_value': 1, 'type': 'int'}
3932
+ ]
3933
+
3934
+ dialog = GenericParameterDialog(
3935
+ title="Batch Windowing Parameters",
3936
+ parameters=parameters,
3937
+ parent=self
3938
+ )
3939
+
3940
+ if dialog.exec_():
3941
+ values = dialog.getValues()
3942
+ window_length = values.get('Window length (traces)', 10)
3943
+ window_side = values.get('Window side', 'best')
3944
+ prefer_side = values.get('Prefer side (when best)', 'right')
3945
+ offset_from_shot = values.get('Offset from shot (traces)', 1)
3946
+
3947
+ # Apply batch windowing to all shots
3948
+ self._applyBatchWindowingToAllShots(window_length, window_side, offset_from_shot, prefer_side)
3949
+
3950
+ def _applyBatchWindowingToAllShots(self, window_length, window_side, offset_from_shot, prefer_side='right'):
3951
+ """Apply batch windowing parameters to all loaded shots using shot and geophone positions"""
3952
+ if not self.streams:
3953
+ QMessageBox.warning(self, "No Data", "No data loaded.")
3954
+ return
3955
+
3956
+ try:
3957
+ # Initialize batch windowing memory if not exists
3958
+ if not hasattr(self, 'batch_windowing_params'):
3959
+ self.batch_windowing_params = {}
3960
+
3961
+ # Store parameters
3962
+ self.batch_windowing_params['window_length'] = window_length
3963
+ self.batch_windowing_params['window_side'] = window_side
3964
+ self.batch_windowing_params['offset_from_shot'] = offset_from_shot
3965
+ self.batch_windowing_params['prefer_side'] = prefer_side
3966
+
3967
+ # Apply to each shot
3968
+ for shot_idx, stream in enumerate(self.streams):
3969
+ n_traces = len(stream)
3970
+
3971
+ # Get shot position and trace positions
3972
+ if shot_idx >= len(self.source_position) or shot_idx >= len(self.trace_position):
3973
+ continue
3974
+
3975
+ shot_pos = self.source_position[shot_idx]
3976
+ trace_positions = np.array(self.trace_position[shot_idx])
3977
+
3978
+ # Calculate mean geophone spacing
3979
+ if len(trace_positions) > 1:
3980
+ sorted_positions = np.sort(trace_positions)
3981
+ mean_dg = np.mean(np.diff(sorted_positions))
3982
+ else:
3983
+ mean_dg = 1.0 # Default spacing if only one trace
3984
+
3985
+ # Calculate offset distance threshold
3986
+ offset_distance = offset_from_shot * mean_dg
3987
+
3988
+ # Calculate distances from shot (positive = right, negative = left)
3989
+ distances = trace_positions - shot_pos
3990
+ abs_distances = np.abs(distances)
3991
+
3992
+ # Sort traces by distance from shot to determine left/right
3993
+ sorted_indices = np.argsort(distances)
3994
+ sorted_distances = distances[sorted_indices]
3995
+
3996
+ # Find traces on left (negative distance) and right (positive distance)
3997
+ left_mask = sorted_distances < 0
3998
+ right_mask = sorted_distances > 0
3999
+
4000
+ left_indices = sorted_indices[left_mask]
4001
+ right_indices = sorted_indices[right_mask]
4002
+
4003
+ # Calculate window range based on side preference
4004
+ first_trace = None
4005
+ last_trace = None
4006
+
4007
+ if window_side == 'left':
4008
+ # Use traces to the left of shot, beyond offset_distance
4009
+ if len(left_indices) > 0:
4010
+ # Filter left traces that are beyond the offset distance
4011
+ left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance]
4012
+
4013
+ if len(left_beyond_offset) > 0:
4014
+ # Sort by absolute distance (closest to shot first, among those beyond offset)
4015
+ left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4016
+ # Take window_length traces
4017
+ selected_traces = left_sorted[:window_length]
4018
+ first_trace = int(np.min(selected_traces))
4019
+ last_trace = int(np.max(selected_traces))
4020
+
4021
+ elif window_side == 'right':
4022
+ # Use traces to the right of shot, beyond offset_distance
4023
+ if len(right_indices) > 0:
4024
+ # Filter right traces that are beyond the offset distance
4025
+ right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance]
4026
+
4027
+ if len(right_beyond_offset) > 0:
4028
+ # Sort by distance (closest to shot first, among those beyond offset)
4029
+ right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4030
+ # Take window_length traces
4031
+ selected_traces = right_sorted[:window_length]
4032
+ first_trace = int(np.min(selected_traces))
4033
+ last_trace = int(np.max(selected_traces))
4034
+
4035
+ elif window_side == 'best':
4036
+ # Try preferred side first, fallback to other side if not enough traces
4037
+ if prefer_side == 'right':
4038
+ # Prefer right side - check if enough traces beyond offset_distance
4039
+ right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance] if len(right_indices) > 0 else np.array([])
4040
+
4041
+ if len(right_beyond_offset) >= window_length:
4042
+ # Enough traces on the right beyond offset
4043
+ right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4044
+ selected_traces = right_sorted[:window_length]
4045
+ first_trace = int(np.min(selected_traces))
4046
+ last_trace = int(np.max(selected_traces))
4047
+ else:
4048
+ # Try left side
4049
+ left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance] if len(left_indices) > 0 else np.array([])
4050
+ if len(left_beyond_offset) >= window_length:
4051
+ left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4052
+ selected_traces = left_sorted[:window_length]
4053
+ first_trace = int(np.min(selected_traces))
4054
+ last_trace = int(np.max(selected_traces))
4055
+ else:
4056
+ # Prefer left side - check if enough traces beyond offset_distance
4057
+ left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance] if len(left_indices) > 0 else np.array([])
4058
+
4059
+ if len(left_beyond_offset) >= window_length:
4060
+ # Enough traces on the left beyond offset
4061
+ left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4062
+ selected_traces = left_sorted[:window_length]
4063
+ first_trace = int(np.min(selected_traces))
4064
+ last_trace = int(np.max(selected_traces))
4065
+ else:
4066
+ # Try right side
4067
+ right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance] if len(right_indices) > 0 else np.array([])
4068
+ if len(right_beyond_offset) >= window_length:
4069
+ right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4070
+ selected_traces = right_sorted[:window_length]
4071
+ first_trace = int(np.min(selected_traces))
4072
+ last_trace = int(np.max(selected_traces))
4073
+
4074
+ # If still no valid range, reduce requirements (fewer than window_length traces or closer than offset)
4075
+ if first_trace is None or last_trace is None:
4076
+ # Use what's available, respecting preference
4077
+ if prefer_side == 'right':
4078
+ # Try right side with any available traces beyond offset
4079
+ right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance] if len(right_indices) > 0 else np.array([])
4080
+ if len(right_beyond_offset) > 0:
4081
+ right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4082
+ selected_traces = right_sorted
4083
+ first_trace = int(np.min(selected_traces))
4084
+ last_trace = int(np.max(selected_traces))
4085
+ else:
4086
+ # Try left side with any available traces beyond offset
4087
+ left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance] if len(left_indices) > 0 else np.array([])
4088
+ if len(left_beyond_offset) > 0:
4089
+ left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4090
+ selected_traces = left_sorted
4091
+ first_trace = int(np.min(selected_traces))
4092
+ last_trace = int(np.max(selected_traces))
4093
+ elif len(right_indices) > 0:
4094
+ # No offset possible, use closest right traces
4095
+ right_sorted = right_indices[np.argsort(abs_distances[right_indices])]
4096
+ selected_traces = right_sorted[:window_length] if len(right_sorted) >= window_length else right_sorted
4097
+ first_trace = int(np.min(selected_traces))
4098
+ last_trace = int(np.max(selected_traces))
4099
+ elif len(left_indices) > 0:
4100
+ # No right traces, use closest left traces
4101
+ left_sorted = left_indices[np.argsort(abs_distances[left_indices])]
4102
+ selected_traces = left_sorted[:window_length] if len(left_sorted) >= window_length else left_sorted
4103
+ first_trace = int(np.min(selected_traces))
4104
+ last_trace = int(np.max(selected_traces))
4105
+ else:
4106
+ # Prefer left side
4107
+ # Try left side with any available traces beyond offset
4108
+ left_beyond_offset = left_indices[abs_distances[left_indices] >= offset_distance] if len(left_indices) > 0 else np.array([])
4109
+ if len(left_beyond_offset) > 0:
4110
+ left_sorted = left_beyond_offset[np.argsort(abs_distances[left_beyond_offset])]
4111
+ selected_traces = left_sorted
4112
+ first_trace = int(np.min(selected_traces))
4113
+ last_trace = int(np.max(selected_traces))
4114
+ else:
4115
+ # Try right side with any available traces beyond offset
4116
+ right_beyond_offset = right_indices[abs_distances[right_indices] >= offset_distance] if len(right_indices) > 0 else np.array([])
4117
+ if len(right_beyond_offset) > 0:
4118
+ right_sorted = right_beyond_offset[np.argsort(abs_distances[right_beyond_offset])]
4119
+ selected_traces = right_sorted
4120
+ first_trace = int(np.min(selected_traces))
4121
+ last_trace = int(np.max(selected_traces))
4122
+ elif len(left_indices) > 0:
4123
+ # No offset possible, use closest left traces
4124
+ left_sorted = left_indices[np.argsort(abs_distances[left_indices])]
4125
+ selected_traces = left_sorted[:window_length] if len(left_sorted) >= window_length else left_sorted
4126
+ first_trace = int(np.min(selected_traces))
4127
+ last_trace = int(np.max(selected_traces))
4128
+ elif len(right_indices) > 0:
4129
+ # No left traces, use closest right traces
4130
+ right_sorted = right_indices[np.argsort(abs_distances[right_indices])]
4131
+ selected_traces = right_sorted[:window_length] if len(right_sorted) >= window_length else right_sorted
4132
+ first_trace = int(np.min(selected_traces))
4133
+ last_trace = int(np.max(selected_traces))
4134
+
4135
+ # Final fallback if still no valid range (shouldn't reach here)
4136
+ if first_trace is None or last_trace is None:
4137
+ # Use closest traces to shot position
4138
+ abs_distances = np.abs(distances)
4139
+ closest_indices = np.argsort(abs_distances)[:min(window_length, n_traces)]
4140
+ first_trace = int(np.min(closest_indices))
4141
+ last_trace = int(np.max(closest_indices))
4142
+
4143
+ # Ensure valid range
4144
+ first_trace = max(0, min(first_trace, n_traces - 1))
4145
+ last_trace = max(first_trace, min(last_trace, n_traces - 1))
4146
+
4147
+ # Store in trace range memory using shot_idx as key (consistent with onFileSelectionChanged)
4148
+ file_key = shot_idx
4149
+ if file_key not in self.trace_range_memory:
4150
+ self.trace_range_memory[file_key] = {}
4151
+
4152
+ # Store for both views (sync them) using dictionary structure
4153
+ self.trace_range_memory[file_key]['top'] = {
4154
+ 'first': first_trace,
4155
+ 'last': last_trace,
4156
+ 'n_traces': n_traces
4157
+ }
4158
+ self.trace_range_memory[file_key]['bottom'] = {
4159
+ 'first': first_trace,
4160
+ 'last': last_trace,
4161
+ 'n_traces': n_traces
4162
+ }
4163
+
4164
+ # Update the current shot's range slider and attributes
4165
+ if self.currentIndex is not None and self.currentIndex < len(self.streams):
4166
+ file_key = self.currentIndex
4167
+
4168
+ if file_key in self.trace_range_memory and 'bottom' in self.trace_range_memory[file_key]:
4169
+ stored_range = self.trace_range_memory[file_key]['bottom']
4170
+ first_trace = stored_range['first']
4171
+ last_trace = stored_range['last']
4172
+ n_traces = stored_range['n_traces']
4173
+
4174
+ # Update view-specific attributes
4175
+ self.bottom_first_trace = first_trace
4176
+ self.bottom_last_trace = last_trace
4177
+ self.top_first_trace = first_trace
4178
+ self.top_last_trace = last_trace
4179
+
4180
+ # Update the slider widget with error handling
4181
+ try:
4182
+ if hasattr(self, 'bottom_traceRangeSlider') and self.bottom_traceRangeSlider is not None:
4183
+ # Check if widget is still valid (not deleted)
4184
+ try:
4185
+ self.bottom_traceRangeSlider.setRange(first_trace, last_trace, n_traces - 1)
4186
+ except RuntimeError:
4187
+ # Widget was deleted, skip update
4188
+ pass
4189
+ except Exception:
4190
+ pass
4191
+
4192
+ try:
4193
+ if hasattr(self, 'top_traceRangeSlider') and self.top_traceRangeSlider is not None:
4194
+ # Check if widget is still valid (not deleted)
4195
+ try:
4196
+ self.top_traceRangeSlider.setRange(first_trace, last_trace, n_traces - 1)
4197
+ except RuntimeError:
4198
+ # Widget was deleted, skip update
4199
+ pass
4200
+ except Exception:
4201
+ pass
4202
+
4203
+ # Redraw dispersion if active
4204
+ if hasattr(self, 'bottomPlotType') and self.bottomPlotType == "dispersion":
4205
+ self._plot_target_widget = self.bottomPlotWidget
4206
+ self._plot_target_viewbox = self.bottomViewBox
4207
+ self.drawTraceExtentIndicator()
4208
+ self.plotDispersion()
4209
+ self._plot_target_widget = None
4210
+ self._plot_target_viewbox = None
4211
+ elif hasattr(self, 'topPlotType') and self.topPlotType == "dispersion":
4212
+ self._plot_target_widget = self.topPlotWidget
4213
+ self._plot_target_viewbox = self.topViewBox
4214
+ self.drawTraceExtentIndicator()
4215
+ self.plotDispersion()
4216
+ self._plot_target_widget = None
4217
+ self._plot_target_viewbox = None
4218
+
4219
+ # Show confirmation message
4220
+ prefer_text = f", Prefer: {prefer_side}" if window_side == 'best' else ""
4221
+ QMessageBox.information(self, "Batch Windowing",
4222
+ f"Applied batch windowing to {len(self.streams)} shots.\n"
4223
+ f"Window: {window_length} traces, Side: {window_side}{prefer_text}")
4224
+
4225
+ except Exception as e:
4226
+ QMessageBox.critical(self, "Error", f"Failed to apply batch windowing: {str(e)}")
4227
+
3834
4228
  def onFreqRangeChanged(self, _=None):
3835
4229
  """Handle frequency range change (fmin/fmax) - unified"""
3836
4230
  # Guard against calls when no file is loaded
3837
4231
  if not (hasattr(self, 'currentIndex') and self.currentIndex is not None and self.streams):
3838
4232
  return
3839
4233
 
4234
+ # Detect which spinbox sent the signal
4235
+ sender_spin = self.sender()
4236
+
3840
4237
  # Get current values from spinboxes
3841
4238
  try:
3842
- fmin_val = self.fminSpin.value()
3843
- fmax_val = self.fmaxSpin.value()
4239
+ # Use the sender spinbox value if available, otherwise use the generic one
4240
+ if sender_spin and hasattr(sender_spin, 'value'):
4241
+ # Sender is one of the spinboxes, but we still need both fmin and fmax
4242
+ # Find the corresponding pair (if sender is fmin spinbox, get both fmin and fmax)
4243
+ fmin_val = None
4244
+ fmax_val = None
4245
+
4246
+ # Check which spinbox sent the signal
4247
+ for prefix in ['top', 'bottom']:
4248
+ fmin_attr = f'{prefix}_fminSpin'
4249
+ fmax_attr = f'{prefix}_fmaxSpin'
4250
+
4251
+ if hasattr(self, fmin_attr) and getattr(self, fmin_attr) is sender_spin:
4252
+ # Sender is the fmin spinbox for this prefix
4253
+ fmin_val = sender_spin.value()
4254
+ if hasattr(self, fmax_attr):
4255
+ fmax_val = getattr(self, fmax_attr).value()
4256
+ break
4257
+ elif hasattr(self, fmax_attr) and getattr(self, fmax_attr) is sender_spin:
4258
+ # Sender is the fmax spinbox for this prefix
4259
+ fmax_val = sender_spin.value()
4260
+ if hasattr(self, fmin_attr):
4261
+ fmin_val = getattr(self, fmin_attr).value()
4262
+ break
4263
+
4264
+ # Fallback if sender not identified
4265
+ if fmin_val is None or fmax_val is None:
4266
+ fmin_val = self.fminSpin.value()
4267
+ fmax_val = self.fmaxSpin.value()
4268
+ else:
4269
+ fmin_val = self.fminSpin.value()
4270
+ fmax_val = self.fmaxSpin.value()
3844
4271
  except (RuntimeError, AttributeError):
3845
4272
  return
3846
4273
 
3847
- # Compute Nyquist for current file if available
3848
- nyquist = None
3849
- try:
3850
- if self.currentIndex is not None:
3851
- dt = self.sample_interval[self.currentIndex]
3852
- if dt and dt > 0:
3853
- nyquist = 1.0 / (2.0 * dt)
3854
- except Exception:
3855
- nyquist = None
3856
-
3857
- if nyquist is not None:
3858
- # Clamp to [0, nyquist]
3859
- fmin_val = max(0.0, min(fmin_val, nyquist))
3860
- fmax_val = max(0.0, min(fmax_val, nyquist))
3861
- try:
3862
- self.fminSpin.blockSignals(True)
3863
- self.fmaxSpin.blockSignals(True)
3864
- self.fminSpin.setValue(fmin_val)
3865
- self.fmaxSpin.setValue(fmax_val)
3866
- finally:
3867
- self.fminSpin.blockSignals(False)
3868
- self.fmaxSpin.blockSignals(False)
3869
-
4274
+ # Store the raw values (allow user to set any frequency)
3870
4275
  self.fmin = fmin_val if fmin_val > 0 else None
3871
4276
  self.fmax = fmax_val if fmax_val > 0 else None
3872
4277
 
3873
- # Guard against invalid ranges
4278
+ # Guard against invalid ranges (fmin >= fmax is only checked for actual values)
3874
4279
  if (self.fmin is not None and self.fmax is not None and self.fmin >= self.fmax):
3875
4280
  return
3876
4281
 
3877
- # Replot both top and bottom
4282
+ # Sync all view-specific spinboxes to the same value (except the sender)
4283
+ for prefix in ['top', 'bottom']:
4284
+ fmin_attr = f'{prefix}_fminSpin'
4285
+ fmax_attr = f'{prefix}_fmaxSpin'
4286
+ if hasattr(self, fmin_attr):
4287
+ try:
4288
+ spin = getattr(self, fmin_attr)
4289
+ if spin is not None and spin is not sender_spin and spin.value() != fmin_val:
4290
+ spin.blockSignals(True)
4291
+ spin.setValue(fmin_val)
4292
+ spin.blockSignals(False)
4293
+ except RuntimeError:
4294
+ pass
4295
+ if hasattr(self, fmax_attr):
4296
+ try:
4297
+ spin = getattr(self, fmax_attr)
4298
+ if spin is not None and spin is not sender_spin and spin.value() != fmax_val:
4299
+ spin.blockSignals(True)
4300
+ spin.setValue(fmax_val)
4301
+ spin.blockSignals(False)
4302
+ except RuntimeError:
4303
+ pass
4304
+
4305
+ # Always replot if this is a dispersion or spectrogram view
4306
+ # The plotting functions will handle Nyquist clamping for their specific data
3878
4307
  if hasattr(self, 'topPlotType'):
3879
4308
  if self.topPlotType == "spectrogram":
3880
4309
  self._plot_target_widget = self.plotWidget
@@ -3978,10 +4407,10 @@ class MainWindow(QMainWindow):
3978
4407
 
3979
4408
  # Update the trace extent indicator and replot only for top view
3980
4409
  if hasattr(self, 'topPlotType') and self.topPlotType == "dispersion":
3981
- self.drawTraceExtentIndicator()
3982
4410
  # Set target to top widget for this plot
3983
4411
  self._plot_target_widget = self.topPlotWidget
3984
4412
  self._plot_target_viewbox = self.topViewBox
4413
+ self.drawTraceExtentIndicator()
3985
4414
  self.plotDispersion()
3986
4415
  self._plot_target_widget = None
3987
4416
  self._plot_target_viewbox = None
@@ -4030,6 +4459,9 @@ class MainWindow(QMainWindow):
4030
4459
 
4031
4460
  # Update the trace extent indicator and replot only for bottom view
4032
4461
  if hasattr(self, 'bottomPlotType') and self.bottomPlotType == "dispersion":
4462
+ # Set target to bottom widget for this plot
4463
+ self._plot_target_widget = self.bottomPlotWidget
4464
+ self._plot_target_viewbox = self.bottomViewBox
4033
4465
  self.drawTraceExtentIndicator()
4034
4466
  self.plotDispersion()
4035
4467
 
@@ -4124,33 +4556,42 @@ class MainWindow(QMainWindow):
4124
4556
  # Update the trace extent indicator and replot only for the controlled view
4125
4557
  if controlled_view == "top":
4126
4558
  if hasattr(self, 'topPlotType') and self.topPlotType == "dispersion":
4127
- if not suppress_indicator_redraw:
4128
- self.drawTraceExtentIndicator()
4129
4559
  # Set target to top widget for this plot
4130
4560
  self._plot_target_widget = self.topPlotWidget
4131
4561
  self._plot_target_viewbox = self.topViewBox
4562
+ if not suppress_indicator_redraw:
4563
+ self.drawTraceExtentIndicator()
4132
4564
  self.plotDispersion()
4133
4565
  self._plot_target_widget = None
4134
4566
  self._plot_target_viewbox = None
4135
4567
  elif controlled_view == "bottom":
4136
4568
  if hasattr(self, 'bottomPlotType') and self.bottomPlotType == "dispersion":
4569
+ # Set target to bottom widget for this plot
4570
+ self._plot_target_widget = self.bottomPlotWidget
4571
+ self._plot_target_viewbox = self.bottomViewBox
4137
4572
  if not suppress_indicator_redraw:
4138
4573
  self.drawTraceExtentIndicator()
4139
4574
  self.plotDispersion()
4575
+ self._plot_target_widget = None
4576
+ self._plot_target_viewbox = None
4140
4577
  else:
4141
4578
  # Fallback: update both if we're not sure
4142
4579
  if hasattr(self, 'topPlotType') and self.topPlotType == "dispersion":
4143
- if not suppress_indicator_redraw:
4144
- self.drawTraceExtentIndicator()
4145
4580
  self._plot_target_widget = self.topPlotWidget
4146
4581
  self._plot_target_viewbox = self.topViewBox
4582
+ if not suppress_indicator_redraw:
4583
+ self.drawTraceExtentIndicator()
4147
4584
  self.plotDispersion()
4148
4585
  self._plot_target_widget = None
4149
4586
  self._plot_target_viewbox = None
4150
4587
  if hasattr(self, 'bottomPlotType') and self.bottomPlotType == "dispersion":
4588
+ self._plot_target_widget = self.bottomPlotWidget
4589
+ self._plot_target_viewbox = self.bottomViewBox
4151
4590
  if not suppress_indicator_redraw:
4152
4591
  self.drawTraceExtentIndicator()
4153
4592
  self.plotDispersion()
4593
+ self._plot_target_widget = None
4594
+ self._plot_target_viewbox = None
4154
4595
 
4155
4596
  def updateDispersionWindowInfo(self):
4156
4597
  """Update the status bar with dispersion window center and width information"""
@@ -5749,9 +6190,16 @@ class MainWindow(QMainWindow):
5749
6190
  self.spectrogram_colormap_str = 'plasma'
5750
6191
  self.dispersion_colormap_str = 'plasma'
5751
6192
 
5752
- # Unified analytical view normalization settings (same for top/bottom)
5753
- self.norm_per_trace = True # Per-trace (Spectrogram), per-f (Phase-Shift)
5754
- self.norm_per_freq = False # Per-freq (Spectrogram), per-v (Phase-Shift)
6193
+ # Spectrogram axis permutation (freq on x or y axis)
6194
+ self.spectrogram_freq_on_y = True # True: freq on y-axis, False: freq on x-axis
6195
+
6196
+ # Spectrogram-specific normalization settings (independent from dispersion)
6197
+ self.spectrogram_norm_per_trace = True
6198
+ self.spectrogram_norm_per_freq = False
6199
+
6200
+ # Dispersion-specific normalization settings (independent from spectrogram)
6201
+ self.dispersion_norm_per_trace = True
6202
+ self.dispersion_norm_per_freq = True
5755
6203
 
5756
6204
  # Unified analytical view parameter defaults (same for top/bottom)
5757
6205
  self.fmin = None # Default fmin (auto -> 0 for Spectrogram/Phase-Shift)
@@ -16351,11 +16799,11 @@ class MainWindow(QMainWindow):
16351
16799
 
16352
16800
  # Normalize FFT data based on user selection
16353
16801
  eps = 1e-12
16354
- if getattr(self, 'norm_per_trace', True):
16802
+ if getattr(self, 'spectrogram_norm_per_trace', True):
16355
16803
  # Per-trace normalization for spectrogram
16356
16804
  max_per_trace = np.maximum(np.max(fft_data, axis=1, keepdims=True), eps)
16357
16805
  fft_data = fft_data / max_per_trace
16358
- if getattr(self, 'norm_per_freq', False):
16806
+ if getattr(self, 'spectrogram_norm_per_freq', False):
16359
16807
  # Per-frequency normalization
16360
16808
  max_per_freq = np.maximum(np.max(fft_data, axis=0, keepdims=True), eps)
16361
16809
  fft_data = fft_data / max_per_freq
@@ -16382,21 +16830,43 @@ class MainWindow(QMainWindow):
16382
16830
  freqs = freqs[fmask]
16383
16831
  fft_data = fft_data[:, fmask]
16384
16832
 
16833
+ # Check if axes should be permuted
16834
+ freq_on_y = getattr(self, 'spectrogram_freq_on_y', True)
16835
+
16385
16836
  # Create image item with colormap
16386
16837
  colormap = getattr(self, 'spectrogram_colormap_str', 'plasma')
16387
- image_item = createImageItem(fft_data.T, x, freqs, colormap=colormap)
16388
-
16389
- # Store image data and coordinates for amplitude lookup
16390
- self.spectrogramImageData = fft_data.T
16391
- self.spectrogramImageX = x
16392
- self.spectrogramImageFreqs = freqs
16838
+
16839
+ if freq_on_y:
16840
+ # Original: frequency on y-axis, traces on x-axis
16841
+ image_item = createImageItem(fft_data.T, x, freqs, colormap=colormap)
16842
+ # Store image data and coordinates for amplitude lookup
16843
+ self.spectrogramImageData = fft_data.T
16844
+ self.spectrogramImageX = x
16845
+ self.spectrogramImageFreqs = freqs
16846
+
16847
+ # Set axis labels
16848
+ x_label = self.x_label
16849
+ y_label = 'Frequency (Hz)'
16850
+ invert_y = True # Frequency increases downward
16851
+ else:
16852
+ # Permuted: traces on y-axis, frequency on x-axis
16853
+ image_item = createImageItem(fft_data, freqs, x, colormap=colormap)
16854
+ # Store image data and coordinates for amplitude lookup
16855
+ self.spectrogramImageData = fft_data
16856
+ self.spectrogramImageX = freqs
16857
+ self.spectrogramImageFreqs = x
16858
+
16859
+ # Set axis labels
16860
+ x_label = 'Frequency (Hz)'
16861
+ y_label = self.x_label
16862
+ invert_y = False # Traces increase downward normally
16393
16863
 
16394
16864
  # Add image to plot
16395
16865
  plot_widget.addItem(image_item)
16396
16866
 
16397
16867
  # Set axis labels
16398
- plot_widget.setLabel('left', 'Frequency (Hz)')
16399
- plot_widget.setLabel('top', self.x_label)
16868
+ plot_widget.setLabel('left', y_label)
16869
+ plot_widget.setLabel('top', x_label)
16400
16870
  plot_widget.setLabel('bottom', '')
16401
16871
  plot_widget.showAxis('top')
16402
16872
  plot_widget.showAxis('bottom')
@@ -16405,16 +16875,31 @@ class MainWindow(QMainWindow):
16405
16875
  plot_widget.getAxis('right').setStyle(showValues=False)
16406
16876
  plot_widget.getAxis('right').setLabel('')
16407
16877
 
16408
- # Ensure Y-axis is inverted (frequency increases downward)
16409
- if not viewbox.state.get('invertY', False):
16410
- viewbox.invertY(True)
16878
+ # Invert Y-axis if needed
16879
+ if invert_y:
16880
+ if not viewbox.state.get('invertY', False):
16881
+ viewbox.invertY(True)
16882
+ else:
16883
+ if viewbox.state.get('invertY', False):
16884
+ viewbox.invertY(False)
16411
16885
 
16412
16886
  # Set explicit ranges for consistent tick label placement and prevent zooming out beyond data range
16887
+ if freq_on_y:
16888
+ x_range_min = min(x) - self.mean_dg
16889
+ x_range_max = max(x) + self.mean_dg
16890
+ y_range_min = min(freqs)
16891
+ y_range_max = max(freqs)
16892
+ else:
16893
+ x_range_min = min(freqs)
16894
+ x_range_max = max(freqs)
16895
+ y_range_min = min(x)
16896
+ y_range_max = max(x)
16897
+
16413
16898
  if len(x) > 0 and len(freqs) > 0:
16414
- viewbox.setLimits(xMin=min(x) - self.mean_dg, xMax=max(x) + self.mean_dg,
16415
- yMin=min(freqs), yMax=max(freqs))
16416
- viewbox.setXRange(min(x) - self.mean_dg, max(x) + self.mean_dg, padding=0)
16417
- viewbox.setYRange(min(freqs), max(freqs), padding=0)
16899
+ viewbox.setLimits(xMin=x_range_min, xMax=x_range_max,
16900
+ yMin=y_range_min, yMax=y_range_max)
16901
+ viewbox.setXRange(x_range_min, x_range_max, padding=0)
16902
+ viewbox.setYRange(y_range_min, y_range_max, padding=0)
16418
16903
  else:
16419
16904
  viewbox.setLimits(xMin=None, xMax=None, yMin=None, yMax=None)
16420
16905
 
@@ -16540,13 +17025,9 @@ class MainWindow(QMainWindow):
16540
17025
  # Compute phase-shift transform
16541
17026
  fs, vs, FV = phase_shift(data, dt, offsets, vmin, vmax, dv, fmax, fmin)
16542
17027
 
16543
- # Normalize based on user selection
17028
+ # Normalize based on user selection (Dispersion only uses per-frequency normalization)
16544
17029
  eps = 1e-12
16545
- if getattr(self, 'norm_per_trace', True):
16546
- # Per-frequency normalization for Phase-Shift (normalize each frequency independently)
16547
- max_per_freq = np.maximum(np.max(FV, axis=1, keepdims=True), eps)
16548
- FV = FV / max_per_freq
16549
- if getattr(self, 'norm_per_freq', False):
17030
+ if getattr(self, 'dispersion_norm_per_freq', False):
16550
17031
  # Per-velocity normalization for Phase-Shift (normalize each velocity independently)
16551
17032
  max_per_v = np.maximum(np.max(FV, axis=0, keepdims=True), eps)
16552
17033
  FV = FV / max_per_v
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyckster
3
- Version: 26.1.1
3
+ Version: 26.1.2
4
4
  Summary: A PyQt5-based GUI for picking seismic traveltimes
5
5
  Home-page: https://gitlab.in2p3.fr/metis-geophysics/pyckster
6
6
  Author: Sylvain Pasquet
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes