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

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