ezmsg-sigproc 1.8.2__tar.gz → 2.1.0__tar.gz

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 (125) hide show
  1. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/PKG-INFO +2 -1
  2. ezmsg_sigproc-2.1.0/docs/ProcessorsBase.md +173 -0
  3. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/pyproject.toml +14 -16
  4. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/__version__.py +2 -2
  5. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/activation.py +84 -0
  6. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/adaptive_lattice_notch.py +231 -0
  7. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/affinetransform.py +239 -0
  8. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/aggregate.py +215 -0
  9. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/bandpower.py +88 -0
  10. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/base.py +1284 -0
  11. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/butterworthfilter.py +37 -33
  12. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/cheby.py +29 -17
  13. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/combfilter.py +163 -0
  14. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/decimate.py +19 -10
  15. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/detrend.py +29 -0
  16. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/diff.py +81 -0
  17. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/downsample.py +120 -0
  18. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/ewma.py +197 -0
  19. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/extract_axis.py +41 -0
  20. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/filter.py +315 -0
  21. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/filterbank.py +329 -0
  22. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/math/abs.py +29 -0
  23. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/math/clip.py +40 -0
  24. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/math/difference.py +34 -30
  25. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/math/invert.py +23 -0
  26. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/math/log.py +47 -0
  27. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/math/scale.py +32 -0
  28. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/quantize.py +71 -0
  29. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/resample.py +298 -0
  30. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/sampler.py +308 -0
  31. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/scaler.py +127 -0
  32. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/signalinjector.py +81 -0
  33. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/slicer.py +158 -0
  34. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/spectrogram.py +97 -0
  35. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/spectrum.py +298 -0
  36. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/synth.py +774 -0
  37. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/transpose.py +131 -0
  38. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/util/asio.py +156 -0
  39. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/util/message.py +31 -0
  40. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/util/profile.py +55 -12
  41. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/util/typeresolution.py +83 -0
  42. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/wavelets.py +196 -0
  43. ezmsg_sigproc-2.1.0/src/ezmsg/sigproc/window.py +380 -0
  44. ezmsg_sigproc-2.1.0/tests/conftest.py +4 -0
  45. ezmsg_sigproc-2.1.0/tests/helpers/__init__.py +0 -0
  46. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/tests/helpers/util.py +84 -8
  47. ezmsg_sigproc-2.1.0/tests/integration/bytewax/test_spectrum_bytewax.py +68 -0
  48. ezmsg_sigproc-2.1.0/tests/integration/bytewax/test_window_bytewax.py +59 -0
  49. ezmsg_sigproc-1.8.2/tests/test_butterworth.py → ezmsg_sigproc-2.1.0/tests/integration/ezmsg/test_butterworth_system.py +2 -2
  50. ezmsg_sigproc-1.8.2/tests/test_decimate.py → ezmsg_sigproc-2.1.0/tests/integration/ezmsg/test_decimate_system.py +1 -1
  51. ezmsg_sigproc-1.8.2/tests/test_downsample.py → ezmsg_sigproc-2.1.0/tests/integration/ezmsg/test_downsample_system.py +3 -72
  52. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/integration/ezmsg}/test_filter_system.py +1 -1
  53. ezmsg_sigproc-1.8.2/tests/test_sampler.py → ezmsg_sigproc-2.1.0/tests/integration/ezmsg/test_sampler_system.py +5 -92
  54. ezmsg_sigproc-1.8.2/tests/test_scaler.py → ezmsg_sigproc-2.1.0/tests/integration/ezmsg/test_scaler_system.py +2 -74
  55. ezmsg_sigproc-2.1.0/tests/integration/ezmsg/test_spectrum_system.py +104 -0
  56. ezmsg_sigproc-1.8.2/tests/test_synth.py → ezmsg_sigproc-2.1.0/tests/integration/ezmsg/test_synth_system.py +2 -143
  57. ezmsg_sigproc-2.1.0/tests/integration/ezmsg/test_window_system.py +192 -0
  58. ezmsg_sigproc-2.1.0/tests/test_profile.py +191 -0
  59. ezmsg_sigproc-2.1.0/tests/unit/test_adaptive_lattice_notch.py +159 -0
  60. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_affine_transform.py +3 -3
  61. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_aggregate.py +25 -1
  62. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_bandpower.py +6 -2
  63. ezmsg_sigproc-2.1.0/tests/unit/test_base.py +1183 -0
  64. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_butter.py +95 -0
  65. ezmsg_sigproc-2.1.0/tests/unit/test_combfilter.py +332 -0
  66. ezmsg_sigproc-2.1.0/tests/unit/test_diff.py +127 -0
  67. ezmsg_sigproc-2.1.0/tests/unit/test_downsample.py +77 -0
  68. ezmsg_sigproc-2.1.0/tests/unit/test_ewma.py +51 -0
  69. ezmsg_sigproc-2.1.0/tests/unit/test_extract_axis.py +65 -0
  70. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_filterbank.py +1 -9
  71. ezmsg_sigproc-2.1.0/tests/unit/test_quantize.py +89 -0
  72. ezmsg_sigproc-2.1.0/tests/unit/test_resample.py +145 -0
  73. ezmsg_sigproc-2.1.0/tests/unit/test_sampler.py +94 -0
  74. ezmsg_sigproc-2.1.0/tests/unit/test_scaler.py +73 -0
  75. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_slicer.py +1 -1
  76. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_spectrogram.py +7 -4
  77. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_spectrum.py +1 -96
  78. ezmsg_sigproc-2.1.0/tests/unit/test_synth.py +150 -0
  79. ezmsg_sigproc-2.1.0/tests/unit/test_transpose.py +64 -0
  80. ezmsg_sigproc-2.1.0/tests/unit/test_util.py +121 -0
  81. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_wavelets.py +1 -9
  82. ezmsg_sigproc-2.1.0/tests/unit/test_window.py +219 -0
  83. ezmsg_sigproc-2.1.0/uv.lock +729 -0
  84. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/activation.py +0 -87
  85. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/affinetransform.py +0 -233
  86. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/aggregate.py +0 -183
  87. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/bandpower.py +0 -76
  88. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/base.py +0 -42
  89. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/downsample.py +0 -126
  90. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/filter.py +0 -199
  91. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/filterbank.py +0 -281
  92. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/math/abs.py +0 -34
  93. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/math/clip.py +0 -40
  94. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/math/invert.py +0 -35
  95. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/math/log.py +0 -52
  96. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/math/scale.py +0 -40
  97. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/sampler.py +0 -326
  98. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/scaler.py +0 -290
  99. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/signalinjector.py +0 -72
  100. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/slicer.py +0 -166
  101. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/spectrogram.py +0 -95
  102. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/spectrum.py +0 -263
  103. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/synth.py +0 -621
  104. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/wavelets.py +0 -195
  105. ezmsg_sigproc-1.8.2/src/ezmsg/sigproc/window.py +0 -322
  106. ezmsg_sigproc-1.8.2/tests/conftest.py +0 -4
  107. ezmsg_sigproc-1.8.2/tests/test_window.py +0 -466
  108. ezmsg_sigproc-1.8.2/uv.lock +0 -716
  109. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/.github/workflows/python-publish-ezmsg-sigproc.yml +0 -0
  110. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/.github/workflows/python-tests.yml +0 -0
  111. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/.gitignore +0 -0
  112. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/.pre-commit-config.yaml +0 -0
  113. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/LICENSE.txt +0 -0
  114. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/README.md +0 -0
  115. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/__init__.py +0 -0
  116. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/ewmfilter.py +0 -0
  117. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/math/__init__.py +0 -0
  118. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/messages.py +0 -0
  119. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/spectral.py +0 -0
  120. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/util/__init__.py +0 -0
  121. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/src/ezmsg/sigproc/util/sparse.py +0 -0
  122. {ezmsg_sigproc-1.8.2/tests/helpers → ezmsg_sigproc-2.1.0/tests}/__init__.py +0 -0
  123. {ezmsg_sigproc-1.8.2 → ezmsg_sigproc-2.1.0}/tests/resources/xform.csv +0 -0
  124. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_activation.py +0 -0
  125. {ezmsg_sigproc-1.8.2/tests → ezmsg_sigproc-2.1.0/tests/unit}/test_math.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ezmsg-sigproc
