oscura 0.7.0__py3-none-any.whl → 0.10.0__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.
Files changed (175) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/analyzers/__init__.py +2 -0
  3. oscura/analyzers/digital/extraction.py +2 -3
  4. oscura/analyzers/digital/quality.py +1 -1
  5. oscura/analyzers/digital/timing.py +1 -1
  6. oscura/analyzers/eye/__init__.py +5 -1
  7. oscura/analyzers/eye/generation.py +501 -0
  8. oscura/analyzers/jitter/__init__.py +6 -6
  9. oscura/analyzers/jitter/timing.py +419 -0
  10. oscura/analyzers/patterns/__init__.py +94 -0
  11. oscura/analyzers/patterns/reverse_engineering.py +991 -0
  12. oscura/analyzers/power/__init__.py +35 -12
  13. oscura/analyzers/power/basic.py +3 -3
  14. oscura/analyzers/power/soa.py +1 -1
  15. oscura/analyzers/power/switching.py +3 -3
  16. oscura/analyzers/signal_classification.py +529 -0
  17. oscura/analyzers/signal_integrity/sparams.py +3 -3
  18. oscura/analyzers/statistics/__init__.py +4 -0
  19. oscura/analyzers/statistics/basic.py +152 -0
  20. oscura/analyzers/statistics/correlation.py +47 -6
  21. oscura/analyzers/validation.py +1 -1
  22. oscura/analyzers/waveform/__init__.py +2 -0
  23. oscura/analyzers/waveform/measurements.py +329 -163
  24. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  25. oscura/analyzers/waveform/spectral.py +498 -54
  26. oscura/api/dsl/commands.py +15 -6
  27. oscura/api/server/templates/base.html +137 -146
  28. oscura/api/server/templates/export.html +84 -110
  29. oscura/api/server/templates/home.html +248 -267
  30. oscura/api/server/templates/protocols.html +44 -48
  31. oscura/api/server/templates/reports.html +27 -35
  32. oscura/api/server/templates/session_detail.html +68 -78
  33. oscura/api/server/templates/sessions.html +62 -72
  34. oscura/api/server/templates/waveforms.html +54 -64
  35. oscura/automotive/__init__.py +1 -1
  36. oscura/automotive/can/session.py +1 -1
  37. oscura/automotive/dbc/generator.py +638 -23
  38. oscura/automotive/dtc/data.json +102 -17
  39. oscura/automotive/uds/decoder.py +99 -6
  40. oscura/cli/analyze.py +8 -2
  41. oscura/cli/batch.py +36 -5
  42. oscura/cli/characterize.py +18 -4
  43. oscura/cli/export.py +47 -5
  44. oscura/cli/main.py +2 -0
  45. oscura/cli/onboarding/wizard.py +10 -6
  46. oscura/cli/pipeline.py +585 -0
  47. oscura/cli/visualize.py +6 -4
  48. oscura/convenience.py +400 -32
  49. oscura/core/config/loader.py +0 -1
  50. oscura/core/measurement_result.py +286 -0
  51. oscura/core/progress.py +1 -1
  52. oscura/core/schemas/device_mapping.json +8 -2
  53. oscura/core/schemas/packet_format.json +24 -4
  54. oscura/core/schemas/protocol_definition.json +12 -2
  55. oscura/core/types.py +300 -199
  56. oscura/correlation/multi_protocol.py +1 -1
  57. oscura/export/legacy/__init__.py +11 -0
  58. oscura/export/legacy/wav.py +75 -0
  59. oscura/exporters/__init__.py +19 -0
  60. oscura/exporters/wireshark.py +809 -0
  61. oscura/hardware/acquisition/file.py +5 -19
  62. oscura/hardware/acquisition/saleae.py +10 -10
  63. oscura/hardware/acquisition/socketcan.py +4 -6
  64. oscura/hardware/acquisition/synthetic.py +1 -5
  65. oscura/hardware/acquisition/visa.py +6 -6
  66. oscura/hardware/security/side_channel_detector.py +5 -508
  67. oscura/inference/message_format.py +686 -1
  68. oscura/jupyter/display.py +2 -2
  69. oscura/jupyter/magic.py +3 -3
  70. oscura/loaders/__init__.py +17 -12
  71. oscura/loaders/binary.py +1 -1
  72. oscura/loaders/chipwhisperer.py +1 -2
  73. oscura/loaders/configurable.py +1 -1
  74. oscura/loaders/csv_loader.py +2 -2
  75. oscura/loaders/hdf5_loader.py +1 -1
  76. oscura/loaders/lazy.py +6 -1
  77. oscura/loaders/mmap_loader.py +0 -1
  78. oscura/loaders/numpy_loader.py +8 -7
  79. oscura/loaders/preprocessing.py +3 -5
  80. oscura/loaders/rigol.py +21 -7
  81. oscura/loaders/sigrok.py +2 -5
  82. oscura/loaders/tdms.py +3 -2
  83. oscura/loaders/tektronix.py +38 -32
  84. oscura/loaders/tss.py +20 -27
  85. oscura/loaders/vcd.py +13 -8
  86. oscura/loaders/wav.py +1 -6
  87. oscura/pipeline/__init__.py +76 -0
  88. oscura/pipeline/handlers/__init__.py +165 -0
  89. oscura/pipeline/handlers/analyzers.py +1045 -0
  90. oscura/pipeline/handlers/decoders.py +899 -0
  91. oscura/pipeline/handlers/exporters.py +1103 -0
  92. oscura/pipeline/handlers/filters.py +891 -0
  93. oscura/pipeline/handlers/loaders.py +640 -0
  94. oscura/pipeline/handlers/transforms.py +768 -0
  95. oscura/reporting/__init__.py +88 -1
  96. oscura/reporting/automation.py +348 -0
  97. oscura/reporting/citations.py +374 -0
  98. oscura/reporting/core.py +54 -0
  99. oscura/reporting/formatting/__init__.py +11 -0
  100. oscura/reporting/formatting/measurements.py +320 -0
  101. oscura/reporting/html.py +57 -0
  102. oscura/reporting/interpretation.py +431 -0
  103. oscura/reporting/summary.py +329 -0
  104. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  105. oscura/reporting/visualization.py +542 -0
  106. oscura/side_channel/__init__.py +38 -57
  107. oscura/utils/builders/signal_builder.py +5 -5
  108. oscura/utils/comparison/compare.py +7 -9
  109. oscura/utils/comparison/golden.py +1 -1
  110. oscura/utils/filtering/convenience.py +2 -2
  111. oscura/utils/math/arithmetic.py +38 -62
  112. oscura/utils/math/interpolation.py +20 -20
  113. oscura/utils/pipeline/__init__.py +4 -17
  114. oscura/utils/progressive.py +1 -4
  115. oscura/utils/triggering/edge.py +1 -1
  116. oscura/utils/triggering/pattern.py +2 -2
  117. oscura/utils/triggering/pulse.py +2 -2
  118. oscura/utils/triggering/window.py +3 -3
  119. oscura/validation/hil_testing.py +11 -11
  120. oscura/visualization/__init__.py +47 -284
  121. oscura/visualization/batch.py +160 -0
  122. oscura/visualization/plot.py +542 -53
  123. oscura/visualization/styles.py +184 -318
  124. oscura/workflows/__init__.py +2 -0
  125. oscura/workflows/batch/advanced.py +1 -1
  126. oscura/workflows/batch/aggregate.py +7 -8
  127. oscura/workflows/complete_re.py +251 -23
  128. oscura/workflows/digital.py +27 -4
  129. oscura/workflows/multi_trace.py +136 -17
  130. oscura/workflows/waveform.py +788 -0
  131. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  132. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/RECORD +135 -149
  133. oscura/side_channel/dpa.py +0 -1025
  134. oscura/utils/optimization/__init__.py +0 -19
  135. oscura/utils/optimization/parallel.py +0 -443
  136. oscura/utils/optimization/search.py +0 -532
  137. oscura/utils/pipeline/base.py +0 -338
  138. oscura/utils/pipeline/composition.py +0 -248
  139. oscura/utils/pipeline/parallel.py +0 -449
  140. oscura/utils/pipeline/pipeline.py +0 -375
  141. oscura/utils/search/__init__.py +0 -16
  142. oscura/utils/search/anomaly.py +0 -424
  143. oscura/utils/search/context.py +0 -294
  144. oscura/utils/search/pattern.py +0 -288
  145. oscura/utils/storage/__init__.py +0 -61
  146. oscura/utils/storage/database.py +0 -1166
  147. oscura/visualization/accessibility.py +0 -526
  148. oscura/visualization/annotations.py +0 -371
  149. oscura/visualization/axis_scaling.py +0 -305
  150. oscura/visualization/colors.py +0 -451
  151. oscura/visualization/digital.py +0 -436
  152. oscura/visualization/eye.py +0 -571
  153. oscura/visualization/histogram.py +0 -281
  154. oscura/visualization/interactive.py +0 -1035
  155. oscura/visualization/jitter.py +0 -1042
  156. oscura/visualization/keyboard.py +0 -394
  157. oscura/visualization/layout.py +0 -400
  158. oscura/visualization/optimization.py +0 -1079
  159. oscura/visualization/palettes.py +0 -446
  160. oscura/visualization/power.py +0 -508
  161. oscura/visualization/power_extended.py +0 -955
  162. oscura/visualization/presets.py +0 -469
  163. oscura/visualization/protocols.py +0 -1246
  164. oscura/visualization/render.py +0 -223
  165. oscura/visualization/rendering.py +0 -444
  166. oscura/visualization/reverse_engineering.py +0 -838
  167. oscura/visualization/signal_integrity.py +0 -989
  168. oscura/visualization/specialized.py +0 -643
  169. oscura/visualization/spectral.py +0 -1226
  170. oscura/visualization/thumbnails.py +0 -340
  171. oscura/visualization/time_axis.py +0 -351
  172. oscura/visualization/waveform.py +0 -454
  173. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  174. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  175. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -24,12 +24,13 @@ import numpy as np
