pymmcore-plus 0.10.1__py3-none-any.whl → 0.11.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 (35) hide show
  1. pymmcore_plus/__init__.py +4 -1
  2. pymmcore_plus/_build.py +2 -0
  3. pymmcore_plus/_cli.py +47 -12
  4. pymmcore_plus/_util.py +99 -9
  5. pymmcore_plus/core/__init__.py +2 -0
  6. pymmcore_plus/core/_constants.py +109 -8
  7. pymmcore_plus/core/_mmcore_plus.py +67 -47
  8. pymmcore_plus/core/events/_psygnal.py +2 -2
  9. pymmcore_plus/mda/__init__.py +2 -2
  10. pymmcore_plus/mda/_engine.py +148 -98
  11. pymmcore_plus/mda/_protocol.py +5 -3
  12. pymmcore_plus/mda/_runner.py +16 -21
  13. pymmcore_plus/mda/events/_protocol.py +10 -2
  14. pymmcore_plus/mda/events/_psygnal.py +2 -2
  15. pymmcore_plus/mda/handlers/_5d_writer_base.py +25 -13
  16. pymmcore_plus/mda/handlers/_img_sequence_writer.py +9 -5
  17. pymmcore_plus/mda/handlers/_ome_tiff_writer.py +7 -3
  18. pymmcore_plus/mda/handlers/_ome_zarr_writer.py +9 -4
  19. pymmcore_plus/mda/handlers/_tensorstore_handler.py +19 -19
  20. pymmcore_plus/metadata/__init__.py +36 -0
  21. pymmcore_plus/metadata/functions.py +343 -0
  22. pymmcore_plus/metadata/schema.py +471 -0
  23. pymmcore_plus/metadata/serialize.py +116 -0
  24. pymmcore_plus/model/_config_file.py +2 -4
  25. pymmcore_plus/model/_config_group.py +29 -3
  26. pymmcore_plus/model/_device.py +20 -1
  27. pymmcore_plus/model/_microscope.py +35 -1
  28. pymmcore_plus/model/_pixel_size_config.py +25 -3
  29. {pymmcore_plus-0.10.1.dist-info → pymmcore_plus-0.11.0.dist-info}/METADATA +4 -3
  30. pymmcore_plus-0.11.0.dist-info/RECORD +59 -0
  31. {pymmcore_plus-0.10.1.dist-info → pymmcore_plus-0.11.0.dist-info}/WHEEL +1 -1
  32. pymmcore_plus/core/_state.py +0 -244
  33. pymmcore_plus-0.10.1.dist-info/RECORD +0 -56
  34. {pymmcore_plus-0.10.1.dist-info → pymmcore_plus-0.11.0.dist-info}/entry_points.txt +0 -0
  35. {pymmcore_plus-0.10.1.dist-info → pymmcore_plus-0.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -31,6 +31,7 @@ from psygnal import SignalInstance
31
31
  from pymmcore_plus._logger import current_logfile, logger
32
32
  from pymmcore_plus._util import find_micromanager, print_tabular_data
33
33
  from pymmcore_plus.mda import MDAEngine, MDARunner, PMDAEngine
34
+ from pymmcore_plus.metadata.functions import summary_metadata
34
35
 
35
36
  from ._adapter import DeviceAdapter
36
37
  from ._config import Configuration
@@ -39,6 +40,7 @@ from ._constants import (
39
40
  DeviceDetectionStatus,
40
41
  DeviceInitializationState,
41
42
  DeviceType,
43
+ FocusDirection,
42
44
  PixelType,
43
45
  PropertyType,
44
46
  )
@@ -46,7 +48,6 @@ from ._device import Device
46
48
  from ._metadata import Metadata
47
49
  from ._property import DeviceProperty
48
50
  from ._sequencing import can_sequence_events
49
- from ._state import core_state
50
51
  from .events import CMMCoreSignaler, PCoreSignaler, _get_auto_core_callback_class
51
52
 
52
53
  if TYPE_CHECKING:
@@ -56,8 +57,7 @@ if TYPE_CHECKING:
56
57
  from useq import MDAEvent
57
58
 
58
59
  from pymmcore_plus.mda._runner import SingleOutput
59
-
60
- from ._state import StateDict
60
+ from pymmcore_plus.metadata.schema import SummaryMetaV1
61
61
 
62
62
  _T = TypeVar("_T")
63
63
  _F = TypeVar("_F", bound=Callable[..., Any])
@@ -193,6 +193,9 @@ class CMMCorePlus(pymmcore.CMMCore):
193
193
  if _instance is None:
194
194
  _instance = self
195
195
 
196
+ if hasattr("self", "enableFeature"):
197
+ self.enableFeature("StrictInitializationChecks", True)
198
+
196
199
  # TODO: test this on windows ... writing to the same file may be an issue there
197
200
  if logfile := current_logfile(logger):
198
201
  self.setPrimaryLogFile(str(logfile))
@@ -400,6 +403,14 @@ class CMMCorePlus(pymmcore.CMMCore):
400
403
  """
401
404
  return DeviceType(super().getDeviceType(label))
402
405
 
406
+ def getFocusDirection(self, stageLabel: str) -> FocusDirection:
407
+ """Return device type for a given device.
408
+
409
+ **Why Override?** The returned [`pymmcore_plus.FocusDirection`][] enum is more
410
+ interpretable than the raw `int` returned by `pymmcore`
411
+ """
412
+ return FocusDirection(super().getFocusDirection(stageLabel))
413
+
403
414
  def getPropertyType(self, label: str, propName: str) -> PropertyType:
404
415
  """Return the intrinsic property type for a given device and property.
405
416
 
@@ -621,7 +632,7 @@ class CMMCorePlus(pymmcore.CMMCore):
621
632
 
622
633
  @synchronized(_lock)
623
634
  def popNextImageAndMD(
624
- self, channel: int | None = None, slice: int | None = None, *, fix: bool = True
635
+ self, channel: int = 0, slice: int = 0, *, fix: bool = True
625
636
  ) -> tuple[np.ndarray, Metadata]:
626
637
  """Gets and removes the next image (and metadata) from the circular buffer.
627
638
 
@@ -651,10 +662,7 @@ class CMMCorePlus(pymmcore.CMMCore):
651
662
  Image and metadata
652
663
  """
653
664
  md = Metadata()
654
- if channel is not None and slice is not None:
655
- img = super().popNextImageMD(channel, slice, md)
656
- else:
657
- img = super().popNextImageMD(md)
665
+ img = super().popNextImageMD(channel, slice, md)
658
666
  return (self.fixImage(img) if fix else img, md)
659
667
 
660
668
  @synchronized(_lock)
@@ -1470,7 +1478,11 @@ class CMMCorePlus(pymmcore.CMMCore):
1470
1478
  old_engine = self.mda.set_engine(engine)
1471
1479
  self.events.mdaEngineRegistered.emit(engine, old_engine)
1472
1480
 
1473
- def fixImage(self, img: np.ndarray, ncomponents: int | None = None) -> np.ndarray:
1481
+ def fixImage(
1482
+ self,
1483
+ img: np.ndarray,
1484
+ ncomponents: int | None = None,
1485
+ ) -> np.ndarray:
1474
1486
  """Fix img shape/dtype based on `self.getNumberOfComponents()`.
1475
1487
 
1476
1488
  :sparkles: *This method is new in `CMMCorePlus`.*
@@ -1498,6 +1510,28 @@ class CMMCorePlus(pymmcore.CMMCore):
1498
1510
  img = img[..., [2, 1, 0]] # Convert from BGRA to RGB
1499
1511
  return img
1500
1512
 
1513
+ def getPhysicalCameraDevice(self, channel_index: int = 0) -> str:
1514
+ """Return the name of the actual camera device for a given channel index.
1515
+
1516
+ :sparkles: *This method is new in `CMMCorePlus`.* It provides a convenience
1517
+ for accessing the name of the actual camera device when using the multi-camera
1518
+ utility.
1519
+ """
1520
+ cam_dev = self.getCameraDevice()
1521
+ # best as I can tell, this is a hard-coded string in Utilities/MultiCamera.cpp
1522
+ # (it also appears in ArduinoCounter.cpp). This appears to be "the way"
1523
+ # to get at the original camera when using the multi-camera utility.
1524
+ prop_name = f"Physical Camera {channel_index+1}"
1525
+ if self.hasProperty(cam_dev, prop_name):
1526
+ return self.getProperty(cam_dev, prop_name)
1527
+ if channel_index > 0:
1528
+ warnings.warn(
1529
+ f"Camera {cam_dev} does not have a property {prop_name}. "
1530
+ f"Cannot get channel_index={channel_index}",
1531
+ stacklevel=2,
1532
+ )
1533
+ return cam_dev
1534
+
1501
1535
  def getTaggedImage(self, channel_index: int = 0) -> TaggedImage:
1502
1536
  """Return getImage as named tuple with metadata.
1503
1537
 
@@ -1570,15 +1604,9 @@ class CMMCorePlus(pymmcore.CMMCore):
1570
1604
  tags["Binning"] = self.getProperty(self.getCameraDevice(), "Binning")
1571
1605
 
1572
1606
  if channel_index is not None:
1573
- if "CameraChannelIndex" not in tags:
1574
- tags["CameraChannelIndex"] = channel_index
1575
- tags["ChannelIndex"] = channel_index
1576
- if "Camera" not in tags:
1577
- core_cam = tags.get("Core-Camera")
1578
- phys_cam_key = f"{core_cam}-Physical Camera {channel_index+1}"
1579
- if phys_cam_key in tags:
1580
- tags["Camera"] = tags[phys_cam_key]
1581
- # tags["Channel"] = tags[phys_cam_key] # ?? why did MMCoreJ do this?
1607
+ tags["CameraChannelIndex"] = channel_index
1608
+ tags["ChannelIndex"] = channel_index
1609
+ tags["Camera"] = self.getPhysicalCameraDevice(channel_index)
1582
1610
 
1583
1611
  # these are added by AcqEngJ
1584
1612
  # yyyy-MM-dd HH:mm:ss.mmmmmm # NOTE AcqEngJ omits microseconds
@@ -2009,35 +2037,17 @@ class CMMCorePlus(pymmcore.CMMCore):
2009
2037
  print_tabular_data(data, sort=sort)
2010
2038
 
2011
2039
  def state(
2012
- self,
2013
- *,
2014
- devices: bool = True,
2015
- image: bool = True,
2016
- system_info: bool = False,
2017
- system_status: bool = False,
2018
- config_groups: bool | Sequence[str] = True,
2019
- position: bool = False,
2020
- autofocus: bool = False,
2021
- pixel_size_configs: bool = False,
2022
- device_types: bool = False,
2023
- cached: bool = True,
2024
- error_value: Any = None,
2025
- ) -> StateDict:
2040
+ self, *, cached: bool = True, include_time: bool = False, **_kwargs: Any
2041
+ ) -> SummaryMetaV1:
2026
2042
  """Return info on the current state of the core."""