3
- Version: 1.8.2
3
+ Version: 2.1.0
4
4
  Summary: Timeseries signal processing implementations in ezmsg
5
5
  Author-email: Griffin Milsap <griffin.milsap@gmail.com>, Preston Peranich <pperanich@gmail.com>, Chadwick Boulay <chadwick.boulay@gmail.com>
6
6
  License-Expression: MIT
7
7
  License-File: LICENSE.txt
8
8
  Requires-Python: >=3.10.15
9
+ Requires-Dist: array-api-compat>=1.11.1
9
10
  Requires-Dist: ezmsg>=3.6.0
10
11
  Requires-Dist: numba>=0.61.0
11
12
  Requires-Dist: numpy>=1.26.0
@@ -0,0 +1,173 @@
1
+ ## Processor Base Classes
2
+
3
+ The `ezmsg.sigproc.base` module contains the base classes for the signal processors. The base classes are designed to allow users to create custom signal processors with minimal errors and minimal repetition of boilerplate code.
4
+
5
+ > The information below was written at the time of a major refactor to `ezmsg.sigproc.base` to help collate the design decisions and to help with future refactoring. However, it may be outdated or incomplete. Please refer to the source code for the most accurate information.
6
+
7
+ > Updates: Added CompositeProducer, BaseProcessorUnit.
8
+
9
+
10
+ ### Generic TypeVars
11
+
12
+ | Idx | Class | Description |
13
+ |-----|-----------------------|----------------------------------------------------------------------------|
14
+ | 1 | `MessageInType` (Mi) | for messages passed to a consumer, processor, or transformer |
15
+ | 2 | `MessageOutType` (Mo) | for messages returned by a producer, processor, or transformer |
16
+ | 3 | `SettingsType` | bound to ez.Settings |
17
+ | 4 | `StateType` (St) | bound to ProcessorState which is simply ez.State with a `hash: int` field. |
18
+
19
+
20
+ ### Protocols
21
+
22
+ | Idx | Class | Parent | State | `__call__` sig | @state | partial_fit |
23
+ |-----|-----------------------|--------|-------|--------------------------|--------|-------------|
24
+ | 1 | `Processor` | - | No | Any -> Any | - | - |
25
+ | 2 | `Producer` | - | No | None -> Mo | - | - |
26
+ | 3 | `Consumer` | 1 | No | Mi -> None | - | - |
27
+ | 4 | `Transformer` | 1 | No | Mi -> Mo | - | - |
28
+ | 5 | `StatefulProcessor` | - | Yes | Any -> Any | Y | - |
29
+ | 6 | `StatefulProducer` | - | Yes | None -> Mo | Y | - |
30
+ | 7 | `StatefulConsumer` | 5 | Yes | Mi -> None | Y | - |
31
+ | 8 | `StatefulTransformer` | 5 | Yes | Mi -> Mo | Y | - |
32
+ | 9 | `AdaptiveTransformer` | 8 | Yes | Mi -> Mo | Y | Y |
33
+
34
+ Note: `__call__` and `partial_fit` both have asynchronous alternatives: `__acall__` and `apartial_fit` respectively.
35
+
36
+
37
+ ### Abstract implementations (Base Classes) for standalone processors
38
+
39
+ | Idx | Class | Parent | Protocol | Features |
40
+ |-----|---------------------------|--------|----------|--------------------------------------------------------------------------------------------|
41
+ | 1 | `BaseProcessor` | - | 1 | `__init__` for settings; `__call__` (alias: `send`) wraps abstract `_process`. |
42
+ | 2 | `BaseProducer` | - | 2 | Similar to `BaseProcessor`; `next`/`anext` instead of `send`/`asend` aliases. async first! |
43
+ | 3 | `BaseConsumer` | 1 | 3 | Overrides return type to None |
44
+ | 4 | `BaseTransformer` | 1 | 4 | Overrides input and return types |
45
+ | 5 | `BaseStatefulProcessor` | 1 | 5 | `state` setter unpickles arg; `stateful_op` wraps `__call__` |
46
+ | 6 | `BaseStatefulProducer` | 2 | 6 | `state` setter and getter; `stateful_op` wraps `__call__` which runs `__acall__`. |
47
+ | 7 | `BaseStatefulConsumer` | 5 | 7 | Overrides return type to None |
48
+ | 8 | `BaseStatefulTransformer` | 5 | 8 | Overrides input and return types |
49
+ | 9 | `BaseAdaptiveTransformer` | 8 | 9 | Implements `partial_fit`. `__call__` may call `partial_fit` if message has `.trigger`. |
50
+ | 10 | `BaseAsyncTransformer` | 8 | 8 | `__acall__` wraps abstract `_aprocess`; `__call__` runs `__acall__`. |
51
+ | 11 | `CompositeProcessor` | 1 | 5 | Methods iterate over sequence of processors created in `_initialize_processors`. |
52
+ | 12 | `CompositeProducer` | 2 | 6 | Similar to `CompositeProcessor`, but first processor must be a producer. |
53
+
54
+ NOTES:
55
+ 1. Producers do not inherit from `BaseProcessor`, so concrete implementations should subclass `BaseProducer` or `BaseStatefulProducer`.
56
+ 2. For concrete implementations of non-producer processors, inherit from the base subclasses of `BaseProcessor` (eg. `BaseConsumer`, `BaseTransformer`) and from base subclasses of `BaseStatefulProcessor`. These two processor classes are primarily used for efficient abstract base class construction.
57
+ 3. For most base classes, the async methods simply call the synchronous methods where the processor logic is expected. Exceptions are `BaseProducer` (and its children) and `BaseAsyncTransformer` which are async-first and should be strongly considered for operations that are I/O bound.
58
+ 4. For async-first classes, the logic is implemented in the async methods and the sync methods are thin wrappers around the async methods. The wrapper uses a helper method called `run_coroutine_sync` to run the async method in a synchronous context, but this adds some noticeable processing overhead.
59
+ 5. If you need to call your processor outside ezmsg (which uses async), and you cannot easily add an async context* in your processing, then you might want to consider duplicating the processor logic in the sync methods. __Note__: Jupyter notebooks are async by default, so you can await async code in a notebook without any extra setup.
60
+ 6. `CompositeProcessor` and `CompositeProducer` are stateful, and structurally subclass the `StatefulProcessor` and `StatefulProducer` protocols, but they
61
+ do not inherit from `BaseStatefulProcessor` and `BaseStatefulProducer`. They accomplish statefulness by inheriting from the mixin abstract base class `CompositeStateful`, which implements the state related methods: `get_state_type`, `state.setter`, `state.getter`, `_hash_message`, `_reset_state`, and `stateful_op` (as well as composite processor chain related methods). However, `BaseStatefulProcessor`, `BaseStatefulProducer` implement `stateful_op` method for a single processor in an incompatible way to what is required for composite chains of processors.
62
+
63
+
64
+ ### Generic TypeVars for ezmsg Units
65
+
66
+ | Idx | Class | Description |
67
+ |-----|---------------------------|------------------------------------------------------------------------------------------------------------------|
68
+ | 5 | `ProducerType` | bound to `BaseProducer` (hence, also `BaseStatefulProducer`, `CompositeProducer`) |
69
+ | 6 | `ConsumerType` | bound to `BaseConsumer`, `BaseStatefulConsumer` |
70
+ | 7 | `TransformerType` | bound to `BaseTransformer`, `BaseStatefulTransformer`, `CompositeProcessor` (hence, also `BaseAsyncTransformer`) |
71
+ | 8 | `AdaptiveTransformerType` | bound to `BaseAdaptiveTransformer` |
72
+
73
+
74
+ ### Abstract implementations (Base Classes) for ezmsg Units using processors:
75
+
76
+ | Idx | Class | Parents | Expected TypeVars |
77
+ |-----|-------------------------------|---------|---------------------------|
78
+ | 1 | `BaseProcessorUnit` | - | - |
79
+ | 2 | `BaseProducerUnit` | - | `ProducerType` |
80
+ | 3 | `BaseConsumerUnit` | 1 | `ConsumerType` |
81
+ | 4 | `BaseTransformerUnit` | 1 | `TransformerType` |
82
+ | 5 | `BaseAdaptiveTransformerUnit` | 1 | `AdaptiveTransformerType` |
83
+
84
+ Note, it is strongly recommended to use `BaseConsumerUnit`, `BaseTransformerUnit`, or `BaseAdaptiveTransformerUnit` for implementing concrete subclasses rather than `BaseProcessorUnit`.
85
+
86
+
87
+ ## Implementing a custom standalone processor
88
+
89
+ 1. Create a new settings dataclass: `class MySettings(ez.Settings):`
90
+ 2. Create a new state dataclass:
91
+ ```
92
+ @processor_state
93
+ class MyState:
94
+ ```
95
+ 3. Decide on your base processor class, considering the protocol, whether it should be async-first, and other factors.
96
+
97
+ ```mermaid
98
+ flowchart TD
99
+ AMP{Multiple Processors?};
100
+ AMP -->|no| ARI{Receives Input?};
101
+ AMP -->|yes| ACB{Single Chain / Branching?}
102
+ ARI -->|no| P(Producer);
103
+ ARI -->|yes| APO{Produces Output?};
104
+ ACB -->|branching| NBC[no base class];
105
+ ACB -->|single chain| ACRI{Receives Input?};
106
+ P --> PS{Stateful?};
107
+ APO -->|no| C(Consumer);
108
+ APO -->|yes| T(Transformer);
109
+ ACRI -->|no| CompositeProducer;
110
+ ACRI -->|yes| CompositeProcessor;
111
+ PS -->|no| BaseProducer;
112
+ PS -->|yes| BaseStatefulProducer;
113
+ C --> CS{Stateful?};
114
+ T --> TS{Stateful?};
115
+ CS -->|no| BaseConsumer;
116
+ CS -->|yes| BaseStatefulConsumer;
117
+ TS -->|no| BaseTransformer;
118
+ TS -->|yes| TSA{Adaptive?};
119
+ TSA -->|no| TSAF{Async First?};
120
+ TSA -->|yes| BaseAdaptiveTransformer;
121
+ TSAF -->|no| BaseStatefulTransformer;
122
+ TSAF -->|yes| BaseAsyncTransformer;
123
+ ```
124
+
125
+ 4. Implement the child class.
126
+ * The minimum implementation is `_process` for sync processors, `_aprocess` for async processors, and `_produce` for producers.
127
+ * For any stateful processor, implement `_reset_state`.
128
+ * For stateful processors that need to respond to a change in the incoming data, implement `_hash_message`.
129
+ * For adaptive transformers, implement `partial_fit`.
130
+ * For chains of processors (`CompositeProcessor`/ `CompositeProducer`), need to implement `_initialize_processors`.
131
+ * See processors in `ezmsg.sigproc` for examples.
132
+ 5. Override non-abstract methods if you need special behaviour. For example:
133
+ * `WindowTransformer` overrides `__init__` to do some sanity checks on the provided settings.
134
+ * `TransposeTransformer` and `WindowTransformer` override `__call__` to provide a passthrough shortcut when the settings allow for it.
135
+ * `ClockProducer` overrides `__call__` in order to provide a synchronous call bypassing the default async behaviour.
136
+
137
+
138
+ ## Implementing a custom ezmsg Unit
139
+
140
+ 1. Create and test custom standalone processor as above.
141
+ 2. Decide which base unit to implement.
142
+ * Use the "Generic TypeVars for ezmsg Units" table above to determine the expected TypeVar.
143
+ * Fine the Expected TypeVar in the "ezmsg Units" table.
144
+ 3. Create the derived class.
145
+
146
+ Often, all that is required is the following (e.g., for a custom transformer):
147
+
148
+ ```Python
149
+ import ezmsg.core as ez
150
+ from ezmsg.util.messages.axisarray import AxisArray
151
+ from ezmsg.sigproc.base import BaseTransformer, BaseTransformerUnit
152
+
153
+
154
+ class CustomTransformerSettings(ez.Settings):
155
+ ...
156
+
157
+
158
+ class CustomTransformer(BaseTransformer[CustomTransformerSettings, AxisArray, AxisArray]):
159
+ def _process(self, message: AxisArray) -> AxisArray:
160
+ # Your processing code here...
161
+ return message
162
+
163
+
164
+ class CustomUnit(BaseTransformerUnit[
165
+ CustomTransformerSettings, # SettingsType
166
+ AxisArray, # MessageInType
167
+ AxisArray, # MessageOutType
168
+ CustomTransformer, # TransformerType
169
+ ]):
170
+ SETTINGS = CustomTransformerSettings
171
+ ```
172
+
173
+ __Note__, the type of ProcessorUnit is based on the internal processor and not the input or output of the unit. Input streams are allowed in ProducerUnits and output streams in ConsumerUnits. For an example of such a use case, see `BaseCounterFirstProducerUnit` and its subclasses. `BaseCounterFirstProducerUnit` has an input stream that receives a flag signal from a clock that triggers a call to the internal producer.
@@ -4,28 +4,29 @@ description = "Timeseries signal processing implementations in ezmsg"
4
4
  authors = [
5
5
  { name = "Griffin Milsap", email = "griffin.milsap@gmail.com" },
6
6
  { name = "Preston Peranich", email = "pperanich@gmail.com" },
7
- { name = "Chadwick Boulay", email = "chadwick.boulay@gmail.com" }
7
+ { name = "Chadwick Boulay", email = "chadwick.boulay@gmail.com" },
8
8
  ]
