pymmcore-plus 0.15.4__py3-none-any.whl → 0.16.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.
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import time
4
+ import warnings
5
+ import weakref
4
6
  from contextlib import suppress
7
+ from functools import cache
5
8
  from itertools import product
6
9
  from typing import TYPE_CHECKING, Literal, NamedTuple, cast
7
10
 
@@ -11,7 +14,7 @@ from useq import AcquireImage, HardwareAutofocus, MDAEvent, MDASequence
11
14
 
12
15
  from pymmcore_plus._logger import logger
13
16
  from pymmcore_plus._util import retry
14
- from pymmcore_plus.core._constants import Keyword
17
+ from pymmcore_plus.core._constants import FocusDirection, Keyword
15
18
  from pymmcore_plus.core._sequencing import SequencedEvent, iter_sequenced_events
16
19
  from pymmcore_plus.metadata import (
17
20
  FrameMetaV1,
@@ -28,6 +31,7 @@ if TYPE_CHECKING:
28
31
  from typing import TypeAlias
29
32
 
30
33
  from numpy.typing import NDArray
34
+ from typing_extensions import TypedDict
31
35
 
32
36
  from pymmcore_plus.core import CMMCorePlus
33
37
 
@@ -35,6 +39,13 @@ if TYPE_CHECKING:
35
39
 
36
40
  IncludePositionArg: TypeAlias = Literal[True, False, "unsequenced-only"]
37
41
 
42
+ class StateDict(TypedDict, total=False):
43
+ xy_position: Sequence[float]
44
+ z_position: float
45
+ exposure: float
46
+ autoshutter: bool
47
+ config_groups: dict[str, str]
48
+
38
49
 
39
50
  # these are SLM devices that have a known pixel_on_value.
40
51
  # there is currently no way to extract this information from the core,
@@ -67,10 +78,22 @@ class MDAEngine(PMDAEngine):
67
78
  reports that the events can be sequenced. This can be set after instantiation.
68
79
  By default, this is `True`, however in various testing and demo scenarios, you
69
80
  may wish to set it to `False` in order to avoid unexpected behavior.
81
+ restore_initial_state : bool | None
82
+ Whether to restore the initial hardware state after the MDA sequence completes.
83
+ If `True`, the engine will capture the initial state (positions,
84
+ config groups, exposure settings) before the sequence starts and restore it
85
+ after completion. If `None` (the default), `restore_initial_state` will
86
+ be set to `True` if FocusDirection is known (i.e. not Unknown).
70
87
  """
71
88
 
72
- def __init__(self, mmc: CMMCorePlus, use_hardware_sequencing: bool = True) -> None:
73
- self._mmc = mmc
89
+ def __init__(
90
+ self,
91
+ mmc: CMMCorePlus,
92
+ *,
93
+ use_hardware_sequencing: bool = True,
94
+ restore_initial_state: bool | None = None,
95
+ ) -> None:
96
+ self._mmcore_ref = weakref.ref(mmc)
74
97
  self.use_hardware_sequencing: bool = use_hardware_sequencing
75
98
  # if True, always set XY position, even if the commanded position is the same
76
99
  # as the last commanded position (this does *not* query the stage for the
@@ -81,6 +104,12 @@ class MDAEngine(PMDAEngine):
81
104
  # omitted by default when performing triggered acquisition because it's slow.
82
105
  self._include_frame_position_metadata: IncludePositionArg = "unsequenced-only"
83
106
 
107
+ # whether to restore the initial hardware state after sequence completion
108
+ self.restore_initial_state: bool | None = restore_initial_state
109
+
110
+ # stored initial state for restoration (if restore_initial_state is True)
111
+ self._initial_state: StateDict = {}
112
+
84
113
  # used to check if the hardware autofocus is engaged when the sequence begins.
85
114
  # if it is, we will re-engage it after the autofocus action (if successful).
86
115
  self._af_was_engaged: bool = False
@@ -94,7 +123,7 @@ class MDAEngine(PMDAEngine):
94
123
  # This is used to determine whether we need to re-enable autoshutter after
95
124
  # the sequence is done (assuming a event.keep_shutter_open was requested)
96
125
  # Note: getAutoShutter() is True when no config is loaded at all
97
- self._autoshutter_was_set: bool = self._mmc.getAutoShutter()
126
+ self._autoshutter_was_set: bool = mmc.getAutoShutter()
98
127
 
99
128
  self._last_config: tuple[str, str] = ("", "")
100
129
  self._last_xy_pos: tuple[float | None, float | None] = (None, None)
@@ -122,7 +151,9 @@ class MDAEngine(PMDAEngine):
122
151
  @property
123
152
  def mmcore(self) -> CMMCorePlus:
124
153
  """The `CMMCorePlus` instance to use for hardware control."""
125
- return self._mmc
154
+ if (mmc := self._mmcore_ref()) is None: # pragma: no cover
155
+ raise RuntimeError("The CMMCorePlus instance has been garbage collected.")
156
+ return mmc
126
157
 
127
158
  # ===================== Protocol Implementation =====================
128
159
 
@@ -131,26 +162,42 @@ class MDAEngine(PMDAEngine):
131
162
  # clear z_correction for new sequence
132
163
  self._z_correction.clear()
133
164
 
134
- if not self._mmc: # pragma: no cover
165
+ if not (core := self._mmcore_ref()): # pragma: no cover
135
166
  from pymmcore_plus.core import CMMCorePlus
136
167
 
137
- self._mmc = CMMCorePlus.instance()
168
+ core = CMMCorePlus.instance()
169
+ self._mmcore_ref = weakref.ref(core)
170
+
171
+ # just in case a non-programmatic changes have been made in the meantime
172
+ # https://github.com/pymmcore-plus/pymmcore-plus/issues/503
173
+ core._last_config = ("", "") # noqa: SLF001
174
+ core._last_xy_position.clear() # noqa: SLF001
138
175
 
139
176
  self._update_config_device_props()
140
177
  # get if the autofocus is engaged at the start of the sequence
141
- self._af_was_engaged = self._mmc.isContinuousFocusLocked()
178
+ self._af_was_engaged = core.isContinuousFocusLocked()
179
+
180
+ # capture initial state if restoration is enabled
181
+ if self.restore_initial_state is None:
182
+ fd = core.getFocusDevice()
183
+ self.restore_initial_state = (
184
+ fd is not None and core.getFocusDirection(fd) != FocusDirection.Unknown
185
+ )
142
186
 
143
- if px_size := self._mmc.getPixelSizeUm():
187
+ if self.restore_initial_state:
188
+ self._initial_state = self._capture_state()
189
+
190
+ if px_size := core.getPixelSizeUm():
144
191
  self._update_grid_fov_sizes(px_size, sequence)
145
192
 
146
- self._autoshutter_was_set = self._mmc.getAutoShutter()
193
+ self._autoshutter_was_set = core.getAutoShutter()
147
194
  return self.get_summary_metadata(mda_sequence=sequence)
148
195
 
149
196
  def get_summary_metadata(self, mda_sequence: MDASequence | None) -> SummaryMetaV1:
150
- return summary_metadata(self._mmc, mda_sequence=mda_sequence)
197
+ return summary_metadata(self.mmcore, mda_sequence=mda_sequence)
151
198
 
152
199
  def _update_grid_fov_sizes(self, px_size: float, sequence: MDASequence) -> None:
153
- *_, x_size, y_size = self._mmc.getROI()
200
+ *_, x_size, y_size = self.mmcore.getROI()
154
201
  fov_width = x_size * px_size
155
202
  fov_height = y_size * px_size
156
203
 
@@ -176,14 +223,15 @@ class MDAEngine(PMDAEngine):
176
223
  self.setup_sequenced_event(event)
177
224
  else:
178
225
  self.setup_single_event(event)
179
- self._mmc.waitForSystem()
226
+ self.mmcore.waitForSystem()
180
227
 
181
228
  def exec_event(self, event: MDAEvent) -> Iterable[PImagePayload]:
182
229
  """Execute an individual event and return the image data."""
183
230
  action = getattr(event, "action", None)
231
+ core = self.mmcore
184
232
  if isinstance(action, HardwareAutofocus):
185
233
  # skip if no autofocus device is found
186
- if not self._mmc.getAutoFocusDevice():
234
+ if not core.getAutoFocusDevice():
187
235
  logger.warning("No autofocus device found. Cannot execute autofocus.")
188
236
  return
189
237
 
@@ -213,7 +261,7 @@ class MDAEngine(PMDAEngine):
213
261
  # did not fail, re-engage it. NOTE: we need to do that AFTER the runner calls
214
262
  # `setup_event`, so we can't do it inside the exec_event autofocus action above.
215
263
  if self._af_was_engaged and self._af_succeeded:
216
- self._mmc.enableContinuousFocus(True)
264
+ core.enableContinuousFocus(True)
217
265
 
218
266
  if isinstance(event, SequencedEvent):
219
267
  yield from self.exec_sequenced_event(event)
@@ -231,7 +279,7 @@ class MDAEngine(PMDAEngine):
231
279
  yield from events
232
280
  return
233
281
 
234
- yield from iter_sequenced_events(self._mmc, events)
282
+ yield from iter_sequenced_events(self.mmcore, events)
235
283
 
236
284
  # ===================== Regular Events =====================
237
285
 
@@ -254,15 +302,16 @@ class MDAEngine(PMDAEngine):
254
302
 
255
303
  self._set_event_channel(event)
256
304
 
305
+ mmcore = self.mmcore
257
306
  if event.exposure is not None:
258
307
  try:
259
- self._mmc.setExposure(event.exposure)
308
+ mmcore.setExposure(event.exposure)
260
309
  except Exception as e:
261
310
  logger.warning("Failed to set exposure. %s", e)
262
311
  if event.properties is not None:
263
312
  try:
264
313
  for dev, prop, value in event.properties:
265
- self._mmc.setProperty(dev, prop, value)
314
+ mmcore.setProperty(dev, prop, value)
266
315
  except Exception as e:
267
316
  logger.warning("Failed to set properties. %s", e)
268
317
  if (
@@ -272,11 +321,11 @@ class MDAEngine(PMDAEngine):
272
321
  # if we want to leave the shutter open after this event, and autoshutter
273
322
  # is currently enabled...
274
323
  and event.keep_shutter_open
275
- and self._mmc.getAutoShutter()
324
+ and mmcore.getAutoShutter()
276
325
  ):
277
326
  # we have to disable autoshutter and open the shutter
278
- self._mmc.setAutoShutter(False)
279
- self._mmc.setShutterOpen(True)
327
+ mmcore.setAutoShutter(False)
328
+ mmcore.setShutterOpen(True)
280
329
 
281
330
  def exec_single_event(self, event: MDAEvent) -> Iterator[PImagePayload]:
282
331
  """Execute a single (non-triggered) event and return the image data.
@@ -288,8 +337,9 @@ class MDAEngine(PMDAEngine):
288
337
  if event.slm_image is not None:
289
338
  self._exec_event_slm_image(event.slm_image)
290
339
 
340
+ mmcore = self.mmcore
291
341
  try:
292
- self._mmc.snapImage()
342
+ mmcore.snapImage()
293
343
  # taking event time after snapImage includes exposure time
294
344
  # not sure that's what we want, but it's currently consistent with the
295
345
  # timing of the sequenced event runner (where Elapsed_Time_ms is taken after
@@ -300,21 +350,21 @@ class MDAEngine(PMDAEngine):
300
350
  logger.warning("Failed to snap image. %s", e)
301
351
  return
302
352
  if not event.keep_shutter_open:
303
- self._mmc.setShutterOpen(False)
353
+ mmcore.setShutterOpen(False)
304
354
 
305
355
  # most cameras will only have a single channel
306
356
  # but Multi-camera may have multiple, and we need to retrieve a buffer for each
307
- for cam in range(self._mmc.getNumberOfCameraChannels()):
357
+ for cam in range(mmcore.getNumberOfCameraChannels()):
308
358
  meta = self.get_frame_metadata(
309
359
  event,
310
360
  runner_time_ms=event_time_ms,
311
- camera_device=self._mmc.getPhysicalCameraDevice(cam),
361
+ camera_device=mmcore.getPhysicalCameraDevice(cam),
312
362
  include_position=self._include_frame_position_metadata is not False,
313
363
  )
314
364
  # Note, the third element is actually a MutableMapping, but mypy doesn't
315
365
  # see TypedDict as a subclass of MutableMapping yet.
316
366
  # https://github.com/python/mypy/issues/4976
317
- yield ImagePayload(self._mmc.getImage(cam), event, meta) # type: ignore[misc]
367
+ yield ImagePayload(mmcore.getImage(cam), event, meta) # type: ignore[misc]
318
368
 
319
369
  def get_frame_metadata(
320
370
  self,
@@ -329,7 +379,7 @@ class MDAEngine(PMDAEngine):
329
379
  else:
330
380
  prop_values = ()
331
381
  return frame_metadata(
332
- self._mmc,
382
+ self.mmcore,
333
383
  cached=True,
334
384
  runner_time_ms=runner_time_ms,
335
385
  camera_device=camera_device,
@@ -342,14 +392,14 @@ class MDAEngine(PMDAEngine):
342
392
  """Teardown state of system (hardware, etc.) after `event`."""
343
393
  # autoshutter was set at the beginning of the sequence, and this event
344
394
  # doesn't want to leave the shutter open. Re-enable autoshutter.
345
- core = self._mmc
395
+ core = self.mmcore
346
396
  if not event.keep_shutter_open and self._autoshutter_was_set:
347
397
  core.setAutoShutter(True)
348
398
  # FIXME: this may not be hitting as intended...
349
399
  # https://github.com/pymmcore-plus/pymmcore-plus/pull/353#issuecomment-2159176491
350
400
  if isinstance(event, SequencedEvent):
351
401
  if event.exposure_sequence:
352
- core.stopExposureSequence(self._mmc.getCameraDevice())
402
+ core.stopExposureSequence(core.getCameraDevice())
353
403
  if event.x_sequence:
354
404
  core.stopXYStageSequence(core.getXYStageDevice())
355
405
  if event.z_sequence:
@@ -359,7 +409,133 @@ class MDAEngine(PMDAEngine):
359
409
 
360
410
  def teardown_sequence(self, sequence: MDASequence) -> None:
361
411
  """Perform any teardown required after the sequence has been executed."""
362
- pass
412
+ # restore initial state if enabled and state was captured
413
+ if self.restore_initial_state and self._initial_state:
414
+ self._restore_initial_state()
415
+
416
+ def _capture_state(self) -> StateDict:
417
+ """Capture the current hardware state for later restoration."""
418
+ state: StateDict = {}
419
+ if (core := self._mmcore_ref()) is None:
420
+ return state
421
+
422
+ try:
423
+ # capture XY position
424
+ if core.getXYStageDevice():
425
+ state["xy_position"] = core.getXYPosition()
426
+ except Exception as e:
427
+ logger.warning("Failed to capture XY position: %s", e)
428
+
429
+ try:
430
+ # capture Z position
431
+ if core.getFocusDevice():
432
+ state["z_position"] = core.getZPosition()
433
+ except Exception as e:
434
+ logger.warning("Failed to capture Z position: %s", e)
435
+
436
+ try:
437
+ state["exposure"] = core.getExposure()
438
+ except Exception as e:
439
+ logger.warning("Failed to capture exposure setting: %s", e)
440
+
441
+ # capture config group states
442
+ try:
443
+ state_groups = state.setdefault("config_groups", {})
444
+ for group in core.getAvailableConfigGroups():
445
+ if current_config := core.getCurrentConfig(group):
446
+ state_groups[group] = current_config
447
+ except Exception as e:
448
+ logger.warning("Failed to get available config groups: %s", e)
449
+
450
+ # capture autoshutter state
451
+ try:
452
+ state["autoshutter"] = core.getAutoShutter()
453
+ except Exception as e:
454
+ logger.warning("Failed to capture autoshutter state: %s", e)
455
+
456
+ return state
457
+
458
+ def _restore_initial_state(self) -> None:
459
+ """Restore the hardware state that was captured before the sequence."""
460
+ if not self._initial_state or (core := self._mmcore_ref()) is None:
461
+ return
462
+
463
+ # !!! We need to be careful about the order of Z and XY restoration:
464
+ #
465
+ # If FocusDirection is Unknown, we cannot safely restore Z *or* XY stage
466
+ # positions: we simply refuse and warn.
467
+ #
468
+ # If focus_dir is TowardSample, and we are restoring a Z-position that is
469
+ # *lower* than the current position or
470
+ # if focus_dir is AwayFromSample, and we are restoring a Z-position that is
471
+ # *higher* than the current position, then we need to move Z *before* moving XY,
472
+ # otherwise we may crash the objective into the sample.
473
+ # Otherwise, we should move XY first, then Z.
474
+ target_z = self._initial_state.get("z_position")
475
+ move_z_first = False
476
+ focus_dir = FocusDirection.Unknown
477
+ if target_z is not None and (focus_device := core.getFocusDevice()):
478
+ focus_dir = core.getFocusDirection(focus_device)
479
+ cur_z = core.getZPosition()
480
+ # focus_dir TowardSample => increasing position brings obj. closer to sample
481
+ if cur_z > target_z:
482
+ if focus_dir == FocusDirection.TowardSample:
483
+ move_z_first = True
484
+ elif focus_dir == FocusDirection.AwayFromSample:
485
+ move_z_first = True
486
+
487
+ if focus_dir == FocusDirection.Unknown:
488
+ _warn_focus_dir(focus_device)
489
+ else:
490
+
491
+ def _move_z() -> None:
492
+ if target_z is not None:
493
+ try:
494
+ if core.getFocusDevice():
495
+ core.setZPosition(target_z)
496
+ except Exception as e:
497
+ logger.warning("Failed to restore Z position: %s", e)
498
+
499
+ if move_z_first:
500
+ _move_z()
501
+
502
+ # restore XY position
503
+ if "xy_position" in self._initial_state:
504
+ try:
505
+ if core.getXYStageDevice():
506
+ core.setXYPosition(*self._initial_state["xy_position"])
507
+ except Exception as e:
508
+ logger.warning("Failed to restore XY position: %s", e)
509
+
510
+ if not move_z_first:
511
+ _move_z()
512
+
513
+ # restore exposure
514
+ if "exposure" in self._initial_state:
515
+ try:
516
+ core.setExposure(self._initial_state["exposure"])
517
+ except Exception as e:
518
+ logger.warning("Failed to restore exposure setting: %s", e)
519
+
520
+ # restore config group states
521
+ for key, value in self._initial_state.get("config_groups", {}).items():
522
+ try:
523
+ core.setConfig(key, value)
524
+ except Exception as e:
525
+ logger.warning(
526
+ "Failed to restore config group %s to %s: %s", key, value, e
527
+ )
528
+
529
+ # restore autoshutter state
530
+ if "autoshutter" in self._initial_state:
531
+ try:
532
+ core.setAutoShutter(self._initial_state["autoshutter"])
533
+ except Exception as e:
534
+ logger.warning("Failed to restore autoshutter state: %s", e)
535
+
536
+ core.waitForSystem()
537
+ # clear the state after restoration
538
+ self._initial_state = {}
363
539
 
364
540
  # ===================== Sequenced Events =====================
365
541
 
@@ -369,7 +545,7 @@ class MDAEngine(PMDAEngine):
369
545
  `SequencedEvent` is a special pymmcore-plus specific subclass of
370
546
  `useq.MDAEvent`.
371
547
  """
372
- core = self._mmc
548
+ core = self.mmcore
373
549
  if event.exposure_sequence:
374
550
  cam_device = core.getCameraDevice()
375
551
  with suppress(RuntimeError):
@@ -408,7 +584,7 @@ class MDAEngine(PMDAEngine):
408
584
  `setup_event`, which *is* part of the protocol), but it is made public
409
585
  in case a user wants to subclass this engine and override this method.
410
586
  """
411
- core = self._mmc
587
+ core = self.mmcore
412
588
 
413
589
  self._load_sequenced_event(event)
414
590
 
@@ -451,8 +627,9 @@ class MDAEngine(PMDAEngine):
451
627
  self, timeout: float = 5.0, poll_interval: float = 0.2
452
628
  ) -> None:
453
629
  tot = 0.0
454
- self._mmc.stopSequenceAcquisition()
455
- while self._mmc.isSequenceRunning():
630
+ core = self.mmcore
631
+ core.stopSequenceAcquisition()
632
+ while core.isSequenceRunning():
456
633
  time.sleep(poll_interval)
457
634
  tot += poll_interval
458
635
  if tot >= timeout:
@@ -483,23 +660,24 @@ class MDAEngine(PMDAEngine):
483
660
  if event.slm_image is not None:
484
661
  self._exec_event_slm_image(event.slm_image)
485
662
 
663
+ core = self.mmcore
486
664
  # Start sequence
487
665
  # Note that the overload of startSequenceAcquisition that takes a camera
488
666
  # label does NOT automatically initialize a circular buffer. So if this call
489
667
  # is changed to accept the camera in the future, that should be kept in mind.
490
- self._mmc.startSequenceAcquisition(
668
+ core.startSequenceAcquisition(
491
669
  n_events,
492
670
  0, # intervalMS # TODO: add support for this
493
671
  True, # stopOnOverflow
494
672
  )
495
673
  self.post_sequence_started(event)
496
674
 
497
- n_channels = self._mmc.getNumberOfCameraChannels()
675
+ n_channels = core.getNumberOfCameraChannels()
498
676
  count = 0
499
677
  iter_events = product(event.events, range(n_channels))
500
678
  # block until the sequence is done, popping images in the meantime
501
- while self._mmc.isSequenceRunning():
502
- if remaining := self._mmc.getRemainingImageCount():
679
+ while core.isSequenceRunning():
680
+ if remaining := core.getRemainingImageCount():
503
681
  yield self._next_seqimg_payload(
504
682
  *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms
505
683
  )
@@ -507,10 +685,10 @@ class MDAEngine(PMDAEngine):
507
685
  else:
508
686
  time.sleep(0.001)
509
687
 
510
- if self._mmc.isBufferOverflowed(): # pragma: no cover
688
+ if core.isBufferOverflowed(): # pragma: no cover
511
689
  raise MemoryError("Buffer overflowed")
512
690
 
513
- while remaining := self._mmc.getRemainingImageCount():
691
+ while remaining := core.getRemainingImageCount():
514
692
  yield self._next_seqimg_payload(
515
693
  *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms
516
694
  )
@@ -536,7 +714,8 @@ class MDAEngine(PMDAEngine):
536
714
  ) -> PImagePayload:
537
715
  """Grab next image from the circular buffer and return it as an ImagePayload."""
538
716
  _slice = 0 # ?
539
- img, mm_meta = self._mmc.popNextImageAndMD(channel, _slice)
717
+ core = self.mmcore
718
+ img, mm_meta = core.popNextImageAndMD(channel, _slice)
540
719
  try:
541
720
  seq_time = float(mm_meta.get(Keyword.Elapsed_Time_ms))
542
721
  except Exception:
@@ -548,7 +727,7 @@ class MDAEngine(PMDAEngine):
548
727
  # see: https://github.com/micro-manager/mmCoreAndDevices/pull/468
549
728
  camera_device = mm_meta.GetSingleTag("Camera").GetValue()
550
729
  except Exception:
551
- camera_device = self._mmc.getPhysicalCameraDevice(channel)
730
+ camera_device = core.getPhysicalCameraDevice(channel)
552
731
 
553
732
  # TODO: determine whether we want to try to populate changing property values
554
733
  # during the course of a triggered sequence
@@ -573,26 +752,27 @@ class MDAEngine(PMDAEngine):
573
752
 
574
753
  Returns the change in ZPosition that occurred during the autofocus event.
575
754
  """
755
+ core = self.mmcore
576
756
  # switch off autofocus device if it is on
577
- self._mmc.enableContinuousFocus(False)
757
+ core.enableContinuousFocus(False)
578
758
 
579
759
  if action.autofocus_motor_offset is not None:
580
760
  # set the autofocus device offset
581
761
  # if name is given explicitly, use it, otherwise use setAutoFocusOffset
582
762
  # (see docs for setAutoFocusOffset for additional details)
583
763
  if name := getattr(action, "autofocus_device_name", None):
584
- self._mmc.setPosition(name, action.autofocus_motor_offset)
764
+ core.setPosition(name, action.autofocus_motor_offset)
585
765
  else:
586
- self._mmc.setAutoFocusOffset(action.autofocus_motor_offset)
587
- self._mmc.waitForSystem()
766
+ core.setAutoFocusOffset(action.autofocus_motor_offset)
767
+ core.waitForSystem()
588
768
 
589
769
  @retry(exceptions=RuntimeError, tries=action.max_retries, logger=logger.warning)
590
770
  def _perform_full_focus(previous_z: float) -> float:
591
- self._mmc.fullFocus()
592
- self._mmc.waitForSystem()
593
- return self._mmc.getZPosition() - previous_z
771
+ core.fullFocus()
772
+ core.waitForSystem()
773
+ return core.getZPosition() - previous_z
594
774
 
595
- return _perform_full_focus(self._mmc.getZPosition())
775
+ return _perform_full_focus(core.getZPosition())
596
776
 
597
777
  def _set_event_xy_position(self, event: MDAEvent) -> None:
598
778
  event_x, event_y = event.x_pos, event.y_pos
@@ -600,13 +780,14 @@ class MDAEngine(PMDAEngine):
600
780
  if event_x is None and event_y is None:
601
781
  return
602
782
 
783
+ core = self.mmcore
603
784
  # skip if no XY stage device is found
604
- if not self._mmc.getXYStageDevice():
785
+ if not core.getXYStageDevice():
605
786
  logger.warning("No XY stage device found. Cannot set XY position.")
606
787
  return
607
788
 
608
789
  # Retrieve the last commanded XY position.
609
- last_x, last_y = self._mmc._last_xy_position.get(None) or (None, None) # noqa: SLF001
790
+ last_x, last_y = core._last_xy_position.get(None) or (None, None) # noqa: SLF001
610
791
  if (
611
792
  not self.force_set_xy_position
612
793
  and (event_x is None or event_x == last_x)
@@ -615,12 +796,12 @@ class MDAEngine(PMDAEngine):
615
796
  return
616
797
 
617
798
  if event_x is None or event_y is None:
618
- cur_x, cur_y = self._mmc.getXYPosition()
799
+ cur_x, cur_y = core.getXYPosition()
619
800
  event_x = cur_x if event_x is None else event_x
620
801
  event_y = cur_y if event_y is None else event_y
621
802
 
622
803
  try:
623
- self._mmc.setXYPosition(event_x, event_y)
804
+ core.setXYPosition(event_x, event_y)
624
805
  except Exception as e:
625
806
  logger.warning("Failed to set XY position. %s", e)
626
807
 
@@ -631,32 +812,33 @@ class MDAEngine(PMDAEngine):
631
812
  # comparison with _last_config is a fast/rough check ... which may miss subtle
632
813
  # differences if device properties have been individually set in the meantime.
633
814
  # could also compare to the system state, with:
634
- # data = self._mmc.getConfigData(ch.group, ch.config)
635
- # if self._mmc.getSystemStateCache().isConfigurationIncluded(data):
815
+ # data = self.mmcore.getConfigData(ch.group, ch.config)
816
+ # if self.mmcore.getSystemStateCache().isConfigurationIncluded(data):
636
817
  # ...
637
- if (ch.group, ch.config) != self._mmc._last_config: # noqa: SLF001
818
+ if (ch.group, ch.config) != self.mmcore._last_config: # noqa: SLF001
638
819
  try:
639
- self._mmc.setConfig(ch.group, ch.config)
820
+ self.mmcore.setConfig(ch.group, ch.config)
640
821
  except Exception as e:
641
822
  logger.warning("Failed to set channel. %s", e)
642
823
 
643
824
  def _set_event_z(self, event: MDAEvent) -> None:
644
825
  # skip if no Z stage device is found
645
- if not self._mmc.getFocusDevice():
826
+ if not self.mmcore.getFocusDevice():
646
827
  logger.warning("No Z stage device found. Cannot set Z position.")
647
828
  return
648
829
 
649
830
  p_idx = event.index.get("p", None)
650
831
  correction = self._z_correction.setdefault(p_idx, 0.0)
651
- self._mmc.setZPosition(cast("float", event.z_pos) + correction)
832
+ self.mmcore.setZPosition(cast("float", event.z_pos) + correction)
652
833
 
653
834
  def _set_event_slm_image(self, event: MDAEvent) -> None:
654
835
  if not event.slm_image:
655
836
  return
837
+ core = self.mmcore
656
838
  try:
657
839
  # Get the SLM device
658
840
  if not (
659
- slm_device := event.slm_image.device or self._mmc.getSLMDevice()
841
+ slm_device := event.slm_image.device or core.getSLMDevice()
660
842
  ): # pragma: no cover
661
843
  raise ValueError("No SLM device found or specified.")
662
844
 
@@ -666,14 +848,14 @@ class MDAEngine(PMDAEngine):
666
848
  if slm_array.ndim == 0:
667
849
  value = slm_array.item()
668
850
  if isinstance(value, bool):
669
- dev_name = self._mmc.getDeviceName(slm_device)
851
+ dev_name = core.getDeviceName(slm_device)
670
852
  on_value = _SLM_DEVICES_PIXEL_ON_VALUES.get(dev_name, 1)
671
853
  value = on_value if value else 0
672
- self._mmc.setSLMPixelsTo(slm_device, int(value))
854
+ core.setSLMPixelsTo(slm_device, int(value))
673
855
  elif slm_array.size == 3:
674
856
  # if it's a 3-valued array, we assume it's RGB
675
857
  r, g, b = slm_array.astype(int)
676
- self._mmc.setSLMPixelsTo(slm_device, r, g, b)
858
+ core.setSLMPixelsTo(slm_device, r, g, b)
677
859
  elif slm_array.ndim in (2, 3):
678
860
  # if it's a 2D/3D array, we assume it's an image
679
861
  # where 3D is RGB with shape (h, w, 3)
@@ -683,30 +865,31 @@ class MDAEngine(PMDAEngine):
683
865
  )
684
866
  # convert boolean on/off values to pixel values
685
867
  if slm_array.dtype == bool:
686
- dev_name = self._mmc.getDeviceName(slm_device)
868
+ dev_name = core.getDeviceName(slm_device)
687
869
  on_value = _SLM_DEVICES_PIXEL_ON_VALUES.get(dev_name, 1)
688
870
  slm_array = np.where(slm_array, on_value, 0).astype(np.uint8)
689
- self._mmc.setSLMImage(slm_device, slm_array)
871
+ core.setSLMImage(slm_device, slm_array)
690
872
  if event.slm_image.exposure:
691
- self._mmc.setSLMExposure(slm_device, event.slm_image.exposure)
873
+ core.setSLMExposure(slm_device, event.slm_image.exposure)
692
874
  except Exception as e:
693
875
  logger.warning("Failed to set SLM Image: %s", e)
694
876
 
695
877
  def _exec_event_slm_image(self, img: useq.SLMImage) -> None:
696
- if slm_device := (img.device or self._mmc.getSLMDevice()):
878
+ if slm_device := (img.device or self.mmcore.getSLMDevice()):
697
879
  try:
698
- self._mmc.displaySLMImage(slm_device)
880
+ self.mmcore.displaySLMImage(slm_device)
699
881
  except Exception as e:
700
882
  logger.warning("Failed to set SLM Image: %s", e)
701
883
 
702
884
  def _update_config_device_props(self) -> None:
703
885
  # store devices/props that make up each config group for faster lookup
704
886
  self._config_device_props.clear()
705
- for grp in self._mmc.getAvailableConfigGroups():
706
- for preset in self._mmc.getAvailableConfigs(grp):
887
+ core = self.mmcore
888
+ for grp in core.getAvailableConfigGroups():
889
+ for preset in core.getAvailableConfigs(grp):
707
890
  # ordered/unique list of (device, property) tuples for each group
708
891
  self._config_device_props[grp] = tuple(
709
- {(i[0], i[1]): None for i in self._mmc.getConfigData(grp, preset)}
892
+ {(i[0], i[1]): None for i in core.getConfigData(grp, preset)}
710
893
  )
711
894
 
712
895
  def _get_current_props(self, *groups: str) -> tuple[PropertyValue, ...]:
@@ -721,7 +904,7 @@ class MDAEngine(PMDAEngine):
721
904
  {
722
905
  "dev": dev,
723
906
  "prop": prop,
724
- "val": self._mmc.getPropertyFromCache(dev, prop),
907
+ "val": self.mmcore.getPropertyFromCache(dev, prop),
725
908
  }
726
909
  for group in groups
727
910
  if (dev_props := self._config_device_props.get(group))
@@ -733,3 +916,15 @@ class ImagePayload(NamedTuple):
733
916
  image: NDArray
734
917
  event: MDAEvent
735
918
  metadata: FrameMetaV1 | SummaryMetaV1
919
+
920
+
921
+ @cache
922
+ def _warn_focus_dir(focus_device: str) -> None:
923
+ warnings.warn(
924
+ "Focus direction is unknown: refusing to restore initial XYZ position "
925
+ "for safety reasons. Please set FocusDirection in your config file:\n\n"
926
+ f" FocusDirection,{focus_device},<1 or -1>\n\n"
927
+ "Or use the `Hardware Configuration Wizard > Stage Focus Direction`",
928
+ stacklevel=3,
929
+ category=RuntimeWarning,
930
+ )