oscura 0.8.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 (151) 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/patterns/__init__.py +66 -0
  7. oscura/analyzers/power/basic.py +3 -3
  8. oscura/analyzers/power/soa.py +1 -1
  9. oscura/analyzers/power/switching.py +3 -3
  10. oscura/analyzers/signal_classification.py +529 -0
  11. oscura/analyzers/signal_integrity/sparams.py +3 -3
  12. oscura/analyzers/statistics/basic.py +10 -7
  13. oscura/analyzers/validation.py +1 -1
  14. oscura/analyzers/waveform/measurements.py +200 -156
  15. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  16. oscura/analyzers/waveform/spectral.py +164 -73
  17. oscura/api/dsl/commands.py +15 -6
  18. oscura/api/server/templates/base.html +137 -146
  19. oscura/api/server/templates/export.html +84 -110
  20. oscura/api/server/templates/home.html +248 -267
  21. oscura/api/server/templates/protocols.html +44 -48
  22. oscura/api/server/templates/reports.html +27 -35
  23. oscura/api/server/templates/session_detail.html +68 -78
  24. oscura/api/server/templates/sessions.html +62 -72
  25. oscura/api/server/templates/waveforms.html +54 -64
  26. oscura/automotive/__init__.py +1 -1
  27. oscura/automotive/can/session.py +1 -1
  28. oscura/automotive/dbc/generator.py +638 -23
  29. oscura/automotive/uds/decoder.py +99 -6
  30. oscura/cli/analyze.py +8 -2
  31. oscura/cli/batch.py +36 -5
  32. oscura/cli/characterize.py +18 -4
  33. oscura/cli/export.py +47 -5
  34. oscura/cli/main.py +2 -0
  35. oscura/cli/onboarding/wizard.py +10 -6
  36. oscura/cli/pipeline.py +585 -0
  37. oscura/cli/visualize.py +6 -4
  38. oscura/convenience.py +400 -32
  39. oscura/core/measurement_result.py +286 -0
  40. oscura/core/progress.py +1 -1
  41. oscura/core/types.py +232 -239
  42. oscura/correlation/multi_protocol.py +1 -1
  43. oscura/export/legacy/__init__.py +11 -0
  44. oscura/export/legacy/wav.py +75 -0
  45. oscura/exporters/__init__.py +19 -0
  46. oscura/exporters/wireshark.py +809 -0
  47. oscura/hardware/acquisition/file.py +5 -19
  48. oscura/hardware/acquisition/saleae.py +10 -10
  49. oscura/hardware/acquisition/socketcan.py +4 -6
  50. oscura/hardware/acquisition/synthetic.py +1 -5
  51. oscura/hardware/acquisition/visa.py +6 -6
  52. oscura/hardware/security/side_channel_detector.py +5 -508
  53. oscura/inference/message_format.py +686 -1
  54. oscura/jupyter/display.py +2 -2
  55. oscura/jupyter/magic.py +3 -3
  56. oscura/loaders/__init__.py +17 -12
  57. oscura/loaders/binary.py +1 -1
  58. oscura/loaders/chipwhisperer.py +1 -2
  59. oscura/loaders/configurable.py +1 -1
  60. oscura/loaders/csv_loader.py +2 -2
  61. oscura/loaders/hdf5_loader.py +1 -1
  62. oscura/loaders/lazy.py +6 -1
  63. oscura/loaders/mmap_loader.py +0 -1
  64. oscura/loaders/numpy_loader.py +8 -7
  65. oscura/loaders/preprocessing.py +3 -5
  66. oscura/loaders/rigol.py +21 -7
  67. oscura/loaders/sigrok.py +2 -5
  68. oscura/loaders/tdms.py +3 -2
  69. oscura/loaders/tektronix.py +38 -32
  70. oscura/loaders/tss.py +20 -27
  71. oscura/loaders/vcd.py +13 -8
  72. oscura/loaders/wav.py +1 -6
  73. oscura/pipeline/__init__.py +76 -0
  74. oscura/pipeline/handlers/__init__.py +165 -0
  75. oscura/pipeline/handlers/analyzers.py +1045 -0
  76. oscura/pipeline/handlers/decoders.py +899 -0
  77. oscura/pipeline/handlers/exporters.py +1103 -0
  78. oscura/pipeline/handlers/filters.py +891 -0
  79. oscura/pipeline/handlers/loaders.py +640 -0
  80. oscura/pipeline/handlers/transforms.py +768 -0
  81. oscura/reporting/formatting/measurements.py +55 -14
  82. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  83. oscura/side_channel/__init__.py +38 -57
  84. oscura/utils/builders/signal_builder.py +5 -5
  85. oscura/utils/comparison/compare.py +7 -9
  86. oscura/utils/comparison/golden.py +1 -1
  87. oscura/utils/filtering/convenience.py +2 -2
  88. oscura/utils/math/arithmetic.py +38 -62
  89. oscura/utils/math/interpolation.py +20 -20
  90. oscura/utils/pipeline/__init__.py +4 -17
  91. oscura/utils/progressive.py +1 -4
  92. oscura/utils/triggering/edge.py +1 -1
  93. oscura/utils/triggering/pattern.py +2 -2
  94. oscura/utils/triggering/pulse.py +2 -2
  95. oscura/utils/triggering/window.py +3 -3
  96. oscura/validation/hil_testing.py +11 -11
  97. oscura/visualization/__init__.py +46 -284
  98. oscura/visualization/batch.py +72 -433
  99. oscura/visualization/plot.py +542 -53
  100. oscura/visualization/styles.py +184 -318
  101. oscura/workflows/batch/advanced.py +1 -1
  102. oscura/workflows/batch/aggregate.py +7 -8
  103. oscura/workflows/complete_re.py +251 -23
  104. oscura/workflows/digital.py +27 -4
  105. oscura/workflows/multi_trace.py +136 -17
  106. oscura/workflows/waveform.py +11 -6
  107. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  108. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/RECORD +111 -136
  109. oscura/side_channel/dpa.py +0 -1025
  110. oscura/utils/optimization/__init__.py +0 -19
  111. oscura/utils/optimization/parallel.py +0 -443
  112. oscura/utils/optimization/search.py +0 -532
  113. oscura/utils/pipeline/base.py +0 -338
  114. oscura/utils/pipeline/composition.py +0 -248
  115. oscura/utils/pipeline/parallel.py +0 -449
  116. oscura/utils/pipeline/pipeline.py +0 -375
  117. oscura/utils/search/__init__.py +0 -16
  118. oscura/utils/search/anomaly.py +0 -424
  119. oscura/utils/search/context.py +0 -294
  120. oscura/utils/search/pattern.py +0 -288
  121. oscura/utils/storage/__init__.py +0 -61
  122. oscura/utils/storage/database.py +0 -1166
  123. oscura/visualization/accessibility.py +0 -526
  124. oscura/visualization/annotations.py +0 -371
  125. oscura/visualization/axis_scaling.py +0 -305
  126. oscura/visualization/colors.py +0 -451
  127. oscura/visualization/digital.py +0 -436
  128. oscura/visualization/eye.py +0 -571
  129. oscura/visualization/histogram.py +0 -281
  130. oscura/visualization/interactive.py +0 -1035
  131. oscura/visualization/jitter.py +0 -1042
  132. oscura/visualization/keyboard.py +0 -394
  133. oscura/visualization/layout.py +0 -400
  134. oscura/visualization/optimization.py +0 -1079
  135. oscura/visualization/palettes.py +0 -446
  136. oscura/visualization/power.py +0 -508
  137. oscura/visualization/power_extended.py +0 -955
  138. oscura/visualization/presets.py +0 -469
  139. oscura/visualization/protocols.py +0 -1246
  140. oscura/visualization/render.py +0 -223
  141. oscura/visualization/rendering.py +0 -444
  142. oscura/visualization/reverse_engineering.py +0 -838
  143. oscura/visualization/signal_integrity.py +0 -989
  144. oscura/visualization/specialized.py +0 -643
  145. oscura/visualization/spectral.py +0 -1226
  146. oscura/visualization/thumbnails.py +0 -340
  147. oscura/visualization/time_axis.py +0 -351
  148. oscura/visualization/waveform.py +0 -454
  149. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  150. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  151. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,891 @@
