pymmcore-plus 0.9.3__py3-none-any.whl → 0.13.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 (68) hide show
  1. pymmcore_plus/__init__.py +7 -4
  2. pymmcore_plus/_benchmark.py +203 -0
  3. pymmcore_plus/_build.py +6 -1
  4. pymmcore_plus/_cli.py +131 -31
  5. pymmcore_plus/_logger.py +19 -10
  6. pymmcore_plus/_pymmcore.py +12 -0
  7. pymmcore_plus/_util.py +139 -32
  8. pymmcore_plus/core/__init__.py +5 -0
  9. pymmcore_plus/core/_config.py +6 -4
  10. pymmcore_plus/core/_config_group.py +4 -3
  11. pymmcore_plus/core/_constants.py +135 -10
  12. pymmcore_plus/core/_device.py +4 -4
  13. pymmcore_plus/core/_metadata.py +3 -3
  14. pymmcore_plus/core/_mmcore_plus.py +254 -170
  15. pymmcore_plus/core/_property.py +6 -6
  16. pymmcore_plus/core/_sequencing.py +370 -233
  17. pymmcore_plus/core/events/__init__.py +6 -6
  18. pymmcore_plus/core/events/_device_signal_view.py +8 -6
  19. pymmcore_plus/core/events/_norm_slot.py +2 -4
  20. pymmcore_plus/core/events/_prop_event_mixin.py +7 -4
  21. pymmcore_plus/core/events/_protocol.py +5 -2
  22. pymmcore_plus/core/events/_psygnal.py +2 -2
  23. pymmcore_plus/experimental/__init__.py +0 -0
  24. pymmcore_plus/experimental/unicore/__init__.py +14 -0
  25. pymmcore_plus/experimental/unicore/_device_manager.py +173 -0
  26. pymmcore_plus/experimental/unicore/_proxy.py +127 -0
  27. pymmcore_plus/experimental/unicore/_unicore.py +703 -0
  28. pymmcore_plus/experimental/unicore/devices/__init__.py +0 -0
  29. pymmcore_plus/experimental/unicore/devices/_device.py +269 -0
  30. pymmcore_plus/experimental/unicore/devices/_properties.py +400 -0
  31. pymmcore_plus/experimental/unicore/devices/_stage.py +221 -0
  32. pymmcore_plus/install.py +16 -11
  33. pymmcore_plus/mda/__init__.py +1 -1
  34. pymmcore_plus/mda/_engine.py +320 -148
  35. pymmcore_plus/mda/_protocol.py +6 -4
  36. pymmcore_plus/mda/_runner.py +62 -51
  37. pymmcore_plus/mda/_thread_relay.py +5 -3
  38. pymmcore_plus/mda/events/__init__.py +2 -2
  39. pymmcore_plus/mda/events/_protocol.py +10 -2
  40. pymmcore_plus/mda/events/_psygnal.py +2 -2
  41. pymmcore_plus/mda/handlers/_5d_writer_base.py +106 -15
  42. pymmcore_plus/mda/handlers/__init__.py +7 -1
  43. pymmcore_plus/mda/handlers/_img_sequence_writer.py +11 -6
  44. pymmcore_plus/mda/handlers/_ome_tiff_writer.py +8 -4
  45. pymmcore_plus/mda/handlers/_ome_zarr_writer.py +82 -9
  46. pymmcore_plus/mda/handlers/_tensorstore_handler.py +374 -0
  47. pymmcore_plus/mda/handlers/_util.py +1 -1
  48. pymmcore_plus/metadata/__init__.py +36 -0
  49. pymmcore_plus/metadata/functions.py +353 -0
  50. pymmcore_plus/metadata/schema.py +472 -0
  51. pymmcore_plus/metadata/serialize.py +120 -0
  52. pymmcore_plus/mocks.py +51 -0
  53. pymmcore_plus/model/_config_file.py +5 -6
  54. pymmcore_plus/model/_config_group.py +29 -2
  55. pymmcore_plus/model/_core_device.py +12 -1
  56. pymmcore_plus/model/_core_link.py +2 -1
  57. pymmcore_plus/model/_device.py +39 -8
  58. pymmcore_plus/model/_microscope.py +39 -3
  59. pymmcore_plus/model/_pixel_size_config.py +27 -4
  60. pymmcore_plus/model/_property.py +13 -3
  61. pymmcore_plus/seq_tester.py +1 -1
  62. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/METADATA +22 -12
  63. pymmcore_plus-0.13.0.dist-info/RECORD +71 -0
  64. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/WHEEL +1 -1
  65. pymmcore_plus/core/_state.py +0 -244
  66. pymmcore_plus-0.9.3.dist-info/RECORD +0 -55
  67. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/entry_points.txt +0 -0
  68. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,54 +1,50 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import time
