pymmcore-plus 0.10.2__py3-none-any.whl → 0.11.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 (33) hide show
  1. pymmcore_plus/__init__.py +4 -1
  2. pymmcore_plus/_build.py +2 -0
  3. pymmcore_plus/_cli.py +49 -14
  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 +69 -49
  8. pymmcore_plus/mda/__init__.py +2 -2
  9. pymmcore_plus/mda/_engine.py +151 -102
  10. pymmcore_plus/mda/_protocol.py +5 -3
  11. pymmcore_plus/mda/_runner.py +16 -21
  12. pymmcore_plus/mda/events/_protocol.py +10 -2
  13. pymmcore_plus/mda/handlers/_5d_writer_base.py +25 -13
  14. pymmcore_plus/mda/handlers/_img_sequence_writer.py +9 -5
  15. pymmcore_plus/mda/handlers/_ome_tiff_writer.py +7 -3
  16. pymmcore_plus/mda/handlers/_ome_zarr_writer.py +9 -4
  17. pymmcore_plus/mda/handlers/_tensorstore_handler.py +19 -19
  18. pymmcore_plus/metadata/__init__.py +36 -0
  19. pymmcore_plus/metadata/functions.py +343 -0
  20. pymmcore_plus/metadata/schema.py +471 -0
  21. pymmcore_plus/metadata/serialize.py +116 -0
  22. pymmcore_plus/model/_config_file.py +2 -4
  23. pymmcore_plus/model/_config_group.py +29 -3
  24. pymmcore_plus/model/_device.py +20 -1
  25. pymmcore_plus/model/_microscope.py +36 -2
  26. pymmcore_plus/model/_pixel_size_config.py +26 -4
  27. {pymmcore_plus-0.10.2.dist-info → pymmcore_plus-0.11.1.dist-info}/METADATA +6 -5
  28. pymmcore_plus-0.11.1.dist-info/RECORD +59 -0
  29. {pymmcore_plus-0.10.2.dist-info → pymmcore_plus-0.11.1.dist-info}/WHEEL +1 -1
  30. pymmcore_plus/core/_state.py +0 -244
  31. pymmcore_plus-0.10.2.dist-info/RECORD +0 -56
  32. {pymmcore_plus-0.10.2.dist-info → pymmcore_plus-0.11.1.dist-info}/entry_points.txt +0 -0
  33. {pymmcore_plus-0.10.2.dist-info → pymmcore_plus-0.11.1.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
 
@@ -1560,7 +1594,7 @@ class CMMCorePlus(pymmcore.CMMCore):
1560
1594
 
1561
1595
  try:
1562
1596
  channel_group = self.getPropertyFromCache("Core", "ChannelGroup")
1563
- channel = self.getCurrentConfigFromCache(channel_group)
1597
+ channel: str = self.getCurrentConfigFromCache(channel_group)
1564
1598
  except Exception:
1565
1599
  channel = "Default"
1566
1600
  tags["Channel"] = channel
@@ -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
@@ -1982,7 +2010,7 @@ class CMMCorePlus(pymmcore.CMMCore):
1982
2010
 
1983
2011
  :sparkles: *This method is new in `CMMCorePlus`.*
1984
2012
  """
1985
- _current = {
2013
+ _current: dict[str, str] = {
1986
2014
  self.getCameraDevice(): "Camera",
1987
2015
  self.getXYStageDevice(): "XYStage",
1988
2016
  self.getFocusDevice(): "Focus",
@@ -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]
@@ -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
@@ -67,12 +54,11 @@ class MDAEngine(PMDAEngine):
67
54
  attempt to combine MDAEvents into a single `SequencedEvent` if
68
55
  [`core.canSequenceEvents()`][pymmcore_plus.CMMCorePlus.canSequenceEvents]
69
56
  reports that the events can be sequenced. This can be set after instantiation.
70
- By default, this is `False`, in order to avoid unexpected behavior, particularly
71
- in testing and demo scenarios. But in many "real world" scenarios, this can be
72
- set to `True` to improve performance.
57
+ By default, this is `True`, however in various testing and demo scenarios, you
58
+ may wish to set it to `False` in order to avoid unexpected behavior.
73
59
  """
74
60
 
75
- def __init__(self, mmc: CMMCorePlus, use_hardware_sequencing: bool = False) -> None:
61
+ def __init__(self, mmc: CMMCorePlus, use_hardware_sequencing: bool = True) -> None:
76
62
  self._mmc = mmc
77
63
  self.use_hardware_sequencing = use_hardware_sequencing
78
64
 
@@ -91,6 +77,13 @@ class MDAEngine(PMDAEngine):
91
77
  # Note: getAutoShutter() is True when no config is loaded at all
92
78
  self._autoshutter_was_set: bool = self._mmc.getAutoShutter()
93
79
 
80
+ # -----
81
+ # The following values are stored during setup_sequence simply to speed up
82
+ # retrieval of metadata during each frame.
83
+ # sequence of (device, property) of all properties used in any of the presets
84
+ # in the channel group.
85
+ self._config_device_props: dict[str, Sequence[tuple[str, str]]] = {}
86
+
94
87
  @property
95
88
  def mmcore(self) -> CMMCorePlus:
96
89
  """The `CMMCorePlus` instance to use for hardware control."""
@@ -98,7 +91,7 @@ class MDAEngine(PMDAEngine):
98
91
 
99
92
  # ===================== Protocol Implementation =====================
100
93
 
101
- def setup_sequence(self, sequence: MDASequence) -> Mapping[str, Any]:
94
+ def setup_sequence(self, sequence: MDASequence) -> SummaryMetaV1 | None:
102
95
  """Setup the hardware for the entire sequence."""
103
96
  # clear z_correction for new sequence
104
97
  self._z_correction.clear()
@@ -108,6 +101,7 @@ class MDAEngine(PMDAEngine):
108
101
 
109
102
  self._mmc = CMMCorePlus.instance()
110
103
 
104
+ self._update_config_device_props()
111
105
  # get if the autofocus is engaged at the start of the sequence
112
106
  self._af_was_engaged = self._mmc.isContinuousFocusLocked()
113
107
 
@@ -115,30 +109,10 @@ class MDAEngine(PMDAEngine):
115
109
  self._update_grid_fov_sizes(px_size, sequence)
116
110
 
117
111
  self._autoshutter_was_set = self._mmc.getAutoShutter()
118
- return self.get_summary_metadata()
112
+ return self.get_summary_metadata(mda_sequence=sequence)
119
113
 
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
- }
114
+ def get_summary_metadata(self, mda_sequence: MDASequence | None) -> SummaryMetaV1:
115
+ return summary_metadata(self._mmc, mda_sequence=mda_sequence)
142
116
 
