ezmsg-baseproc 1.1.0__tar.gz → 1.2.1__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 (50) hide show
  1. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/PKG-INFO +1 -1
  2. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/guides/ProcessorsBase.md +25 -20
  3. ezmsg_baseproc-1.2.1/docs/source/guides/how-tos/processors/clockdriven.rst +224 -0
  4. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/guides/how-tos/processors/content-processors.rst +1 -0
  5. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/__init__.py +19 -0
  6. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/__version__.py +2 -2
  7. ezmsg_baseproc-1.2.1/src/ezmsg/baseproc/clockdriven.py +179 -0
  8. ezmsg_baseproc-1.2.1/src/ezmsg/baseproc/counter.py +67 -0
  9. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/units.py +48 -1
  10. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/util/typeresolution.py +9 -1
  11. ezmsg_baseproc-1.2.1/tests/conftest.py +18 -0
  12. ezmsg_baseproc-1.2.1/tests/helpers/__init__.py +0 -0
  13. ezmsg_baseproc-1.2.1/tests/helpers/util.py +24 -0
  14. ezmsg_baseproc-1.2.1/tests/test_clock_counter_system.py +229 -0
  15. ezmsg_baseproc-1.2.1/tests/test_clockdriven.py +373 -0
  16. ezmsg_baseproc-1.1.0/src/ezmsg/baseproc/counter.py +0 -128
  17. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/.github/workflows/docs.yml +0 -0
  18. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/.github/workflows/python-publish.yml +0 -0
  19. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/.github/workflows/python-tests.yml +0 -0
  20. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/.gitignore +0 -0
  21. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/.pre-commit-config.yaml +0 -0
  22. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/LICENSE +0 -0
  23. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/README.md +0 -0
  24. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/Makefile +0 -0
  25. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/make.bat +0 -0
  26. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/_templates/autosummary/module.rst +0 -0
  27. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/api/index.rst +0 -0
  28. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/conf.py +0 -0
  29. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/guides/how-tos/processors/adaptive.rst +0 -0
  30. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/guides/how-tos/processors/checkpoint.rst +0 -0
  31. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/guides/how-tos/processors/composite.rst +0 -0
  32. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/guides/how-tos/processors/processor.rst +0 -0
  33. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/guides/how-tos/processors/standalone.rst +0 -0
  34. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/guides/how-tos/processors/stateful.rst +0 -0
  35. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/guides/how-tos/processors/unit.rst +0 -0
  36. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/docs/source/index.md +0 -0
  37. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/pyproject.toml +0 -0
  38. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/clock.py +0 -0
  39. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/composite.py +0 -0
  40. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/processor.py +0 -0
  41. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/protocols.py +0 -0
  42. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/stateful.py +0 -0
  43. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/util/__init__.py +0 -0
  44. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/util/asio.py +0 -0
  45. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/util/message.py +0 -0
  46. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/src/ezmsg/baseproc/util/profile.py +0 -0
  47. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/tests/test_baseproc.py +0 -0
  48. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/tests/test_clock.py +0 -0
  49. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/tests/test_counter.py +0 -0
  50. {ezmsg_baseproc-1.1.0 → ezmsg_baseproc-1.2.1}/tests/test_profile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ezmsg-baseproc
3
- Version: 1.1.0
3
+ Version: 1.2.1
4
4
  Summary: Base processor classes and protocols for ezmsg signal processing pipelines
5
5
  Author-email: Griffin Milsap <griffin.milsap@gmail.com>, Preston Peranich <pperanich@gmail.com>, Chadwick Boulay <chadwick.boulay@gmail.com>, Kyle McGraw <kmcgraw@blackrockneuro.com>
6
6
  License-Expression: MIT
@@ -6,12 +6,13 @@ The `ezmsg.baseproc` module contains the base classes for message processors. Th
6
6
 
7
7
  ### Generic TypeVars
8
8
 
