oscura 0.8.0__py3-none-any.whl → 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/__main__.py +4 -0
  3. oscura/analyzers/__init__.py +2 -0
  4. oscura/analyzers/digital/extraction.py +2 -3
  5. oscura/analyzers/digital/quality.py +1 -1
  6. oscura/analyzers/digital/timing.py +1 -1
  7. oscura/analyzers/ml/signal_classifier.py +6 -0
  8. oscura/analyzers/patterns/__init__.py +66 -0
  9. oscura/analyzers/power/basic.py +3 -3
  10. oscura/analyzers/power/soa.py +1 -1
  11. oscura/analyzers/power/switching.py +3 -3
  12. oscura/analyzers/signal_classification.py +529 -0
  13. oscura/analyzers/signal_integrity/sparams.py +3 -3
  14. oscura/analyzers/statistics/basic.py +10 -7
  15. oscura/analyzers/validation.py +1 -1
  16. oscura/analyzers/waveform/measurements.py +200 -156
  17. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  18. oscura/analyzers/waveform/spectral.py +182 -84
  19. oscura/api/dsl/commands.py +15 -6
  20. oscura/api/server/templates/base.html +137 -146
  21. oscura/api/server/templates/export.html +84 -110
  22. oscura/api/server/templates/home.html +248 -267
  23. oscura/api/server/templates/protocols.html +44 -48
  24. oscura/api/server/templates/reports.html +27 -35
  25. oscura/api/server/templates/session_detail.html +68 -78
  26. oscura/api/server/templates/sessions.html +62 -72
  27. oscura/api/server/templates/waveforms.html +54 -64
  28. oscura/automotive/__init__.py +1 -1
  29. oscura/automotive/can/session.py +1 -1
  30. oscura/automotive/dbc/generator.py +638 -23
  31. oscura/automotive/dtc/data.json +17 -102
  32. oscura/automotive/flexray/fibex.py +9 -1
  33. oscura/automotive/uds/decoder.py +99 -6
  34. oscura/cli/analyze.py +8 -2
  35. oscura/cli/batch.py +36 -5
  36. oscura/cli/characterize.py +18 -4
  37. oscura/cli/export.py +47 -5
  38. oscura/cli/main.py +2 -0
  39. oscura/cli/onboarding/wizard.py +10 -6
  40. oscura/cli/pipeline.py +585 -0
  41. oscura/cli/visualize.py +6 -4
  42. oscura/convenience.py +400 -32
  43. oscura/core/measurement_result.py +286 -0
  44. oscura/core/progress.py +1 -1
  45. oscura/core/schemas/device_mapping.json +2 -8
  46. oscura/core/schemas/packet_format.json +4 -24
  47. oscura/core/schemas/protocol_definition.json +2 -12
  48. oscura/core/types.py +232 -239
  49. oscura/correlation/multi_protocol.py +1 -1
  50. oscura/export/legacy/__init__.py +11 -0
  51. oscura/export/legacy/wav.py +75 -0
  52. oscura/exporters/__init__.py +19 -0
  53. oscura/exporters/wireshark.py +809 -0
  54. oscura/hardware/acquisition/file.py +5 -19
  55. oscura/hardware/acquisition/saleae.py +10 -10
  56. oscura/hardware/acquisition/socketcan.py +4 -6
  57. oscura/hardware/acquisition/synthetic.py +1 -5
  58. oscura/hardware/acquisition/visa.py +6 -6
  59. oscura/hardware/security/side_channel_detector.py +5 -508
  60. oscura/inference/message_format.py +686 -1
  61. oscura/jupyter/display.py +2 -2
  62. oscura/jupyter/magic.py +3 -3
  63. oscura/loaders/__init__.py +17 -12
  64. oscura/loaders/binary.py +1 -1
  65. oscura/loaders/chipwhisperer.py +1 -2
  66. oscura/loaders/configurable.py +1 -1
  67. oscura/loaders/csv_loader.py +2 -2
  68. oscura/loaders/hdf5_loader.py +1 -1
  69. oscura/loaders/lazy.py +6 -1
  70. oscura/loaders/mmap_loader.py +0 -1
  71. oscura/loaders/numpy_loader.py +8 -7
  72. oscura/loaders/preprocessing.py +3 -5
  73. oscura/loaders/rigol.py +21 -7
  74. oscura/loaders/sigrok.py +2 -5
  75. oscura/loaders/tdms.py +3 -2
  76. oscura/loaders/tektronix.py +38 -32
  77. oscura/loaders/tss.py +20 -27
  78. oscura/loaders/validation.py +17 -10
  79. oscura/loaders/vcd.py +13 -8
  80. oscura/loaders/wav.py +1 -6
  81. oscura/pipeline/__init__.py +76 -0
  82. oscura/pipeline/handlers/__init__.py +165 -0
  83. oscura/pipeline/handlers/analyzers.py +1045 -0
  84. oscura/pipeline/handlers/decoders.py +899 -0
  85. oscura/pipeline/handlers/exporters.py +1103 -0
  86. oscura/pipeline/handlers/filters.py +891 -0
  87. oscura/pipeline/handlers/loaders.py +640 -0
  88. oscura/pipeline/handlers/transforms.py +768 -0
  89. oscura/reporting/formatting/measurements.py +55 -14
  90. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  91. oscura/sessions/legacy.py +49 -1
  92. oscura/side_channel/__init__.py +38 -57
  93. oscura/utils/builders/signal_builder.py +5 -5
  94. oscura/utils/comparison/compare.py +7 -9
  95. oscura/utils/comparison/golden.py +1 -1
  96. oscura/utils/filtering/convenience.py +2 -2
  97. oscura/utils/math/arithmetic.py +38 -62
  98. oscura/utils/math/interpolation.py +20 -20
  99. oscura/utils/pipeline/__init__.py +4 -17
  100. oscura/utils/progressive.py +1 -4
  101. oscura/utils/triggering/edge.py +1 -1
  102. oscura/utils/triggering/pattern.py +2 -2
  103. oscura/utils/triggering/pulse.py +2 -2
  104. oscura/utils/triggering/window.py +3 -3
  105. oscura/validation/hil_testing.py +11 -11
  106. oscura/visualization/__init__.py +46 -284
  107. oscura/visualization/batch.py +72 -433
  108. oscura/visualization/plot.py +542 -53
  109. oscura/visualization/styles.py +184 -318
  110. oscura/workflows/batch/advanced.py +1 -1
  111. oscura/workflows/batch/aggregate.py +12 -9
  112. oscura/workflows/complete_re.py +251 -23
  113. oscura/workflows/digital.py +27 -4
  114. oscura/workflows/multi_trace.py +136 -17
  115. oscura/workflows/waveform.py +11 -6
  116. oscura-0.11.0.dist-info/METADATA +460 -0
  117. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/RECORD +120 -145
  118. oscura/side_channel/dpa.py +0 -1025
  119. oscura/utils/optimization/__init__.py +0 -19
  120. oscura/utils/optimization/parallel.py +0 -443
  121. oscura/utils/optimization/search.py +0 -532
  122. oscura/utils/pipeline/base.py +0 -338
  123. oscura/utils/pipeline/composition.py +0 -248
  124. oscura/utils/pipeline/parallel.py +0 -449
  125. oscura/utils/pipeline/pipeline.py +0 -375
  126. oscura/utils/search/__init__.py +0 -16
  127. oscura/utils/search/anomaly.py +0 -424
  128. oscura/utils/search/context.py +0 -294
  129. oscura/utils/search/pattern.py +0 -288
  130. oscura/utils/storage/__init__.py +0 -61
  131. oscura/utils/storage/database.py +0 -1166
  132. oscura/visualization/accessibility.py +0 -526
  133. oscura/visualization/annotations.py +0 -371
  134. oscura/visualization/axis_scaling.py +0 -305
  135. oscura/visualization/colors.py +0 -451
  136. oscura/visualization/digital.py +0 -436
  137. oscura/visualization/eye.py +0 -571
  138. oscura/visualization/histogram.py +0 -281
  139. oscura/visualization/interactive.py +0 -1035
  140. oscura/visualization/jitter.py +0 -1042
  141. oscura/visualization/keyboard.py +0 -394
  142. oscura/visualization/layout.py +0 -400
  143. oscura/visualization/optimization.py +0 -1079
  144. oscura/visualization/palettes.py +0 -446
  145. oscura/visualization/power.py +0 -508
  146. oscura/visualization/power_extended.py +0 -955
  147. oscura/visualization/presets.py +0 -469
  148. oscura/visualization/protocols.py +0 -1246
  149. oscura/visualization/render.py +0 -223
  150. oscura/visualization/rendering.py +0 -444
  151. oscura/visualization/reverse_engineering.py +0 -838
  152. oscura/visualization/signal_integrity.py +0 -989
  153. oscura/visualization/specialized.py +0 -643
  154. oscura/visualization/spectral.py +0 -1226
  155. oscura/visualization/thumbnails.py +0 -340
  156. oscura/visualization/time_axis.py +0 -351
  157. oscura/visualization/waveform.py +0 -454
  158. oscura-0.8.0.dist-info/METADATA +0 -661
  159. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/WHEEL +0 -0
  160. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/entry_points.txt +0 -0
  161. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -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,6 +20,7 @@ from typing import TYPE_CHECKING, Any
