pymmcore-plus 0.13.7__py3-none-any.whl → 0.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. pymmcore_plus/__init__.py +2 -0
  2. pymmcore_plus/_accumulator.py +258 -0
  3. pymmcore_plus/_pymmcore.py +4 -2
  4. pymmcore_plus/core/__init__.py +34 -1
  5. pymmcore_plus/core/_constants.py +21 -3
  6. pymmcore_plus/core/_device.py +739 -19
  7. pymmcore_plus/core/_mmcore_plus.py +260 -47
  8. pymmcore_plus/core/events/_protocol.py +49 -34
  9. pymmcore_plus/core/events/_psygnal.py +2 -2
  10. pymmcore_plus/experimental/unicore/__init__.py +7 -1
  11. pymmcore_plus/experimental/unicore/_proxy.py +20 -3
  12. pymmcore_plus/experimental/unicore/core/__init__.py +0 -0
  13. pymmcore_plus/experimental/unicore/core/_sequence_buffer.py +318 -0
  14. pymmcore_plus/experimental/unicore/core/_unicore.py +1702 -0
  15. pymmcore_plus/experimental/unicore/devices/_camera.py +196 -0
  16. pymmcore_plus/experimental/unicore/devices/_device.py +54 -28
  17. pymmcore_plus/experimental/unicore/devices/_properties.py +8 -1
  18. pymmcore_plus/experimental/unicore/devices/_slm.py +82 -0
  19. pymmcore_plus/experimental/unicore/devices/_state.py +152 -0
  20. pymmcore_plus/mda/events/_protocol.py +8 -8
  21. pymmcore_plus/mda/handlers/_tensorstore_handler.py +3 -1
  22. {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/METADATA +14 -37
  23. {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/RECORD +26 -20
  24. pymmcore_plus/experimental/unicore/_unicore.py +0 -703
  25. {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/WHEEL +0 -0
  26. {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/entry_points.txt +0 -0
  27. {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import abstractmethod
4
+ from types import MappingProxyType
5
+ from typing import TYPE_CHECKING, Callable, ClassVar, Literal
6
+
7
+ from pymmcore_plus.core._constants import DeviceType, Keyword, PixelFormat
8
+
9
+ from ._device import Device
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator, Mapping, Sequence
13
+
14
+ import numpy as np
15
+ from numpy.typing import DTypeLike
16
+
17
+
18
+ class Camera(Device):
19
+ # mandatory methods for Camera device adapters
20
+
21
+ _TYPE: ClassVar[Literal[DeviceType.Camera]] = DeviceType.Camera
22
+
23
+ @abstractmethod
24
+ def get_exposure(self) -> float:
25
+ """Get the current exposure time in milliseconds."""
26
+ ...
27
+
28
+ @abstractmethod
29
+ def set_exposure(self, exposure: float) -> None:
30
+ """Set the exposure time in milliseconds."""
31
+ ...
32
+
33
+ @abstractmethod
34
+ def shape(self) -> tuple[int, ...]:
35
+ """Return the shape of the image buffer.
36
+
37
+ This is used when querying Width, Height, *and* number of components.
38
+ If the camera is grayscale, it should return (width, height).
39
+ If the camera is color, it should return (width, height, n_channels).
40
+ """
41
+
42
+ @abstractmethod
43
+ def dtype(self) -> DTypeLike:
44
+ """Return the data type of the image buffer."""
45
+
46
+ @abstractmethod
47
+ def start_sequence(
48
+ self,
49
+ n: int,
50
+ get_buffer: Callable[[Sequence[int], DTypeLike], np.ndarray],
51
+ ) -> Iterator[Mapping]:
52
+ """Start a sequence acquisition.
53
+
54
+ This method should be implemented by the camera device adapter and should
55
+ yield metadata for each acquired image. The implementation should call
56
+ get_buffer() to get a buffer, fill it with image data, then yield the
57
+ metadata for that image.
58
+
59
+ The core will handle threading and synchronization. This function may block.
60
+
61
+ Parameters
62
+ ----------
63
+ n : int
64
+ The number of images to acquire.
65
+ get_buffer : Callable[[Sequence[int], DTypeLike], np.ndarray]
66
+ A callable that returns a buffer for the camera to fill with image data.
67
+ You should call this with the shape of the image and the dtype
68
+ of the image data. The core will produce a buffer of the requested shape
69
+ and dtype, and you should fill it (in place) with the image data.
70
+
71
+ Yields
72
+ ------
73
+ Mapping
74
+ Metadata for each acquired image. This should be yielded after the
75
+ corresponding buffer has been filled with image data.
76
+ """
77
+ # EXAMPLE USAGE:
78
+ # shape, dtype = self.shape(), self.dtype()
79
+ # for _ in range(n):
80
+ # image = get_buffer(shape, dtype)
81
+ # get the image from the camera, and fill the buffer in place
82
+ # image[:] = <your_camera_data>
83
+ # notify the core that the buffer is ready, and provide any metadata
84
+ # yield {"key": "value", ...} # metadata for the image
85
+
86
+ # TODO:
87
+ # Open question: who is responsible for key pieces of metadata?
88
+ # in CMMCore, each of the camera device adapters is responsible for
89
+ # injecting to following bits of metadata:
90
+ # - MM::g_Keyword_Metadata_CameraLabel
91
+ # - MM::g_Keyword_Elapsed_Time_ms (GetCurrentMMTime - start_time)
92
+ # - MM::g_Keyword_Metadata_ROI_X
93
+ # - MM::g_Keyword_Metadata_ROI_Y
94
+ # - MM::g_Keyword_Binning
95
+ # --- while the CircularBuffer InsertMultiChannel is responsible for adding:
96
+ # - MM::g_Keyword_Metadata_ImageNumber
97
+ # - MM::g_Keyword_Elapsed_Time_ms
98
+ # - MM::g_Keyword_Metadata_TimeInCore
99
+ # - MM::g_Keyword_Metadata_Width
100
+ # - MM::g_Keyword_Metadata_Height
101
+ # - MM::g_Keyword_PixelType
102
+
103
+ # Standard Properties --------------------------------------------
104
+
105
+ # these are the standard properties that cameras may implement.
106
+ # Cameras are not required to implement all of these properties, and they may
107
+ # implement additional properties as well.
108
+ # To implement a property, you MUST define a `get_<snake_name>` method and
109
+ # MAY define a `set_<snake_name>` method.
110
+ # To modify the standard properties, you can use the following methods in your
111
+ # __init__, (after calling super().__init__()):
112
+ # self.set_property_value(name, ...)
113
+ # self.set_property_allowed_values(name, ...)
114
+ # self.set_property_limits(name, ...)
115
+ # self.set_property_sequence_max_length(name, ...)
116
+
117
+ STANDARD_PROPERTIES: ClassVar = MappingProxyType(
118
+ {
119
+ Keyword.ActualInterval_ms: ("actual_interval_ms", float),
120
+ Keyword.Binning: ("binning", int),
121
+ Keyword.CameraID: ("camera_id", str),
122
+ Keyword.CameraName: ("camera_name", str),
123
+ Keyword.CCDTemperature: ("ccd_temperature", float),
124
+ Keyword.CCDTemperatureSetPoint: ("ccd_temperature_set_point", float),
125
+ Keyword.EMGain: ("em_gain", float),
126
+ Keyword.Exposure: ("exposure", float),
127
+ Keyword.Gain: ("gain", float),
128
+ Keyword.Interval_ms: ("interval_ms", float),
129
+ Keyword.Offset: ("offset", float),
130
+ # Keyword.PixelType: ("pixel_type", str), # don't use. use PixelFormat
131
+ "PixelFormat": ("pixel_format", PixelFormat),
132
+ Keyword.ReadoutMode: ("readout_mode", str),
133
+ Keyword.ReadoutTime: ("readout_time", float),
134
+ Keyword.Metadata_ROI_X: ("roi_x", int),
135
+ Keyword.Metadata_ROI_Y: ("roi_y", int),
136
+ }
137
+ )
138
+
139
+ # optional methods
140
+ # def get_camera_name(self) -> str:
141
+ # def set_camera_name(self, value: str) -> None:
142
+ # def get_camera_id(self) -> str:
143
+ # def set_camera_id(self, value: str) -> None:
144
+ # def get_binning(self) -> str:
145
+ # def set_binning(self, value: str) -> None:
146
+ # def get_pixel_format(self) -> PixelFormat: ...
147
+ # def set_pixel_format(self, value: PixelFormat) -> None: ...
148
+ # def get_gain(self) -> str:
149
+ # def set_gain(self, value: str) -> None:
150
+ # def get_offset(self) -> str:
151
+ # def set_offset(self, value: str) -> None:
152
+ # def get_readout_mode(self) -> str:
153
+ # def set_readout_mode(self, value: str) -> None:
154
+ # def get_readout_time(self) -> str:
155
+ # def set_readout_time(self, value: str) -> None:
156
+ # def get_actual_interval_ms(self) -> str:
157
+ # def set_actual_interval_ms(self, value: str) -> None:
158
+ # def get_interval_ms(self) -> str:
159
+ # def set_interval_ms(self, value: str) -> None:
160
+ # def get_em_gain(self) -> str:
161
+ # def set_em_gain(self, value: str) -> None:
162
+ # def get_ccd_temperature(self) -> str:
163
+ # def set_ccd_temperature(self, value: str) -> None:
164
+ # def get_ccd_temperature_set_point(self) -> str:
165
+ # def set_ccd_temperature_set_point(self, value: str) -> None:
166
+
167
+ def __init__(self) -> None:
168
+ super().__init__()
169
+ self.register_standard_properties()
170
+
171
+ def register_standard_properties(self) -> None:
172
+ """Inspect the class for standard properties and register them."""
173
+ cls = type(self)
174
+ for name, (snake_name, prop_type) in self.STANDARD_PROPERTIES.items():
175
+ if getter := getattr(cls, f"get_{snake_name}", None):
176
+ setter = getattr(cls, f"set_{snake_name}", None)
177
+ seq_loader = getattr(cls, f"load_{snake_name}_sequence", None)
178
+ seq_starter = getattr(cls, f"start_{snake_name}_sequence", None)
179
+ seq_stopper = getattr(cls, f"stop_{snake_name}_sequence", None)
180
+ self.register_property(
181
+ name=name,
182
+ property_type=prop_type,
183
+ getter=getter,
184
+ setter=setter,
185
+ sequence_loader=seq_loader,
186
+ sequence_starter=seq_starter,
187
+ sequence_stopper=seq_stopper,
188
+ )
189
+
190
+ # Standard Properties, default implementations -------------------
191
+
192
+ # We always implement a standard binning getter. It does not
193
+ # mean that the camera supports binning, unless they implement a setter.
194
+ def get_binning(self) -> int:
195
+ """Get the binning factor for the camera."""
196
+ return 1 # pragma: no cover
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import threading
4
4
  from abc import ABC
5
5
  from collections import ChainMap
6
+ from enum import EnumMeta
6
7
  from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, TypeVar, final
7
8
 
8
9
  from pymmcore_plus.core import DeviceType
@@ -73,11 +74,11 @@ class Device(_Lockable, ABC):
73
74
 
74
75
  def __init_subclass__(cls) -> None:
75
76
  """Initialize the property controllers."""
76
- cls._cls_prop_controllers = {
77
- p.property.name: p
78
- for p in cls.__dict__.values()
79
- if isinstance(p, PropertyController)
80
- }
77
+ cls._cls_prop_controllers = {}
78
+ for base in cls.__mro__:
79
+ for p in base.__dict__.values():
80
+ if isinstance(p, PropertyController):
81
+ cls._cls_prop_controllers[p.property.name] = p
81
82
  return super().__init_subclass__()
82
83
 
83
84
  def register_property(
@@ -93,6 +94,9 @@ class Device(_Lockable, ABC):
93
94
  is_read_only: bool = False,
94
95
  is_pre_init: bool = False,
95
96
  property_type: PropArg = None,
97
+ sequence_loader: Callable[[TDev, Sequence[TProp]], None] | None = None,
98
+ sequence_starter: Callable[[TDev], None] | None = None,
99
+ sequence_stopper: Callable[[TDev], None] | None = None,
96
100
  ) -> None:
97
101
  """Manually register a property.
98
102
 
@@ -108,6 +112,9 @@ class Device(_Lockable, ABC):
108
112
  if property_type is None and default_value is not None:
109
113
  property_type = type(default_value)
110
114
 
115
+ if isinstance(property_type, EnumMeta) and allowed_values is None:
116
+ allowed_values = tuple(property_type)
117
+
111
118
  prop_info = PropertyInfo(
112
119
  name=name,
113
120
  default_value=default_value,
@@ -120,7 +127,14 @@ class Device(_Lockable, ABC):
120
127
  is_pre_init=is_pre_init,
121
128
  type=PropertyType.create(property_type),
122
129
  )
123
- controller = PropertyController(property=prop_info, fget=getter, fset=setter)
130
+ controller = PropertyController(
131
+ property=prop_info,
132
+ fget=getter,
133
+ fset=setter,
134
+ fseq_load=sequence_loader,
135
+ fseq_start=sequence_starter,
136
+ fseq_stop=sequence_stopper,
137
+ )
124
138
  self._prop_controllers_[name] = controller
125
139
 
126
140
  def initialize(self) -> None:
@@ -154,26 +168,38 @@ class Device(_Lockable, ABC):
154
168
 
155
169
  # PROPERTIES
156
170
 
171
+ def _get_prop_or_raise(self, prop_name: str) -> PropertyController:
172
+ """Get a property controller by name or raise an error."""
173
+ if prop_name not in self._prop_controllers_:
174
+ raise KeyError(
175
+ f"Device {self.get_label()!r} has no property {prop_name!r}."
176
+ )
177
+ return self._prop_controllers_[prop_name]
178
+
179
+ def has_property(self, prop_name: str) -> bool:
180
+ """Return `True` if the device has a property with the given name."""
181
+ return prop_name in self._prop_controllers_
182
+
157
183
  def get_property_names(self) -> KeysView[str]:
158
184
  """Return the names of the properties."""
159
185
  return self._prop_controllers_.keys()
160
186
 
161
- def property(self, prop_name: str) -> PropertyInfo:
187
+ def get_property_info(self, prop_name: str) -> PropertyInfo:
162
188
  """Return the property controller for a property."""
163
- return self._prop_controllers_[prop_name].property
189
+ return self._get_prop_or_raise(prop_name).property
164
190
 
165
191
  def get_property_value(self, prop_name: str) -> Any:
166
192
  """Return the value of a property."""
167
193
  # TODO: catch errors
168
- ctrl = self._prop_controllers_[prop_name]
194
+ ctrl = self._get_prop_or_raise(prop_name)
169
195
  if ctrl.fget is None:
170
196
  return ctrl.property.last_value
171
- return self._prop_controllers_[prop_name].__get__(self, self.__class__)
197
+ return ctrl.__get__(self, self.__class__)
172
198
 
173
199
  def set_property_value(self, prop_name: str, value: Any) -> None:
174
200
  """Set the value of a property."""
175
201
  # TODO: catch errors
176
- ctrl = self._prop_controllers_[prop_name]
202
+ ctrl = self._get_prop_or_raise(prop_name)
177
203
  if ctrl.is_read_only:
178
204
  raise ValueError(f"Property {prop_name!r} is read-only.")
179
205
  if ctrl.fset is not None:
@@ -181,41 +207,41 @@ class Device(_Lockable, ABC):
181
207
  else:
182
208
  ctrl.property.last_value = ctrl.validate(value)
183
209
 
184
- def load_property_sequence(self, prop_name: str, sequence: Sequence[Any]) -> None:
185
- """Load a sequence into a property."""
186
- self._prop_controllers_[prop_name].load_sequence(self, sequence)
187
-
188
- def start_property_sequence(self, prop_name: str) -> None:
189
- """Start a sequence of a property."""
190
- self._prop_controllers_[prop_name].start_sequence(self)
191
-
192
- def stop_property_sequence(self, prop_name: str) -> None:
193
- """Stop a sequence of a property."""
194
- self._prop_controllers_[prop_name].stop_sequence(self)
195
-
196
210
  def set_property_allowed_values(
197
211
  self, prop_name: str, allowed_values: Sequence[Any]
198
212
  ) -> None:
199
213
  """Set the allowed values of a property."""
200
- self._prop_controllers_[prop_name].property.allowed_values = allowed_values
214
+ self._get_prop_or_raise(prop_name).property.allowed_values = allowed_values
201
215
 
202
216
  def set_property_limits(
203
217
  self, prop_name: str, limits: tuple[float, float] | None
204
218
  ) -> None:
205
219
  """Set the limits of a property."""
206
- self._prop_controllers_[prop_name].property.limits = limits
220
+ self._get_prop_or_raise(prop_name).property.limits = limits
207
221
 
208
222
  def set_property_sequence_max_length(self, prop_name: str, max_length: int) -> None:
209
223
  """Set the sequence max length of a property."""
210
- self._prop_controllers_[prop_name].property.sequence_max_length = max_length
224
+ self._get_prop_or_raise(prop_name).property.sequence_max_length = max_length
225
+
226
+ def load_property_sequence(self, prop_name: str, sequence: Sequence[Any]) -> None:
227
+ """Load a sequence into a property."""
228
+ self._get_prop_or_raise(prop_name).load_sequence(self, sequence)
229
+
230
+ def start_property_sequence(self, prop_name: str) -> None:
231
+ """Start a sequence of a property."""
232
+ self._get_prop_or_raise(prop_name).start_sequence(self)
233
+
234
+ def stop_property_sequence(self, prop_name: str) -> None:
235
+ """Stop a sequence of a property."""
236
+ self._get_prop_or_raise(prop_name).stop_sequence(self)
211
237
 
212
238
  def is_property_sequenceable(self, prop_name: str) -> bool:
213
239
  """Return `True` if the property is sequenceable."""
214
- return self._prop_controllers_[prop_name].is_sequenceable
240
+ return self._get_prop_or_raise(prop_name).is_sequenceable
215
241
 
216
242
  def is_property_read_only(self, prop_name: str) -> bool:
217
243
  """Return `True` if the property is read-only."""
218
- return self._prop_controllers_[prop_name].is_read_only
244
+ return self._get_prop_or_raise(prop_name).is_read_only
219
245
 
220
246
 
221
247
  SeqT = TypeVar("SeqT")
@@ -76,6 +76,13 @@ class PropertyInfo(Generic[TProp]):
76
76
  is_read_only: bool | None = None
77
77
  is_pre_init: bool = False
78
78
 
79
+ @property
80
+ def number_of_allowed_values(self) -> int:
81
+ """Return the number of allowed values."""
82
+ if self.allowed_values is None:
83
+ return 0
84
+ return len(self.allowed_values)
85
+
79
86
  @property
80
87
  def is_sequenceable(self) -> bool:
81
88
  """Return True if the property is sequenceable."""
@@ -291,7 +298,7 @@ def pymm_property(
291
298
  is_pre_init: bool = ...,
292
299
  name: str | None = ...,
293
300
  property_type: PropArg = ...,
294
- ) -> Callable[[Callable[[TDev], TLim]], PropertyController[TDev, TLim]]: ...
301
+ ) -> Callable[[Callable[[TDev], TProp]], PropertyController[TDev, TProp]]: ...
295
302
  def pymm_property(
296
303
  fget: Callable[[TDev], TProp] | None = None,
297
304
  *,
@@ -0,0 +1,82 @@
1
+ from abc import abstractmethod
2
+ from collections.abc import Sequence
3
+ from typing import ClassVar, Literal
4
+
5
+ import numpy as np
6
+ from numpy.typing import DTypeLike
7
+
8
+ from pymmcore_plus.core._constants import DeviceType
9
+
10
+ from ._device import SequenceableDevice
11
+
12
+
13
+ class SLMDevice(SequenceableDevice[np.ndarray]):
14
+ """ABC for Spatial Light Modulator (SLM) devices.
15
+
16
+ SLM devices are capable of displaying images. They are expected to represent a
17
+ rectangular grid of pixels that can be either 8-bit or 32-bit. Illumination (light
18
+ source on or off) is logically independent of displaying the image.
19
+ """
20
+
21
+ _TYPE: ClassVar[Literal[DeviceType.SLM]] = DeviceType.SLM
22
+
23
+ @abstractmethod
24
+ def shape(self) -> tuple[int, ...]:
25
+ """Return the shape of the SLM image buffer.
26
+
27
+ This is used when querying Width, Height, *and* number of components.
28
+ If the SLM is grayscale, it should return (width, height).
29
+ If the SLM is color, it should return (width, height, n_channels).
30
+ """
31
+ ...
32
+
33
+ @abstractmethod
34
+ def dtype(self) -> DTypeLike:
35
+ """Return the data type of the image buffer."""
36
+ ...
37
+
38
+ @abstractmethod
39
+ def set_image(self, pixels: np.ndarray) -> None:
40
+ """Load the image into the SLM device adapter."""
41
+ ...
42
+
43
+ def get_image(self) -> np.ndarray:
44
+ """Get the current image from the SLM device adapter.
45
+
46
+ This is useful for verifying that the image was set correctly.
47
+ """
48
+ raise NotImplementedError("This SLM device does not support getting images.")
49
+
50
+ @abstractmethod
51
+ def display_image(self) -> None:
52
+ """Command the SLM to display the loaded image."""
53
+
54
+ @abstractmethod
55
+ def set_exposure(self, interval_ms: float) -> None:
56
+ """Command the SLM to turn off after a specified interval."""
57
+ ...
58
+
59
+ @abstractmethod
60
+ def get_exposure(self) -> float:
61
+ """Find out the exposure interval of an SLM."""
62
+ ...
63
+
64
+ # Sequence methods from SequenceableDevice
65
+ def get_sequence_max_length(self) -> int:
66
+ """Return the maximum length of an image sequence that can be uploaded."""
67
+ return 0 # Override in subclasses that support sequencing
68
+
69
+ def send_sequence(self, sequence: Sequence[np.ndarray]) -> None:
70
+ """Load a sequence of images to the SLM."""
71
+ # Default implementation - override in subclasses that support sequencing
72
+ raise NotImplementedError("This SLM device does not support sequences.")
73
+
74
+ def start_sequence(self) -> None:
75
+ """Start a sequence of images on the SLM."""
76
+ # Default implementation - override in subclasses that support sequencing
77
+ raise NotImplementedError("This SLM device does not support sequences.")
78
+
79
+ def stop_sequence(self) -> None:
80
+ """Stop a sequence of images on the SLM."""
81
+ # Default implementation - override in subclasses that support sequencing
82
+ raise NotImplementedError("This SLM device does not support sequences.")
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import abstractmethod
4
+ from typing import TYPE_CHECKING, cast
5
+
6
+ from pymmcore_plus.core._constants import DeviceType, Keyword
7
+
8
+ from ._device import Device
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Iterable, Mapping
12
+ from typing import ClassVar, Literal
13
+
14
+ from pymmcore import StateLabel
15
+ from typing_extensions import Self
16
+
17
+
18
+ class StateDevice(Device):
19
+ """State device API, e.g. filter wheel, objective turret, etc.
20
+
21
+ A state device is a device that at any point in time is in a single state out of a
22
+ list of possible states, like a filter wheel, an objective turret, etc. The
23
+ interface contains functions to get and set the state, to give states human readable
24
+ labels, and functions to make it possible to treat the state device as a shutter.
25
+
26
+ In terms of implementation, this base class provides the basic functionality by
27
+ presenting state and label as properties, which it keeps in sync with the
28
+ underlying device.
29
+
30
+ Parameters
31
+ ----------
32
+ state_labels: Mapping[int, str] | Iterable[tuple[int, str]]
33
+ A mapping (or iterable of 2-tuples) of integer state indices to string labels.
34
+ """
35
+
36
+ # Mandatory methods for state devices
37
+
38
+ @abstractmethod
39
+ def get_state(self) -> int:
40
+ """Get the current state of the device (integer index)."""
41
+ ...
42
+
43
+ @abstractmethod
44
+ def set_state(self, position: int) -> None:
45
+ """Set the state of the device (integer index)."""
46
+ ...
47
+
48
+ # ------------------ The rest is base class implementation ------------------
49
+ # (adaptors may override these methods if desired)
50
+
51
+ _TYPE: ClassVar[Literal[DeviceType.State]] = DeviceType.State
52
+
53
+ @classmethod
54
+ def from_count(cls, count: int) -> Self:
55
+ """Simplified constructor with just a number of states."""
56
+ if count < 1:
57
+ raise ValueError("State device must have at least one state.")
58
+ return cls({i: f"State-{i}" for i in range(count)})
59
+
60
+ def __init__(
61
+ self, state_labels: Mapping[int, str] | Iterable[tuple[int, str]], /
62
+ ) -> None:
63
+ super().__init__()
64
+ if not (states := dict(state_labels)): # pragma: no cover
65
+ raise ValueError("State device must have at least one state.")
66
+
67
+ self._state_to_label: dict[int, StateLabel] = states # type: ignore[assignment]
68
+ # reverse mapping for O(1) lookup
69
+ self._label_to_state: dict[str, int] = {lbl: p for p, lbl in states.items()}
70
+
71
+ self.register_standard_properties()
72
+
73
+ def register_standard_properties(self) -> None:
74
+ """Inspect the class for standard properties and register them."""
75
+ states, labels = zip(*self._state_to_label.items())
76
+ cls = type(self)
77
+ self.register_property(
78
+ name=Keyword.State,
79
+ default_value=states[0],
80
+ allowed_values=states,
81
+ getter=cls.get_state,
82
+ setter=cls._set_state,
83
+ )
84
+ self.register_property(
85
+ name=Keyword.Label.value,
86
+ default_value=labels[0],
87
+ allowed_values=labels,
88
+ getter=cls._get_current_label,
89
+ setter=cls._set_current_label,
90
+ )
91
+
92
+ def set_position_or_label(self, pos_or_label: int | str) -> None:
93
+ """Set the position of the device by index or label."""
94
+ if isinstance(pos_or_label, str):
95
+ label = pos_or_label
96
+ pos = self.get_position_for_label(pos_or_label)
97
+ else:
98
+ pos = int(pos_or_label)
99
+ label = self._state_to_label.get(pos, "")
100
+ if pos not in self._state_to_label:
101
+ raise ValueError(
102
+ f"Position {pos} is not a valid state. "
103
+ f"Available states: {self._state_to_label.keys()}"
104
+ )
105
+ self.set_property_value(Keyword.State, pos) # will trigger set_state
106
+ self.set_property_value(Keyword.Label.value, label)
107
+
108
+ def assign_label_to_position(self, pos: int, label: str) -> None:
109
+ """Assign a User-defined label to a position."""
110
+ if not isinstance(pos, int):
111
+ raise TypeError(f"Position must be an integer, got {type(pos).__name__}.")
112
+
113
+ # update internal state
114
+ self._state_to_label[pos] = label = cast("StateLabel", str(label))
115
+ self._label_to_state[label] = pos
116
+ self._update_allowed_labels()
117
+
118
+ def get_position_for_label(self, label: str) -> int:
119
+ """Return the position corresponding to the provided label."""
120
+ if label not in self._label_to_state:
121
+ raise KeyError(
122
+ f"Label not defined: {label!r}. "
123
+ f"Available labels: {self._state_to_label.values()}"
124
+ )
125
+ return self._label_to_state[label]
126
+
127
+ # ------------------ private methods for internal use ------------------
128
+
129
+ def _update_allowed_labels(self) -> None:
130
+ """Update the allowed values for the label property."""
131
+ label_prop_info = self.get_property_info(Keyword.Label)
132
+ label_prop_info.allowed_values = list(self._state_to_label.values())
133
+
134
+ def _set_state(self, state: int) -> None:
135
+ # internal method to set the state, called by the property setter
136
+ # to keep the label and state property in sync
137
+ self.set_state(state) # call the device-specific method
138
+ label = self._state_to_label.get(state, "")
139
+ self.set_property_value(Keyword.Label, label)
140
+
141
+ def _get_current_label(self) -> str:
142
+ # internal method to get the current label, called by the property getter
143
+ # to keep the label and state property in sync
144
+ pos = self.get_property_value(Keyword.State)
145
+ return self._state_to_label.get(pos, "")
146
+
147
+ def _set_current_label(self, label: str) -> None:
148
+ # internal method to set the label, called by the property setter
149
+ # to keep the label and state property in sync
150
+ pos = self._label_to_state.get(label)
151
+ if pos != self.get_property_value(Keyword.State):
152
+ self.set_property_value(Keyword.State, pos) # will trigger set_state
@@ -1,4 +1,4 @@
1
- from typing import Protocol, runtime_checkable
1
+ from typing import ClassVar, Protocol, runtime_checkable
2
2
 
3
3
  from pymmcore_plus.core.events._protocol import PSignal
4
4
 
@@ -7,29 +7,29 @@ from pymmcore_plus.core.events._protocol import PSignal
7
7
  class PMDASignaler(Protocol):
8
8
  """Declares the protocol for all signals that will be emitted from [`pymmcore_plus.mda.MDARunner`][].""" # noqa: E501
9
9
 
10
- sequenceStarted: PSignal
10
+ sequenceStarted: ClassVar[PSignal]
11
11
  """Emits `(sequence: MDASequence, metadata: dict)` when an acquisition sequence is started.
12
12
 
13
13
  For the default [`MDAEngine`][pymmcore_plus.mda.MDAEngine], the metadata `dict` will
14
14
  be of type [`SummaryMetaV1`][pymmcore_plus.metadata.schema.SummaryMetaV1].
15
15
  """ # noqa: E501
16
- sequencePauseToggled: PSignal
16
+ sequencePauseToggled: ClassVar[PSignal]
17
17
  """Emits `(paused: bool)` when an acquisition sequence is paused or unpaused."""
18
- sequenceCanceled: PSignal
18
+ sequenceCanceled: ClassVar[PSignal]
19
19
  """Emits `(sequence: MDASequence)` when an acquisition sequence is canceled."""
20
- sequenceFinished: PSignal
20
+ sequenceFinished: ClassVar[PSignal]
21
21
  """Emits `(sequence: MDASequence)` when an acquisition sequence is finished."""
22
- frameReady: PSignal
22
+ frameReady: ClassVar[PSignal]
23
23
  """Emits `(img: np.ndarray, event: MDAEvent, metadata: dict)` after an image is acquired during an acquisition sequence.
24
24
 
25
25
  For the default [`MDAEngine`][pymmcore_plus.mda.MDAEngine], the metadata `dict` will
26
26
  be of type [`FrameMetaV1`][pymmcore_plus.metadata.schema.FrameMetaV1].
27
27
  """ # noqa: E501
28
- awaitingEvent: PSignal
28
+ awaitingEvent: ClassVar[PSignal]
29
29
  """Emits `(event: MDAEvent, remaining_sec: float)` when the runner is waiting to start an event.
30
30
 
31
31
  Note: Not all events in a sequence will emit this signal. This will only be emitted
32
32
  if the wait time is non-zero.
33
33
  """ # noqa: E501
34
- eventStarted: PSignal
34
+ eventStarted: ClassVar[PSignal]
35
35
  """Emits `(event: MDAEvent)` immediately before event setup and execution."""
@@ -320,7 +320,9 @@ class TensorStoreHandler:
320
320
  ]
321
321
 
322
322
  if self.ts_driver.startswith("zarr"):
323
- store.kvstore.write(".zattrs", json_dumps(metadata).decode("utf-8"))
323
+ store.kvstore.write(
324
+ ".zattrs", json_dumps(metadata).decode("utf-8")
325
+ ).result()
324
326
  elif self.ts_driver == "n5": # pragma: no cover
325
327
  attrs = json_loads(store.kvstore.read("attributes.json").result().value)
326
328
  attrs.update(metadata)