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,1045 @@
|
|
|
1
|
+
"""Analysis handlers for pipeline system.
|
|
2
|
+
|
|
3
|
+
This module provides handlers for signal analysis including waveform measurements,
|
|
4
|
+
spectral analysis, power analysis, timing measurements, jitter analysis, and statistics.
|
|
5
|
+
All handlers follow the standard signature: (inputs, params, step_name) -> outputs.
|
|
6
|
+
|
|
7
|
+
Available Handlers:
|
|
8
|
+
- analysis.waveform: Basic waveform measurements (rise/fall time, frequency, amplitude)
|
|
9
|
+
- analysis.spectral: Spectral analysis (FFT, PSD, THD, SNR, SINAD, ENOB, SFDR)
|
|
10
|
+
- analysis.power: Power analysis (voltage, current, active/reactive/apparent power)
|
|
11
|
+
- analysis.timing: IEEE 181 pulse timing measurements and clock recovery
|
|
12
|
+
- analysis.jitter: Jitter analysis (TIE, period jitter, cycle-to-cycle)
|
|
13
|
+
- analysis.statistics: Statistical measurements (mean, std, histogram, outliers)
|
|
14
|
+
- analysis.fft: FFT analysis returning frequency and magnitude arrays
|
|
15
|
+
- analysis.psd: Power spectral density calculation
|
|
16
|
+
- analysis.thd: Total harmonic distortion measurement
|
|
17
|
+
- analysis.eye_diagram: Eye diagram analysis for digital signals
|
|
18
|
+
- analysis.auto: Auto-detect signal type and perform appropriate analysis
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from oscura.core.config.pipeline import PipelineExecutionError
|
|
26
|
+
from oscura.pipeline.handlers import register_handler
|
|
27
|
+
|
|
28
|
+
# Lazy imports to avoid circular dependencies
|
|
29
|
+
_waveform = None
|
|
30
|
+
_spectral = None
|
|
31
|
+
_power = None
|
|
32
|
+
_jitter = None
|
|
33
|
+
_statistics = None
|
|
34
|
+
_eye = None
|
|
35
|
+
_signal = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_waveform() -> Any:
|
|
39
|
+
"""Lazy import waveform analyzer module."""
|
|
40
|
+
global _waveform
|
|
41
|
+
if _waveform is None:
|
|
42
|
+
from oscura.analyzers import waveform as _waveform_module
|
|
43
|
+
|
|
44
|
+
_waveform = _waveform_module
|
|
45
|
+
return _waveform
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_spectral() -> Any:
|
|
49
|
+
"""Lazy import spectral analyzer module."""
|
|
50
|
+
global _spectral
|
|
51
|
+
if _spectral is None:
|
|
52
|
+
from oscura.analyzers import spectral as _spectral_module
|
|
53
|
+
|
|
54
|
+
_spectral = _spectral_module
|
|
55
|
+
return _spectral
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_power() -> Any:
|
|
59
|
+
"""Lazy import power analyzer module."""
|
|
60
|
+
global _power
|
|
61
|
+
if _power is None:
|
|
62
|
+
from oscura.analyzers import power as _power_module
|
|
63
|
+
|
|
64
|
+
_power = _power_module
|
|
65
|
+
return _power
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_jitter() -> Any:
|
|
69
|
+
"""Lazy import jitter analyzer module."""
|
|
70
|
+
global _jitter
|
|
71
|
+
if _jitter is None:
|
|
72
|
+
from oscura.analyzers import jitter as _jitter_module
|
|
73
|
+
|
|
74
|
+
_jitter = _jitter_module
|
|
75
|
+
return _jitter
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_statistics() -> Any:
|
|
79
|
+
"""Lazy import statistics analyzer module."""
|
|
80
|
+
global _statistics
|
|
81
|
+
if _statistics is None:
|
|
82
|
+
from oscura.analyzers import statistics as _statistics_module
|
|
83
|
+
|
|
84
|
+
_statistics = _statistics_module
|
|
85
|
+
return _statistics
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_eye() -> Any:
|
|
89
|
+
"""Lazy import eye diagram analyzer module."""
|
|
90
|
+
global _eye
|
|
91
|
+
if _eye is None:
|
|
92
|
+
from oscura.analyzers import eye as _eye_module
|
|
93
|
+
|
|
94
|
+
_eye = _eye_module
|
|
95
|
+
return _eye
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _get_signal() -> Any:
|
|
99
|
+
"""Lazy import signal analyzer module."""
|
|
100
|
+
global _signal
|
|
101
|
+
if _signal is None:
|
|
102
|
+
from oscura.analyzers import signal as _signal_module
|
|
103
|
+
|
|
104
|
+
_signal = _signal_module
|
|
105
|
+
return _signal
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@register_handler("analysis.waveform")
|
|
109
|
+
def handle_analysis_waveform(
|
|
110
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
"""Perform basic waveform measurements.
|
|
113
|
+
|
|
114
|
+
Inputs:
|
|
115
|
+
trace: WaveformTrace to analyze
|
|
116
|
+
|
|
117
|
+
Parameters:
|
|
118
|
+
measurements (list[str], optional): Specific measurements to perform
|
|
119
|
+
Available: rise_time, fall_time, frequency, amplitude, rms, duty_cycle,
|
|
120
|
+
period, pulse_width, overshoot, undershoot, preshoot, mean
|
|
121
|
+
Default: all measurements
|
|
122
|
+
threshold_low (float, optional): Low threshold percentage (default: 10%)
|
|
123
|
+
threshold_mid (float, optional): Mid threshold percentage (default: 50%)
|
|
124
|
+
threshold_high (float, optional): High threshold percentage (default: 90%)
|
|
125
|
+
|
|
126
|
+
Outputs:
|
|
127
|
+
measurements: Dict of measurement name to value
|
|
128
|
+
rise_time: Rise time in seconds (if measured)
|
|
129
|
+
fall_time: Fall time in seconds (if measured)
|
|
130
|
+
frequency: Frequency in Hz (if measured)
|
|
131
|
+
amplitude: Peak-to-peak amplitude (if measured)
|
|
132
|
+
rms: RMS value (if measured)
|
|
133
|
+
duty_cycle: Duty cycle percentage (if measured)
|
|
134
|
+
period: Period in seconds (if measured)
|
|
135
|
+
pulse_width: Pulse width in seconds (if measured)
|
|
136
|
+
mean: Mean value (if measured)
|
|
137
|
+
overshoot: Overshoot percentage (if measured)
|
|
138
|
+
undershoot: Undershoot percentage (if measured)
|
|
139
|
+
"""
|
|
140
|
+
waveform = _get_waveform()
|
|
141
|
+
|
|
142
|
+
trace = inputs.get("trace")
|
|
143
|
+
if trace is None:
|
|
144
|
+
raise PipelineExecutionError(
|
|
145
|
+
"Missing required input 'trace'. Suggestion: Provide a WaveformTrace to analyze",
|
|
146
|
+
step_name=step_name,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
requested_measurements = params.get("measurements")
|
|
150
|
+
threshold_low = params.get("threshold_low", 0.1)
|
|
151
|
+
threshold_mid = params.get("threshold_mid", 0.5)
|
|
152
|
+
threshold_high = params.get("threshold_high", 0.9)
|
|
153
|
+
|
|
154
|
+
# Define all available measurements
|
|
155
|
+
all_measurements = [
|
|
156
|
+
"rise_time",
|
|
157
|
+
"fall_time",
|
|
158
|
+
"frequency",
|
|
159
|
+
"amplitude",
|
|
160
|
+
"rms",
|
|
161
|
+
"duty_cycle",
|
|
162
|
+
"period",
|
|
163
|
+
"pulse_width",
|
|
164
|
+
"overshoot",
|
|
165
|
+
"undershoot",
|
|
166
|
+
"preshoot",
|
|
167
|
+
"mean",
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
# Use requested or all measurements
|
|
171
|
+
measurements_to_run = requested_measurements or all_measurements
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
results = {}
|
|
175
|
+
measurements_dict = {}
|
|
176
|
+
|
|
177
|
+
for measurement in measurements_to_run:
|
|
178
|
+
try:
|
|
179
|
+
if measurement == "rise_time":
|
|
180
|
+
value = waveform.rise_time(trace, low=threshold_low, high=threshold_high)
|
|
181
|
+
results["rise_time"] = value
|
|
182
|
+
measurements_dict["rise_time"] = value
|
|
183
|
+
elif measurement == "fall_time":
|
|
184
|
+
value = waveform.fall_time(trace, low=threshold_low, high=threshold_high)
|
|
185
|
+
results["fall_time"] = value
|
|
186
|
+
measurements_dict["fall_time"] = value
|
|
187
|
+
elif measurement == "frequency":
|
|
188
|
+
value = waveform.frequency(trace)
|
|
189
|
+
results["frequency"] = value
|
|
190
|
+
measurements_dict["frequency"] = value
|
|
191
|
+
elif measurement == "amplitude":
|
|
192
|
+
value = waveform.amplitude(trace)
|
|
193
|
+
results["amplitude"] = value
|
|
194
|
+
measurements_dict["amplitude"] = value
|
|
195
|
+
elif measurement == "rms":
|
|
196
|
+
value = waveform.rms(trace)
|
|
197
|
+
results["rms"] = value
|
|
198
|
+
measurements_dict["rms"] = value
|
|
199
|
+
elif measurement == "duty_cycle":
|
|
200
|
+
value = waveform.duty_cycle(trace, threshold=threshold_mid)
|
|
201
|
+
results["duty_cycle"] = value
|
|
202
|
+
measurements_dict["duty_cycle"] = value
|
|
203
|
+
elif measurement == "period":
|
|
204
|
+
value = waveform.period(trace)
|
|
205
|
+
results["period"] = value
|
|
206
|
+
measurements_dict["period"] = value
|
|
207
|
+
elif measurement == "pulse_width":
|
|
208
|
+
value = waveform.pulse_width(trace, threshold=threshold_mid)
|
|
209
|
+
results["pulse_width"] = value
|
|
210
|
+
measurements_dict["pulse_width"] = value
|
|
211
|
+
elif measurement == "overshoot":
|
|
212
|
+
value = waveform.overshoot(trace)
|
|
213
|
+
results["overshoot"] = value
|
|
214
|
+
measurements_dict["overshoot"] = value
|
|
215
|
+
elif measurement == "undershoot":
|
|
216
|
+
value = waveform.undershoot(trace)
|
|
217
|
+
results["undershoot"] = value
|
|
218
|
+
measurements_dict["undershoot"] = value
|
|
219
|
+
elif measurement == "preshoot":
|
|
220
|
+
value = waveform.preshoot(trace)
|
|
221
|
+
results["preshoot"] = value
|
|
222
|
+
measurements_dict["preshoot"] = value
|
|
223
|
+
elif measurement == "mean":
|
|
224
|
+
value = waveform.mean(trace)
|
|
225
|
+
results["mean"] = value
|
|
226
|
+
measurements_dict["mean"] = value
|
|
227
|
+
except Exception as e:
|
|
228
|
+
# Log measurement failure but continue with others
|
|
229
|
+
measurements_dict[f"{measurement}_error"] = str(e)
|
|
230
|
+
|
|
231
|
+
results["measurements"] = measurements_dict
|
|
232
|
+
|
|
233
|
+
except Exception as e:
|
|
234
|
+
raise PipelineExecutionError(f"Waveform analysis failed: {e}", step_name=step_name) from e
|
|
235
|
+
|
|
236
|
+
return results
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@register_handler("analysis.spectral")
|
|
240
|
+
def handle_analysis_spectral(
|
|
241
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
242
|
+
) -> dict[str, Any]:
|
|
243
|
+
"""Perform spectral analysis with quality metrics.
|
|
244
|
+
|
|
245
|
+
Inputs:
|
|
246
|
+
trace: WaveformTrace to analyze
|
|
247
|
+
|
|
248
|
+
Parameters:
|
|
249
|
+
window (str, optional): FFT window function (default: 'hann')
|
|
250
|
+
nfft (int, optional): FFT length (default: trace length)
|
|
251
|
+
fundamental_freq (float, optional): Fundamental frequency for THD/SNR
|
|
252
|
+
num_harmonics (int, optional): Number of harmonics for THD (default: 5)
|
|
253
|
+
|
|
254
|
+
Outputs:
|
|
255
|
+
frequencies: FFT frequency array
|
|
256
|
+
magnitudes: FFT magnitude array (linear)
|
|
257
|
+
thd_db: Total harmonic distortion in dB
|
|
258
|
+
snr_db: Signal-to-noise ratio in dB
|
|
259
|
+
sinad_db: Signal-to-noise and distortion ratio in dB
|
|
260
|
+
enob: Effective number of bits
|
|
261
|
+
sfdr_db: Spurious-free dynamic range in dB
|
|
262
|
+
fundamental_power: Power at fundamental frequency
|
|
263
|
+
fundamental_idx: Index of fundamental frequency peak
|
|
264
|
+
"""
|
|
265
|
+
spectral = _get_spectral()
|
|
266
|
+
|
|
267
|
+
trace = inputs.get("trace")
|
|
268
|
+
if trace is None:
|
|
269
|
+
raise PipelineExecutionError(
|
|
270
|
+
"Missing required input 'trace'. Suggestion: Provide a WaveformTrace to analyze",
|
|
271
|
+
step_name=step_name,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
window = params.get("window", "hann")
|
|
275
|
+
nfft = params.get("nfft")
|
|
276
|
+
fundamental_freq = params.get("fundamental_freq")
|
|
277
|
+
num_harmonics = params.get("num_harmonics", 5)
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
# Perform FFT
|
|
281
|
+
frequencies, magnitudes = spectral.fft(trace, window=window, nfft=nfft)
|
|
282
|
+
|
|
283
|
+
results = {
|
|
284
|
+
"frequencies": frequencies,
|
|
285
|
+
"magnitudes": magnitudes,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
# Calculate quality metrics if fundamental frequency provided
|
|
289
|
+
if fundamental_freq:
|
|
290
|
+
thd_value = spectral.thd(
|
|
291
|
+
trace, fundamental_freq=fundamental_freq, harmonics=num_harmonics
|
|
292
|
+
)
|
|
293
|
+
results["thd_db"] = thd_value
|
|
294
|
+
|
|
295
|
+
snr_value = spectral.snr(trace, fundamental_freq=fundamental_freq)
|
|
296
|
+
results["snr_db"] = snr_value
|
|
297
|
+
|
|
298
|
+
sinad_value = spectral.sinad(trace, fundamental_freq=fundamental_freq)
|
|
299
|
+
results["sinad_db"] = sinad_value
|
|
300
|
+
|
|
301
|
+
enob_value = spectral.enob(trace, fundamental_freq=fundamental_freq)
|
|
302
|
+
results["enob"] = enob_value
|
|
303
|
+
|
|
304
|
+
sfdr_value = spectral.sfdr(trace, fundamental_freq=fundamental_freq)
|
|
305
|
+
results["sfdr_db"] = sfdr_value
|
|
306
|
+
|
|
307
|
+
# Find fundamental frequency peak
|
|
308
|
+
freq_resolution = frequencies[1] - frequencies[0]
|
|
309
|
+
fund_idx = int(fundamental_freq / freq_resolution)
|
|
310
|
+
results["fundamental_idx"] = fund_idx
|
|
311
|
+
results["fundamental_power"] = float(magnitudes[fund_idx] ** 2)
|
|
312
|
+
|
|
313
|
+
except Exception as e:
|
|
314
|
+
raise PipelineExecutionError(f"Spectral analysis failed: {e}", step_name=step_name) from e
|
|
315
|
+
|
|
316
|
+
return results
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@register_handler("analysis.power")
|
|
320
|
+
def handle_analysis_power(
|
|
321
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
322
|
+
) -> dict[str, Any]:
|
|
323
|
+
"""Perform power analysis on voltage and current traces.
|
|
324
|
+
|
|
325
|
+
Inputs:
|
|
326
|
+
voltage: Voltage WaveformTrace
|
|
327
|
+
current: Current WaveformTrace
|
|
328
|
+
|
|
329
|
+
Parameters:
|
|
330
|
+
fundamental_freq (float, optional): Fundamental frequency for AC analysis
|
|
331
|
+
|
|
332
|
+
Outputs:
|
|
333
|
+
instantaneous_power: Power trace (voltage * current)
|
|
334
|
+
average_power: Average power in watts
|
|
335
|
+
peak_power: Peak instantaneous power
|
|
336
|
+
rms_power: RMS power
|
|
337
|
+
energy: Total energy in joules
|
|
338
|
+
active_power: Active (real) power in watts (if fundamental_freq provided)
|
|
339
|
+
reactive_power: Reactive power in VAR (if fundamental_freq provided)
|
|
340
|
+
apparent_power: Apparent power in VA (if fundamental_freq provided)
|
|
341
|
+
power_factor: Power factor (if fundamental_freq provided)
|
|
342
|
+
displacement_power_factor: Displacement power factor (if fundamental_freq provided)
|
|
343
|
+
distortion_power_factor: Distortion power factor (if fundamental_freq provided)
|
|
344
|
+
"""
|
|
345
|
+
power = _get_power()
|
|
346
|
+
|
|
347
|
+
voltage = inputs.get("voltage")
|
|
348
|
+
current = inputs.get("current")
|
|
349
|
+
|
|
350
|
+
if voltage is None or current is None:
|
|
351
|
+
raise PipelineExecutionError(
|
|
352
|
+
"Missing required inputs 'voltage' and 'current'. Suggestion: Provide both voltage and current WaveformTraces",
|
|
353
|
+
step_name=step_name,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
fundamental_freq = params.get("fundamental_freq")
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
# Basic power measurements
|
|
360
|
+
power_trace = power.instantaneous_power(voltage, current)
|
|
361
|
+
avg_power = power.average_power(voltage, current)
|
|
362
|
+
peak = power.peak_power(voltage, current)
|
|
363
|
+
rms_pwr = power.rms_power(voltage, current)
|
|
364
|
+
energy_val = power.energy(voltage, current)
|
|
365
|
+
|
|
366
|
+
results = {
|
|
367
|
+
"instantaneous_power": power_trace,
|
|
368
|
+
"average_power": avg_power,
|
|
369
|
+
"peak_power": peak,
|
|
370
|
+
"rms_power": rms_pwr,
|
|
371
|
+
"energy": energy_val,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
# AC power analysis if fundamental frequency provided
|
|
375
|
+
if fundamental_freq:
|
|
376
|
+
active_pwr = power.average_power(voltage, current) # Same as average
|
|
377
|
+
reactive_pwr = power.reactive_power(voltage, current, fundamental_freq)
|
|
378
|
+
apparent_pwr = power.apparent_power(voltage, current)
|
|
379
|
+
pf = power.power_factor(voltage, current, fundamental_freq)
|
|
380
|
+
dpf = power.displacement_power_factor(voltage, current, fundamental_freq)
|
|
381
|
+
distortion_pf = power.distortion_power_factor(voltage, current, fundamental_freq)
|
|
382
|
+
|
|
383
|
+
results.update(
|
|
384
|
+
{
|
|
385
|
+
"active_power": active_pwr,
|
|
386
|
+
"reactive_power": reactive_pwr,
|
|
387
|
+
"apparent_power": apparent_pwr,
|
|
388
|
+
"power_factor": pf,
|
|
389
|
+
"displacement_power_factor": dpf,
|
|
390
|
+
"distortion_power_factor": distortion_pf,
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
except Exception as e:
|
|
395
|
+
raise PipelineExecutionError(f"Power analysis failed: {e}", step_name=step_name) from e
|
|
396
|
+
|
|
397
|
+
return results
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@register_handler("analysis.timing")
|
|
401
|
+
def handle_analysis_timing(
|
|
402
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
403
|
+
) -> dict[str, Any]:
|
|
404
|
+
"""Perform IEEE 181 pulse timing measurements and clock recovery.
|
|
405
|
+
|
|
406
|
+
Inputs:
|
|
407
|
+
trace: WaveformTrace or DigitalTrace to analyze
|
|
408
|
+
|
|
409
|
+
Parameters:
|
|
410
|
+
method (str, optional): Clock recovery method
|
|
411
|
+
('zcd', 'histogram', 'autocorrelation', 'pll', 'fft')
|
|
412
|
+
Default: 'autocorrelation'
|
|
413
|
+
expected_freq (float, optional): Expected clock frequency for validation
|
|
414
|
+
|
|
415
|
+
Outputs:
|
|
416
|
+
detected_clock_rate: Recovered clock frequency in Hz
|
|
417
|
+
confidence: Clock recovery confidence (0-1)
|
|
418
|
+
jitter_rms: RMS jitter in seconds
|
|
419
|
+
drift_rate: Clock drift in ppm
|
|
420
|
+
snr_db: Signal-to-noise ratio in dB
|
|
421
|
+
method: Method used for clock recovery
|
|
422
|
+
statistics: Additional timing statistics
|
|
423
|
+
"""
|
|
424
|
+
trace = inputs.get("trace")
|
|
425
|
+
if trace is None:
|
|
426
|
+
raise PipelineExecutionError(
|
|
427
|
+
"Missing required input 'trace'. Suggestion: Provide a WaveformTrace or DigitalTrace to analyze",
|
|
428
|
+
step_name=step_name,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
method = params.get("method", "autocorrelation")
|
|
432
|
+
expected_freq = params.get("expected_freq")
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
# Import TimingAnalyzer
|
|
436
|
+
from oscura.analyzers.signal.timing_analysis import TimingAnalyzer
|
|
437
|
+
|
|
438
|
+
analyzer = TimingAnalyzer(method=method)
|
|
439
|
+
result = analyzer.recover_clock(trace.data, trace.metadata.sample_rate)
|
|
440
|
+
|
|
441
|
+
outputs = {
|
|
442
|
+
"detected_clock_rate": result.detected_clock_rate,
|
|
443
|
+
"confidence": result.confidence,
|
|
444
|
+
"jitter_rms": result.jitter_rms,
|
|
445
|
+
"drift_rate": result.drift_rate,
|
|
446
|
+
"snr_db": result.snr_db,
|
|
447
|
+
"method": result.method,
|
|
448
|
+
"statistics": result.statistics,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
# Validate against expected frequency if provided
|
|
452
|
+
if expected_freq:
|
|
453
|
+
freq_error_pct = abs(result.detected_clock_rate - expected_freq) / expected_freq * 100
|
|
454
|
+
outputs["frequency_error_percent"] = freq_error_pct
|
|
455
|
+
outputs["frequency_match"] = freq_error_pct < 1.0 # Within 1%
|
|
456
|
+
|
|
457
|
+
except Exception as e:
|
|
458
|
+
raise PipelineExecutionError(f"Timing analysis failed: {e}", step_name=step_name) from e
|
|
459
|
+
|
|
460
|
+
return outputs
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@register_handler("analysis.jitter")
|
|
464
|
+
def handle_analysis_jitter(
|
|
465
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
466
|
+
) -> dict[str, Any]:
|
|
467
|
+
"""Perform jitter analysis on digital signals.
|
|
468
|
+
|
|
469
|
+
Inputs:
|
|
470
|
+
trace: DigitalTrace or WaveformTrace to analyze
|
|
471
|
+
edges (array, optional): Pre-computed edge times
|
|
472
|
+
|
|
473
|
+
Parameters:
|
|
474
|
+
unit_interval (float, optional): Unit interval in seconds (required for TIE)
|
|
475
|
+
decompose (bool, optional): Perform jitter decomposition into RJ/DJ (default: False)
|
|
476
|
+
bathtub_ber (float, optional): Target BER for bathtub curve (e.g., 1e-12)
|
|
477
|
+
|
|
478
|
+
Outputs:
|
|
479
|
+
tie: Time interval error array (if unit_interval provided)
|
|
480
|
+
period_jitter: Period jitter in seconds
|
|
481
|
+
cycle_jitter: Cycle-to-cycle jitter in seconds
|
|
482
|
+
dcd: Duty cycle distortion in seconds
|
|
483
|
+
rj_rms: Random jitter RMS (if decompose=True)
|
|
484
|
+
dj_pp: Deterministic jitter peak-to-peak (if decompose=True)
|
|
485
|
+
tj_at_ber: Total jitter at target BER (if bathtub_ber provided)
|
|
486
|
+
bathtub_positions: Bathtub curve positions (if bathtub_ber provided)
|
|
487
|
+
bathtub_ber_values: Bathtub curve BER values (if bathtub_ber provided)
|
|
488
|
+
"""
|
|
489
|
+
jitter = _get_jitter()
|
|
490
|
+
|
|
491
|
+
trace = inputs.get("trace")
|
|
492
|
+
edges = inputs.get("edges")
|
|
493
|
+
|
|
494
|
+
if trace is None and edges is None:
|
|
495
|
+
raise PipelineExecutionError(
|
|
496
|
+
"Missing required input 'trace' or 'edges'. Suggestion: Provide either a trace or pre-computed edge times",
|
|
497
|
+
step_name=step_name,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
unit_interval = params.get("unit_interval")
|
|
501
|
+
decompose = params.get("decompose", False)
|
|
502
|
+
bathtub_ber = params.get("bathtub_ber")
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
results = {}
|
|
506
|
+
|
|
507
|
+
# Extract edges if not provided
|
|
508
|
+
if edges is None:
|
|
509
|
+
import numpy as np
|
|
510
|
+
|
|
511
|
+
from oscura.analyzers.digital.edges import detect_edges
|
|
512
|
+
|
|
513
|
+
# trace is guaranteed non-None here due to validation at line 494
|
|
514
|
+
assert trace is not None # Help mypy understand the guarantee
|
|
515
|
+
edge_list = detect_edges(trace.data, sample_rate=trace.metadata.sample_rate)
|
|
516
|
+
edges = np.array([edge.time for edge in edge_list])
|
|
517
|
+
|
|
518
|
+
# Period jitter
|
|
519
|
+
period_jitter_result = jitter.period_jitter(edges)
|
|
520
|
+
results["period_jitter"] = period_jitter_result.rms_jitter
|
|
521
|
+
|
|
522
|
+
# Cycle-to-cycle jitter
|
|
523
|
+
cycle_jitter_result = jitter.cycle_to_cycle_jitter(edges)
|
|
524
|
+
results["cycle_jitter"] = cycle_jitter_result.rms_jitter
|
|
525
|
+
|
|
526
|
+
# Duty cycle distortion
|
|
527
|
+
dcd_result = jitter.measure_dcd(edges)
|
|
528
|
+
results["dcd"] = dcd_result.dcd_mean
|
|
529
|
+
|
|
530
|
+
# TIE calculation if unit interval provided
|
|
531
|
+
if unit_interval:
|
|
532
|
+
tie = jitter.tie_from_edges(edges, unit_interval)
|
|
533
|
+
results["tie"] = tie
|
|
534
|
+
|
|
535
|
+
# Jitter decomposition
|
|
536
|
+
if decompose:
|
|
537
|
+
decomposition = jitter.decompose_jitter(tie)
|
|
538
|
+
results["rj_rms"] = decomposition.rj.rj_rms
|
|
539
|
+
results["dj_pp"] = decomposition.dj.dj_pp
|
|
540
|
+
|
|
541
|
+
# Total jitter at BER if requested
|
|
542
|
+
if bathtub_ber:
|
|
543
|
+
tj = jitter.tj_at_ber(
|
|
544
|
+
rj_rms=decomposition.rj.rj_rms,
|
|
545
|
+
dj_pp=decomposition.dj.dj_pp,
|
|
546
|
+
ber=bathtub_ber,
|
|
547
|
+
)
|
|
548
|
+
results["tj_at_ber"] = tj
|
|
549
|
+
|
|
550
|
+
# Bathtub curve
|
|
551
|
+
if bathtub_ber:
|
|
552
|
+
bathtub_result = jitter.bathtub_curve(tie, unit_interval)
|
|
553
|
+
results["bathtub_positions"] = bathtub_result.sample_positions
|
|
554
|
+
results["bathtub_ber_values"] = bathtub_result.ber_values
|
|
555
|
+
|
|
556
|
+
except Exception as e:
|
|
557
|
+
raise PipelineExecutionError(f"Jitter analysis failed: {e}", step_name=step_name) from e
|
|
558
|
+
|
|
559
|
+
return results
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@register_handler("analysis.statistics")
|
|
563
|
+
def handle_analysis_statistics(
|
|
564
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
565
|
+
) -> dict[str, Any]:
|
|
566
|
+
"""Perform statistical measurements on signal data.
|
|
567
|
+
|
|
568
|
+
Inputs:
|
|
569
|
+
trace: WaveformTrace to analyze
|
|
570
|
+
|
|
571
|
+
Parameters:
|
|
572
|
+
outliers (bool, optional): Detect outliers (default: False)
|
|
573
|
+
outlier_method (str, optional): Outlier detection method
|
|
574
|
+
('zscore', 'modified_zscore', 'iqr') (default: 'modified_zscore')
|
|
575
|
+
histogram_bins (int, optional): Number of histogram bins (default: 50)
|
|
576
|
+
percentiles (list[float], optional): Percentiles to calculate (default: [25, 50, 75])
|
|
577
|
+
|
|
578
|
+
Outputs:
|
|
579
|
+
mean: Mean value
|
|
580
|
+
std: Standard deviation
|
|
581
|
+
min: Minimum value
|
|
582
|
+
max: Maximum value
|
|
583
|
+
median: Median value
|
|
584
|
+
variance: Variance
|
|
585
|
+
skewness: Skewness
|
|
586
|
+
kurtosis: Kurtosis
|
|
587
|
+
percentiles: Dict of percentile values
|
|
588
|
+
histogram_counts: Histogram bin counts
|
|
589
|
+
histogram_edges: Histogram bin edges
|
|
590
|
+
outlier_indices: Indices of outliers (if outliers=True)
|
|
591
|
+
outlier_count: Number of outliers detected (if outliers=True)
|
|
592
|
+
"""
|
|
593
|
+
statistics = _get_statistics()
|
|
594
|
+
|
|
595
|
+
trace = inputs.get("trace")
|
|
596
|
+
if trace is None:
|
|
597
|
+
raise PipelineExecutionError(
|
|
598
|
+
"Missing required input 'trace'. Suggestion: Provide a WaveformTrace to analyze",
|
|
599
|
+
step_name=step_name,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
detect_outliers_flag = params.get("outliers", False)
|
|
603
|
+
outlier_method = params.get("outlier_method", "modified_zscore")
|
|
604
|
+
histogram_bins = params.get("histogram_bins", 50)
|
|
605
|
+
percentiles_list = params.get("percentiles", [25, 50, 75])
|
|
606
|
+
|
|
607
|
+
try:
|
|
608
|
+
# Basic statistics
|
|
609
|
+
stats = statistics.summary_stats(trace.data)
|
|
610
|
+
|
|
611
|
+
results = {
|
|
612
|
+
"mean": stats.mean,
|
|
613
|
+
"std": stats.std,
|
|
614
|
+
"min": stats.min,
|
|
615
|
+
"max": stats.max,
|
|
616
|
+
"median": stats.median,
|
|
617
|
+
"variance": stats.variance,
|
|
618
|
+
"skewness": stats.skewness,
|
|
619
|
+
"kurtosis": stats.kurtosis,
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
# Percentiles
|
|
623
|
+
percentile_values = statistics.percentiles(trace.data, percentiles_list)
|
|
624
|
+
results["percentiles"] = {
|
|
625
|
+
f"p{int(p)}": v for p, v in zip(percentiles_list, percentile_values, strict=True)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
# Histogram
|
|
629
|
+
hist_result = statistics.histogram(trace.data, bins=histogram_bins)
|
|
630
|
+
results["histogram_counts"] = hist_result.counts
|
|
631
|
+
results["histogram_edges"] = hist_result.edges
|
|
632
|
+
|
|
633
|
+
# Outlier detection
|
|
634
|
+
if detect_outliers_flag:
|
|
635
|
+
outlier_result = statistics.detect_outliers(trace.data, method=outlier_method)
|
|
636
|
+
results["outlier_indices"] = outlier_result.indices
|
|
637
|
+
results["outlier_count"] = len(outlier_result.indices)
|
|
638
|
+
|
|
639
|
+
except Exception as e:
|
|
640
|
+
raise PipelineExecutionError(
|
|
641
|
+
f"Statistical analysis failed: {e}", step_name=step_name
|
|
642
|
+
) from e
|
|
643
|
+
|
|
644
|
+
return results
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
@register_handler("analysis.fft")
|
|
648
|
+
def handle_analysis_fft(
|
|
649
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
650
|
+
) -> dict[str, Any]:
|
|
651
|
+
"""Perform FFT analysis returning frequency and magnitude arrays.
|
|
652
|
+
|
|
653
|
+
Inputs:
|
|
654
|
+
trace: WaveformTrace to analyze
|
|
655
|
+
|
|
656
|
+
Parameters:
|
|
657
|
+
window (str, optional): FFT window function (default: 'hann')
|
|
658
|
+
Options: 'hann', 'hamming', 'blackman', 'bartlett', 'kaiser', 'rectangular'
|
|
659
|
+
nfft (int, optional): FFT length (default: trace length)
|
|
660
|
+
return_phase (bool, optional): Also return phase information (default: False)
|
|
661
|
+
|
|
662
|
+
Outputs:
|
|
663
|
+
frequencies: Frequency array in Hz
|
|
664
|
+
magnitudes: Magnitude array (linear scale)
|
|
665
|
+
magnitudes_db: Magnitude array in dB (20*log10)
|
|
666
|
+
phases: Phase array in radians (if return_phase=True)
|
|
667
|
+
peak_freq: Frequency of peak magnitude
|
|
668
|
+
peak_magnitude: Peak magnitude value
|
|
669
|
+
"""
|
|
670
|
+
spectral = _get_spectral()
|
|
671
|
+
|
|
672
|
+
trace = inputs.get("trace")
|
|
673
|
+
if trace is None:
|
|
674
|
+
raise PipelineExecutionError(
|
|
675
|
+
"Missing required input 'trace'. Suggestion: Provide a WaveformTrace to analyze",
|
|
676
|
+
step_name=step_name,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
window = params.get("window", "hann")
|
|
680
|
+
nfft = params.get("nfft")
|
|
681
|
+
return_phase = params.get("return_phase", False)
|
|
682
|
+
|
|
683
|
+
try:
|
|
684
|
+
# Perform FFT
|
|
685
|
+
frequencies, magnitudes = spectral.fft(trace, window=window, nfft=nfft)
|
|
686
|
+
|
|
687
|
+
import numpy as np
|
|
688
|
+
|
|
689
|
+
# Convert to dB
|
|
690
|
+
magnitudes_db = 20 * np.log10(magnitudes + 1e-12) # Avoid log(0)
|
|
691
|
+
|
|
692
|
+
# Find peak
|
|
693
|
+
peak_idx = np.argmax(magnitudes)
|
|
694
|
+
peak_freq = float(frequencies[peak_idx])
|
|
695
|
+
peak_mag = float(magnitudes[peak_idx])
|
|
696
|
+
|
|
697
|
+
results = {
|
|
698
|
+
"frequencies": frequencies,
|
|
699
|
+
"magnitudes": magnitudes,
|
|
700
|
+
"magnitudes_db": magnitudes_db,
|
|
701
|
+
"peak_freq": peak_freq,
|
|
702
|
+
"peak_magnitude": peak_mag,
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
# Phase information if requested
|
|
706
|
+
if return_phase:
|
|
707
|
+
fft_result = np.fft.rfft(trace.data, n=nfft)
|
|
708
|
+
phases = np.angle(fft_result)
|
|
709
|
+
results["phases"] = phases
|
|
710
|
+
|
|
711
|
+
except Exception as e:
|
|
712
|
+
raise PipelineExecutionError(f"FFT analysis failed: {e}", step_name=step_name) from e
|
|
713
|
+
|
|
714
|
+
return results
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@register_handler("analysis.psd")
|
|
718
|
+
def handle_analysis_psd(
|
|
719
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
720
|
+
) -> dict[str, Any]:
|
|
721
|
+
"""Calculate power spectral density.
|
|
722
|
+
|
|
723
|
+
Inputs:
|
|
724
|
+
trace: WaveformTrace to analyze
|
|
725
|
+
|
|
726
|
+
Parameters:
|
|
727
|
+
method (str, optional): PSD estimation method ('periodogram', 'welch', 'bartlett')
|
|
728
|
+
Default: 'periodogram'
|
|
729
|
+
nfft (int, optional): FFT length for PSD calculation
|
|
730
|
+
window (str, optional): Window function (default: 'hann')
|
|
731
|
+
|
|
732
|
+
Outputs:
|
|
733
|
+
frequencies: Frequency array in Hz
|
|
734
|
+
psd: Power spectral density array (V^2/Hz or A^2/Hz)
|
|
735
|
+
psd_db: PSD in dB scale (10*log10)
|
|
736
|
+
total_power: Total power integrated across frequency
|
|
737
|
+
"""
|
|
738
|
+
spectral = _get_spectral()
|
|
739
|
+
|
|
740
|
+
trace = inputs.get("trace")
|
|
741
|
+
if trace is None:
|
|
742
|
+
raise PipelineExecutionError(
|
|
743
|
+
"Missing required input 'trace'. Suggestion: Provide a WaveformTrace to analyze",
|
|
744
|
+
step_name=step_name,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
method = params.get("method", "periodogram")
|
|
748
|
+
nfft = params.get("nfft")
|
|
749
|
+
window = params.get("window", "hann")
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
# Calculate PSD based on method
|
|
753
|
+
if method == "periodogram":
|
|
754
|
+
frequencies, psd_values = spectral.periodogram(trace, nfft=nfft, window=window)
|
|
755
|
+
elif method == "welch":
|
|
756
|
+
frequencies, psd_values = spectral.psd(trace, nfft=nfft, window=window)
|
|
757
|
+
elif method == "bartlett":
|
|
758
|
+
frequencies, psd_values = spectral.bartlett_psd(trace, nfft=nfft)
|
|
759
|
+
else:
|
|
760
|
+
raise ValueError(f"Unknown PSD method: {method}")
|
|
761
|
+
|
|
762
|
+
import numpy as np
|
|
763
|
+
|
|
764
|
+
# Convert to dB
|
|
765
|
+
psd_db = 10 * np.log10(psd_values + 1e-20) # Avoid log(0)
|
|
766
|
+
|
|
767
|
+
# Total power (integrate PSD)
|
|
768
|
+
freq_spacing = frequencies[1] - frequencies[0]
|
|
769
|
+
total_power = float(np.sum(psd_values) * freq_spacing)
|
|
770
|
+
|
|
771
|
+
results = {
|
|
772
|
+
"frequencies": frequencies,
|
|
773
|
+
"psd": psd_values,
|
|
774
|
+
"psd_db": psd_db,
|
|
775
|
+
"total_power": total_power,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
except Exception as e:
|
|
779
|
+
raise PipelineExecutionError(f"PSD calculation failed: {e}", step_name=step_name) from e
|
|
780
|
+
|
|
781
|
+
return results
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
@register_handler("analysis.thd")
|
|
785
|
+
def handle_analysis_thd(
|
|
786
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
787
|
+
) -> dict[str, Any]:
|
|
788
|
+
"""Calculate total harmonic distortion.
|
|
789
|
+
|
|
790
|
+
Inputs:
|
|
791
|
+
trace: WaveformTrace to analyze
|
|
792
|
+
|
|
793
|
+
Parameters:
|
|
794
|
+
fundamental_freq (float): Fundamental frequency in Hz
|
|
795
|
+
num_harmonics (int, optional): Number of harmonics to analyze (default: 5)
|
|
796
|
+
|
|
797
|
+
Outputs:
|
|
798
|
+
thd_db: Total harmonic distortion in dB
|
|
799
|
+
thd_percent: THD as percentage
|
|
800
|
+
harmonic_powers: Array of harmonic power values
|
|
801
|
+
harmonic_frequencies: Array of harmonic frequencies
|
|
802
|
+
fundamental_power: Power at fundamental frequency
|
|
803
|
+
"""
|
|
804
|
+
spectral = _get_spectral()
|
|
805
|
+
|
|
806
|
+
trace = inputs.get("trace")
|
|
807
|
+
if trace is None:
|
|
808
|
+
raise PipelineExecutionError(
|
|
809
|
+
"Missing required input 'trace'. Suggestion: Provide a WaveformTrace to analyze",
|
|
810
|
+
step_name=step_name,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
fundamental_freq = params.get("fundamental_freq")
|
|
814
|
+
if fundamental_freq is None:
|
|
815
|
+
raise PipelineExecutionError(
|
|
816
|
+
"Missing required parameter 'fundamental_freq'. Suggestion: Specify the fundamental frequency for THD calculation",
|
|
817
|
+
step_name=step_name,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
num_harmonics = params.get("num_harmonics", 5)
|
|
821
|
+
|
|
822
|
+
try:
|
|
823
|
+
# Calculate THD
|
|
824
|
+
thd_db = spectral.thd(trace, fundamental_freq=fundamental_freq, harmonics=num_harmonics)
|
|
825
|
+
|
|
826
|
+
# Convert to percentage
|
|
827
|
+
thd_percent = 100 * (10 ** (thd_db / 20))
|
|
828
|
+
|
|
829
|
+
# Get FFT to extract harmonic powers
|
|
830
|
+
frequencies, magnitudes = spectral.fft(trace)
|
|
831
|
+
|
|
832
|
+
freq_resolution = frequencies[1] - frequencies[0]
|
|
833
|
+
harmonic_powers = []
|
|
834
|
+
harmonic_frequencies = []
|
|
835
|
+
|
|
836
|
+
for h in range(1, num_harmonics + 2): # Include fundamental + harmonics
|
|
837
|
+
harmonic_freq = fundamental_freq * h
|
|
838
|
+
harmonic_idx = int(harmonic_freq / freq_resolution)
|
|
839
|
+
if harmonic_idx < len(magnitudes):
|
|
840
|
+
harmonic_powers.append(float(magnitudes[harmonic_idx] ** 2))
|
|
841
|
+
harmonic_frequencies.append(harmonic_freq)
|
|
842
|
+
|
|
843
|
+
fundamental_power = harmonic_powers[0] if harmonic_powers else 0.0
|
|
844
|
+
|
|
845
|
+
results = {
|
|
846
|
+
"thd_db": thd_db,
|
|
847
|
+
"thd_percent": thd_percent,
|
|
848
|
+
"harmonic_powers": harmonic_powers[1:], # Exclude fundamental
|
|
849
|
+
"harmonic_frequencies": harmonic_frequencies[1:],
|
|
850
|
+
"fundamental_power": fundamental_power,
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
except Exception as e:
|
|
854
|
+
raise PipelineExecutionError(f"THD calculation failed: {e}", step_name=step_name) from e
|
|
855
|
+
|
|
856
|
+
return results
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
@register_handler("analysis.eye_diagram")
|
|
860
|
+
def handle_analysis_eye_diagram(
|
|
861
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
862
|
+
) -> dict[str, Any]:
|
|
863
|
+
"""Generate and analyze eye diagram for digital signals.
|
|
864
|
+
|
|
865
|
+
Inputs:
|
|
866
|
+
trace: WaveformTrace or DigitalTrace to analyze
|
|
867
|
+
|
|
868
|
+
Parameters:
|
|
869
|
+
unit_interval (float): Unit interval (bit period) in seconds
|
|
870
|
+
samples_per_ui (int, optional): Samples per unit interval (default: 100)
|
|
871
|
+
threshold (float, optional): Decision threshold (default: auto)
|
|
872
|
+
|
|
873
|
+
Outputs:
|
|
874
|
+
eye_diagram: 2D array of eye diagram samples
|
|
875
|
+
eye_height: Eye height (voltage units)
|
|
876
|
+
eye_width: Eye width (time units)
|
|
877
|
+
eye_opening: Eye opening at center (voltage units)
|
|
878
|
+
q_factor: Q-factor (signal quality metric)
|
|
879
|
+
crossing_percentage: Percentage of ideal crossing point
|
|
880
|
+
jitter_rms: RMS jitter at threshold crossings
|
|
881
|
+
"""
|
|
882
|
+
eye = _get_eye()
|
|
883
|
+
|
|
884
|
+
trace = inputs.get("trace")
|
|
885
|
+
if trace is None:
|
|
886
|
+
raise PipelineExecutionError(
|
|
887
|
+
"Missing required input 'trace'. Suggestion: Provide a WaveformTrace or DigitalTrace to analyze",
|
|
888
|
+
step_name=step_name,
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
unit_interval = params.get("unit_interval")
|
|
892
|
+
if unit_interval is None:
|
|
893
|
+
raise PipelineExecutionError(
|
|
894
|
+
"Missing required parameter 'unit_interval'. Suggestion: Specify the unit interval (bit period) in seconds",
|
|
895
|
+
step_name=step_name,
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
samples_per_ui = params.get("samples_per_ui", 100)
|
|
899
|
+
threshold = params.get("threshold")
|
|
900
|
+
|
|
901
|
+
try:
|
|
902
|
+
# Generate eye diagram
|
|
903
|
+
eye_result = eye.generate_eye(trace, unit_interval, samples_per_ui=samples_per_ui)
|
|
904
|
+
|
|
905
|
+
# Calculate eye metrics
|
|
906
|
+
eye_metrics = eye.measure_eye(eye_result, threshold=threshold)
|
|
907
|
+
|
|
908
|
+
results = {
|
|
909
|
+
"eye_diagram": eye_result.data,
|
|
910
|
+
"eye_height": eye_metrics.height,
|
|
911
|
+
"eye_width": eye_metrics.width,
|
|
912
|
+
"eye_opening": eye_metrics.opening,
|
|
913
|
+
"q_factor": eye_metrics.q_factor,
|
|
914
|
+
"crossing_percentage": eye_metrics.crossing_percentage,
|
|
915
|
+
"jitter_rms": eye_metrics.jitter_rms,
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
except Exception as e:
|
|
919
|
+
raise PipelineExecutionError(
|
|
920
|
+
f"Eye diagram analysis failed: {e}", step_name=step_name
|
|
921
|
+
) from e
|
|
922
|
+
|
|
923
|
+
return results
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
@register_handler("analysis.auto")
|
|
927
|
+
def handle_analysis_auto(
|
|
928
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
929
|
+
) -> dict[str, Any]:
|
|
930
|
+
"""Auto-detect signal type and perform appropriate analysis.
|
|
931
|
+
|
|
932
|
+
Automatically determines signal characteristics and performs relevant measurements.
|
|
933
|
+
|
|
934
|
+
Inputs:
|
|
935
|
+
trace: WaveformTrace or DigitalTrace to analyze
|
|
936
|
+
|
|
937
|
+
Parameters:
|
|
938
|
+
detailed (bool, optional): Include all available measurements (default: False)
|
|
939
|
+
|
|
940
|
+
Outputs:
|
|
941
|
+
signal_type: Detected signal type ('analog', 'digital', 'mixed')
|
|
942
|
+
frequency: Dominant frequency if detected
|
|
943
|
+
measurements: Dict of relevant measurements
|
|
944
|
+
recommendations: List of suggested additional analyses
|
|
945
|
+
"""
|
|
946
|
+
waveform = _get_waveform()
|
|
947
|
+
statistics = _get_statistics()
|
|
948
|
+
|
|
949
|
+
trace = inputs.get("trace")
|
|
950
|
+
if trace is None:
|
|
951
|
+
raise PipelineExecutionError(
|
|
952
|
+
"Missing required input 'trace'. Suggestion: Provide a trace to analyze",
|
|
953
|
+
step_name=step_name,
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
detailed = params.get("detailed", False)
|
|
957
|
+
|
|
958
|
+
try:
|
|
959
|
+
import numpy as np
|
|
960
|
+
|
|
961
|
+
# Detect signal type
|
|
962
|
+
data = trace.data
|
|
963
|
+
unique_values = len(np.unique(data))
|
|
964
|
+
data_range = np.ptp(data)
|
|
965
|
+
|
|
966
|
+
# Simple heuristic: digital if few unique values
|
|
967
|
+
if unique_values <= 10 and data_range > 0:
|
|
968
|
+
signal_type = "digital"
|
|
969
|
+
elif unique_values > 10:
|
|
970
|
+
signal_type = "analog"
|
|
971
|
+
else:
|
|
972
|
+
signal_type = "unknown"
|
|
973
|
+
|
|
974
|
+
measurements = {}
|
|
975
|
+
recommendations = []
|
|
976
|
+
|
|
977
|
+
# Frequency detection
|
|
978
|
+
try:
|
|
979
|
+
freq = waveform.frequency(trace)
|
|
980
|
+
measurements["frequency"] = freq
|
|
981
|
+
except Exception:
|
|
982
|
+
# Best-effort: frequency detection may fail on noisy/aperiodic signals
|
|
983
|
+
freq = None
|
|
984
|
+
|
|
985
|
+
# Basic statistics
|
|
986
|
+
stats = statistics.summary_stats(data)
|
|
987
|
+
measurements.update(
|
|
988
|
+
{
|
|
989
|
+
"mean": stats.mean,
|
|
990
|
+
"std": stats.std,
|
|
991
|
+
"min": stats.min,
|
|
992
|
+
"max": stats.max,
|
|
993
|
+
}
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
# Additional measurements based on signal type
|
|
997
|
+
if signal_type == "analog":
|
|
998
|
+
measurements["amplitude"] = waveform.amplitude(trace)
|
|
999
|
+
measurements["rms"] = waveform.rms(trace)
|
|
1000
|
+
|
|
1001
|
+
if detailed:
|
|
1002
|
+
try:
|
|
1003
|
+
measurements["rise_time"] = waveform.rise_time(trace)
|
|
1004
|
+
measurements["fall_time"] = waveform.fall_time(trace)
|
|
1005
|
+
except Exception:
|
|
1006
|
+
# Best-effort: rise/fall time may fail on slow/noisy edges
|
|
1007
|
+
pass
|
|
1008
|
+
|
|
1009
|
+
recommendations.extend(
|
|
1010
|
+
[
|
|
1011
|
+
"analysis.spectral - for frequency domain analysis",
|
|
1012
|
+
"analysis.waveform - for detailed timing measurements",
|
|
1013
|
+
]
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
elif signal_type == "digital":
|
|
1017
|
+
try:
|
|
1018
|
+
measurements["duty_cycle"] = waveform.duty_cycle(trace)
|
|
1019
|
+
except Exception:
|
|
1020
|
+
# Best-effort: duty cycle may fail on aperiodic signals
|
|
1021
|
+
pass
|
|
1022
|
+
|
|
1023
|
+
recommendations.extend(
|
|
1024
|
+
[
|
|
1025
|
+
"analysis.jitter - for jitter analysis",
|
|
1026
|
+
"analysis.eye_diagram - if data rate is known",
|
|
1027
|
+
"decoder.auto - to identify protocol",
|
|
1028
|
+
]
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
# Suggest power analysis if signal looks like voltage or current
|
|
1032
|
+
if data_range > 0.1: # Arbitrary threshold
|
|
1033
|
+
recommendations.append("analysis.power - if you have both voltage and current traces")
|
|
1034
|
+
|
|
1035
|
+
results = {
|
|
1036
|
+
"signal_type": signal_type,
|
|
1037
|
+
"frequency": freq,
|
|
1038
|
+
"measurements": measurements,
|
|
1039
|
+
"recommendations": recommendations,
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
except Exception as e:
|
|
1043
|
+
raise PipelineExecutionError(f"Auto-analysis failed: {e}", step_name=step_name) from e
|
|
1044
|
+
|
|
1045
|
+
return results
|