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,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
+ }