oscura 0.8.0__py3-none-any.whl → 0.11.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 (161) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/__main__.py +4 -0
  3. oscura/analyzers/__init__.py +2 -0
  4. oscura/analyzers/digital/extraction.py +2 -3
  5. oscura/analyzers/digital/quality.py +1 -1
  6. oscura/analyzers/digital/timing.py +1 -1
  7. oscura/analyzers/ml/signal_classifier.py +6 -0
  8. oscura/analyzers/patterns/__init__.py +66 -0
  9. oscura/analyzers/power/basic.py +3 -3
  10. oscura/analyzers/power/soa.py +1 -1
  11. oscura/analyzers/power/switching.py +3 -3
  12. oscura/analyzers/signal_classification.py +529 -0
  13. oscura/analyzers/signal_integrity/sparams.py +3 -3
  14. oscura/analyzers/statistics/basic.py +10 -7
  15. oscura/analyzers/validation.py +1 -1
  16. oscura/analyzers/waveform/measurements.py +200 -156
  17. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  18. oscura/analyzers/waveform/spectral.py +182 -84
  19. oscura/api/dsl/commands.py +15 -6
  20. oscura/api/server/templates/base.html +137 -146
  21. oscura/api/server/templates/export.html +84 -110
  22. oscura/api/server/templates/home.html +248 -267
  23. oscura/api/server/templates/protocols.html +44 -48
  24. oscura/api/server/templates/reports.html +27 -35
  25. oscura/api/server/templates/session_detail.html +68 -78
  26. oscura/api/server/templates/sessions.html +62 -72
  27. oscura/api/server/templates/waveforms.html +54 -64
  28. oscura/automotive/__init__.py +1 -1
  29. oscura/automotive/can/session.py +1 -1
  30. oscura/automotive/dbc/generator.py +638 -23
  31. oscura/automotive/dtc/data.json +17 -102
  32. oscura/automotive/flexray/fibex.py +9 -1
  33. oscura/automotive/uds/decoder.py +99 -6
  34. oscura/cli/analyze.py +8 -2
  35. oscura/cli/batch.py +36 -5
  36. oscura/cli/characterize.py +18 -4
  37. oscura/cli/export.py +47 -5
  38. oscura/cli/main.py +2 -0
  39. oscura/cli/onboarding/wizard.py +10 -6
  40. oscura/cli/pipeline.py +585 -0
  41. oscura/cli/visualize.py +6 -4
  42. oscura/convenience.py +400 -32
  43. oscura/core/measurement_result.py +286 -0
  44. oscura/core/progress.py +1 -1
  45. oscura/core/schemas/device_mapping.json +2 -8
  46. oscura/core/schemas/packet_format.json +4 -24
  47. oscura/core/schemas/protocol_definition.json +2 -12
  48. oscura/core/types.py +232 -239
  49. oscura/correlation/multi_protocol.py +1 -1
  50. oscura/export/legacy/__init__.py +11 -0
  51. oscura/export/legacy/wav.py +75 -0
  52. oscura/exporters/__init__.py +19 -0
  53. oscura/exporters/wireshark.py +809 -0
  54. oscura/hardware/acquisition/file.py +5 -19
  55. oscura/hardware/acquisition/saleae.py +10 -10
  56. oscura/hardware/acquisition/socketcan.py +4 -6
  57. oscura/hardware/acquisition/synthetic.py +1 -5
  58. oscura/hardware/acquisition/visa.py +6 -6
  59. oscura/hardware/security/side_channel_detector.py +5 -508
  60. oscura/inference/message_format.py +686 -1
  61. oscura/jupyter/display.py +2 -2
  62. oscura/jupyter/magic.py +3 -3
  63. oscura/loaders/__init__.py +17 -12
  64. oscura/loaders/binary.py +1 -1
  65. oscura/loaders/chipwhisperer.py +1 -2
  66. oscura/loaders/configurable.py +1 -1
  67. oscura/loaders/csv_loader.py +2 -2
  68. oscura/loaders/hdf5_loader.py +1 -1
  69. oscura/loaders/lazy.py +6 -1
  70. oscura/loaders/mmap_loader.py +0 -1
  71. oscura/loaders/numpy_loader.py +8 -7
  72. oscura/loaders/preprocessing.py +3 -5
  73. oscura/loaders/rigol.py +21 -7
  74. oscura/loaders/sigrok.py +2 -5
  75. oscura/loaders/tdms.py +3 -2
  76. oscura/loaders/tektronix.py +38 -32
  77. oscura/loaders/tss.py +20 -27
  78. oscura/loaders/validation.py +17 -10
  79. oscura/loaders/vcd.py +13 -8
  80. oscura/loaders/wav.py +1 -6
  81. oscura/pipeline/__init__.py +76 -0
  82. oscura/pipeline/handlers/__init__.py +165 -0
  83. oscura/pipeline/handlers/analyzers.py +1045 -0
  84. oscura/pipeline/handlers/decoders.py +899 -0
  85. oscura/pipeline/handlers/exporters.py +1103 -0
  86. oscura/pipeline/handlers/filters.py +891 -0
  87. oscura/pipeline/handlers/loaders.py +640 -0
  88. oscura/pipeline/handlers/transforms.py +768 -0
  89. oscura/reporting/formatting/measurements.py +55 -14
  90. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  91. oscura/sessions/legacy.py +49 -1
  92. oscura/side_channel/__init__.py +38 -57
  93. oscura/utils/builders/signal_builder.py +5 -5
  94. oscura/utils/comparison/compare.py +7 -9
  95. oscura/utils/comparison/golden.py +1 -1
  96. oscura/utils/filtering/convenience.py +2 -2
  97. oscura/utils/math/arithmetic.py +38 -62
  98. oscura/utils/math/interpolation.py +20 -20
  99. oscura/utils/pipeline/__init__.py +4 -17
  100. oscura/utils/progressive.py +1 -4
  101. oscura/utils/triggering/edge.py +1 -1
  102. oscura/utils/triggering/pattern.py +2 -2
  103. oscura/utils/triggering/pulse.py +2 -2
  104. oscura/utils/triggering/window.py +3 -3
  105. oscura/validation/hil_testing.py +11 -11
  106. oscura/visualization/__init__.py +46 -284
  107. oscura/visualization/batch.py +72 -433
  108. oscura/visualization/plot.py +542 -53
  109. oscura/visualization/styles.py +184 -318
  110. oscura/workflows/batch/advanced.py +1 -1
  111. oscura/workflows/batch/aggregate.py +12 -9
  112. oscura/workflows/complete_re.py +251 -23
  113. oscura/workflows/digital.py +27 -4
  114. oscura/workflows/multi_trace.py +136 -17
  115. oscura/workflows/waveform.py +11 -6
  116. oscura-0.11.0.dist-info/METADATA +460 -0
  117. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/RECORD +120 -145
  118. oscura/side_channel/dpa.py +0 -1025
  119. oscura/utils/optimization/__init__.py +0 -19
  120. oscura/utils/optimization/parallel.py +0 -443
  121. oscura/utils/optimization/search.py +0 -532
  122. oscura/utils/pipeline/base.py +0 -338
  123. oscura/utils/pipeline/composition.py +0 -248
  124. oscura/utils/pipeline/parallel.py +0 -449
  125. oscura/utils/pipeline/pipeline.py +0 -375
  126. oscura/utils/search/__init__.py +0 -16
  127. oscura/utils/search/anomaly.py +0 -424
  128. oscura/utils/search/context.py +0 -294
  129. oscura/utils/search/pattern.py +0 -288
  130. oscura/utils/storage/__init__.py +0 -61
  131. oscura/utils/storage/database.py +0 -1166
  132. oscura/visualization/accessibility.py +0 -526
  133. oscura/visualization/annotations.py +0 -371
  134. oscura/visualization/axis_scaling.py +0 -305
  135. oscura/visualization/colors.py +0 -451
  136. oscura/visualization/digital.py +0 -436
  137. oscura/visualization/eye.py +0 -571
  138. oscura/visualization/histogram.py +0 -281
  139. oscura/visualization/interactive.py +0 -1035
  140. oscura/visualization/jitter.py +0 -1042
  141. oscura/visualization/keyboard.py +0 -394
  142. oscura/visualization/layout.py +0 -400
  143. oscura/visualization/optimization.py +0 -1079
  144. oscura/visualization/palettes.py +0 -446
  145. oscura/visualization/power.py +0 -508
  146. oscura/visualization/power_extended.py +0 -955
  147. oscura/visualization/presets.py +0 -469
  148. oscura/visualization/protocols.py +0 -1246
  149. oscura/visualization/render.py +0 -223
  150. oscura/visualization/rendering.py +0 -444
  151. oscura/visualization/reverse_engineering.py +0 -838
  152. oscura/visualization/signal_integrity.py +0 -989
  153. oscura/visualization/specialized.py +0 -643
  154. oscura/visualization/spectral.py +0 -1226
  155. oscura/visualization/thumbnails.py +0 -340
  156. oscura/visualization/time_axis.py +0 -351
  157. oscura/visualization/waveform.py +0 -454
  158. oscura-0.8.0.dist-info/METADATA +0 -661
  159. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/WHEEL +0 -0
  160. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/entry_points.txt +0 -0
  161. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -17,6 +17,7 @@ References:
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import threading
20
21
  from functools import lru_cache
