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.
- oscura/__init__.py +19 -19
- oscura/__main__.py +4 -0
- oscura/analyzers/__init__.py +2 -0
- oscura/analyzers/digital/extraction.py +2 -3
- oscura/analyzers/digital/quality.py +1 -1
- oscura/analyzers/digital/timing.py +1 -1
- oscura/analyzers/ml/signal_classifier.py +6 -0
- oscura/analyzers/patterns/__init__.py +66 -0
- oscura/analyzers/power/basic.py +3 -3
- oscura/analyzers/power/soa.py +1 -1
- oscura/analyzers/power/switching.py +3 -3
- oscura/analyzers/signal_classification.py +529 -0
- oscura/analyzers/signal_integrity/sparams.py +3 -3
- oscura/analyzers/statistics/basic.py +10 -7
- oscura/analyzers/validation.py +1 -1
- oscura/analyzers/waveform/measurements.py +200 -156
- oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
- oscura/analyzers/waveform/spectral.py +182 -84
- oscura/api/dsl/commands.py +15 -6
- oscura/api/server/templates/base.html +137 -146
- oscura/api/server/templates/export.html +84 -110
- oscura/api/server/templates/home.html +248 -267
- oscura/api/server/templates/protocols.html +44 -48
- oscura/api/server/templates/reports.html +27 -35
- oscura/api/server/templates/session_detail.html +68 -78
- oscura/api/server/templates/sessions.html +62 -72
- oscura/api/server/templates/waveforms.html +54 -64
- oscura/automotive/__init__.py +1 -1
- oscura/automotive/can/session.py +1 -1
- oscura/automotive/dbc/generator.py +638 -23
- oscura/automotive/dtc/data.json +17 -102
- oscura/automotive/flexray/fibex.py +9 -1
- oscura/automotive/uds/decoder.py +99 -6
- oscura/cli/analyze.py +8 -2
- oscura/cli/batch.py +36 -5
- oscura/cli/characterize.py +18 -4
- oscura/cli/export.py +47 -5
- oscura/cli/main.py +2 -0
- oscura/cli/onboarding/wizard.py +10 -6
- oscura/cli/pipeline.py +585 -0
- oscura/cli/visualize.py +6 -4
- oscura/convenience.py +400 -32
- oscura/core/measurement_result.py +286 -0
- oscura/core/progress.py +1 -1
- oscura/core/schemas/device_mapping.json +2 -8
- oscura/core/schemas/packet_format.json +4 -24
- oscura/core/schemas/protocol_definition.json +2 -12
- oscura/core/types.py +232 -239
- oscura/correlation/multi_protocol.py +1 -1
- oscura/export/legacy/__init__.py +11 -0
- oscura/export/legacy/wav.py +75 -0
- oscura/exporters/__init__.py +19 -0
- oscura/exporters/wireshark.py +809 -0
- oscura/hardware/acquisition/file.py +5 -19
- oscura/hardware/acquisition/saleae.py +10 -10
- oscura/hardware/acquisition/socketcan.py +4 -6
- oscura/hardware/acquisition/synthetic.py +1 -5
- oscura/hardware/acquisition/visa.py +6 -6
- oscura/hardware/security/side_channel_detector.py +5 -508
- oscura/inference/message_format.py +686 -1
- oscura/jupyter/display.py +2 -2
- oscura/jupyter/magic.py +3 -3
- oscura/loaders/__init__.py +17 -12
- oscura/loaders/binary.py +1 -1
- oscura/loaders/chipwhisperer.py +1 -2
- oscura/loaders/configurable.py +1 -1
- oscura/loaders/csv_loader.py +2 -2
- oscura/loaders/hdf5_loader.py +1 -1
- oscura/loaders/lazy.py +6 -1
- oscura/loaders/mmap_loader.py +0 -1
- oscura/loaders/numpy_loader.py +8 -7
- oscura/loaders/preprocessing.py +3 -5
- oscura/loaders/rigol.py +21 -7
- oscura/loaders/sigrok.py +2 -5
- oscura/loaders/tdms.py +3 -2
- oscura/loaders/tektronix.py +38 -32
- oscura/loaders/tss.py +20 -27
- oscura/loaders/validation.py +17 -10
- oscura/loaders/vcd.py +13 -8
- oscura/loaders/wav.py +1 -6
- oscura/pipeline/__init__.py +76 -0
- oscura/pipeline/handlers/__init__.py +165 -0
- oscura/pipeline/handlers/analyzers.py +1045 -0
- oscura/pipeline/handlers/decoders.py +899 -0
- oscura/pipeline/handlers/exporters.py +1103 -0
- oscura/pipeline/handlers/filters.py +891 -0
- oscura/pipeline/handlers/loaders.py +640 -0
- oscura/pipeline/handlers/transforms.py +768 -0
- oscura/reporting/formatting/measurements.py +55 -14
- oscura/reporting/templates/enhanced/protocol_re.html +504 -503
- oscura/sessions/legacy.py +49 -1
- oscura/side_channel/__init__.py +38 -57
- oscura/utils/builders/signal_builder.py +5 -5
- oscura/utils/comparison/compare.py +7 -9
- oscura/utils/comparison/golden.py +1 -1
- oscura/utils/filtering/convenience.py +2 -2
- oscura/utils/math/arithmetic.py +38 -62
- oscura/utils/math/interpolation.py +20 -20
- oscura/utils/pipeline/__init__.py +4 -17
- oscura/utils/progressive.py +1 -4
- oscura/utils/triggering/edge.py +1 -1
- oscura/utils/triggering/pattern.py +2 -2
- oscura/utils/triggering/pulse.py +2 -2
- oscura/utils/triggering/window.py +3 -3
- oscura/validation/hil_testing.py +11 -11
- oscura/visualization/__init__.py +46 -284
- oscura/visualization/batch.py +72 -433
- oscura/visualization/plot.py +542 -53
- oscura/visualization/styles.py +184 -318
- oscura/workflows/batch/advanced.py +1 -1
- oscura/workflows/batch/aggregate.py +12 -9
- oscura/workflows/complete_re.py +251 -23
- oscura/workflows/digital.py +27 -4
- oscura/workflows/multi_trace.py +136 -17
- oscura/workflows/waveform.py +11 -6
- oscura-0.11.0.dist-info/METADATA +460 -0
- {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/RECORD +120 -145
- oscura/side_channel/dpa.py +0 -1025
- oscura/utils/optimization/__init__.py +0 -19
- oscura/utils/optimization/parallel.py +0 -443
- oscura/utils/optimization/search.py +0 -532
- oscura/utils/pipeline/base.py +0 -338
- oscura/utils/pipeline/composition.py +0 -248
- oscura/utils/pipeline/parallel.py +0 -449
- oscura/utils/pipeline/pipeline.py +0 -375
- oscura/utils/search/__init__.py +0 -16
- oscura/utils/search/anomaly.py +0 -424
- oscura/utils/search/context.py +0 -294
- oscura/utils/search/pattern.py +0 -288
- oscura/utils/storage/__init__.py +0 -61
- oscura/utils/storage/database.py +0 -1166
- oscura/visualization/accessibility.py +0 -526
- oscura/visualization/annotations.py +0 -371
- oscura/visualization/axis_scaling.py +0 -305
- oscura/visualization/colors.py +0 -451
- oscura/visualization/digital.py +0 -436
- oscura/visualization/eye.py +0 -571
- oscura/visualization/histogram.py +0 -281
- oscura/visualization/interactive.py +0 -1035
- oscura/visualization/jitter.py +0 -1042
- oscura/visualization/keyboard.py +0 -394
- oscura/visualization/layout.py +0 -400
- oscura/visualization/optimization.py +0 -1079
- oscura/visualization/palettes.py +0 -446
- oscura/visualization/power.py +0 -508
- oscura/visualization/power_extended.py +0 -955
- oscura/visualization/presets.py +0 -469
- oscura/visualization/protocols.py +0 -1246
- oscura/visualization/render.py +0 -223
- oscura/visualization/rendering.py +0 -444
- oscura/visualization/reverse_engineering.py +0 -838
- oscura/visualization/signal_integrity.py +0 -989
- oscura/visualization/specialized.py +0 -643
- oscura/visualization/spectral.py +0 -1226
- oscura/visualization/thumbnails.py +0 -340
- oscura/visualization/time_axis.py +0 -351
- oscura/visualization/waveform.py +0 -454
- oscura-0.8.0.dist-info/METADATA +0 -661
- {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/WHEEL +0 -0
- {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
) ->
|
|
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:
|
|
670
|
+
return_db: Ignored (always returns percentage, with dB in metadata if needed).
|
|
663
671
|
|
|
664
672
|
Returns:
|
|
665
|
-
|
|
666
|
-
Always non-negative in percentage form.
|
|
673
|
+
MeasurementResult with THD as percentage, or inapplicable if cannot compute.
|
|
667
674
|
|
|
668
675
|
Example:
|
|
669
|
-
>>>
|
|
670
|
-
>>>
|
|
671
|
-
|
|
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
|
|
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
|
|
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
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
) ->
|
|
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
|
-
>>>
|
|
745
|
-
>>>
|
|
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
|
|
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
|
|
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
|
-
) ->
|
|
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
|
-
>>>
|
|
875
|
-
>>>
|
|
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
|
|
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
|
|
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
|
-
) ->
|
|
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
|
|
936
|
+
MeasurementResult with ENOB in bits, or inapplicable if SINAD is invalid.
|
|
931
937
|
|
|
932
938
|
Example:
|
|
933
|
-
>>>
|
|
934
|
-
>>>
|
|
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
|
-
|
|
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
|
-
|
|
942
|
-
|
|
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
|
-
) ->
|
|
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
|
-
>>>
|
|
968
|
-
>>>
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
2226
|
+
include_units: If True, return MeasurementResult format. If False, return flat values.
|
|
2214
2227
|
|
|
2215
2228
|
Returns:
|
|
2216
|
-
Dictionary mapping measurement names to
|
|
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
|
-
>>>
|
|
2226
|
-
|
|
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
|
|
2245
|
+
>>> # Get flat values (legacy compatibility)
|
|
2232
2246
|
>>> results = measure(trace, include_units=False)
|
|
2233
|
-
>>> thd_value = results["thd"] #
|
|
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
|
|
2252
|
+
# Define all available spectral measurements
|
|
2239
2253
|
all_measurements = {
|
|
2240
|
-
"thd":
|
|
2241
|
-
"snr":
|
|
2242
|
-
"sinad":
|
|
2243
|
-
"enob":
|
|
2244
|
-
"sfdr":
|
|
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,
|
|
2269
|
+
for name, func in selected.items():
|
|
2256
2270
|
try:
|
|
2257
|
-
|
|
2258
|
-
except Exception:
|
|
2259
|
-
value = np.nan
|
|
2271
|
+
measurement_result = func(trace) # type: ignore[operator]
|
|
2260
2272
|
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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"] =
|
|
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"] =
|
|
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",
|
oscura/api/dsl/commands.py
CHANGED
|
@@ -147,18 +147,27 @@ def cmd_plot(trace: Any, **options: Any) -> None:
|
|
|
147
147
|
OscuraError: If plotting fails
|
|
148
148
|
"""
|
|
149
149
|
try:
|
|
150
|
-
|
|
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
|
-
|
|
157
|
+
fig, ax = plt.subplots()
|
|
158
|
+
plot_waveform(ax, trace)
|
|
159
|
+
ax.set_title(title)
|
|
156
160
|
|
|
157
161
|
if annotate:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|