143
117
  def _update_grid_fov_sizes(self, px_size: float, sequence: MDASequence) -> None:
144
118
  *_, x_size, y_size = self._mmc.getROI()
@@ -250,6 +224,7 @@ class MDAEngine(PMDAEngine):
250
224
 
251
225
  if event.channel is not None:
252
226
  try:
227
+ # possible speedup by setting manually.
253
228
  self._mmc.setConfig(event.channel.group, event.channel.config)
254
229
  except Exception as e:
255
230
  logger.warning("Failed to set channel. %s", e)
@@ -281,46 +256,50 @@ class MDAEngine(PMDAEngine):
281
256
  """
282
257
  try:
283
258
  self._mmc.snapImage()
259
+ # taking event time after snapImage includes exposure time
260
+ # not sure that's what we want, but it's currently consistent with the
261
+ # timing of the sequenced event runner (where Elapsed_Time_ms is taken after
262
+ # the image is acquired, not before the exposure starts)
263
+ t0 = event.metadata.get("runner_t0") or time.perf_counter()
264
+ event_time_ms = (time.perf_counter() - t0) * 1000
284
265
  except Exception as e:
285
266
  logger.warning("Failed to snap image. %s", e)
286
267
  return
287
268
  if not event.keep_shutter_open:
288
269
  self._mmc.setShutterOpen(False)
289
- yield ImagePayload(self._mmc.getImage(), event, self.get_frame_metadata())
270
+
271
+ # most cameras will only have a single channel
272
+ # but Multi-camera may have multiple, and we need to retrieve a buffer for each
273
+ for cam in range(self._mmc.getNumberOfCameraChannels()):
274
+ meta = self.get_frame_metadata(
275
+ event,
276
+ runner_time_ms=event_time_ms,
277
+ camera_device=self._mmc.getPhysicalCameraDevice(cam),
278
+ )
279
+ # Note, the third element is actually a MutableMapping, but mypy doesn't
280
+ # see TypedDict as a subclass of MutableMapping yet.
281
+ # https://github.com/python/mypy/issues/4976
282
+ yield ImagePayload(self._mmc.getImage(cam), event, meta) # type: ignore[misc]
290
283
 
291
284
  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
285
+ self,
286
+ event: MDAEvent,
287
+ prop_values: tuple[PropertyValue, ...] | None = None,
288
+ runner_time_ms: float = 0.0,
289
+ camera_device: str | None = None,
290
+ ) -> FrameMetaV1:
291
+ if prop_values is None and (ch := event.channel):
292
+ prop_values = self._get_current_props(ch.group)
293
+ else:
294
+ prop_values = ()
295
+ return frame_metadata(
296
+ self._mmc,
297
+ cached=True,
298
+ runner_time_ms=runner_time_ms,
299
+ camera_device=camera_device,
300
+ property_values=prop_values,
301
+ mda_event=event,
302
+ )
324
303
 
325
304
  def teardown_event(self, event: MDAEvent) -> None:
326
305
  """Teardown state of system (hardware, etc.) after `event`."""
@@ -434,9 +413,11 @@ class MDAEngine(PMDAEngine):
434
413
  `exec_event`, which *is* part of the protocol), but it is made public
435
414
  in case a user wants to subclass this engine and override this method.
436
415
  """
