oscura 0.7.0__py3-none-any.whl → 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/analyzers/__init__.py +2 -0
  3. oscura/analyzers/digital/extraction.py +2 -3
  4. oscura/analyzers/digital/quality.py +1 -1
  5. oscura/analyzers/digital/timing.py +1 -1
  6. oscura/analyzers/eye/__init__.py +5 -1
  7. oscura/analyzers/eye/generation.py +501 -0
  8. oscura/analyzers/jitter/__init__.py +6 -6
  9. oscura/analyzers/jitter/timing.py +419 -0
  10. oscura/analyzers/patterns/__init__.py +94 -0
  11. oscura/analyzers/patterns/reverse_engineering.py +991 -0
  12. oscura/analyzers/power/__init__.py +35 -12
  13. oscura/analyzers/power/basic.py +3 -3
  14. oscura/analyzers/power/soa.py +1 -1
  15. oscura/analyzers/power/switching.py +3 -3
  16. oscura/analyzers/signal_classification.py +529 -0
  17. oscura/analyzers/signal_integrity/sparams.py +3 -3
  18. oscura/analyzers/statistics/__init__.py +4 -0
  19. oscura/analyzers/statistics/basic.py +152 -0
  20. oscura/analyzers/statistics/correlation.py +47 -6
  21. oscura/analyzers/validation.py +1 -1
  22. oscura/analyzers/waveform/__init__.py +2 -0
  23. oscura/analyzers/waveform/measurements.py +329 -163
  24. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  25. oscura/analyzers/waveform/spectral.py +498 -54
  26. oscura/api/dsl/commands.py +15 -6
  27. oscura/api/server/templates/base.html +137 -146
  28. oscura/api/server/templates/export.html +84 -110
  29. oscura/api/server/templates/home.html +248 -267
  30. oscura/api/server/templates/protocols.html +44 -48
  31. oscura/api/server/templates/reports.html +27 -35
  32. oscura/api/server/templates/session_detail.html +68 -78
  33. oscura/api/server/templates/sessions.html +62 -72
  34. oscura/api/server/templates/waveforms.html +54 -64
  35. oscura/automotive/__init__.py +1 -1
  36. oscura/automotive/can/session.py +1 -1
  37. oscura/automotive/dbc/generator.py +638 -23
  38. oscura/automotive/dtc/data.json +102 -17
  39. oscura/automotive/uds/decoder.py +99 -6
  40. oscura/cli/analyze.py +8 -2
  41. oscura/cli/batch.py +36 -5
  42. oscura/cli/characterize.py +18 -4
  43. oscura/cli/export.py +47 -5
  44. oscura/cli/main.py +2 -0
  45. oscura/cli/onboarding/wizard.py +10 -6
  46. oscura/cli/pipeline.py +585 -0
  47. oscura/cli/visualize.py +6 -4
  48. oscura/convenience.py +400 -32
  49. oscura/core/config/loader.py +0 -1
  50. oscura/core/measurement_result.py +286 -0
  51. oscura/core/progress.py +1 -1
  52. oscura/core/schemas/device_mapping.json +8 -2
  53. oscura/core/schemas/packet_format.json +24 -4
  54. oscura/core/schemas/protocol_definition.json +12 -2
  55. oscura/core/types.py +300 -199
  56. oscura/correlation/multi_protocol.py +1 -1
  57. oscura/export/legacy/__init__.py +11 -0
  58. oscura/export/legacy/wav.py +75 -0
  59. oscura/exporters/__init__.py +19 -0
  60. oscura/exporters/wireshark.py +809 -0
  61. oscura/hardware/acquisition/file.py +5 -19
  62. oscura/hardware/acquisition/saleae.py +10 -10
  63. oscura/hardware/acquisition/socketcan.py +4 -6
  64. oscura/hardware/acquisition/synthetic.py +1 -5
  65. oscura/hardware/acquisition/visa.py +6 -6
  66. oscura/hardware/security/side_channel_detector.py +5 -508
  67. oscura/inference/message_format.py +686 -1
  68. oscura/jupyter/display.py +2 -2
  69. oscura/jupyter/magic.py +3 -3
  70. oscura/loaders/__init__.py +17 -12
  71. oscura/loaders/binary.py +1 -1
  72. oscura/loaders/chipwhisperer.py +1 -2
  73. oscura/loaders/configurable.py +1 -1
  74. oscura/loaders/csv_loader.py +2 -2
  75. oscura/loaders/hdf5_loader.py +1 -1
  76. oscura/loaders/lazy.py +6 -1
  77. oscura/loaders/mmap_loader.py +0 -1
  78. oscura/loaders/numpy_loader.py +8 -7
  79. oscura/loaders/preprocessing.py +3 -5
  80. oscura/loaders/rigol.py +21 -7
  81. oscura/loaders/sigrok.py +2 -5
  82. oscura/loaders/tdms.py +3 -2
  83. oscura/loaders/tektronix.py +38 -32
  84. oscura/loaders/tss.py +20 -27
  85. oscura/loaders/vcd.py +13 -8
  86. oscura/loaders/wav.py +1 -6
  87. oscura/pipeline/__init__.py +76 -0
  88. oscura/pipeline/handlers/__init__.py +165 -0
  89. oscura/pipeline/handlers/analyzers.py +1045 -0
  90. oscura/pipeline/handlers/decoders.py +899 -0
  91. oscura/pipeline/handlers/exporters.py +1103 -0
  92. oscura/pipeline/handlers/filters.py +891 -0
  93. oscura/pipeline/handlers/loaders.py +640 -0
  94. oscura/pipeline/handlers/transforms.py +768 -0
  95. oscura/reporting/__init__.py +88 -1
  96. oscura/reporting/automation.py +348 -0
  97. oscura/reporting/citations.py +374 -0
  98. oscura/reporting/core.py +54 -0
  99. oscura/reporting/formatting/__init__.py +11 -0
  100. oscura/reporting/formatting/measurements.py +320 -0
  101. oscura/reporting/html.py +57 -0
  102. oscura/reporting/interpretation.py +431 -0
  103. oscura/reporting/summary.py +329 -0
  104. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  105. oscura/reporting/visualization.py +542 -0
  106. oscura/side_channel/__init__.py +38 -57
  107. oscura/utils/builders/signal_builder.py +5 -5
  108. oscura/utils/comparison/compare.py +7 -9
  109. oscura/utils/comparison/golden.py +1 -1
  110. oscura/utils/filtering/convenience.py +2 -2
  111. oscura/utils/math/arithmetic.py +38 -62
  112. oscura/utils/math/interpolation.py +20 -20
  113. oscura/utils/pipeline/__init__.py +4 -17
  114. oscura/utils/progressive.py +1 -4
  115. oscura/utils/triggering/edge.py +1 -1
  116. oscura/utils/triggering/pattern.py +2 -2
  117. oscura/utils/triggering/pulse.py +2 -2
  118. oscura/utils/triggering/window.py +3 -3
  119. oscura/validation/hil_testing.py +11 -11
  120. oscura/visualization/__init__.py +47 -284
  121. oscura/visualization/batch.py +160 -0
  122. oscura/visualization/plot.py +542 -53
  123. oscura/visualization/styles.py +184 -318
  124. oscura/workflows/__init__.py +2 -0
  125. oscura/workflows/batch/advanced.py +1 -1
  126. oscura/workflows/batch/aggregate.py +7 -8
  127. oscura/workflows/complete_re.py +251 -23
  128. oscura/workflows/digital.py +27 -4
  129. oscura/workflows/multi_trace.py +136 -17
  130. oscura/workflows/waveform.py +788 -0
  131. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  132. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/RECORD +135 -149
  133. oscura/side_channel/dpa.py +0 -1025
  134. oscura/utils/optimization/__init__.py +0 -19
  135. oscura/utils/optimization/parallel.py +0 -443
  136. oscura/utils/optimization/search.py +0 -532
  137. oscura/utils/pipeline/base.py +0 -338
  138. oscura/utils/pipeline/composition.py +0 -248
  139. oscura/utils/pipeline/parallel.py +0 -449
  140. oscura/utils/pipeline/pipeline.py +0 -375
  141. oscura/utils/search/__init__.py +0 -16
  142. oscura/utils/search/anomaly.py +0 -424
  143. oscura/utils/search/context.py +0 -294
  144. oscura/utils/search/pattern.py +0 -288
  145. oscura/utils/storage/__init__.py +0 -61
  146. oscura/utils/storage/database.py +0 -1166
  147. oscura/visualization/accessibility.py +0 -526
  148. oscura/visualization/annotations.py +0 -371
  149. oscura/visualization/axis_scaling.py +0 -305
  150. oscura/visualization/colors.py +0 -451
  151. oscura/visualization/digital.py +0 -436
  152. oscura/visualization/eye.py +0 -571
  153. oscura/visualization/histogram.py +0 -281
  154. oscura/visualization/interactive.py +0 -1035
  155. oscura/visualization/jitter.py +0 -1042
  156. oscura/visualization/keyboard.py +0 -394
  157. oscura/visualization/layout.py +0 -400
  158. oscura/visualization/optimization.py +0 -1079
  159. oscura/visualization/palettes.py +0 -446
  160. oscura/visualization/power.py +0 -508
  161. oscura/visualization/power_extended.py +0 -955
  162. oscura/visualization/presets.py +0 -469
  163. oscura/visualization/protocols.py +0 -1246
  164. oscura/visualization/render.py +0 -223
  165. oscura/visualization/rendering.py +0 -444
  166. oscura/visualization/reverse_engineering.py +0 -838
  167. oscura/visualization/signal_integrity.py +0 -989
  168. oscura/visualization/specialized.py +0 -643
  169. oscura/visualization/spectral.py +0 -1226
  170. oscura/visualization/thumbnails.py +0 -340
  171. oscura/visualization/time_axis.py +0 -351
  172. oscura/visualization/waveform.py +0 -454
  173. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  174. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  175. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
  ]