24
24
  from scipy import signal as sp_signal
25
25
 
26
26
  from oscura.core.exceptions import AnalysisError, InsufficientDataError
27
+ from oscura.core.measurement_result import make_inapplicable, make_measurement
27
28
  from oscura.utils.windowing import get_window
28
29
 
29
30
  if TYPE_CHECKING:
30
31
  from numpy.typing import NDArray
31
32
 
32
- from oscura.core.types import WaveformTrace
33
+ from oscura.core.types import MeasurementResult, WaveformTrace
33
34
 
34
35
  # Global FFT cache statistics
35
36
  _fft_cache_stats = {"hits": 0, "misses": 0, "size": 128}
@@ -645,10 +646,13 @@ def thd(
645
646
  window: str = "hann",
646
647
  nfft: int | None = None,
647
648
  return_db: bool = True,
648
- ) -> float:
649
- """Compute Total Harmonic Distortion.
649
+ ) -> MeasurementResult:
650
+ """Compute Total Harmonic Distortion per IEEE 1241-2010.
650
651
 
651
- THD is the ratio of harmonic power to fundamental power.
652
+ THD is defined as the ratio of RMS harmonic power to fundamental amplitude:
653
+ THD = sqrt(sum(A_harmonics^2)) / A_fundamental
654
+
655
+ where harmonics are the 2nd, 3rd, ..., nth harmonic frequencies.
652
656
 