2027
- return core_state(
2028
- self,
2029
- devices=devices,
2030
- image=image,
2031
- system_info=system_info,
2032
- system_status=system_status,
2033
- config_groups=config_groups,
2034
- position=position,
2035
- autofocus=autofocus,
2036
- pixel_size_configs=pixel_size_configs,
2037
- device_types=device_types,
2038
- cached=cached,
2039
- error_value=error_value,
2040
- )
2043
+ if _kwargs:
2044
+ keys = ", ".join(_kwargs.keys())
2045
+ warnings.warn(
2046
+ f"CMMCorePlus.state no longer takes arguments: {keys}. Ignoring."
2047
+ "Please update your code as this may be an error in the future.",
2048
+ stacklevel=2,
2049
+ )
2050
+ return summary_metadata(self, include_time=include_time, cached=cached)
2041
2051
 
2042
2052
  @contextmanager
2043
2053
  def _property_change_emission_ensured(
@@ -2061,8 +2071,18 @@ class CMMCorePlus(pymmcore.CMMCore):
2061
2071
  and self.getDeviceType(device) is DeviceType.StateDevice
2062
2072
  ):
2063
2073
  properties = STATE_PROPS
2074
+ try:
2075
+ before = [self.getProperty(device, p) for p in properties]
2076
+ except Exception as e:
2077
+ logger.error(
2078
+ "Error getting properties %s on %s: %s. Cannot ensure signal emission",
2079
+ properties,
2080
+ device,
2081
+ e,
2082
+ )
2083
+ yield
2084
+ return
2064
2085
 