21
22
  from typing import TYPE_CHECKING, Any, Literal
22
23
 
@@ -24,15 +25,17 @@ import numpy as np
24
25
  from scipy import signal as sp_signal
25
26
 
26
27
  from oscura.core.exceptions import AnalysisError, InsufficientDataError
28
+ from oscura.core.measurement_result import make_inapplicable, make_measurement
27
29
  from oscura.utils.windowing import get_window
28
30
 
29
31
  if TYPE_CHECKING:
30
32
  from numpy.typing import NDArray
31
33
 
32
- from oscura.core.types import WaveformTrace
34
+ from oscura.core.types import MeasurementResult, WaveformTrace
33
35
 
34
- # Global FFT cache statistics
36
+ # Global FFT cache statistics (thread-safe)
35
37
  _fft_cache_stats = {"hits": 0, "misses": 0, "size": 128}
38
+ _fft_cache_lock = threading.Lock()
36
39
 
37
40
 
38
41
  def get_fft_cache_stats() -> dict[str, int]:
@@ -45,7 +48,8 @@ def get_fft_cache_stats() -> dict[str, int]:
45
48
  >>> stats = get_fft_cache_stats()
46
49
  >>> print(f"Cache hit rate: {stats['hits'] / (stats['hits'] + stats['misses']):.1%}")