4
- from datetime import datetime
5
- from typing import (
6
- TYPE_CHECKING,
7
- Any,
8
- Iterable,
9
- Iterator,
10
- Mapping,
11
- NamedTuple,
12
- cast,
13
- )
4
+ from contextlib import suppress
5
+ from itertools import product
6
+ from typing import TYPE_CHECKING, Literal, NamedTuple, cast
14
7
 
8
+ import numpy as np
9
+ import useq
15
10
  from useq import HardwareAutofocus, MDAEvent, MDASequence
16
11
 
17
12
  from pymmcore_plus._logger import logger
18
13
  from pymmcore_plus._util import retry
19
- from pymmcore_plus.core._constants import PixelType
20
- from pymmcore_plus.core._sequencing import SequencedEvent
14
+ from pymmcore_plus.core._constants import Keyword
15
+ from pymmcore_plus.core._sequencing import SequencedEvent, iter_sequenced_events
16
+ from pymmcore_plus.metadata import (
17
+ FrameMetaV1,
18
+ PropertyValue,
19
+ SummaryMetaV1,
20
+ frame_metadata,
21
+ summary_metadata,
22
+ )
21
23
 
22
24
  from ._protocol import PMDAEngine
23
25
 
24
26
  if TYPE_CHECKING:
25
- from typing import TypedDict
27
+ from collections.abc import Iterable, Iterator, Sequence
28
+ from typing import TypeAlias
26
29
 
27
30
  from numpy.typing import NDArray
28
31
 
29
- from pymmcore_plus.core import CMMCorePlus, Metadata
32
+ from pymmcore_plus.core import CMMCorePlus
30
33
 
31
34
  from ._protocol import PImagePayload
32
35
 
33
- # currently matching keys from metadata from AcqEngJ
34
- SummaryMetadata = TypedDict(
35
- "SummaryMetadata",
36
- {
37
- "DateAndTime": str,
38
- "PixelType": str,
39
- "PixelSize_um": float,
40
- "PixelSizeAffine": str,
41
- "Core-XYStage": str,
42
- "Core-Focus": str,
43
- "Core-Autofocus": str,
44
- "Core-Camera": str,
45
- "Core-Galvo": str,
46
- "Core-ImageProcessor": str,
47
- "Core-SLM": str,
48
- "Core-Shutter": str,
49
- "AffineTransform": str,
50
- },
51
- )
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
+ }
52
48
 
53
49
 
54
50
  class MDAEngine(PMDAEngine):
@@ -57,6 +53,9 @@ class MDAEngine(PMDAEngine):
57
53
  This implements the [`PMDAEngine`][pymmcore_plus.mda.PMDAEngine] protocol, and
58
54
  uses a [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] instance to control the hardware.
59
55
 
56
+ It may be subclassed to provide custom behavior, or to override specific methods.
57
+ <https://pymmcore-plus.github.io/pymmcore-plus/guides/custom_engine/>
58
+
60
59
  Attributes
61
60
  ----------
62
61
  mmcore: CMMCorePlus
@@ -66,14 +65,17 @@ class MDAEngine(PMDAEngine):
66
65
  attempt to combine MDAEvents into a single `SequencedEvent` if
67
66
  [`core.canSequenceEvents()`][pymmcore_plus.CMMCorePlus.canSequenceEvents]
68
67
  reports that the events can be sequenced. This can be set after instantiation.