2065
- before = [self.getProperty(device, p) for p in properties]
2066
2086
  with _blockSignal(self.events, self.events.propertyChanged):
2067
2087
  yield
2068
2088
  after = [self.getProperty(device, p) for p in properties]
@@ -1,11 +1,11 @@
1
- from psygnal import Signal, SignalInstance
1
+ from psygnal import Signal, SignalGroup, SignalInstance
2
2
 
3
3
  from pymmcore_plus.mda import MDAEngine
4
4
 
5
5
  from ._prop_event_mixin import _DevicePropertyEventMixin
6
6
 
7
7
 
8
- class CMMCoreSignaler(_DevicePropertyEventMixin):
8
+ class CMMCoreSignaler(SignalGroup, _DevicePropertyEventMixin):
9
9
  """Signals that will be emitted from CMMCorePlus objects."""
10
10
 
11
11
  # native MMCore callback events
@@ -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",
8
9
  "MDAEngine",
9
- "PMDAEngine",
10
10
  "MDARunner",
11
+ "PMDAEngine",
11
12
  "PMDASignaler",
12
- "mda_listeners_connected",
13
13
  ]
@@ -2,14 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  import time
4
4
  from contextlib import suppress
5
- from datetime import datetime
5
+ from itertools import product
6
6
  from typing import (
7
7
  TYPE_CHECKING,
8
- Any,
9
8
  Iterable,
10
9
  Iterator,
11
- Mapping,
12
10
  NamedTuple,
11
+ Sequence,
13
12
  cast,
14
13
  )