47
50
  """
48
- return _fft_cache_stats.copy()
51
+ with _fft_cache_lock:
52
+ return _fft_cache_stats.copy()
49
53
 
50
54
 
51
55
  def clear_fft_cache() -> None:
@@ -57,8 +61,9 @@ def clear_fft_cache() -> None:
57
61
  >>> clear_fft_cache() # Clear cached FFT results
58
62
  """
59
63
  _compute_fft_cached.cache_clear()
60
- _fft_cache_stats["hits"] = 0
61
- _fft_cache_stats["misses"] = 0
64
+ with _fft_cache_lock:
65
+ _fft_cache_stats["hits"] = 0
66
+ _fft_cache_stats["misses"] = 0
62
67
 
63
68
 
64
69
  def configure_fft_cache(size: int) -> None:
@@ -71,11 +76,12 @@ def configure_fft_cache(size: int) -> None:
71
76
  >>> configure_fft_cache(256) # Increase cache size for better hit rate
72
77
  """
73
78
  global _compute_fft_cached
74
- _fft_cache_stats["size"] = size
75
- # Recreate cache with new size
76
- _compute_fft_cached = lru_cache(maxsize=size)(_compute_fft_impl)
77
- _fft_cache_stats["hits"] = 0
78
- _fft_cache_stats["misses"] = 0
79
+ with _fft_cache_lock:
80
+ _fft_cache_stats["size"] = size
81
+ # Recreate cache with new size
82
+ _compute_fft_cached = lru_cache(maxsize=size)(_compute_fft_impl)
83
+ _fft_cache_stats["hits"] = 0
84
+ _fft_cache_stats["misses"] = 0
79
85
 
80
86
 
81
87
  def _compute_fft_impl(
@@ -269,7 +275,8 @@ def _fft_cached_path(
269
275
  freq, magnitude_db, phase = _compute_fft_cached(
270
276
  data_bytes, n, window, nfft_computed, detrend, sample_rate
271
277
  )
272
- _fft_cache_stats["hits"] += 1
278
+ with _fft_cache_lock:
279
+ _fft_cache_stats["hits"] += 1
273
280
 
274
281
  if return_phase:
275
282
  return freq, magnitude_db, phase
@@ -301,7 +308,8 @@ def _fft_direct_path(
301
308
  Returns:
302
309
  FFT results (with or without phase).
303
310
  """
304
- _fft_cache_stats["misses"] += 1
311
+ with _fft_cache_lock:
312
+ _fft_cache_stats["misses"] += 1
305
313
 
306
314
  w = get_window(window, n)
307
315
  data_windowed = data_processed * w