20
20
 
21
21
  import numpy as np
22
22
 
23
+ from oscura.core.measurement_result import make_measurement
23
24
  from oscura.core.types import WaveformTrace
24
25
 
25
26
  if TYPE_CHECKING:
@@ -331,27 +332,28 @@ def measure(
331
332
  """Compute statistical measurements with consistent format.
332
333
 
333
334
  Unified function matching the API pattern of waveform.measure() and spectral.measure().
334
- Returns measurements with units for easy formatting and display.
335
+ Returns MeasurementResult format with applicability tracking and formatting.
335
336
 
336
337
  Args:
337
338
  trace: Input trace or numpy array.
338
339
  parameters: List of measurement names to compute. If None, compute all.
339
340
  Valid names: mean, variance, std, min, max, range, count, p1, p5, p25, p50, p75, p95, p99
340
- include_units: If True, return {value, unit} dicts. If False, return flat values.
341
+ include_units: If True, return MeasurementResult format. If False, return flat values.
341
342
 
342
343
  Returns:
343
- Dictionary mapping measurement names to values (with units if requested).
344
+ Dictionary mapping measurement names to MeasurementResults (if include_units=True)
345
+ or raw values (if include_units=False).
344
346
 
345
347
  Example:
346
348
  >>> from oscura.analyzers.statistics import measure
347
349
  >>> results = measure(trace)
348
- >>> print(f"Mean: {results['mean']['value']} {results['mean']['unit']}")
349
- >>> print(f"Std: {results['std']['value']} {results['std']['unit']}")
350
+ >>> if results['mean']['applicable']:
351
+ ... print(f"Mean: {results['mean']['display']}")
350
352
 
351
353
  >>> # Get specific measurements only
352
354
  >>> results = measure(trace, parameters=["mean", "std"])
353
355
 
354
- >>> # Get flat values without units
356
+ >>> # Get flat values (legacy compatibility)
355
357
  >>> results = measure(trace, include_units=False)
356
358
  >>> mean_value = results["mean"] # Just the float
357
359
  """
@@ -394,7 +396,8 @@ def measure(
394
396
  results = {}
395
397
  for name, value in all_measurements.items():
396
398
  unit = unit_map.get(name, "")
397
- results[name] = {"value": value, "unit": unit}
399
+ # All statistical measurements are always applicable (never NaN from valid data)
400
+ results[name] = make_measurement(value, unit)
398
401
  return results
399
402
  else:
400
403
  return all_measurements
@@ -64,7 +64,7 @@ def is_suitable_for_frequency_measurement(trace: WaveformTrace) -> tuple[bool, s
64
64
 
65
65
  # Check period consistency (is it periodic?)
66
66
  if len(rising_edges) >= 3:
67
- edge_times = rising_edges * trace.metadata.time_base
67
+ edge_times = rising_edges / trace.metadata.sample_rate
68
68
  periods = np.diff(edge_times)
69
69
  period_cv = np.std(periods) / np.mean(periods) if np.mean(periods) > 0 else float("inf")
70
70