69
- By default, this is `False`, in order to avoid unexpected behavior, particularly
70
- in testing and demo scenarios. But in many "real world" scenarios, this can be
71
- set to `True` to improve performance.
68
+ By default, this is `True`, however in various testing and demo scenarios, you
69
+ may wish to set it to `False` in order to avoid unexpected behavior.
72
70
  """
73
71
 
74
- def __init__(self, mmc: CMMCorePlus, use_hardware_sequencing: bool = False) -> None:
72
+ def __init__(self, mmc: CMMCorePlus, use_hardware_sequencing: bool = True) -> None:
75
73
  self._mmc = mmc
76
- 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"
77
79
 
78
80
  # used to check if the hardware autofocus is engaged when the sequence begins.
79
81
  # if it is, we will re-engage it after the autofocus action (if successful).
@@ -90,6 +92,26 @@ class MDAEngine(PMDAEngine):
90
92
  # Note: getAutoShutter() is True when no config is loaded at all
91
93
  self._autoshutter_was_set: bool = self._mmc.getAutoShutter()
92
94
 
95
+ # -----
96
+ # The following values are stored during setup_sequence simply to speed up
97
+ # retrieval of metadata during each frame.
98
+ # sequence of (device, property) of all properties used in any of the presets
99
+ # in the channel group.
100
+ self._config_device_props: dict[str, Sequence[tuple[str, str]]] = {}
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
+
93
115
  @property
94
116
  def mmcore(self) -> CMMCorePlus:
95
117
  """The `CMMCorePlus` instance to use for hardware control."""
@@ -97,7 +119,7 @@ class MDAEngine(PMDAEngine):
97
119
 
98
120
  # ===================== Protocol Implementation =====================
99
121
 
100
- def setup_sequence(self, sequence: MDASequence) -> Mapping[str, Any]:
122
+ def setup_sequence(self, sequence: MDASequence) -> SummaryMetaV1 | None:
101
123
  """Setup the hardware for the entire sequence."""
102
124
  # clear z_correction for new sequence
103
125
  self._z_correction.clear()
@@ -107,6 +129,7 @@ class MDAEngine(PMDAEngine):
107
129
 
108
130
  self._mmc = CMMCorePlus.instance()
109
131
 
132
+ self._update_config_device_props()
110
133
  # get if the autofocus is engaged at the start of the sequence
111
134
  self._af_was_engaged = self._mmc.isContinuousFocusLocked()
112
135
 
@@ -114,30 +137,10 @@ class MDAEngine(PMDAEngine):
114
137
  self._update_grid_fov_sizes(px_size, sequence)
115
138
 
116
139
  self._autoshutter_was_set = self._mmc.getAutoShutter()
117
- return self.get_summary_metadata()
140
+ return self.get_summary_metadata(mda_sequence=sequence)
118
141
 
119
- def get_summary_metadata(self) -> SummaryMetadata:
120
- """Get the summary metadata for the sequence."""
121
- pt = PixelType.for_bytes(
122
- self._mmc.getBytesPerPixel(), self._mmc.getNumberOfComponents()
123
- )
124
- affine = self._mmc.getPixelSizeAffine(True) # true == cached
125
-
126
- return {
127
- "DateAndTime": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f"),
128
- "PixelType": str(pt),
129
- "PixelSize_um": self._mmc.getPixelSizeUm(),
130
- "PixelSizeAffine": ";".join(str(x) for x in affine),
131
- "Core-XYStage": self._mmc.getXYStageDevice(),
132
- "Core-Focus": self._mmc.getFocusDevice(),
133
- "Core-Autofocus": self._mmc.getAutoFocusDevice(),
134
- "Core-Camera": self._mmc.getCameraDevice(),
135
- "Core-Galvo": self._mmc.getGalvoDevice(),
136
- "Core-ImageProcessor": self._mmc.getImageProcessorDevice(),
137
- "Core-SLM": self._mmc.getSLMDevice(),
138
- "Core-Shutter": self._mmc.getShutterDevice(),
139
- "AffineTransform": "Undefined",
140
- }
142
+ def get_summary_metadata(self, mda_sequence: MDASequence | None) -> SummaryMetaV1:
143
+ return summary_metadata(self._mmc, mda_sequence=mda_sequence)
141
144
 
142
145
  def _update_grid_fov_sizes(self, px_size: float, sequence: MDASequence) -> None:
143
146
  *_, x_size, y_size = self._mmc.getROI()
@@ -175,7 +178,7 @@ class MDAEngine(PMDAEngine):
175
178
  # skip if no autofocus device is found
176
179
  if not self._mmc.getAutoFocusDevice():
177
180
  logger.warning("No autofocus device found. Cannot execute autofocus.")
178
- return ()
181
+ return
179
182
 
180
183
  try:
181
184
  # execute hardware autofocus
@@ -190,7 +193,7 @@ class MDAEngine(PMDAEngine):
190
193
  self._z_correction[p_idx] = new_correction + self._z_correction.get(
191
194
  p_idx, 0.0
192
195
  )
193
- return ()
196
+ return
194
197
 
195
198
  # if the autofocus was engaged at the start of the sequence AND autofocus action
196
199
  # did not fail, re-engage it. NOTE: we need to do that AFTER the runner calls
@@ -214,21 +217,7 @@ class MDAEngine(PMDAEngine):
214
217
  yield from events
215
218
  return
216
219
 
217
- seq: list[MDAEvent] = []
218
- for event in events:
219
- # if the sequence is empty or the current event can be sequenced with the
220
- # previous event, add it to the sequence
221
- if not seq or self._mmc.canSequenceEvents(seq[-1], event, len(seq)):
222
- seq.append(event)
223
- else:
224
- # otherwise, yield a SequencedEvent if the sequence has accumulated
225
- # more than one event, otherwise yield the single event
226
- yield seq[0] if len(seq) == 1 else SequencedEvent.create(seq)
227
- # add this current event and start a new sequence
228
- seq = [event]
229
- # yield any remaining events
230
- if seq:
231
- yield seq[0] if len(seq) == 1 else SequencedEvent.create(seq)
220
+ yield from iter_sequenced_events(self._mmc, events)
232
221
 
233
222
  # ===================== Regular Events =====================
234
223
 
@@ -246,9 +235,11 @@ class MDAEngine(PMDAEngine):
246
235
  self._set_event_position(event)
247
236
  if event.z_pos is not None:
248
237
  self._set_event_z(event)
249
-
238
+ if event.slm_image is not None:
239
+ self._set_event_slm_image(event)
250
240
  if event.channel is not None:
251
241
  try:
242
+ # possible speedup by setting manually.
252
243
  self._mmc.setConfig(event.channel.group, event.channel.config)
253
244
  except Exception as e:
254
245
  logger.warning("Failed to set channel. %s", e)
@@ -278,49 +269,77 @@ class MDAEngine(PMDAEngine):
278
269
  `exec_event`, which *is* part of the protocol), but it is made public
279
270
  in case a user wants to subclass this engine and override this method.
280
271
  """