1
+ """Filter handlers for pipeline system.
2
+
3
+ This module provides handlers for applying various digital filters to signals.
4
+ All handlers follow the standard signature: (inputs, params, step_name) -> outputs.
5
+
6
+ Available Handlers:
7
+ - filter.low_pass: Apply low-pass filter
8
+ - filter.high_pass: Apply high-pass filter
9
+ - filter.band_pass: Apply band-pass filter
10
+ - filter.band_stop: Apply band-stop/notch filter
11
+ - filter.butterworth: Apply Butterworth filter (generic)
12
+ - filter.chebyshev: Apply Chebyshev Type 1 filter
13
+ - filter.bessel: Apply Bessel filter
14
+ - filter.moving_average: Apply moving average filter
15
+ - filter.savitzky_golay: Apply Savitzky-Golay smoothing
16
+ - filter.median: Apply median filter
17
+ - filter.gaussian: Apply Gaussian smoothing
18
+ - filter.notch: Apply narrow notch filter at specific frequency
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from oscura.core.config.pipeline import PipelineExecutionError
26
+ from oscura.pipeline.handlers import register_handler
27
+
28
+ # Lazy imports to avoid circular dependencies
29
+ _filtering = None
30
+
31
+
32
+ def _get_filtering() -> Any:
33
+ """Lazy import filtering module."""
34
+ global _filtering
35
+ if _filtering is None:
36
+ import oscura.utils.filtering as _filtering_module
37
+
38
+ _filtering = _filtering_module
39
+ return _filtering
40
+
41
+
42
+ @register_handler("filter.low_pass")
43
+ def handle_filter_low_pass(
44
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
45
+ ) -> dict[str, Any]:
46
+ """Apply low-pass filter to remove high-frequency components.
47
+
48
+ Inputs:
49
+ trace: WaveformTrace to filter
50
+
51
+ Parameters:
52
+ cutoff (float): Cutoff frequency in Hz
53
+ order (int, optional): Filter order (default: 4)
54
+ filter_type (str, optional): Filter type - 'butterworth', 'chebyshev1',
55
+ 'chebyshev2', 'bessel', or 'elliptic' (default: 'butterworth')
56
+
57
+ Outputs:
58
+ trace: Filtered WaveformTrace
59
+ cutoff: Applied cutoff frequency
60
+ order: Filter order used
61
+ filter_type: Filter type used
62
+
63
+ Example:
64
+ >>> # Remove noise above 1 MHz
65
+ >>> result = handle_filter_low_pass(
66
+ ... {"trace": my_trace},
67
+ ... {"cutoff": 1e6, "order": 6},
68
+ ... "lpf"
69
+ ... )
70
+ """
71
+ filtering = _get_filtering()
72
+
73
+ trace = inputs.get("trace")
74
+ if trace is None:
75
+ raise PipelineExecutionError(
76
+ "Missing required input 'trace' - provide WaveformTrace",
77
+ step_name=step_name,
78
+ )
79
+
80
+ cutoff = params.get("cutoff")
81
+ if cutoff is None:
82
+ raise PipelineExecutionError(
83
+ "Missing required parameter 'cutoff' - add 'cutoff: 1e6' (frequency in Hz)",
84
+ step_name=step_name,
85
+ )
86
+
87
+ order = params.get("order", 4)
88
+ filter_type = params.get("filter_type", "butterworth")
89
+
90
+ try:
91
+ filtered_trace = filtering.low_pass(
92
+ trace,
93
+ cutoff=cutoff,
94
+ order=order,
95
+ filter_type=filter_type,
96
+ )
97
+ except Exception as e:
98
+ raise PipelineExecutionError(f"Low-pass filtering failed: {e}", step_name=step_name) from e
99
+
100
+ return {
101
+ "trace": filtered_trace,
102
+ "cutoff": cutoff,
103
+ "order": order,
104
+ "filter_type": filter_type,
105
+ }
106
+
107
+
108
+ @register_handler("filter.high_pass")
109
+ def handle_filter_high_pass(
110
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
111
+ ) -> dict[str, Any]:
112
+ """Apply high-pass filter to remove low-frequency components and DC offset.
113
+
114
+ Inputs:
115
+ trace: WaveformTrace to filter
116
+
117
+ Parameters:
118
+ cutoff (float): Cutoff frequency in Hz
119
+ order (int, optional): Filter order (default: 4)
120
+ filter_type (str, optional): Filter type - 'butterworth', 'chebyshev1',
121
+ 'chebyshev2', 'bessel', or 'elliptic' (default: 'butterworth')
122
+
123
+ Outputs:
124
+ trace: Filtered WaveformTrace
125
+ cutoff: Applied cutoff frequency
126
+ order: Filter order used
127
+ filter_type: Filter type used
128
+
129
+ Example:
130
+ >>> # Remove DC and frequencies below 100 Hz
131
+ >>> result = handle_filter_high_pass(
132
+ ... {"trace": my_trace},
133
+ ... {"cutoff": 100, "order": 4},
134
+ ... "hpf"
135
+ ... )
136
+ """
137
+ filtering = _get_filtering()
138
+
139
+ trace = inputs.get("trace")
140
+ if trace is None:
141
+ raise PipelineExecutionError(
142
+ "Missing required input 'trace' - provide WaveformTrace",
143
+ step_name=step_name,
144
+ )
145
+
146
+ cutoff = params.get("cutoff")
147
+ if cutoff is None:
148
+ raise PipelineExecutionError(
149
+ "Missing required parameter 'cutoff' - add 'cutoff: 100' (frequency in Hz)",
150
+ step_name=step_name,
151
+ )
152
+
153
+ order = params.get("order", 4)
154
+ filter_type = params.get("filter_type", "butterworth")
155
+
156
+ try:
157
+ filtered_trace = filtering.high_pass(
158
+ trace,
159
+ cutoff=cutoff,
160
+ order=order,
161
+ filter_type=filter_type,
162
+ )
163
+ except Exception as e:
164
+ raise PipelineExecutionError(f"High-pass filtering failed: {e}", step_name=step_name) from e
165
+
166
+ return {
167
+ "trace": filtered_trace,
168
+ "cutoff": cutoff,
169
+ "order": order,
170
+ "filter_type": filter_type,
171
+ }
172
+
173
+
174
+ @register_handler("filter.band_pass")
175
+ def handle_filter_band_pass(
176
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
177
+ ) -> dict[str, Any]:
178
+ """Apply band-pass filter to pass frequencies in a specified band.
179
+
180
+ Inputs:
181
+ trace: WaveformTrace to filter
182
+
183
+ Parameters:
184
+ low (float): Lower cutoff frequency in Hz
185
+ high (float): Upper cutoff frequency in Hz
186
+ order (int, optional): Filter order (default: 4)
187
+ filter_type (str, optional): Filter type - 'butterworth', 'chebyshev1',
188
+ 'chebyshev2', 'bessel', or 'elliptic' (default: 'butterworth')
189
+
190
+ Outputs:
191
+ trace: Filtered WaveformTrace
192
+ low: Lower cutoff frequency
193
+ high: Upper cutoff frequency
194
+ order: Filter order used
195
+ filter_type: Filter type used
196
+
197
+ Example:
198
+ >>> # Pass only 1 kHz to 10 kHz
199
+ >>> result = handle_filter_band_pass(
200
+ ... {"trace": my_trace},
201
+ ... {"low": 1e3, "high": 10e3, "order": 6},
202
+ ... "bpf"
203
+ ... )
204
+ """
205
+ filtering = _get_filtering()
206
+
207
+ trace = inputs.get("trace")
208
+ if trace is None:
209
+ raise PipelineExecutionError(
210
+ "Missing required input 'trace' - provide WaveformTrace",
211
+ step_name=step_name,
212
+ )
213
+
214
+ low = params.get("low")
215
+ high = params.get("high")
216
+
217
+ if low is None or high is None:
218
+ raise PipelineExecutionError(
219
+ "Missing required parameters 'low' and 'high' - add frequencies in Hz",
220
+ step_name=step_name,
221
+ )
222
+
223
+ if low >= high:
224
+ raise PipelineExecutionError(
225
+ f"Low cutoff ({low}) must be less than high cutoff ({high})",
226
+ step_name=step_name,
227
+ )
228
+
229
+ order = params.get("order", 4)
230
+ filter_type = params.get("filter_type", "butterworth")
231
+
232
+ try:
233
+ filtered_trace = filtering.band_pass(
234
+ trace,
235
+ low=low,
236
+ high=high,
237
+ order=order,
238
+ filter_type=filter_type,
239
+ )
240
+ except Exception as e:
241
+ raise PipelineExecutionError(f"Band-pass filtering failed: {e}", step_name=step_name) from e
242
+
243
+ return {
244
+ "trace": filtered_trace,
245
+ "low": low,
246
+ "high": high,
247
+ "order": order,
248
+ "filter_type": filter_type,
249
+ }
250
+
251
+
252
+ @register_handler("filter.band_stop")
253
+ def handle_filter_band_stop(
254
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
255
+ ) -> dict[str, Any]:
256
+ """Apply band-stop (notch) filter to reject frequencies in a specified band.
257
+
258
+ Inputs:
259
+ trace: WaveformTrace to filter
260
+
261
+ Parameters:
262
+ low (float): Lower cutoff frequency in Hz
263
+ high (float): Upper cutoff frequency in Hz
264
+ order (int, optional): Filter order (default: 4)
265
+ filter_type (str, optional): Filter type - 'butterworth', 'chebyshev1',
266
+ 'chebyshev2', 'bessel', or 'elliptic' (default: 'butterworth')
267
+
268
+ Outputs:
269
+ trace: Filtered WaveformTrace
270
+ low: Lower cutoff frequency
271
+ high: Upper cutoff frequency
272
+ order: Filter order used
273
+ filter_type: Filter type used
274
+
275
+ Example:
276
+ >>> # Remove 50 Hz power line interference
277
+ >>> result = handle_filter_band_stop(
278
+ ... {"trace": my_trace},
279
+ ... {"low": 49, "high": 51, "order": 4},
280
+ ... "bsf"
281
+ ... )
282
+ """
283
+ filtering = _get_filtering()
284
+
285
+ trace = inputs.get("trace")
286
+ if trace is None:
287
+ raise PipelineExecutionError(
288
+ "Missing required input 'trace' - provide WaveformTrace",
289
+ step_name=step_name,
290
+ )
291
+
292
+ low = params.get("low")
293
+ high = params.get("high")
294
+
295
+ if low is None or high is None:
296
+ raise PipelineExecutionError(
297
+ "Missing required parameters 'low' and 'high' - add frequencies in Hz",
298
+ step_name=step_name,
299
+ )
300
+
301
+ if low >= high:
302
+ raise PipelineExecutionError(
303
+ f"Low cutoff ({low}) must be less than high cutoff ({high})",
304
+ step_name=step_name,
305
+ )
306
+
307
+ order = params.get("order", 4)
308
+ filter_type = params.get("filter_type", "butterworth")
309
+
310
+ try:
311
+ filtered_trace = filtering.band_stop(
312
+ trace,
313
+ low=low,
314
+ high=high,
315
+ order=order,
316
+ filter_type=filter_type,
317
+ )
318
+ except Exception as e:
319
+ raise PipelineExecutionError(f"Band-stop filtering failed: {e}", step_name=step_name) from e
320
+
321
+ return {
322
+ "trace": filtered_trace,
323
+ "low": low,
324
+ "high": high,
325
+ "order": order,
326
+ "filter_type": filter_type,
327
+ }
328
+
329
+
330
+ @register_handler("filter.butterworth")
331
+ def handle_filter_butterworth(
332
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
333
+ ) -> dict[str, Any]:
334
+ """Apply generic Butterworth filter with specified band type.
335
+
336
+ Butterworth filters have maximally flat passband response.
337
+
338
+ Inputs:
339
+ trace: WaveformTrace to filter
340
+
341
+ Parameters:
342
+ cutoff (float|tuple): Cutoff frequency in Hz (scalar for low/high-pass,
343
+ tuple (low, high) for band-pass/band-stop)
344
+ band_type (str): Filter band type - 'lowpass', 'highpass', 'bandpass', or 'bandstop'
345
+ order (int, optional): Filter order (default: 4)
346
+
347
+ Outputs:
348
+ trace: Filtered WaveformTrace
349
+ cutoff: Applied cutoff frequency
350
+ band_type: Filter band type used
351
+ order: Filter order used
352
+
353
+ Example:
354
+ >>> # Generic low-pass Butterworth
355
+ >>> result = handle_filter_butterworth(
356
+ ... {"trace": my_trace},
357
+ ... {"cutoff": 1e6, "band_type": "lowpass", "order": 6},
358
+ ... "butter"
359
+ ... )
360
+ """
361
+ filtering = _get_filtering()
362
+
363
+ trace = inputs.get("trace")
364
+ if trace is None:
365
+ raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
366
+
367
+ cutoff = params.get("cutoff")
368
+ if cutoff is None:
369
+ raise PipelineExecutionError(
370
+ "Missing required parameter 'cutoff'",
371
+ step_name=step_name,
372
+ )
373
+
374
+ band_type = params.get("band_type", "lowpass")
375
+ if band_type not in ["lowpass", "highpass", "bandpass", "bandstop"]:
376
+ raise PipelineExecutionError(
377
+ f"Invalid band_type '{band_type}' - use lowpass/highpass/bandpass/bandstop",
378
+ step_name=step_name,
379
+ )
380
+
381
+ order = params.get("order", 4)
382
+
383
+ try:
384
+ # Create Butterworth filter
385
+ sample_rate = trace.metadata.sample_rate
386
+ filt = filtering.ButterworthFilter(
387
+ cutoff=cutoff,
388
+ sample_rate=sample_rate,
389
+ order=order,
390
+ band_type=band_type,
391
+ )
392
+ filtered_trace = filt.apply(trace)
393
+
394
+ except Exception as e:
395
+ raise PipelineExecutionError(
396
+ f"Butterworth filtering failed: {e}", step_name=step_name
397
+ ) from e
398
+
399
+ return {
400
+ "trace": filtered_trace,
401
+ "cutoff": cutoff,
402
+ "band_type": band_type,
403
+ "order": order,
404
+ }
405
+
406
+
407
+ @register_handler("filter.chebyshev")
408
+ def handle_filter_chebyshev(
409
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
410
+ ) -> dict[str, Any]:
411
+ """Apply Chebyshev Type 1 filter with specified band type.
412
+
413
+ Chebyshev Type 1 filters have ripple in the passband but steeper rolloff
414
+ than Butterworth filters.
415
+
416
+ Inputs:
417
+ trace: WaveformTrace to filter
418
+
419
+ Parameters:
420
+ cutoff (float|tuple): Cutoff frequency in Hz (scalar for low/high-pass,
421
+ tuple (low, high) for band-pass/band-stop)
422
+ band_type (str): Filter band type - 'lowpass', 'highpass', 'bandpass', or 'bandstop'
423
+ order (int, optional): Filter order (default: 4)
424
+ ripple (float, optional): Passband ripple in dB (default: 1.0)
425
+
426
+ Outputs:
427
+ trace: Filtered WaveformTrace
428
+ cutoff: Applied cutoff frequency
429
+ band_type: Filter band type used
430
+ order: Filter order used
431
+ ripple: Passband ripple in dB
432
+
433
+ Example:
434
+ >>> # Chebyshev low-pass with 0.5 dB ripple
435
+ >>> result = handle_filter_chebyshev(
436
+ ... {"trace": my_trace},
437
+ ... {"cutoff": 1e6, "band_type": "lowpass", "ripple": 0.5},
438
+ ... "cheby"
439
+ ... )
440
+ """
441
+ filtering = _get_filtering()
442
+
443
+ trace = inputs.get("trace")
444
+ if trace is None:
445
+ raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
446
+
447
+ cutoff = params.get("cutoff")
448
+ if cutoff is None:
449
+ raise PipelineExecutionError("Missing required parameter 'cutoff'", step_name=step_name)
450
+
451
+ band_type = params.get("band_type", "lowpass")
452
+ if band_type not in ["lowpass", "highpass", "bandpass", "bandstop"]:
453
+ raise PipelineExecutionError(
454
+ f"Invalid band_type '{band_type}' - use lowpass/highpass/bandpass/bandstop",
455
+ step_name=step_name,
456
+ )
457
+
458
+ order = params.get("order", 4)
459
+ ripple = params.get("ripple", 1.0)
460
+
461
+ try:
462
+ sample_rate = trace.metadata.sample_rate
463
+ filt = filtering.ChebyshevType1Filter(
464
+ cutoff=cutoff,
465
+ sample_rate=sample_rate,
466
+ order=order,
467
+ band_type=band_type,
468
+ ripple=ripple,
469
+ )
470
+ filtered_trace = filt.apply(trace)
471
+
472
+ except Exception as e:
473
+ raise PipelineExecutionError(f"Chebyshev filtering failed: {e}", step_name=step_name) from e
474
+
475
+ return {
476
+ "trace": filtered_trace,
477
+ "cutoff": cutoff,
478
+ "band_type": band_type,
479
+ "order": order,
480
+ "ripple": ripple,
481
+ }
482
+
483
+
484
+ @register_handler("filter.bessel")
485
+ def handle_filter_bessel(
486
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
487
+ ) -> dict[str, Any]:
488
+ """Apply Bessel filter with specified band type.
489
+
490
+ Bessel filters have maximally flat group delay (linear phase response),
491
+ preserving pulse shapes better than other filter types.
492
+
493
+ Inputs:
494
+ trace: WaveformTrace to filter
495
+
496
+ Parameters:
497
+ cutoff (float|tuple): Cutoff frequency in Hz (scalar for low/high-pass,
498
+ tuple (low, high) for band-pass/band-stop)
499
+ band_type (str): Filter band type - 'lowpass', 'highpass', 'bandpass', or 'bandstop'
500
+ order (int, optional): Filter order (default: 4)
501
+
502
+ Outputs:
503
+ trace: Filtered WaveformTrace
504
+ cutoff: Applied cutoff frequency
505
+ band_type: Filter band type used
506
+ order: Filter order used
507
+
508
+ Example:
509
+ >>> # Bessel low-pass for minimal pulse distortion
510
+ >>> result = handle_filter_bessel(
511
+ ... {"trace": my_trace},
512
+ ... {"cutoff": 1e6, "band_type": "lowpass", "order": 5},
513
+ ... "bessel"
514
+ ... )
515
+ """
516
+ filtering = _get_filtering()
517
+
518
+ trace = inputs.get("trace")
519
+ if trace is None:
520
+ raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
521
+
522
+ cutoff = params.get("cutoff")
523
+ if cutoff is None:
524
+ raise PipelineExecutionError("Missing required parameter 'cutoff'", step_name=step_name)
525
+
526
+ band_type = params.get("band_type", "lowpass")
527
+ if band_type not in ["lowpass", "highpass", "bandpass", "bandstop"]:
528
+ raise PipelineExecutionError(
529
+ f"Invalid band_type '{band_type}' - use lowpass/highpass/bandpass/bandstop",
530
+ step_name=step_name,
531
+ )
532
+
533
+ order = params.get("order", 4)
534
+
535
+ try:
536
+ sample_rate = trace.metadata.sample_rate
537
+ filt = filtering.BesselFilter(
538
+ cutoff=cutoff,
539
+ sample_rate=sample_rate,
540
+ order=order,
541
+ band_type=band_type,
542
+ )
543
+ filtered_trace = filt.apply(trace)
544
+
545
+ except Exception as e:
546
+ raise PipelineExecutionError(f"Bessel filtering failed: {e}", step_name=step_name) from e
547
+
548
+ return {
549
+ "trace": filtered_trace,
550
+ "cutoff": cutoff,
551
+ "band_type": band_type,
552
+ "order": order,
553
+ }
554
+
555
+
556
+ @register_handler("filter.moving_average")
557
+ def handle_filter_moving_average(
558
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
559
+ ) -> dict[str, Any]:
560
+ """Apply moving average (FIR) filter for simple smoothing.
561
+
562
+ Inputs:
563
+ trace: WaveformTrace to filter
564
+
565
+ Parameters:
566
+ window_size (int): Number of samples in averaging window (must be odd for 'same' mode)
567
+ mode (str, optional): Convolution mode - 'same' (default), 'valid', or 'full'
568
+
569
+ Outputs:
570
+ trace: Filtered WaveformTrace
571
+ window_size: Window size used
572
+ mode: Convolution mode used
573
+
574
+ Example:
575
+ >>> # Simple 11-sample moving average
576
+ >>> result = handle_filter_moving_average(
577
+ ... {"trace": my_trace},
578
+ ... {"window_size": 11},
579
+ ... "mavg"
580
+ ... )
581
+ """
582
+ filtering = _get_filtering()
583
+
584
+ trace = inputs.get("trace")
585
+ if trace is None:
586
+ raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
587
+
588
+ window_size = params.get("window_size")
589
+ if window_size is None:
590
+ raise PipelineExecutionError(
591
+ "Missing required parameter 'window_size'",
592
+ step_name=step_name,
593
+ )
594
+
595
+ if not isinstance(window_size, int) or window_size < 1:
596
+ raise PipelineExecutionError(
597
+ f"Window size must be a positive integer, got {window_size}",
598
+ step_name=step_name,
599
+ )
600
+
601
+ mode = params.get("mode", "same")
602
+
603
+ try:
604
+ filtered_trace = filtering.moving_average(
605
+ trace,
606
+ window_size=window_size,
607
+ mode=mode,
608
+ )
609
+ except Exception as e:
610
+ raise PipelineExecutionError(
611
+ f"Moving average filtering failed: {e}", step_name=step_name
612
+ ) from e
613
+
614
+ return {
615
+ "trace": filtered_trace,
616
+ "window_size": window_size,
617
+ "mode": mode,
618
+ }
619
+
620
+
621
+ @register_handler("filter.savitzky_golay")
622
+ def handle_filter_savitzky_golay(
623
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
624
+ ) -> dict[str, Any]:
625
+ """Apply Savitzky-Golay smoothing filter.
626
+
627
+ Smooths data while preserving higher moments (peaks, etc.) better than
628
+ simple moving average. Uses polynomial fitting within a moving window.
629
+
630
+ Inputs:
631
+ trace: WaveformTrace to filter
632
+
633
+ Parameters:
634
+ window_length (int): Length of filter window (must be odd and > polyorder)
635
+ polyorder (int): Order of polynomial used in fitting
636
+ deriv (int, optional): Derivative order (0 = smoothing, 1 = first derivative, etc.)
637
+ (default: 0)
638
+
639
+ Outputs:
640
+ trace: Filtered WaveformTrace
641
+ window_length: Window length used
642
+ polyorder: Polynomial order used
643
+ deriv: Derivative order
644
+
645
+ Example:
646
+ >>> # Savitzky-Golay smoothing with cubic polynomial
647
+ >>> result = handle_filter_savitzky_golay(
648
+ ... {"trace": my_trace},
649
+ ... {"window_length": 11, "polyorder": 3},
650
+ ... "savgol"
651
+ ... )
652
+ """
653
+ filtering = _get_filtering()
654
+
655
+ trace = inputs.get("trace")
656
+ if trace is None:
657
+ raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
658
+
659
+ window_length = params.get("window_length")
660
+ polyorder = params.get("polyorder")
661
+
662
+ if window_length is None or polyorder is None:
663
+ raise PipelineExecutionError(
664
+ "Missing required parameters 'window_length' and 'polyorder'",
665
+ step_name=step_name,
666
+ )
667
+
668
+ if not isinstance(window_length, int) or window_length < 1:
669
+ raise PipelineExecutionError(
670
+ f"Window length must be a positive integer, got {window_length}",
671
+ step_name=step_name,
672
+ )
673
+
674
+ if not isinstance(polyorder, int) or polyorder < 0:
675
+ raise PipelineExecutionError(
676
+ f"Polynomial order must be a non-negative integer, got {polyorder}",
677
+ step_name=step_name,
678
+ )
679
+
680
+ deriv = params.get("deriv", 0)
681
+
682
+ try:
683
+ filtered_trace = filtering.savgol_filter(
684
+ trace,
685
+ window_length=window_length,
686
+ polyorder=polyorder,
687
+ deriv=deriv,
688
+ )
689
+ except Exception as e:
690
+ raise PipelineExecutionError(
691
+ f"Savitzky-Golay filtering failed: {e}", step_name=step_name
692
+ ) from e
693
+
694
+ return {
695
+ "trace": filtered_trace,
696
+ "window_length": window_length,
697
+ "polyorder": polyorder,
698
+ "deriv": deriv,
699
+ }
700
+
701
+
702
+ @register_handler("filter.median")
703
+ def handle_filter_median(
704
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
705
+ ) -> dict[str, Any]:
706
+ """Apply median filter for spike/impulse noise removal.
707
+
708
+ Non-linear filter that preserves edges while removing outliers.
709
+ Excellent for removing salt-and-pepper noise or voltage spikes.
710
+
711
+ Inputs:
712
+ trace: WaveformTrace to filter
713
+
714
+ Parameters:
715
+ kernel_size (int): Size of the median filter kernel (must be odd)
716
+
717
+ Outputs:
718
+ trace: Filtered WaveformTrace
719
+ kernel_size: Kernel size used
720
+
721
+ Example:
722
+ >>> # Remove impulse noise with 5-sample median filter
723
+ >>> result = handle_filter_median(
724
+ ... {"trace": my_trace},
725
+ ... {"kernel_size": 5},
726
+ ... "median"
727
+ ... )
728
+ """
729
+ filtering = _get_filtering()
730
+
731
+ trace = inputs.get("trace")
732
+ if trace is None:
733
+ raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
734
+
735
+ kernel_size = params.get("kernel_size")
736
+ if kernel_size is None:
737
+ raise PipelineExecutionError(
738
+ "Missing required parameter 'kernel_size'",
739
+ step_name=step_name,
740
+ )
741
+
742
+ if not isinstance(kernel_size, int) or kernel_size < 1:
743
+ raise PipelineExecutionError(
744
+ f"Kernel size must be a positive integer, got {kernel_size}",
745
+ step_name=step_name,
746
+ )
747
+
748
+ try:
749
+ filtered_trace = filtering.median_filter(
750
+ trace,
751
+ kernel_size=kernel_size,
752
+ )
753
+ except Exception as e:
754
+ raise PipelineExecutionError(f"Median filtering failed: {e}", step_name=step_name) from e
755
+
756
+ return {
757
+ "trace": filtered_trace,
758
+ "kernel_size": kernel_size,
759
+ }
760
+
761
+
762
+ @register_handler("filter.gaussian")
763
+ def handle_filter_gaussian(
764
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
765
+ ) -> dict[str, Any]:
766
+ """Apply Gaussian smoothing filter.
767
+
768
+ Smooths with Gaussian kernel of specified standard deviation.
769
+ Provides optimal time-frequency localization.
770
+
771
+ Inputs:
772
+ trace: WaveformTrace to filter
773
+
774
+ Parameters:
775
+ sigma (float): Standard deviation of Gaussian kernel in samples
776
+
777
+ Outputs:
778
+ trace: Filtered WaveformTrace
779
+ sigma: Standard deviation used
780
+
781
+ Example:
782
+ >>> # Gaussian smoothing with sigma=3
783
+ >>> result = handle_filter_gaussian(
784
+ ... {"trace": my_trace},
785
+ ... {"sigma": 3.0},
786
+ ... "gaussian"
787
+ ... )
788
+ """
789
+ filtering = _get_filtering()
790
+
791
+ trace = inputs.get("trace")
792
+ if trace is None:
793
+ raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
794
+
795
+ sigma = params.get("sigma")
796
+ if sigma is None:
797
+ raise PipelineExecutionError(
798
+ "Missing required parameter 'sigma'",
799
+ step_name=step_name,
800
+ )
801
+
802
+ if not isinstance(sigma, (int, float)) or sigma <= 0:
803
+ raise PipelineExecutionError(
804
+ f"Sigma must be a positive number, got {sigma}",
805
+ step_name=step_name,
806
+ )
807
+
808
+ try:
809
+ filtered_trace = filtering.gaussian_filter(
810
+ trace,
811
+ sigma=sigma,
812
+ )
813
+ except Exception as e:
814
+ raise PipelineExecutionError(f"Gaussian filtering failed: {e}", step_name=step_name) from e
815
+
816
+ return {
817
+ "trace": filtered_trace,
818
+ "sigma": sigma,
819
+ }
820
+
821
+
822
+ @register_handler("filter.notch")
823
+ def handle_filter_notch(
824
+ inputs: dict[str, Any], params: dict[str, Any], step_name: str
825
+ ) -> dict[str, Any]:
826
+ """Apply narrow notch filter at specific frequency.
827
+
828
+ Uses band-stop Butterworth filter with bandwidth determined by Q factor.
829
+ Ideal for removing specific interference (e.g., 50/60 Hz power line noise).
830
+
831
+ Inputs:
832
+ trace: WaveformTrace to filter
833
+
834
+ Parameters:
835
+ freq (float): Center frequency to notch out in Hz
836
+ q_factor (float, optional): Quality factor (higher = narrower notch) (default: 30.0)
837
+
838
+ Outputs:
839
+ trace: Filtered WaveformTrace
840
+ freq: Notch frequency
841
+ q_factor: Quality factor used
842
+ bandwidth: Actual bandwidth in Hz (freq / q_factor)
843
+
844
+ Example:
845
+ >>> # Remove 60 Hz power line noise
846
+ >>> result = handle_filter_notch(
847
+ ... {"trace": my_trace},
848
+ ... {"freq": 60, "q_factor": 30},
849
+ ... "notch_60hz"
850
+ ... )
851
+ """
852
+ filtering = _get_filtering()
853
+
854
+ trace = inputs.get("trace")
855
+ if trace is None:
856
+ raise PipelineExecutionError("Missing required input 'trace'", step_name=step_name)
857
+
858
+ freq = params.get("freq")
859
+ if freq is None:
860
+ raise PipelineExecutionError(
861
+ "Missing required parameter 'freq'",
862
+ step_name=step_name,
863
+ )
864
+
865
+ if not isinstance(freq, (int, float)) or freq <= 0:
866
+ raise PipelineExecutionError(
867
+ f"Frequency must be a positive number, got {freq}",
868
+ step_name=step_name,
869
+ )
870
+
871
+ q_factor = params.get("q_factor", 30.0)
872
+
873
+ try:
874
+ filtered_trace = filtering.notch_filter(
875
+ trace,
876
+ freq=freq,
877
+ q_factor=q_factor,
878
+ )
879
+
880
+ # Calculate bandwidth for output
881
+ bandwidth = freq / q_factor
882
+
883
+ except Exception as e:
884
+ raise PipelineExecutionError(f"Notch filtering failed: {e}", step_name=step_name) from e
885
+
886
+ return {
887
+ "trace": filtered_trace,
888
+ "freq": freq,
889
+ "q_factor": q_factor,
890
+ "bandwidth": bandwidth,
891
+ }