ezmsg-baseproc 1.0.2__tar.gz → 1.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 (44) hide show
  1. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/PKG-INFO +21 -24
  2. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/README.md +20 -23
  3. ezmsg_baseproc-1.1.0/docs/source/index.md +18 -0
  4. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/__init__.py +23 -0
  5. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/__version__.py +2 -2
  6. ezmsg_baseproc-1.1.0/src/ezmsg/baseproc/clock.py +109 -0
  7. ezmsg_baseproc-1.1.0/src/ezmsg/baseproc/counter.py +128 -0
  8. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/protocols.py +3 -1
  9. ezmsg_baseproc-1.1.0/tests/test_clock.py +83 -0
  10. ezmsg_baseproc-1.1.0/tests/test_counter.py +180 -0
  11. ezmsg_baseproc-1.0.2/docs/source/index.rst +0 -87
  12. ezmsg_baseproc-1.0.2/examples/example.py +0 -44
  13. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/.github/workflows/docs.yml +0 -0
  14. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/.github/workflows/python-publish.yml +0 -0
  15. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/.github/workflows/python-tests.yml +0 -0
  16. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/.gitignore +0 -0
  17. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/.pre-commit-config.yaml +0 -0
  18. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/LICENSE +0 -0
  19. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/Makefile +0 -0
  20. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/make.bat +0 -0
  21. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/_templates/autosummary/module.rst +0 -0
  22. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/api/index.rst +0 -0
  23. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/conf.py +0 -0
  24. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/guides/ProcessorsBase.md +0 -0
  25. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/guides/how-tos/processors/adaptive.rst +0 -0
  26. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/guides/how-tos/processors/checkpoint.rst +0 -0
  27. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/guides/how-tos/processors/composite.rst +0 -0
  28. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/guides/how-tos/processors/content-processors.rst +0 -0
  29. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/guides/how-tos/processors/processor.rst +0 -0
  30. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/guides/how-tos/processors/standalone.rst +0 -0
  31. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/guides/how-tos/processors/stateful.rst +0 -0
  32. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/docs/source/guides/how-tos/processors/unit.rst +0 -0
  33. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/pyproject.toml +0 -0
  34. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/composite.py +0 -0
  35. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/processor.py +0 -0
  36. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/stateful.py +0 -0
  37. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/units.py +0 -0
  38. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/util/__init__.py +0 -0
  39. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/util/asio.py +0 -0
  40. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/util/message.py +0 -0
  41. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/util/profile.py +0 -0
  42. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/src/ezmsg/baseproc/util/typeresolution.py +0 -0
  43. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/tests/test_baseproc.py +0 -0
  44. {ezmsg_baseproc-1.0.2 → ezmsg_baseproc-1.1.0}/tests/test_profile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ezmsg-baseproc
3
- Version: 1.0.2
3
+ Version: 1.1.0
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
@@ -12,7 +12,7 @@ Description-Content-Type: text/markdown
12
12
 
13
13
  # ezmsg-baseproc
14
14
 