272
+ if event.slm_image is not None:
273
+ self._exec_event_slm_image(event.slm_image)
274
+
281
275
  try:
282
276
  self._mmc.snapImage()
277
+ # taking event time after snapImage includes exposure time
278
+ # not sure that's what we want, but it's currently consistent with the
279
+ # timing of the sequenced event runner (where Elapsed_Time_ms is taken after
280
+ # the image is acquired, not before the exposure starts)
281
+ t0 = event.metadata.get("runner_t0") or time.perf_counter()
282
+ event_time_ms = (time.perf_counter() - t0) * 1000
283
283
  except Exception as e:
284
284
  logger.warning("Failed to snap image. %s", e)
285
- return ()
285
+ return
286
286
  if not event.keep_shutter_open:
287
287
  self._mmc.setShutterOpen(False)
288
- yield ImagePayload(self._mmc.getImage(), event, self.get_frame_metadata())
289
-
290
- def get_frame_metadata(
291
- self, meta: Metadata | None = None, channel_index: int | None = None
292
- ) -> dict[str, Any]:
293
- # TODO:
294
288
 
295
- # this is not a very fast method, and it is called for every frame.
296
- # Nico Stuurman has suggested that it was a mistake for MM to pull so much
297
- # metadata for every frame. So we'll begin with a more conservative approach.
298
-
299
- # while users can now simply re-implement this method,
300
- # consider coming up with a user-configurable way to specify needed metadata
301
-
302
- # rather than using self._mmc.getTags (which mimics MM) we pull a smaller
303
- # amount of metadata.
304
- # If you need more than this, either override or open an issue.
305
-
306
- tags = dict(meta) if meta else {}
307
- for dev, label, val in self._mmc.getSystemStateCache():
308
- tags[f"{dev}-{label}"] = val
309
-
310
- # these are added by AcqEngJ
311
- # yyyy-MM-dd HH:mm:ss.mmmmmm # NOTE AcqEngJ omits microseconds
312
- tags["Time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
289
+ # most cameras will only have a single channel
290
+ # but Multi-camera may have multiple, and we need to retrieve a buffer for each
291
+ for cam in range(self._mmc.getNumberOfCameraChannels()):
292
+ meta = self.get_frame_metadata(
293
+ event,
294
+ runner_time_ms=event_time_ms,
295
+ camera_device=self._mmc.getPhysicalCameraDevice(cam),
296
+ include_position=self._include_frame_position_metadata is not False,
297
+ )
298
+ # Note, the third element is actually a MutableMapping, but mypy doesn't
299
+ # see TypedDict as a subclass of MutableMapping yet.
300
+ # https://github.com/python/mypy/issues/4976
301
+ yield ImagePayload(self._mmc.getImage(cam), event, meta) # type: ignore[misc]
313
302
 
314
- # used by Runner
315
- tags["PerfCounter"] = time.perf_counter()
316
- return tags
303
+ def get_frame_metadata(
304
+ self,
305
+ event: MDAEvent,
306
+ prop_values: tuple[PropertyValue, ...] | None = None,
307
+ runner_time_ms: float = 0.0,
308
+ include_position: bool = True,
309
+ camera_device: str | None = None,
310
+ ) -> FrameMetaV1:
311
+ if prop_values is None and (ch := event.channel):
312
+ prop_values = self._get_current_props(ch.group)
313
+ else:
314
+ prop_values = ()
315
+ return frame_metadata(
316
+ self._mmc,
317
+ cached=True,
318
+ runner_time_ms=runner_time_ms,
319
+ camera_device=camera_device,
320
+ property_values=prop_values,
321
+ mda_event=event,
322
+ include_position=include_position,
323
+ )
317
324
 
318
325
  def teardown_event(self, event: MDAEvent) -> None:
319
326
  """Teardown state of system (hardware, etc.) after `event`."""
320
327
  # autoshutter was set at the beginning of the sequence, and this event
321
328
  # doesn't want to leave the shutter open. Re-enable autoshutter.
329
+ core = self._mmc
322
330
  if not event.keep_shutter_open and self._autoshutter_was_set:
323
- self._mmc.setAutoShutter(True)
331
+ core.setAutoShutter(True)
332
+ # FIXME: this may not be hitting as intended...
333
+ # https://github.com/pymmcore-plus/pymmcore-plus/pull/353#issuecomment-2159176491
334
+ if isinstance(event, SequencedEvent):
335
+ if event.exposure_sequence:
336
+ core.stopExposureSequence(self._mmc.getCameraDevice())
337
+ if event.x_sequence:
338
+ core.stopXYStageSequence(core.getXYStageDevice())
339
+ if event.z_sequence:
340
+ core.stopStageSequence(core.getFocusDevice())
341
+ for dev, prop in event.property_sequences:
342
+ core.stopPropertySequence(dev, prop)
324
343
 
325
344
  def teardown_sequence(self, sequence: MDASequence) -> None:
326
345
  """Perform any teardown required after the sequence has been executed."""
@@ -328,63 +347,93 @@ class MDAEngine(PMDAEngine):
328
347
 
329
348
  # ===================== Sequenced Events =====================
330
349
 
331
- def setup_sequenced_event(self, event: SequencedEvent) -> None:
332
- """Setup hardware for a sequenced (triggered) event.
350
+ def _load_sequenced_event(self, event: SequencedEvent) -> None:
351
+ """Load a `SequencedEvent` into the core.
333
352
 
334
- This method is not part of the PMDAEngine protocol (it is called by
335
- `setup_event`, which *is* part of the protocol), but it is made public
336
- in case a user wants to subclass this engine and override this method.
353
+ `SequencedEvent` is a special pymmcore-plus specific subclass of
354
+ `useq.MDAEvent`.
337
355
  """
338
356
  core = self._mmc
339
- cam_device = self._mmc.getCameraDevice()
340
-
341
357
  if event.exposure_sequence:
358
+ cam_device = core.getCameraDevice()
359
+ with suppress(RuntimeError):
360
+ core.stopExposureSequence(cam_device)
342
361
  core.loadExposureSequence(cam_device, event.exposure_sequence)
343
362
  if event.x_sequence: # y_sequence is implied and will be the same length
344
363
  stage = core.getXYStageDevice()
364
+ with suppress(RuntimeError):
365
+ core.stopXYStageSequence(stage)
345
366
  core.loadXYStageSequence(stage, event.x_sequence, event.y_sequence)
346
367
  if event.z_sequence:
347
- # these notes are from Nico Stuurman in AcqEngJ
348
- # https://github.com/micro-manager/AcqEngJ/pull/108
349
- # at least some zStages freak out (in this case, NIDAQ board) when you
350
- # try to load a sequence while the sequence is still running. Nothing in
351
- # the engine stops a stage sequence if all goes well.
352
- # Stopping a sequence if it is not running hopefully will not harm anyone.
353
368
  zstage = core.getFocusDevice()
354
- core.stopStageSequence(zstage)
369
+ with suppress(RuntimeError):
370
+ core.stopStageSequence(zstage)
355
371
  core.loadStageSequence(zstage, event.z_sequence)
356
- if prop_seqs := event.property_sequences(core):
357
- for (dev, prop), value_sequence in prop_seqs.items():
372
+ if event.slm_sequence:
373
+ slm = core.getSLMDevice()
374
+ with suppress(RuntimeError):
375
+ core.stopSLMSequence(slm)
376
+ core.loadSLMSequence(slm, event.slm_sequence) # type: ignore[arg-type]
377
+ if event.property_sequences:
378
+ for (dev, prop), value_sequence in event.property_sequences.items():
379
+ with suppress(RuntimeError):
380
+ core.stopPropertySequence(dev, prop)
358
381
  core.loadPropertySequence(dev, prop, value_sequence)
359
382
 
360
- # TODO: SLM
383
+ # set all static properties, these won't change over the course of the sequence.
384
+ if event.properties:
385
+ for dev, prop, value in event.properties:
386
+ core.setProperty(dev, prop, value)
387
+
388
+ def setup_sequenced_event(self, event: SequencedEvent) -> None:
389
+ """Setup hardware for a sequenced (triggered) event.
390
+
391
+ This method is not part of the PMDAEngine protocol (it is called by
392
+ `setup_event`, which *is* part of the protocol), but it is made public
393
+ in case a user wants to subclass this engine and override this method.
394
+ """
395
+ core = self._mmc
396
+
397
+ self._load_sequenced_event(event)
398
+
399
+ # this is probably not necessary. loadSequenceEvent will have already
400
+ # set all the config properties individually/manually. However, without
401
+ # the call below, we won't be able to query `core.getCurrentConfig()`
402
+ # not sure that's necessary; and this is here for tests to pass for now,
403
+ # but this could be removed.
404
+ if event.channel is not None:
405
+ try:
406
+ core.setConfig(event.channel.group, event.channel.config)
407
+ except Exception as e:
408
+ logger.warning("Failed to set channel. %s", e)
409
+
410
+ if event.slm_image:
411
+ self._set_event_slm_image(event)
361
412
 
362
413
  # preparing a Sequence while another is running is dangerous.
363
414
  if core.isSequenceRunning():
364
415
  self._await_sequence_acquisition()
365
- core.prepareSequenceAcquisition(cam_device)
416
+ core.prepareSequenceAcquisition(core.getCameraDevice())
366
417
 
367
418
  # start sequences or set non-sequenced values
368
419
  if event.x_sequence:
369
- core.startXYStageSequence(stage)
420
+ core.startXYStageSequence(core.getXYStageDevice())
370
421
  elif event.x_pos is not None or event.y_pos is not None:
371
422
  self._set_event_position(event)
372
423
 
373
424
  if event.z_sequence:
374
- core.startStageSequence(zstage)
425
+ core.startStageSequence(core.getFocusDevice())
375
426
  elif event.z_pos is not None:
376
427
  self._set_event_z(event)
377
428
 
378
429
  if event.exposure_sequence:
379
- core.startExposureSequence(cam_device)
430
+ core.startExposureSequence(core.getCameraDevice())
380
431
  elif event.exposure is not None:
381
432
  core.setExposure(event.exposure)
382
433
 
383
- if prop_seqs:
384
- for dev, prop in prop_seqs:
434
+ if event.property_sequences:
435
+ for dev, prop in event.property_sequences:
385
436
  core.startPropertySequence(dev, prop)
386
- elif event.channel is not None:
387
- core.setConfig(event.channel.group, event.channel.config)
388
437
 
389
438
  def _await_sequence_acquisition(
390
439
  self, timeout: float = 5.0, poll_interval: float = 0.2
@@ -414,9 +463,14 @@ class MDAEngine(PMDAEngine):
414
463
  `exec_event`, which *is* part of the protocol), but it is made public
415
464
  in case a user wants to subclass this engine and override this method.
416
465
  """
417
- # TODO: add support for multiple camera devices
418
466
  n_events = len(event.events)
419
467
 
468
+ t0 = event.metadata.get("runner_t0") or time.perf_counter()
469
+ event_t0_ms = (time.perf_counter() - t0) * 1000
470
+
471
+ if event.slm_image is not None:
472
+ self._exec_event_slm_image(event.slm_image)
473
+
420
474
  # Start sequence
421
475
  # Note that the overload of startSequenceAcquisition that takes a camera
422
476
  # label does NOT automatically initialize a circular buffer. So if this call
@@ -426,15 +480,17 @@ class MDAEngine(PMDAEngine):
426
480
  0, # intervalMS # TODO: add support for this
427
481
  True, # stopOnOverflow
428
482
  )
