pymmcore-plus 0.12.0__py3-none-any.whl → 0.13.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. pymmcore_plus/__init__.py +3 -3
  2. pymmcore_plus/_benchmark.py +203 -0
  3. pymmcore_plus/_cli.py +78 -13
  4. pymmcore_plus/_logger.py +10 -2
  5. pymmcore_plus/_pymmcore.py +12 -0
  6. pymmcore_plus/_util.py +16 -10
  7. pymmcore_plus/core/__init__.py +3 -0
  8. pymmcore_plus/core/_config.py +1 -1
  9. pymmcore_plus/core/_config_group.py +2 -2
  10. pymmcore_plus/core/_constants.py +27 -3
  11. pymmcore_plus/core/_device.py +4 -4
  12. pymmcore_plus/core/_metadata.py +1 -1
  13. pymmcore_plus/core/_mmcore_plus.py +184 -118
  14. pymmcore_plus/core/_property.py +3 -5
  15. pymmcore_plus/core/_sequencing.py +369 -234
  16. pymmcore_plus/core/events/__init__.py +3 -3
  17. pymmcore_plus/experimental/__init__.py +0 -0
  18. pymmcore_plus/experimental/unicore/__init__.py +14 -0
  19. pymmcore_plus/experimental/unicore/_device_manager.py +173 -0
  20. pymmcore_plus/experimental/unicore/_proxy.py +127 -0
  21. pymmcore_plus/experimental/unicore/_unicore.py +703 -0
  22. pymmcore_plus/experimental/unicore/devices/__init__.py +0 -0
  23. pymmcore_plus/experimental/unicore/devices/_device.py +269 -0
  24. pymmcore_plus/experimental/unicore/devices/_properties.py +400 -0
  25. pymmcore_plus/experimental/unicore/devices/_stage.py +221 -0
  26. pymmcore_plus/install.py +10 -7
  27. pymmcore_plus/mda/__init__.py +1 -1
  28. pymmcore_plus/mda/_engine.py +152 -43
  29. pymmcore_plus/mda/_runner.py +8 -1
  30. pymmcore_plus/mda/events/__init__.py +2 -2
  31. pymmcore_plus/mda/handlers/__init__.py +1 -1
  32. pymmcore_plus/mda/handlers/_ome_zarr_writer.py +2 -2
  33. pymmcore_plus/mda/handlers/_tensorstore_handler.py +6 -2
  34. pymmcore_plus/metadata/__init__.py +3 -3
  35. pymmcore_plus/metadata/functions.py +18 -8
  36. pymmcore_plus/metadata/schema.py +6 -5
  37. pymmcore_plus/mocks.py +49 -0
  38. pymmcore_plus/model/_config_file.py +1 -1
  39. pymmcore_plus/model/_core_device.py +10 -1
  40. pymmcore_plus/model/_device.py +17 -6
  41. pymmcore_plus/model/_property.py +11 -2
  42. pymmcore_plus/seq_tester.py +1 -1
  43. {pymmcore_plus-0.12.0.dist-info → pymmcore_plus-0.13.1.dist-info}/METADATA +14 -6
  44. pymmcore_plus-0.13.1.dist-info/RECORD +71 -0
  45. {pymmcore_plus-0.12.0.dist-info → pymmcore_plus-0.13.1.dist-info}/WHEEL +1 -1
  46. pymmcore_plus-0.12.0.dist-info/RECORD +0 -59
  47. {pymmcore_plus-0.12.0.dist-info → pymmcore_plus-0.13.1.dist-info}/entry_points.txt +0 -0
  48. {pymmcore_plus-0.12.0.dist-info → pymmcore_plus-0.13.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,106 +1,104 @@
1
1
  from __future__ import annotations
2
2
 
3
- from itertools import product
4
- from typing import TYPE_CHECKING, Literal, 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:
11
- from collections.abc import Sequence
14
+ from collections.abc import Iterable, Iterator
15
+ from typing import Self
16
+
17
+ from useq._mda_event import Channel as EventChannel
12
18
 
13
19
  from pymmcore_plus import CMMCorePlus
14
20
 
15
21
 
16
- class SequencedEvent(MDAEvent):
17
- """Subclass of MDAEvent that represents a sequence of triggered events.
22
+ T = TypeVar("T")
18
23
 
19
- Prefer instantiating this class via the `create` classmethod, which will
20
- calculate sequences for x, y, z, and exposure based on an a sequence of events.
21
- """
24
+ __all__ = ["SequencedEvent", "get_all_sequenceable", "iter_sequenced_events"]
22
25
 
23
- events: tuple[MDAEvent, ...]
24
26
 
25
- exposure_sequence: tuple[float, ...]
26
- x_sequence: tuple[float, ...]
27
- y_sequence: tuple[float, ...]
28
- z_sequence: tuple[float, ...]
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.
29
31
 
30
- # technically this is more like a field, but it requires a core instance
31
- # to getConfigData for channels, so we leave it as a method.
32
- def property_sequences(self, core: CMMCorePlus) -> dict[tuple[str, str], list[str]]:
33
- """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.
34
38
 
35
- Returns
36
- -------
37
- dict[tuple[str, str], list[str]]
38
- mapping of (device_name, prop_name) -> sequence of values
39
- """
40
- prop_seqs: dict[tuple[str, str], list[str]] = {}
41
- if not self.events[0].channel:
42
- return {}
43
-
44
- # NOTE: we already should have checked that all of these properties were
45
- # Sequenceable in can_sequence_events, so we don't check again here.
46
- for e in self.events:
47
- if e.channel is not None:
48
- e_cfg = core.getConfigData(e.channel.group, e.channel.config)
49
- for dev, prop, val in e_cfg:
50
- prop_seqs.setdefault((dev, prop), []).append(val)
51
- if e.properties:
52
- for dev, prop, val in e.properties:
53
- prop_seqs.setdefault((dev, prop), []).append(val)
54
-
55
- # filter out any sequences that are all the same value
56
- return {k: v for k, v in prop_seqs.items() if len(set(v)) > 1}
57
-
58
- @classmethod
59
- def create(cls, events: Sequence[MDAEvent]) -> SequencedEvent:
60
- """Create a SequencedEvent from a sequence of events.
61
-
62
- This pre-calculates sequences of length > 1 for x, y, z positions, and exposure.
63
- Channel configs and other sequenceable properties are determined by the
64
- `property_sequences` method, which requires access to a core instance.
65
- """
66
- _events = tuple(events)
67
- if len(_events) <= 1:
68
- raise ValueError("Sequences must have at least two events.")
69
-
70
- data: dict[str, list] = {a: [] for a in ("z_pos", "x_pos", "y_pos", "exposure")}
71
- for event, attr in product(_events, list(data)):
72
- # do we need to check if not None?
73
- # the only problem might occur if some are None and some are not
74
- data[attr].append(getattr(event, attr))
75
-
76
- x_seq = data["x_pos"] if len(set(data["x_pos"])) > 1 else ()
77
- y_seq = data["y_pos"] if len(set(data["y_pos"])) > 1 else ()
78
- if len(x_seq) != len(y_seq): # pragma: no cover
79
- raise ValueError(
80
- "X and Y sequences must be the same length: "
81
- f"{len(x_seq)=}, {len(y_seq)=}"
82
- )
83
-
84
- e0 = _events[0]
85
- return cls(
86
- events=_events,
87
- exposure_sequence=(
88
- data["exposure"] if len(set(data["exposure"])) > 1 else ()
89
- ),
90
- x_sequence=x_seq,
91
- y_sequence=y_seq,
92
- z_sequence=data["z_pos"] if len(set(data["z_pos"])) > 1 else (),
93
- # use the first event to provide all other values like min_start_time, etc.
94
- **(e0.model_dump() if hasattr(e0, "model_dump") else e0.dict()),
95
- )
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
96
52
 
53
+ if (leftover := combiner.flush()) is not None:
54
+ yield leftover
97
55
 
98
- def get_all_sequenceable(core: CMMCorePlus) -> dict[tuple[str | DeviceType, str], int]:
56
+
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]:
99
90
  """Return all sequenceable devices in `core`.
100
91
 
101
92
  This is just a convenience function to help determine which devices can be
102
93
  sequenced on a given configuration.
103
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
+
104
102
  Returns
105
103
  -------
106
104
  dict[tuple[str | DeviceType, str], int]
@@ -116,9 +114,10 @@ def get_all_sequenceable(core: CMMCorePlus) -> dict[tuple[str | DeviceType, str]
116
114
  """