15
14
 
@@ -17,40 +16,25 @@ from useq import HardwareAutofocus, MDAEvent, MDASequence
17
16
 
18
17
  from pymmcore_plus._logger import logger
19
18
  from pymmcore_plus._util import retry
20
- from pymmcore_plus.core._constants import PixelType
19
+ from pymmcore_plus.core._constants import Keyword
21
20
  from pymmcore_plus.core._sequencing import SequencedEvent
21
+ from pymmcore_plus.metadata import (
22
+ FrameMetaV1,
23
+ PropertyValue,
24
+ SummaryMetaV1,
25
+ frame_metadata,
26
+ summary_metadata,
27
+ )
22
28
 
23
29
  from ._protocol import PMDAEngine
24
30
 
25
31
  if TYPE_CHECKING:
26
- from typing import TypedDict
27
-
28
32
  from numpy.typing import NDArray
29
33
 
30
- from pymmcore_plus.core import CMMCorePlus, Metadata
34
+ from pymmcore_plus.core import CMMCorePlus
31
35
 
32
36
  from ._protocol import PImagePayload
33
37
 
34
- # currently matching keys from metadata from AcqEngJ
35
- SummaryMetadata = TypedDict(
36
- "SummaryMetadata",
37
- {
38
- "DateAndTime": str,
39
- "PixelType": str,
40
- "PixelSize_um": float,
41
- "PixelSizeAffine": str,
42
- "Core-XYStage": str,
43
- "Core-Focus": str,
44
- "Core-Autofocus": str,
45
- "Core-Camera": str,
46
- "Core-Galvo": str,
47
- "Core-ImageProcessor": str,
48
- "Core-SLM": str,
49
- "Core-Shutter": str,
50
- "AffineTransform": str,
51
- },
52
- )
53
-
54
38
 
55
39
  class MDAEngine(PMDAEngine):