429
-
430
483
  self.post_sequence_started(event)
431
484
 
485
+ n_channels = self._mmc.getNumberOfCameraChannels()
432
486
  count = 0
433
- iter_events = iter(event.events)
487
+ iter_events = product(event.events, range(n_channels))
434
488
  # block until the sequence is done, popping images in the meantime
435
489
  while self._mmc.isSequenceRunning():
436
- if self._mmc.getRemainingImageCount():
437
- yield self._next_img_payload(next(iter_events))
490
+ if remaining := self._mmc.getRemainingImageCount():
491
+ yield self._next_seqimg_payload(
492
+ *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms
493
+ )
438
494
  count += 1
439
495
  else:
440
496
  time.sleep(0.001)
@@ -442,23 +498,61 @@ class MDAEngine(PMDAEngine):
442
498
  if self._mmc.isBufferOverflowed(): # pragma: no cover
443
499
  raise MemoryError("Buffer overflowed")
444
500
 
445
- while self._mmc.getRemainingImageCount():
446
- yield self._next_img_payload(next(iter_events))
501
+ while remaining := self._mmc.getRemainingImageCount():
502
+ yield self._next_seqimg_payload(
503
+ *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms
504
+ )
447
505
  count += 1
448
506
 
449
- if count != n_events:
507
+ # necessary?
508
+ expected_images = n_events * n_channels
509
+ if count != expected_images:
450
510
  logger.warning(
451
511
  "Unexpected number of images returned from sequence. "
452
512
  "Expected %s, got %s",
453
- n_events,
513
+ expected_images,
454
514
  count,
455
515
  )