437
- # TODO: add support for multiple camera devices
438
416
  n_events = len(event.events)
439
417
 
418
+ t0 = event.metadata.get("runner_t0") or time.perf_counter()
419
+ event_t0_ms = (time.perf_counter() - t0) * 1000
420
+
440
421
  # Start sequence
441
422
  # Note that the overload of startSequenceAcquisition that takes a camera
442
423
  # label does NOT automatically initialize a circular buffer. So if this call
@@ -446,15 +427,17 @@ class MDAEngine(PMDAEngine):
446
427
  0, # intervalMS # TODO: add support for this
447
428
  True, # stopOnOverflow
448
429
  )
449
-
450
430
  self.post_sequence_started(event)
451
431
 
432
+ n_channels = self._mmc.getNumberOfCameraChannels()
452
433
  count = 0
453
- iter_events = iter(event.events)
434
+ iter_events = product(event.events, range(n_channels))
454
435
  # block until the sequence is done, popping images in the meantime
455
436
  while self._mmc.isSequenceRunning():
456
- if self._mmc.getRemainingImageCount():
457
- yield self._next_img_payload(next(iter_events))
437
+ if remaining := self._mmc.getRemainingImageCount():
438
+ yield self._next_seqimg_payload(
439
+ *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms
440
+ )
458
441
  count += 1
459
442
  else:
460
443
  time.sleep(0.001)
@@ -462,23 +445,60 @@ class MDAEngine(PMDAEngine):
462
445
  if self._mmc.isBufferOverflowed(): # pragma: no cover
463
446
  raise MemoryError("Buffer overflowed")
464
447
 
465
- while self._mmc.getRemainingImageCount():
466
- yield self._next_img_payload(next(iter_events))
448
+ while remaining := self._mmc.getRemainingImageCount():
449
+ yield self._next_seqimg_payload(
450
+ *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms
451
+ )
467
452
  count += 1
468
453
 