9
9
  license = "MIT"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10.15"
12
12
  dynamic = ["version"]
13
13
  dependencies = [
14
- "ezmsg>=3.6.0",
15
- "numba>=0.61.0",
16
- "numpy>=1.26.0",
17
- "pywavelets>=1.6.0",
18
- "scipy>=1.13.1",
19
- "sparse>=0.15.4",
14
+ "array-api-compat>=1.11.1",
15
+ "ezmsg>=3.6.0",
16
+ "numba>=0.61.0",
17
+ "numpy>=1.26.0",
18
+ "pywavelets>=1.6.0",
19
+ "scipy>=1.13.1",
20
+ "sparse>=0.15.4",
20
21
  ]
21
22
 
22
23
  [project.optional-dependencies]
23
24
  test = [
24
- "flake8>=7.1.1",
25
- "frozendict>=2.4.4",
26
- "pytest-asyncio>=0.24.0",
27
- "pytest-cov>=5.0.0",
28
- "pytest>=8.3.3",
25
+ "flake8>=7.1.1",
26
+ "frozendict>=2.4.4",
27
+ "pytest-asyncio>=0.24.0",
28
+ "pytest-cov>=5.0.0",
29
+ "pytest>=8.3.3",
29
30
  ]
30
31
 
31
32
  [build-system]