@@ -645,7 +653,7 @@ def thd(
645
653
  window: str = "hann",
646
654
  nfft: int | None = None,
647
655
  return_db: bool = True,
648
- ) -> float:
656
+ ) -> MeasurementResult:
649
657
  """Compute Total Harmonic Distortion per IEEE 1241-2010.
650
658
 
651
659
  THD is defined as the ratio of RMS harmonic power to fundamental amplitude:
@@ -659,17 +667,15 @@ def thd(
659
667
  window: Window function for FFT.
660
668
  nfft: FFT length. If None, uses data length (no zero-padding) to
661
669
  preserve coherent sampling per IEEE 1241-2010.
662
- return_db: If True, return in dB. If False, return percentage.
670
+ return_db: Ignored (always returns percentage, with dB in metadata if needed).
663
671
 
664
672
  Returns:
665
- THD in dB (if return_db=True) or percentage (if return_db=False).
666
- Always non-negative in percentage form.
673
+ MeasurementResult with THD as percentage, or inapplicable if cannot compute.
667
674
 
668
675
  Example:
669
- >>> thd_db = thd(trace)
670
- >>> thd_pct = thd(trace, return_db=False)
671
- >>> print(f"THD: {thd_db:.1f} dB ({thd_pct:.2f}%)")
672
- >>> assert thd_pct >= 0, "THD percentage must be non-negative"
676
+ >>> result = thd(trace)
677
+ >>> if result["applicable"]:
678
+ ... print(f"THD: {result['display']}")
673
679
 
674
680
  References:
675
681
  IEEE 1241-2010 Section 4.1.4.2
@@ -688,13 +694,13 @@ def thd(
688
694
  _fund_idx, fund_freq, fund_mag = _find_fundamental(freq, magnitude)
689
695
 
690
696
  if fund_mag == 0 or fund_freq == 0:
691
- return np.nan
697
+ return make_inapplicable("%", "No fundamental frequency detected")
692
698
 
693
699
  # Find harmonic frequencies (2*f0, 3*f0, ..., n*f0)
694
700
  harmonic_indices = _find_harmonic_indices(freq, fund_freq, n_harmonics)
695
701
 
696
702
  if len(harmonic_indices) == 0:
697
- return 0.0 if not return_db else -np.inf
703
+ return make_measurement(0.0, "%")
698
704
 
699
705
  # Compute total harmonic power: sum of squared magnitudes
700
706
  harmonic_power = sum(magnitude[i] ** 2 for i in harmonic_indices)
@@ -710,13 +716,11 @@ def thd(
710
716
  f"Fundamental: {fund_mag:.6f}, Harmonic power: {harmonic_power:.6f}"
711
717
  )
712
718
 
713
- if return_db:
714
- if thd_ratio <= 0:
715
- return -np.inf
716
- return float(20 * np.log10(thd_ratio))
717
- else:
718
- # Return as percentage
719
- return float(thd_ratio * 100)
719
+ if thd_ratio <= 0:
720
+ return make_measurement(0.0, "%")
721
+
722
+ # Return as percentage (0-100)
723
+ return make_measurement(float(thd_ratio * 100), "%")
720
724
 
721
725
 
722
726
  def snr(
@@ -725,7 +729,7 @@ def snr(
725
729
  n_harmonics: int = 10,
726
730
  window: str = "hann",
727
731
  nfft: int | None = None,
728
- ) -> float:
732
+ ) -> MeasurementResult:
729
733
  """Compute Signal-to-Noise Ratio.
730
734
 
731
735
  SNR is the ratio of signal power to noise power, excluding harmonics.