456
516
 
457
- def _next_img_payload(self, event: MDAEvent) -> PImagePayload:
517
+ def _next_seqimg_payload(
518
+ self,
519
+ event: MDAEvent,
520
+ channel: int = 0,
521
+ *,
522
+ event_t0: float = 0.0,
523
+ remaining: int = 0,
524
+ ) -> PImagePayload:
458
525
  """Grab next image from the circular buffer and return it as an ImagePayload."""
459
- img, meta = self._mmc.popNextImageAndMD()
460
- tags = self.get_frame_metadata(meta)
461
- return ImagePayload(img, event, tags)
526
+ _slice = 0 # ?
527
+ img, mm_meta = self._mmc.popNextImageAndMD(channel, _slice)
528
+ try:
529
+ seq_time = float(mm_meta.get(Keyword.Elapsed_Time_ms))
530
+ except Exception:
531
+ seq_time = 0.0
532
+ try:
533
+ # note, when present in circular buffer meta, this key is called "Camera".
534
+ # It's NOT actually Keyword.CoreCamera (but it's the same value)
535
+ # it is hardcoded in various places in mmCoreAndDevices, see:
536
+ # see: https://github.com/micro-manager/mmCoreAndDevices/pull/468
537
+ camera_device = mm_meta.GetSingleTag("Camera").GetValue()
538
+ except Exception:
539
+ camera_device = self._mmc.getPhysicalCameraDevice(channel)
540
+
541
+ # TODO: determine whether we want to try to populate changing property values
542
+ # during the course of a triggered sequence
543
+ meta = self.get_frame_metadata(
544
+ event,
545
+ prop_values=(),
546
+ runner_time_ms=event_t0 + seq_time,
547
+ camera_device=camera_device,
548
+ include_position=self._include_frame_position_metadata is True,
549
+ )
550
+ meta["hardware_triggered"] = True
551
+ meta["images_remaining_in_buffer"] = remaining
552
+ meta["camera_metadata"] = dict(mm_meta)
553
+
554
+ # https://github.com/python/mypy/issues/4976
555
+ return ImagePayload(img, event, meta) # type: ignore[return-value]
462
556
 