9
- | Idx | Class | Description |
10
- |-----|-----------------------|----------------------------------------------------------------------------|
11
- | 1 | `MessageInType` (Mi) | for messages passed to a consumer, processor, or transformer |
12
- | 2 | `MessageOutType` (Mo) | for messages returned by a producer, processor, or transformer |
13
- | 3 | `SettingsType` | bound to ez.Settings |
14
- | 4 | `StateType` (St) | bound to ProcessorState which is simply ez.State with a `hash: int` field. |
9
+ | Idx | Class | Description |
10
+ |-----|----------------------------|----------------------------------------------------------------------------|
11
+ | 1 | `MessageInType` (Mi) | for messages passed to a consumer, processor, or transformer |
12
+ | 2 | `MessageOutType` (Mo) | for messages returned by a producer, processor, or transformer |
13
+ | 3 | `SettingsType` | bound to ez.Settings |
14
+ | 4 | `StateType` (St) | bound to ProcessorState which is simply ez.State with a `hash: int` field. |
15
+ | 5 | `ClockDrivenSettingsType` | bound to `ClockDrivenSettings` (provides `fs` and `n_time`) |
15
16
 
16
17
 
17
18
  ### Protocols
@@ -47,6 +48,7 @@ Note: `__call__` and `partial_fit` both have asynchronous alternatives: `__acall
47
48
  | 10 | `BaseAsyncTransformer` | 8 | 8 | `__acall__` wraps abstract `_aprocess`; `__call__` runs `__acall__`. |
48
49
  | 11 | `CompositeProcessor` | 1 | 5 | Methods iterate over sequence of processors created in `_initialize_processors`. |
49
50
  | 12 | `CompositeProducer` | 2 | 6 | Similar to `CompositeProcessor`, but first processor must be a producer. |
51
+ | 13 | `BaseClockDrivenProducer` | 5 | 8 | Clock-driven data generator. Implements `_produce(n_samples, time_axis)`. |
50
52
 
51
53
  NOTES:
52
54
  1. Producers do not inherit from `BaseProcessor`, so concrete implementations should subclass `BaseProducer` or `BaseStatefulProducer`.
@@ -60,25 +62,27 @@ do not inherit from `BaseStatefulProcessor` and `BaseStatefulProducer`. They acc
60
62
 
61
63
  ### Generic TypeVars for ezmsg Units
62
64
 
63
- | Idx | Class | Description |
64
- |-----|---------------------------|------------------------------------------------------------------------------------------------------------------|
65
- | 5 | `ProducerType` | bound to `BaseProducer` (hence, also `BaseStatefulProducer`, `CompositeProducer`) |
66
- | 6 | `ConsumerType` | bound to `BaseConsumer`, `BaseStatefulConsumer` |
67
- | 7 | `TransformerType` | bound to `BaseTransformer`, `BaseStatefulTransformer`, `CompositeProcessor` (hence, also `BaseAsyncTransformer`) |
68
- | 8 | `AdaptiveTransformerType` | bound to `BaseAdaptiveTransformer` |
65
+ | Idx | Class | Description |
66
+ |-----|----------------------------|------------------------------------------------------------------------------------------------------------------|
67
+ | 5 | `ProducerType` | bound to `BaseProducer` (hence, also `BaseStatefulProducer`, `CompositeProducer`) |
68
+ | 6 | `ConsumerType` | bound to `BaseConsumer`, `BaseStatefulConsumer` |
69
+ | 7 | `TransformerType` | bound to `BaseTransformer`, `BaseStatefulTransformer`, `CompositeProcessor` (hence, also `BaseAsyncTransformer`) |
70
+ | 8 | `AdaptiveTransformerType` | bound to `BaseAdaptiveTransformer` |
71
+ | 9 | `ClockDrivenProducerType` | bound to `BaseClockDrivenProducer` |
69
72
 
70
73
 
71
74
  ### Abstract implementations (Base Classes) for ezmsg Units using processors:
72
75
 
73
- | Idx | Class | Parents | Expected TypeVars |
74
- |-----|-------------------------------|---------|---------------------------|
75
- | 1 | `BaseProcessorUnit` | - | - |
76
- | 2 | `BaseProducerUnit` | - | `ProducerType` |
77
- | 3 | `BaseConsumerUnit` | 1 | `ConsumerType` |
78
- | 4 | `BaseTransformerUnit` | 1 | `TransformerType` |
79
- | 5 | `BaseAdaptiveTransformerUnit` | 1 | `AdaptiveTransformerType` |
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
+ | 6 | `BaseClockDrivenUnit` | 1 | `ClockDrivenProducerType` |
80
84
 
81
- Note, it is strongly recommended to use `BaseConsumerUnit`, `BaseTransformerUnit`, or `BaseAdaptiveTransformerUnit` for implementing concrete subclasses rather than `BaseProcessorUnit`.
85
+ Note, it is strongly recommended to use `BaseConsumerUnit`, `BaseTransformerUnit`, `BaseAdaptiveTransformerUnit`, or `BaseClockDrivenUnit` for implementing concrete subclasses rather than `BaseProcessorUnit`.
82
86
 
83
87
 
84
88
  ## Implementing a custom standalone processor
@@ -125,6 +129,7 @@ flowchart TD
125
129
  * For stateful processors that need to respond to a change in the incoming data, implement `_hash_message`.
126
130
  * For adaptive transformers, implement `partial_fit`.
127
131
  * For chains of processors (`CompositeProcessor`/ `CompositeProducer`), need to implement `_initialize_processors`.
132
+ * For clock-driven producers (`BaseClockDrivenProducer`), implement `_reset_state(time_axis)` and `_produce(n_samples, time_axis)`. See the [clock-driven how-to guide](how-tos/processors/clockdriven.rst).
128
133
  * See processors in `ezmsg.sigproc` for signal processing examples, or `ezmsg.learn` for machine learning examples.
129
134
  5. Override non-abstract methods if you need special behaviour.
130
135
 
@@ -0,0 +1,224 @@
1
+ How to implement a clock-driven producer?
2
+ #########################################
3
+
4
+ Clock-driven producers generate data synchronized to clock ticks. They are useful
5
+ for signal generators, simulators, and other components that need to produce
6
+ timed data streams.
7
+
8
+ The ``BaseClockDrivenProducer`` base class simplifies this pattern by handling
9
+ all the timing and sample counting logic internally. You only need to implement
10
+ the data generation.
11
+
12
+ When to use BaseClockDrivenProducer
13
+ ===================================
14
+
15
+ Use ``BaseClockDrivenProducer`` when you need to:
16
+
17
+ - Generate synthetic signals (sine waves, noise, test patterns)
18
+ - Simulate sensor data at a specific sample rate
19
+ - Produce timed data streams driven by a ``Clock``
20
+
21
+ This base class eliminates the need for the ``Clock → Counter → Generator``
22
+ pattern by combining the counter functionality into the generator.
23
+
24
+ Basic Structure
25
+ ===============
26
+
27
+ A clock-driven producer consists of three parts:
28
+
29
+ 1. **Settings** - Extends ``ClockDrivenSettings`` (which provides ``fs`` and ``n_time``)
30
+ 2. **State** - Extends ``ClockDrivenState`` (which provides ``counter`` and ``fractional_samples``)
31
+ 3. **Producer** - Extends ``BaseClockDrivenProducer`` and implements ``_reset_state`` and ``_produce``
32
+
33
+ Example: Sine Wave Generator
34
+ ============================
35
+
36
+ Here's a complete example of a sine wave generator:
37
+
38
+ .. code-block:: python
39
+
40
+ import numpy as np
41
+ from ezmsg.util.messages.axisarray import AxisArray, LinearAxis
42
+
43
+ from ezmsg.baseproc import (
44
+ BaseClockDrivenProducer,
45
+ BaseClockDrivenUnit,
46
+ ClockDrivenSettings,
47
+ ClockDrivenState,
48
+ processor_state,
49
+ )
50
+
51
+
52
+ class SinGeneratorSettings(ClockDrivenSettings):
53
+ """
54
+ Settings for the sine wave generator.
55
+
56
+ Inherits from ClockDrivenSettings which provides:
57
+ - fs: Output sampling rate in Hz
58
+ - n_time: Samples per block (optional, derived from clock if None)
59
+ """
60
+
61
+ freq: float = 1.0
62
+ """Frequency of the sine wave in Hz."""
63
+
64
+ amp: float = 1.0
65
+ """Amplitude of the sine wave."""
66
+
67
+ phase: float = 0.0
68
+ """Initial phase in radians."""
69
+
70
+
71
+ @processor_state
72
+ class SinGeneratorState(ClockDrivenState):
73
+ """
74
+ State for the sine wave generator.
75
+
76
+ Inherits from ClockDrivenState which provides:
77
+ - counter: Current sample counter (total samples produced)
78
+ - fractional_samples: For accumulating sub-sample timing
79
+ """
80
+
81
+ ang_freq: float = 0.0
82
+ """Pre-computed angular frequency (2 * pi * freq)."""
83
+
84
+
85
+ class SinGenerator(
86
+ BaseClockDrivenProducer[SinGeneratorSettings, SinGeneratorState]
87
+ ):
88
+ """
89
+ Generates sine wave data synchronized to clock ticks.
90
+ """
91
+
92
+ def _reset_state(self, time_axis: LinearAxis) -> None:
93
+ """
94
+ Initialize state. Called once before first production.
95
+
96
+ Use this to pre-compute values that don't change between chunks.
97
+ """
98
+ self._state.ang_freq = 2 * np.pi * self.settings.freq
99
+
100
+ def _produce(self, n_samples: int, time_axis: LinearAxis) -> AxisArray:
101
+ """
102
+ Generate sine wave data for this chunk.
103
+
104
+ Args:
105
+ n_samples: Number of samples to generate
106
+ time_axis: LinearAxis with correct offset and gain (1/fs)
107
+
108
+ Returns:
109
+ AxisArray containing the sine wave data
110
+ """
111
+ # Calculate time values using the internal counter
112
+ t = (np.arange(n_samples) + self._state.counter) * time_axis.gain
113
+
114
+ # Generate sine wave
115
+ data = self.settings.amp * np.sin(
116
+ self._state.ang_freq * t + self.settings.phase
117
+ )
118
+
119
+ return AxisArray(
120
+ data=data,
121
+ dims=["time"],
122
+ axes={"time": time_axis},
123
+ )
124
+
125
+
126
+ class SinGeneratorUnit(
127
+ BaseClockDrivenUnit[SinGeneratorSettings, SinGenerator]
128
+ ):
129
+ """
130
+ ezmsg Unit wrapper for SinGenerator.
131
+
132
+ Receives clock ticks on INPUT_CLOCK and outputs AxisArray on OUTPUT_SIGNAL.
133
+ """
134
+
135
+ SETTINGS = SinGeneratorSettings
136
+
137
+
138
+ Key Points
139
+ ==========
140
+
141
+ **Settings inheritance**: Your settings class should extend ``ClockDrivenSettings``,
142
+ which provides:
143
+
144
+ - ``fs``: The output sampling rate in Hz
145
+ - ``n_time``: Optional fixed chunk size. If ``None``, chunk size is derived from
146
+ the clock's gain (``fs * clock.gain``)
147
+
148
+ **State inheritance**: Your state class should extend ``ClockDrivenState``,
149
+ which provides:
150
+
151
+ - ``counter``: Tracks total samples produced (use this for continuous signals)
152
+ - ``fractional_samples``: Accumulates sub-sample timing for accurate chunk sizes
153
+
154
+ **The _produce method**: This is where you generate data. You receive:
155
+
156
+ - ``n_samples``: How many samples to generate this chunk
157
+ - ``time_axis``: A ``LinearAxis`` with the correct ``offset`` and ``gain`` (1/fs)
158
+
159
+ The base class automatically:
160
+
161
+ - Computes ``n_samples`` from clock timing or settings
162
+ - Manages the sample counter (incremented after ``_produce`` returns)
163
+ - Handles fractional sample accumulation for non-integer chunk sizes
164
+ - Supports both fixed ``n_time`` and variable chunk modes
165
+
166
+ Using Standalone (Outside ezmsg)
167
+ ================================
168
+
169
+ Clock-driven producers can be used standalone for testing or offline processing:
170
+
171
+ .. code-block:: python
172
+
173
+ from ezmsg.util.messages.axisarray import AxisArray
174
+
175
+ # Create the producer
176
+ producer = SinGenerator(SinGeneratorSettings(
177
+ fs=1000.0, # 1000 Hz sample rate
178
+ n_time=100, # 100 samples per chunk
179
+ freq=10.0, # 10 Hz sine wave
180
+ amp=1.0,
181
+ ))
182
+
183
+ # Simulate clock ticks (LinearAxis with gain=1/dispatch_rate, offset=timestamp)
184
+ clock_tick = AxisArray.LinearAxis(gain=0.1, offset=0.0) # 10 Hz dispatch
185
+
186
+ # Generate data
187
+ result = producer(clock_tick)
188
+ print(f"Shape: {result.data.shape}") # (100,)
189
+ print(f"Sample rate: {1/result.axes['time'].gain} Hz") # 1000.0 Hz
190
+
191
+
192
+ Using with ezmsg
193
+ ================
194
+
195
+ In an ezmsg pipeline, connect a ``Clock`` to your generator's ``INPUT_CLOCK``:
196
+
197
+ .. code-block:: python
198
+
199
+ import ezmsg.core as ez
200
+ from ezmsg.baseproc import Clock, ClockSettings
201
+
202
+
203
+ class SinPipeline(ez.Collection):
204
+ SETTINGS = SinGeneratorSettings
205
+
206
+ CLOCK = Clock()
207
+ GENERATOR = SinGeneratorUnit()
208
+
209
+ def configure(self) -> None:
210
+ self.CLOCK.apply_settings(ClockSettings(dispatch_rate=10.0))
211
+ self.GENERATOR.apply_settings(self.SETTINGS)
212
+
213
+ def network(self) -> ez.NetworkDefinition:
214
+ return (
215
+ (self.CLOCK.OUTPUT_SIGNAL, self.GENERATOR.INPUT_CLOCK),
216
+ )
217
+
218
+
219
+ See Also
220
+ ========
221
+
222
+ - :doc:`API Reference for clockdriven module <../../../api/generated/ezmsg.baseproc.clockdriven>`
223
+ - :doc:`stateful` - For general stateful processor patterns
224
+ - :doc:`unit` - For converting processors to ezmsg Units
@@ -10,4 +10,5 @@ Processor HOW TOs
10
10
  adaptive
11
11
  composite
12
12
  unit
13
+ clockdriven
13
14
  checkpoint
@@ -15,6 +15,14 @@ from .clock import (
15
15
  ClockState,
16
16
  )
17
17
 
18
+ # Clock-driven producers
19
+ from .clockdriven import (
20
+ BaseClockDrivenProducer,
21
+ ClockDrivenSettings,
22
+ ClockDrivenSettingsType,
23
+ ClockDrivenState,
24
+ )
25
+
18
26
  # Composite processor classes
19
27
  from .composite import (
20
28
  CompositeProcessor,
@@ -74,15 +82,18 @@ from .stateful import (
74
82
  from .units import (
75
83
  AdaptiveTransformerType,
76
84
  BaseAdaptiveTransformerUnit,
85
+ BaseClockDrivenUnit,
77
86
  BaseConsumerUnit,
78
87
  BaseProcessorUnit,
79
88
  BaseProducerUnit,
80
89
  BaseTransformerUnit,
90
+ ClockDrivenProducerType,
81
91
  ConsumerType,
82
92
  GenAxisArray,
83
93
  ProducerType,
84
94
  TransformerType,
85
95
  get_base_adaptive_transformer_type,
96
+ get_base_clockdriven_producer_type,
86
97
  get_base_consumer_type,
87
98
  get_base_producer_type,
88
99
  get_base_transformer_type,
@@ -116,6 +127,7 @@ __all__ = [
116
127
  "ConsumerType",
117
128
  "TransformerType",
118
129
  "AdaptiveTransformerType",
130
+ "ClockDrivenProducerType",
119
131
  # Decorators
120
132
  "processor_state",
121
133
  # Base processor classes
@@ -131,6 +143,11 @@ __all__ = [
131
143
  "BaseStatefulTransformer",
132
144
  "BaseAdaptiveTransformer",
133
145
  "BaseAsyncTransformer",
146
+ # Clock-driven producers
147
+ "BaseClockDrivenProducer",
148
+ "ClockDrivenSettings",
149
+ "ClockDrivenSettingsType",
150
+ "ClockDrivenState",
134
151
  # Composite classes
135
152
  "CompositeStateful",
136
153
  "CompositeProcessor",
@@ -141,12 +158,14 @@ __all__ = [
141
158
  "BaseConsumerUnit",
142
159
  "BaseTransformerUnit",
143
160
  "BaseAdaptiveTransformerUnit",
161
+ "BaseClockDrivenUnit",
144
162
  "GenAxisArray",
145
163
  # Type resolution helpers
146
164
  "get_base_producer_type",
147
165
  "get_base_consumer_type",
148
166
  "get_base_transformer_type",
149
167
  "get_base_adaptive_transformer_type",
168
+ "get_base_clockdriven_producer_type",
150
169
  "_get_base_processor_settings_type",
151
170
  "_get_base_processor_message_in_type",
152
171
  "_get_base_processor_message_out_type",
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.1.0'
32
- __version_tuple__ = version_tuple = (1, 1, 0)
31
+ __version__ = version = '1.2.1'
32
+ __version_tuple__ = version_tuple = (1, 2, 1)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -0,0 +1,179 @@
1
+ """Clock-driven producer base classes for generating data synchronized to clock ticks."""
2
+
3
+ import typing
4
+ from abc import abstractmethod
5
+
6
+ import ezmsg.core as ez
7
+ from ezmsg.util.messages.axisarray import AxisArray, LinearAxis
8
+
9
+ from .protocols import StateType, processor_state
10
+ from .stateful import BaseStatefulProcessor
11
+
12
+
13
+ class ClockDrivenSettings(ez.Settings):
14
+ """
15
+ Base settings for clock-driven producers.
16
+
17
+ Subclass this to add your own settings while inheriting fs and n_time.
18
+
19
+ Example::
20
+
21
+ class SinGeneratorSettings(ClockDrivenSettings):
22
+ freq: float = 1.0
23
+ amp: float = 1.0
24
+ """
25
+
26
+ fs: float
27
+ """Output sampling rate in Hz."""
28
+
29
+ n_time: int | None = None
30
+ """
31
+ Samples per block.
32
+ - If specified: fixed chunk size (clock gain is ignored for determining chunk size)
33
+ - If None: derived from clock gain (fs * clock.gain), with fractional sample tracking
34
+ """
35
+
36
+
37
+ # Type variable for settings that extend ClockDrivenSettings
38
+ ClockDrivenSettingsType = typing.TypeVar("ClockDrivenSettingsType", bound=ClockDrivenSettings)
39
+
40
+
41
+ @processor_state
42
+ class ClockDrivenState:
43
+ """
44
+ Internal state for clock-driven producers.
45
+
46
+ Tracks sample counting and fractional sample accumulation.
47
+ Subclasses should extend this if they need additional state.
48
+ """
49
+
50
+ counter: int = 0
51
+ """Current sample counter (total samples produced)."""
52
+
53
+ fractional_samples: float = 0.0
54
+ """Accumulated fractional samples for variable chunk mode."""
55
+
56
+
57
+ class BaseClockDrivenProducer(
58
+ BaseStatefulProcessor[ClockDrivenSettingsType, AxisArray.LinearAxis, AxisArray, StateType],
59
+ typing.Generic[ClockDrivenSettingsType, StateType],
60
+ ):
61
+ """
62
+ Base class for clock-driven data producers.
63
+
64
+ Accepts clock ticks (LinearAxis) as input and produces AxisArray output.
65
+ Handles all the timing/counter logic internally, so subclasses only need
66
+ to implement the data generation logic.
67
+
68
+ This eliminates the need for the Clock → Counter → Generator pattern
69
+ by combining the Counter functionality into the generator base class.
70
+
71
+ Subclasses must implement:
72
+ - ``_reset_state(time_axis)``: Initialize any state needed for production
73
+ - ``_produce(n_samples, time_axis)``: Generate the actual output data
74
+
75
+ Example::
76
+
77
+ @processor_state
78
+ class SinState(ClockDrivenState):
79
+ ang_freq: float = 0.0
80
+
81
+ class SinProducer(BaseClockDrivenProducer[SinSettings, SinState]):
82
+ def _reset_state(self, time_axis: AxisArray.TimeAxis) -> None:
83
+ self._state.ang_freq = 2 * np.pi * self.settings.fs
84
+
85
+ def _produce(self, n_samples: int, time_axis: AxisArray.TimeAxis) -> AxisArray:
86
+ t = (np.arange(n_samples) + self._state.counter) * time_axis.gain
87
+ data = np.sin(self._state.ang_freq * t)
88
+ return AxisArray(data=data, dims=["time"], axes={"time": time_axis})
89
+ """
90
+
91
+ def _hash_message(self, message: AxisArray.LinearAxis) -> int:
92
+ # Return constant hash - state should not reset based on clock rate changes.
93
+ # The producer maintains continuity regardless of clock rate changes.
94
+ return 0
95
+
96
+ def _compute_samples_and_offset(self, clock_tick: AxisArray.LinearAxis) -> tuple[int, float] | None:
97
+ """
98
+ Compute number of samples and time offset from a clock tick.
99
+
100
+ Returns:
101
+ Tuple of (n_samples, offset) or None if no samples to produce yet.
102
+
103
+ Raises:
104
+ ValueError: If clock gain is 0 (AFAP mode) and n_time is not specified.
105
+ """
106
+ if self.settings.n_time is not None:
107
+ # Fixed chunk size mode
108
+ n_samples = self.settings.n_time
109
+ if clock_tick.gain == 0.0:
110
+ # AFAP mode - synthetic offset based on counter
111
+ offset = self._state.counter / self.settings.fs
112
+ else:
113
+ # Use clock's timestamp
114
+ offset = clock_tick.offset
115
+ else:
116
+ # Variable chunk size mode - derive from clock gain
117
+ if clock_tick.gain == 0.0:
118
+ raise ValueError("Cannot use clock with gain=0 (AFAP) without specifying n_time")
119
+
120
+ # Calculate samples including fractional accumulation
121
+ samples_float = self.settings.fs * clock_tick.gain + self._state.fractional_samples
122
+ n_samples = int(samples_float + 1e-9)
123
+ self._state.fractional_samples = samples_float - n_samples
124
+
125
+ if n_samples == 0:
126
+ return None
127
+
128
+ offset = clock_tick.offset
129
+
130
+ return n_samples, offset
131
+
132
+ @abstractmethod
133
+ def _reset_state(self, time_axis: LinearAxis) -> None:
134
+ """
135
+ Reset/initialize state for production.
136
+
137
+ Called once before the first call to _produce, or when state needs resetting.
138
+ Use this to pre-compute values, create templates, etc.
139
+
140
+ Args:
141
+ time_axis: TimeAxis with the output sampling rate (fs) and initial offset.
142
+ """
143
+ ...
144
+
145
+ @abstractmethod
146
+ def _produce(self, n_samples: int, time_axis: LinearAxis) -> AxisArray:
147
+ """
148
+ Generate output data for this chunk.
149
+
150
+ Args:
151
+ n_samples: Number of samples to generate.
152
+ time_axis: TimeAxis with correct offset and gain (1/fs) for this chunk.
153
+
154
+ Returns:
155
+ AxisArray containing the generated data. The time axis should use
156
+ the provided time_axis or one derived from it.
157
+ """
158
+ ...
159
+
160
+ def _process(self, clock_tick: LinearAxis) -> AxisArray | None:
161
+ """
162
+ Process a clock tick and produce output.
163
+
164
+ Handles all the counter/timing logic internally, then calls _produce.
165
+ """
166
+ result = self._compute_samples_and_offset(clock_tick)
167
+ if result is None:
168
+ return None
169
+
170
+ n_samples, offset = result
171
+ time_axis = AxisArray.TimeAxis(fs=self.settings.fs, offset=offset)
172
+
173
+ # Call subclass production method
174
+ output = self._produce(n_samples, time_axis)
175
+
176
+ # Update counter
177
+ self._state.counter += n_samples
178
+
179
+ return output
@@ -0,0 +1,67 @@
1
+ """Counter generator for sample counting and timing."""
2
+
3
+ import numpy as np
4
+ from ezmsg.util.messages.axisarray import AxisArray, LinearAxis, replace
5
+
6
+ from .clockdriven import (
7
+ BaseClockDrivenProducer,
8
+ ClockDrivenSettings,
9
+ ClockDrivenState,
10
+ )
11
+ from .protocols import processor_state
12
+ from .units import BaseClockDrivenUnit
13
+
14
+
15
+ class CounterSettings(ClockDrivenSettings):
16
+ """Settings for :obj:`Counter` and :obj:`CounterTransformer`."""
17
+
18
+ mod: int | None = None
19
+ """If set, counter values rollover at this modulus."""
20
+
21
+
22
+ @processor_state
23
+ class CounterTransformerState(ClockDrivenState):
24
+ """State for :obj:`CounterTransformer`."""
25
+
26
+ template: AxisArray | None = None
27
+
28
+
29
+ class CounterTransformer(BaseClockDrivenProducer[CounterSettings, CounterTransformerState]):
30
+ """
31
+ Transforms clock ticks (LinearAxis) into AxisArray counter values.
32
+
33
+ Each clock tick produces a block of counter values. The block size is either
34
+ fixed (n_time setting) or derived from the clock's gain (fs * gain).
35
+ """
36
+
37
+ def _reset_state(self, time_axis: LinearAxis) -> None:
38
+ """Reset state - initialize template for counter output."""
39
+ self._state.template = AxisArray(
40
+ data=np.array([], dtype=int),
41
+ dims=["time"],
42
+ axes={"time": time_axis},
43
+ key="counter",
44
+ )
45
+
46
+ def _produce(self, n_samples: int, time_axis: LinearAxis) -> AxisArray:
47
+ """Generate counter values for this chunk."""
48
+ # Generate counter data (using pre-increment counter value)
49
+ block_samp = np.arange(self._state.counter, self._state.counter + n_samples)
50
+ if self.settings.mod is not None:
51
+ block_samp = block_samp % self.settings.mod
52
+
53
+ return replace(
54
+ self._state.template,
55
+ data=block_samp,
56
+ axes={"time": time_axis},
57
+ )
58
+
59
+
60
+ class Counter(BaseClockDrivenUnit[CounterSettings, CounterTransformer]):
61
+ """
62
+ Transforms clock ticks into monotonically increasing counter values as AxisArray.
63
+
64
+ Receives timing from INPUT_CLOCK (LinearAxis from Clock) and outputs AxisArray.
65
+ """
66
+
67
+ SETTINGS = CounterSettings