oscura 0.8.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 (151) 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/patterns/__init__.py +66 -0
  7. oscura/analyzers/power/basic.py +3 -3
  8. oscura/analyzers/power/soa.py +1 -1
  9. oscura/analyzers/power/switching.py +3 -3
  10. oscura/analyzers/signal_classification.py +529 -0
  11. oscura/analyzers/signal_integrity/sparams.py +3 -3
  12. oscura/analyzers/statistics/basic.py +10 -7
  13. oscura/analyzers/validation.py +1 -1
  14. oscura/analyzers/waveform/measurements.py +200 -156
  15. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  16. oscura/analyzers/waveform/spectral.py +164 -73
  17. oscura/api/dsl/commands.py +15 -6
  18. oscura/api/server/templates/base.html +137 -146
  19. oscura/api/server/templates/export.html +84 -110
  20. oscura/api/server/templates/home.html +248 -267
  21. oscura/api/server/templates/protocols.html +44 -48
  22. oscura/api/server/templates/reports.html +27 -35
  23. oscura/api/server/templates/session_detail.html +68 -78
  24. oscura/api/server/templates/sessions.html +62 -72
  25. oscura/api/server/templates/waveforms.html +54 -64
  26. oscura/automotive/__init__.py +1 -1
  27. oscura/automotive/can/session.py +1 -1
  28. oscura/automotive/dbc/generator.py +638 -23
  29. oscura/automotive/uds/decoder.py +99 -6
  30. oscura/cli/analyze.py +8 -2
  31. oscura/cli/batch.py +36 -5
  32. oscura/cli/characterize.py +18 -4
  33. oscura/cli/export.py +47 -5
  34. oscura/cli/main.py +2 -0
  35. oscura/cli/onboarding/wizard.py +10 -6
  36. oscura/cli/pipeline.py +585 -0
  37. oscura/cli/visualize.py +6 -4
  38. oscura/convenience.py +400 -32
  39. oscura/core/measurement_result.py +286 -0
  40. oscura/core/progress.py +1 -1
  41. oscura/core/types.py +232 -239
  42. oscura/correlation/multi_protocol.py +1 -1
  43. oscura/export/legacy/__init__.py +11 -0
  44. oscura/export/legacy/wav.py +75 -0
  45. oscura/exporters/__init__.py +19 -0
  46. oscura/exporters/wireshark.py +809 -0
  47. oscura/hardware/acquisition/file.py +5 -19
  48. oscura/hardware/acquisition/saleae.py +10 -10
  49. oscura/hardware/acquisition/socketcan.py +4 -6
  50. oscura/hardware/acquisition/synthetic.py +1 -5
  51. oscura/hardware/acquisition/visa.py +6 -6
  52. oscura/hardware/security/side_channel_detector.py +5 -508
  53. oscura/inference/message_format.py +686 -1
  54. oscura/jupyter/display.py +2 -2
  55. oscura/jupyter/magic.py +3 -3
  56. oscura/loaders/__init__.py +17 -12
  57. oscura/loaders/binary.py +1 -1
  58. oscura/loaders/chipwhisperer.py +1 -2
  59. oscura/loaders/configurable.py +1 -1
  60. oscura/loaders/csv_loader.py +2 -2
  61. oscura/loaders/hdf5_loader.py +1 -1
  62. oscura/loaders/lazy.py +6 -1
  63. oscura/loaders/mmap_loader.py +0 -1
  64. oscura/loaders/numpy_loader.py +8 -7
  65. oscura/loaders/preprocessing.py +3 -5
  66. oscura/loaders/rigol.py +21 -7
  67. oscura/loaders/sigrok.py +2 -5
  68. oscura/loaders/tdms.py +3 -2
  69. oscura/loaders/tektronix.py +38 -32
  70. oscura/loaders/tss.py +20 -27
  71. oscura/loaders/vcd.py +13 -8
  72. oscura/loaders/wav.py +1 -6
  73. oscura/pipeline/__init__.py +76 -0
  74. oscura/pipeline/handlers/__init__.py +165 -0
  75. oscura/pipeline/handlers/analyzers.py +1045 -0
  76. oscura/pipeline/handlers/decoders.py +899 -0
  77. oscura/pipeline/handlers/exporters.py +1103 -0
  78. oscura/pipeline/handlers/filters.py +891 -0
  79. oscura/pipeline/handlers/loaders.py +640 -0
  80. oscura/pipeline/handlers/transforms.py +768 -0
  81. oscura/reporting/formatting/measurements.py +55 -14
  82. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  83. oscura/side_channel/__init__.py +38 -57
  84. oscura/utils/builders/signal_builder.py +5 -5
  85. oscura/utils/comparison/compare.py +7 -9
  86. oscura/utils/comparison/golden.py +1 -1
  87. oscura/utils/filtering/convenience.py +2 -2
  88. oscura/utils/math/arithmetic.py +38 -62
  89. oscura/utils/math/interpolation.py +20 -20
  90. oscura/utils/pipeline/__init__.py +4 -17
  91. oscura/utils/progressive.py +1 -4
  92. oscura/utils/triggering/edge.py +1 -1
  93. oscura/utils/triggering/pattern.py +2 -2
  94. oscura/utils/triggering/pulse.py +2 -2
  95. oscura/utils/triggering/window.py +3 -3
  96. oscura/validation/hil_testing.py +11 -11
  97. oscura/visualization/__init__.py +46 -284
  98. oscura/visualization/batch.py +72 -433
  99. oscura/visualization/plot.py +542 -53
  100. oscura/visualization/styles.py +184 -318
  101. oscura/workflows/batch/advanced.py +1 -1
  102. oscura/workflows/batch/aggregate.py +7 -8
  103. oscura/workflows/complete_re.py +251 -23
  104. oscura/workflows/digital.py +27 -4
  105. oscura/workflows/multi_trace.py +136 -17
  106. oscura/workflows/waveform.py +11 -6
  107. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  108. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/RECORD +111 -136
  109. oscura/side_channel/dpa.py +0 -1025
  110. oscura/utils/optimization/__init__.py +0 -19
  111. oscura/utils/optimization/parallel.py +0 -443
  112. oscura/utils/optimization/search.py +0 -532
  113. oscura/utils/pipeline/base.py +0 -338
  114. oscura/utils/pipeline/composition.py +0 -248
  115. oscura/utils/pipeline/parallel.py +0 -449
  116. oscura/utils/pipeline/pipeline.py +0 -375
  117. oscura/utils/search/__init__.py +0 -16
  118. oscura/utils/search/anomaly.py +0 -424
  119. oscura/utils/search/context.py +0 -294
  120. oscura/utils/search/pattern.py +0 -288
  121. oscura/utils/storage/__init__.py +0 -61
  122. oscura/utils/storage/database.py +0 -1166
  123. oscura/visualization/accessibility.py +0 -526
  124. oscura/visualization/annotations.py +0 -371
  125. oscura/visualization/axis_scaling.py +0 -305
  126. oscura/visualization/colors.py +0 -451
  127. oscura/visualization/digital.py +0 -436
  128. oscura/visualization/eye.py +0 -571
  129. oscura/visualization/histogram.py +0 -281
  130. oscura/visualization/interactive.py +0 -1035
  131. oscura/visualization/jitter.py +0 -1042
  132. oscura/visualization/keyboard.py +0 -394
  133. oscura/visualization/layout.py +0 -400
  134. oscura/visualization/optimization.py +0 -1079
  135. oscura/visualization/palettes.py +0 -446
  136. oscura/visualization/power.py +0 -508
  137. oscura/visualization/power_extended.py +0 -955
  138. oscura/visualization/presets.py +0 -469
  139. oscura/visualization/protocols.py +0 -1246
  140. oscura/visualization/render.py +0 -223
  141. oscura/visualization/rendering.py +0 -444
  142. oscura/visualization/reverse_engineering.py +0 -838
  143. oscura/visualization/signal_integrity.py +0 -989
  144. oscura/visualization/specialized.py +0 -643
  145. oscura/visualization/spectral.py +0 -1226
  146. oscura/visualization/thumbnails.py +0 -340
  147. oscura/visualization/time_axis.py +0 -351
  148. oscura/visualization/waveform.py +0 -454
  149. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  150. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  151. {oscura-0.8.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,7 +646,7 @@ def thd(
645
646
  window: str = "hann",
646
647
  nfft: int | None = None,
647
648
  return_db: bool = True,
648
- ) -> float:
649
+ ) -> MeasurementResult:
649
650
  """Compute Total Harmonic Distortion per IEEE 1241-2010.
650
651
 
651
652
  THD is defined as the ratio of RMS harmonic power to fundamental amplitude:
@@ -659,17 +660,15 @@ def thd(
659
660
  window: Window function for FFT.
660
661
  nfft: FFT length. If None, uses data length (no zero-padding) to
661
662
  preserve coherent sampling per IEEE 1241-2010.
662
- return_db: If True, return in dB. If False, return percentage.
663
+ return_db: Ignored (always returns percentage, with dB in metadata if needed).
663
664
 
664
665
  Returns:
665
- THD in dB (if return_db=True) or percentage (if return_db=False).
666
- Always non-negative in percentage form.
666
+ MeasurementResult with THD as percentage, or inapplicable if cannot compute.
667
667
 
668
668
  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"
669
+ >>> result = thd(trace)
670
+ >>> if result["applicable"]:
671
+ ... print(f"THD: {result['display']}")
673
672
 
674
673
  References:
675
674
  IEEE 1241-2010 Section 4.1.4.2
@@ -688,13 +687,13 @@ def thd(
688
687
  _fund_idx, fund_freq, fund_mag = _find_fundamental(freq, magnitude)
689
688
 
690
689
  if fund_mag == 0 or fund_freq == 0:
691
- return np.nan
690
+ return make_inapplicable("%", "No fundamental frequency detected")
692
691
 
693
692
  # Find harmonic frequencies (2*f0, 3*f0, ..., n*f0)
694
693
  harmonic_indices = _find_harmonic_indices(freq, fund_freq, n_harmonics)
695
694
 
696
695
  if len(harmonic_indices) == 0:
697
- return 0.0 if not return_db else -np.inf
696
+ return make_measurement(0.0, "%")
698
697
 
699
698
  # Compute total harmonic power: sum of squared magnitudes
700
699
  harmonic_power = sum(magnitude[i] ** 2 for i in harmonic_indices)
@@ -710,13 +709,11 @@ def thd(
710
709
  f"Fundamental: {fund_mag:.6f}, Harmonic power: {harmonic_power:.6f}"
711
710
  )
712
711
 
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)
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), "%")
720
717
 
721
718
 
722
719
  def snr(
@@ -725,7 +722,7 @@ def snr(
725
722
  n_harmonics: int = 10,
726
723
  window: str = "hann",
727
724
  nfft: int | None = None,
728
- ) -> float:
725
+ ) -> MeasurementResult:
729
726
  """Compute Signal-to-Noise Ratio.