@@ -42,10 +43,7 @@ version-file = "src/ezmsg/sigproc/__version__.py"
42
43
  packages = ["src/ezmsg"]
43
44
 
44
45
  [tool.uv]
45
- dev-dependencies = [
46
- "pre-commit>=3.8.0",
47
- "ruff>=0.6.7",
48
- ]
46
+ dev-dependencies = ["pre-commit>=3.8.0", "ruff>=0.6.7"]
49
47
 
50
48
  [tool.pytest.ini_options]
51
49
  norecursedirs = "tests/helpers"
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '1.8.2'
21
- __version_tuple__ = version_tuple = (1, 8, 2)
20
+ __version__ = version = '2.1.0'
21
+ __version_tuple__ = version_tuple = (2, 1, 0)
@@ -0,0 +1,84 @@
1
+ import scipy.special
2
+ import ezmsg.core as ez
3
+ from ezmsg.util.messages.axisarray import AxisArray
4
+ from ezmsg.util.messages.util import replace
5
+
6
+ from .spectral import OptionsEnum
7
+ from .base import BaseTransformer, BaseTransformerUnit
8
+
9
+
10
+ class ActivationFunction(OptionsEnum):
11
+ """Activation (transformation) function."""
12
+
13
+ NONE = "none"
14
+ """None."""
15
+
16
+ SIGMOID = "sigmoid"
17
+ """:obj:`scipy.special.expit`"""
18
+
19
+ EXPIT = "expit"
20
+ """:obj:`scipy.special.expit`"""
21
+
22
+ LOGIT = "logit"
23
+ """:obj:`scipy.special.logit`"""
24
+
25
+ LOGEXPIT = "log_expit"
26
+ """:obj:`scipy.special.log_expit`"""
27
+
28
+
29
+ ACTIVATIONS = {
30
+ ActivationFunction.NONE: lambda x: x,
31
+ ActivationFunction.SIGMOID: scipy.special.expit,
32
+ ActivationFunction.EXPIT: scipy.special.expit,
33
+ ActivationFunction.LOGIT: scipy.special.logit,
34
+ ActivationFunction.LOGEXPIT: scipy.special.log_expit,
35
+ }
36
+
37
+
38
+ class ActivationSettings(ez.Settings):
39
+ function: str | ActivationFunction = ActivationFunction.NONE
40
+ """An enum value from ActivationFunction or a string representing the activation function.
41
+ Possible values are: SIGMOID, EXPIT, LOGIT, LOGEXPIT, "sigmoid", "expit", "logit", "log_expit".
42
+ SIGMOID and EXPIT are equivalent. See :obj:`scipy.special.expit` for more details."""
43
+
44
+
45
+ class ActivationTransformer(BaseTransformer[ActivationSettings, AxisArray, AxisArray]):
46
+ def _process(self, message: AxisArray) -> AxisArray:
47
+ if type(self.settings.function) is ActivationFunction:
48
+ func = ACTIVATIONS[self.settings.function]
49
+ else:
50
+ # str type handling
51
+ function = self.settings.function.lower()
52
+ if function not in ActivationFunction.options():
53
+ raise ValueError(
54
+ f"Unrecognized activation function {function}. Must be one of {ACTIVATIONS.keys()}"
55
+ )
56
+ function = list(ACTIVATIONS.keys())[
57
+ ActivationFunction.options().index(function)
58
+ ]
59
+ func = ACTIVATIONS[function]
60
+
61
+ return replace(message, data=func(message.data))
62
+
63
+
64
+ class Activation(
65
+ BaseTransformerUnit[ActivationSettings, AxisArray, AxisArray, ActivationTransformer]
66
+ ):
67
+ SETTINGS = ActivationSettings
68
+
69
+
70
+ def activation(
71
+ function: str | ActivationFunction,
72
+ ) -> ActivationTransformer:
73
+ """
74
+ Transform the data with a simple activation function.
75
+
76
+ Args:
77
+ function: An enum value from ActivationFunction or a string representing the activation function.
78
+ Possible values are: SIGMOID, EXPIT, LOGIT, LOGEXPIT, "sigmoid", "expit", "logit", "log_expit".
79
+ SIGMOID and EXPIT are equivalent. See :obj:`scipy.special.expit` for more details.
80
+
81
+ Returns: :obj:`ActivationTransformer`
82
+
83
+ """
84
+ return ActivationTransformer(ActivationSettings(function=function))
@@ -0,0 +1,231 @@
1
+ import numpy as np
2
+ import numpy.typing as npt
3
+ import scipy.signal
4
+ import ezmsg.core as ez
5
+ from ezmsg.util.messages.axisarray import AxisArray, CoordinateAxis
6
+ from ezmsg.util.messages.util import replace
7
+
8
+ from .base import processor_state, BaseStatefulTransformer
9
+
10
+
11
+ class AdaptiveLatticeNotchFilterSettings(ez.Settings):
12
+ """Settings for the Adaptive Lattice Notch Filter."""
13
+
14
+ gamma: float = 0.995
15
+ """Pole-zero contraction factor"""
16
+ mu: float = 0.99
17
+ """Smoothing factor"""
18
+ eta: float = 0.99
19
+ """Forgetting factor"""
20
+ axis: str = "time"
21
+ """Axis to apply filter to"""
22
+ init_notch_freq: float | None = None
23
+ """Initial notch frequency. Should be < nyquist."""
24
+ chunkwise: bool = False
25
+ """Speed up processing by updating the target freq once per chunk only."""
26
+
27
+
28
+ @processor_state
29
+ class AdaptiveLatticeNotchFilterState:
30
+ """State for the Adaptive Lattice Notch Filter."""
31
+
32
+ s_history: npt.NDArray | None = None
33
+ """Historical `s` values for the adaptive filter."""
34
+
35
+ p: npt.NDArray | None = None
36
+ """Accumulated product for reflection coefficient update"""
37
+
38
+ q: npt.NDArray | None = None
39
+ """Accumulated product for reflection coefficient update"""
40
+
41
+ k1: npt.NDArray | None = None
42
+ """Reflection coefficient"""
43
+
44
+ freq_template: CoordinateAxis | None = None
45
+ """Template for the frequency axis on the output"""
46
+
47
+ zi: npt.NDArray | None = None
48
+ """Initial conditions for the filter, updated after every chunk"""
49
+
50
+
51
+ class AdaptiveLatticeNotchFilterTransformer(
52
+ BaseStatefulTransformer[
53
+ AdaptiveLatticeNotchFilterSettings,
54
+ AxisArray,
55
+ AxisArray,
56
+ AdaptiveLatticeNotchFilterState,
57
+ ]
58
+ ):
59
+ """
60
+ Adaptive Lattice Notch Filter implementation as a stateful transformer.
61
+
62
+ https://biomedical-engineering-online.biomedcentral.com/articles/10.1186/1475-925X-13-170
63
+
64
+ The filter automatically tracks and removes frequency components from the input signal.
65
+ It outputs the estimated frequency (in Hz) and the filtered sample.
66
+ """
67
+
68
+ def _hash_message(self, message: AxisArray) -> int:
69
+ ax_idx = message.get_axis_idx(self.settings.axis)
70
+ sample_shape = message.data.shape[:ax_idx] + message.data.shape[ax_idx + 1 :]
71
+ return hash((message.key, message.axes[self.settings.axis].gain, sample_shape))
72
+
73
+ def _reset_state(self, message: AxisArray) -> None:
74
+ ax_idx = message.get_axis_idx(self.settings.axis)
75
+ sample_shape = message.data.shape[:ax_idx] + message.data.shape[ax_idx + 1 :]
76
+
77
+ fs = 1 / message.axes[self.settings.axis].gain
78
+ init_f = (
79
+ self.settings.init_notch_freq
80
+ if self.settings.init_notch_freq is not None
81
+ else 0.07178314656435313 * fs
82
+ )
83
+ init_omega = init_f * (2 * np.pi) / fs
84
+ init_k1 = -np.cos(init_omega)
85
+
86
+ """Reset filter state to initial values."""
87
+ self._state = AdaptiveLatticeNotchFilterState()
88
+ self._state.s_history = np.zeros((2,) + sample_shape, dtype=float)
89
+ self._state.p = np.zeros(sample_shape, dtype=float)
90
+ self._state.q = np.zeros(sample_shape, dtype=float)
91
+ self._state.k1 = init_k1 + np.zeros(sample_shape, dtype=float)
92
+ self._state.freq_template = CoordinateAxis(
93
+ data=np.zeros((0,) + sample_shape, dtype=float),
94
+ dims=[self.settings.axis]
95
+ + message.dims[:ax_idx]
96
+ + message.dims[ax_idx + 1 :],
97
+ unit="Hz",
98
+ )
99
+
100
+ # Initialize the initial conditions for the filter
101
+ self._state.zi = np.zeros((2, np.prod(sample_shape)), dtype=float)
102
+ # Note: we could calculate it properly, but as long as we are initializing s_history with zeros,
103
+ # it will always be zero.
104
+ # a = [1, init_k1 * (1 + self.settings.gamma), self.settings.gamma]
105
+ # b = [1]
106
+ # s = np.reshape(self._state.s_history, (2, -1))
107
+ # for feat_ix in range(np.prod(sample_shape)):
108
+ # self._state.zi[:, feat_ix] = scipy.signal.lfiltic(b, a, s[::-1, feat_ix], x=None)
109
+
110
+ def _process(self, message: AxisArray) -> AxisArray:
111
+ x_data = message.data
112
+ ax_idx = message.get_axis_idx(self.settings.axis)
113
+
114
+ # TODO: Time should be moved to -1th axis, not the 0th axis
115
+ if message.dims[0] != self.settings.axis:
116
+ x_data = np.moveaxis(x_data, ax_idx, 0)
117
+
118
+ # Access settings once
119
+ gamma = self.settings.gamma
120
+ eta = self.settings.eta
121
+ mu = self.settings.mu
122
+ fs = 1 / message.axes[self.settings.axis].gain
123
+
124
+ # Pre-compute constants
125
+ one_minus_eta = 1 - eta
126
+ one_minus_mu = 1 - mu
127
+ gamma_plus_1 = 1 + gamma
128
+ omega_scale = fs / (2 * np.pi)
129
+
130
+ # For the lattice filter with constant k1:
131
+ # s_n = x_n - k1*(1+gamma)*s_n_1 - gamma*s_n_2
132
+ # This is equivalent to an IIR filter with b=1, a=[1, k1*(1+gamma), gamma]
133
+
134
+ # For the output filter:
135
+ # y_n = s_n + 2*k1*s_n_1 + s_n_2
136
+ # We can treat this as a direct-form FIR filter applied to s_out
137
+
138
+ if self.settings.chunkwise:
139
+ # Process each chunk using current filter parameters
140
+ # Reshape input and prepare output arrays
141
+ _s = self._state.s_history.reshape((2, -1))
142
+ _x = x_data.reshape((x_data.shape[0], -1))
143
+ s_n = np.zeros_like(_x)
144
+ y_out = np.zeros_like(_x)
145
+
146
+ # Apply static filter for each feature dimension
147
+ for ix, k in enumerate(self._state.k1.flatten()):
148
+ # Filter to get s_n (notch filter state)
149
+ a_s = [1, k * gamma_plus_1, gamma]
150
+ s_n[:, ix], self._state.zi[:, ix] = scipy.signal.lfilter(
151
+ [1], a_s, _x[:, ix], zi=self._state.zi[:, ix]
152
+ )
153
+
154
+ # Apply output filter to get y_out
155
+ b_y = [1, 2 * k, 1]
156
+ y_out[:, ix] = scipy.signal.lfilter(b_y, [1], s_n[:, ix])
157
+
158
+ # Update filter parameters using final values from the chunk
159
+ s_n_reshaped = s_n.reshape((s_n.shape[0],) + x_data.shape[1:])
160
+ s_final = s_n_reshaped[-1] # Current s_n
161
+ s_final_1 = s_n_reshaped[-2] # s_n_1
162
+ s_final_2 = (
163
+ s_n_reshaped[-3] if len(s_n_reshaped) > 2 else self._state.s_history[0]
164
+ ) # s_n_2
165
+
166
+ # Update p and q using final values
167
+ self._state.p = eta * self._state.p + one_minus_eta * (
168
+ s_final_1 * (s_final + s_final_2)
169
+ )
170
+ self._state.q = eta * self._state.q + one_minus_eta * (
171
+ 2 * (s_final_1 * s_final_1)
172
+ )
173
+
174
+ # Update reflection coefficient
175
+ new_k1 = -self._state.p / (self._state.q + 1e-8) # Avoid division by zero
176
+ new_k1 = np.clip(new_k1, -1, 1) # Clip to prevent instability
177
+ self._state.k1 = mu * self._state.k1 + one_minus_mu * new_k1 # Smoothed
178
+
179
+ # Calculate frequency from updated k1 value
180
+ omega_n = np.arccos(-self._state.k1)
181
+ freq = omega_n * omega_scale
182
+ freq_out = np.full_like(x_data.reshape(x_data.shape), freq)
183
+
184
+ # Update s_history for next chunk
185
+ self._state.s_history = s_n_reshaped[-2:].reshape((2,) + x_data.shape[1:])
186
+
187
+ # Reshape y_out back to original dimensions
188
+ y_out = y_out.reshape(x_data.shape)
189
+
190
+ else:
191
+ # Perform filtering, sample-by-sample
192
+ y_out = np.zeros_like(x_data)
193
+ freq_out = np.zeros_like(x_data)
194
+ for sample_ix, x_n in enumerate(x_data):
195
+ s_n_1 = self._state.s_history[-1]
196
+ s_n_2 = self._state.s_history[-2]
197
+
198
+ s_n = x_n - self._state.k1 * gamma_plus_1 * s_n_1 - gamma * s_n_2
199
+ y_out[sample_ix] = s_n + 2 * self._state.k1 * s_n_1 + s_n_2
200
+
201
+ # Update filter parameters
202
+ self._state.p = eta * self._state.p + one_minus_eta * (
203
+ s_n_1 * (s_n + s_n_2)
204
+ )
205
+ self._state.q = eta * self._state.q + one_minus_eta * (
206
+ 2 * (s_n_1 * s_n_1)
207
+ )
208
+
209
+ # Update reflection coefficient
210
+ new_k1 = -self._state.p / (
211
+ self._state.q + 1e-8
212
+ ) # Avoid division by zero
213
+ new_k1 = np.clip(new_k1, -1, 1) # Clip to prevent instability
214
+ self._state.k1 = mu * self._state.k1 + one_minus_mu * new_k1 # Smoothed
215
+
216
+ # Compute normalized angular frequency using equation 13 from the paper
217
+ omega_n = np.arccos(-self._state.k1)
218
+ freq_out[sample_ix] = omega_n * omega_scale # As Hz
219
+
220
+ # Update for next iteration
221
+ self._state.s_history[-2] = s_n_1
222
+ self._state.s_history[-1] = s_n
223
+
224
+ return replace(
225
+ message,
226
+ data=y_out,
227
+ axes={
228
+ **message.axes,
229
+ "freq": replace(self._state.freq_template, data=freq_out),
230
+ },
231
+ )