117
115
  d: dict[tuple[str | DeviceType, str], int] = {}
118
116
  for device in core.iterDevices():
119
- for prop in device.properties:
120
- if prop.isSequenceable():
121
- 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()
122
121
  if device.type() == DeviceType.Stage:
123
122
  # isStageLinearSequenceable?
124
123
  if core.isStageSequenceable(device.label):
@@ -135,165 +134,301 @@ def get_all_sequenceable(core: CMMCorePlus) -> dict[tuple[str | DeviceType, str]
135
134
  return d
136
135
 
137
136
 
138
- @overload
139
- def can_sequence_events(
140
- core: CMMCorePlus,
141
- e1: MDAEvent,
142
- e2: MDAEvent,
143
- cur_length: int = ...,
144
- *,
145
- return_reason: Literal[False] = ...,
146
- ) -> bool: ...
147
-
148
-
149
- @overload
150
- def can_sequence_events(
151
- core: CMMCorePlus,
152
- e1: MDAEvent,
153
- e2: MDAEvent,
154
- cur_length: int = ...,
155
- *,
156
- return_reason: Literal[True],
157
- ) -> tuple[bool, str]: ...
158
-
159
-
160
- def can_sequence_events(
161
- core: CMMCorePlus,
162
- e1: MDAEvent,
163
- e2: MDAEvent,
164
- cur_length: int = -1,
165
- *,
166
- return_reason: bool = False,
167
- ) -> bool | tuple[bool, str]:
168
- """Check whether two [`useq.MDAEvent`][] are sequenceable.
137
+ # ==============================================
169
138
 
170
- Micro-manager calls hardware triggering "sequencing". Two events can be
171
- sequenced if *all* device properties that are changing between the first and
172
- second event support sequencing.
173
139
 
174
- If `cur_length` is provided, it is used to determine if the sequence is
175
- "full" (i.e. the sequence is already at the maximum length) as determined by
176
- the `...SequenceMaxLength()` method corresponding to the device property.
140
+ class EventCombiner:
141
+ """Helper class to combine multiple MDAEvents into a single SequencedEvent.
177
142
 
178
- 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.
179
144
 
180
145
  Parameters
181
146
  ----------
182
147
  core : CMMCorePlus
183
- The core instance.
184
- e1 : MDAEvent
185
- The first event.
186
- e2 : MDAEvent
187
- The second event.
188
- cur_length : int
189
- The current length of the sequence. Used when checking
190
- `.get<...>SequenceMaxLength` for a given property. If the current length
191
- is greater than the max length, the events cannot be sequenced. By default
192
- -1, which means the current length is not checked.
193
- return_reason : bool
194
- If True, return a tuple of (bool, str) where the str is a reason for failure.
195
- Otherwise just return a bool.
196
-
197
- Returns
198
- -------
199
- bool | tuple[bool, str]
200
- If return_reason is True, return a tuple of a boolean indicating whether the
201
- events can be sequenced and a string describing the reason for failure if the
202
- events cannot be sequenced. Otherwise just return a boolean indicating
203
- whether the events can be sequenced.
204
-
205
- Examples
206
- --------
207
- !!! note
208
-
209
- The results here will depend on the current state of the core and devices.
210
-
211
- ```python
212
- >>> from useq import MDAEvent
213
- >>> core = CMMCorePlus.instance()
214
- >>> core.loadSystemConfiguration()
215
- >>> can_sequence_events(core, MDAEvent(), MDAEvent())
216
- (True, "")
217
- >>> can_sequence_events(core, MDAEvent(x_pos=1), MDAEvent(x_pos=2))
218
- (False, "Stage 'XY' is not sequenceable")
219
- >>> can_sequence_events(
220
- ... core,
221
- ... MDAEvent(channel={'config': 'DAPI'}),
222
- ... MDAEvent(channel={'config': 'FITC'})
223
- ... )
224
- (False, "'Dichroic-Label' is not sequenceable")
225
- ```
148
+ The core object to use for determining sequenceable properties
226
149
  """
227
150
 
228
- def _nope(reason: str) -> tuple[bool, str] | bool:
229
- 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
+ )
230
156
 
231
- # Action
232
- if not isinstance(e1.action, (AcquireImage, type(None))) or not isinstance(
233
- e2.action, (AcquireImage, type(None))
234
- ):
235
- return _nope("Cannot sequence non-'AcquireImage' events.")
236
-
237
- # channel
238
- if e1.channel and e1.channel != e2.channel:
239
- if not e2.channel or e1.channel.group != e2.channel.group:
240
- e2_channel_group = getattr(e2.channel, "group", None)
241
- return _nope(
242
- "Cannot sequence across config groups: "
243
- f"{e1.channel.group=}, {e2_channel_group=}"
244
- )
245
- cfg = core.getConfigData(e1.channel.group, e1.channel.config)
246
- for dev, prop, _ in cfg:
247
- # note: we don't need _ here, so can perhaps speed up with native=True
248
- if not core.isPropertySequenceable(dev, prop):
249
- return _nope(f"'{dev}-{prop}' is not sequenceable")
250
- max_len = core.getPropertySequenceMaxLength(dev, prop)
251
- if cur_length >= max_len: # pragma: no cover
252
- return _nope(f"'{dev}-{prop}' {max_len=} < {cur_length=}")
253
-
254
- # Z
255
- if e1.z_pos != e2.z_pos:
256
- focus_dev = core.getFocusDevice()
257
- if not core.isStageSequenceable(focus_dev):
258
- return _nope(f"Focus device {focus_dev!r} is not sequenceable")
259
- max_len = core.getStageSequenceMaxLength(focus_dev)
260
- if cur_length >= max_len: # pragma: no cover
261
- return _nope(f"Focus device {focus_dev!r} {max_len=} < {cur_length=}")
262
-
263
- # XY
264
- if e1.x_pos != e2.x_pos or e1.y_pos != e2.y_pos:
265
- stage = core.getXYStageDevice()
266
- if not core.isXYStageSequenceable(stage):
267
- return _nope(f"XYStage {stage!r} is not sequenceable")
268
- max_len = core.getXYStageSequenceMaxLength(stage)
269
- if cur_length >= max_len: # pragma: no cover
270
- return _nope(f"XYStage {stage!r} {max_len=} < {cur_length=}")
271
-
272
- # camera
273
- cam_dev = core.getCameraDevice()
274
- if not core.isExposureSequenceable(cam_dev):
275
- if e1.exposure != e2.exposure:
276
- return _nope(f"Camera {cam_dev!r} is not exposure-sequenceable")
277
- elif cur_length >= core.getExposureSequenceMaxLength(cam_dev): # pragma: no cover
278
- return _nope(f"Camera {cam_dev!r} {max_len=} < {cur_length=}")
279
-
280
- # time
281
- # TODO: use better axis keys when they are available
282
- if (
283
- e1.index.get("t") != e2.index.get("t")
284
- 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
+ ),
285
414
  ):
286
- pause = (e2.min_start_time or 0) - (e1.min_start_time or 0)
287
- return _nope(f"Must pause at least {pause} s between events.")
288
-
289
- # misc additional properties
290
- if e1.properties and e2.properties:
291
- for dev, prop, value1 in e1.properties:
292
- for dev2, prop2, value2 in e2.properties:
293
- if dev == dev2 and prop == prop2 and value1 != value2:
294
- if not core.isPropertySequenceable(dev, prop):
295
- return _nope(f"'{dev}-{prop}' is not sequenceable")
296
- if cur_length >= core.getPropertySequenceMaxLength(dev, prop):
297
- return _nope(f"'{dev}-{prop}' {max_len=} < {cur_length=}")
298
-
299
- 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)
@@ -6,15 +6,15 @@ from ._protocol import PCoreSignaler
6
6
  from ._psygnal import CMMCoreSignaler
7
7
 
8
8
  if TYPE_CHECKING:
9
- from ._qsignals import QCoreSignaler # noqa: TCH004
9
+ from ._qsignals import QCoreSignaler # noqa: TC004
10
10
 
11
11
 
12
12
  __all__ = [
13
13
  "CMMCoreSignaler",
14
- "QCoreSignaler",
15
14
  "PCoreSignaler",
16
- "_get_auto_core_callback_class",
15
+ "QCoreSignaler",
17
16
  "_denormalize_slot",
17
+ "_get_auto_core_callback_class",
18
18
  ]
19
19
 
20
20
 
File without changes