pymmcore-plus 0.9.4__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 +133 -30
  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.4.dist-info → pymmcore_plus-0.13.0.dist-info}/METADATA +22 -11
  63. pymmcore_plus-0.13.0.dist-info/RECORD +71 -0
  64. {pymmcore_plus-0.9.4.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.4.dist-info/RECORD +0 -55
  67. {pymmcore_plus-0.9.4.dist-info → pymmcore_plus-0.13.0.dist-info}/entry_points.txt +0 -0
  68. {pymmcore_plus-0.9.4.dist-info → pymmcore_plus-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,104 +1,104 @@
1
1
  from __future__ import annotations
2
2
 
3
- from itertools import product
4
- from typing import TYPE_CHECKING, Literal, Sequence, Tuple, overload
3
+ from collections import defaultdict
4
+ from collections.abc import Iterable
5
+ from contextlib import suppress
6
+ from typing import TYPE_CHECKING, Any, TypeVar
5
7
 
8
+ from pydantic import Field, model_validator
6
9
  from useq import AcquireImage, MDAEvent
7
10
 
8
- from pymmcore_plus.core._constants import DeviceType
11
+ from pymmcore_plus.core._constants import DeviceType, Keyword
9
12
 
10
13
  if TYPE_CHECKING:
14
+ from collections.abc import Iterable, Iterator
15
+ from typing import Self
16
+
17
+ from useq._mda_event import Channel as EventChannel
18
+
11
19
  from pymmcore_plus import CMMCorePlus
12
20
 
13
21
 
14
- class SequencedEvent(MDAEvent):
15
- """Subclass of MDAEvent that represents a sequence of triggered events.
22
+ T = TypeVar("T")
16
23
 
17
- Prefer instantiating this class via the `create` classmethod, which will
18
- calculate sequences for x, y, z, and exposure based on an a sequence of events.
19
- """
24
+ __all__ = ["SequencedEvent", "get_all_sequenceable", "iter_sequenced_events"]
20
25
 
21
- events: Tuple[MDAEvent, ...] # noqa: UP006
22
26
 
23
- exposure_sequence: Tuple[float, ...] # noqa: UP006
24
- x_sequence: Tuple[float, ...] # noqa: UP006
25
- y_sequence: Tuple[float, ...] # noqa: UP006
26
- z_sequence: Tuple[float, ...] # noqa: UP006
27
+ def iter_sequenced_events(
28
+ core: CMMCorePlus, events: Iterable[MDAEvent]
29
+ ) -> Iterator[MDAEvent | SequencedEvent]:
30
+ """Iterate over a sequence of MDAEvents, yielding SequencedEvents when possible.
27
31
 
28
- # technically this is more like a field, but it requires a core instance
29
- # to getConfigData for channels, so we leave it as a method.
30
- def property_sequences(self, core: CMMCorePlus) -> dict[tuple[str, str], list[str]]:
31
- """Return a dict of all sequenceable properties and their sequences.
32
+ Parameters
33
+ ----------
34
+ core : CMMCorePlus
35
+ The core object to use for determining sequenceable properties.
36
+ events : Iterable[MDAEvent]
37
+ The events to iterate over.
32
38
 
33
- Returns
34
- -------
35
- dict[tuple[str, str], list[str]]
36
- mapping of (device_name, prop_name) -> sequence of values
37
- """
38
- prop_seqs: dict[tuple[str, str], list[str]] = {}
39
- if not self.events[0].channel:
40
- return {}
41
-
42
- # NOTE: we already should have checked that all of these properties were
43
- # Sequenceable in can_sequence_events, so we don't check again here.
44
- for e in self.events:
45
- if e.channel is not None:
46
- e_cfg = core.getConfigData(e.channel.group, e.channel.config)
47
- for dev, prop, val in e_cfg:
48
- prop_seqs.setdefault((dev, prop), []).append(val)
49
- if e.properties:
50
- for dev, prop, val in e.properties:
51
- prop_seqs.setdefault((dev, prop), []).append(val)
52
-
53
- # filter out any sequences that are all the same value
54
- return {k: v for k, v in prop_seqs.items() if len(set(v)) > 1}
55
-
56
- @classmethod
57
- def create(cls, events: Sequence[MDAEvent]) -> SequencedEvent:
58
- """Create a SequencedEvent from a sequence of events.
59
-
60
- This pre-calculates sequences of length > 1 for x, y, z positions, and exposure.
61
- Channel configs and other sequenceable properties are determined by the
62
- `property_sequences` method, which requires access to a core instance.
63
- """
64
- _events = tuple(events)
65
- if len(_events) <= 1:
66
- raise ValueError("Sequences must have at least two events.")
67
-
68
- data: dict[str, list] = {a: [] for a in ("z_pos", "x_pos", "y_pos", "exposure")}
69
- for event, attr in product(_events, list(data)):
70
- # do we need to check if not None?
71
- # the only problem might occur if some are None and some are not
72
- data[attr].append(getattr(event, attr))
73
-
74
- x_seq = data["x_pos"] if len(set(data["x_pos"])) > 1 else ()
75
- y_seq = data["y_pos"] if len(set(data["y_pos"])) > 1 else ()
76
- if len(x_seq) != len(y_seq): # pragma: no cover
77
- raise ValueError(
78
- "X and Y sequences must be the same length: "
79
- f"{len(x_seq)=}, {len(y_seq)=}"
80
- )
81
-
82
- e0 = _events[0]
83
- return cls(
84
- events=_events,
85
- exposure_sequence=(
86
- data["exposure"] if len(set(data["exposure"])) > 1 else ()
87
- ),
88
- x_sequence=x_seq,
89
- y_sequence=y_seq,
90
- z_sequence=data["z_pos"] if len(set(data["z_pos"])) > 1 else (),
91
- # use the first event to provide all other values like min_start_time, etc.
92
- **(e0.model_dump() if hasattr(e0, "model_dump") else e0.dict()),
93
- )
39
+ Returns
40
+ -------
41
+ Iterator[MDAEvent | SequencedEvent]
42
+ A new iterator that will combine multiple MDAEvents into a single SequencedEvent
43
+ when possible, based on the sequenceable properties of the core object.
44
+ Note that `SequencedEvent` itself is a subclass of `MDAEvent`, but it's up to
45
+ the engine to check `isinstance(event, SequencedEvent)` in order to handle
46
+ SequencedEvents differently.
47
+ """
48
+ combiner = EventCombiner(core)
49
+ for e in events:
50
+ if (flushed := combiner.feed_event(e)) is not None:
51
+ yield flushed
52
+
53
+ if (leftover := combiner.flush()) is not None:
54
+ yield leftover
94
55
 
95
56
 
96
- def get_all_sequenceable(core: CMMCorePlus) -> dict[tuple[str | DeviceType, str], int]:
57
+ class SequencedEvent(MDAEvent):
58
+ """Subclass of MDAEvent that represents a sequence of triggered events."""
59
+
60
+ events: tuple[MDAEvent, ...] = Field(repr=False)
61
+
62
+ exposure_sequence: tuple[float, ...] = Field(default_factory=tuple)
63
+ x_sequence: tuple[float, ...] = Field(default_factory=tuple)
64
+ y_sequence: tuple[float, ...] = Field(default_factory=tuple)
65
+ z_sequence: tuple[float, ...] = Field(default_factory=tuple)
66
+ slm_sequence: tuple[bytes, ...] = Field(default_factory=tuple)
67
+
68
+ # all other property sequences
69
+ property_sequences: dict[tuple[str, str], list[str]] = Field(default_factory=dict)
70
+ # static properties should be added to MDAEvent.properties as usual
71
+
72
+ @model_validator(mode="after")
73
+ def _check_lengths(self) -> Self:
74
+ if len(self.x_sequence) != len(self.y_sequence):
75
+ raise ValueError("XY sequence lengths must match")
76
+ return self
77
+
78
+ def __repr_args__(self) -> Iterable[tuple[str | None, Any]]:
79
+ for k, v in super().__repr_args__():
80
+ if isinstance(v, tuple):
81
+ v = f"({len(v)} items)"
82
+ if isinstance(v, dict):
83
+ v = f"({len(v)} items)"
84
+ yield k, v
85
+
86
+
87
+ def get_all_sequenceable(
88
+ core: CMMCorePlus, include_properties: bool = True
89
+ ) -> dict[tuple[str | DeviceType, str], int]:
97
90
  """Return all sequenceable devices in `core`.
98
91
 
99
92
  This is just a convenience function to help determine which devices can be
100
93
  sequenced on a given configuration.
101
94
 
95
+ Parameters
96
+ ----------
97
+ core : CMMCorePlus
98
+ The core object to use for determining sequenceable properties.
99
+ include_properties : bool
100
+ Whether to check/include all device properties in the result.
101
+
102
102
  Returns
103
103
  -------
104
104
  dict[tuple[str | DeviceType, str], int]
@@ -114,9 +114,10 @@ def get_all_sequenceable(core: CMMCorePlus) -> dict[tuple[str | DeviceType, str]
114
114
  """
115
115
  d: dict[tuple[str | DeviceType, str], int] = {}
116
116
  for device in core.iterDevices():
117
- for prop in device.properties:
118
- if prop.isSequenceable():
119
- d[(prop.device, prop.name)] = prop.sequenceMaxLength()
117
+ if include_properties:
118
+ for prop in device.properties:
119
+ if prop.isSequenceable():
120
+ d[(prop.device, prop.name)] = prop.sequenceMaxLength()
120
121
  if device.type() == DeviceType.Stage:
121
122
  # isStageLinearSequenceable?
122
123
  if core.isStageSequenceable(device.label):
@@ -133,165 +134,301 @@ def get_all_sequenceable(core: CMMCorePlus) -> dict[tuple[str | DeviceType, str]
133
134
  return d
134
135
 
135
136
 
136
- @overload
137
- def can_sequence_events(
138
- core: CMMCorePlus,
139
- e1: MDAEvent,
140
- e2: MDAEvent,
141
- cur_length: int = ...,
142
- *,
143
- return_reason: Literal[False] = ...,
144
- ) -> bool: ...
145
-
146
-
147
- @overload
148
- def can_sequence_events(
149
- core: CMMCorePlus,
150
- e1: MDAEvent,
151
- e2: MDAEvent,
152
- cur_length: int = ...,
153
- *,
154
- return_reason: Literal[True],
155
- ) -> tuple[bool, str]: ...
156
-
157
-
158
- def can_sequence_events(
159
- core: CMMCorePlus,
160
- e1: MDAEvent,
161
- e2: MDAEvent,
162
- cur_length: int = -1,
163
- *,
164
- return_reason: bool = False,
165
- ) -> bool | tuple[bool, str]:
166
- """Check whether two [`useq.MDAEvent`][] are sequenceable.
137
+ # ==============================================
167
138
 
168
- Micro-manager calls hardware triggering "sequencing". Two events can be
169
- sequenced if *all* device properties that are changing between the first and
170
- second event support sequencing.
171
139
 
172
- If `cur_length` is provided, it is used to determine if the sequence is
173
- "full" (i.e. the sequence is already at the maximum length) as determined by
174
- the `...SequenceMaxLength()` method corresponding to the device property.
140
+ class EventCombiner:
141
+ """Helper class to combine multiple MDAEvents into a single SequencedEvent.
175
142
 
176
- See: <https://micro-manager.org/Hardware-based_Synchronization_in_Micro-Manager>
143
+ See also: `iter_sequenced_events`, which is the primary way that this class is used.
177
144
 
178
145
  Parameters
179
146
  ----------
180
147
  core : CMMCorePlus
181
- The core instance.
182
- e1 : MDAEvent
183
- The first event.
184
- e2 : MDAEvent
185
- The second event.
186
- cur_length : int
187
- The current length of the sequence. Used when checking
188
- `.get<...>SequenceMaxLength` for a given property. If the current length
189
- is greater than the max length, the events cannot be sequenced. By default
190
- -1, which means the current length is not checked.
191
- return_reason : bool
192
- If True, return a tuple of (bool, str) where the str is a reason for failure.
193
- Otherwise just return a bool.
194
-
195
- Returns
196
- -------
197
- bool | tuple[bool, str]
198
- If return_reason is True, return a tuple of a boolean indicating whether the
199
- events can be sequenced and a string describing the reason for failure if the
200
- events cannot be sequenced. Otherwise just return a boolean indicating
201
- whether the events can be sequenced.
202
-
203
- Examples
204
- --------
205
- !!! note
206
-
207
- The results here will depend on the current state of the core and devices.
208
-
209
- ```python
210
- >>> from useq import MDAEvent
211
- >>> core = CMMCorePlus.instance()
212
- >>> core.loadSystemConfiguration()
213
- >>> can_sequence_events(core, MDAEvent(), MDAEvent())
214
- (True, "")
215
- >>> can_sequence_events(core, MDAEvent(x_pos=1), MDAEvent(x_pos=2))
216
- (False, "Stage 'XY' is not sequenceable")
217
- >>> can_sequence_events(
218
- ... core,
219
- ... MDAEvent(channel={'config': 'DAPI'}),
220
- ... MDAEvent(channel={'config': 'FITC'})
221
- ... )
222
- (False, "'Dichroic-Label' is not sequenceable")
223
- ```
148
+ The core object to use for determining sequenceable properties
224
149
  """
225
150
 
226
- def _nope(reason: str) -> tuple[bool, str] | bool:
227
- return (False, reason) if return_reason else False
151
+ def __init__(self, core: CMMCorePlus) -> None:
152
+ self.core = core
153
+ self.max_lengths: dict[Keyword | tuple[str, str], int] = (
154
+ _get_max_sequence_lengths(core) # type: ignore [assignment]
155
+ )
228
156
 
229
- # Action
230
- if not isinstance(e1.action, (AcquireImage, type(None))) or not isinstance(
231
- e2.action, (AcquireImage, type(None))
232
- ):
233
- return _nope("Cannot sequence non-'AcquireImage' events.")
234
-
235
- # channel
236
- if e1.channel and e1.channel != e2.channel:
237
- if not e2.channel or e1.channel.group != e2.channel.group:
238
- e2_channel_group = getattr(e2.channel, "group", None)
239
- return _nope(
240
- "Cannot sequence across config groups: "
241
- f"{e1.channel.group=}, {e2_channel_group=}"
242
- )
243
- cfg = core.getConfigData(e1.channel.group, e1.channel.config)
244
- for dev, prop, _ in cfg:
245
- # note: we don't need _ here, so can perhaps speed up with native=True
246
- if not core.isPropertySequenceable(dev, prop):
247
- return _nope(f"'{dev}-{prop}' is not sequenceable")
248
- max_len = core.getPropertySequenceMaxLength(dev, prop)
249
- if cur_length >= max_len: # pragma: no cover
250
- return _nope(f"'{dev}-{prop}' {max_len=} < {cur_length=}")
251
-
252
- # Z
253
- if e1.z_pos != e2.z_pos:
254
- focus_dev = core.getFocusDevice()
255
- if not core.isStageSequenceable(focus_dev):
256
- return _nope(f"Focus device {focus_dev!r} is not sequenceable")
257
- max_len = core.getStageSequenceMaxLength(focus_dev)
258
- if cur_length >= max_len: # pragma: no cover
259
- return _nope(f"Focus device {focus_dev!r} {max_len=} < {cur_length=}")
260
-
261
- # XY
262
- if e1.x_pos != e2.x_pos or e1.y_pos != e2.y_pos:
263
- stage = core.getXYStageDevice()
264
- if not core.isXYStageSequenceable(stage):
265
- return _nope(f"XYStage {stage!r} is not sequenceable")
266
- max_len = core.getXYStageSequenceMaxLength(stage)
267
- if cur_length >= max_len: # pragma: no cover
268
- return _nope(f"XYStage {stage!r} {max_len=} < {cur_length=}")
269
-
270
- # camera
271
- cam_dev = core.getCameraDevice()
272
- if not core.isExposureSequenceable(cam_dev):
273
- if e1.exposure != e2.exposure:
274
- return _nope(f"Camera {cam_dev!r} is not exposure-sequenceable")
275
- elif cur_length >= core.getExposureSequenceMaxLength(cam_dev): # pragma: no cover
276
- return _nope(f"Camera {cam_dev!r} {max_len=} < {cur_length=}")
277
-
278
- # time
279
- # TODO: use better axis keys when they are available
280
- if (
281
- e1.index.get("t") != e2.index.get("t")
282
- and e1.min_start_time != e2.min_start_time
157
+ # cached property values for each channel
158
+ self._channel_props: dict[EventChannel, dict[tuple[str, str], Any]] = {}
159
+ # cached max sequence lengths for each property
160
+ self._prop_lengths: dict[tuple[str, str], int] = {}
161
+
162
+ # growing list of MDAEvents to be combined into a single SequencedEvent
163
+ self.event_batch: list[MDAEvent] = []
164
+
165
+ # whether a given attribute has changed in the current batch
166
+ self.attribute_changes: dict[Keyword | tuple[str, str], bool] = {}
167
+ self.first_event_props: dict[tuple[str, str], Any] = {}
168
+ self._reset_tracking()
169
+
170
+ def _reset_tracking(self) -> None:
171
+ self.event_batch.clear()
172
+ self.attribute_changes.clear()
173
+ self.first_event_props.clear()
174
+
175
+ def feed_event(self, event: MDAEvent) -> MDAEvent | SequencedEvent | None:
176
+ """Feed one new event into the combiner.
177
+
178
+ Returns a flushed MDAEvent/SequencedEvent if the new event *cannot* extend the
179
+ current batch, or `None` otherwise.
180
+ """
181
+ if not self.event_batch:
182
+ # Starting a new batch
183
+ self.event_batch.append(event)
184
+ self.first_event_props = self._event_properties(event)
185
+ return None
186
+
187
+ if self.can_extend(event):
188
+ # Extend the current batch
189
+ self.event_batch.append(event)
190
+ return None
191
+
192
+ # we've hit the end of the sequence
193
+ # first, flus the existing batch...
194
+ flushed = self._create_sequenced_event()
195
+
196
+ # Then start a new batch with this new event...
197
+ self._reset_tracking()
198
+ self.event_batch.append(event)
199
+ self.first_event_props = self._event_properties(event)
200
+
201
+ # then return the flushed event
202
+ return flushed
203
+
204
+ def can_extend(self, event: MDAEvent) -> bool:
205
+ """Return True if the new event can be added to the current batch."""
206
+ # cannot add pre-existing SequencedEvents to the sequence
207
+ if not self.event_batch:
208
+ return True
209
+
210
+ e0 = self.event_batch[0]
211
+
212
+ # cannot sequence on top of SequencedEvents
213
+ if isinstance(e0, SequencedEvent) or isinstance(event, SequencedEvent):
214
+ return False
215
+ # cannot sequence on top of non-'AcquireImage' events
216
+ acq = (AcquireImage, type(None))
217
+ if not isinstance(e0.action, acq) or not isinstance(event.action, acq):
218
+ return False
219
+
220
+ new_chunk_len = len(self.event_batch) + 1
221
+
222
+ # NOTE: these should be ordered from "fastest to check / most likely to fail",
223
+ # to "slowest to check / most likely to pass"
224
+
225
+ # If it's a new timepoint, and they have a different start time
226
+ # we don't (yet) support sequencing.
227
+ if (
228
+ event.index.get("t") != e0.index.get("t")
229
+ and event.min_start_time != e0.min_start_time
230
+ ):
231
+ return False
232
+
233
+ # Exposure
234
+ if event.exposure != e0.exposure:
235
+ if new_chunk_len > self.max_lengths[Keyword.CoreCamera]:
236
+ return False
237
+ self.attribute_changes[Keyword.CoreCamera] = True
238
+
239
+ # XY
240
+ if event.x_pos != e0.x_pos or event.y_pos != e0.y_pos:
241
+ if new_chunk_len > self.max_lengths[Keyword.CoreXYStage]:
242
+ return False
243
+ self.attribute_changes[Keyword.CoreXYStage] = True
244
+
245
+ # Z
246
+ if event.z_pos != e0.z_pos:
247
+ if new_chunk_len > self.max_lengths[Keyword.CoreFocus]:
248
+ return False
249
+ self.attribute_changes[Keyword.CoreFocus] = True
250
+
251
+ # SLM
252
+ if event.slm_image != e0.slm_image:
253
+ if new_chunk_len > self.max_lengths[Keyword.CoreSLM]:
254
+ return False
255
+ self.attribute_changes[Keyword.CoreSLM] = True
256
+
257
+ # properties
258
+ event_props = self._event_properties(event)
259
+ all_props = event_props.keys() | self.first_event_props.keys()
260
+ for dev_prop in all_props:
261
+ new_val = event_props.get(dev_prop)
262
+ old_val = self.first_event_props.get(dev_prop)
263
+ if new_val != old_val:
264
+ # if the property has changed, (or is missing in one dict)
265
+ if new_chunk_len > self._get_property_max_length(dev_prop):
266
+ return False
267
+ self.attribute_changes[dev_prop] = True
268
+
269
+ return True
270
+
271
+ def flush(self) -> MDAEvent | SequencedEvent | None:
272
+ """Flush any remaining events in the buffer."""
273
+ if not self.event_batch:
274
+ return None
275
+ result = self._create_sequenced_event()
276
+ self._reset_tracking()
277
+ return result
278
+
279
+ def _create_sequenced_event(self) -> MDAEvent | SequencedEvent:
280
+ """Convert self.event_batch into a SequencedEvent.
281
+
282
+ If the batch contains only a single event, that event is returned directly.
283
+ """
284
+ if not self.event_batch:
285
+ raise RuntimeError("Cannot flush an empty chunk")
286
+
287
+ first_event = self.event_batch[0]
288
+
289
+ if (num_events := len(self.event_batch)) == 1:
290
+ return first_event
291
+
292
+ exposures: list[float | None] = []
293
+ x_positions: list[float | None] = []
294
+ y_positions: list[float | None] = []
295
+ z_positions: list[float | None] = []
296
+ slm_images: list[Any] = []
297
+ property_sequences: defaultdict[tuple[str, str], list[Any]] = defaultdict(list)
298
+ static_props: list[tuple[str, str, Any]] = []
299
+
300
+ # Single pass
301
+ for e in self.event_batch:
302
+ exposures.append(e.exposure)
303
+ x_positions.append(e.x_pos)
304
+ y_positions.append(e.y_pos)
305
+ z_positions.append(e.z_pos)
306
+ slm_images.append(e.slm_image)
307
+ for dev_prop, val in self._event_properties(e).items():
308
+ property_sequences[dev_prop].append(val)
309
+
310
+ # remove any property sequences that are static
311
+ for key, prop_seq in list(property_sequences.items()):
312
+ if not self.attribute_changes.get(key):
313
+ static_props.append((*key, prop_seq[0]))
314
+ property_sequences.pop(key)
315
+ elif len(prop_seq) != num_events:
316
+ raise RuntimeError(
317
+ "Property sequence length mismatch. "
318
+ "Please report this with an example."
319
+ )
320
+
321
+ exp_changed = self.attribute_changes.get(Keyword.CoreCamera)
322
+ xy_changed = self.attribute_changes.get(Keyword.CoreXYStage)
323
+ z_changed = self.attribute_changes.get(Keyword.CoreFocus)
324
+ slm_changed = self.attribute_changes.get(Keyword.CoreSLM)
325
+
326
+ exp_seq = tuple(exposures) if exp_changed else ()
327
+ x_seq = tuple(x_positions) if xy_changed else ()
328
+ y_seq = tuple(y_positions) if xy_changed else ()
329
+ z_seq = tuple(z_positions) if z_changed else ()
330
+ slm_seq = tuple(slm_images) if slm_changed else ()
331
+
332
+ return SequencedEvent(
333
+ events=tuple(self.event_batch),
334
+ exposure_sequence=exp_seq,
335
+ x_sequence=x_seq,
336
+ y_sequence=y_seq,
337
+ z_sequence=z_seq,
338
+ slm_sequence=slm_seq,
339
+ property_sequences=property_sequences,
340
+ properties=static_props,
341
+ # all other "standard" MDAEvent fields are derived from the first event
342
+ # the engine will use these values if the corresponding sequence is empty
343
+ x_pos=first_event.x_pos,
344
+ y_pos=first_event.y_pos,
345
+ z_pos=first_event.z_pos,
346
+ exposure=first_event.exposure,
347
+ channel=first_event.channel,
348
+ )
349
+
350
+ # -------------- helper methods to query props & max lengths ----------------
351
+
352
+ def _event_properties(self, event: MDAEvent) -> dict[tuple[str, str], Any]:
353
+ """Return a dict of all property values for a given event."""
354
+ props: dict[tuple[str, str], Any] = {}
355
+
356
+ if (ch := event.channel) is not None:
357
+ props.update(self._get_channel_properties(ch))
358
+ if event.properties:
359
+ for dev, prop, val in event.properties:
360
+ props[(dev, prop)] = val
361
+ return props
362
+
363
+ def _get_channel_properties(self, ch: EventChannel) -> dict[tuple[str, str], Any]:
364
+ """Get (and cache) property values for a given channel."""
365
+ if ch not in self._channel_props:
366
+ cfg = self.core.getConfigData(ch.group, ch.config, native=True)
367
+ data: dict[tuple[str, str], Any] = {}
368
+ for n in range(cfg.size()):
369
+ s = cfg.getSetting(n)
370
+ data[(s.getDeviceLabel(), s.getPropertyName())] = s.getPropertyValue()
371
+ self._channel_props[ch] = data
372
+
373
+ return self._channel_props[ch]
374
+
375
+ def _get_property_max_length(self, dev_prop: tuple[str, str]) -> int:
376
+ """Get (and cache) the max sequence length for a given property."""
377
+ if dev_prop not in self._prop_lengths:
378
+ max_length = 0
379
+ with suppress(RuntimeError):
380
+ dev, prop = dev_prop
381
+ if self.core.isPropertySequenceable(dev, prop):
382
+ max_length = self.core.getPropertySequenceMaxLength(dev, prop)
383
+ self._prop_lengths[dev_prop] = max_length
384
+ return self._prop_lengths[dev_prop]
385
+
386
+
387
+ def _get_max_sequence_lengths(core: CMMCorePlus) -> dict[Keyword, int]:
388
+ max_lengths: dict[Keyword, int] = {}
389
+ for keyword, get_device, is_sequenceable, get_max_length in (
390
+ (
391
+ Keyword.CoreCamera,
392
+ core.getCameraDevice,
393
+ core.isExposureSequenceable,
394
+ core.getExposureSequenceMaxLength,
395
+ ),
396
+ (
397
+ Keyword.CoreFocus,
398
+ core.getFocusDevice,
399
+ core.isStageSequenceable,
400
+ core.getStageSequenceMaxLength,
401
+ ),
402
+ (
403
+ Keyword.CoreXYStage,
404
+ core.getXYStageDevice,
405
+ core.isXYStageSequenceable,
406
+ core.getXYStageSequenceMaxLength,
407
+ ),
408
+ (
409
+ Keyword.CoreSLM,
410
+ core.getSLMDevice,
411
+ lambda _device: True, # there is no isSLMSequenceable method
412
+ core.getSLMSequenceMaxLength,
413
+ ),
283
414
  ):
284
- pause = (e2.min_start_time or 0) - (e1.min_start_time or 0)
285
- return _nope(f"Must pause at least {pause} s between events.")
286
-
287
- # misc additional properties
288
- if e1.properties and e2.properties:
289
- for dev, prop, value1 in e1.properties:
290
- for dev2, prop2, value2 in e2.properties:
291
- if dev == dev2 and prop == prop2 and value1 != value2:
292
- if not core.isPropertySequenceable(dev, prop):
293
- return _nope(f"'{dev}-{prop}' is not sequenceable")
294
- if cur_length >= core.getPropertySequenceMaxLength(dev, prop):
295
- return _nope(f"'{dev}-{prop}' {max_len=} < {cur_length=}")
296
-
297
- return (True, "") if return_reason else True
415
+ max_lengths[keyword] = 0
416
+ with suppress(RuntimeError):
417
+ if (device := get_device()) and is_sequenceable(device):
418
+ max_lengths[keyword] = get_max_length(device)
419
+ return max_lengths
420
+
421
+
422
+ def can_sequence_events(core: CMMCorePlus, e1: MDAEvent, e2: MDAEvent) -> bool:
423
+ """Check whether two [`useq.MDAEvent`][] are sequenceable.
424
+
425
+ !!! warning
426
+ This function is deprecated and should not be used in new code. It is only
427
+ retained for backwards compatibility.
428
+ """
429
+ # this is an old function that simply exists to return a value in the deprecated
430
+ # core.canSequenceEvents method. It is not used in the current implementation
431
+ # and should not be used in new code.
432
+ combiner = EventCombiner(core)
433
+ combiner.feed_event(e1)
434
+ return combiner.can_extend(e2)