56
40
  """The default MDAengine that ships with pymmcore-plus.
@@ -58,6 +42,9 @@ class MDAEngine(PMDAEngine):
58
42
  This implements the [`PMDAEngine`][pymmcore_plus.mda.PMDAEngine] protocol, and
59
43
  uses a [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] instance to control the hardware.
60
44
 
45
+ It may be subclassed to provide custom behavior, or to override specific methods.
46
+ <https://pymmcore-plus.github.io/pymmcore-plus/guides/custom_engine/>
47
+
61
48
  Attributes
62
49
  ----------
63
50
  mmcore: CMMCorePlus
@@ -91,6 +78,13 @@ class MDAEngine(PMDAEngine):
91
78
  # Note: getAutoShutter() is True when no config is loaded at all
92
79
  self._autoshutter_was_set: bool = self._mmc.getAutoShutter()
93
80
 
81
+ # -----
82
+ # The following values are stored during setup_sequence simply to speed up
83
+ # retrieval of metadata during each frame.
84
+ # sequence of (device, property) of all properties used in any of the presets
85
+ # in the channel group.
86
+ self._config_device_props: dict[str, Sequence[tuple[str, str]]] = {}
87
+
94
88
  @property
95
89
  def mmcore(self) -> CMMCorePlus:
96
90
  """The `CMMCorePlus` instance to use for hardware control."""
@@ -98,7 +92,7 @@ class MDAEngine(PMDAEngine):
98
92
 
99
93
  # ===================== Protocol Implementation =====================
100
94
 
101
- def setup_sequence(self, sequence: MDASequence) -> Mapping[str, Any]:
95
+ def setup_sequence(self, sequence: MDASequence) -> SummaryMetaV1 | None:
102
96
  """Setup the hardware for the entire sequence."""
103
97
  # clear z_correction for new sequence
104
98
  self._z_correction.clear()
@@ -108,6 +102,7 @@ class MDAEngine(PMDAEngine):
108
102
 
109
103
  self._mmc = CMMCorePlus.instance()
110
104
 
105
+ self._update_config_device_props()
111
106
  # get if the autofocus is engaged at the start of the sequence
112
107
  self._af_was_engaged = self._mmc.isContinuousFocusLocked()
113
108
 
@@ -115,30 +110,10 @@ class MDAEngine(PMDAEngine):
115
110
  self._update_grid_fov_sizes(px_size, sequence)
116
111
 
117
112
  self._autoshutter_was_set = self._mmc.getAutoShutter()
118
- return self.get_summary_metadata()
113
+ return self.get_summary_metadata(mda_sequence=sequence)
119
114
 
120
- def get_summary_metadata(self) -> SummaryMetadata:
121
- """Get the summary metadata for the sequence."""
122
- pt = PixelType.for_bytes(
123
- self._mmc.getBytesPerPixel(), self._mmc.getNumberOfComponents()
124
- )
125
- affine = self._mmc.getPixelSizeAffine(True) # true == cached
126
-
127
- return {
128
- "DateAndTime": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f"),
129
- "PixelType": str(pt),
130
- "PixelSize_um": self._mmc.getPixelSizeUm(),
131
- "PixelSizeAffine": ";".join(str(x) for x in affine),
132
- "Core-XYStage": self._mmc.getXYStageDevice(),
133
- "Core-Focus": self._mmc.getFocusDevice(),
134
- "Core-Autofocus": self._mmc.getAutoFocusDevice(),
135
- "Core-Camera": self._mmc.getCameraDevice(),
136
- "Core-Galvo": self._mmc.getGalvoDevice(),
137
- "Core-ImageProcessor": self._mmc.getImageProcessorDevice(),
138
- "Core-SLM": self._mmc.getSLMDevice(),
139
- "Core-Shutter": self._mmc.getShutterDevice(),
140
- "AffineTransform": "Undefined",
141
- }
115
+ def get_summary_metadata(self, mda_sequence: MDASequence | None) -> SummaryMetaV1:
116
+ return summary_metadata(self._mmc, mda_sequence=mda_sequence)
142
117
 
143
118
  def _update_grid_fov_sizes(self, px_size: float, sequence: MDASequence) -> None:
144
119
  *_, x_size, y_size = self._mmc.getROI()
@@ -250,6 +225,7 @@ class MDAEngine(PMDAEngine):
250
225
 
251
226
  if event.channel is not None:
252
227
  try:
228
+ # possible speedup by setting manually.
253
229
  self._mmc.setConfig(event.channel.group, event.channel.config)
254
230
  except Exception as e:
255
231
  logger.warning("Failed to set channel. %s", e)
@@ -281,46 +257,50 @@ class MDAEngine(PMDAEngine):
281
257
  """
