pymmcore-plus 0.12.0__py3-none-any.whl → 0.13.1__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 (48) hide show
  1. pymmcore_plus/__init__.py +3 -3
  2. pymmcore_plus/_benchmark.py +203 -0
  3. pymmcore_plus/_cli.py +78 -13
  4. pymmcore_plus/_logger.py +10 -2
  5. pymmcore_plus/_pymmcore.py +12 -0
  6. pymmcore_plus/_util.py +16 -10
  7. pymmcore_plus/core/__init__.py +3 -0
  8. pymmcore_plus/core/_config.py +1 -1
  9. pymmcore_plus/core/_config_group.py +2 -2
  10. pymmcore_plus/core/_constants.py +27 -3
  11. pymmcore_plus/core/_device.py +4 -4
  12. pymmcore_plus/core/_metadata.py +1 -1
  13. pymmcore_plus/core/_mmcore_plus.py +184 -118
  14. pymmcore_plus/core/_property.py +3 -5
  15. pymmcore_plus/core/_sequencing.py +369 -234
  16. pymmcore_plus/core/events/__init__.py +3 -3
  17. pymmcore_plus/experimental/__init__.py +0 -0
  18. pymmcore_plus/experimental/unicore/__init__.py +14 -0
  19. pymmcore_plus/experimental/unicore/_device_manager.py +173 -0
  20. pymmcore_plus/experimental/unicore/_proxy.py +127 -0
  21. pymmcore_plus/experimental/unicore/_unicore.py +703 -0
  22. pymmcore_plus/experimental/unicore/devices/__init__.py +0 -0
  23. pymmcore_plus/experimental/unicore/devices/_device.py +269 -0
  24. pymmcore_plus/experimental/unicore/devices/_properties.py +400 -0
  25. pymmcore_plus/experimental/unicore/devices/_stage.py +221 -0
  26. pymmcore_plus/install.py +10 -7
  27. pymmcore_plus/mda/__init__.py +1 -1
  28. pymmcore_plus/mda/_engine.py +152 -43
  29. pymmcore_plus/mda/_runner.py +8 -1
  30. pymmcore_plus/mda/events/__init__.py +2 -2
  31. pymmcore_plus/mda/handlers/__init__.py +1 -1
  32. pymmcore_plus/mda/handlers/_ome_zarr_writer.py +2 -2
  33. pymmcore_plus/mda/handlers/_tensorstore_handler.py +6 -2
  34. pymmcore_plus/metadata/__init__.py +3 -3
  35. pymmcore_plus/metadata/functions.py +18 -8
  36. pymmcore_plus/metadata/schema.py +6 -5
  37. pymmcore_plus/mocks.py +49 -0
  38. pymmcore_plus/model/_config_file.py +1 -1
  39. pymmcore_plus/model/_core_device.py +10 -1
  40. pymmcore_plus/model/_device.py +17 -6
  41. pymmcore_plus/model/_property.py +11 -2
  42. pymmcore_plus/seq_tester.py +1 -1
  43. {pymmcore_plus-0.12.0.dist-info → pymmcore_plus-0.13.1.dist-info}/METADATA +14 -6
  44. pymmcore_plus-0.13.1.dist-info/RECORD +71 -0
  45. {pymmcore_plus-0.12.0.dist-info → pymmcore_plus-0.13.1.dist-info}/WHEEL +1 -1
  46. pymmcore_plus-0.12.0.dist-info/RECORD +0 -59
  47. {pymmcore_plus-0.12.0.dist-info → pymmcore_plus-0.13.1.dist-info}/entry_points.txt +0 -0
  48. {pymmcore_plus-0.12.0.dist-info → pymmcore_plus-0.13.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,221 @@
1
+ from abc import abstractmethod
2
+ from typing import ClassVar, Literal
3
+
4
+ from pymmcore_plus.core import DeviceType
5
+ from pymmcore_plus.core._constants import Keyword
6
+
7
+ from ._device import SeqT, SequenceableDevice
8
+
9
+ __all__ = ["_BaseStage"]
10
+
11
+
12
+ class _BaseStage(SequenceableDevice[SeqT]):
13
+ """Shared logic for Stage and XYStage devices."""
14
+
15
+ @abstractmethod
16
+ def home(self) -> None:
17
+ """Move the stage to its home position."""
18
+
19
+ @abstractmethod
20
+ def stop(self) -> None:
21
+ """Stop the stage."""
22
+
23
+ @abstractmethod
24
+ def set_origin(self) -> None:
25
+ """Zero the stage's coordinates at the current position."""
26
+
27
+
28
+ class StageDevice(_BaseStage[float]):
29
+ """ABC for Stage devices."""
30
+
31
+ _TYPE: ClassVar[Literal[DeviceType.Stage]] = DeviceType.Stage
32
+
33
+ @abstractmethod
34
+ def set_position_um(self, val: float) -> None:
35
+ """Set the position of the stage in microns."""
36
+
37
+ @abstractmethod
38
+ def get_position_um(self) -> float:
39
+ """Returns the current position of the stage in microns."""
40
+
41
+
42
+ # TODO: consider if we can just subclass StageDevice instead of _BaseStage
43
+ class XYStageDevice(_BaseStage[tuple[float, float]]):
44
+ """ABC for XYStage devices."""
45
+
46
+ _TYPE: ClassVar[Literal[DeviceType.XYStage]] = DeviceType.XYStage
47
+
48
+ @abstractmethod
49
+ def set_position_um(self, x: float, y: float) -> None:
50
+ """Set the position of the XY stage in microns."""
51
+
52
+ @abstractmethod
53
+ def get_position_um(self) -> tuple[float, float]:
54
+ """Returns the current position of the XY stage in microns."""
55
+
56
+ @abstractmethod
57
+ def set_origin_x(self) -> None:
58
+ """Zero the stage's X coordinates at the current position."""
59
+
60
+ @abstractmethod
61
+ def set_origin_y(self) -> None:
62
+ """Zero the stage's Y coordinates at the current position."""
63
+
64
+ # ----------------------------------------------------------------
65
+
66
+ def set_relative_position_um(self, dx: float, dy: float) -> None:
67
+ """Move the stage by a relative amount.
68
+
69
+ Can be overridden for more efficient implementations.
70
+ """
71
+ x, y = self.get_position_um()
72
+ self.set_position_um(x + dx, y + dy)
73
+
74
+ def set_adapter_origin_um(self, x: float, y: float) -> None:
75
+ """Alter the software coordinate translation between micrometers and steps.
76
+
77
+ ... such that the current position becomes the given coordinates.
78
+ """
79
+ # I don't quite understand what this method is supposed to do yet.
80
+ # I believe it's here to give device adapter implementations a way to to set
81
+ # the origin of some translation between micrometers and steps, rather than to
82
+ # directly update the origin on the device itself.
83
+
84
+ def set_origin(self) -> None:
85
+ """Zero the stage's coordinates at the current position.
86
+
87
+ This is a convenience method that calls `set_origin_x` and `set_origin_y`.
88
+ Can be overridden for more efficient implementations.
89
+ """
90
+ self.set_origin_x()
91
+ self.set_origin_y()
92
+
93
+
94
+ class XYStepperStageDevice(XYStageDevice):
95
+ """ABC for XYStage devices that support stepper motors.
96
+
97
+ In this variant, rather than providing `set_position_um` and `get_position_um`,
98
+ you provide `set_position_steps`, `get_position_steps`, `get_step_size_x_um`,
99
+ and `get_step_size_y_um`. A default implementation of `set_position_um` and
100
+ `get_position_um` is then provided that uses these methods, taking into account
101
+ the XY-mirroring properties of the device.
102
+ """
103
+
104
+ @abstractmethod
105
+ def set_position_steps(self, x: int, y: int) -> None:
106
+ """Set the position of the XY stage in steps."""
107
+
108
+ @abstractmethod
109
+ def get_position_steps(self) -> tuple[int, int]:
110
+ """Returns the current position of the XY stage in steps."""
111
+
112
+ @abstractmethod
113
+ def get_step_size_x_um(self) -> float:
114
+ """Returns the step size of the X axis in microns."""
115
+
116
+ @abstractmethod
117
+ def get_step_size_y_um(self) -> float:
118
+ """Returns the step size of the Y axis in microns."""
119
+
120
+ # ----------------------------------------------------------------
121
+
122
+ def __init__(self) -> None:
123
+ super().__init__()
124
+ self.register_property(name=Keyword.Transpose_MirrorX, default_value=False)
125
+ self.register_property(name=Keyword.Transpose_MirrorY, default_value=False)
126
+ self._origin_x_steps: int = 0
127
+ self._origin_y_steps: int = 0
128
+
129
+ def set_position_um(self, x: float, y: float) -> None:
130
+ """Set the position of the XY stage in microns."""
131
+ # Converts the given micrometer coordinates to steps and sets the position.
132
+ mirror_x, mirror_y = self._get_orientation()
133
+
134
+ steps_x = int(x / self.get_step_size_x_um())
135
+ steps_y = int(y / self.get_step_size_y_um())
136
+
137
+ if mirror_x:
138
+ steps_x = -steps_x
139
+ if mirror_y:
140
+ steps_y = -steps_y
141
+
142
+ x_steps = self._origin_x_steps + steps_x
143
+ y_steps = self._origin_y_steps + steps_y
144
+ self.set_position_steps(x_steps, y_steps)
145
+
146
+ self.core.events.XYStagePositionChanged.emit(self.get_label(), x, y)
147
+
148
+ def get_position_um(self) -> tuple[float, float]:
149
+ """Get the position of the XY stage in microns."""
150
+ # Converts the current steps to micrometer coordinates and returns the position.
151
+ mirror_x, mirror_y = self._get_orientation()
152
+ x_steps, y_steps = self.get_position_steps()
153
+
154
+ x = (self._origin_x_steps - x_steps) * self.get_step_size_x_um()
155
+ y = (self._origin_y_steps - y_steps) * self.get_step_size_y_um()
156
+ if not mirror_x:
157
+ x = -x
158
+ if not mirror_y:
159
+ y = -y
160
+
161
+ return x, y
162
+
163
+ def set_relative_position_steps(self, dx: int, dy: int) -> None:
164
+ """Move the stage by a relative amount.
165
+
166
+ Can be overridden for more efficient implementations.
167
+ """
168
+ x_steps, y_steps = self.get_position_steps()
169
+ self.set_position_steps(x_steps + dx, y_steps + dy)
170
+
171
+ def set_relative_position_um(self, dx: float, dy: float) -> None:
172
+ """Default implementation for relative motion.
173
+
174
+ Can be overridden for more efficient implementations.
175
+ """
176
+ mirror_x, mirror_y = self._get_orientation()
177
+
178
+ if mirror_x:
179
+ dx = -dx
180
+ if mirror_y:
181
+ dy = -dy
182
+
183
+ steps_x = int(dx / self.get_step_size_x_um())
184
+ steps_y = int(dy / self.get_step_size_y_um())
185
+
186
+ self.set_relative_position_steps(steps_x, steps_y)
187
+
188
+ x, y = self.get_position_um()
189
+ self.core.events.XYStagePositionChanged.emit(self.get_label(), x, y)
190
+
191
+ def set_adapter_origin_um(self, x: float = 0.0, y: float = 0.0) -> None:
192
+ """Alter the software coordinate translation between micrometers and steps.
193
+
194
+ ... such that the current position becomes the given coordinates.
195
+ """
196
+ mirror_x, mirror_y = self._get_orientation()
197
+ x_steps, y_steps = self.get_position_steps()
198
+
199
+ steps_x = int(x / self.get_step_size_x_um())
200
+ steps_y = int(y / self.get_step_size_y_um())
201
+
202
+ self._origin_x_steps = x_steps + (steps_x if mirror_x else -steps_x)
203
+ self._origin_y_steps = y_steps + (steps_y if mirror_y else -steps_y)
204
+
205
+ def set_origin(self) -> None:
206
+ """Zero the stage's coordinates at the current position."""
207
+ self.set_adapter_origin_um()
208
+
209
+ def set_origin_x(self) -> None:
210
+ """Zero the stage's X coordinates at the current position."""
211
+ raise NotImplementedError # pragma: no cover
212
+
213
+ def set_origin_y(self) -> None:
214
+ """Zero the stage's Y coordinates at the current position."""
215
+ raise NotImplementedError # pragma: no cover
216
+
217
+ def _get_orientation(self) -> tuple[bool, bool]:
218
+ return (
219
+ self.get_property_value(Keyword.Transpose_MirrorX),
220
+ self.get_property_value(Keyword.Transpose_MirrorY),
221
+ )
pymmcore_plus/install.py CHANGED
@@ -37,14 +37,12 @@ try:
37
37
  rich_print(f"{emoji}{color}{text}")
38
38
 
39
39
  @contextmanager
40
- def _spinner(
41
- text: str = "", color: str = "bold blue"
42
- ) -> Iterator[progress.Progress]:
40
+ def _spinner(text: str, color: str = "bold blue") -> Iterator[None]:
43
41
  with progress.Progress(
44
42
  progress.SpinnerColumn(), progress.TextColumn(f"[{color}]{text}")
45
43
  ) as pbar:
46
44
  pbar.add_task(description=text, total=None)
47
- yield pbar
45
+ yield None
48
46
 
49
47
  except ImportError: # pragma: no cover
50
48
  progress = None # type: ignore
@@ -53,7 +51,7 @@ except ImportError: # pragma: no cover
53
51
  print(text)
54
52
 
55
53
  @contextmanager
56
- def _spinner(text: str = "", color: str = "") -> Iterator[None]:
54
+ def _spinner(text: str, color: str = "") -> Iterator[None]:
57
55
  print(text)
58
56
  yield
59
57
 
@@ -78,8 +76,8 @@ def _get_spinner(log_msg: _MsgLogger) -> Callable[[str], AbstractContextManager]
78
76
  else:
79
77
 
80
78
  @contextmanager
81
- def spinner(msg: str) -> Iterator[None]:
82
- log_msg(msg)
79
+ def spinner(text: str, color: str = "") -> Iterator[None]:
80
+ log_msg(text)
83
81
  yield
84
82
 
85
83
  return spinner
@@ -209,6 +207,11 @@ def install(
209
207
  """
210
208
  if PLATFORM not in ("Darwin", "Windows"): # pragma: no cover
211
209
  log_msg(f"Unsupported platform: {PLATFORM!r}", "bold red", ":x:")
210
+ log_msg(
211
+ "Consider building from source (mmcore build-dev).",
212
+ "bold yellow",
213
+ ":light_bulb:",
214
+ )
212
215
  raise sys.exit(1)
213
216
 
214
217
  if release == "latest":
@@ -5,9 +5,9 @@ from ._thread_relay import mda_listeners_connected
5
5
  from .events import PMDASignaler
6
6
 
7
7
  __all__ = [
8
- "mda_listeners_connected",
9
8
  "MDAEngine",
10
9
  "MDARunner",
11
10
  "PMDAEngine",
12
11
  "PMDASignaler",
12
+ "mda_listeners_connected",
13
13
  ]
@@ -3,18 +3,16 @@ from __future__ import annotations
3
3
  import time
4
4
  from contextlib import suppress
5
5
  from itertools import product
6
- from typing import (
7
- TYPE_CHECKING,
8
- NamedTuple,
9
- cast,
10
- )
6
+ from typing import TYPE_CHECKING, Literal, NamedTuple, cast
11
7
 
8
+ import numpy as np
9
+ import useq
12
10
  from useq import HardwareAutofocus, MDAEvent, MDASequence
13
11
 
14
12
  from pymmcore_plus._logger import logger
15
13
  from pymmcore_plus._util import retry
16
14
  from pymmcore_plus.core._constants import Keyword
17
- from pymmcore_plus.core._sequencing import SequencedEvent
15
+ from pymmcore_plus.core._sequencing import SequencedEvent, iter_sequenced_events
18
16
  from pymmcore_plus.metadata import (
19
17
  FrameMetaV1,
20
18
  PropertyValue,
@@ -27,6 +25,7 @@ from ._protocol import PMDAEngine
27
25
 
28
26
  if TYPE_CHECKING:
29
27
  from collections.abc import Iterable, Iterator, Sequence
28
+ from typing import TypeAlias
30
29
 
31
30
  from numpy.typing import NDArray
32
31
 
@@ -34,6 +33,19 @@ if TYPE_CHECKING:
34
33
 
35
34
  from ._protocol import PImagePayload
36
35
 
36
+ IncludePositionArg: TypeAlias = Literal[True, False, "unsequenced-only"]
37
+
38
+
39
+ # these are SLM devices that have a known pixel_on_value.
40
+ # there is currently no way to extract this information from the core,
41
+ # so it is hard-coded here.
42
+ # maps device_name -> pixel_on_value
43
+ _SLM_DEVICES_PIXEL_ON_VALUES: dict[str, int] = {
44
+ "MightexPolygon1000": 255,
45
+ "Mosaic3": 1,
46
+ "GenericSLM": 255,
47
+ }
48
+
37
49
 
38
50
  class MDAEngine(PMDAEngine):
39
51
  """The default MDAengine that ships with pymmcore-plus.
@@ -59,7 +71,11 @@ class MDAEngine(PMDAEngine):
59
71
 
60
72
  def __init__(self, mmc: CMMCorePlus, use_hardware_sequencing: bool = True) -> None:
61
73
  self._mmc = mmc
62
- self.use_hardware_sequencing = use_hardware_sequencing
74
+ self.use_hardware_sequencing: bool = use_hardware_sequencing
75
+
76
+ # whether to include position metadata when fetching on-frame metadata
77
+ # omitted by default when performing triggered acquisition because it's slow.
78
+ self._include_frame_position_metadata: IncludePositionArg = "unsequenced-only"
63
79
 
64
80
  # used to check if the hardware autofocus is engaged when the sequence begins.
65
81
  # if it is, we will re-engage it after the autofocus action (if successful).
@@ -83,6 +99,19 @@ class MDAEngine(PMDAEngine):
83
99
  # in the channel group.
84
100
  self._config_device_props: dict[str, Sequence[tuple[str, str]]] = {}
85
101
 
102
+ @property
103
+ def include_frame_position_metadata(self) -> IncludePositionArg:
104
+ return self._include_frame_position_metadata
105
+
106
+ @include_frame_position_metadata.setter
107
+ def include_frame_position_metadata(self, value: IncludePositionArg) -> None:
108
+ if value not in (True, False, "unsequenced-only"): # pragma: no cover
109
+ raise ValueError(
110
+ "include_frame_position_metadata must be True, False, or "
111
+ "'unsequenced-only'"
112
+ )
113
+ self._include_frame_position_metadata = value
114
+
86
115
  @property
87
116
  def mmcore(self) -> CMMCorePlus:
88
117
  """The `CMMCorePlus` instance to use for hardware control."""
@@ -188,21 +217,7 @@ class MDAEngine(PMDAEngine):
188
217
  yield from events
189
218
  return
190
219
 
191
- seq: list[MDAEvent] = []
192
- for event in events:
193
- # if the sequence is empty or the current event can be sequenced with the
194
- # previous event, add it to the sequence
195
- if not seq or self._mmc.canSequenceEvents(seq[-1], event, len(seq)):
196
- seq.append(event)
197
- else:
198
- # otherwise, yield a SequencedEvent if the sequence has accumulated
199
- # more than one event, otherwise yield the single event
200
- yield seq[0] if len(seq) == 1 else SequencedEvent.create(seq)
201
- # add this current event and start a new sequence
202
- seq = [event]
203
- # yield any remaining events
204
- if seq:
205
- yield seq[0] if len(seq) == 1 else SequencedEvent.create(seq)
220
+ yield from iter_sequenced_events(self._mmc, events)
206
221
 
207
222
  # ===================== Regular Events =====================
208
223
 
@@ -220,7 +235,8 @@ class MDAEngine(PMDAEngine):
220
235
  self._set_event_position(event)
221
236
  if event.z_pos is not None:
222
237
  self._set_event_z(event)
223
-
238
+ if event.slm_image is not None:
239
+ self._set_event_slm_image(event)
224
240
  if event.channel is not None:
225
241
  try:
226
242
  # possible speedup by setting manually.
@@ -232,7 +248,12 @@ class MDAEngine(PMDAEngine):
232
248
  self._mmc.setExposure(event.exposure)
233
249
  except Exception as e:
234
250
  logger.warning("Failed to set exposure. %s", e)
235
-
251
+ if event.properties is not None:
252
+ try:
253
+ for dev, prop, value in event.properties:
254
+ self._mmc.setProperty(dev, prop, value)
255
+ except Exception as e:
256
+ logger.warning("Failed to set properties. %s", e)
236
257
  if (
237
258
  # (if autoshutter wasn't set at the beginning of the sequence
238
259
  # then it never matters...)
@@ -253,6 +274,9 @@ class MDAEngine(PMDAEngine):
253
274
  `exec_event`, which *is* part of the protocol), but it is made public
254
275
  in case a user wants to subclass this engine and override this method.
255
276
  """
277
+ if event.slm_image is not None:
278
+ self._exec_event_slm_image(event.slm_image)
279
+
256
280
  try:
257
281
  self._mmc.snapImage()
258
282
  # taking event time after snapImage includes exposure time
@@ -274,6 +298,7 @@ class MDAEngine(PMDAEngine):
274
298
  event,
275
299
  runner_time_ms=event_time_ms,
276
300
  camera_device=self._mmc.getPhysicalCameraDevice(cam),
301
+ include_position=self._include_frame_position_metadata is not False,
277
302
  )
278
303
  # Note, the third element is actually a MutableMapping, but mypy doesn't
279
304
  # see TypedDict as a subclass of MutableMapping yet.
@@ -285,6 +310,7 @@ class MDAEngine(PMDAEngine):
285
310
  event: MDAEvent,
286
311
  prop_values: tuple[PropertyValue, ...] | None = None,
287
312
  runner_time_ms: float = 0.0,
313
+ include_position: bool = True,
288
314
  camera_device: str | None = None,
289
315
  ) -> FrameMetaV1:
290
316
  if prop_values is None and (ch := event.channel):
@@ -298,6 +324,7 @@ class MDAEngine(PMDAEngine):
298
324
  camera_device=camera_device,
299
325
  property_values=prop_values,
300
326
  mda_event=event,
327
+ include_position=include_position,
301
328
  )
302
329
 
303
330
  def teardown_event(self, event: MDAEvent) -> None:
@@ -316,7 +343,7 @@ class MDAEngine(PMDAEngine):
316
343
  core.stopXYStageSequence(core.getXYStageDevice())
317
344
  if event.z_sequence:
318
345
  core.stopStageSequence(core.getFocusDevice())
319
- for dev, prop in event.property_sequences(core):
346
+ for dev, prop in event.property_sequences:
320
347
  core.stopPropertySequence(dev, prop)
321
348
 
322
349
  def teardown_sequence(self, sequence: MDASequence) -> None:
@@ -325,17 +352,15 @@ class MDAEngine(PMDAEngine):
325
352
 
326
353
  # ===================== Sequenced Events =====================
327
354
 
328
- def setup_sequenced_event(self, event: SequencedEvent) -> None:
329
- """Setup hardware for a sequenced (triggered) event.
355
+ def _load_sequenced_event(self, event: SequencedEvent) -> None:
356
+ """Load a `SequencedEvent` into the core.
330
357
 
331
- This method is not part of the PMDAEngine protocol (it is called by
332
- `setup_event`, which *is* part of the protocol), but it is made public
333
- in case a user wants to subclass this engine and override this method.
358
+ `SequencedEvent` is a special pymmcore-plus specific subclass of
359
+ `useq.MDAEvent`.
334
360
  """
335
361
  core = self._mmc
336
- cam_device = self._mmc.getCameraDevice()
337
-
338
362
  if event.exposure_sequence:
363
+ cam_device = core.getCameraDevice()
339
364
  with suppress(RuntimeError):
340
365
  core.stopExposureSequence(cam_device)
341
366
  core.loadExposureSequence(cam_device, event.exposure_sequence)
@@ -349,40 +374,71 @@ class MDAEngine(PMDAEngine):
349
374
  with suppress(RuntimeError):
350
375
  core.stopStageSequence(zstage)
351
376
  core.loadStageSequence(zstage, event.z_sequence)
352
- if prop_seqs := event.property_sequences(core):
353
- for (dev, prop), value_sequence in prop_seqs.items():
377
+ if event.slm_sequence:
378
+ slm = core.getSLMDevice()
379
+ with suppress(RuntimeError):
380
+ core.stopSLMSequence(slm)
381
+ core.loadSLMSequence(slm, event.slm_sequence) # type: ignore[arg-type]
382
+ if event.property_sequences:
383
+ for (dev, prop), value_sequence in event.property_sequences.items():
354
384
  with suppress(RuntimeError):
355
385
  core.stopPropertySequence(dev, prop)
356
386
  core.loadPropertySequence(dev, prop, value_sequence)
357
387
 
358
- # TODO: SLM
388
+ # set all static properties, these won't change over the course of the sequence.
389
+ if event.properties:
390
+ for dev, prop, value in event.properties:
391
+ core.setProperty(dev, prop, value)
392
+
393
+ def setup_sequenced_event(self, event: SequencedEvent) -> None:
394
+ """Setup hardware for a sequenced (triggered) event.
395
+
396
+ This method is not part of the PMDAEngine protocol (it is called by
397
+ `setup_event`, which *is* part of the protocol), but it is made public
398
+ in case a user wants to subclass this engine and override this method.
399
+ """
400
+ core = self._mmc
401
+
402
+ self._load_sequenced_event(event)
403
+
404
+ # this is probably not necessary. loadSequenceEvent will have already
405
+ # set all the config properties individually/manually. However, without
406
+ # the call below, we won't be able to query `core.getCurrentConfig()`
407
+ # not sure that's necessary; and this is here for tests to pass for now,
408
+ # but this could be removed.
409
+ if event.channel is not None:
410
+ try:
411
+ core.setConfig(event.channel.group, event.channel.config)
412
+ except Exception as e:
413
+ logger.warning("Failed to set channel. %s", e)
414
+
415
+ if event.slm_image:
416
+ self._set_event_slm_image(event)
359
417
 
360
418
  # preparing a Sequence while another is running is dangerous.
361
419
  if core.isSequenceRunning():
362
420
  self._await_sequence_acquisition()
363
- core.prepareSequenceAcquisition(cam_device)
421
+ core.prepareSequenceAcquisition(core.getCameraDevice())
364
422
 
365
423
  # start sequences or set non-sequenced values
366
424
  if event.x_sequence:
367
- core.startXYStageSequence(stage)
425
+ core.startXYStageSequence(core.getXYStageDevice())
368
426
  elif event.x_pos is not None or event.y_pos is not None:
369
427
  self._set_event_position(event)
370
428
 
371
429
  if event.z_sequence:
372
- core.startStageSequence(zstage)
430
+ core.startStageSequence(core.getFocusDevice())
373
431
  elif event.z_pos is not None:
374
432
  self._set_event_z(event)
375
433
 
376
434
  if event.exposure_sequence:
377
- core.startExposureSequence(cam_device)
435
+ core.startExposureSequence(core.getCameraDevice())
378
436
  elif event.exposure is not None:
379
437
  core.setExposure(event.exposure)
380
438
 
381
- if prop_seqs:
382
- for dev, prop in prop_seqs:
439
+ if event.property_sequences:
440
+ for dev, prop in event.property_sequences:
383
441
  core.startPropertySequence(dev, prop)
384
- elif event.channel is not None:
385
- core.setConfig(event.channel.group, event.channel.config)
386
442
 
387
443
  def _await_sequence_acquisition(
388
444
  self, timeout: float = 5.0, poll_interval: float = 0.2
@@ -417,6 +473,9 @@ class MDAEngine(PMDAEngine):
417
473
  t0 = event.metadata.get("runner_t0") or time.perf_counter()
418
474
  event_t0_ms = (time.perf_counter() - t0) * 1000
419
475
 
476
+ if event.slm_image is not None:
477
+ self._exec_event_slm_image(event.slm_image)
478
+
420
479
  # Start sequence
421
480
  # Note that the overload of startSequenceAcquisition that takes a camera
422
481
  # label does NOT automatically initialize a circular buffer. So if this call
@@ -491,6 +550,7 @@ class MDAEngine(PMDAEngine):
491
550
  prop_values=(),
492
551
  runner_time_ms=event_t0 + seq_time,
493
552
  camera_device=camera_device,
553
+ include_position=self._include_frame_position_metadata is True,
494
554
  )
495
555
  meta["hardware_triggered"] = True
496
556
  meta["images_remaining_in_buffer"] = remaining
@@ -547,6 +607,55 @@ class MDAEngine(PMDAEngine):
547
607
  correction = self._z_correction.setdefault(p_idx, 0.0)
548
608
  self._mmc.setZPosition(cast("float", event.z_pos) + correction)
549
609
 
610
+ def _set_event_slm_image(self, event: MDAEvent) -> None:
611
+ if not event.slm_image:
612
+ return
613
+ try:
614
+ # Get the SLM device
615
+ if not (
616
+ slm_device := event.slm_image.device or self._mmc.getSLMDevice()
617
+ ): # pragma: no cover
618
+ raise ValueError("No SLM device found or specified.")
619
+
620
+ # cast to numpy array
621
+ slm_array = np.asarray(event.slm_image)
622
+ # if it's a single value, we can just set all pixels to that value
623
+ if slm_array.ndim == 0:
624
+ value = slm_array.item()
625
+ if isinstance(value, bool):
626
+ dev_name = self._mmc.getDeviceName(slm_device)
627
+ on_value = _SLM_DEVICES_PIXEL_ON_VALUES.get(dev_name, 1)
628
+ value = on_value if value else 0
629
+ self._mmc.setSLMPixelsTo(slm_device, int(value))
630
+ elif slm_array.size == 3:
631
+ # if it's a 3-valued array, we assume it's RGB
632
+ r, g, b = slm_array.astype(int)
633
+ self._mmc.setSLMPixelsTo(slm_device, r, g, b)
634
+ elif slm_array.ndim in (2, 3):
635
+ # if it's a 2D/3D array, we assume it's an image
636
+ # where 3D is RGB with shape (h, w, 3)
637
+ if slm_array.ndim == 3 and slm_array.shape[2] != 3:
638
+ raise ValueError( # pragma: no cover
639
+ "SLM image must be 2D or 3D with 3 channels (RGB)."
640
+ )
641
+ # convert boolean on/off values to pixel values
642
+ if slm_array.dtype == bool:
643
+ dev_name = self._mmc.getDeviceName(slm_device)
644
+ on_value = _SLM_DEVICES_PIXEL_ON_VALUES.get(dev_name, 1)
645
+ slm_array = np.where(slm_array, on_value, 0).astype(np.uint8)
646
+ self._mmc.setSLMImage(slm_device, slm_array)
647
+ if event.slm_image.exposure:
648
+ self._mmc.setSLMExposure(slm_device, event.slm_image.exposure)
649
+ except Exception as e:
650
+ logger.warning("Failed to set SLM Image: %s", e)
651
+
652
+ def _exec_event_slm_image(self, img: useq.SLMImage) -> None:
653
+ if slm_device := (img.device or self._mmc.getSLMDevice()):
654
+ try:
655
+ self._mmc.displaySLMImage(slm_device)
656
+ except Exception as e:
657
+ logger.warning("Failed to set SLM Image: %s", e)
658
+
550
659
  def _update_config_device_props(self) -> None:
551
660
  # store devices/props that make up each config group for faster lookup
552
661
  self._config_device_props.clear()
@@ -274,7 +274,14 @@ class MDARunner:
274
274
  def _run(self, engine: PMDAEngine, events: Iterable[MDAEvent]) -> None:
275
275
  """Main execution of events, inside the try/except block of `run`."""
276
276
  teardown_event = getattr(engine, "teardown_event", lambda e: None)
277
- event_iterator = getattr(engine, "event_iterator", iter)
277
+ if isinstance(events, Iterator):
278
+ # if an iterator is passed directly, then we use that iterator
279
+ # instead of the engine's event_iterator. Directly passing an iterator
280
+ # is an advanced use case, (for example, `iter(Queue(), None)` for event-
281
+ # driven acquisition) and we don't want the engine to interfere with it.
282
+ event_iterator = iter
283
+ else:
284
+ event_iterator = getattr(engine, "event_iterator", iter)
278
285
  _events: Iterator[MDAEvent] = event_iterator(events)
279
286
  self._reset_event_timer()
280
287
  self._sequence_t0 = self._t0
@@ -8,12 +8,12 @@ from ._protocol import PMDASignaler
8
8
  from ._psygnal import MDASignaler
9
9
 
10
10
  if TYPE_CHECKING:
11
- from ._qsignals import QMDASignaler # noqa: TCH004
11
+ from ._qsignals import QMDASignaler # noqa: TC004
12
12
 
13
13
 
14
14
  __all__ = [
15
- "PMDASignaler",
16
15
  "MDASignaler",
16
+ "PMDASignaler",
17
17
  "QMDASignaler",
18
18
  "_get_auto_MDA_callback_class",
19
19
  ]
@@ -5,7 +5,7 @@ from ._tensorstore_handler import TensorStoreHandler
5
5
 
6
6
  __all__ = [
7
7
  "ImageSequenceWriter",
8
- "OMEZarrWriter",
9
8
  "OMETiffWriter",
9
+ "OMEZarrWriter",
10
10
  "TensorStoreHandler",
11
11
  ]
@@ -134,8 +134,8 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
134
134
 
135
135
  # if we don't check this here, we'll get an error when creating the first array
136
136
  if (
137
- not overwrite and any(self._group.arrays()) or self._group.attrs
138
- ): # pragma: no cover
137
+ not overwrite and any(self._group.arrays())
138
+ ) or self._group.attrs: # pragma: no cover
139
139
  path = self._group.store.path if hasattr(self._group.store, "path") else ""
140
140
  raise ValueError(
141
141
  f"There is already data in {path!r}. Use 'overwrite=True' to overwrite."