15
- Base processor classes and protocols for building signal processing pipelines in [ezmsg](https://github.com/ezmsg-org/ezmsg).
15
+ Base processor classes and protocols for building message-processing components in [ezmsg](https://github.com/ezmsg-org/ezmsg).
16
16
 
17
17
  ## Installation
18
18
 
@@ -20,30 +20,26 @@ Base processor classes and protocols for building signal processing pipelines in
20
20
  pip install ezmsg-baseproc
21
21
  ```
22
22
 
23
- ## Overview
23
+ Or install the latest development version:
24
24
 
25
- This package provides the foundational processor architecture for ezmsg signal processing:
25
+ ```bash
26
+ pip install git+https://github.com/ezmsg-org/ezmsg-baseproc@dev
27
+ ```
26
28
 
27
- - **Protocols** - Type definitions for processors, transformers, consumers, and producers
28
- - **Base Classes** - Abstract base classes for building stateless and stateful processors
29
- - **Composite Processors** - Classes for chaining processors into pipelines
30
- - **Unit Wrappers** - ezmsg Unit base classes that wrap processors for graph integration
29
+ ## Overview
31
30
 
32
- ## Module Structure
31
+ ``ezmsg-baseproc`` provides abstract base classes for creating message processors that can be used both standalone and within ezmsg pipelines. The package offers a consistent pattern for building:
33
32
 
34
- ```
35
- ezmsg.baseproc/
36
- ├── protocols.py # Protocol definitions and type variables
37
- ├── processor.py # Base non-stateful processors
38
- ├── stateful.py # Stateful processor base classes
39
- ├── composite.py # CompositeProcessor and CompositeProducer
40
- ├── units.py # ezmsg Unit wrappers
41
- └── util/
42
- ├── asio.py # Async/sync utilities
43
- ├── message.py # SampleMessage definitions
44
- ├── profile.py # Profiling decorators
45
- └── typeresolution.py # Type resolution helpers
46
- ```
33
+ * **Protocols** - Type definitions for processors, transformers, consumers, and producers
34
+ * **Processors** - Transform input messages to output messages
35
+ * **Producers** - Generate output messages without requiring input
36
+ * **Consumers** - Accept input messages without producing output
37
+ * **Transformers** - A specific type of processor with typed input/output
38
+ * **Stateful variants** - Processors that maintain state across invocations
39
+ * **Adaptive transformers** - Transformers that can be trained via ``partial_fit``
40
+ * **Composite processors** - Chain multiple processors together efficiently
41
+
42
+ All base classes support both synchronous and asynchronous operation, making them suitable for offline analysis and real-time streaming applications.
47
43
 
48
44
  ## Usage
49
45
 
@@ -52,7 +48,7 @@ ezmsg.baseproc/
52
48
  ```python
53
49
  from dataclasses import dataclass
54
50
  from ezmsg.baseproc import BaseTransformer
55
- from ezmsg.util.messages.axisarray import AxisArray
51
+ from ezmsg.util.messages.axisarray import AxisArray, replace
56
52
 
57
53
  @dataclass
58
54
  class MySettings:
@@ -60,7 +56,7 @@ class MySettings:
60
56
 
61
57
  class MyTransformer(BaseTransformer[MySettings, AxisArray, AxisArray]):
62
58
  def _process(self, message: AxisArray) -> AxisArray:
63
- return message.replace(data=message.data * self.settings.scale)
59
+ return replace(message, data=message.data * self.settings.scale)
64
60
  ```
65
61
 
66
62
  ### Creating a Stateful Transformer
@@ -100,6 +96,7 @@ We use [`uv`](https://docs.astral.sh/uv/getting-started/installation/) for devel
100
96
  2. Clone and cd into the repository
101
97
  3. Run `uv sync` to create a `.venv` and install dependencies
102
98
  4. Run `uv run pytest tests` to run tests
99
+ 5. (Optional) Install pre-commit hooks: `uv run pre-commit install`
103
100
 
104
101
  ## License
105
102
 
@@ -1,6 +1,6 @@
1
1
  # ezmsg-baseproc
2
2
 
3
- Base processor classes and protocols for building signal processing pipelines in [ezmsg](https://github.com/ezmsg-org/ezmsg).
3
+ Base processor classes and protocols for building message-processing components in [ezmsg](https://github.com/ezmsg-org/ezmsg).
4
4
 
5
5
  ## Installation
6
6
 
@@ -8,30 +8,26 @@ Base processor classes and protocols for building signal processing pipelines in
8
8
  pip install ezmsg-baseproc
9
9
  ```
10
10
 
11
- ## Overview
11
+ Or install the latest development version:
12
12
 
13
- This package provides the foundational processor architecture for ezmsg signal processing:
13
+ ```bash
14
+ pip install git+https://github.com/ezmsg-org/ezmsg-baseproc@dev
15
+ ```
14
16
 
15
- - **Protocols** - Type definitions for processors, transformers, consumers, and producers
16
- - **Base Classes** - Abstract base classes for building stateless and stateful processors
17
- - **Composite Processors** - Classes for chaining processors into pipelines
18
- - **Unit Wrappers** - ezmsg Unit base classes that wrap processors for graph integration
17
+ ## Overview
19
18
 
20
- ## Module Structure
19
+ ``ezmsg-baseproc`` provides abstract base classes for creating message processors that can be used both standalone and within ezmsg pipelines. The package offers a consistent pattern for building:
21
20
 
22
- ```
23
- ezmsg.baseproc/
24
- ├── protocols.py # Protocol definitions and type variables
25
- ├── processor.py # Base non-stateful processors
26
- ├── stateful.py # Stateful processor base classes
27
- ├── composite.py # CompositeProcessor and CompositeProducer
28
- ├── units.py # ezmsg Unit wrappers
29
- └── util/
30
- ├── asio.py # Async/sync utilities
31
- ├── message.py # SampleMessage definitions
32
- ├── profile.py # Profiling decorators
33
- └── typeresolution.py # Type resolution helpers
34
- ```
21
+ * **Protocols** - Type definitions for processors, transformers, consumers, and producers
22
+ * **Processors** - Transform input messages to output messages
23
+ * **Producers** - Generate output messages without requiring input
24
+ * **Consumers** - Accept input messages without producing output
25
+ * **Transformers** - A specific type of processor with typed input/output
26
+ * **Stateful variants** - Processors that maintain state across invocations
27
+ * **Adaptive transformers** - Transformers that can be trained via ``partial_fit``
28
+ * **Composite processors** - Chain multiple processors together efficiently
29
+
30
+ All base classes support both synchronous and asynchronous operation, making them suitable for offline analysis and real-time streaming applications.
35
31
 
36
32
  ## Usage
37
33
 
@@ -40,7 +36,7 @@ ezmsg.baseproc/
40
36
  ```python
41
37
  from dataclasses import dataclass
42
38
  from ezmsg.baseproc import BaseTransformer
43
- from ezmsg.util.messages.axisarray import AxisArray
39
+ from ezmsg.util.messages.axisarray import AxisArray, replace
44
40
 
45
41
  @dataclass
46
42
  class MySettings:
@@ -48,7 +44,7 @@ class MySettings:
48
44
 
49
45
  class MyTransformer(BaseTransformer[MySettings, AxisArray, AxisArray]):
50
46
  def _process(self, message: AxisArray) -> AxisArray:
51
- return message.replace(data=message.data * self.settings.scale)
47
+ return replace(message, data=message.data * self.settings.scale)
52
48
  ```
53
49
 
54
50
  ### Creating a Stateful Transformer
@@ -88,6 +84,7 @@ We use [`uv`](https://docs.astral.sh/uv/getting-started/installation/) for devel
88
84
  2. Clone and cd into the repository
89
85
  3. Run `uv sync` to create a `.venv` and install dependencies
90
86
  4. Run `uv run pytest tests` to run tests
87
+ 5. (Optional) Install pre-commit hooks: `uv run pre-commit install`
91
88
 
92
89
  ## License
93
90
 
@@ -0,0 +1,18 @@
1
+ ```{include} ../../README.md
2
+ ```
3
+
4
+ ## Documentation
5
+
6
+ ```{toctree}
7
+ :maxdepth: 2
8
+ :caption: Contents:
9
+
10
+ guides/ProcessorsBase
11
+ guides/how-tos/processors/content-processors
12
+ api/index
13
+ ```
14
+
15
+ ## Indices and tables
16
+
17
+ - {ref}`genindex`
18
+ - {ref}`modindex`
@@ -7,6 +7,14 @@ signal processing pipelines in ezmsg.
7
7
 
8
8
  from .__version__ import __version__ as __version__
9
9
 
10
+ # Clock and Counter
11
+ from .clock import (
12
+ Clock,
13
+ ClockProducer,
14
+ ClockSettings,
15
+ ClockState,
16
+ )
17
+
10
18
  # Composite processor classes
11
19
  from .composite import (
12
20
  CompositeProcessor,
@@ -14,6 +22,12 @@ from .composite import (
14
22
  CompositeStateful,
15
23
  _get_processor_message_type,
16
24
  )
25
+ from .counter import (
26
+ Counter,
27
+ CounterSettings,
28
+ CounterTransformer,
29
+ CounterTransformerState,
30
+ )
17
31
 
18
32
  # Base processor classes (non-stateful)
19
33
  from .processor import (
@@ -152,4 +166,13 @@ __all__ = [
152
166
  # Type utilities
153
167
  "check_message_type_compatibility",
154
168
  "resolve_typevar",
169
+ # Clock and Counter
170
+ "Clock",
171
+ "ClockProducer",
172
+ "ClockSettings",
173
+ "ClockState",
174
+ "Counter",
175
+ "CounterSettings",
176
+ "CounterTransformer",
177
+ "CounterTransformerState",
155
178
  ]
@@ -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.0.2'
32
- __version_tuple__ = version_tuple = (1, 0, 2)
31
+ __version__ = version = '1.1.0'
32
+ __version_tuple__ = version_tuple = (1, 1, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -0,0 +1,109 @@
1
+ """Clock generator for timing control."""
2
+
3
+ import asyncio
4
+ import math
5
+ import time
6
+ from dataclasses import field
7
+
8
+ import ezmsg.core as ez
9
+ from ezmsg.util.messages.axisarray import AxisArray
10
+
11
+ from .protocols import processor_state
12
+ from .stateful import BaseStatefulProducer
13
+ from .units import BaseProducerUnit
14
+
15
+
16
+ class ClockSettings(ez.Settings):
17
+ """Settings for :obj:`ClockProducer`."""
18
+
19
+ dispatch_rate: float = math.inf
20
+ """
21
+ Dispatch rate in Hz.
22
+ - Finite value (e.g., 100.0): Dispatch 100 times per second
23
+ - math.inf: Dispatch as fast as possible (no sleep)
24
+ """
25
+
26
+
27
+ @processor_state
28
+ class ClockState:
29
+ """State for :obj:`ClockProducer`."""
30
+
31
+ t_0: float = field(default_factory=time.monotonic)
32
+ """Start time (monotonic)."""
33
+
34
+ n_dispatch: int = 0
35
+ """Number of dispatches since reset."""
36
+
37
+
38
+ class ClockProducer(BaseStatefulProducer[ClockSettings, AxisArray.LinearAxis, ClockState]):
39
+ """
40
+ Produces clock ticks at a specified rate.
41
+
42
+ Each tick outputs a :obj:`AxisArray.LinearAxis` containing:
43
+ - ``gain``: 1/dispatch_rate (seconds per tick), or 0.0 if dispatch_rate is infinite
44
+ - ``offset``: Wall clock timestamp (time.monotonic)
45
+
46
+ This output type allows downstream components (like Counter) to know both
47
+ the timing of the tick and the nominal dispatch rate.
48
+ """
49
+
50
+ def _reset_state(self) -> None:
51
+ """Reset internal state."""
52
+ self._state.t_0 = time.monotonic()
53
+ self._state.n_dispatch = 0
54
+
55
+ def _make_output(self, timestamp: float) -> AxisArray.LinearAxis:
56
+ """Create LinearAxis output with gain and offset."""
57
+ if math.isinf(self.settings.dispatch_rate):
58
+ gain = 0.0
59
+ else:
60
+ gain = 1.0 / self.settings.dispatch_rate
61
+ return AxisArray.LinearAxis(gain=gain, offset=timestamp)
62
+
63
+ def __call__(self) -> AxisArray.LinearAxis:
64
+ """Synchronous clock production."""
65
+ if self._hash == -1:
66
+ self._reset_state()
67
+ self._hash = 0
68
+
69
+ now = time.monotonic()
70
+ if math.isfinite(self.settings.dispatch_rate):
71
+ target_time = self.state.t_0 + (self.state.n_dispatch + 1) / self.settings.dispatch_rate
72
+ if target_time > now:
73
+ time.sleep(target_time - now)
74
+ else:
75
+ target_time = now
76
+
77
+ self.state.n_dispatch += 1
78
+ return self._make_output(target_time)
79
+
80
+ async def _produce(self) -> AxisArray.LinearAxis:
81
+ """Generate next clock tick."""
82
+ now = time.monotonic()
83
+ if math.isfinite(self.settings.dispatch_rate):
84
+ target_time = self.state.t_0 + (self.state.n_dispatch + 1) / self.settings.dispatch_rate
85
+ if target_time > now:
86
+ await asyncio.sleep(target_time - now)
87
+ else:
88
+ target_time = now
89
+
90
+ self.state.n_dispatch += 1
91
+ return self._make_output(target_time)
92
+
93
+
94
+ class Clock(
95
+ BaseProducerUnit[
96
+ ClockSettings,
97
+ AxisArray.LinearAxis,
98
+ ClockProducer,
99
+ ]
100
+ ):
101
+ """
102
+ Clock unit that produces ticks at a specified rate.
103
+
104
+ Output is a :obj:`AxisArray.LinearAxis` with:
105
+ - ``gain``: 1/dispatch_rate (seconds per tick)
106
+ - ``offset``: Wall clock timestamp
107
+ """
108
+
109
+ SETTINGS = ClockSettings
@@ -0,0 +1,128 @@
1
+ """Counter generator for sample counting and timing."""
2
+
3
+ import ezmsg.core as ez
4
+ import numpy as np
5
+ from ezmsg.util.messages.axisarray import AxisArray, replace
6
+
7
+ from .protocols import processor_state
8
+ from .stateful import BaseStatefulTransformer
9
+ from .units import BaseTransformerUnit
10
+
11
+
12
+ class CounterSettings(ez.Settings):
13
+ """Settings for :obj:`Counter` and :obj:`CounterTransformer`."""
14
+
15
+ fs: float
16
+ """Sampling rate in Hz."""
17
+
18
+ n_time: int | None = None
19
+ """
20
+ Samples per block.
21
+ - If specified: fixed chunk size (clock gain is ignored)
22
+ - If None: derived from clock gain (fs * clock.gain), with fractional sample tracking
23
+ """
24
+
25
+ mod: int | None = None
26
+ """If set, counter values rollover at this modulus."""
27
+
28
+
29
+ @processor_state
30
+ class CounterTransformerState:
31
+ """State for :obj:`CounterTransformer`."""
32
+
33
+ counter: int = 0
34
+ """Current counter value (next sample index)."""
35
+
36
+ fractional_samples: float = 0.0
37
+ """Accumulated fractional samples for variable chunk mode."""
38
+
39
+ template: AxisArray | None = None
40
+
41
+
42
+ class CounterTransformer(
43
+ BaseStatefulTransformer[CounterSettings, AxisArray.LinearAxis, AxisArray, CounterTransformerState]
44
+ ):
45
+ """
46
+ Transforms clock ticks (LinearAxis) into AxisArray counter values.
47
+
48
+ Each clock tick produces a block of counter values. The block size is either
49
+ fixed (n_time setting) or derived from the clock's gain (fs * gain).
50
+ """
51
+
52
+ def _reset_state(self, message: AxisArray.LinearAxis) -> None:
53
+ """Reset state - counter transformer state is simple, just reset values."""
54
+ self._state.counter = 0
55
+ self._state.fractional_samples = 0.0
56
+ self._state.template = AxisArray(
57
+ data=np.array([], dtype=int),
58
+ dims=["time"],
59
+ axes={
60
+ "time": AxisArray.TimeAxis(fs=self.settings.fs, offset=message.offset),
61
+ },
62
+ key="counter",
63
+ )
64
+
65
+ def _hash_message(self, message: AxisArray.LinearAxis) -> int:
66
+ # Return constant hash - counter state should never reset based on message content.
67
+ # The counter maintains continuity regardless of clock rate changes.
68
+ return 0
69
+
70
+ def _process(self, clock_tick: AxisArray.LinearAxis) -> AxisArray | None:
71
+ """Transform a clock tick into counter AxisArray."""
72
+ # Determine number of samples for this block
73
+ if self.settings.n_time is not None:
74
+ # Fixed chunk size mode
75
+ n_samples = self.settings.n_time
76
+ # Use wall clock or synthetic offset based on clock gain
77
+ if clock_tick.gain == 0.0:
78
+ # AFAP mode - synthetic offset
79
+ offset = self.state.counter / self.settings.fs
80
+ else:
81
+ # Use clock's timestamp
82
+ offset = clock_tick.offset
83
+ else:
84
+ # Variable chunk size mode - derive from clock gain
85
+ if clock_tick.gain == 0.0:
86
+ # AFAP with no fixed n_time - this is an error
87
+ raise ValueError("Cannot use clock with gain=0 (AFAP) without specifying n_time")
88
+
89
+ # Calculate samples including fractional accumulation
90
+ # Add small epsilon to avoid floating point truncation errors (e.g., 0.9999999 -> 0)
91
+ samples_float = self.settings.fs * clock_tick.gain + self.state.fractional_samples
92
+ n_samples = int(samples_float + 1e-9)
93
+ self.state.fractional_samples = samples_float - n_samples
94
+
95
+ if n_samples == 0:
96
+ # Not enough samples accumulated yet
97
+ # TODO: Return empty array. What should offset be?
98
+ return None
99
+
100
+ # Use clock's timestamp for offset
101
+ offset = clock_tick.offset
102
+
103
+ # Generate counter data
104
+ block_samp = np.arange(self.state.counter, self.state.counter + n_samples)
105
+ if self.settings.mod is not None:
106
+ block_samp = block_samp % self.settings.mod
107
+
108
+ # Create output AxisArray
109
+ result = replace(
110
+ self._state.template,
111
+ data=block_samp,
112
+ axes={"time": replace(self._state.template.axes["time"], offset=offset)},
113
+ )
114
+
115
+ # Update state
116
+ self.state.counter += n_samples
117
+
118
+ return result
119
+
120
+
121
+ class Counter(BaseTransformerUnit[CounterSettings, AxisArray.LinearAxis, AxisArray, CounterTransformer]):
122
+ """
123
+ Transforms clock ticks into monotonically increasing counter values as AxisArray.
124
+
125
+ Receives timing from INPUT_SIGNAL (LinearAxis from Clock) and outputs AxisArray.
126
+ """
127
+
128
+ SETTINGS = CounterSettings
@@ -4,6 +4,8 @@ import functools
4
4
  import typing
5
5
  from dataclasses import dataclass
6
6
 
7
+ import ezmsg.core as ez
8
+
7
9
  from .util.message import SampleMessage
8
10
 
9
11
  # --- Processor state decorator ---
@@ -12,7 +14,7 @@ processor_state = functools.partial(dataclass, unsafe_hash=True, frozen=False, i
12
14
  # --- Type variables for protocols and processors ---
13
15
  MessageInType = typing.TypeVar("MessageInType")
14
16
  MessageOutType = typing.TypeVar("MessageOutType")
15
- SettingsType = typing.TypeVar("SettingsType")
17
+ SettingsType = typing.TypeVar("SettingsType", bound=ez.Settings)
16
18
  StateType = typing.TypeVar("StateType")
17
19
 
18
20
 
@@ -0,0 +1,83 @@
1
+ """Unit tests for ezmsg.baseproc.clock module."""
2
+
3
+ import math
4
+ import time
5
+
6
+ import numpy as np
7
+ import pytest
8
+ from ezmsg.util.messages.axisarray import AxisArray
9
+
10
+ from ezmsg.baseproc import ClockProducer, ClockSettings
11
+
12
+
13
+ @pytest.mark.parametrize("dispatch_rate", [math.inf, 1.0, 2.0, 5.0, 10.0, 20.0])
14
+ def test_clock_producer_sync(dispatch_rate: float):
15
+ """Test synchronous ClockProducer via __call__."""
16
+ run_time = 1.0
17
+ n_target = 100 if math.isinf(dispatch_rate) else int(np.ceil(dispatch_rate * run_time))
18
+
19
+ producer = ClockProducer(ClockSettings(dispatch_rate=dispatch_rate))
20
+
21
+ results = []
22
+ t_start = time.monotonic()
23
+ while len(results) < n_target:
24
+ results.append(producer())
25
+ t_elapsed = time.monotonic() - t_start
26
+
27
+ # All results should be LinearAxis
28
+ assert all(isinstance(r, AxisArray.LinearAxis) for r in results)
29
+
30
+ # Check gain values
31
+ if math.isinf(dispatch_rate):
32
+ assert all(r.gain == 0.0 for r in results)
33
+ else:
34
+ expected_gain = 1.0 / dispatch_rate
35
+ assert all(abs(r.gain - expected_gain) < 1e-10 for r in results)
36
+
37
+ # Offsets (timestamps) should be monotonically increasing
38
+ offsets = [r.offset for r in results]
39
+ assert all(offsets[i] <= offsets[i + 1] for i in range(len(offsets) - 1))
40
+
41
+ # Check timing
42
+ if math.isfinite(dispatch_rate):
43
+ assert (run_time - 1 / dispatch_rate) < t_elapsed < (run_time + 0.2)
44
+ else:
45
+ # 100 usec per iteration is pretty generous for AFAP
46
+ assert t_elapsed < (n_target * 1e-4)
47
+
48
+
49
+ @pytest.mark.parametrize("dispatch_rate", [math.inf, 2.0, 20.0])
50
+ @pytest.mark.asyncio
51
+ async def test_clock_producer_async(dispatch_rate: float):
52
+ """Test asynchronous ClockProducer via __acall__."""
53
+ run_time = 1.0
54
+ n_target = 100 if math.isinf(dispatch_rate) else int(np.ceil(dispatch_rate * run_time))
55
+
56
+ producer = ClockProducer(ClockSettings(dispatch_rate=dispatch_rate))
57
+
58
+ results = []
59
+ t_start = time.monotonic()
60
+ while len(results) < n_target:
61
+ results.append(await producer.__acall__())
62
+ t_elapsed = time.monotonic() - t_start
63
+
64
+ # All results should be LinearAxis
65
+ assert all(isinstance(r, AxisArray.LinearAxis) for r in results)
66
+
67
+ # Check gain values
68
+ if math.isinf(dispatch_rate):
69
+ assert all(r.gain == 0.0 for r in results)
70
+ else:
71
+ expected_gain = 1.0 / dispatch_rate
72
+ assert all(abs(r.gain - expected_gain) < 1e-10 for r in results)
73
+
74
+ # Offsets (timestamps) should be monotonically increasing
75
+ offsets = [r.offset for r in results]
76
+ assert all(offsets[i] <= offsets[i + 1] for i in range(len(offsets) - 1))
77
+
78
+ # Check timing
79
+ if math.isfinite(dispatch_rate):
80
+ assert (run_time - 1.1 / dispatch_rate) < t_elapsed < (run_time + 0.1)
81
+ else:
82
+ # 100 usec per iteration is pretty generous for AFAP
83
+ assert t_elapsed < (n_target * 1e-4)
@@ -0,0 +1,180 @@
1
+ """Unit tests for ezmsg.baseproc.counter module."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+ from ezmsg.util.messages.axisarray import AxisArray
6
+
7
+ from ezmsg.baseproc import (
8
+ CounterSettings,
9
+ CounterTransformer,
10
+ )
11
+
12
+
13
+ class TestCounterTransformer:
14
+ """Tests for CounterTransformer."""
15
+
16
+ def test_fixed_n_time_mode(self):
17
+ """Test transformer with fixed n_time."""
18
+ transformer = CounterTransformer(CounterSettings(fs=1000.0, n_time=100, mod=None))
19
+
20
+ # Create clock tick with gain = 0.1 (10 Hz dispatch rate)
21
+ clock_tick = AxisArray.LinearAxis(gain=0.1, offset=1.0)
22
+
23
+ result = transformer(clock_tick)
24
+
25
+ assert isinstance(result, AxisArray)
26
+ assert result.data.shape == (100,)
27
+ assert result.dims == ["time"]
28
+ # TimeAxis has gain = 1/fs
29
+ assert result.axes["time"].gain == 1 / 1000.0
30
+ assert result.axes["time"].offset == 1.0 # Uses clock's offset
31
+ np.testing.assert_array_equal(result.data, np.arange(100))
32
+
33
+ def test_fixed_n_time_with_afap_clock(self):
34
+ """Test transformer with fixed n_time and AFAP clock (gain=0)."""
35
+ transformer = CounterTransformer(CounterSettings(fs=1000.0, n_time=50, mod=None))
36
+
37
+ # AFAP clock has gain=0
38
+ clock_tick = AxisArray.LinearAxis(gain=0.0, offset=123.456)
39
+
40
+ result = transformer(clock_tick)
41
+
42
+ assert isinstance(result, AxisArray)
43
+ assert result.data.shape == (50,)
44
+ # With AFAP clock, offset is synthetic (counter / fs)
45
+ assert result.axes["time"].offset == 0.0 # First block starts at 0
46
+
47
+ # Second call
48
+ result2 = transformer(clock_tick)
49
+ assert result2.axes["time"].offset == 50 / 1000.0 # 0.05 seconds
50
+
51
+ def test_variable_n_time_mode(self):
52
+ """Test transformer with n_time derived from clock gain."""
53
+ transformer = CounterTransformer(CounterSettings(fs=1000.0, n_time=None, mod=None))
54
+
55
+ # Clock at 10 Hz with fs=1000 -> 100 samples per tick
56
+ clock_tick = AxisArray.LinearAxis(gain=0.1, offset=5.0)
57
+
58
+ result = transformer(clock_tick)
59
+
60
+ assert isinstance(result, AxisArray)
61
+ assert result.data.shape == (100,) # 1000 * 0.1 = 100
62
+ assert result.axes["time"].offset == 5.0
63
+
64
+ def test_variable_n_time_fractional_accumulation(self):
65
+ """Test fractional sample accumulation in variable mode."""
66
+ transformer = CounterTransformer(CounterSettings(fs=1000.0, n_time=None, mod=None))
67
+
68
+ # Clock at 3 Hz with fs=1000 -> 333.33... samples per tick
69
+ # Over 3 ticks we should get exactly 1000 samples (333 + 333 + 334)
70
+ clock_tick = AxisArray.LinearAxis(gain=1.0 / 3.0, offset=0.0)
71
+
72
+ # First tick: 333.33 -> 333 samples, 0.33 fractional
73
+ result1 = transformer(clock_tick)
74
+ assert result1.data.shape == (333,)
75
+
76
+ # Second tick: 333.33 + 0.33 = 666.66 -> 333 samples, 0.66 fractional
77
+ result2 = transformer(clock_tick)
78
+ assert result2.data.shape == (333,)
79
+
80
+ # Third tick: 333.33 + 0.66 = 999.99... ≈ 1000 -> 334 samples
81
+ result3 = transformer(clock_tick)
82
+ assert result3.data.shape == (334,)
83
+
84
+ # Verify total is exactly 1000 (3 ticks * 333.33... = 1000)
85
+ total_samples = sum(r.data.shape[0] for r in [result1, result2, result3])
86
+ assert total_samples == 1000
87
+
88
+ # Fourth tick starts fresh cycle: 333 samples
89
+ result4 = transformer(clock_tick)
90
+ assert result4.data.shape == (333,)
91
+
92
+ def test_variable_n_time_returns_none_when_no_samples(self):
93
+ """Test that transformer returns None when not enough samples accumulated."""
94
+ transformer = CounterTransformer(
95
+ CounterSettings(fs=10.0, n_time=None, mod=None) # Low fs
96
+ )
97
+
98
+ # Clock at 100 Hz with fs=10 -> 0.1 samples per tick
99
+ clock_tick = AxisArray.LinearAxis(gain=0.01, offset=0.0)
100
+
101
+ # Need 10 ticks to accumulate 1 sample
102
+ for _ in range(9):
103
+ result = transformer(clock_tick)
104
+ assert result is None
105
+
106
+ # 10th tick should produce 1 sample
107
+ result = transformer(clock_tick)
108
+ assert result is not None
109
+ assert result.data.shape == (1,)
110
+
111
+ def test_variable_n_time_afap_raises_error(self):
112
+ """Test that variable mode with AFAP clock raises error."""
113
+ transformer = CounterTransformer(CounterSettings(fs=1000.0, n_time=None, mod=None))
114
+
115
+ clock_tick = AxisArray.LinearAxis(gain=0.0, offset=0.0)
116
+
117
+ with pytest.raises(ValueError, match="Cannot use clock with gain=0"):
118
+ transformer(clock_tick)
119
+
120
+ def test_mod_rollover(self):
121
+ """Test counter rollover with mod."""
122
+ transformer = CounterTransformer(CounterSettings(fs=100.0, n_time=10, mod=8))
123
+
124
+ clock_tick = AxisArray.LinearAxis(gain=0.1, offset=0.0)
125
+
126
+ result = transformer(clock_tick)
127
+ np.testing.assert_array_equal(result.data, [0, 1, 2, 3, 4, 5, 6, 7, 0, 1])
128
+
129
+ def test_continuity_across_calls(self):
130
+ """Test counter continuity across multiple calls."""
131
+ transformer = CounterTransformer(CounterSettings(fs=100.0, n_time=5, mod=None))
132
+
133
+ clock_tick = AxisArray.LinearAxis(gain=0.05, offset=0.0)
134
+
135
+ results = [transformer(clock_tick) for _ in range(4)]
136
+ agg = AxisArray.concatenate(*results, dim="time")
137
+ np.testing.assert_array_equal(agg.data, np.arange(20))
138
+
139
+
140
+ class TestCounterTransformerExternalClock:
141
+ """Tests for external clock mode patterns."""
142
+
143
+ def test_external_clock_with_fixed_n_time(self):
144
+ """Test external clock mode with fixed n_time."""
145
+ transformer = CounterTransformer(CounterSettings(fs=1000.0, n_time=100, mod=None))
146
+
147
+ # Simulate external clock ticks
148
+ timestamps = [1.0, 1.1, 1.2, 1.3, 1.4]
149
+ clock_ticks = [AxisArray.LinearAxis(gain=0.1, offset=ts) for ts in timestamps]
150
+
151
+ results = [transformer(tick) for tick in clock_ticks]
152
+
153
+ # Verify offsets match clock timestamps
154
+ offsets = [r.axes["time"].offset for r in results]
155
+ np.testing.assert_array_equal(offsets, timestamps)
156
+
157
+ # Verify data continuity
158
+ agg = AxisArray.concatenate(*results, dim="time")
159
+ np.testing.assert_array_equal(agg.data, np.arange(500))
160
+
161
+ def test_external_clock_variable_chunk_sizes(self):
162
+ """Test external clock mode with variable chunk sizes."""
163
+ transformer = CounterTransformer(CounterSettings(fs=1000.0, n_time=None, mod=None))
164
+
165
+ # Clock with varying rates
166
+ clock_ticks = [
167
+ AxisArray.LinearAxis(gain=0.1, offset=0.0), # 100 samples
168
+ AxisArray.LinearAxis(gain=0.05, offset=0.1), # 50 samples
169
+ AxisArray.LinearAxis(gain=0.2, offset=0.15), # 200 samples
170
+ ]
171
+
172
+ results = [transformer(tick) for tick in clock_ticks]
173
+
174
+ assert results[0].data.shape == (100,)
175
+ assert results[1].data.shape == (50,)
176
+ assert results[2].data.shape == (200,)
177
+
178
+ # Verify data continuity
179
+ agg = AxisArray.concatenate(*results, dim="time")
180
+ np.testing.assert_array_equal(agg.data, np.arange(350))
@@ -1,87 +0,0 @@
1
- ezmsg.baseproc
2
- ==============
3
-
4
- Base processor classes and utilities for building message-processing components in the `ezmsg <https://www.ezmsg.org>`_ framework.
5
-
6
- Overview
7
- --------
8
-
9
- ``ezmsg-baseproc`` provides abstract base classes for creating message processors that can be used both standalone and within ezmsg pipelines. The package offers a consistent pattern for building:
10
-
11
- * **Processors** - Transform input messages to output messages
12
- * **Producers** - Generate output messages without requiring input
13
- * **Consumers** - Accept input messages without producing output
14
- * **Transformers** - A specific type of processor with typed input/output
15
- * **Stateful variants** - Processors that maintain state across invocations
16
- * **Adaptive transformers** - Transformers that can be trained via ``partial_fit``
17
- * **Composite processors** - Chain multiple processors together efficiently
18
-
19
- All base classes support both synchronous and asynchronous operation, making them suitable for offline analysis and real-time streaming applications.
20
-
21
- Installation
22
- ------------
23
-
24
- Install from PyPI:
25
-
26
- .. code-block:: bash
27
-
28
- pip install ezmsg-baseproc
29
-
30
- Or install the latest development version:
31
-
32
- .. code-block:: bash
33
-
34
- pip install git+https://github.com/ezmsg-org/ezmsg-baseproc@main
35
-
36
- Dependencies
37
- ^^^^^^^^^^^^
38
-
39
- Core dependencies:
40
-
41
- * ``ezmsg`` - Core messaging framework
42
- * ``typing-extensions`` - Extended typing support
43
-
44
- Quick Start
45
- -----------
46
-
47
- For general ezmsg tutorials and guides, visit `ezmsg.org <https://www.ezmsg.org>`_.
48
-
49
- Here's a simple example of creating a custom transformer:
50
-
51
- .. code-block:: python
52
-
53
- import ezmsg.core as ez
54
- from ezmsg.baseproc import BaseTransformer, BaseTransformerUnit
55
-
56
- class MySettings(ez.Settings):
57
- scale: float = 1.0
58
-
59
- class MyTransformer(BaseTransformer[MySettings, float, float]):
60
- def _process(self, message: float) -> float:
61
- return message * self.settings.scale
62
-
63
- # Use standalone
64
- transformer = MyTransformer(scale=2.0)
65
- result = transformer(5.0) # Returns 10.0
66
-
67
- # Or wrap in an ezmsg Unit
68
- class MyUnit(BaseTransformerUnit[MySettings, float, float, MyTransformer]):
69
- SETTINGS = MySettings
70
-
71
- Documentation
72
- -------------
73
-
74
- .. toctree::
75
- :maxdepth: 2
76
- :caption: Contents:
77
-
78
- guides/ProcessorsBase
79
- guides/how-tos/processors/content-processors
80
- api/index
81
-
82
-
83
- Indices and tables
84
- ------------------
85
-
86
- * :ref:`genindex`
87
- * :ref:`modindex`
@@ -1,44 +0,0 @@
1
- """Example usage of ezmsg-baseproc package."""
2
-
3
- import asyncio
4
-
5
- import ezmsg.core as ez
6
-
7
- # Import your units from the package
8
- # from ezmsg.baseproc import MyUnit
9
-
10
-
11
- class ExampleSettings(ez.Settings):
12
- """Settings for ExampleUnit."""
13
-
14
- message: str = "Hello from ezmsg-baseproc!"
15
-
16
-
17
- class ExampleUnit(ez.Unit):
18
- """Example ezmsg Unit demonstrating basic patterns."""
19
-
20
- SETTINGS = ExampleSettings
21
-
22
- INPUT = ez.InputStream(str)
23
- OUTPUT = ez.OutputStream(str)
24
-
25
- @ez.subscriber(INPUT)
26
- @ez.publisher(OUTPUT)
27
- async def on_message(self, message: str) -> ez.AsyncGenerator:
28
- """Process incoming messages."""
29
- result = f"{self.SETTINGS.message} Received: {message}"
30
- yield self.OUTPUT, result
31
-
32
-
33
- async def main():
34
- """Run the example."""
35
- print("ezmsg-baseproc loaded successfully!")
36
- print(f"Version: {__import__('ezmsg.baseproc').__version__}")
37
-
38
- # Example: Create and run a simple system
39
- # system = ExampleSystem()
40
- # await ez.run(SYSTEM=system)
41
-
42
-
43
- if __name__ == "__main__":
44
- asyncio.run(main())
File without changes