282
258
  try:
283
259
  self._mmc.snapImage()
260
+ # taking event time after snapImage includes exposure time
261
+ # not sure that's what we want, but it's currently consistent with the
262
+ # timing of the sequenced event runner (where Elapsed_Time_ms is taken after
263
+ # the image is acquired, not before the exposure starts)
264
+ t0 = event.metadata.get("runner_t0") or time.perf_counter()
265
+ event_time_ms = (time.perf_counter() - t0) * 1000
284
266
  except Exception as e:
285
267
  logger.warning("Failed to snap image. %s", e)
286
268
  return
287
269
  if not event.keep_shutter_open:
288
270
  self._mmc.setShutterOpen(False)
289
- yield ImagePayload(self._mmc.getImage(), event, self.get_frame_metadata())
271
+
272
+ # most cameras will only have a single channel
273
+ # but Multi-camera may have multiple, and we need to retrieve a buffer for each
274
+ for cam in range(self._mmc.getNumberOfCameraChannels()):
275
+ meta = self.get_frame_metadata(
276
+ event,
277
+ runner_time_ms=event_time_ms,
278
+ camera_device=self._mmc.getPhysicalCameraDevice(cam),
279
+ )
280
+ # Note, the third element is actually a MutableMapping, but mypy doesn't
281
+ # see TypedDict as a subclass of MutableMapping yet.
282
+ # https://github.com/python/mypy/issues/4976
283
+ yield ImagePayload(self._mmc.getImage(cam), event, meta) # type: ignore[misc]
290
284
 
291
285
  def get_frame_metadata(
292
- self, meta: Metadata | None = None, channel_index: int | None = None
293
- ) -> dict[str, Any]:
294
- # TODO:
295
-
296
- # this is not a very fast method, and it is called for every frame.
297
- # Nico Stuurman has suggested that it was a mistake for MM to pull so much
298
- # metadata for every frame. So we'll begin with a more conservative approach.
299
-
300
- # while users can now simply re-implement this method,
301
- # consider coming up with a user-configurable way to specify needed metadata
302
-
303
- # rather than using self._mmc.getTags (which mimics MM) we pull a smaller
304
- # amount of metadata.
305
- # If you need more than this, either override or open an issue.
306
-
307
- tags = dict(meta) if meta else {}
308
- for dev, label, val in self._mmc.getSystemStateCache():
309
- tags[f"{dev}-{label}"] = val
310
-
311
- # these are added by AcqEngJ
312
- # yyyy-MM-dd HH:mm:ss.mmmmmm # NOTE AcqEngJ omits microseconds
313
- tags["Time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
314
- tags["PixelSizeUm"] = self._mmc.getPixelSizeUm(True) # true == cached
315
- with suppress(RuntimeError):
316
- tags["XPositionUm"] = self._mmc.getXPosition()
317
- tags["YPositionUm"] = self._mmc.getYPosition()
318
- with suppress(RuntimeError):
319
- tags["ZPositionUm"] = self._mmc.getZPosition()
320
-
321
- # used by Runner
322
- tags["PerfCounter"] = time.perf_counter()
323
- return tags
286
+ self,
287
+ event: MDAEvent,
288
+ prop_values: tuple[PropertyValue, ...] | None = None,
289
+ runner_time_ms: float = 0.0,
290
+ camera_device: str | None = None,
291
+ ) -> FrameMetaV1:
292
+ if prop_values is None and (ch := event.channel):
293
+ prop_values = self._get_current_props(ch.group)
294
+ else:
295
+ prop_values = ()
296
+ return frame_metadata(
297
+ self._mmc,
298
+ cached=True,
299
+ runner_time_ms=runner_time_ms,
300
+ camera_device=camera_device,
301
+ property_values=prop_values,
302
+ mda_event=event,
303
+ )
324
304
 