469
- if count != n_events:
454
+ # necessary?
455
+ expected_images = n_events * n_channels
456
+ if count != expected_images:
470
457
  logger.warning(
471
458
  "Unexpected number of images returned from sequence. "
472
459
  "Expected %s, got %s",
473
- n_events,
460
+ expected_images,
474
461
  count,
475
462
  )
476
463
 
477
- def _next_img_payload(self, event: MDAEvent) -> PImagePayload:
464
+ def _next_seqimg_payload(
465
+ self,
466
+ event: MDAEvent,
467
+ channel: int = 0,
468
+ *,
469
+ event_t0: float = 0.0,
470
+ remaining: int = 0,
471
+ ) -> PImagePayload:
478
472
  """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)
473
+ _slice = 0 # ?
474
+ img, mm_meta = self._mmc.popNextImageAndMD(channel, _slice)
475
+ try:
476
+ seq_time = float(mm_meta.get(Keyword.Elapsed_Time_ms))
477
+ except Exception:
478
+ seq_time = 0.0
479
+ try:
480
+ # note, when present in circular buffer meta, this key is called "Camera".
481
+ # It's NOT actually Keyword.CoreCamera (but it's the same value)
482
+ # it is hardcoded in various places in mmCoreAndDevices, see:
483
+ # see: https://github.com/micro-manager/mmCoreAndDevices/pull/468
484
+ camera_device = mm_meta.GetSingleTag("Camera").GetValue()
485
+ except Exception:
486
+ camera_device = self._mmc.getPhysicalCameraDevice(channel)
487
+
488
+ # TODO: determine whether we want to try to populate changing property values
489
+ # during the course of a triggered sequence
490
+ meta = self.get_frame_metadata(
491
+ event,
492
+ prop_values=(),
493
+ runner_time_ms=event_t0 + seq_time,
494
+ camera_device=camera_device,
495
+ )
496
+ meta["hardware_triggered"] = True
497
+ meta["images_remaining_in_buffer"] = remaining
498
+ meta["camera_metadata"] = dict(mm_meta)
499
+
500
+ # https://github.com/python/mypy/issues/4976
501
+ return ImagePayload(img, event, meta) # type: ignore[return-value]
482
502
 
483
503
  # ===================== EXTRA =====================
484
504
 
@@ -528,8 +548,37 @@ class MDAEngine(PMDAEngine):
528
548
  correction = self._z_correction.setdefault(p_idx, 0.0)
529
549
  self._mmc.setZPosition(cast("float", event.z_pos) + correction)
530
550
 
551
+ def _update_config_device_props(self) -> None:
552
+ # store devices/props that make up each config group for faster lookup
553
+ self._config_device_props.clear()
554
+ for grp in self._mmc.getAvailableConfigGroups():
555
+ for preset in self._mmc.getAvailableConfigs(grp):
556
+ # ordered/unique list of (device, property) tuples for each group
557
+ self._config_device_props[grp] = tuple(
558
+ {(i[0], i[1]): None for i in self._mmc.getConfigData(grp, preset)}
559
+ )
560
+
561
+ def _get_current_props(self, *groups: str) -> tuple[PropertyValue, ...]:
562
+ """Faster version of core.getConfigGroupState(group).
563
+
564
+ MMCore does some excess iteration that we want to avoid here. It calls
565
+ GetAvailableConfigs and then calls getConfigData for *every* preset in the
566
+ group, (not only the one being requested). We go straight to cached data
567
+ for the group we want.
568
+ """
569
+ return tuple(
570
+ {
571
+ "dev": dev,
572
+ "prop": prop,
573
+ "val": self._mmc.getPropertyFromCache(dev, prop),
574
+ }
575
+ for group in groups
576
+ if (dev_props := self._config_device_props.get(group))
577
+ for dev, prop in dev_props
578
+ )
579
+
531
580
 
532
581
  class ImagePayload(NamedTuple):
533
582
  image: NDArray
534
583
  event: MDAEvent
535
- metadata: dict
584
+ 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.