463
557
  # ===================== EXTRA =====================
464
558
 
@@ -508,8 +602,86 @@ class MDAEngine(PMDAEngine):
508
602
  correction = self._z_correction.setdefault(p_idx, 0.0)
509
603
  self._mmc.setZPosition(cast("float", event.z_pos) + correction)
510
604
 
605
+ def _set_event_slm_image(self, event: MDAEvent) -> None:
606
+ if not event.slm_image:
607
+ return
608
+ try:
609
+ # Get the SLM device
610
+ if not (
611
+ slm_device := event.slm_image.device or self._mmc.getSLMDevice()
612
+ ): # pragma: no cover
613
+ raise ValueError("No SLM device found or specified.")
614
+
615
+ # cast to numpy array
616
+ slm_array = np.asarray(event.slm_image)
617
+ # if it's a single value, we can just set all pixels to that value
618
+ if slm_array.ndim == 0:
619
+ value = slm_array.item()
620
+ if isinstance(value, bool):
621
+ dev_name = self._mmc.getDeviceName(slm_device)
622
+ on_value = _SLM_DEVICES_PIXEL_ON_VALUES.get(dev_name, 1)
623
+ value = on_value if value else 0
624
+ self._mmc.setSLMPixelsTo(slm_device, int(value))
625
+ elif slm_array.size == 3:
626
+ # if it's a 3-valued array, we assume it's RGB
627
+ r, g, b = slm_array.astype(int)
628
+ self._mmc.setSLMPixelsTo(slm_device, r, g, b)
629
+ elif slm_array.ndim in (2, 3):
630
+ # if it's a 2D/3D array, we assume it's an image
631
+ # where 3D is RGB with shape (h, w, 3)
632
+ if slm_array.ndim == 3 and slm_array.shape[2] != 3:
633
+ raise ValueError( # pragma: no cover
634
+ "SLM image must be 2D or 3D with 3 channels (RGB)."
635
+ )
636
+ # convert boolean on/off values to pixel values
637
+ if slm_array.dtype == bool:
638
+ dev_name = self._mmc.getDeviceName(slm_device)
639
+ on_value = _SLM_DEVICES_PIXEL_ON_VALUES.get(dev_name, 1)
640
+ slm_array = np.where(slm_array, on_value, 0).astype(np.uint8)
641
+ self._mmc.setSLMImage(slm_device, slm_array)
642
+ if event.slm_image.exposure:
643
+ self._mmc.setSLMExposure(slm_device, event.slm_image.exposure)
644
+ except Exception as e:
645
+ logger.warning("Failed to set SLM Image: %s", e)
646
+
647
+ def _exec_event_slm_image(self, img: useq.SLMImage) -> None:
648
+ if slm_device := (img.device or self._mmc.getSLMDevice()):
649
+ try:
650
+ self._mmc.displaySLMImage(slm_device)
651
+ except Exception as e:
652
+ logger.warning("Failed to set SLM Image: %s", e)
653
+
654
+ def _update_config_device_props(self) -> None:
655
+ # store devices/props that make up each config group for faster lookup
656
+ self._config_device_props.clear()
657
+ for grp in self._mmc.getAvailableConfigGroups():
658
+ for preset in self._mmc.getAvailableConfigs(grp):
659
+ # ordered/unique list of (device, property) tuples for each group
660
+ self._config_device_props[grp] = tuple(
661
+ {(i[0], i[1]): None for i in self._mmc.getConfigData(grp, preset)}
662
+ )
663
+
664
+ def _get_current_props(self, *groups: str) -> tuple[PropertyValue, ...]:
665
+ """Faster version of core.getConfigGroupState(group).
666
+
667
+ MMCore does some excess iteration that we want to avoid here. It calls
668
+ GetAvailableConfigs and then calls getConfigData for *every* preset in the
669
+ group, (not only the one being requested). We go straight to cached data
670
+ for the group we want.
671
+ """
672
+ return tuple(
673
+ {
674
+ "dev": dev,
675
+ "prop": prop,
676
+ "val": self._mmc.getPropertyFromCache(dev, prop),
677
+ }
678
+ for group in groups
679
+ if (dev_props := self._config_device_props.get(group))
680
+ for dev, prop in dev_props
681
+ )
682
+
511
683
 
512
684
  class ImagePayload(NamedTuple):
513
685
  image: NDArray
514
686
  event: MDAEvent
515
- metadata: dict
687
+ metadata: FrameMetaV1 | SummaryMetaV1