653
657
  Args:
654
658
  trace: Input waveform trace.
@@ -656,15 +660,15 @@ def thd(
656
660
  window: Window function for FFT.
657
661
  nfft: FFT length. If None, uses data length (no zero-padding) to
658
662
  preserve coherent sampling per IEEE 1241-2010.
659
- return_db: If True, return in dB. If False, return percentage.
663
+ return_db: Ignored (always returns percentage, with dB in metadata if needed).
660
664
 
661
665
  Returns:
662
- THD in dB or percentage.
666
+ MeasurementResult with THD as percentage, or inapplicable if cannot compute.
663
667
 
664
668
  Example:
665
- >>> thd_db = thd(trace)
666
- >>> thd_pct = thd(trace, return_db=False)
667
- >>> print(f"THD: {thd_db:.1f} dB ({thd_pct:.2f}%)")
669
+ >>> result = thd(trace)
670
+ >>> if result["applicable"]:
671
+ ... print(f"THD: {result['display']}")
668
672
 
669
673
  References:
670
674
  IEEE 1241-2010 Section 4.1.4.2
@@ -676,33 +680,40 @@ def thd(
676
680
  result = fft(trace, window=window, nfft=nfft, detrend="mean")
677
681
  freq, mag_db = result[0], result[1]
678
682
 
679
- # Convert to linear
683
+ # Convert to linear magnitude
680
684
  magnitude = 10 ** (mag_db / 20)
681
685
 
682
- # Find fundamental
686
+ # Find fundamental (strongest peak above DC)
683
687
  _fund_idx, fund_freq, fund_mag = _find_fundamental(freq, magnitude)
684
688
 
685
689
  if fund_mag == 0 or fund_freq == 0:
686
- return np.nan
690
+ return make_inapplicable("%", "No fundamental frequency detected")
687
691
 
688
- # Find harmonics
692
+ # Find harmonic frequencies (2*f0, 3*f0, ..., n*f0)
689
693
  harmonic_indices = _find_harmonic_indices(freq, fund_freq, n_harmonics)
690
694
 
691
695
  if len(harmonic_indices) == 0:
692
- return 0.0 if not return_db else -np.inf
696
+ return make_measurement(0.0, "%")
693
697
 
694
- # Sum harmonic power
698
+ # Compute total harmonic power: sum of squared magnitudes
695
699
  harmonic_power = sum(magnitude[i] ** 2 for i in harmonic_indices)
696
700
 
697
- # THD ratio
701
+ # THD = sqrt(sum(harmonic_power)) / fundamental_amplitude
702
+ # This is the IEEE 1241-2010 definition
698
703
  thd_ratio = np.sqrt(harmonic_power) / fund_mag
699
704
 
700
- if return_db:
701
- if thd_ratio <= 0:
702
- return -np.inf
703
- return float(20 * np.log10(thd_ratio))
704
- else:
705
- return float(thd_ratio * 100)
705
+ # Validate: THD must always be non-negative
706
+ if thd_ratio < 0:
707
+ raise ValueError(
708
+ f"THD ratio is negative ({thd_ratio:.6f}), indicating a calculation error. "
709
+ f"Fundamental: {fund_mag:.6f}, Harmonic power: {harmonic_power:.6f}"
710
+ )
711
+
712
+ if thd_ratio <= 0:
713
+ return make_measurement(0.0, "%")
714
+
715
+ # Return as percentage (0-100)
716
+ return make_measurement(float(thd_ratio * 100), "%")
706
717
 
707
718
 
708
719
  def snr(
@@ -711,7 +722,7 @@ def snr(
711
722
  n_harmonics: int = 10,
712
723
  window: str = "hann",
713
724
  nfft: int | None = None,
714
- ) -> float:
725
+ ) -> MeasurementResult:
715
726
  """Compute Signal-to-Noise Ratio.
716
727
 
717
728
  SNR is the ratio of signal power to noise power, excluding harmonics.
@@ -724,11 +735,12 @@ def snr(
724
735
  preserve coherent sampling per IEEE 1241-2010.
725
736
 
726
737
  Returns:
727
- SNR in dB.
738
+ MeasurementResult with SNR in dB, or inapplicable if cannot compute.
728
739
 
729
740
  Example:
730
- >>> snr_db = snr(trace)
731
- >>> print(f"SNR: {snr_db:.1f} dB")
741
+ >>> result = snr(trace)
742
+ >>> if result["applicable"]:
743
+ ... print(f"SNR: {result['display']}")
732
744
 
733
745
  References:
734
746
  IEEE 1241-2010 Section 4.1.4.1
@@ -740,7 +752,7 @@ def snr(
740
752
  fund_idx, fund_freq, fund_mag = _find_fundamental(freq, magnitude)
741
753
 
742
754
  if fund_mag == 0 or fund_freq == 0:
743
- return np.nan
755
+ return make_inapplicable("dB", "No fundamental frequency detected")
744
756
 
745
757
  harmonic_indices = _find_harmonic_indices(freq, fund_freq, n_harmonics)
746
758
  exclude_indices = _build_exclusion_set(fund_idx, harmonic_indices, len(magnitude))
@@ -749,9 +761,9 @@ def snr(
749
761
  noise_power = _compute_noise_power(magnitude, exclude_indices)
750
762
 
751
763
  if noise_power <= 0:
752
- return np.inf
764
+ return make_inapplicable("dB", "No noise detected (perfect signal)")
753
765
 
754
- return float(10 * np.log10(signal_power / noise_power))
766
+ return make_measurement(float(10 * np.log10(signal_power / noise_power)), "dB")
755
767
 
756
768
 
757
769
  def _compute_magnitude_spectrum(
@@ -842,7 +854,7 @@ def sinad(
842
854
  *,
843
855
  window: str = "hann",
844
856
  nfft: int | None = None,
845
- ) -> float:
857
+ ) -> MeasurementResult:
846
858
  """Compute Signal-to-Noise and Distortion ratio.
847
859
 
848
860
  SINAD is the ratio of signal power to noise plus distortion power.
@@ -854,11 +866,12 @@ def sinad(
854
866
  preserve coherent sampling per IEEE 1241-2010.
855
867
 
856
868
  Returns:
857
- SINAD in dB.
869
+ MeasurementResult with SINAD in dB, or inapplicable if cannot compute.
858
870
 
859
871
  Example:
860
- >>> sinad_db = sinad(trace)
861
- >>> print(f"SINAD: {sinad_db:.1f} dB")
872
+ >>> result = sinad(trace)
873
+ >>> if result["applicable"]:
874
+ ... print(f"SINAD: {result['display']}")
862
875
 
863
876
  References:
864
877
  IEEE 1241-2010 Section 4.1.4.3
@@ -875,7 +888,7 @@ def sinad(
875
888
  fund_idx, _fund_freq, fund_mag = _find_fundamental(freq, magnitude)
876
889
 
877
890
  if fund_mag == 0:
878
- return np.nan
891
+ return make_inapplicable("dB", "No fundamental frequency detected")
879
892
 
880
893
  # Signal power: use 3-bin window around fundamental to capture spectral leakage
881
894
  signal_power = 0.0
@@ -891,10 +904,10 @@ def sinad(
891
904
  nad_power = total_power - signal_power
892
905
 
893
906
  if nad_power <= 0:
894
- return np.inf
907
+ return make_inapplicable("dB", "No noise/distortion detected (perfect signal)")
895
908
 
896
909
  sinad_ratio = signal_power / nad_power
897
- return float(10 * np.log10(sinad_ratio))
910
+ return make_measurement(float(10 * np.log10(sinad_ratio)), "dB")
898
911
 
899
912
 
900
913
  def enob(
@@ -902,7 +915,7 @@ def enob(
902
915
  *,
903
916
  window: str = "hann",
904
917
  nfft: int | None = None,
905
- ) -> float:
918
+ ) -> MeasurementResult:
906
919
  """Compute Effective Number of Bits from SINAD.
907
920
 
908
921
  ENOB = (SINAD - 1.76) / 6.02
@@ -913,21 +926,26 @@ def enob(
913
926
  nfft: FFT length.
914
927
 
915
928
  Returns:
916
- ENOB in bits, or np.nan if SINAD is invalid.
929
+ MeasurementResult with ENOB in bits, or inapplicable if SINAD is invalid.
917
930
 
918
931
  Example:
919
- >>> bits = enob(trace)
920
- >>> print(f"ENOB: {bits:.2f} bits")
932
+ >>> result = enob(trace)
933
+ >>> if result["applicable"]:
934
+ ... print(f"ENOB: {result['display']}")
921
935
 
922
936
  References:
923
937
  IEEE 1241-2010 Section 4.1.4.4
924
938
  """
925
- sinad_db = sinad(trace, window=window, nfft=nfft)
939
+ sinad_result = sinad(trace, window=window, nfft=nfft)
940
+
941
+ if not sinad_result["applicable"] or sinad_result["value"] is None:
942
+ return make_inapplicable("", "SINAD unavailable")
926
943
 
927
- if np.isnan(sinad_db) or sinad_db <= 0:
928
- return np.nan
944
+ sinad_db = sinad_result["value"]
945
+ if sinad_db <= 0:
946
+ return make_inapplicable("", "SINAD too low (≤0 dB)")
929
947
 
930
- return float((sinad_db - 1.76) / 6.02)
948
+ return make_measurement(float((sinad_db - 1.76) / 6.02), "")
931
949
 
932
950
 
933
951
  def sfdr(
@@ -935,7 +953,7 @@ def sfdr(
935
953
  *,
936
954
  window: str = "hann",
937
955
  nfft: int | None = None,
938
- ) -> float:
956
+ ) -> MeasurementResult:
939
957
  """Compute Spurious-Free Dynamic Range.
940
958
 
941
959
  SFDR is the ratio of fundamental to largest spurious component.
@@ -947,11 +965,13 @@ def sfdr(
947
965
  preserve coherent sampling per IEEE 1241-2010.
948
966
 
949
967
  Returns:
950
- SFDR in dBc (dB relative to carrier/fundamental).
968
+ MeasurementResult with SFDR in dBc (dB relative to carrier/fundamental),
969
+ or inapplicable if cannot compute.
951
970
 
952
971
  Example:
953
- >>> sfdr_db = sfdr(trace)
954
- >>> print(f"SFDR: {sfdr_db:.1f} dBc")
972
+ >>> result = sfdr(trace)
973
+ >>> if result["applicable"]:
974
+ ... print(f"SFDR: {result['display']}")
955
975
 
956
976
  References:
957
977
  IEEE 1241-2010 Section 4.1.4.5
@@ -968,7 +988,7 @@ def sfdr(
968
988
  fund_idx, _fund_freq, fund_mag = _find_fundamental(freq, magnitude)
969
989
 
970
990
  if fund_mag == 0:
971
- return np.nan
991
+ return make_inapplicable("dB", "No fundamental frequency detected")
972
992
 
973
993
  # Create mask for spurs (exclude fundamental and DC)
974
994
  spur_mask = np.ones(len(magnitude), dtype=bool)
@@ -987,15 +1007,15 @@ def sfdr(
987
1007
  # Find largest spur
988
1008
  spur_magnitudes = magnitude[spur_mask]
989
1009
  if len(spur_magnitudes) == 0:
990
- return np.inf
1010
+ return make_inapplicable("dB", "No spurs detected (clean spectrum)")
991
1011
 
992
1012
  max_spur = np.max(spur_magnitudes)
993
1013
 
994
1014
  if max_spur <= 0:
995
- return np.inf
1015
+ return make_inapplicable("dB", "No valid spurs detected")
996
1016
 
997
1017
  sfdr_ratio = fund_mag / max_spur
998
- return float(20 * np.log10(sfdr_ratio))
1018
+ return make_measurement(float(20 * np.log10(sfdr_ratio)), "dB")
999
1019
 
1000
1020
 
1001
1021
  def hilbert_transform(
@@ -1015,7 +1035,7 @@ def hilbert_transform(
1015
1035
 
1016
1036
  Example:
1017
1037
  >>> envelope, phase, inst_freq = hilbert_transform(trace)
1018
- >>> plt.plot(trace.time_vector, envelope)
1038
+ >>> plt.plot(trace.time, envelope)
1019
1039
 
1020
1040
  References:
1021
1041
  Oppenheim, A. V. & Schafer, R. W. (2009). Discrete-Time
@@ -1072,7 +1092,7 @@ def cwt(
1072
1092
 
1073
1093
  Example:
1074
1094
  >>> scales, freqs, coef = cwt(trace, wavelet="morlet")
1075
- >>> plt.pcolormesh(trace.time_vector, freqs, np.abs(coef))
1095
+ >>> plt.pcolormesh(trace.time, freqs, np.abs(coef))
1076
1096
  >>> plt.ylabel("Frequency (Hz)")
1077
1097
 
1078
1098
  References:
@@ -1936,20 +1956,444 @@ def _extract_fft_segment(
1936
1956
  return segment
1937
1957
 
1938
1958
 
1959
+ def find_peaks(
1960
+ trace: WaveformTrace,
1961
+ *,
1962
+ window: str = "hann",
1963
+ nfft: int | None = None,
1964
+ threshold_db: float = -60.0,
1965
+ min_distance: int = 5,
1966
+ n_peaks: int | None = None,
1967
+ ) -> dict[str, NDArray[np.float64]]:
1968
+ """Find spectral peaks in FFT magnitude spectrum.
1969
+
1970
+ Identifies prominent frequency components above a threshold with
1971
+ minimum spacing between peaks.
1972
+
1973
+ Args:
1974
+ trace: Input waveform trace.
1975
+ window: Window function for FFT.
1976
+ nfft: FFT length.
1977
+ threshold_db: Minimum peak magnitude in dB (relative to max).
1978
+ min_distance: Minimum bin spacing between peaks.
1979
+ n_peaks: Maximum number of peaks to return (None = all).
1980
+
1981
+ Returns:
1982
+ Dictionary with keys:
1983
+ - "frequencies": Peak frequencies in Hz
1984
+ - "magnitudes_db": Peak magnitudes in dB
1985
+ - "indices": FFT bin indices of peaks
1986
+
1987
+ Example:
1988
+ >>> peaks = find_peaks(trace, threshold_db=-40, n_peaks=10)
1989
+ >>> print(f"Found {len(peaks['frequencies'])} peaks")
1990
+ >>> for freq, mag in zip(peaks['frequencies'], peaks['magnitudes_db']):
1991
+ ... print(f" {freq:.1f} Hz: {mag:.1f} dB")
1992
+
1993
+ References:
1994
+ IEEE 1241-2010 Section 4.1.5 - Spectral Analysis
1995
+ """
1996
+ from scipy.signal import find_peaks as sp_find_peaks
1997
+
1998
+ result = fft(trace, window=window, nfft=nfft)
1999
+ freq, mag_db = result[0], result[1]
2000
+
2001
+ # Find peaks using scipy
2002
+ # Convert threshold from dB relative to max
2003
+ max_mag_db = np.max(mag_db)
2004
+ abs_threshold = max_mag_db + threshold_db # threshold_db is negative
2005
+
2006
+ peak_indices, _ = sp_find_peaks(mag_db, height=abs_threshold, distance=min_distance)
2007
+
2008
+ # Sort by magnitude (strongest first)
2009
+ sorted_idx = np.argsort(mag_db[peak_indices])[::-1]
2010
+ peak_indices = peak_indices[sorted_idx]
2011
+
2012
+ # Limit number of peaks
2013
+ if n_peaks is not None:
2014
+ peak_indices = peak_indices[:n_peaks]
2015
+
2016
+ return {
2017
+ "frequencies": freq[peak_indices],
2018
+ "magnitudes_db": mag_db[peak_indices],
2019
+ "indices": peak_indices.astype(np.float64),
2020
+ }
2021
+
2022
+
2023
+ def extract_harmonics(
2024
+ trace: WaveformTrace,
2025
+ *,
2026
+ fundamental_freq: float | None = None,
2027
+ n_harmonics: int = 10,
2028
+ window: str = "hann",
2029
+ nfft: int | None = None,
2030
+ search_width_hz: float = 50.0,
2031
+ ) -> dict[str, NDArray[np.float64]]:
2032
+ """Extract harmonic frequencies and amplitudes from spectrum.
2033
+
2034
+ Identifies fundamental frequency (if not provided) and extracts
2035
+ harmonic series with frequencies and amplitudes.
2036
+
2037
+ Args:
2038
+ trace: Input waveform trace.
2039
+ fundamental_freq: Fundamental frequency in Hz. If None, auto-detected.
2040
+ n_harmonics: Number of harmonics to extract (excluding fundamental).
2041
+ window: Window function for FFT.
2042
+ nfft: FFT length.
2043
+ search_width_hz: Search range around expected harmonic frequencies.
2044
+
2045
+ Returns:
2046
+ Dictionary with keys:
2047
+ - "frequencies": Harmonic frequencies [f0, 2f0, 3f0, ...]
2048
+ - "amplitudes": Harmonic amplitudes (linear scale)
2049
+ - "amplitudes_db": Harmonic amplitudes in dB
2050
+ - "fundamental_freq": Detected or provided fundamental frequency
2051
+
2052
+ Example:
2053
+ >>> harmonics = extract_harmonics(trace, n_harmonics=5)
2054
+ >>> f0 = harmonics["fundamental_freq"]
2055
+ >>> print(f"Fundamental: {f0:.1f} Hz")
2056
+ >>> for i, (freq, amp_db) in enumerate(
2057
+ ... zip(harmonics["frequencies"], harmonics["amplitudes_db"]), 1
2058
+ ... ):
2059
+ ... print(f" H{i}: {freq:.1f} Hz at {amp_db:.1f} dB")
2060
+
2061
+ References:
2062
+ IEEE 1241-2010 Section 4.1.4.2 - Harmonic Analysis
2063
+ """
2064
+ result = fft(trace, window=window, nfft=nfft)
2065
+ freq, mag_db = result[0], result[1]
2066
+ magnitude = 10 ** (mag_db / 20)
2067
+
2068
+ # Auto-detect fundamental if not provided
2069
+ if fundamental_freq is None:
2070
+ _fund_idx, fund_freq, _fund_mag = _find_fundamental(freq, magnitude)
2071
+ fundamental_freq = fund_freq
2072
+
2073
+ if fundamental_freq == 0:
2074
+ # Return empty result
2075
+ return {
2076
+ "frequencies": np.array([]),
2077
+ "amplitudes": np.array([]),
2078
+ "amplitudes_db": np.array([]),
2079
+ "fundamental_freq": np.array([0.0]),
2080
+ }
2081
+
2082
+ # Extract harmonics
2083
+ harmonic_freqs = []
2084
+ harmonic_amps = []
2085
+
2086
+ for h in range(1, n_harmonics + 2): # Include fundamental (h=1)
2087
+ target_freq = h * fundamental_freq
2088
+ if target_freq > freq[-1]:
2089
+ break
2090
+
2091
+ # Search around expected frequency
2092
+ search_mask = np.abs(freq - target_freq) <= search_width_hz
2093
+ if not np.any(search_mask):
2094
+ continue
2095
+
2096
+ # Find peak in search region
2097
+ search_region_mag = magnitude.copy()
2098
+ search_region_mag[~search_mask] = 0
2099
+ peak_idx = np.argmax(search_region_mag)
2100
+
2101
+ harmonic_freqs.append(float(freq[peak_idx]))
2102
+ harmonic_amps.append(float(magnitude[peak_idx]))
2103
+
2104
+ harmonic_freqs_arr = np.array(harmonic_freqs)
2105
+ harmonic_amps_arr = np.array(harmonic_amps)
2106
+ harmonic_amps_db = 20 * np.log10(np.maximum(harmonic_amps_arr, 1e-20))
2107
+
2108
+ return {
2109
+ "frequencies": harmonic_freqs_arr,
2110
+ "amplitudes": harmonic_amps_arr,
2111
+ "amplitudes_db": harmonic_amps_db,
2112
+ "fundamental_freq": np.array([fundamental_freq]),
2113
+ }
2114
+
2115
+
2116
+ def phase_spectrum(
2117
+ trace: WaveformTrace,
2118
+ *,
2119
+ window: str = "hann",
2120
+ nfft: int | None = None,
2121
+ unwrap: bool = True,
2122
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
2123
+ """Compute phase spectrum from FFT.
2124
+
2125
+ Extracts phase information from frequency domain representation.
2126
+
2127
+ Args:
2128
+ trace: Input waveform trace.
2129
+ window: Window function for FFT.
2130
+ nfft: FFT length.
2131
+ unwrap: If True, unwrap phase to remove 2π discontinuities.
2132
+
2133
+ Returns:
2134
+ (frequencies, phase) where phase is in radians.
2135
+
2136
+ Example:
2137
+ >>> freq, phase = phase_spectrum(trace)
2138
+ >>> plt.plot(freq, phase)
2139
+ >>> plt.xlabel("Frequency (Hz)")
2140
+ >>> plt.ylabel("Phase (radians)")
2141
+
2142
+ References:
2143
+ Oppenheim & Schafer (2009) - Discrete-Time Signal Processing
2144
+ """
2145
+ result = fft(trace, window=window, nfft=nfft, return_phase=True)
2146
+ assert len(result) == 3, "Expected 3-tuple from fft with return_phase=True"
2147
+ freq, _mag_db, phase = result[0], result[1], result[2]
2148
+
2149
+ if unwrap:
2150
+ phase = np.unwrap(phase)
2151
+
2152
+ return freq, phase
2153
+
2154
+
2155
+ def group_delay(
2156
+ trace: WaveformTrace,
2157
+ *,
2158
+ window: str = "hann",
2159
+ nfft: int | None = None,
2160
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
2161
+ """Compute group delay from phase spectrum.
2162
+
2163
+ Group delay is the negative derivative of phase with respect to
2164
+ frequency, representing signal delay at each frequency.
2165
+
2166
+ Args:
2167
+ trace: Input waveform trace.
2168
+ window: Window function for FFT.
2169
+ nfft: FFT length.
2170
+
2171
+ Returns:
2172
+ (frequencies, group_delay_samples) - Delay in samples at each frequency.
2173
+
2174
+ Example:
2175
+ >>> freq, gd = group_delay(trace)
2176
+ >>> plt.plot(freq, gd)
2177
+ >>> plt.xlabel("Frequency (Hz)")
2178
+ >>> plt.ylabel("Group Delay (samples)")
2179
+
2180
+ References:
2181
+ Oppenheim & Schafer (2009) Section 5.1.1
2182
+ """
2183
+ freq, phase = phase_spectrum(trace, window=window, nfft=nfft, unwrap=True)
2184
+
2185
+ # Group delay = -dφ/dω
2186
+ # In discrete frequency: gd[i] ≈ -(φ[i+1] - φ[i]) / (ω[i+1] - ω[i])
2187
+ # Convert frequency to angular frequency: ω = 2π * f
2188
+ omega = 2 * np.pi * freq
2189
+
2190
+ # Compute derivative using central differences
2191
+ gd = np.zeros_like(phase)
2192
+
2193
+ # Central difference for interior points
2194
+ gd[1:-1] = -(phase[2:] - phase[:-2]) / (omega[2:] - omega[:-2])
2195
+
2196
+ # Forward/backward difference for boundaries
2197
+ if len(phase) > 1:
2198
+ gd[0] = -(phase[1] - phase[0]) / (omega[1] - omega[0])
2199
+ gd[-1] = -(phase[-1] - phase[-2]) / (omega[-1] - omega[-2])
2200
+
2201
+ return freq, gd
2202
+
2203
+
2204
+ def measure(
2205
+ trace: WaveformTrace,
2206
+ *,
2207
+ parameters: list[str] | None = None,
2208
+ include_units: bool = True,
2209
+ ) -> dict[str, Any]:
2210
+ """Compute multiple spectral measurements with consistent format.
2211
+
2212
+ Unified function for computing spectral quality metrics following IEEE 1241-2010.
2213
+ Returns MeasurementResult format with applicability tracking.
2214
+
2215
+ Args:
2216
+ trace: Input waveform trace.
2217
+ parameters: List of measurement names to compute. If None, compute all.
2218
+ Valid names: thd, snr, sinad, enob, sfdr, dominant_freq
2219
+ include_units: If True, return MeasurementResult format. If False, return flat values.
2220
+
2221
+ Returns:
2222
+ Dictionary mapping measurement names to MeasurementResults (if include_units=True)
2223
+ or raw values (if include_units=False).
2224
+
2225
+ Raises:
2226
+ InsufficientDataError: If trace is too short for analysis.
2227
+ AnalysisError: If computation fails.
2228
+
2229
+ Example:
2230
+ >>> from oscura.analyzers.waveform.spectral import measure
2231
+ >>> results = measure(trace)
2232
+ >>> if results['thd']['applicable']:
2233
+ ... print(f"THD: {results['thd']['display']}")
2234
+
2235
+ >>> # Get specific measurements only
2236
+ >>> results = measure(trace, parameters=["thd", "snr"])
2237
+
2238
+ >>> # Get flat values (legacy compatibility)
2239
+ >>> results = measure(trace, include_units=False)
2240
+ >>> thd_value = results["thd"] # float or np.nan
2241
+
2242
+ References:
2243
+ IEEE 1241-2010: ADC Terminology and Test Methods
2244
+ """
2245
+ # Define all available spectral measurements
2246
+ all_measurements = {
2247
+ "thd": thd,
2248
+ "snr": snr,
2249
+ "sinad": sinad,
2250
+ "enob": enob,
2251
+ "sfdr": sfdr,
2252
+ }
2253
+
2254
+ # Select requested measurements or all
2255
+ if parameters is None:
2256
+ selected = all_measurements
2257
+ else:
2258
+ selected = {k: v for k, v in all_measurements.items() if k in parameters}
2259
+
2260
+ results: dict[str, Any] = {}
2261
+
2262
+ for name, func in selected.items():
2263
+ try:
2264
+ measurement_result = func(trace) # type: ignore[operator]
2265
+
2266
+ if include_units:
2267
+ # Return full MeasurementResult
2268
+ results[name] = measurement_result
2269
+ else:
2270
+ # Legacy mode: extract raw value (NaN if inapplicable)
2271
+ if measurement_result["applicable"]:
2272
+ results[name] = measurement_result["value"]
2273
+ else:
2274
+ results[name] = np.nan
2275
+
2276
+ except Exception:
2277
+ # On error, create inapplicable result
2278
+ if include_units:
2279
+ results[name] = make_inapplicable("", "Measurement failed")
2280
+ else:
2281
+ results[name] = np.nan
2282
+
2283
+ # Add dominant frequency if requested or if computing all
2284
+ if parameters is None or "dominant_freq" in parameters:
2285
+ try:
2286
+ fft_result = fft(trace, return_phase=False)
2287
+ freq, magnitude = fft_result[0], fft_result[1]
2288
+ dominant_idx = int(np.argmax(np.abs(magnitude[1:]))) + 1 # Skip DC
2289
+ dominant_freq_value = float(freq[dominant_idx])
2290
+
2291
+ if include_units:
2292
+ results["dominant_freq"] = make_measurement(dominant_freq_value, "Hz")
2293
+ else:
2294
+ results["dominant_freq"] = dominant_freq_value
2295
+ except Exception:
2296
+ if include_units:
2297
+ results["dominant_freq"] = make_inapplicable("Hz", "FFT failed")
2298
+ else:
2299
+ results["dominant_freq"] = np.nan
2300
+
2301
+ return results
2302
+
2303
+
2304
+ class SpectralAnalyzer:
2305
+ """Spectral analysis class wrapping functional APIs.
2306
+
2307
+ Provides object-oriented interface to spectral analysis functions
2308
+ for compatibility with legacy code and workflows.
2309
+
2310
+ Example:
2311
+ >>> analyzer = SpectralAnalyzer()
2312
+ >>> freqs, mags = analyzer.fft(data, sample_rate)
2313
+ >>> thd_value = analyzer.thd(trace)
2314
+ """
2315
+
2316
+ def fft(
2317
+ self,
2318
+ data: NDArray[np.floating[Any]],
2319
+ sample_rate: float,
2320
+ *,
2321
+ window: str = "hann",
2322
+ nfft: int | None = None,
2323
+ detrend: Literal["none", "mean", "linear"] = "mean",
2324
+ return_phase: bool = False,
2325
+ ) -> (
2326
+ tuple[NDArray[np.float64], NDArray[np.float64]]
2327
+ | tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]
2328
+ ):
2329
+ """Compute FFT of signal data.
2330
+
2331
+ Args:
2332
+ data: Input signal data
2333
+ sample_rate: Sample rate in Hz
2334
+ window: Window function name (default "hann")
2335
+ nfft: FFT size (power of 2), auto-computed if None
2336
+ detrend: Detrend method ('none', 'mean', or 'linear')
2337
+ return_phase: If True, also return phase spectrum
2338
+
2339
+ Returns:
2340
+ Tuple of (frequencies, magnitudes_db) or (frequencies, magnitudes_db, phase)
2341
+ """
2342
+ from oscura.core.types import TraceMetadata, WaveformTrace
2343
+
2344
+ # Convert to WaveformTrace for functional API
2345
+ trace = WaveformTrace(data=data, metadata=TraceMetadata(sample_rate=sample_rate))
2346
+
2347
+ # Call functional API (always returns dB)
2348
+ return fft(trace, window=window, nfft=nfft, detrend=detrend, return_phase=return_phase)
2349
+
2350
+ def thd(self, trace: WaveformTrace, fundamental_freq: float | None = None) -> MeasurementResult:
2351
+ """Compute Total Harmonic Distortion."""
2352
+ return thd(trace)
2353
+
2354
+ def snr(self, trace: WaveformTrace, fundamental_freq: float | None = None) -> MeasurementResult:
2355
+ """Compute Signal-to-Noise Ratio."""
2356
+ return snr(trace)
2357
+
2358
+ def sinad(
2359
+ self, trace: WaveformTrace, fundamental_freq: float | None = None
2360
+ ) -> MeasurementResult:
2361
+ """Compute SINAD (Signal-to-Noise-And-Distortion)."""
2362
+ return sinad(trace)
2363
+
2364
+ def enob(
2365
+ self, trace: WaveformTrace, fundamental_freq: float | None = None
2366
+ ) -> MeasurementResult:
2367
+ """Compute Effective Number of Bits."""
2368
+ return enob(trace)
2369
+
2370
+ def sfdr(
2371
+ self, trace: WaveformTrace, fundamental_freq: float | None = None
2372
+ ) -> MeasurementResult:
2373
+ """Compute Spurious-Free Dynamic Range."""
2374
+ return sfdr(trace)
2375
+
2376
+
1939
2377
  __all__ = [
2378
+ "SpectralAnalyzer",
1940
2379
  "bartlett_psd",
1941
2380
  "clear_fft_cache",
1942
2381
  "configure_fft_cache",
1943
2382
  "cwt",
1944
2383
  "dwt",
1945
2384
  "enob",
2385
+ "extract_harmonics",
1946
2386
  "fft",
1947
2387
  "fft_chunked",
2388
+ "find_peaks",
1948
2389
  "get_fft_cache_stats",
2390
+ "group_delay",
1949
2391
  "hilbert_transform",
1950
2392
  "idwt",
2393
+ "measure",
1951
2394
  "mfcc",
1952
2395
  "periodogram",
2396
+ "phase_spectrum",
1953
2397
  "psd",
1954
2398
  "psd_chunked",
1955
2399
  "sfdr",