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.
- oscura/__init__.py +19 -19
- 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/eye/__init__.py +5 -1
- oscura/analyzers/eye/generation.py +501 -0
- oscura/analyzers/jitter/__init__.py +6 -6
- oscura/analyzers/jitter/timing.py +419 -0
- oscura/analyzers/patterns/__init__.py +94 -0
- oscura/analyzers/patterns/reverse_engineering.py +991 -0
- oscura/analyzers/power/__init__.py +35 -12
- 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/__init__.py +4 -0
- oscura/analyzers/statistics/basic.py +152 -0
- oscura/analyzers/statistics/correlation.py +47 -6
- oscura/analyzers/validation.py +1 -1
- oscura/analyzers/waveform/__init__.py +2 -0
- oscura/analyzers/waveform/measurements.py +329 -163
- oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
- oscura/analyzers/waveform/spectral.py +498 -54
- 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 +102 -17
- 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/config/loader.py +0 -1
- oscura/core/measurement_result.py +286 -0
- oscura/core/progress.py +1 -1
- oscura/core/schemas/device_mapping.json +8 -2
- oscura/core/schemas/packet_format.json +24 -4
- oscura/core/schemas/protocol_definition.json +12 -2
- oscura/core/types.py +300 -199
- 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/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/__init__.py +88 -1
- oscura/reporting/automation.py +348 -0
- oscura/reporting/citations.py +374 -0
- oscura/reporting/core.py +54 -0
- oscura/reporting/formatting/__init__.py +11 -0
- oscura/reporting/formatting/measurements.py +320 -0
- oscura/reporting/html.py +57 -0
- oscura/reporting/interpretation.py +431 -0
- oscura/reporting/summary.py +329 -0
- oscura/reporting/templates/enhanced/protocol_re.html +504 -503
- oscura/reporting/visualization.py +542 -0
- 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 +47 -284
- oscura/visualization/batch.py +160 -0
- oscura/visualization/plot.py +542 -53
- oscura/visualization/styles.py +184 -318
- oscura/workflows/__init__.py +2 -0
- oscura/workflows/batch/advanced.py +1 -1
- oscura/workflows/batch/aggregate.py +7 -8
- oscura/workflows/complete_re.py +251 -23
- oscura/workflows/digital.py +27 -4
- oscura/workflows/multi_trace.py +136 -17
- oscura/workflows/waveform.py +788 -0
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/RECORD +135 -149
- 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.7.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Signal classification and waveform type detection.
|
|
2
|
+
|
|
3
|
+
This module provides automatic signal classification to detect periodicity,
|
|
4
|
+
waveform shape, and signal characteristics. Classification results are used
|
|
5
|
+
to determine which measurements are applicable to a given signal.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from oscura.analyzers.signal_classification import classify_signal
|
|
9
|
+
>>> from oscura.core.types import WaveformTrace, TraceMetadata
|
|
10
|
+
>>> import numpy as np
|
|
11
|
+
>>> # Create test signal
|
|
12
|
+
>>> t = np.linspace(0, 0.01, 1000)
|
|
13
|
+
>>> data = np.sin(2 * np.pi * 1000 * t)
|
|
14
|
+
>>> meta = TraceMetadata(sample_rate=1e5, units="V")
|
|
15
|
+
>>> trace = WaveformTrace(data=data, metadata=meta)
|
|
16
|
+
>>> result = classify_signal(trace)
|
|
17
|
+
>>> print(f"Waveform: {result['waveform']['waveform_type']}")
|
|
18
|
+
Waveform: sine
|
|
19
|
+
|
|
20
|
+
References:
|
|
21
|
+
IEEE 181-2011: Standard for Transitional Waveform Definitions
|
|
22
|
+
Signal Processing Fundamentals (autocorrelation methods)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from numpy.typing import NDArray
|
|
33
|
+
|
|
34
|
+
from oscura.core.types import WaveformTrace
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_periodicity(signal: NDArray[np.floating[Any]], sample_rate: float) -> dict[str, Any]:
|
|
38
|
+
"""Detect periodic vs aperiodic signals using autocorrelation.
|
|
39
|
+
|
|
40
|
+
Uses normalized autocorrelation to identify periodic patterns in the signal.
|
|
41
|
+
A strong peak at non-zero lag indicates periodicity, while absence of peaks
|
|
42
|
+
suggests aperiodic signals (impulse, DC, random noise).
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
signal: Input signal array.
|
|
46
|
+
sample_rate: Sample rate in Hz.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Dictionary containing:
|
|
50
|
+
- periodicity: "periodic", "aperiodic", or "quasi-periodic"
|
|
51
|
+
- confidence: Float between 0.0 and 1.0
|
|
52
|
+
- period: Period in seconds (None if aperiodic)
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
>>> # Periodic signal
|
|
56
|
+
>>> signal = np.sin(2 * np.pi * 1000 * np.linspace(0, 0.01, 1000))
|
|
57
|
+
>>> result = detect_periodicity(signal, 100000)
|
|
58
|
+
>>> print(f"Type: {result['periodicity']}, Period: {result['period']}")
|
|
59
|
+
Type: periodic, Period: 0.001
|
|
60
|
+
|
|
61
|
+
>>> # Aperiodic signal (impulse)
|
|
62
|
+
>>> impulse = np.zeros(1000)
|
|
63
|
+
>>> impulse[500] = 1.0
|
|
64
|
+
>>> result = detect_periodicity(impulse, 100000)
|
|
65
|
+
>>> print(f"Type: {result['periodicity']}")
|
|
66
|
+
Type: aperiodic
|
|
67
|
+
"""
|
|
68
|
+
if len(signal) < 16:
|
|
69
|
+
return {
|
|
70
|
+
"periodicity": "aperiodic",
|
|
71
|
+
"confidence": 0.0,
|
|
72
|
+
"period": None,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Remove DC offset for better autocorrelation
|
|
76
|
+
signal_centered = signal - np.mean(signal)
|
|
77
|
+
|
|
78
|
+
# Check if signal is essentially constant (DC only)
|
|
79
|
+
if np.std(signal_centered) < 1e-10:
|
|
80
|
+
return {
|
|
81
|
+
"periodicity": "aperiodic",
|
|
82
|
+
"confidence": 1.0,
|
|
83
|
+
"period": None,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Compute normalized autocorrelation
|
|
87
|
+
# Use only first half of signal to avoid edge effects
|
|
88
|
+
max_lag = min(len(signal_centered) // 2, 1000)
|
|
89
|
+
autocorr = np.correlate(signal_centered, signal_centered, mode="full")
|
|
90
|
+
autocorr = autocorr[len(autocorr) // 2 :] # Take only positive lags
|
|
91
|
+
autocorr = autocorr[:max_lag]
|
|
92
|
+
|
|
93
|
+
# Normalize by zero-lag value
|
|
94
|
+
if autocorr[0] > 1e-10:
|
|
95
|
+
autocorr = autocorr / autocorr[0]
|
|
96
|
+
else:
|
|
97
|
+
return {
|
|
98
|
+
"periodicity": "aperiodic",
|
|
99
|
+
"confidence": 1.0,
|
|
100
|
+
"period": None,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Find peaks in autocorrelation (skip first 2 samples to avoid DC)
|
|
104
|
+
min_period_samples = 3
|
|
105
|
+
search_region = autocorr[min_period_samples:]
|
|
106
|
+
|
|
107
|
+
if len(search_region) < 3:
|
|
108
|
+
return {
|
|
109
|
+
"periodicity": "aperiodic",
|
|
110
|
+
"confidence": 0.5,
|
|
111
|
+
"period": None,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Find first significant peak
|
|
115
|
+
peak_idx = -1
|
|
116
|
+
peak_value = 0.0
|
|
117
|
+
|
|
118
|
+
# Look for local maxima
|
|
119
|
+
for i in range(1, len(search_region) - 1):
|
|
120
|
+
if (
|
|
121
|
+
search_region[i] > search_region[i - 1]
|
|
122
|
+
and search_region[i] > search_region[i + 1]
|
|
123
|
+
and search_region[i] > peak_value
|
|
124
|
+
):
|
|
125
|
+
peak_value = search_region[i]
|
|
126
|
+
peak_idx = i + min_period_samples
|
|
127
|
+
|
|
128
|
+
# Classify based on peak strength
|
|
129
|
+
# Strong peak (>0.5) = periodic
|
|
130
|
+
# Moderate peak (0.3-0.5) = quasi-periodic
|
|
131
|
+
# Weak/no peak (<0.3) = aperiodic
|
|
132
|
+
if peak_value > 0.5:
|
|
133
|
+
period_samples = peak_idx
|
|
134
|
+
period_seconds = period_samples / sample_rate
|
|
135
|
+
return {
|
|
136
|
+
"periodicity": "periodic",
|
|
137
|
+
"confidence": float(peak_value),
|
|
138
|
+
"period": float(period_seconds),
|
|
139
|
+
}
|
|
140
|
+
elif peak_value > 0.3:
|
|
141
|
+
period_samples = peak_idx
|
|
142
|
+
period_seconds = period_samples / sample_rate
|
|
143
|
+
return {
|
|
144
|
+
"periodicity": "quasi-periodic",
|
|
145
|
+
"confidence": float(peak_value),
|
|
146
|
+
"period": float(period_seconds),
|
|
147
|
+
}
|
|
148
|
+
else:
|
|
149
|
+
return {
|
|
150
|
+
"periodicity": "aperiodic",
|
|
151
|
+
"confidence": float(1.0 - peak_value),
|
|
152
|
+
"period": None,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def classify_waveform(signal: NDArray[np.floating[Any]], sample_rate: float) -> dict[str, Any]:
|
|
157
|
+
"""Classify waveform shape using spectral and time-domain analysis.
|
|
158
|
+
|
|
159
|
+
Analyzes harmonic content, duty cycle, edge sharpness, and DC component
|
|
160
|
+
to determine the waveform type.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
signal: Input signal array.
|
|
164
|
+
sample_rate: Sample rate in Hz.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Dictionary containing:
|
|
168
|
+
- waveform_type: One of "sine", "square", "triangle", "sawtooth",
|
|
169
|
+
"pwm", "impulse", "dc", "noise", "unknown"
|
|
170
|
+
- confidence: Float between 0.0 and 1.0
|
|
171
|
+
- characteristics: Dict with additional features
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
>>> # Square wave
|
|
175
|
+
>>> signal = np.sign(np.sin(2 * np.pi * 1000 * np.linspace(0, 0.01, 1000)))
|
|
176
|
+
>>> result = classify_waveform(signal, 100000)
|
|
177
|
+
>>> print(f"Type: {result['waveform_type']}")
|
|
178
|
+
Type: square
|
|
179
|
+
"""
|
|
180
|
+
if len(signal) < 16:
|
|
181
|
+
return {
|
|
182
|
+
"waveform_type": "unknown",
|
|
183
|
+
"confidence": 0.0,
|
|
184
|
+
"characteristics": {},
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Remove DC offset
|
|
188
|
+
signal_centered = signal - np.mean(signal)
|
|
189
|
+
dc_component = float(np.mean(signal))
|
|
190
|
+
|
|
191
|
+
# Check for DC signal (no variation)
|
|
192
|
+
signal_std = np.std(signal_centered)
|
|
193
|
+
if signal_std < 1e-10:
|
|
194
|
+
return {
|
|
195
|
+
"waveform_type": "dc",
|
|
196
|
+
"confidence": 1.0,
|
|
197
|
+
"characteristics": {
|
|
198
|
+
"dc_level": float(dc_component),
|
|
199
|
+
"noise_level": 0.0,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Compute FFT for harmonic analysis
|
|
204
|
+
n = len(signal_centered)
|
|
205
|
+
fft_mag = np.abs(np.fft.rfft(signal_centered))
|
|
206
|
+
fft_freqs = np.fft.rfftfreq(n, 1 / sample_rate)
|
|
207
|
+
|
|
208
|
+
# Skip DC component
|
|
209
|
+
fft_mag = fft_mag[1:]
|
|
210
|
+
fft_freqs = fft_freqs[1:]
|
|
211
|
+
|
|
212
|
+
if len(fft_mag) < 3:
|
|
213
|
+
return {
|
|
214
|
+
"waveform_type": "unknown",
|
|
215
|
+
"confidence": 0.0,
|
|
216
|
+
"characteristics": {},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# Find fundamental frequency (strongest component)
|
|
220
|
+
fundamental_idx = np.argmax(fft_mag)
|
|
221
|
+
fundamental_freq = fft_freqs[fundamental_idx]
|
|
222
|
+
fundamental_mag = fft_mag[fundamental_idx]
|
|
223
|
+
|
|
224
|
+
# Check if signal is essentially noise (no dominant frequency)
|
|
225
|
+
mean_mag = np.mean(fft_mag)
|
|
226
|
+
if fundamental_mag < 3.0 * mean_mag:
|
|
227
|
+
return {
|
|
228
|
+
"waveform_type": "noise",
|
|
229
|
+
"confidence": 0.8,
|
|
230
|
+
"characteristics": {
|
|
231
|
+
"snr_estimate": float(fundamental_mag / mean_mag) if mean_mag > 0 else 0.0,
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# Analyze harmonics (multiples of fundamental)
|
|
236
|
+
harmonics = []
|
|
237
|
+
for harmonic_num in range(2, 6): # Check 2nd through 5th harmonic
|
|
238
|
+
harmonic_freq = fundamental_freq * harmonic_num
|
|
239
|
+
# Find closest frequency bin
|
|
240
|
+
freq_idx = np.argmin(np.abs(fft_freqs - harmonic_freq))
|
|
241
|
+
if freq_idx < len(fft_mag):
|
|
242
|
+
harmonic_mag = fft_mag[freq_idx]
|
|
243
|
+
# Normalize by fundamental
|
|
244
|
+
harmonic_ratio = harmonic_mag / fundamental_mag if fundamental_mag > 0 else 0.0
|
|
245
|
+
harmonics.append(float(harmonic_ratio))
|
|
246
|
+
|
|
247
|
+
# Calculate total harmonic distortion (THD)
|
|
248
|
+
harmonic_power = sum(h**2 for h in harmonics)
|
|
249
|
+
thd = np.sqrt(harmonic_power) if harmonics else 0.0
|
|
250
|
+
|
|
251
|
+
# Classify based on harmonic content
|
|
252
|
+
characteristics: dict[str, Any] = {
|
|
253
|
+
"fundamental_freq": float(fundamental_freq),
|
|
254
|
+
"thd": float(thd),
|
|
255
|
+
"harmonics": harmonics,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# Early check for impulse: Very brief pulse (check time domain first)
|
|
259
|
+
# For impulse detection, we need to check if signal is mostly near zero
|
|
260
|
+
# with only brief excursions. Use absolute signal, not centered.
|
|
261
|
+
abs_max = np.max(np.abs(signal))
|
|
262
|
+
# Count samples that are significant (>10% of peak)
|
|
263
|
+
threshold = 0.1 * abs_max if abs_max > 0 else signal_std
|
|
264
|
+
significant_samples = np.sum(np.abs(signal) > threshold)
|
|
265
|
+
pulse_width_ratio = significant_samples / len(signal) if len(signal) > 0 else 0
|
|
266
|
+
|
|
267
|
+
# True impulse: very few significant samples AND mostly zero baseline
|
|
268
|
+
signal_range = np.max(signal) - np.min(signal)
|
|
269
|
+
near_zero_count = np.sum(np.abs(signal - np.min(signal)) < 0.1 * signal_range)
|
|
270
|
+
near_zero_ratio = near_zero_count / len(signal) if len(signal) > 0 else 0
|
|
271
|
+
|
|
272
|
+
if pulse_width_ratio < 0.15 and near_zero_ratio > 0.7:
|
|
273
|
+
return {
|
|
274
|
+
"waveform_type": "impulse",
|
|
275
|
+
"confidence": 0.85,
|
|
276
|
+
"characteristics": {
|
|
277
|
+
**characteristics,
|
|
278
|
+
"pulse_width_ratio": float(pulse_width_ratio),
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# Sine wave: Low THD (<0.1), single dominant frequency
|
|
283
|
+
if thd < 0.1:
|
|
284
|
+
return {
|
|
285
|
+
"waveform_type": "sine",
|
|
286
|
+
"confidence": 0.9,
|
|
287
|
+
"characteristics": characteristics,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
# Early check for PWM: estimate duty cycle first
|
|
291
|
+
duty_cycle = _estimate_duty_cycle(signal)
|
|
292
|
+
|
|
293
|
+
# PWM: Extreme duty cycle (far from 50%) with digital-like behavior
|
|
294
|
+
if duty_cycle < 0.35 or duty_cycle > 0.65: # Duty cycle far from 50%
|
|
295
|
+
# Additional check: signal should be mostly at two levels (digital-like)
|
|
296
|
+
unique_count = len(np.unique(signal))
|
|
297
|
+
if unique_count <= 10: # Few discrete levels = digital PWM
|
|
298
|
+
return {
|
|
299
|
+
"waveform_type": "pwm",
|
|
300
|
+
"confidence": 0.8,
|
|
301
|
+
"characteristics": {**characteristics, "duty_cycle": float(duty_cycle)},
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
# Square wave: Strong odd harmonics (1, 3, 5, 7...)
|
|
305
|
+
# Theoretical ratios: 1/3, 1/5, 1/7, 1/9
|
|
306
|
+
if len(harmonics) >= 2:
|
|
307
|
+
# Check for strong 3rd harmonic and weak 2nd harmonic
|
|
308
|
+
if harmonics[1] > 0.2 and harmonics[0] < 0.15: # 3rd strong, 2nd weak
|
|
309
|
+
# Additional check: duty cycle near 50%
|
|
310
|
+
if 0.4 < duty_cycle < 0.6:
|
|
311
|
+
return {
|
|
312
|
+
"waveform_type": "square",
|
|
313
|
+
"confidence": 0.85,
|
|
314
|
+
"characteristics": {**characteristics, "duty_cycle": float(duty_cycle)},
|
|
315
|
+
}
|
|
316
|
+
else:
|
|
317
|
+
# Square wave with duty cycle != 50% = PWM
|
|
318
|
+
return {
|
|
319
|
+
"waveform_type": "pwm",
|
|
320
|
+
"confidence": 0.8,
|
|
321
|
+
"characteristics": {**characteristics, "duty_cycle": float(duty_cycle)},
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
# Triangle wave: Strong odd harmonics with rapid decay (1/9, 1/25, ...)
|
|
325
|
+
if len(harmonics) >= 2:
|
|
326
|
+
# Check for rapidly decaying odd harmonics
|
|
327
|
+
if (
|
|
328
|
+
harmonics[1] > 0.05 and harmonics[1] < 0.15 # 3rd harmonic weaker than square
|
|
329
|
+
):
|
|
330
|
+
return {
|
|
331
|
+
"waveform_type": "triangle",
|
|
332
|
+
"confidence": 0.75,
|
|
333
|
+
"characteristics": characteristics,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
# Sawtooth: Both odd and even harmonics (1/2, 1/3, 1/4, ...)
|
|
337
|
+
if len(harmonics) >= 2:
|
|
338
|
+
if harmonics[0] > 0.3 and harmonics[1] > 0.15: # Both 2nd and 3rd strong
|
|
339
|
+
return {
|
|
340
|
+
"waveform_type": "sawtooth",
|
|
341
|
+
"confidence": 0.75,
|
|
342
|
+
"characteristics": characteristics,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# Unknown waveform type
|
|
346
|
+
return {
|
|
347
|
+
"waveform_type": "unknown",
|
|
348
|
+
"confidence": 0.5,
|
|
349
|
+
"characteristics": characteristics,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def classify_signal(trace: WaveformTrace) -> dict[str, Any]:
|
|
354
|
+
"""Perform complete signal classification.
|
|
355
|
+
|
|
356
|
+
Combines periodicity detection, waveform classification, and signal
|
|
357
|
+
quality assessment to provide comprehensive signal characterization.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
trace: Input waveform trace.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Dictionary containing:
|
|
364
|
+
- domain: "analog", "digital", or "mixed"
|
|
365
|
+
- periodicity: Dict from detect_periodicity()
|
|
366
|
+
- waveform: Dict from classify_waveform()
|
|
367
|
+
- signal_quality: Dict with SNR, clipping detection, etc.
|
|
368
|
+
|
|
369
|
+
Example:
|
|
370
|
+
>>> from oscura.core.types import WaveformTrace, TraceMetadata
|
|
371
|
+
>>> import numpy as np
|
|
372
|
+
>>> # Sine wave example
|
|
373
|
+
>>> t = np.linspace(0, 0.01, 1000)
|
|
374
|
+
>>> data = 3.3 * np.sin(2 * np.pi * 1000 * t)
|
|
375
|
+
>>> meta = TraceMetadata(sample_rate=1e5, units="V")
|
|
376
|
+
>>> trace = WaveformTrace(data=data, metadata=meta)
|
|
377
|
+
>>> result = classify_signal(trace)
|
|
378
|
+
>>> print(f"Domain: {result['domain']}")
|
|
379
|
+
>>> print(f"Type: {result['waveform']['waveform_type']}")
|
|
380
|
+
>>> print(f"Periodic: {result['periodicity']['periodicity']}")
|
|
381
|
+
Domain: analog
|
|
382
|
+
Type: sine
|
|
383
|
+
Periodic: periodic
|
|
384
|
+
"""
|
|
385
|
+
signal = trace.data
|
|
386
|
+
sample_rate = trace.metadata.sample_rate
|
|
387
|
+
|
|
388
|
+
# Detect domain (analog vs digital vs mixed)
|
|
389
|
+
domain = _detect_domain(signal)
|
|
390
|
+
|
|
391
|
+
# Detect periodicity
|
|
392
|
+
periodicity = detect_periodicity(signal, sample_rate)
|
|
393
|
+
|
|
394
|
+
# Classify waveform shape
|
|
395
|
+
waveform = classify_waveform(signal, sample_rate)
|
|
396
|
+
|
|
397
|
+
# Assess signal quality
|
|
398
|
+
signal_quality = _assess_signal_quality(signal)
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
"domain": domain,
|
|
402
|
+
"periodicity": periodicity,
|
|
403
|
+
"waveform": waveform,
|
|
404
|
+
"signal_quality": signal_quality,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# =============================================================================
|
|
409
|
+
# Helper Functions
|
|
410
|
+
# =============================================================================
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _detect_domain(signal: NDArray[np.floating[Any]]) -> str:
|
|
414
|
+
"""Detect if signal is analog, digital, or mixed.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
signal: Input signal array.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
"analog", "digital", or "mixed"
|
|
421
|
+
"""
|
|
422
|
+
if len(signal) < 3:
|
|
423
|
+
return "analog"
|
|
424
|
+
|
|
425
|
+
# Count unique values
|
|
426
|
+
unique_values = np.unique(signal)
|
|
427
|
+
|
|
428
|
+
# If only 2 unique values, likely digital
|
|
429
|
+
if len(unique_values) <= 2:
|
|
430
|
+
return "digital"
|
|
431
|
+
|
|
432
|
+
# Check if signal spends most time at discrete levels (digital-like)
|
|
433
|
+
# Build histogram and check if most samples are at a few discrete levels
|
|
434
|
+
hist, _ = np.histogram(signal, bins=50)
|
|
435
|
+
# If >80% of samples are in top 3 bins, likely digital or mixed
|
|
436
|
+
sorted_bins = np.sort(hist)[::-1]
|
|
437
|
+
top_3_ratio = np.sum(sorted_bins[:3]) / len(signal) if len(signal) > 0 else 0
|
|
438
|
+
|
|
439
|
+
if top_3_ratio > 0.8:
|
|
440
|
+
# Check if there are transitions (if yes, digital; if no, DC)
|
|
441
|
+
if len(unique_values) <= 5:
|
|
442
|
+
return "digital"
|
|
443
|
+
else:
|
|
444
|
+
return "mixed"
|
|
445
|
+
|
|
446
|
+
return "analog"
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _assess_signal_quality(signal: NDArray[np.floating[Any]]) -> dict[str, Any]:
|
|
450
|
+
"""Assess signal quality (SNR, clipping, etc.).
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
signal: Input signal array.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Dictionary with quality metrics.
|
|
457
|
+
"""
|
|
458
|
+
if len(signal) < 3:
|
|
459
|
+
return {
|
|
460
|
+
"snr": 0.0,
|
|
461
|
+
"clipping_detected": False,
|
|
462
|
+
"noise_level": 0.0,
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
# Estimate SNR (simplified)
|
|
466
|
+
signal_power = float(np.mean(signal**2))
|
|
467
|
+
noise_estimate = float(np.std(signal) * 0.1) # Rough estimate
|
|
468
|
+
snr = 10 * np.log10(signal_power / noise_estimate**2) if noise_estimate > 0 else 100.0
|
|
469
|
+
|
|
470
|
+
# Detect clipping (signal at rail)
|
|
471
|
+
signal_min = float(np.min(signal))
|
|
472
|
+
signal_max = float(np.max(signal))
|
|
473
|
+
signal_range = signal_max - signal_min
|
|
474
|
+
|
|
475
|
+
# Check if many samples are at min or max
|
|
476
|
+
threshold = signal_range * 0.01 # Within 1% of rail
|
|
477
|
+
at_min = np.sum(signal <= (signal_min + threshold))
|
|
478
|
+
at_max = np.sum(signal >= (signal_max - threshold))
|
|
479
|
+
clipping_ratio = (at_min + at_max) / len(signal)
|
|
480
|
+
|
|
481
|
+
clipping_detected = clipping_ratio > 0.1 # >10% of samples at rail
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
"snr": float(snr),
|
|
485
|
+
"clipping_detected": bool(clipping_detected),
|
|
486
|
+
"noise_level": float(noise_estimate),
|
|
487
|
+
"dynamic_range": float(signal_range),
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _estimate_duty_cycle(signal: NDArray[np.floating[Any]]) -> float:
|
|
492
|
+
"""Estimate duty cycle from signal.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
signal: Input signal array.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
Duty cycle as ratio (0.0 to 1.0).
|
|
499
|
+
"""
|
|
500
|
+
if len(signal) < 3:
|
|
501
|
+
return 0.5
|
|
502
|
+
|
|
503
|
+
# Find signal levels using histogram
|
|
504
|
+
hist, bin_edges = np.histogram(signal, bins=50)
|
|
505
|
+
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
|
|
506
|
+
|
|
507
|
+
# Find peaks in lower and upper halves
|
|
508
|
+
mid_idx = len(hist) // 2
|
|
509
|
+
low_idx = np.argmax(hist[:mid_idx]) if mid_idx > 0 else 0
|
|
510
|
+
high_idx = mid_idx + np.argmax(hist[mid_idx:])
|
|
511
|
+
|
|
512
|
+
low = bin_centers[low_idx]
|
|
513
|
+
high = bin_centers[high_idx]
|
|
514
|
+
|
|
515
|
+
# Calculate threshold at midpoint
|
|
516
|
+
mid = (low + high) / 2
|
|
517
|
+
|
|
518
|
+
# Count samples above threshold
|
|
519
|
+
above_threshold = np.sum(signal >= mid)
|
|
520
|
+
duty_cycle = above_threshold / len(signal) if len(signal) > 0 else 0.5
|
|
521
|
+
|
|
522
|
+
return float(duty_cycle)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
__all__ = [
|
|
526
|
+
"classify_signal",
|
|
527
|
+
"classify_waveform",
|
|
528
|
+
"detect_periodicity",
|
|
529
|
+
]
|
|
@@ -24,6 +24,9 @@ from typing import TYPE_CHECKING
|
|
|
24
24
|
|
|
25
25
|
import numpy as np
|
|
26
26
|
|
|
27
|
+
# Note: load_touchstone removed from re-export to break cyclic import
|
|
28
|
+
# Import directly from: from oscura.loaders import load_touchstone
|
|
29
|
+
|
|
27
30
|
if TYPE_CHECKING:
|
|
28
31
|
from numpy.typing import NDArray
|
|
29
32
|
|
|
@@ -277,9 +280,6 @@ def _abcd_to_s_single(abcd: NDArray[np.complex128], z0: float) -> NDArray[np.com
|
|
|
277
280
|
return np.array([[S11, S12], [S21, S22]], dtype=np.complex128)
|
|
278
281
|
|
|
279
282
|
|
|
280
|
-
# load_touchstone has been moved to oscura.loaders module
|
|
281
|
-
|
|
282
|
-
|
|
283
283
|
__all__ = [
|
|
284
284
|
"SParameterData",
|
|
285
285
|
"abcd_to_s",
|
|
@@ -20,11 +20,13 @@ from oscura.analyzers.statistics.advanced import (
|
|
|
20
20
|
)
|
|
21
21
|
from oscura.analyzers.statistics.basic import (
|
|
22
22
|
basic_stats,
|
|
23
|
+
measure,
|
|
23
24
|
percentiles,
|
|
24
25
|
quartiles,
|
|
25
26
|
running_stats,
|
|
26
27
|
summary_stats,
|
|
27
28
|
weighted_mean,
|
|
29
|
+
weighted_std,
|
|
28
30
|
)
|
|
29
31
|
from oscura.analyzers.statistics.correlation import (
|
|
30
32
|
CrossCorrelationResult,
|
|
@@ -99,6 +101,7 @@ __all__ = [
|
|
|
99
101
|
"kernel_density",
|
|
100
102
|
# Advanced (STAT-012)
|
|
101
103
|
"local_outlier_factor",
|
|
104
|
+
"measure",
|
|
102
105
|
"modified_zscore_outliers",
|
|
103
106
|
"moment",
|
|
104
107
|
"moving_average",
|
|
@@ -114,6 +117,7 @@ __all__ = [
|
|
|
114
117
|
"seasonal_decompose",
|
|
115
118
|
"summary_stats",
|
|
116
119
|
"weighted_mean",
|
|
120
|
+
"weighted_std",
|
|
117
121
|
# Outlier detection
|
|
118
122
|
"zscore_outliers",
|
|
119
123
|
]
|