@@ -738,11 +742,12 @@ def snr(
738
742
  preserve coherent sampling per IEEE 1241-2010.
739
743
 
740
744
  Returns:
741
- SNR in dB.
745
+ MeasurementResult with SNR in dB, or inapplicable if cannot compute.
742
746
 
743
747
  Example:
744
- >>> snr_db = snr(trace)
745
- >>> print(f"SNR: {snr_db:.1f} dB")
748
+ >>> result = snr(trace)
749
+ >>> if result["applicable"]:
750
+ ... print(f"SNR: {result['display']}")
746
751
 
747
752
  References:
748
753
  IEEE 1241-2010 Section 4.1.4.1
@@ -754,7 +759,7 @@ def snr(
754
759
  fund_idx, fund_freq, fund_mag = _find_fundamental(freq, magnitude)
755
760
 
756
761
  if fund_mag == 0 or fund_freq == 0:
757
- return np.nan
762
+ return make_inapplicable("dB", "No fundamental frequency detected")
758
763
 
759
764
  harmonic_indices = _find_harmonic_indices(freq, fund_freq, n_harmonics)
760
765
  exclude_indices = _build_exclusion_set(fund_idx, harmonic_indices, len(magnitude))
@@ -763,9 +768,9 @@ def snr(
763
768
  noise_power = _compute_noise_power(magnitude, exclude_indices)
764
769
 
765
770
  if noise_power <= 0:
766
- return np.inf
771
+ return make_inapplicable("dB", "No noise detected (perfect signal)")
767
772
 
768
- return float(10 * np.log10(signal_power / noise_power))
773
+ return make_measurement(float(10 * np.log10(signal_power / noise_power)), "dB")
769
774
 
770
775
 
771
776
  def _compute_magnitude_spectrum(
@@ -856,7 +861,7 @@ def sinad(
856
861
  *,
857
862
  window: str = "hann",
858
863
  nfft: int | None = None,
859
- ) -> float:
864
+ ) -> MeasurementResult:
860
865
  """Compute Signal-to-Noise and Distortion ratio.
861
866
 
862
867
  SINAD is the ratio of signal power to noise plus distortion power.
@@ -868,11 +873,12 @@ def sinad(
868
873
  preserve coherent sampling per IEEE 1241-2010.
869
874
 
870
875
  Returns:
871
- SINAD in dB.
876
+ MeasurementResult with SINAD in dB, or inapplicable if cannot compute.
872
877
 
873
878
  Example:
874
- >>> sinad_db = sinad(trace)
875
- >>> print(f"SINAD: {sinad_db:.1f} dB")
879
+ >>> result = sinad(trace)
880
+ >>> if result["applicable"]:
881
+ ... print(f"SINAD: {result['display']}")
876
882
 
877
883
  References:
878
884
  IEEE 1241-2010 Section 4.1.4.3
@@ -889,7 +895,7 @@ def sinad(
889
895
  fund_idx, _fund_freq, fund_mag = _find_fundamental(freq, magnitude)
890
896
 
891
897
  if fund_mag == 0:
892
- return np.nan
898
+ return make_inapplicable("dB", "No fundamental frequency detected")
893
899
 
894
900
  # Signal power: use 3-bin window around fundamental to capture spectral leakage
895
901
  signal_power = 0.0
@@ -905,10 +911,10 @@ def sinad(
905
911
  nad_power = total_power - signal_power
906
912
 
907
913
  if nad_power <= 0:
908
- return np.inf
914
+ return make_inapplicable("dB", "No noise/distortion detected (perfect signal)")
909
915
 
910
916
  sinad_ratio = signal_power / nad_power
911
- return float(10 * np.log10(sinad_ratio))
917
+ return make_measurement(float(10 * np.log10(sinad_ratio)), "dB")
912
918
 
913
919
 
914
920
  def enob(
@@ -916,7 +922,7 @@ def enob(
916
922
  *,
917
923
  window: str = "hann",
918
924
  nfft: int | None = None,
919
- ) -> float:
925
+ ) -> MeasurementResult:
920
926
  """Compute Effective Number of Bits from SINAD.
921
927
 
922
928
  ENOB = (SINAD - 1.76) / 6.02
@@ -927,21 +933,26 @@ def enob(
927
933
  nfft: FFT length.
928
934
 
929
935
  Returns:
930
- ENOB in bits, or np.nan if SINAD is invalid.
936
+ MeasurementResult with ENOB in bits, or inapplicable if SINAD is invalid.
931
937
 
932
938
  Example:
933
- >>> bits = enob(trace)
934
- >>> print(f"ENOB: {bits:.2f} bits")
939
+ >>> result = enob(trace)
940
+ >>> if result["applicable"]:
941
+ ... print(f"ENOB: {result['display']}")
935
942
 
936
943
  References:
937
944
  IEEE 1241-2010 Section 4.1.4.4
938
945
  """
939
- sinad_db = sinad(trace, window=window, nfft=nfft)
946
+ sinad_result = sinad(trace, window=window, nfft=nfft)
947
+
948
+ if not sinad_result["applicable"] or sinad_result["value"] is None:
949
+ return make_inapplicable("", "SINAD unavailable")
940
950
 
941
- if np.isnan(sinad_db) or sinad_db <= 0:
942
- return np.nan
951
+ sinad_db = sinad_result["value"]
952
+ if sinad_db <= 0:
953
+ return make_inapplicable("", "SINAD too low (≤0 dB)")
943
954
 
944
- return float((sinad_db - 1.76) / 6.02)
955
+ return make_measurement(float((sinad_db - 1.76) / 6.02), "")
945
956
 
946
957
 
947
958
  def sfdr(
@@ -949,7 +960,7 @@ def sfdr(
949
960
  *,
950
961
  window: str = "hann",
951
962
  nfft: int | None = None,
952
- ) -> float:
963
+ ) -> MeasurementResult:
953
964
  """Compute Spurious-Free Dynamic Range.
954
965
 
955
966
  SFDR is the ratio of fundamental to largest spurious component.
@@ -961,11 +972,13 @@ def sfdr(
961
972
  preserve coherent sampling per IEEE 1241-2010.
962
973
 
963
974
  Returns:
964
- SFDR in dBc (dB relative to carrier/fundamental).
975
+ MeasurementResult with SFDR in dBc (dB relative to carrier/fundamental),
976
+ or inapplicable if cannot compute.
965
977
 
966
978
  Example:
967
- >>> sfdr_db = sfdr(trace)
968
- >>> print(f"SFDR: {sfdr_db:.1f} dBc")
979
+ >>> result = sfdr(trace)
980
+ >>> if result["applicable"]:
981
+ ... print(f"SFDR: {result['display']}")
969
982
 
970
983
  References:
971
984
  IEEE 1241-2010 Section 4.1.4.5
@@ -982,7 +995,7 @@ def sfdr(
982
995
  fund_idx, _fund_freq, fund_mag = _find_fundamental(freq, magnitude)
983
996
 
984
997
  if fund_mag == 0:
985
- return np.nan
998
+ return make_inapplicable("dB", "No fundamental frequency detected")
986
999
 
987
1000
  # Create mask for spurs (exclude fundamental and DC)
988
1001
  spur_mask = np.ones(len(magnitude), dtype=bool)
@@ -1001,15 +1014,15 @@ def sfdr(
1001
1014
  # Find largest spur
1002
1015
  spur_magnitudes = magnitude[spur_mask]
1003
1016
  if len(spur_magnitudes) == 0:
1004
- return np.inf
1017
+ return make_inapplicable("dB", "No spurs detected (clean spectrum)")
1005
1018
 
1006
1019
  max_spur = np.max(spur_magnitudes)
1007
1020
 
1008
1021
  if max_spur <= 0:
1009
- return np.inf
1022
+ return make_inapplicable("dB", "No valid spurs detected")
1010
1023
 
1011
1024
  sfdr_ratio = fund_mag / max_spur
1012
- return float(20 * np.log10(sfdr_ratio))
1025
+ return make_measurement(float(20 * np.log10(sfdr_ratio)), "dB")
1013
1026
 
1014
1027
 
1015
1028
  def hilbert_transform(
@@ -1029,7 +1042,7 @@ def hilbert_transform(
1029
1042
 
1030
1043
  Example:
1031
1044
  >>> envelope, phase, inst_freq = hilbert_transform(trace)
1032
- >>> plt.plot(trace.time_vector, envelope)
1045
+ >>> plt.plot(trace.time, envelope)
1033
1046
 
1034
1047
  References:
1035
1048
  Oppenheim, A. V. & Schafer, R. W. (2009). Discrete-Time
@@ -1086,7 +1099,7 @@ def cwt(
1086
1099
 
1087
1100
  Example:
1088
1101
  >>> scales, freqs, coef = cwt(trace, wavelet="morlet")
1089
- >>> plt.pcolormesh(trace.time_vector, freqs, np.abs(coef))
1102
+ >>> plt.pcolormesh(trace.time, freqs, np.abs(coef))
1090
1103
  >>> plt.ylabel("Frequency (Hz)")
1091
1104
 
1092
1105
  References:
@@ -2204,16 +2217,17 @@ def measure(
2204
2217
  """Compute multiple spectral measurements with consistent format.
2205
2218
 
2206
2219
  Unified function for computing spectral quality metrics following IEEE 1241-2010.
2207
- Matches the API pattern of oscura.analyzers.waveform.measurements.measure().
2220
+ Returns MeasurementResult format with applicability tracking.
2208
2221
 
2209
2222
  Args:
2210
2223
  trace: Input waveform trace.
2211
2224
  parameters: List of measurement names to compute. If None, compute all.
2212
2225
  Valid names: thd, snr, sinad, enob, sfdr, dominant_freq
2213
- include_units: If True, return {value, unit} dicts. If False, return flat values.
2226
+ include_units: If True, return MeasurementResult format. If False, return flat values.
2214
2227
 
2215
2228
  Returns:
2216
- Dictionary mapping measurement names to values (with units if requested).
2229
+ Dictionary mapping measurement names to MeasurementResults (if include_units=True)
2230
+ or raw values (if include_units=False).
2217
2231
 
2218
2232
  Raises:
2219
2233
  InsufficientDataError: If trace is too short for analysis.
@@ -2222,26 +2236,26 @@ def measure(
2222
2236
  Example:
2223
2237
  >>> from oscura.analyzers.waveform.spectral import measure
2224
2238
  >>> results = measure(trace)
2225
- >>> print(f"THD: {results['thd']['value']}{results['thd']['unit']}")
2226
- >>> print(f"SNR: {results['snr']['value']} {results['snr']['unit']}")
2239
+ >>> if results['thd']['applicable']:
2240
+ ... print(f"THD: {results['thd']['display']}")
2227
2241
 
2228
2242
  >>> # Get specific measurements only
2229
2243
  >>> results = measure(trace, parameters=["thd", "snr"])
2230
2244
 
2231
- >>> # Get flat values without units
2245
+ >>> # Get flat values (legacy compatibility)
2232
2246
  >>> results = measure(trace, include_units=False)
2233
- >>> thd_value = results["thd"] # Just the float
2247
+ >>> thd_value = results["thd"] # float or np.nan
2234
2248
 
2235
2249
  References:
2236
2250
  IEEE 1241-2010: ADC Terminology and Test Methods
2237
2251
  """
2238
- # Define all available spectral measurements with units
2252
+ # Define all available spectral measurements
2239
2253
  all_measurements = {
2240
- "thd": (thd, "%"),
2241
- "snr": (snr, "dB"),
2242
- "sinad": (sinad, "dB"),
2243
- "enob": (enob, "bits"),
2244
- "sfdr": (sfdr, "dB"),
2254
+ "thd": thd,
2255
+ "snr": snr,
2256
+ "sinad": sinad,
2257
+ "enob": enob,
2258
+ "sfdr": sfdr,
2245
2259
  }
2246
2260
 
2247
2261
  # Select requested measurements or all
@@ -2252,16 +2266,26 @@ def measure(
2252
2266
 
2253
2267
  results: dict[str, Any] = {}
2254
2268
 
2255
- for name, (func, unit) in selected.items():
2269
+ for name, func in selected.items():
2256
2270
  try:
2257
- value = func(trace) # type: ignore[operator]
2258
- except Exception:
2259
- value = np.nan
2271
+ measurement_result = func(trace) # type: ignore[operator]
2260
2272
 
2261
- if include_units:
2262
- results[name] = {"value": value, "unit": unit}
2263
- else:
2264
- results[name] = value
2273
+ if include_units:
2274
+ # Return full MeasurementResult
2275
+ results[name] = measurement_result
2276
+ else:
2277
+ # Legacy mode: extract raw value (NaN if inapplicable)
2278
+ if measurement_result["applicable"]:
2279
+ results[name] = measurement_result["value"]
2280
+ else:
2281
+ results[name] = np.nan
2282
+
2283
+ except Exception:
2284
+ # On error, create inapplicable result
2285
+ if include_units:
2286
+ results[name] = make_inapplicable("", "Measurement failed")
2287
+ else:
2288
+ results[name] = np.nan
2265
2289
 
2266
2290
  # Add dominant frequency if requested or if computing all
2267
2291
  if parameters is None or "dominant_freq" in parameters:
@@ -2272,19 +2296,93 @@ def measure(
2272
2296
  dominant_freq_value = float(freq[dominant_idx])
2273
2297
 
2274
2298
  if include_units:
2275
- results["dominant_freq"] = {"value": dominant_freq_value, "unit": "Hz"}
2299
+ results["dominant_freq"] = make_measurement(dominant_freq_value, "Hz")
2276
2300
  else:
2277
2301
  results["dominant_freq"] = dominant_freq_value
2278
2302
  except Exception:
2279
2303
  if include_units:
2280
- results["dominant_freq"] = {"value": np.nan, "unit": "Hz"}
2304
+ results["dominant_freq"] = make_inapplicable("Hz", "FFT failed")
2281
2305
  else:
2282
2306
  results["dominant_freq"] = np.nan
2283
2307
 
2284
2308
  return results
2285
2309
 
2286
2310
 
2311
+ class SpectralAnalyzer:
2312
+ """Spectral analysis class wrapping functional APIs.
2313
+
2314
+ Provides object-oriented interface to spectral analysis functions
2315
+ for compatibility with legacy code and workflows.
2316
+
2317
+ Example:
2318
+ >>> analyzer = SpectralAnalyzer()
2319
+ >>> freqs, mags = analyzer.fft(data, sample_rate)
2320
+ >>> thd_value = analyzer.thd(trace)
2321
+ """
2322
+
2323
+ def fft(
2324
+ self,
2325
+ data: NDArray[np.floating[Any]],
2326
+ sample_rate: float,
2327
+ *,
2328
+ window: str = "hann",
2329
+ nfft: int | None = None,
2330
+ detrend: Literal["none", "mean", "linear"] = "mean",
2331
+ return_phase: bool = False,
2332
+ ) -> (
2333
+ tuple[NDArray[np.float64], NDArray[np.float64]]
2334
+ | tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]
2335
+ ):
2336
+ """Compute FFT of signal data.
2337
+
2338
+ Args:
2339
+ data: Input signal data
2340
+ sample_rate: Sample rate in Hz
2341
+ window: Window function name (default "hann")
2342
+ nfft: FFT size (power of 2), auto-computed if None
2343
+ detrend: Detrend method ('none', 'mean', or 'linear')
2344
+ return_phase: If True, also return phase spectrum
2345
+
2346
+ Returns:
2347
+ Tuple of (frequencies, magnitudes_db) or (frequencies, magnitudes_db, phase)
2348
+ """
2349
+ from oscura.core.types import TraceMetadata, WaveformTrace
2350
+
2351
+ # Convert to WaveformTrace for functional API
2352
+ trace = WaveformTrace(data=data, metadata=TraceMetadata(sample_rate=sample_rate))
2353
+
2354
+ # Call functional API (always returns dB)
2355
+ return fft(trace, window=window, nfft=nfft, detrend=detrend, return_phase=return_phase)
2356
+
2357
+ def thd(self, trace: WaveformTrace, fundamental_freq: float | None = None) -> MeasurementResult:
2358
+ """Compute Total Harmonic Distortion."""
2359
+ return thd(trace)
2360
+
2361
+ def snr(self, trace: WaveformTrace, fundamental_freq: float | None = None) -> MeasurementResult:
2362
+ """Compute Signal-to-Noise Ratio."""
2363
+ return snr(trace)
2364
+
2365
+ def sinad(
2366
+ self, trace: WaveformTrace, fundamental_freq: float | None = None
2367
+ ) -> MeasurementResult:
2368
+ """Compute SINAD (Signal-to-Noise-And-Distortion)."""
2369
+ return sinad(trace)
2370
+
2371
+ def enob(
2372
+ self, trace: WaveformTrace, fundamental_freq: float | None = None
2373
+ ) -> MeasurementResult:
2374
+ """Compute Effective Number of Bits."""
2375
+ return enob(trace)
2376
+
2377
+ def sfdr(
2378
+ self, trace: WaveformTrace, fundamental_freq: float | None = None
2379
+ ) -> MeasurementResult:
2380
+ """Compute Spurious-Free Dynamic Range."""
2381
+ return sfdr(trace)
2382
+
2383
+
2287
2384
  __all__ = [
2385
+ "SpectralAnalyzer",
2288
2386
  "bartlett_psd",
2289
2387
  "clear_fft_cache",
2290
2388
  "configure_fft_cache",
@@ -147,18 +147,27 @@ def cmd_plot(trace: Any, **options: Any) -> None:
147
147
  OscuraError: If plotting fails
148
148
  """
149
149
  try:
150
- from oscura.visualization import plot
150
+ import matplotlib.pyplot as plt
151
+
152
+ from oscura.visualization import plot_waveform
151
153
 
152
154
  title = options.get("title", "Trace Plot")
153
155
  annotate = options.get("annotate")
154
156
 
155
- plot.plot_trace(trace, title=title)
157
+ fig, ax = plt.subplots()
158
+ plot_waveform(ax, trace)
159
+ ax.set_title(title)
156
160
 
157
161
  if annotate:
158
- plot.add_annotation(annotate)
159
-
160
- # Import matplotlib.pyplot for show()
161
- import matplotlib.pyplot as plt
162
+ ax.text(
163
+ 0.5,
164
+ 0.95,
165
+ annotate,
166
+ transform=ax.transAxes,
167
+ ha="center",
168
+ va="top",
169
+ bbox={"boxstyle": "round", "facecolor": "wheat"},
170
+ )
162
171
 
163
172
  plt.show()
164
173