oscura 0.8.0__py3-none-any.whl → 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oscura/__init__.py +19 -19
- oscura/__main__.py +4 -0
- oscura/analyzers/__init__.py +2 -0
- oscura/analyzers/digital/extraction.py +2 -3
- oscura/analyzers/digital/quality.py +1 -1
- oscura/analyzers/digital/timing.py +1 -1
- oscura/analyzers/ml/signal_classifier.py +6 -0
- oscura/analyzers/patterns/__init__.py +66 -0
- oscura/analyzers/power/basic.py +3 -3
- oscura/analyzers/power/soa.py +1 -1
- oscura/analyzers/power/switching.py +3 -3
- oscura/analyzers/signal_classification.py +529 -0
- oscura/analyzers/signal_integrity/sparams.py +3 -3
- oscura/analyzers/statistics/basic.py +10 -7
- oscura/analyzers/validation.py +1 -1
- oscura/analyzers/waveform/measurements.py +200 -156
- oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
- oscura/analyzers/waveform/spectral.py +182 -84
- oscura/api/dsl/commands.py +15 -6
- oscura/api/server/templates/base.html +137 -146
- oscura/api/server/templates/export.html +84 -110
- oscura/api/server/templates/home.html +248 -267
- oscura/api/server/templates/protocols.html +44 -48
- oscura/api/server/templates/reports.html +27 -35
- oscura/api/server/templates/session_detail.html +68 -78
- oscura/api/server/templates/sessions.html +62 -72
- oscura/api/server/templates/waveforms.html +54 -64
- oscura/automotive/__init__.py +1 -1
- oscura/automotive/can/session.py +1 -1
- oscura/automotive/dbc/generator.py +638 -23
- oscura/automotive/dtc/data.json +17 -102
- oscura/automotive/flexray/fibex.py +9 -1
- oscura/automotive/uds/decoder.py +99 -6
- oscura/cli/analyze.py +8 -2
- oscura/cli/batch.py +36 -5
- oscura/cli/characterize.py +18 -4
- oscura/cli/export.py +47 -5
- oscura/cli/main.py +2 -0
- oscura/cli/onboarding/wizard.py +10 -6
- oscura/cli/pipeline.py +585 -0
- oscura/cli/visualize.py +6 -4
- oscura/convenience.py +400 -32
- oscura/core/measurement_result.py +286 -0
- oscura/core/progress.py +1 -1
- oscura/core/schemas/device_mapping.json +2 -8
- oscura/core/schemas/packet_format.json +4 -24
- oscura/core/schemas/protocol_definition.json +2 -12
- oscura/core/types.py +232 -239
- oscura/correlation/multi_protocol.py +1 -1
- oscura/export/legacy/__init__.py +11 -0
- oscura/export/legacy/wav.py +75 -0
- oscura/exporters/__init__.py +19 -0
- oscura/exporters/wireshark.py +809 -0
- oscura/hardware/acquisition/file.py +5 -19
- oscura/hardware/acquisition/saleae.py +10 -10
- oscura/hardware/acquisition/socketcan.py +4 -6
- oscura/hardware/acquisition/synthetic.py +1 -5
- oscura/hardware/acquisition/visa.py +6 -6
- oscura/hardware/security/side_channel_detector.py +5 -508
- oscura/inference/message_format.py +686 -1
- oscura/jupyter/display.py +2 -2
- oscura/jupyter/magic.py +3 -3
- oscura/loaders/__init__.py +17 -12
- oscura/loaders/binary.py +1 -1
- oscura/loaders/chipwhisperer.py +1 -2
- oscura/loaders/configurable.py +1 -1
- oscura/loaders/csv_loader.py +2 -2
- oscura/loaders/hdf5_loader.py +1 -1
- oscura/loaders/lazy.py +6 -1
- oscura/loaders/mmap_loader.py +0 -1
- oscura/loaders/numpy_loader.py +8 -7
- oscura/loaders/preprocessing.py +3 -5
- oscura/loaders/rigol.py +21 -7
- oscura/loaders/sigrok.py +2 -5
- oscura/loaders/tdms.py +3 -2
- oscura/loaders/tektronix.py +38 -32
- oscura/loaders/tss.py +20 -27
- oscura/loaders/validation.py +17 -10
- oscura/loaders/vcd.py +13 -8
- oscura/loaders/wav.py +1 -6
- oscura/pipeline/__init__.py +76 -0
- oscura/pipeline/handlers/__init__.py +165 -0
- oscura/pipeline/handlers/analyzers.py +1045 -0
- oscura/pipeline/handlers/decoders.py +899 -0
- oscura/pipeline/handlers/exporters.py +1103 -0
- oscura/pipeline/handlers/filters.py +891 -0
- oscura/pipeline/handlers/loaders.py +640 -0
- oscura/pipeline/handlers/transforms.py +768 -0
- oscura/reporting/formatting/measurements.py +55 -14
- oscura/reporting/templates/enhanced/protocol_re.html +504 -503
- oscura/sessions/legacy.py +49 -1
- oscura/side_channel/__init__.py +38 -57
- oscura/utils/builders/signal_builder.py +5 -5
- oscura/utils/comparison/compare.py +7 -9
- oscura/utils/comparison/golden.py +1 -1
- oscura/utils/filtering/convenience.py +2 -2
- oscura/utils/math/arithmetic.py +38 -62
- oscura/utils/math/interpolation.py +20 -20
- oscura/utils/pipeline/__init__.py +4 -17
- oscura/utils/progressive.py +1 -4
- oscura/utils/triggering/edge.py +1 -1
- oscura/utils/triggering/pattern.py +2 -2
- oscura/utils/triggering/pulse.py +2 -2
- oscura/utils/triggering/window.py +3 -3
- oscura/validation/hil_testing.py +11 -11
- oscura/visualization/__init__.py +46 -284
- oscura/visualization/batch.py +72 -433
- oscura/visualization/plot.py +542 -53
- oscura/visualization/styles.py +184 -318
- oscura/workflows/batch/advanced.py +1 -1
- oscura/workflows/batch/aggregate.py +12 -9
- oscura/workflows/complete_re.py +251 -23
- oscura/workflows/digital.py +27 -4
- oscura/workflows/multi_trace.py +136 -17
- oscura/workflows/waveform.py +11 -6
- oscura-0.11.0.dist-info/METADATA +460 -0
- {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/RECORD +120 -145
- oscura/side_channel/dpa.py +0 -1025
- oscura/utils/optimization/__init__.py +0 -19
- oscura/utils/optimization/parallel.py +0 -443
- oscura/utils/optimization/search.py +0 -532
- oscura/utils/pipeline/base.py +0 -338
- oscura/utils/pipeline/composition.py +0 -248
- oscura/utils/pipeline/parallel.py +0 -449
- oscura/utils/pipeline/pipeline.py +0 -375
- oscura/utils/search/__init__.py +0 -16
- oscura/utils/search/anomaly.py +0 -424
- oscura/utils/search/context.py +0 -294
- oscura/utils/search/pattern.py +0 -288
- oscura/utils/storage/__init__.py +0 -61
- oscura/utils/storage/database.py +0 -1166
- oscura/visualization/accessibility.py +0 -526
- oscura/visualization/annotations.py +0 -371
- oscura/visualization/axis_scaling.py +0 -305
- oscura/visualization/colors.py +0 -451
- oscura/visualization/digital.py +0 -436
- oscura/visualization/eye.py +0 -571
- oscura/visualization/histogram.py +0 -281
- oscura/visualization/interactive.py +0 -1035
- oscura/visualization/jitter.py +0 -1042
- oscura/visualization/keyboard.py +0 -394
- oscura/visualization/layout.py +0 -400
- oscura/visualization/optimization.py +0 -1079
- oscura/visualization/palettes.py +0 -446
- oscura/visualization/power.py +0 -508
- oscura/visualization/power_extended.py +0 -955
- oscura/visualization/presets.py +0 -469
- oscura/visualization/protocols.py +0 -1246
- oscura/visualization/render.py +0 -223
- oscura/visualization/rendering.py +0 -444
- oscura/visualization/reverse_engineering.py +0 -838
- oscura/visualization/signal_integrity.py +0 -989
- oscura/visualization/specialized.py +0 -643
- oscura/visualization/spectral.py +0 -1226
- oscura/visualization/thumbnails.py +0 -340
- oscura/visualization/time_axis.py +0 -351
- oscura/visualization/waveform.py +0 -454
- oscura-0.8.0.dist-info/METADATA +0 -661
- {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/WHEEL +0 -0
- {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
"""Signal transform handlers for pipeline system.
|
|
2
|
+
|
|
3
|
+
This module provides handlers for signal processing transformations.
|
|
4
|
+
All handlers follow the standard signature: (inputs, params, step_name) -> outputs.
|
|
5
|
+
|
|
6
|
+
Available Handlers:
|
|
7
|
+
- transform.resample: Resample signal to new sample rate
|
|
8
|
+
- transform.offset: Add DC offset to signal
|
|
9
|
+
- transform.scale: Scale signal amplitude
|
|
10
|
+
- transform.invert: Invert signal polarity
|
|
11
|
+
- transform.abs: Compute absolute value
|
|
12
|
+
- transform.derivative: Calculate time derivative
|
|
13
|
+
- transform.integral: Calculate time integral
|
|
14
|
+
- transform.math: Generic math operations between traces
|
|
15
|
+
- transform.window: Apply window function
|
|
16
|
+
- transform.normalize: Normalize to specified range
|
|
17
|
+
- transform.clip: Clip signal to value range
|
|
18
|
+
- transform.rectify: Rectify signal (half-wave or full-wave)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from oscura.core.config.pipeline import PipelineExecutionError
|
|
28
|
+
from oscura.core.types import WaveformTrace
|
|
29
|
+
from oscura.pipeline.handlers import register_handler
|
|
30
|
+
|
|
31
|
+
# Lazy imports to avoid circular dependencies
|
|
32
|
+
_interpolation = None
|
|
33
|
+
_windowing = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_interpolation() -> Any:
|
|
37
|
+
"""Lazy import interpolation module."""
|
|
38
|
+
global _interpolation
|
|
39
|
+
if _interpolation is None:
|
|
40
|
+
from oscura.utils.math import interpolation as _interp_module
|
|
41
|
+
|
|
42
|
+
_interpolation = _interp_module
|
|
43
|
+
return _interpolation
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_windowing() -> Any:
|
|
47
|
+
"""Lazy import windowing module."""
|
|
48
|
+
global _windowing
|
|
49
|
+
if _windowing is None:
|
|
50
|
+
from oscura.utils import windowing as _window_module
|
|
51
|
+
|
|
52
|
+
_windowing = _window_module
|
|
53
|
+
return _windowing
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@register_handler("transform.resample")
|
|
57
|
+
def handle_transform_resample(
|
|
58
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
"""Resample signal to new sample rate.
|
|
61
|
+
|
|
62
|
+
Uses high-quality polyphase resampling (scipy.signal.resample_poly) for
|
|
63
|
+
integer rate changes, or linear interpolation for arbitrary rates.
|
|
64
|
+
|
|
65
|
+
Inputs:
|
|
66
|
+
trace: WaveformTrace to resample
|
|
67
|
+
|
|
68
|
+
Parameters:
|
|
69
|
+
sample_rate (float): Target sample rate in Hz
|
|
70
|
+
method (str, optional): Resampling method ('auto', 'polyphase', 'interpolate')
|
|
71
|
+
(default: 'auto')
|
|
72
|
+
|
|
73
|
+
Outputs:
|
|
74
|
+
trace: Resampled WaveformTrace
|
|
75
|
+
original_sample_rate: Original sample rate in Hz
|
|
76
|
+
new_sample_rate: New sample rate in Hz
|
|
77
|
+
num_samples: Number of samples in resampled trace
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
>>> # Downsample from 1 MHz to 100 kHz
|
|
81
|
+
>>> params = {"sample_rate": 100e3}
|
|
82
|
+
>>> result = handle_transform_resample({"trace": trace}, params, "downsample")
|
|
83
|
+
"""
|
|
84
|
+
interpolation = _get_interpolation()
|
|
85
|
+
|
|
86
|
+
trace = inputs.get("trace")
|
|
87
|
+
if trace is None:
|
|
88
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
89
|
+
|
|
90
|
+
new_sample_rate = params.get("sample_rate")
|
|
91
|
+
if new_sample_rate is None:
|
|
92
|
+
raise PipelineExecutionError(
|
|
93
|
+
"Missing required parameter 'sample_rate'. Add 'sample_rate: 1000000' (in Hz) to params",
|
|
94
|
+
step_name=step_name,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
method = params.get("method", "auto")
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
# Use resample utility from interpolation module
|
|
101
|
+
resampled = interpolation.resample(trace, new_sample_rate=new_sample_rate, method=method)
|
|
102
|
+
original_rate = trace.metadata.sample_rate
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
raise PipelineExecutionError(f"Resampling failed: {e}", step_name=step_name) from e
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"trace": resampled,
|
|
109
|
+
"original_sample_rate": original_rate,
|
|
110
|
+
"new_sample_rate": new_sample_rate,
|
|
111
|
+
"num_samples": len(resampled.data),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@register_handler("transform.offset")
|
|
116
|
+
def handle_transform_offset(
|
|
117
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Add DC offset to signal.
|
|
120
|
+
|
|
121
|
+
Adds a constant value to all samples in the trace.
|
|
122
|
+
|
|
123
|
+
Inputs:
|
|
124
|
+
trace: WaveformTrace to offset
|
|
125
|
+
|
|
126
|
+
Parameters:
|
|
127
|
+
offset (float): DC offset value to add
|
|
128
|
+
|
|
129
|
+
Outputs:
|
|
130
|
+
trace: Offset WaveformTrace
|
|
131
|
+
offset: Applied offset value
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
>>> # Remove 2.5V DC bias
|
|
135
|
+
>>> params = {"offset": -2.5}
|
|
136
|
+
>>> result = handle_transform_offset({"trace": trace}, params, "remove_bias")
|
|
137
|
+
"""
|
|
138
|
+
trace = inputs.get("trace")
|
|
139
|
+
if trace is None:
|
|
140
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
141
|
+
|
|
142
|
+
offset = params.get("offset")
|
|
143
|
+
if offset is None:
|
|
144
|
+
raise PipelineExecutionError(
|
|
145
|
+
"Missing required parameter 'offset'. Add 'offset: 1.5' to params",
|
|
146
|
+
step_name=step_name,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
# Apply offset
|
|
151
|
+
new_data = trace.data + offset
|
|
152
|
+
new_trace = WaveformTrace(data=new_data, metadata=trace.metadata)
|
|
153
|
+
|
|
154
|
+
except Exception as e:
|
|
155
|
+
raise PipelineExecutionError(f"Offset failed: {e}", step_name=step_name) from e
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"trace": new_trace,
|
|
159
|
+
"offset": offset,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@register_handler("transform.scale")
|
|
164
|
+
def handle_transform_scale(
|
|
165
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
"""Scale signal amplitude.
|
|
168
|
+
|
|
169
|
+
Multiplies all samples by a constant factor.
|
|
170
|
+
|
|
171
|
+
Inputs:
|
|
172
|
+
trace: WaveformTrace to scale
|
|
173
|
+
|
|
174
|
+
Parameters:
|
|
175
|
+
factor (float): Scale factor to apply
|
|
176
|
+
unit (str, optional): Updated unit string after scaling
|
|
177
|
+
|
|
178
|
+
Outputs:
|
|
179
|
+
trace: Scaled WaveformTrace
|
|
180
|
+
factor: Applied scale factor
|
|
181
|
+
|
|
182
|
+
Example:
|
|
183
|
+
>>> # Convert 10x probe to 1x
|
|
184
|
+
>>> params = {"factor": 10.0, "unit": "V"}
|
|
185
|
+
>>> result = handle_transform_scale({"trace": trace}, params, "probe_scale")
|
|
186
|
+
"""
|
|
187
|
+
trace = inputs.get("trace")
|
|
188
|
+
if trace is None:
|
|
189
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
190
|
+
|
|
191
|
+
factor = params.get("factor")
|
|
192
|
+
if factor is None:
|
|
193
|
+
raise PipelineExecutionError(
|
|
194
|
+
"Missing required parameter 'factor'. Add 'factor: 2.0' to params",
|
|
195
|
+
step_name=step_name,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
# Apply scaling
|
|
200
|
+
new_data = trace.data * factor
|
|
201
|
+
new_metadata = trace.metadata
|
|
202
|
+
if "unit" in params:
|
|
203
|
+
# Update unit if provided (shallow copy to avoid mutation)
|
|
204
|
+
from dataclasses import replace
|
|
205
|
+
|
|
206
|
+
new_metadata = replace(new_metadata, unit=params["unit"])
|
|
207
|
+
|
|
208
|
+
new_trace = WaveformTrace(data=new_data, metadata=new_metadata)
|
|
209
|
+
|
|
210
|
+
except Exception as e:
|
|
211
|
+
raise PipelineExecutionError(f"Scaling failed: {e}", step_name=step_name) from e
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
"trace": new_trace,
|
|
215
|
+
"factor": factor,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@register_handler("transform.invert")
|
|
220
|
+
def handle_transform_invert(
|
|
221
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
222
|
+
) -> dict[str, Any]:
|
|
223
|
+
"""Invert signal polarity.
|
|
224
|
+
|
|
225
|
+
Multiplies all samples by -1.
|
|
226
|
+
|
|
227
|
+
Inputs:
|
|
228
|
+
trace: WaveformTrace or DigitalTrace to invert
|
|
229
|
+
|
|
230
|
+
Parameters:
|
|
231
|
+
None
|
|
232
|
+
|
|
233
|
+
Outputs:
|
|
234
|
+
trace: Inverted trace
|
|
235
|
+
|
|
236
|
+
Example:
|
|
237
|
+
>>> # Invert UART idle-low to idle-high
|
|
238
|
+
>>> result = handle_transform_invert({"trace": trace}, {}, "invert_uart")
|
|
239
|
+
"""
|
|
240
|
+
trace = inputs.get("trace")
|
|
241
|
+
if trace is None:
|
|
242
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
# Invert data
|
|
246
|
+
new_data = -trace.data
|
|
247
|
+
new_trace = type(trace)(data=new_data, metadata=trace.metadata)
|
|
248
|
+
|
|
249
|
+
except Exception as e:
|
|
250
|
+
raise PipelineExecutionError(f"Inversion failed: {e}", step_name=step_name) from e
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"trace": new_trace,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@register_handler("transform.abs")
|
|
258
|
+
def handle_transform_abs(
|
|
259
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
260
|
+
) -> dict[str, Any]:
|
|
261
|
+
"""Compute absolute value of signal.
|
|
262
|
+
|
|
263
|
+
Takes the absolute value of all samples.
|
|
264
|
+
|
|
265
|
+
Inputs:
|
|
266
|
+
trace: WaveformTrace to process
|
|
267
|
+
|
|
268
|
+
Parameters:
|
|
269
|
+
None
|
|
270
|
+
|
|
271
|
+
Outputs:
|
|
272
|
+
trace: Absolute value WaveformTrace
|
|
273
|
+
|
|
274
|
+
Example:
|
|
275
|
+
>>> # Get envelope of AC signal
|
|
276
|
+
>>> result = handle_transform_abs({"trace": trace}, {}, "abs_envelope")
|
|
277
|
+
"""
|
|
278
|
+
trace = inputs.get("trace")
|
|
279
|
+
if trace is None:
|
|
280
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
# Compute absolute value
|
|
284
|
+
new_data = np.abs(trace.data)
|
|
285
|
+
new_trace = WaveformTrace(data=new_data, metadata=trace.metadata)
|
|
286
|
+
|
|
287
|
+
except Exception as e:
|
|
288
|
+
raise PipelineExecutionError(f"Absolute value failed: {e}", step_name=step_name) from e
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
"trace": new_trace,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@register_handler("transform.derivative")
|
|
296
|
+
def handle_transform_derivative(
|
|
297
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
298
|
+
) -> dict[str, Any]:
|
|
299
|
+
"""Calculate time derivative of signal.
|
|
300
|
+
|
|
301
|
+
Computes numerical derivative (dV/dt) using numpy.gradient for centered differences.
|
|
302
|
+
|
|
303
|
+
Inputs:
|
|
304
|
+
trace: WaveformTrace to differentiate
|
|
305
|
+
|
|
306
|
+
Parameters:
|
|
307
|
+
None
|
|
308
|
+
|
|
309
|
+
Outputs:
|
|
310
|
+
trace: Derivative WaveformTrace
|
|
311
|
+
unit: Derivative unit string (e.g., "V/s")
|
|
312
|
+
|
|
313
|
+
Example:
|
|
314
|
+
>>> # Find slew rate
|
|
315
|
+
>>> result = handle_transform_derivative({"trace": trace}, {}, "slew_rate")
|
|
316
|
+
"""
|
|
317
|
+
trace = inputs.get("trace")
|
|
318
|
+
if trace is None:
|
|
319
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
# Compute derivative using centered differences
|
|
323
|
+
dt = 1.0 / trace.metadata.sample_rate
|
|
324
|
+
derivative = np.gradient(trace.data, dt)
|
|
325
|
+
|
|
326
|
+
# Update unit (V -> V/s)
|
|
327
|
+
from dataclasses import replace
|
|
328
|
+
|
|
329
|
+
original_unit = getattr(trace.metadata, "unit", "")
|
|
330
|
+
new_unit = f"{original_unit}/s" if original_unit else "1/s"
|
|
331
|
+
new_metadata = replace(trace.metadata, unit=new_unit)
|
|
332
|
+
|
|
333
|
+
new_trace = WaveformTrace(data=derivative, metadata=new_metadata)
|
|
334
|
+
|
|
335
|
+
except Exception as e:
|
|
336
|
+
raise PipelineExecutionError(f"Derivative failed: {e}", step_name=step_name) from e
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
"trace": new_trace,
|
|
340
|
+
"unit": new_unit,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@register_handler("transform.integral")
|
|
345
|
+
def handle_transform_integral(
|
|
346
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
347
|
+
) -> dict[str, Any]:
|
|
348
|
+
"""Calculate time integral of signal.
|
|
349
|
+
|
|
350
|
+
Computes cumulative integral using trapezoidal rule.
|
|
351
|
+
|
|
352
|
+
Inputs:
|
|
353
|
+
trace: WaveformTrace to integrate
|
|
354
|
+
|
|
355
|
+
Parameters:
|
|
356
|
+
initial_value (float, optional): Initial integration constant (default: 0.0)
|
|
357
|
+
|
|
358
|
+
Outputs:
|
|
359
|
+
trace: Integrated WaveformTrace
|
|
360
|
+
unit: Integral unit string (e.g., "V·s")
|
|
361
|
+
|
|
362
|
+
Example:
|
|
363
|
+
>>> # Integrate current to get charge
|
|
364
|
+
>>> params = {"initial_value": 0.0}
|
|
365
|
+
>>> result = handle_transform_integral({"trace": trace}, params, "charge")
|
|
366
|
+
"""
|
|
367
|
+
trace = inputs.get("trace")
|
|
368
|
+
if trace is None:
|
|
369
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
370
|
+
|
|
371
|
+
initial_value = params.get("initial_value", 0.0)
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
# Compute integral using trapezoidal rule
|
|
375
|
+
dt = 1.0 / trace.metadata.sample_rate
|
|
376
|
+
integral = np.cumsum(trace.data) * dt + initial_value
|
|
377
|
+
|
|
378
|
+
# Update unit (V -> V·s)
|
|
379
|
+
from dataclasses import replace
|
|
380
|
+
|
|
381
|
+
original_unit = getattr(trace.metadata, "unit", "")
|
|
382
|
+
new_unit = f"{original_unit}·s" if original_unit else "s"
|
|
383
|
+
new_metadata = replace(trace.metadata, unit=new_unit)
|
|
384
|
+
|
|
385
|
+
new_trace = WaveformTrace(data=integral, metadata=new_metadata)
|
|
386
|
+
|
|
387
|
+
except Exception as e:
|
|
388
|
+
raise PipelineExecutionError(f"Integration failed: {e}", step_name=step_name) from e
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
"trace": new_trace,
|
|
392
|
+
"unit": new_unit,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@register_handler("transform.math")
|
|
397
|
+
def handle_transform_math(
|
|
398
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
399
|
+
) -> dict[str, Any]:
|
|
400
|
+
"""Generic math operations between two traces.
|
|
401
|
+
|
|
402
|
+
Performs element-wise arithmetic operations (add, subtract, multiply, divide)
|
|
403
|
+
between two traces. Traces must have same length or one will be interpolated.
|
|
404
|
+
|
|
405
|
+
Inputs:
|
|
406
|
+
trace1: First WaveformTrace
|
|
407
|
+
trace2: Second WaveformTrace
|
|
408
|
+
|
|
409
|
+
Parameters:
|
|
410
|
+
operation (str): Operation to perform ('add', 'subtract', 'multiply', 'divide')
|
|
411
|
+
auto_align (bool, optional): Auto-align traces if sample rates differ (default: True)
|
|
412
|
+
|
|
413
|
+
Outputs:
|
|
414
|
+
trace: Result WaveformTrace
|
|
415
|
+
operation: Operation performed
|
|
416
|
+
|
|
417
|
+
Example:
|
|
418
|
+
>>> # Compute differential signal
|
|
419
|
+
>>> params = {"operation": "subtract"}
|
|
420
|
+
>>> result = handle_transform_math({"trace1": pos, "trace2": neg}, params, "diff")
|
|
421
|
+
"""
|
|
422
|
+
trace1 = inputs.get("trace1")
|
|
423
|
+
trace2 = inputs.get("trace2")
|
|
424
|
+
|
|
425
|
+
if trace1 is None or trace2 is None:
|
|
426
|
+
raise PipelineExecutionError(
|
|
427
|
+
"Missing required inputs 'trace1' and 'trace2'", step_name=step_name
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
operation = params.get("operation")
|
|
431
|
+
if operation not in {"add", "subtract", "multiply", "divide"}:
|
|
432
|
+
raise PipelineExecutionError(
|
|
433
|
+
f"Invalid operation '{operation}'. Must be: add, subtract, multiply, divide",
|
|
434
|
+
step_name=step_name,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
auto_align = params.get("auto_align", True)
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
# Align traces if needed
|
|
441
|
+
if auto_align and (
|
|
442
|
+
trace1.metadata.sample_rate != trace2.metadata.sample_rate
|
|
443
|
+
or len(trace1.data) != len(trace2.data)
|
|
444
|
+
):
|
|
445
|
+
interpolation = _get_interpolation()
|
|
446
|
+
# Resample trace2 to match trace1
|
|
447
|
+
trace2 = interpolation.resample(trace2, new_sample_rate=trace1.metadata.sample_rate)
|
|
448
|
+
# Ensure same length
|
|
449
|
+
min_len = min(len(trace1.data), len(trace2.data))
|
|
450
|
+
data1 = trace1.data[:min_len]
|
|
451
|
+
data2 = trace2.data[:min_len]
|
|
452
|
+
else:
|
|
453
|
+
if len(trace1.data) != len(trace2.data):
|
|
454
|
+
raise PipelineExecutionError(
|
|
455
|
+
f"Trace lengths don't match: {len(trace1.data)} vs {len(trace2.data)}. "
|
|
456
|
+
"Set 'auto_align: true' to automatically align traces",
|
|
457
|
+
step_name=step_name,
|
|
458
|
+
)
|
|
459
|
+
data1 = trace1.data
|
|
460
|
+
data2 = trace2.data
|
|
461
|
+
|
|
462
|
+
# Perform operation
|
|
463
|
+
if operation == "add":
|
|
464
|
+
result_data = data1 + data2
|
|
465
|
+
elif operation == "subtract":
|
|
466
|
+
result_data = data1 - data2
|
|
467
|
+
elif operation == "multiply":
|
|
468
|
+
result_data = data1 * data2
|
|
469
|
+
elif operation == "divide":
|
|
470
|
+
# Avoid division by zero
|
|
471
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
472
|
+
result_data = np.divide(data1, data2)
|
|
473
|
+
result_data = np.nan_to_num(result_data, nan=0.0, posinf=0.0, neginf=0.0)
|
|
474
|
+
else:
|
|
475
|
+
# Should never reach here due to earlier validation
|
|
476
|
+
raise ValueError(f"Unknown operation: {operation}")
|
|
477
|
+
|
|
478
|
+
new_trace = WaveformTrace(data=result_data, metadata=trace1.metadata)
|
|
479
|
+
|
|
480
|
+
except PipelineExecutionError:
|
|
481
|
+
raise
|
|
482
|
+
except Exception as e:
|
|
483
|
+
raise PipelineExecutionError(f"Math operation failed: {e}", step_name=step_name) from e
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
"trace": new_trace,
|
|
487
|
+
"operation": operation,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@register_handler("transform.window")
|
|
492
|
+
def handle_transform_window(
|
|
493
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
494
|
+
) -> dict[str, Any]:
|
|
495
|
+
"""Apply window function to signal.
|
|
496
|
+
|
|
497
|
+
Multiplies signal by a window function for spectral analysis.
|
|
498
|
+
|
|
499
|
+
Inputs:
|
|
500
|
+
trace: WaveformTrace to window
|
|
501
|
+
|
|
502
|
+
Parameters:
|
|
503
|
+
window (str): Window type ('hann', 'hamming', 'blackman', 'bartlett',
|
|
504
|
+
'rectangular', 'kaiser')
|
|
505
|
+
beta (float, optional): Beta parameter for Kaiser window (default: 8.6)
|
|
506
|
+
|
|
507
|
+
Outputs:
|
|
508
|
+
trace: Windowed WaveformTrace
|
|
509
|
+
window: Window type applied
|
|
510
|
+
|
|
511
|
+
Example:
|
|
512
|
+
>>> # Apply Hann window for FFT
|
|
513
|
+
>>> params = {"window": "hann"}
|
|
514
|
+
>>> result = handle_transform_window({"trace": trace}, params, "fft_window")
|
|
515
|
+
"""
|
|
516
|
+
windowing = _get_windowing()
|
|
517
|
+
|
|
518
|
+
trace = inputs.get("trace")
|
|
519
|
+
if trace is None:
|
|
520
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
521
|
+
|
|
522
|
+
window_name = params.get("window")
|
|
523
|
+
if window_name is None:
|
|
524
|
+
raise PipelineExecutionError(
|
|
525
|
+
"Missing required parameter 'window'. Add 'window: hann' to params",
|
|
526
|
+
step_name=step_name,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
# Get window coefficients
|
|
531
|
+
if window_name == "kaiser":
|
|
532
|
+
beta = params.get("beta", 8.6)
|
|
533
|
+
window = windowing.kaiser(len(trace.data), beta)
|
|
534
|
+
else:
|
|
535
|
+
window = windowing.get_window(window_name, len(trace.data))
|
|
536
|
+
|
|
537
|
+
# Apply window
|
|
538
|
+
windowed_data = trace.data * window
|
|
539
|
+
new_trace = WaveformTrace(data=windowed_data, metadata=trace.metadata)
|
|
540
|
+
|
|
541
|
+
except Exception as e:
|
|
542
|
+
raise PipelineExecutionError(
|
|
543
|
+
f"Window application failed: {e}. "
|
|
544
|
+
"Valid windows: hann, hamming, blackman, bartlett, rectangular, kaiser",
|
|
545
|
+
step_name=step_name,
|
|
546
|
+
) from e
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
"trace": new_trace,
|
|
550
|
+
"window": window_name,
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@register_handler("transform.normalize")
|
|
555
|
+
def handle_transform_normalize(
|
|
556
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
557
|
+
) -> dict[str, Any]:
|
|
558
|
+
"""Normalize signal to specified range.
|
|
559
|
+
|
|
560
|
+
Scales signal to fit within a specified range [min_val, max_val].
|
|
561
|
+
|
|
562
|
+
Inputs:
|
|
563
|
+
trace: WaveformTrace to normalize
|
|
564
|
+
|
|
565
|
+
Parameters:
|
|
566
|
+
min (float, optional): Minimum value of output range (default: -1.0)
|
|
567
|
+
max (float, optional): Maximum value of output range (default: 1.0)
|
|
568
|
+
method (str, optional): Normalization method ('minmax', 'zscore', 'peak')
|
|
569
|
+
(default: 'minmax')
|
|
570
|
+
|
|
571
|
+
Outputs:
|
|
572
|
+
trace: Normalized WaveformTrace
|
|
573
|
+
method: Normalization method used
|
|
574
|
+
original_min: Original minimum value
|
|
575
|
+
original_max: Original maximum value
|
|
576
|
+
scale_factor: Applied scale factor
|
|
577
|
+
|
|
578
|
+
Example:
|
|
579
|
+
>>> # Normalize to [-1, 1]
|
|
580
|
+
>>> params = {"min": -1.0, "max": 1.0, "method": "minmax"}
|
|
581
|
+
>>> result = handle_transform_normalize({"trace": trace}, params, "norm")
|
|
582
|
+
"""
|
|
583
|
+
trace = inputs.get("trace")
|
|
584
|
+
if trace is None:
|
|
585
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
586
|
+
|
|
587
|
+
min_val = params.get("min", -1.0)
|
|
588
|
+
max_val = params.get("max", 1.0)
|
|
589
|
+
method = params.get("method", "minmax")
|
|
590
|
+
|
|
591
|
+
if min_val >= max_val:
|
|
592
|
+
raise PipelineExecutionError(
|
|
593
|
+
f"min ({min_val}) must be less than max ({max_val})", step_name=step_name
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
original_min = float(np.min(trace.data))
|
|
598
|
+
original_max = float(np.max(trace.data))
|
|
599
|
+
|
|
600
|
+
if method == "minmax":
|
|
601
|
+
# Min-max normalization
|
|
602
|
+
data_range = original_max - original_min
|
|
603
|
+
if data_range == 0:
|
|
604
|
+
normalized = np.full_like(trace.data, (min_val + max_val) / 2)
|
|
605
|
+
scale_factor = 0.0
|
|
606
|
+
else:
|
|
607
|
+
normalized = (trace.data - original_min) / data_range
|
|
608
|
+
normalized = normalized * (max_val - min_val) + min_val
|
|
609
|
+
scale_factor = (max_val - min_val) / data_range
|
|
610
|
+
|
|
611
|
+
elif method == "zscore":
|
|
612
|
+
# Z-score normalization (standardization)
|
|
613
|
+
mean = np.mean(trace.data)
|
|
614
|
+
std = np.std(trace.data)
|
|
615
|
+
if std == 0:
|
|
616
|
+
normalized = np.zeros_like(trace.data)
|
|
617
|
+
scale_factor = 0.0
|
|
618
|
+
else:
|
|
619
|
+
normalized = (trace.data - mean) / std
|
|
620
|
+
# Scale to [min, max]
|
|
621
|
+
norm_min = np.min(normalized)
|
|
622
|
+
norm_max = np.max(normalized)
|
|
623
|
+
norm_range = norm_max - norm_min
|
|
624
|
+
if norm_range > 0:
|
|
625
|
+
normalized = (normalized - norm_min) / norm_range
|
|
626
|
+
normalized = normalized * (max_val - min_val) + min_val
|
|
627
|
+
scale_factor = 1.0 / std
|
|
628
|
+
|
|
629
|
+
elif method == "peak":
|
|
630
|
+
# Peak normalization (divide by absolute maximum)
|
|
631
|
+
peak = np.max(np.abs(trace.data))
|
|
632
|
+
if peak == 0:
|
|
633
|
+
normalized = np.zeros_like(trace.data)
|
|
634
|
+
scale_factor = 0.0
|
|
635
|
+
else:
|
|
636
|
+
normalized = trace.data / peak
|
|
637
|
+
# Scale to [min, max]
|
|
638
|
+
normalized = normalized * (max_val - min_val) / 2.0 + (max_val + min_val) / 2.0
|
|
639
|
+
scale_factor = (max_val - min_val) / (2.0 * peak)
|
|
640
|
+
|
|
641
|
+
else:
|
|
642
|
+
raise PipelineExecutionError(
|
|
643
|
+
f"Unknown normalization method '{method}'. Valid methods: minmax, zscore, peak",
|
|
644
|
+
step_name=step_name,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
new_trace = WaveformTrace(data=normalized, metadata=trace.metadata)
|
|
648
|
+
|
|
649
|
+
except PipelineExecutionError:
|
|
650
|
+
raise
|
|
651
|
+
except Exception as e:
|
|
652
|
+
raise PipelineExecutionError(f"Normalization failed: {e}", step_name=step_name) from e
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
"trace": new_trace,
|
|
656
|
+
"method": method,
|
|
657
|
+
"original_min": original_min,
|
|
658
|
+
"original_max": original_max,
|
|
659
|
+
"scale_factor": scale_factor,
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@register_handler("transform.clip")
|
|
664
|
+
def handle_transform_clip(
|
|
665
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
666
|
+
) -> dict[str, Any]:
|
|
667
|
+
"""Clip signal to value range.
|
|
668
|
+
|
|
669
|
+
Limits signal values to [min_val, max_val] range.
|
|
670
|
+
|
|
671
|
+
Inputs:
|
|
672
|
+
trace: WaveformTrace to clip
|
|
673
|
+
|
|
674
|
+
Parameters:
|
|
675
|
+
min (float, optional): Minimum value (default: -inf)
|
|
676
|
+
max (float, optional): Maximum value (default: +inf)
|
|
677
|
+
|
|
678
|
+
Outputs:
|
|
679
|
+
trace: Clipped WaveformTrace
|
|
680
|
+
clipped_samples: Number of samples clipped
|
|
681
|
+
|
|
682
|
+
Example:
|
|
683
|
+
>>> # Clip to ADC range
|
|
684
|
+
>>> params = {"min": 0.0, "max": 3.3}
|
|
685
|
+
>>> result = handle_transform_clip({"trace": trace}, params, "adc_clip")
|
|
686
|
+
"""
|
|
687
|
+
trace = inputs.get("trace")
|
|
688
|
+
if trace is None:
|
|
689
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
690
|
+
|
|
691
|
+
min_val = params.get("min", -np.inf)
|
|
692
|
+
max_val = params.get("max", np.inf)
|
|
693
|
+
|
|
694
|
+
if min_val >= max_val:
|
|
695
|
+
raise PipelineExecutionError(
|
|
696
|
+
f"min ({min_val}) must be less than max ({max_val})", step_name=step_name
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
original_data = trace.data.copy()
|
|
701
|
+
clipped_data = np.clip(trace.data, min_val, max_val)
|
|
702
|
+
|
|
703
|
+
# Count clipped samples
|
|
704
|
+
clipped_samples = int(np.sum(clipped_data != original_data))
|
|
705
|
+
|
|
706
|
+
new_trace = WaveformTrace(data=clipped_data, metadata=trace.metadata)
|
|
707
|
+
|
|
708
|
+
except Exception as e:
|
|
709
|
+
raise PipelineExecutionError(f"Clipping failed: {e}", step_name=step_name) from e
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
"trace": new_trace,
|
|
713
|
+
"clipped_samples": clipped_samples,
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@register_handler("transform.rectify")
|
|
718
|
+
def handle_transform_rectify(
|
|
719
|
+
inputs: dict[str, Any], params: dict[str, Any], step_name: str
|
|
720
|
+
) -> dict[str, Any]:
|
|
721
|
+
"""Rectify signal (half-wave or full-wave).
|
|
722
|
+
|
|
723
|
+
Performs rectification for envelope detection or power analysis.
|
|
724
|
+
|
|
725
|
+
Inputs:
|
|
726
|
+
trace: WaveformTrace to rectify
|
|
727
|
+
|
|
728
|
+
Parameters:
|
|
729
|
+
mode (str): Rectification mode ('half', 'full') (default: 'full')
|
|
730
|
+
|
|
731
|
+
Outputs:
|
|
732
|
+
trace: Rectified WaveformTrace
|
|
733
|
+
mode: Rectification mode applied
|
|
734
|
+
|
|
735
|
+
Example:
|
|
736
|
+
>>> # Full-wave rectification for power measurement
|
|
737
|
+
>>> params = {"mode": "full"}
|
|
738
|
+
>>> result = handle_transform_rectify({"trace": trace}, params, "rectify")
|
|
739
|
+
"""
|
|
740
|
+
trace = inputs.get("trace")
|
|
741
|
+
if trace is None:
|
|
742
|
+
raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
|
|
743
|
+
|
|
744
|
+
mode = params.get("mode", "full")
|
|
745
|
+
|
|
746
|
+
if mode not in {"half", "full"}:
|
|
747
|
+
raise PipelineExecutionError(
|
|
748
|
+
f"Invalid rectification mode '{mode}'. Valid modes: half, full",
|
|
749
|
+
step_name=step_name,
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
if mode == "full":
|
|
754
|
+
# Full-wave rectification (absolute value)
|
|
755
|
+
rectified_data = np.abs(trace.data)
|
|
756
|
+
else:
|
|
757
|
+
# Half-wave rectification (zero negative values)
|
|
758
|
+
rectified_data = np.maximum(trace.data, 0.0)
|
|
759
|
+
|
|
760
|
+
new_trace = WaveformTrace(data=rectified_data, metadata=trace.metadata)
|
|
761
|
+
|
|
762
|
+
except Exception as e:
|
|
763
|
+
raise PipelineExecutionError(f"Rectification failed: {e}", step_name=step_name) from e
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
"trace": new_trace,
|
|
767
|
+
"mode": mode,
|
|
768
|
+
}
|