325
305
  def teardown_event(self, event: MDAEvent) -> None:
326
306
  """Teardown state of system (hardware, etc.) after `event`."""
@@ -434,9 +414,11 @@ class MDAEngine(PMDAEngine):
434
414
  `exec_event`, which *is* part of the protocol), but it is made public
435
415
  in case a user wants to subclass this engine and override this method.
436
416
  """
437
- # TODO: add support for multiple camera devices
438
417
  n_events = len(event.events)
439
418
 
419
+ t0 = event.metadata.get("runner_t0") or time.perf_counter()
420
+ event_t0_ms = (time.perf_counter() - t0) * 1000
421
+
440
422
  # Start sequence
441
423
  # Note that the overload of startSequenceAcquisition that takes a camera
442
424
  # label does NOT automatically initialize a circular buffer. So if this call
@@ -446,15 +428,17 @@ class MDAEngine(PMDAEngine):
446
428
  0, # intervalMS # TODO: add support for this
447
429
  True, # stopOnOverflow
448
430
  )
449
-
450
431
  self.post_sequence_started(event)
451
432
 
433
+ n_channels = self._mmc.getNumberOfCameraChannels()
452
434
  count = 0
453
- iter_events = iter(event.events)
435
+ iter_events = product(event.events, range(n_channels))
454
436
  # block until the sequence is done, popping images in the meantime
455
437
  while self._mmc.isSequenceRunning():
456
- if self._mmc.getRemainingImageCount():
457
- yield self._next_img_payload(next(iter_events))
438
+ if remaining := self._mmc.getRemainingImageCount():
439
+ yield self._next_seqimg_payload(
440
+ *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms
441
+ )
458
442
  count += 1
459
443
  else:
460
444
  time.sleep(0.001)
@@ -462,23 +446,60 @@ class MDAEngine(PMDAEngine):
462
446
  if self._mmc.isBufferOverflowed(): # pragma: no cover
463
447
  raise MemoryError("Buffer overflowed")
464
448
 
465
- while self._mmc.getRemainingImageCount():
466
- yield self._next_img_payload(next(iter_events))
449
+ while remaining := self._mmc.getRemainingImageCount():
450
+ yield self._next_seqimg_payload(
451
+ *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms
452
+ )
467
453
  count += 1
468
454
 
469
- if count != n_events:
455
+ # necessary?
456
+ expected_images = n_events * n_channels
457
+ if count != expected_images:
470
458
  logger.warning(
471
459
  "Unexpected number of images returned from sequence. "
472
460
  "Expected %s, got %s",
473
- n_events,
461
+ expected_images,
474
462
  count,
475
463
  )
476
464
 
477
- def _next_img_payload(self, event: MDAEvent) -> PImagePayload:
465
+ def _next_seqimg_payload(
466
+ self,
467
+ event: MDAEvent,
468
+ channel: int = 0,
469
+ *,
470
+ event_t0: float = 0.0,
471
+ remaining: int = 0,
472
+ ) -> PImagePayload:
478
473
  """Grab next image from the circular buffer and return it as an ImagePayload."""
479
- img, meta = self._mmc.popNextImageAndMD()
480
- tags = self.get_frame_metadata(meta)
481
- return ImagePayload(img, event, tags)
474
+ _slice = 0 # ?
475
+ img, mm_meta = self._mmc.popNextImageAndMD(channel, _slice)
476
+ try:
477
+ seq_time = float(mm_meta.get(Keyword.Elapsed_Time_ms))
478
+ except Exception:
479
+ seq_time = 0.0
480
+ try:
481
+ # note, when present in circular buffer meta, this key is called "Camera".
482
+ # It's NOT actually Keyword.CoreCamera (but it's the same value)
483
+ # it is hardcoded in various places in mmCoreAndDevices, see:
484
+ # see: https://github.com/micro-manager/mmCoreAndDevices/pull/468
485
+ camera_device = mm_meta.GetSingleTag("Camera").GetValue()
486
+ except Exception:
487
+ camera_device = self._mmc.getPhysicalCameraDevice(channel)
488
+
489
+ # TODO: determine whether we want to try to populate changing property values
490
+ # during the course of a triggered sequence
491
+ meta = self.get_frame_metadata(
492
+ event,
493
+ prop_values=(),
494
+ runner_time_ms=event_t0 + seq_time,
495
+ camera_device=camera_device,
496
+ )
497
+ meta["hardware_triggered"] = True
498
+ meta["images_remaining_in_buffer"] = remaining
499
+ meta["camera_metadata"] = dict(mm_meta)
500
+
501
+ # https://github.com/python/mypy/issues/4976
502
+ return ImagePayload(img, event, meta) # type: ignore[return-value]
482
503
 
483
504
  # ===================== EXTRA =====================
484
505
 
@@ -528,8 +549,37 @@ class MDAEngine(PMDAEngine):
528
549
  correction = self._z_correction.setdefault(p_idx, 0.0)
529
550
  self._mmc.setZPosition(cast("float", event.z_pos) + correction)
530
551
 
552
+ def _update_config_device_props(self) -> None:
553
+ # store devices/props that make up each config group for faster lookup
554
+ self._config_device_props.clear()
555
+ for grp in self._mmc.getAvailableConfigGroups():
556
+ for preset in self._mmc.getAvailableConfigs(grp):
557
+ # ordered/unique list of (device, property) tuples for each group
558
+ self._config_device_props[grp] = tuple(
559
+ {(i[0], i[1]): None for i in self._mmc.getConfigData(grp, preset)}
560
+ )
561
+
562
+ def _get_current_props(self, *groups: str) -> tuple[PropertyValue, ...]:
563
+ """Faster version of core.getConfigGroupState(group).
564
+
565
+ MMCore does some excess iteration that we want to avoid here. It calls
566
+ GetAvailableConfigs and then calls getConfigData for *every* preset in the
567
+ group, (not only the one being requested). We go straight to cached data
568
+ for the group we want.
569
+ """
570
+ return tuple(
571
+ {
572
+ "dev": dev,
573
+ "prop": prop,
574
+ "val": self._mmc.getPropertyFromCache(dev, prop),
575
+ }
576
+ for group in groups
577
+ if (dev_props := self._config_device_props.get(group))
578
+ for dev, prop in dev_props
579
+ )
580
+
531
581
 
532
582
  class ImagePayload(NamedTuple):
533
583
  image: NDArray
534
584
  event: MDAEvent
535
- metadata: dict
585
+ metadata: FrameMetaV1 | SummaryMetaV1
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import abstractmethod
4
- from typing import TYPE_CHECKING, Any, Mapping, Protocol, runtime_checkable
4
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
5
5
 
6
6
  if TYPE_CHECKING:
7
7
  from typing import Iterable, Iterator
@@ -9,7 +9,9 @@ if TYPE_CHECKING:
9
9
  from numpy.typing import NDArray
10
10
  from useq import MDAEvent, MDASequence
11
11
 
12
- PImagePayload = tuple[NDArray, MDAEvent, dict]
12
+ from pymmcore_plus.metadata.schema import FrameMetaV1, SummaryMetaV1
13
+
14
+ PImagePayload = tuple[NDArray, MDAEvent, FrameMetaV1]
13
15
 
14
16
 
15
17
  # NOTE: This whole thing could potentially go in useq-schema
@@ -21,7 +23,7 @@ class PMDAEngine(Protocol):
21
23
  """Protocol that all MDA engines must implement."""
22
24
 
23
25
  @abstractmethod
24
- def setup_sequence(self, sequence: MDASequence) -> None | Mapping[str, Any]:
26
+ def setup_sequence(self, sequence: MDASequence) -> SummaryMetaV1 | None:
25
27
  """Setup state of system (hardware, etc.) before an MDA is run.
26
28
 
27
29
  This method is called once at the beginning of a sequence.