730
727
 
731
728
  SNR is the ratio of signal power to noise power, excluding harmonics.
@@ -738,11 +735,12 @@ def snr(
738
735
  preserve coherent sampling per IEEE 1241-2010.
739
736
 
740
737
  Returns:
741
- SNR in dB.
738
+ MeasurementResult with SNR in dB, or inapplicable if cannot compute.
742
739
 
743
740
  Example:
744
- >>> snr_db = snr(trace)
745
- >>> print(f"SNR: {snr_db:.1f} dB")
741
+ >>> result = snr(trace)
742
+ >>> if result["applicable"]:
743
+ ... print(f"SNR: {result['display']}")
746
744
 
747
745
  References:
748
746
  IEEE 1241-2010 Section 4.1.4.1
@@ -754,7 +752,7 @@ def snr(
754
752
  fund_idx, fund_freq, fund_mag = _find_fundamental(freq, magnitude)
755
753
 
756
754
  if fund_mag == 0 or fund_freq == 0:
757
- return np.nan
755
+ return make_inapplicable("dB", "No fundamental frequency detected")
758
756
 
759
757
  harmonic_indices = _find_harmonic_indices(freq, fund_freq, n_harmonics)
760
758
  exclude_indices = _build_exclusion_set(fund_idx, harmonic_indices, len(magnitude))
@@ -763,9 +761,9 @@ def snr(
763
761
  noise_power = _compute_noise_power(magnitude, exclude_indices)
764
762
 
765
763
  if noise_power <= 0:
766
- return np.inf
764
+ return make_inapplicable("dB", "No noise detected (perfect signal)")
767
765
 
768
- return float(10 * np.log10(signal_power / noise_power))
766
+ return make_measurement(float(10 * np.log10(signal_power / noise_power)), "dB")
769
767
 
770
768
 
771
769
  def _compute_magnitude_spectrum(
@@ -856,7 +854,7 @@ def sinad(
856
854
  *,
857
855
  window: str = "hann",
858
856
  nfft: int | None = None,
859
- ) -> float:
857
+ ) -> MeasurementResult:
860
858
  """Compute Signal-to-Noise and Distortion ratio.
861
859
 
862
860
  SINAD is the ratio of signal power to noise plus distortion power.
@@ -868,11 +866,12 @@ def sinad(
868
866
  preserve coherent sampling per IEEE 1241-2010.
869
867
 
870
868
  Returns:
871
- SINAD in dB.
869
+ MeasurementResult with SINAD in dB, or inapplicable if cannot compute.
872
870
 
873
871
  Example:
874
- >>> sinad_db = sinad(trace)
875
- >>> print(f"SINAD: {sinad_db:.1f} dB")
872
+ >>> result = sinad(trace)
873
+ >>> if result["applicable"]:
874
+ ... print(f"SINAD: {result['display']}")
876
875
 
877
876
  References:
878
877
  IEEE 1241-2010 Section 4.1.4.3
@@ -889,7 +888,7 @@ def sinad(
889
888
  fund_idx, _fund_freq, fund_mag = _find_fundamental(freq, magnitude)
890
889
 
891
890
  if fund_mag == 0:
892
- return np.nan
891
+ return make_inapplicable("dB", "No fundamental frequency detected")
893
892
 
894
893
  # Signal power: use 3-bin window around fundamental to capture spectral leakage
895
894
  signal_power = 0.0
@@ -905,10 +904,10 @@ def sinad(
905
904
  nad_power = total_power - signal_power
906
905
 
907
906
  if nad_power <= 0:
908
- return np.inf
907
+ return make_inapplicable("dB", "No noise/distortion detected (perfect signal)")
909
908
 
910
909
  sinad_ratio = signal_power / nad_power
911
- return float(10 * np.log10(sinad_ratio))
910
+ return make_measurement(float(10 * np.log10(sinad_ratio)), "dB")
912
911
 
913
912
 
914
913
  def enob(
@@ -916,7 +915,7 @@ def enob(
916
915
  *,
917
916
  window: str = "hann",
918
917
  nfft: int | None = None,
919
- ) -> float:
918
+ ) -> MeasurementResult:
920
919
  """Compute Effective Number of Bits from SINAD.
921
920
 
922
921
  ENOB = (SINAD - 1.76) / 6.02
@@ -927,21 +926,26 @@ def enob(
927
926
  nfft: FFT length.
928
927
 
929
928
  Returns:
930
- ENOB in bits, or np.nan if SINAD is invalid.
929
+ MeasurementResult with ENOB in bits, or inapplicable if SINAD is invalid.
931
930
 
932
931
  Example:
933
- >>> bits = enob(trace)
934
- >>> print(f"ENOB: {bits:.2f} bits")
932
+ >>> result = enob(trace)
933
+ >>> if result["applicable"]:
934
+ ... print(f"ENOB: {result['display']}")
935
935
 
936
936
  References:
937
937
  IEEE 1241-2010 Section 4.1.4.4
938
938
  """
939
- 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")
940
943
 
941
- if np.isnan(sinad_db) or sinad_db <= 0:
942
- return np.nan
944
+ sinad_db = sinad_result["value"]
945
+ if sinad_db <= 0:
946
+ return make_inapplicable("", "SINAD too low (≤0 dB)")
943
947
 
944
- return float((sinad_db - 1.76) / 6.02)
948
+ return make_measurement(float((sinad_db - 1.76) / 6.02), "")
945
949
 
946
950
 
947
951
  def sfdr(
@@ -949,7 +953,7 @@ def sfdr(
949
953
  *,
950
954
  window: str = "hann",
951
955
  nfft: int | None = None,
952
- ) -> float:
956
+ ) -> MeasurementResult:
953
957
  """Compute Spurious-Free Dynamic Range.
954
958
 
955
959
  SFDR is the ratio of fundamental to largest spurious component.
@@ -961,11 +965,13 @@ def sfdr(
961
965
  preserve coherent sampling per IEEE 1241-2010.
962
966
 
963
967
  Returns:
964
- 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.
965
970
 
966
971
  Example:
967
- >>> sfdr_db = sfdr(trace)
968
- >>> print(f"SFDR: {sfdr_db:.1f} dBc")
972
+ >>> result = sfdr(trace)
973
+ >>> if result["applicable"]:
974
+ ... print(f"SFDR: {result['display']}")
969
975
 
970
976
  References:
971
977
  IEEE 1241-2010 Section 4.1.4.5
@@ -982,7 +988,7 @@ def sfdr(
982
988
  fund_idx, _fund_freq, fund_mag = _find_fundamental(freq, magnitude)
983
989
 
984
990
  if fund_mag == 0:
985
- return np.nan
991
+ return make_inapplicable("dB", "No fundamental frequency detected")
986
992
 
987
993
  # Create mask for spurs (exclude fundamental and DC)
988
994
  spur_mask = np.ones(len(magnitude), dtype=bool)
@@ -1001,15 +1007,15 @@ def sfdr(
1001
1007
  # Find largest spur
1002
1008
  spur_magnitudes = magnitude[spur_mask]
1003
1009
  if len(spur_magnitudes) == 0:
1004
- return np.inf
1010
+ return make_inapplicable("dB", "No spurs detected (clean spectrum)")
1005
1011
 
1006
1012
  max_spur = np.max(spur_magnitudes)
1007
1013
 
1008
1014
  if max_spur <= 0:
1009
- return np.inf
1015
+ return make_inapplicable("dB", "No valid spurs detected")
1010
1016
 
1011
1017
  sfdr_ratio = fund_mag / max_spur
1012
- return float(20 * np.log10(sfdr_ratio))
1018
+ return make_measurement(float(20 * np.log10(sfdr_ratio)), "dB")
1013
1019
 
1014
1020
 
1015
1021
  def hilbert_transform(
@@ -1029,7 +1035,7 @@ def hilbert_transform(
1029
1035
 
1030
1036
  Example:
1031
1037
  >>> envelope, phase, inst_freq = hilbert_transform(trace)
1032
- >>> plt.plot(trace.time_vector, envelope)
1038
+ >>> plt.plot(trace.time, envelope)
1033
1039
 
1034
1040
  References:
1035
1041
  Oppenheim, A. V. & Schafer, R. W. (2009). Discrete-Time
@@ -1086,7 +1092,7 @@ def cwt(
1086
1092
 
1087
1093
  Example:
1088
1094
  >>> scales, freqs, coef = cwt(trace, wavelet="morlet")
1089
- >>> plt.pcolormesh(trace.time_vector, freqs, np.abs(coef))
1095
+ >>> plt.pcolormesh(trace.time, freqs, np.abs(coef))
1090
1096
  >>> plt.ylabel("Frequency (Hz)")
1091
1097
 
1092
1098
  References:
@@ -2204,16 +2210,17 @@ def measure(
2204
2210
  """Compute multiple spectral measurements with consistent format.
2205
2211
 
2206
2212
  Unified function for computing spectral quality metrics following IEEE 1241-2010.
2207
- Matches the API pattern of oscura.analyzers.waveform.measurements.measure().
2213
+ Returns MeasurementResult format with applicability tracking.
2208
2214
 
2209
2215
  Args:
2210
2216
  trace: Input waveform trace.
2211
2217
  parameters: List of measurement names to compute. If None, compute all.
2212
2218
  Valid names: thd, snr, sinad, enob, sfdr, dominant_freq
2213
- include_units: If True, return {value, unit} dicts. If False, return flat values.
2219
+ include_units: If True, return MeasurementResult format. If False, return flat values.
2214
2220
 
2215
2221
  Returns:
2216
- Dictionary mapping measurement names to values (with units if requested).
2222
+ Dictionary mapping measurement names to MeasurementResults (if include_units=True)
2223
+ or raw values (if include_units=False).
2217
2224
 
2218
2225
  Raises:
2219
2226
  InsufficientDataError: If trace is too short for analysis.
@@ -2222,26 +2229,26 @@ def measure(
2222
2229
  Example:
2223
2230
  >>> from oscura.analyzers.waveform.spectral import measure
2224
2231
  >>> results = measure(trace)
2225
- >>> print(f"THD: {results['thd']['value']}{results['thd']['unit']}")
2226
- >>> print(f"SNR: {results['snr']['value']} {results['snr']['unit']}")
2232
+ >>> if results['thd']['applicable']:
2233
+ ... print(f"THD: {results['thd']['display']}")
2227
2234
 
2228
2235
  >>> # Get specific measurements only
2229
2236
  >>> results = measure(trace, parameters=["thd", "snr"])
2230
2237
 
2231
- >>> # Get flat values without units
2238
+ >>> # Get flat values (legacy compatibility)
2232
2239
  >>> results = measure(trace, include_units=False)
2233
- >>> thd_value = results["thd"] # Just the float
2240
+ >>> thd_value = results["thd"] # float or np.nan
2234
2241
 
2235
2242
  References:
2236
2243
  IEEE 1241-2010: ADC Terminology and Test Methods
2237
2244
  """
2238
- # Define all available spectral measurements with units
2245
+ # Define all available spectral measurements
2239
2246
  all_measurements = {
2240
- "thd": (thd, "%"),
2241
- "snr": (snr, "dB"),
2242
- "sinad": (sinad, "dB"),
2243
- "enob": (enob, "bits"),
2244
- "sfdr": (sfdr, "dB"),
2247
+ "thd": thd,
2248
+ "snr": snr,
2249
+ "sinad": sinad,
2250
+ "enob": enob,
2251
+ "sfdr": sfdr,
2245
2252
  }
2246
2253
 
2247
2254
  # Select requested measurements or all
@@ -2252,16 +2259,26 @@ def measure(
2252
2259
 
2253
2260
  results: dict[str, Any] = {}
2254
2261
 
2255
- for name, (func, unit) in selected.items():
2262
+ for name, func in selected.items():
2256
2263
  try:
2257
- value = func(trace) # type: ignore[operator]
2258
- except Exception:
2259
- value = np.nan
2264
+ measurement_result = func(trace) # type: ignore[operator]
2260
2265
 
2261
- if include_units:
2262
- results[name] = {"value": value, "unit": unit}
2263
- else:
2264
- results[name] = value
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
2265
2282
 
2266
2283
  # Add dominant frequency if requested or if computing all
2267
2284
  if parameters is None or "dominant_freq" in parameters:
@@ -2272,19 +2289,93 @@ def measure(
2272
2289
  dominant_freq_value = float(freq[dominant_idx])
2273
2290
 
2274
2291
  if include_units:
2275
- results["dominant_freq"] = {"value": dominant_freq_value, "unit": "Hz"}
2292
+ results["dominant_freq"] = make_measurement(dominant_freq_value, "Hz")
2276
2293
  else:
2277
2294
  results["dominant_freq"] = dominant_freq_value
2278
2295
  except Exception:
2279
2296
  if include_units:
2280
- results["dominant_freq"] = {"value": np.nan, "unit": "Hz"}
2297
+ results["dominant_freq"] = make_inapplicable("Hz", "FFT failed")
2281
2298
  else:
2282
2299
  results["dominant_freq"] = np.nan
2283
2300
 
2284
2301
  return results
2285
2302
 
2286
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
+
2287
2377
  __all__ = [
2378
+ "SpectralAnalyzer",
2288
2379
  "bartlett_psd",
2289
2380
  "clear_fft_cache",
2290
2381
  "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