pymmcore-plus 0.14.0__py3-none-any.whl → 0.15.2__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 (31) hide show
  1. pymmcore_plus/__init__.py +25 -0
  2. pymmcore_plus/_ipy_completion.py +363 -0
  3. pymmcore_plus/_pymmcore.py +4 -2
  4. pymmcore_plus/core/_constants.py +25 -3
  5. pymmcore_plus/core/_mmcore_plus.py +110 -55
  6. pymmcore_plus/core/_sequencing.py +1 -1
  7. pymmcore_plus/core/events/_deprecated.py +67 -0
  8. pymmcore_plus/core/events/_protocol.py +64 -39
  9. pymmcore_plus/core/events/_psygnal.py +35 -6
  10. pymmcore_plus/core/events/_qsignals.py +34 -6
  11. pymmcore_plus/experimental/unicore/__init__.py +12 -2
  12. pymmcore_plus/experimental/unicore/_device_manager.py +1 -1
  13. pymmcore_plus/experimental/unicore/_proxy.py +20 -3
  14. pymmcore_plus/experimental/unicore/core/__init__.py +0 -0
  15. pymmcore_plus/experimental/unicore/core/_sequence_buffer.py +314 -0
  16. pymmcore_plus/experimental/unicore/core/_unicore.py +1769 -0
  17. pymmcore_plus/experimental/unicore/devices/_camera.py +201 -0
  18. pymmcore_plus/experimental/unicore/devices/{_device.py → _device_base.py} +54 -28
  19. pymmcore_plus/experimental/unicore/devices/_generic_device.py +12 -0
  20. pymmcore_plus/experimental/unicore/devices/_properties.py +9 -2
  21. pymmcore_plus/experimental/unicore/devices/_shutter.py +30 -0
  22. pymmcore_plus/experimental/unicore/devices/_slm.py +82 -0
  23. pymmcore_plus/experimental/unicore/devices/_stage.py +1 -1
  24. pymmcore_plus/experimental/unicore/devices/_state.py +152 -0
  25. pymmcore_plus/mda/events/_protocol.py +8 -8
  26. {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/METADATA +2 -2
  27. {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/RECORD +30 -21
  28. pymmcore_plus/experimental/unicore/_unicore.py +0 -703
  29. {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/WHEEL +0 -0
  30. {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/entry_points.txt +0 -0
  31. {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1769 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import warnings
5
+ from collections.abc import Iterator, MutableMapping, Sequence
6
+ from contextlib import suppress
7
+ from datetime import datetime
8
+ from itertools import count
9
+ from time import perf_counter_ns
10
+ from typing import (
11
+ TYPE_CHECKING,
12
+ Any,
13
+ Callable,
14
+ Literal,
15
+ TypeVar,
16
+ cast,
17
+ overload,
18
+ )
19
+
20
+ import numpy as np
21
+
22
+ import pymmcore_plus._pymmcore as pymmcore
23
+ from pymmcore_plus.core import CMMCorePlus, DeviceType, Keyword
24
+ from pymmcore_plus.core import Keyword as KW
25
+ from pymmcore_plus.core._constants import PixelType
26
+ from pymmcore_plus.experimental.unicore._device_manager import PyDeviceManager
27
+ from pymmcore_plus.experimental.unicore._proxy import create_core_proxy
28
+ from pymmcore_plus.experimental.unicore.devices._camera import CameraDevice
29
+ from pymmcore_plus.experimental.unicore.devices._device_base import Device
30
+ from pymmcore_plus.experimental.unicore.devices._shutter import ShutterDevice
31
+ from pymmcore_plus.experimental.unicore.devices._slm import SLMDevice
32
+ from pymmcore_plus.experimental.unicore.devices._stage import XYStageDevice, _BaseStage
33
+ from pymmcore_plus.experimental.unicore.devices._state import StateDevice
34
+
35
+ from ._sequence_buffer import SequenceBuffer
36
+
37
+ if TYPE_CHECKING:
38
+ from collections.abc import Mapping, Sequence
39
+ from typing import Literal, NewType
40
+
41
+ from numpy.typing import DTypeLike
42
+ from pymmcore import (
43
+ AdapterName,
44
+ AffineTuple,
45
+ DeviceLabel,
46
+ DeviceName,
47
+ PropertyName,
48
+ StateLabel,
49
+ )
50
+
51
+ from pymmcore_plus.core._constants import DeviceInitializationState, PropertyType
52
+
53
+ PyDeviceLabel = NewType("PyDeviceLabel", DeviceLabel)
54
+ _T = TypeVar("_T")
55
+
56
+
57
+ class BufferOverflowStop(Exception):
58
+ """Exception raised to signal graceful stop on buffer overflow."""
59
+
60
+
61
+ CURRENT = {
62
+ KW.CoreCamera: None,
63
+ KW.CoreShutter: None,
64
+ KW.CoreFocus: None,
65
+ KW.CoreXYStage: None,
66
+ KW.CoreAutoFocus: None,
67
+ KW.CoreSLM: None,
68
+ KW.CoreGalvo: None,
69
+ }
70
+
71
+
72
+ class _CoreDevice:
73
+ """A virtual core device.
74
+
75
+ This mirrors the pattern used in CMMCore, where there is a virtual "core" device
76
+ that maintains state about various "current" (real) devices. When a call is made to
77
+ `setSomeThing()` without specifying a device label, the CoreDevice is used to
78
+ determine which real device to use.
79
+ """
80
+
81
+ def __init__(self, state_cache: PropertyStateCache) -> None:
82
+ self._state_cache = state_cache
83
+ self._pycurrent: dict[Keyword, PyDeviceLabel | None] = {}
84
+ self.reset_current()
85
+
86
+ def reset_current(self) -> None:
87
+ self._pycurrent.update(CURRENT)
88
+
89
+ def current(self, keyword: Keyword) -> PyDeviceLabel | None:
90
+ return self._pycurrent[keyword]
91
+
92
+ def set_current(self, keyword: Keyword, label: str | None) -> None:
93
+ self._pycurrent[keyword] = cast("PyDeviceLabel", label)
94
+ self._state_cache[(KW.CoreDevice, keyword)] = label
95
+
96
+
97
+ _DEFAULT_BUFFER_SIZE_MB: int = 1000
98
+
99
+
100
+ class UniMMCore(CMMCorePlus):
101
+ """Unified Core object that first checks for python, then C++ devices."""
102
+
103
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
104
+ self._pydevices = PyDeviceManager() # manager for python devices
105
+ self._state_cache = PropertyStateCache() # threadsafe cache for property states
106
+ self._pycore = _CoreDevice(self._state_cache) # virtual core for python
107
+ self._stop_event: threading.Event = threading.Event()
108
+ self._acquisition_thread: AcquisitionThread | None = None # TODO: implement
109
+ self._seq_buffer = SequenceBuffer(size_mb=_DEFAULT_BUFFER_SIZE_MB)
110
+
111
+ super().__init__(*args, **kwargs)
112
+
113
+ def _set_current_if_pydevice(self, keyword: Keyword, label: str) -> str:
114
+ """Helper function to set the current core device if it is a python device.
115
+
116
+ If the label is a python device, the current device is set and the label is
117
+ cleared (in preparation for calling `super().setDevice()`), otherwise the
118
+ label is returned unchanged.
119
+ """
120
+ if label in self._pydevices:
121
+ self._pycore.set_current(keyword, label)
122
+ label = ""
123
+ elif not label:
124
+ self._pycore.set_current(keyword, None)
125
+ return label
126
+
127
+ # -----------------------------------------------------------------------
128
+ # ------------------------ General Core methods ------------------------
129
+ # -----------------------------------------------------------------------
130
+
131
+ def reset(self) -> None:
132
+ with suppress(TimeoutError):
133
+ self.waitForSystem()
134
+ self.unloadAllDevices()
135
+ self._pycore.reset_current()
136
+ super().reset()
137
+
138
+ # ------------------------------------------------------------------------
139
+ # ----------------- Functionality for All Devices ------------------------
140
+ # ------------------------------------------------------------------------
141
+
142
+ def loadDevice(
143
+ self, label: str, moduleName: AdapterName | str, deviceName: DeviceName | str
144
+ ) -> None:
145
+ """Loads a device from the plugin library, or python module.
146
+
147
+ In the standard MM case, this will load a device from the plugin library:
148
+
149
+ ```python
150
+ core.loadDevice("cam", "DemoCamera", "DCam")
151
+ ```
152
+
153
+ For python devices, this will load a device from a python module:
154
+
155
+ ```python
156
+ core.loadDevice("pydev", "package.module", "DeviceClass")
157
+ ```
158
+
159
+ """
160
+ try:
161
+ CMMCorePlus.loadDevice(self, label, moduleName, deviceName)
162
+ except RuntimeError as e:
163
+ # it was a C++ device, should have worked ... raise the error
164
+ if moduleName not in super().getDeviceAdapterNames():
165
+ pydev = self._get_py_device_instance(moduleName, deviceName)
166
+ self.loadPyDevice(label, pydev)
167
+ return
168
+ if exc := self._load_error_with_info(label, moduleName, deviceName, str(e)):
169
+ raise exc from e
170
+
171
+ def _get_py_device_instance(self, module_name: str, cls_name: str) -> Device:
172
+ """Import and instantiate a python device from `module_name.cls_name`."""
173
+ try:
174
+ module = __import__(module_name, fromlist=[cls_name])
175
+ except ImportError as e:
176
+ raise type(e)(
177
+ f"{module_name!r} is not a known Micro-manager DeviceAdapter, or "
178
+ "an importable python module "
179
+ ) from e
180
+ try:
181
+ cls = getattr(module, cls_name)
182
+ except AttributeError as e:
183
+ raise AttributeError(
184
+ f"Could not find class {cls_name!r} in python module {module_name!r}"
185
+ ) from e
186
+ if isinstance(cls, type) and issubclass(cls, Device):
187
+ return cls()
188
+ raise TypeError(f"{cls_name} is not a subclass of Device")
189
+
190
+ def loadPyDevice(self, label: str, device: Device) -> None:
191
+ """Load a `unicore.Device` as a python device.
192
+
193
+ This API allows you to create python-side Device objects that can be used in
194
+ tandem with the C++ devices. Whenever a method is called that would normally
195
+ interact with a C++ device, this class will first check if a python device with
196
+ the same label exists, and if so, use that instead.
197
+
198
+ Parameters
199
+ ----------
200
+ label : str
201
+ The label to assign to the device.
202
+ device : unicore.Device
203
+ The device object to load. Use the appropriate subclass of `Device` for the
204
+ type of device you are creating.
205
+ """
206
+ if label in self.getLoadedDevices():
207
+ raise ValueError(f"The specified device label {label!r} is already in use")
208
+ self._pydevices.load(label, device, create_core_proxy(self))
209
+
210
+ load_py_device = loadPyDevice
211
+
212
+ def unloadDevice(self, label: DeviceLabel | str) -> None:
213
+ if label not in self._pydevices: # pragma: no cover
214
+ return super().unloadDevice(label)
215
+ self._pydevices.unload(label)
216
+
217
+ def unloadAllDevices(self) -> None:
218
+ self._pydevices.unload_all()
219
+ super().unloadAllDevices()
220
+
221
+ def initializeDevice(self, label: DeviceLabel | str) -> None:
222
+ if label not in self._pydevices: # pragma: no cover
223
+ return super().initializeDevice(label)
224
+ return self._pydevices.initialize(label)
225
+
226
+ def initializeAllDevices(self) -> None:
227
+ super().initializeAllDevices()
228
+ return self._pydevices.initialize_all()
229
+
230
+ def getDeviceInitializationState(self, label: str) -> DeviceInitializationState:
231
+ if label not in self._pydevices: # pragma: no cover
232
+ return super().getDeviceInitializationState(label)
233
+ return self._pydevices.get_initialization_state(label)
234
+
235
+ def getLoadedDevices(self) -> tuple[DeviceLabel, ...]:
236
+ return tuple(self._pydevices) + tuple(super().getLoadedDevices())
237
+
238
+ def getLoadedDevicesOfType(self, devType: int) -> tuple[DeviceLabel, ...]:
239
+ pydevs = self._pydevices.get_labels_of_type(devType)
240
+ return pydevs + super().getLoadedDevicesOfType(devType)
241
+
242
+ def getDeviceType(self, label: str) -> DeviceType:
243
+ if label not in self._pydevices: # pragma: no cover
244
+ return super().getDeviceType(label)
245
+ return self._pydevices[label].type()
246
+
247
+ def getDeviceLibrary(self, label: DeviceLabel | str) -> AdapterName:
248
+ if label not in self._pydevices: # pragma: no cover
249
+ return super().getDeviceLibrary(label)
250
+ return cast("AdapterName", self._pydevices[label].__module__)
251
+
252
+ def getDeviceName(self, label: DeviceLabel | str) -> DeviceName:
253
+ if label not in self._pydevices: # pragma: no cover
254
+ return super().getDeviceName(label)
255
+ return cast("DeviceName", self._pydevices[label].name())
256
+
257
+ def getDeviceDescription(self, label: DeviceLabel | str) -> str:
258
+ if label not in self._pydevices: # pragma: no cover
259
+ return super().getDeviceDescription(label)
260
+ return self._pydevices[label].description()
261
+
262
+ # ---------------------------- Properties ---------------------------
263
+
264
+ def getDevicePropertyNames(
265
+ self, label: DeviceLabel | str
266
+ ) -> tuple[PropertyName, ...]:
267
+ if label not in self._pydevices: # pragma: no cover
268
+ return super().getDevicePropertyNames(label)
269
+ names = tuple(self._pydevices[label].get_property_names())
270
+ return cast("tuple[PropertyName, ...]", names)
271
+
272
+ def hasProperty(
273
+ self, label: DeviceLabel | str, propName: PropertyName | str
274
+ ) -> bool:
275
+ if label not in self._pydevices: # pragma: no cover
276
+ return super().hasProperty(label, propName)
277
+ return self._pydevices[label].has_property(propName)
278
+
279
+ def getProperty(
280
+ self, label: DeviceLabel | str, propName: PropertyName | str
281
+ ) -> Any: # broadening to Any, because pydevices can return non-string values?
282
+ if label not in self._pydevices: # pragma: no cover
283
+ return super().getProperty(label, propName)
284
+ with self._pydevices[label] as dev:
285
+ value = dev.get_property_value(propName)
286
+ self._state_cache[(label, propName)] = value
287
+ return value
288
+
289
+ def getPropertyFromCache(
290
+ self, deviceLabel: DeviceLabel | str, propName: PropertyName | str
291
+ ) -> Any:
292
+ if deviceLabel not in self._pydevices: # pragma: no cover
293
+ return super().getPropertyFromCache(deviceLabel, propName)
294
+ return self._state_cache[(deviceLabel, propName)]
295
+
296
+ def setProperty(
297
+ self, label: str, propName: str, propValue: bool | float | int | str
298
+ ) -> None:
299
+ if label not in self._pydevices: # pragma: no cover
300
+ return super().setProperty(label, propName, propValue)
301
+ with self._pydevices[label] as dev:
302
+ dev.set_property_value(propName, propValue)
303
+ self._state_cache[(label, propName)] = propValue
304
+
305
+ def getPropertyType(self, label: str, propName: str) -> PropertyType:
306
+ if label not in self._pydevices: # pragma: no cover
307
+ return super().getPropertyType(label, propName)
308
+ return self._pydevices[label].get_property_info(propName).type
309
+
310
+ def hasPropertyLimits(
311
+ self, label: DeviceLabel | str, propName: PropertyName | str
312
+ ) -> bool:
313
+ if label not in self._pydevices: # pragma: no cover
314
+ return super().hasPropertyLimits(label, propName)
315
+ with self._pydevices[label] as dev:
316
+ return dev.get_property_info(propName).limits is not None
317
+
318
+ def getPropertyLowerLimit(
319
+ self, label: DeviceLabel | str, propName: PropertyName | str
320
+ ) -> float:
321
+ if label not in self._pydevices: # pragma: no cover
322
+ return super().getPropertyLowerLimit(label, propName)
323
+ with self._pydevices[label] as dev:
324
+ if lims := dev.get_property_info(propName).limits:
325
+ return lims[0]
326
+ return 0
327
+
328
+ def getPropertyUpperLimit(
329
+ self, label: DeviceLabel | str, propName: PropertyName | str
330
+ ) -> float:
331
+ if label not in self._pydevices: # pragma: no cover
332
+ return super().getPropertyUpperLimit(label, propName)
333
+ with self._pydevices[label] as dev:
334
+ if lims := dev.get_property_info(propName).limits:
335
+ return lims[1]
336
+ return 0
337
+
338
+ def getAllowedPropertyValues(
339
+ self, label: DeviceLabel | str, propName: PropertyName | str
340
+ ) -> tuple[str, ...]:
341
+ if label not in self._pydevices: # pragma: no cover
342
+ return super().getAllowedPropertyValues(label, propName)
343
+ with self._pydevices[label] as dev:
344
+ return tuple(dev.get_property_info(propName).allowed_values or ())
345
+
346
+ def isPropertyPreInit(
347
+ self, label: DeviceLabel | str, propName: PropertyName | str
348
+ ) -> bool:
349
+ if label not in self._pydevices: # pragma: no cover
350
+ return super().isPropertyPreInit(label, propName)
351
+ with self._pydevices[label] as dev:
352
+ return dev.get_property_info(propName).is_pre_init
353
+
354
+ def isPropertyReadOnly(
355
+ self, label: DeviceLabel | str, propName: PropertyName | str
356
+ ) -> bool:
357
+ if label not in self._pydevices: # pragma: no cover
358
+ return super().isPropertyReadOnly(label, propName)
359
+ with self._pydevices[label] as dev:
360
+ return dev.is_property_read_only(propName)
361
+
362
+ def isPropertySequenceable(
363
+ self, label: DeviceLabel | str, propName: PropertyName | str
364
+ ) -> bool:
365
+ if label not in self._pydevices: # pragma: no cover
366
+ return super().isPropertySequenceable(label, propName)
367
+ with self._pydevices[label] as dev:
368
+ return dev.is_property_sequenceable(propName)
369
+
370
+ def getPropertySequenceMaxLength(
371
+ self, label: DeviceLabel | str, propName: PropertyName | str
372
+ ) -> int:
373
+ if label not in self._pydevices: # pragma: no cover
374
+ return super().getPropertySequenceMaxLength(label, propName)
375
+ with self._pydevices[label] as dev:
376
+ return dev.get_property_info(propName).sequence_max_length
377
+
378
+ def loadPropertySequence(
379
+ self,
380
+ label: DeviceLabel | str,
381
+ propName: PropertyName | str,
382
+ eventSequence: Sequence[Any],
383
+ ) -> None:
384
+ if label not in self._pydevices: # pragma: no cover
385
+ return super().loadPropertySequence(label, propName, eventSequence)
386
+ with self._pydevices[label] as dev:
387
+ dev.load_property_sequence(propName, eventSequence)
388
+
389
+ def startPropertySequence(
390
+ self, label: DeviceLabel | str, propName: PropertyName | str
391
+ ) -> None:
392
+ if label not in self._pydevices: # pragma: no cover
393
+ return super().startPropertySequence(label, propName)
394
+ with self._pydevices[label] as dev:
395
+ dev.start_property_sequence(propName)
396
+
397
+ def stopPropertySequence(
398
+ self, label: DeviceLabel | str, propName: PropertyName | str
399
+ ) -> None:
400
+ if label not in self._pydevices: # pragma: no cover
401
+ return super().stopPropertySequence(label, propName)
402
+ with self._pydevices[label] as dev:
403
+ dev.stop_property_sequence(propName)
404
+
405
+ # ------------------------------ Ready State ----------------------------
406
+
407
+ def deviceBusy(self, label: DeviceLabel | str) -> bool:
408
+ if label not in self._pydevices: # pragma: no cover
409
+ return super().deviceBusy(label)
410
+ with self._pydevices[label] as dev:
411
+ return dev.busy()
412
+
413
+ def waitForDevice(self, label: DeviceLabel | str) -> None:
414
+ if label not in self._pydevices: # pragma: no cover
415
+ return super().waitForDevice(label)
416
+ self._pydevices.wait_for(label, self.getTimeoutMs())
417
+
418
+ # def waitForConfig
419
+
420
+ # probably only needed because C++ method is not virtual
421
+ def systemBusy(self) -> bool:
422
+ return self.deviceTypeBusy(DeviceType.AnyType)
423
+
424
+ # probably only needed because C++ method is not virtual
425
+ def waitForSystem(self) -> None:
426
+ self.waitForDeviceType(DeviceType.AnyType)
427
+
428
+ def waitForDeviceType(self, devType: int) -> None:
429
+ super().waitForDeviceType(devType)
430
+ self._pydevices.wait_for_device_type(devType, self.getTimeoutMs())
431
+
432
+ def deviceTypeBusy(self, devType: int) -> bool:
433
+ if super().deviceTypeBusy(devType):
434
+ return True # pragma: no cover
435
+
436
+ for label in self._pydevices.get_labels_of_type(devType):
437
+ with self._pydevices[label] as dev:
438
+ if dev.busy():
439
+ return True
440
+ return False
441
+
442
+ def getDeviceDelayMs(self, label: DeviceLabel | str) -> float:
443
+ if label not in self._pydevices: # pragma: no cover
444
+ return super().getDeviceDelayMs(label)
445
+ return 0 # pydevices don't yet support delays
446
+
447
+ def setDeviceDelayMs(self, label: DeviceLabel | str, delayMs: float) -> None:
448
+ if label not in self._pydevices: # pragma: no cover
449
+ return super().setDeviceDelayMs(label, delayMs)
450
+ if delayMs != 0: # pragma: no cover
451
+ raise NotImplementedError("Python devices do not support delays")
452
+
453
+ def usesDeviceDelay(self, label: DeviceLabel | str) -> bool:
454
+ if label not in self._pydevices: # pragma: no cover
455
+ return super().usesDeviceDelay(label)
456
+ return False
457
+
458
+ # ########################################################################
459
+ # ---------------------------- XYStageDevice -----------------------------
460
+ # ########################################################################
461
+
462
+ def setXYStageDevice(self, xyStageLabel: DeviceLabel | str) -> None:
463
+ label = self._set_current_if_pydevice(KW.CoreXYStage, xyStageLabel)
464
+ super().setXYStageDevice(label)
465
+
466
+ def getXYStageDevice(self) -> DeviceLabel | Literal[""]:
467
+ """Returns the label of the currently selected XYStage device.
468
+
469
+ Returns empty string if no XYStage device is selected.
470
+ """
471
+ return self._pycore.current(KW.CoreXYStage) or super().getXYStageDevice()
472
+
473
+ @overload
474
+ def setXYPosition(self, x: float, y: float, /) -> None: ...
475
+ @overload
476
+ def setXYPosition(
477
+ self, xyStageLabel: DeviceLabel | str, x: float, y: float, /
478
+ ) -> None: ...
479
+ def setXYPosition(self, *args: Any) -> None:
480
+ """Sets the position of the XY stage in microns."""
481
+ label, args = _ensure_label(args, min_args=3, getter=self.getXYStageDevice)
482
+ if label not in self._pydevices: # pragma: no cover
483
+ return super().setXYPosition(label, *args)
484
+
485
+ with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
486
+ dev.set_position_um(*args)
487
+
488
+ @overload
489
+ def getXYPosition(self) -> tuple[float, float]: ...
490
+ @overload
491
+ def getXYPosition(self, xyStageLabel: DeviceLabel | str) -> tuple[float, float]: ...
492
+ def getXYPosition(
493
+ self, xyStageLabel: DeviceLabel | str = ""
494
+ ) -> tuple[float, float]:
495
+ """Obtains the current position of the XY stage in microns."""
496
+ label = xyStageLabel or self.getXYStageDevice()
497
+ if label not in self._pydevices: # pragma: no cover
498
+ return tuple(super().getXYPosition(label)) # type: ignore
499
+
500
+ with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
501
+ return dev.get_position_um()
502
+
503
+ # reimplementation needed because the C++ method are not virtual
504
+ @overload
505
+ def getXPosition(self) -> float: ...
506
+ @overload
507
+ def getXPosition(self, xyStageLabel: DeviceLabel | str) -> float: ...
508
+ def getXPosition(self, xyStageLabel: DeviceLabel | str = "") -> float:
509
+ """Obtains the current position of the X axis of the XY stage in microns."""
510
+ return self.getXYPosition(xyStageLabel)[0]
511
+
512
+ # reimplementation needed because the C++ method are not virtual
513
+ @overload
514
+ def getYPosition(self) -> float: ...
515
+ @overload
516
+ def getYPosition(self, xyStageLabel: DeviceLabel | str) -> float: ...
517
+ def getYPosition(self, xyStageLabel: DeviceLabel | str = "") -> float:
518
+ """Obtains the current position of the Y axis of the XY stage in microns."""
519
+ return self.getXYPosition(xyStageLabel)[1]
520
+
521
+ def getXYStageSequenceMaxLength(self, xyStageLabel: DeviceLabel | str) -> int:
522
+ """Gets the maximum length of an XY stage's position sequence."""
523
+ if xyStageLabel not in self._pydevices: # pragma: no cover
524
+ return super().getXYStageSequenceMaxLength(xyStageLabel)
525
+ dev = self._pydevices.get_device_of_type(xyStageLabel, XYStageDevice)
526
+ return dev.get_sequence_max_length()
527
+
528
+ def isXYStageSequenceable(self, xyStageLabel: DeviceLabel | str) -> bool:
529
+ """Queries XY stage if it can be used in a sequence."""
530
+ if xyStageLabel not in self._pydevices: # pragma: no cover
531
+ return super().isXYStageSequenceable(xyStageLabel)
532
+ dev = self._pydevices.get_device_of_type(xyStageLabel, XYStageDevice)
533
+ return dev.is_sequenceable()
534
+
535
+ def loadXYStageSequence(
536
+ self,
537
+ xyStageLabel: DeviceLabel | str,
538
+ xSequence: Sequence[float],
539
+ ySequence: Sequence[float],
540
+ /,
541
+ ) -> None:
542
+ """Transfer a sequence of stage positions to the xy stage.
543
+
544
+ xSequence and ySequence must have the same length. This should only be called
545
+ for XY stages that are sequenceable
546
+ """
547
+ if xyStageLabel not in self._pydevices: # pragma: no cover
548
+ return super().loadXYStageSequence(xyStageLabel, xSequence, ySequence)
549
+ if len(xSequence) != len(ySequence):
550
+ raise ValueError("xSequence and ySequence must have the same length")
551
+ dev = self._pydevices.get_device_of_type(xyStageLabel, XYStageDevice)
552
+ seq = tuple(zip(xSequence, ySequence))
553
+ if len(seq) > dev.get_sequence_max_length():
554
+ raise ValueError(
555
+ f"Sequence is too long. Max length is {dev.get_sequence_max_length()}"
556
+ )
557
+ dev.send_sequence(seq)
558
+
559
+ @overload
560
+ def setOriginX(self) -> None: ...
561
+ @overload
562
+ def setOriginX(self, xyStageLabel: DeviceLabel | str) -> None: ...
563
+ def setOriginX(self, xyStageLabel: DeviceLabel | str = "") -> None:
564
+ """Zero the given XY stage's X coordinate at the current position."""
565
+ label = xyStageLabel or self.getXYStageDevice()
566
+ if label not in self._pydevices: # pragma: no cover
567
+ return super().setOriginX(label)
568
+
569
+ with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
570
+ dev.set_origin_x()
571
+
572
+ @overload
573
+ def setOriginY(self) -> None: ...
574
+ @overload
575
+ def setOriginY(self, xyStageLabel: DeviceLabel | str) -> None: ...
576
+ def setOriginY(self, xyStageLabel: DeviceLabel | str = "") -> None:
577
+ """Zero the given XY stage's Y coordinate at the current position."""
578
+ label = xyStageLabel or self.getXYStageDevice()
579
+ if label not in self._pydevices: # pragma: no cover
580
+ return super().setOriginY(label)
581
+
582
+ with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
583
+ dev.set_origin_y()
584
+
585
+ @overload
586
+ def setOriginXY(self) -> None: ...
587
+ @overload
588
+ def setOriginXY(self, xyStageLabel: DeviceLabel | str) -> None: ...
589
+ def setOriginXY(self, xyStageLabel: DeviceLabel | str = "") -> None:
590
+ """Zero the given XY stage's coordinates at the current position."""
591
+ label = xyStageLabel or self.getXYStageDevice()
592
+ if label not in self._pydevices: # pragma: no cover
593
+ return super().setOriginXY(label)
594
+
595
+ with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
596
+ dev.set_origin()
597
+
598
+ @overload
599
+ def setAdapterOriginXY(self, newXUm: float, newYUm: float, /) -> None: ...
600
+ @overload
601
+ def setAdapterOriginXY(
602
+ self, xyStageLabel: DeviceLabel | str, newXUm: float, newYUm: float, /
603
+ ) -> None: ...
604
+ def setAdapterOriginXY(self, *args: Any) -> None:
605
+ """Enable software translation of coordinates for the current XY stage.
606
+
607
+ The current position of the stage becomes (newXUm, newYUm). It is recommended
608
+ that setOriginXY() be used instead where available.
609
+ """
610
+ label, args = _ensure_label(args, min_args=3, getter=self.getXYStageDevice)
611
+ if label not in self._pydevices: # pragma: no cover
612
+ return super().setAdapterOriginXY(label, *args)
613
+
614
+ with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
615
+ dev.set_adapter_origin_um(*args)
616
+
617
+ @overload
618
+ def setRelativeXYPosition(self, dx: float, dy: float, /) -> None: ...
619
+ @overload
620
+ def setRelativeXYPosition(
621
+ self, xyStageLabel: DeviceLabel | str, dx: float, dy: float, /
622
+ ) -> None: ...
623
+ def setRelativeXYPosition(self, *args: Any) -> None:
624
+ """Sets the relative position of the XY stage in microns."""
625
+ label, args = _ensure_label(args, min_args=3, getter=self.getXYStageDevice)
626
+ if label not in self._pydevices: # pragma: no cover
627
+ return super().setRelativeXYPosition(label, *args)
628
+
629
+ with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
630
+ dev.set_relative_position_um(*args)
631
+
632
+ def startXYStageSequence(self, xyStageLabel: DeviceLabel | str) -> None:
633
+ """Starts an ongoing sequence of triggered events in an XY stage.
634
+
635
+ This should only be called for stages that are sequenceable
636
+ """
637
+ label = xyStageLabel or self.getXYStageDevice()
638
+ if label not in self._pydevices: # pragma: no cover
639
+ return super().startXYStageSequence(label)
640
+
641
+ with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
642
+ dev.start_sequence()
643
+
644
+ def stopXYStageSequence(self, xyStageLabel: DeviceLabel | str) -> None:
645
+ """Stops an ongoing sequence of triggered events in an XY stage.
646
+
647
+ This should only be called for stages that are sequenceable
648
+ """
649
+ label = xyStageLabel or self.getXYStageDevice()
650
+ if label not in self._pydevices: # pragma: no cover
651
+ return super().stopXYStageSequence(label)
652
+
653
+ with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
654
+ dev.stop_sequence()
655
+
656
+ # -----------------------------------------------------------------------
657
+ # ---------------------------- Any Stage --------------------------------
658
+ # -----------------------------------------------------------------------
659
+
660
+ def home(self, xyOrZStageLabel: DeviceLabel | str) -> None:
661
+ """Perform a hardware homing operation for an XY or focus/Z stage."""
662
+ if xyOrZStageLabel not in self._pydevices:
663
+ return super().home(xyOrZStageLabel)
664
+
665
+ dev = self._pydevices.get_device_of_type(xyOrZStageLabel, _BaseStage)
666
+ dev.home()
667
+
668
+ def stop(self, xyOrZStageLabel: DeviceLabel | str) -> None:
669
+ """Stop the XY or focus/Z stage."""
670
+ if xyOrZStageLabel not in self._pydevices:
671
+ return super().stop(xyOrZStageLabel)
672
+
673
+ dev = self._pydevices.get_device_of_type(xyOrZStageLabel, _BaseStage)
674
+ dev.stop()
675
+
676
+ # ########################################################################
677
+ # ------------------------ Camera Device Methods -------------------------
678
+ # ########################################################################
679
+
680
+ # --------------------------------------------------------------------- utils
681
+
682
+ def _py_camera(self, cameraLabel: str | None = None) -> CameraDevice | None:
683
+ """Return the *Python* Camera for ``label`` (or current), else ``None``."""
684
+ label = cameraLabel or self.getCameraDevice()
685
+ if label in self._pydevices:
686
+ return self._pydevices.get_device_of_type(label, CameraDevice)
687
+ return None
688
+
689
+ def setCameraDevice(self, cameraLabel: DeviceLabel | str) -> None:
690
+ """Set the camera device."""
691
+ label = self._set_current_if_pydevice(KW.CoreCamera, cameraLabel)
692
+ super().setCameraDevice(label)
693
+
694
+ def getCameraDevice(self) -> DeviceLabel | Literal[""]:
695
+ """Returns the label of the currently selected camera device.
696
+
697
+ Returns empty string if no camera device is selected.
698
+ """
699
+ return self._pycore.current(KW.CoreCamera) or super().getCameraDevice()
700
+
701
+ # --------------------------------------------------------------------- snap
702
+
703
+ _current_image_buffer: np.ndarray | None = None
704
+
705
+ def _do_snap_image(self) -> None:
706
+ if (cam := self._py_camera()) is None:
707
+ return pymmcore.CMMCore.snapImage(self)
708
+
709
+ buf = None
710
+
711
+ def _get_buffer(shape: Sequence[int], dtype: DTypeLike) -> np.ndarray:
712
+ """Get a buffer for the camera image."""
713
+ nonlocal buf
714
+ buf = np.empty(shape, dtype=dtype)
715
+ return buf
716
+
717
+ # synchronous call - consume one item from the generator
718
+ with cam:
719
+ for _ in cam.start_sequence(1, get_buffer=_get_buffer):
720
+ if buf is not None:
721
+ self._current_image_buffer = buf
722
+ else: # pragma: no cover # bad camera implementation
723
+ warnings.warn(
724
+ "Camera device did not provide an image buffer.",
725
+ RuntimeWarning,
726
+ stacklevel=2,
727
+ )
728
+ return
729
+
730
+ # --------------------------------------------------------------------- getImage
731
+
732
+ @overload
733
+ def getImage(self, *, fix: bool = True) -> np.ndarray: ...
734
+ @overload
735
+ def getImage(self, numChannel: int, *, fix: bool = True) -> np.ndarray: ...
736
+
737
+ def getImage(
738
+ self, numChannel: int | None = None, *, fix: bool = True
739
+ ) -> np.ndarray:
740
+ if self._py_camera() is None: # pragma: no cover
741
+ if numChannel is not None:
742
+ return super().getImage(numChannel, fix=fix)
743
+ return super().getImage(fix=fix)
744
+
745
+ if self._current_image_buffer is None:
746
+ raise RuntimeError(
747
+ "No image buffer available. Call snapImage() before calling getImage()."
748
+ )
749
+
750
+ return self._current_image_buffer
751
+
752
+ # ---------------------------------------------------------------- sequence common
753
+
754
+ def _start_sequence(
755
+ self, cam: CameraDevice, n_images: int | None, stop_on_overflow: bool
756
+ ) -> None:
757
+ """Initialise _seq state and call cam.start_sequence."""
758
+ shape, dtype = cam.shape(), np.dtype(cam.dtype())
759
+ camera_label = cam.get_label()
760
+
761
+ n_components = shape[2] if len(shape) > 2 else 1
762
+ base_meta: dict[str, Any] = {
763
+ KW.Binning: cam.get_property_value(KW.Binning),
764
+ KW.Metadata_CameraLabel: camera_label,
765
+ KW.Metadata_Height: str(shape[0]),
766
+ KW.Metadata_Width: str(shape[1]),
767
+ KW.Metadata_ROI_X: "0",
768
+ KW.Metadata_ROI_Y: "0",
769
+ KW.PixelType: PixelType.for_bytes(dtype.itemsize, n_components),
770
+ }
771
+
772
+ def get_buffer_with_overflow_handling(
773
+ shape: Sequence[int], dtype: DTypeLike
774
+ ) -> np.ndarray:
775
+ try:
776
+ return self._seq_buffer.acquire_slot(shape, dtype)
777
+ except BufferError:
778
+ if not stop_on_overflow: # we shouldn't get here...
779
+ raise # pragma: no cover
780
+ raise BufferOverflowStop() from None
781
+
782
+ # Keep track of images acquired for metadata and auto-stop
783
+ counter = count()
784
+
785
+ # Create metadata-injecting wrapper for finalize callback
786
+ def finalize_with_metadata(cam_meta: Mapping) -> None:
787
+ img_number = next(counter)
788
+ elapsed_ms = (perf_counter_ns() - start_time) / 1e6
789
+ received = datetime.now().isoformat(sep=" ")
790
+ self._seq_buffer.finalize_slot(
791
+ {
792
+ **base_meta,
793
+ **cam_meta,
794
+ KW.Metadata_TimeInCore: received,
795
+ KW.Metadata_ImageNumber: str(img_number),
796
+ KW.Elapsed_Time_ms: f"{elapsed_ms:.2f}",
797
+ }
798
+ )
799
+
800
+ # Auto-stop when we've acquired the requested number of images
801
+ if n_images is not None and (img_number + 1) >= n_images:
802
+ self._stop_event.set()
803
+
804
+ # Reset the circular buffer and stop event -------------
805
+
806
+ self._stop_event.clear()
807
+ self._seq_buffer.clear()
808
+ self._seq_buffer.overwrite_on_overflow = not stop_on_overflow
809
+
810
+ # Create the Acquisition Thread ---------
811
+
812
+ self._acquisition_thread = AcquisitionThread(
813
+ image_generator=cam.start_sequence(
814
+ n_images, get_buffer_with_overflow_handling
815
+ ),
816
+ finalize=finalize_with_metadata,
817
+ label=camera_label,
818
+ stop_event=self._stop_event,
819
+ )
820
+
821
+ # Zoom zoom ---------
822
+
823
+ start_time = perf_counter_ns()
824
+ self._acquisition_thread.start()
825
+
826
+ # ------------------------------------------------- startSequenceAcquisition
827
+
828
+ # startSequenceAcquisition
829
+ def _do_start_sequence_acquisition(
830
+ self, cameraLabel: str, numImages: int, intervalMs: float, stopOnOverflow: bool
831
+ ) -> None:
832
+ if (cam := self._py_camera(cameraLabel)) is None: # pragma: no cover
833
+ return pymmcore.CMMCore.startSequenceAcquisition(
834
+ self, cameraLabel, numImages, intervalMs, stopOnOverflow
835
+ )
836
+ with cam:
837
+ self._start_sequence(cam, numImages, stopOnOverflow)
838
+
839
+ # ------------------------------------------------- continuous acquisition
840
+
841
+ # startContinuousSequenceAcquisition
842
+ def _do_start_continuous_sequence_acquisition(self, intervalMs: float = 0) -> None:
843
+ if (cam := self._py_camera()) is None: # pragma: no cover
844
+ return pymmcore.CMMCore.startContinuousSequenceAcquisition(self, intervalMs)
845
+ with cam:
846
+ self._start_sequence(cam, None, False)
847
+
848
+ # ---------------------------------------------------------------- stopSequence
849
+
850
+ def _do_stop_sequence_acquisition(self, cameraLabel: str) -> None:
851
+ if self._py_camera(cameraLabel) is None: # pragma: no cover
852
+ pymmcore.CMMCore.stopSequenceAcquisition(self, cameraLabel)
853
+
854
+ if self._acquisition_thread is not None:
855
+ self._stop_event.set()
856
+ self._acquisition_thread.join()
857
+ self._acquisition_thread = None
858
+
859
+ # ------------------------------------------------------------------ queries
860
+ @overload
861
+ def isSequenceRunning(self) -> bool: ...
862
+ @overload
863
+ def isSequenceRunning(self, cameraLabel: DeviceLabel | str) -> bool: ...
864
+ def isSequenceRunning(self, cameraLabel: DeviceLabel | str | None = None) -> bool:
865
+ if self._py_camera(cameraLabel) is None:
866
+ return super().isSequenceRunning()
867
+
868
+ if self._acquisition_thread is None:
869
+ return False
870
+
871
+ # Check if the thread is actually still alive
872
+ if not self._acquisition_thread.is_alive():
873
+ # Thread has finished, clean it up
874
+ self._acquisition_thread = None
875
+ return False
876
+
877
+ return True
878
+
879
+ def getRemainingImageCount(self) -> int:
880
+ if self._py_camera() is None:
881
+ return super().getRemainingImageCount()
882
+ return len(self._seq_buffer) if self._seq_buffer is not None else 0
883
+
884
+ # ---------------------------------------------------- getImages
885
+
886
+ def getLastImage(self, *, out: np.ndarray | None = None) -> np.ndarray:
887
+ if self._py_camera() is None:
888
+ return super().getLastImage()
889
+ if (
890
+ not (self._seq_buffer)
891
+ or (result := self._seq_buffer.peek_last(out=out)) is None
892
+ ):
893
+ raise IndexError("Circular buffer is empty.")
894
+ return result[0]
895
+
896
+ @overload
897
+ def getLastImageMD(
898
+ self,
899
+ channel: int,
900
+ slice: int,
901
+ md: pymmcore.Metadata,
902
+ /,
903
+ *,
904
+ out: np.ndarray | None = None,
905
+ ) -> np.ndarray: ...
906
+ @overload
907
+ def getLastImageMD(
908
+ self, md: pymmcore.Metadata, /, *, out: np.ndarray | None = None
909
+ ) -> np.ndarray: ...
910
+ def getLastImageMD(self, *args: Any, out: np.ndarray | None = None) -> np.ndarray:
911
+ if self._py_camera() is None:
912
+ return super().getLastImageMD(*args)
913
+ md_object = args[0] if len(args) == 1 else args[-1]
914
+ if not isinstance(md_object, pymmcore.Metadata): # pragma: no cover
915
+ raise TypeError("Expected a Metadata object for the last argument.")
916
+
917
+ if (
918
+ not (self._seq_buffer)
919
+ or (result := self._seq_buffer.peek_last(out=out)) is None
920
+ ):
921
+ raise IndexError("Circular buffer is empty.")
922
+
923
+ img, md = result
924
+ for k, v in md.items():
925
+ tag = pymmcore.MetadataSingleTag(k, "_", False)
926
+ tag.SetValue(str(v))
927
+ md_object.SetTag(tag)
928
+
929
+ return img
930
+
931
+ def getNBeforeLastImageMD(
932
+ self,
933
+ n: int,
934
+ md: pymmcore.Metadata,
935
+ /,
936
+ *,
937
+ out: np.ndarray | None = None,
938
+ ) -> np.ndarray:
939
+ if self._py_camera() is None:
940
+ return super().getNBeforeLastImageMD(n, md)
941
+
942
+ if (
943
+ not (self._seq_buffer)
944
+ or (result := self._seq_buffer.peek_nth_from_last(n, out=out)) is None
945
+ ):
946
+ raise IndexError("Circular buffer is empty or n is out of range.")
947
+
948
+ img, md_data = result
949
+ for k, v in md_data.items():
950
+ tag = pymmcore.MetadataSingleTag(k, "_", False)
951
+ tag.SetValue(str(v))
952
+ md.SetTag(tag)
953
+
954
+ return img
955
+
956
+ # ---------------------------------------------------- popNext
957
+
958
+ def _pop_or_raise(self) -> tuple[np.ndarray, Mapping]:
959
+ if not self._seq_buffer or (data := self._seq_buffer.pop_next()) is None:
960
+ raise IndexError("Circular buffer is empty.")
961
+ return data
962
+
963
+ def popNextImage(self, *, fix: bool = True) -> np.ndarray:
964
+ if self._py_camera() is None:
965
+ return super().popNextImage(fix=fix)
966
+ return self._pop_or_raise()[0]
967
+
968
+ @overload
969
+ def popNextImageMD(
970
+ self, channel: int, slice: int, md: pymmcore.Metadata, /
971
+ ) -> np.ndarray: ...
972
+ @overload
973
+ def popNextImageMD(self, md: pymmcore.Metadata, /) -> np.ndarray: ...
974
+ def popNextImageMD(self, *args: Any) -> np.ndarray:
975
+ if self._py_camera() is None:
976
+ return super().popNextImageMD(*args)
977
+
978
+ md_object = args[0] if len(args) == 1 else args[-1]
979
+ if not isinstance(md_object, pymmcore.Metadata): # pragma: no cover
980
+ raise TypeError("Expected a Metadata object for the last argument.")
981
+
982
+ img, md = self._pop_or_raise()
983
+ for k, v in md.items():
984
+ tag = pymmcore.MetadataSingleTag(k, "_", False)
985
+ tag.SetValue(str(v))
986
+ md_object.SetTag(tag)
987
+ return img
988
+
989
+ # ---------------------------------------------------------------- circular buffer
990
+
991
+ def setCircularBufferMemoryFootprint(self, sizeMB: int) -> None:
992
+ """Set the circular buffer memory footprint in MB."""
993
+ if self._py_camera() is None:
994
+ return super().setCircularBufferMemoryFootprint(sizeMB)
995
+
996
+ if sizeMB <= 0: # pragma: no cover
997
+ raise ValueError("Buffer size must be greater than 0 MB")
998
+
999
+ # TODO: what if sequence is running?
1000
+ if self.isSequenceRunning():
1001
+ self.stopSequenceAcquisition()
1002
+
1003
+ self._seq_buffer = SequenceBuffer(size_mb=sizeMB)
1004
+
1005
+ def initializeCircularBuffer(self) -> None:
1006
+ """Initialize the circular buffer."""
1007
+ if self._py_camera() is None:
1008
+ return super().initializeCircularBuffer()
1009
+
1010
+ self._seq_buffer.clear()
1011
+
1012
+ def getBufferFreeCapacity(self) -> int:
1013
+ """Get the number of free slots in the circular buffer."""
1014
+ if (cam := self._py_camera()) is None:
1015
+ return super().getBufferFreeCapacity()
1016
+
1017
+ if (bytes_per_frame := self._predicted_bytes_per_frame(cam)) <= 0:
1018
+ return 0 # pragma: no cover # Invalid frame size
1019
+
1020
+ if (free_bytes := self._seq_buffer.free_bytes) <= 0:
1021
+ return 0
1022
+
1023
+ return free_bytes // bytes_per_frame
1024
+
1025
+ def getBufferTotalCapacity(self) -> int:
1026
+ """Get the total capacity of the circular buffer."""
1027
+ if (cam := self._py_camera()) is None:
1028
+ return super().getBufferTotalCapacity()
1029
+
1030
+ if (bytes_per_frame := self._predicted_bytes_per_frame(cam)) <= 0:
1031
+ return 0 # pragma: no cover # Invalid frame size
1032
+
1033
+ return self._seq_buffer.size_bytes // bytes_per_frame
1034
+
1035
+ def _predicted_bytes_per_frame(self, cam: CameraDevice) -> int:
1036
+ # Estimate capacity based on camera settings and circular buffer size
1037
+ shape, dtype = cam.shape(), np.dtype(cam.dtype())
1038
+ return int(np.prod(shape) * dtype.itemsize)
1039
+
1040
+ def getCircularBufferMemoryFootprint(self) -> int:
1041
+ """Get the circular buffer memory footprint in MB."""
1042
+ if self._py_camera() is None:
1043
+ return super().getCircularBufferMemoryFootprint()
1044
+
1045
+ return int(self._seq_buffer.size_mb)
1046
+
1047
+ def clearCircularBuffer(self) -> None:
1048
+ """Clear all images from the circular buffer."""
1049
+ if self._py_camera() is None:
1050
+ return super().clearCircularBuffer()
1051
+
1052
+ self._seq_buffer.clear()
1053
+
1054
+ def isBufferOverflowed(self) -> bool:
1055
+ """Check if the circular buffer has overflowed."""
1056
+ if self._py_camera() is None:
1057
+ return super().isBufferOverflowed()
1058
+
1059
+ return self._seq_buffer.overflow_occurred
1060
+
1061
+ # ----------------------------------------------------------------- image info
1062
+
1063
+ def getImageBitDepth(self) -> int:
1064
+ if (cam := self._py_camera()) is None: # pragma: no cover
1065
+ return super().getImageBitDepth()
1066
+ dtype = np.dtype(cam.dtype())
1067
+ return dtype.itemsize * 8
1068
+
1069
+ def getBytesPerPixel(self) -> int:
1070
+ if (cam := self._py_camera()) is None: # pragma: no cover
1071
+ return super().getBytesPerPixel()
1072
+ dtype = np.dtype(cam.dtype())
1073
+ return dtype.itemsize
1074
+
1075
+ def getImageBufferSize(self) -> int:
1076
+ if (cam := self._py_camera()) is None: # pragma: no cover
1077
+ return super().getImageBufferSize()
1078
+ shape, dtype = cam.shape(), np.dtype(cam.dtype())
1079
+ return int(np.prod(shape) * dtype.itemsize)
1080
+
1081
+ def getImageHeight(self) -> int:
1082
+ if (cam := self._py_camera()) is None: # pragma: no cover
1083
+ return super().getImageHeight()
1084
+ return cam.shape()[0]
1085
+
1086
+ def getImageWidth(self) -> int:
1087
+ if (cam := self._py_camera()) is None: # pragma: no cover
1088
+ return super().getImageWidth()
1089
+ return cam.shape()[1]
1090
+
1091
+ def getNumberOfComponents(self) -> int:
1092
+ if (cam := self._py_camera()) is None: # pragma: no cover
1093
+ return super().getNumberOfComponents()
1094
+ shape = cam.shape()
1095
+ return 1 if len(shape) == 2 else shape[2]
1096
+
1097
+ def getNumberOfCameraChannels(self) -> int:
1098
+ if self._py_camera() is None: # pragma: no cover
1099
+ return super().getNumberOfCameraChannels()
1100
+ raise NotImplementedError(
1101
+ "getNumberOfCameraChannels is not implemented for Python cameras."
1102
+ )
1103
+
1104
+ def getCameraChannelName(self, channelNr: int) -> str:
1105
+ """Get the name of the camera channel."""
1106
+ if self._py_camera() is None: # pragma: no cover
1107
+ return super().getCameraChannelName(channelNr)
1108
+ raise NotImplementedError(
1109
+ "getCameraChannelName is not implemented for Python cameras."
1110
+ )
1111
+
1112
+ @overload
1113
+ def getExposure(self) -> float: ...
1114
+ @overload
1115
+ def getExposure(self, cameraLabel: DeviceLabel | str, /) -> float: ...
1116
+ def getExposure(self, cameraLabel: DeviceLabel | str | None = None) -> float:
1117
+ """Get the exposure time in milliseconds."""
1118
+ if (cam := self._py_camera(cameraLabel)) is None: # pragma: no cover
1119
+ if cameraLabel is None:
1120
+ return super().getExposure()
1121
+ return super().getExposure(cameraLabel)
1122
+
1123
+ with cam:
1124
+ return cam.get_exposure()
1125
+
1126
+ @overload
1127
+ def setExposure(self, exp: float, /) -> None: ...
1128
+ @overload
1129
+ def setExposure(self, cameraLabel: DeviceLabel | str, dExp: float, /) -> None: ...
1130
+ def setExposure(self, *args: Any) -> None:
1131
+ """Set the exposure time in milliseconds."""
1132
+ label, args = _ensure_label(args, min_args=2, getter=self.getCameraDevice)
1133
+ if (cam := self._py_camera(label)) is None: # pragma: no cover
1134
+ return super().setExposure(label, *args)
1135
+ with cam:
1136
+ cam.set_exposure(*args)
1137
+
1138
+ def _do_set_roi(self, label: str, x: int, y: int, width: int, height: int) -> None:
1139
+ if self._py_camera(label) is not None:
1140
+ raise NotImplementedError(
1141
+ "setROI is not yet implemented for Python cameras."
1142
+ )
1143
+ return pymmcore.CMMCore.setROI(self, label, x, y, width, height)
1144
+
1145
+ @overload
1146
+ def getROI(self) -> list[int]: ...
1147
+ @overload
1148
+ def getROI(self, label: DeviceLabel | str) -> list[int]: ...
1149
+ def getROI(self, label: DeviceLabel | str = "") -> list[int]:
1150
+ """Get the current region of interest (ROI) for the camera."""
1151
+ if self._py_camera(label) is None: # pragma: no cover
1152
+ raise NotImplementedError(
1153
+ "getROI is not yet implemented for Python cameras."
1154
+ )
1155
+ return super().getROI(label)
1156
+
1157
+ def clearROI(self) -> None:
1158
+ """Clear the current region of interest (ROI) for the camera."""
1159
+ if self._py_camera() is not None: # pragma: no cover
1160
+ raise NotImplementedError(
1161
+ "clearROI is not yet implemented for Python cameras."
1162
+ )
1163
+ return super().clearROI()
1164
+
1165
+ def isExposureSequenceable(self, cameraLabel: DeviceLabel | str) -> bool:
1166
+ """Check if the camera supports exposure sequences."""
1167
+ if (cam := self._py_camera(cameraLabel)) is None: # pragma: no cover
1168
+ return super().isExposureSequenceable(cameraLabel)
1169
+ with cam:
1170
+ return cam.is_property_sequenceable(KW.Exposure)
1171
+
1172
+ def loadExposureSequence(
1173
+ self, cameraLabel: DeviceLabel | str, exposureSequence_ms: Sequence[float]
1174
+ ) -> None:
1175
+ """Transfer a sequence of exposure times to the camera."""
1176
+ if (cam := self._py_camera(cameraLabel)) is None: # pragma: no cover
1177
+ return super().loadExposureSequence(cameraLabel, exposureSequence_ms)
1178
+ with cam:
1179
+ cam.load_property_sequence(KW.Exposure, exposureSequence_ms)
1180
+
1181
+ def getExposureSequenceMaxLength(self, cameraLabel: DeviceLabel | str) -> int:
1182
+ """Get the maximum length of the exposure sequence."""
1183
+ if (cam := self._py_camera(cameraLabel)) is None: # pragma: no cover
1184
+ return super().getExposureSequenceMaxLength(cameraLabel)
1185
+ with cam:
1186
+ return cam.get_property_info(KW.Exposure).sequence_max_length
1187
+
1188
+ def startExposureSequence(self, cameraLabel: DeviceLabel | str) -> None:
1189
+ """Start a sequence of exposures."""
1190
+ if (cam := self._py_camera(cameraLabel)) is None: # pragma: no cover
1191
+ return super().startExposureSequence(cameraLabel)
1192
+ with cam:
1193
+ cam.start_property_sequence(KW.Exposure)
1194
+
1195
+ def stopExposureSequence(self, cameraLabel: DeviceLabel | str) -> None:
1196
+ """Stop a sequence of exposures."""
1197
+ if (cam := self._py_camera(cameraLabel)) is None: # pragma: no cover
1198
+ return super().stopExposureSequence(cameraLabel)
1199
+ with cam:
1200
+ cam.stop_property_sequence(KW.Exposure)
1201
+
1202
+ def prepareSequenceAcquisition(self, cameraLabel: DeviceLabel | str) -> None:
1203
+ """Prepare the camera for sequence acquisition."""
1204
+ if self._py_camera(cameraLabel) is None: # pragma: no cover
1205
+ return super().prepareSequenceAcquisition(cameraLabel)
1206
+ # TODO: Implement prepareSequenceAcquisition for Python cameras?
1207
+
1208
+ @overload
1209
+ def getPixelSizeAffine(self) -> AffineTuple: ...
1210
+ @overload
1211
+ def getPixelSizeAffine(self, cached: bool, /) -> AffineTuple: ...
1212
+ def getPixelSizeAffine(self, cached: bool = False) -> AffineTuple:
1213
+ """Get the pixel size affine transformation matrix."""
1214
+ if not (res_id := self.getCurrentPixelSizeConfig(cached)): # pragma: no cover
1215
+ return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0) # null affine
1216
+
1217
+ cam = self._py_camera()
1218
+ if cam is not None:
1219
+ with cam:
1220
+ binning = float(cam.get_property_value(KW.Binning))
1221
+ else:
1222
+ binning = 1.0
1223
+ if cam is None or binning == 1:
1224
+ return tuple(super().getPixelSizeAffine(cached)) # type: ignore
1225
+
1226
+ # in CMMCore, they scale the pixel size affine by the binning factor and mag
1227
+ # but they won't pay attention to our camera so we have to reimplement it here
1228
+ af = self.getPixelSizeAffineByID(res_id)
1229
+ if (factor := binning / self.getMagnificationFactor()) != 1.0:
1230
+ af = cast("AffineTuple", tuple(v * factor for v in af))
1231
+ return af
1232
+
1233
+ @overload
1234
+ def getPixelSizeUm(self) -> float: ...
1235
+ @overload
1236
+ def getPixelSizeUm(self, cached: bool) -> float: ...
1237
+ def getPixelSizeUm(self, cached: bool = False) -> float:
1238
+ """Get the pixel size in micrometers."""
1239
+ if not (res_id := self.getCurrentPixelSizeConfig(cached)): # pragma: no cover
1240
+ return 0.0
1241
+
1242
+ # in CMMCore, they scale the pixel size by the binning factor and mag
1243
+ # but they won't pay attention to our camera so we have to reimplement it here
1244
+ cam = self._py_camera()
1245
+ if cam is None or (binning := float(cam.get_property_value(KW.Binning))) == 1:
1246
+ return super().getPixelSizeUm(cached)
1247
+
1248
+ return self.getPixelSizeUmByID(res_id) * binning / self.getMagnificationFactor()
1249
+
1250
+ # ########################################################################
1251
+ # ------------------------- SLM Device Methods -------------------------
1252
+ # ########################################################################
1253
+
1254
+ # --------------------------------------------------------------------- utils
1255
+
1256
+ def _py_slm(self, slmLabel: str | None = None) -> SLMDevice | None:
1257
+ """Return the *Python* SLM for ``label`` (or current), else ``None``."""
1258
+ label = slmLabel or self.getSLMDevice()
1259
+ if label in self._pydevices:
1260
+ return self._pydevices.get_device_of_type(label, SLMDevice)
1261
+ return None # pragma: no cover
1262
+
1263
+ def setSLMDevice(self, slmLabel: DeviceLabel | str) -> None:
1264
+ """Set the SLM device."""
1265
+ label = self._set_current_if_pydevice(KW.CoreSLM, slmLabel)
1266
+ super().setSLMDevice(label)
1267
+
1268
+ def getSLMDevice(self) -> DeviceLabel | Literal[""]:
1269
+ """Returns the label of the currently selected SLM device.
1270
+
1271
+ Returns empty string if no SLM device is selected.
1272
+ """
1273
+ return self._pycore.current(KW.CoreSLM) or super().getSLMDevice()
1274
+
1275
+ # ------------------------------------------------------------------- set image
1276
+
1277
+ @overload
1278
+ def setSLMImage(self, pixels: np.ndarray, /) -> None: ...
1279
+ @overload
1280
+ def setSLMImage(
1281
+ self, slmLabel: DeviceLabel | str, pixels: np.ndarray, /
1282
+ ) -> None: ...
1283
+ def setSLMImage(self, *args: Any) -> None:
1284
+ """Load the image into the SLM device adapter."""
1285
+ label, args = _ensure_label(args, min_args=2, getter=self.getSLMDevice)
1286
+ if (slm := self._py_slm(label)) is None: # pragma: no cover
1287
+ return super().setSLMImage(label, *args)
1288
+
1289
+ with slm:
1290
+ shape, dtype = slm.shape(), np.dtype(slm.dtype())
1291
+ arr = np.asarray(args[0], dtype=dtype)
1292
+ if not arr.shape == shape: # pragma: no cover
1293
+ raise ValueError(
1294
+ f"Image shape {arr.shape} doesn't match SLM shape {shape}."
1295
+ )
1296
+ slm.set_image(arr)
1297
+
1298
+ def getSLMImage(self, slmLabel: DeviceLabel | str | None = None) -> np.ndarray:
1299
+ """Get the current image from the SLM device."""
1300
+ if (slm := self._py_slm(slmLabel)) is None:
1301
+ raise NotImplementedError(
1302
+ "getSLMImage is not implemented for C++ SLM devices. "
1303
+ "(This method is unique to Python SLM devices.)"
1304
+ )
1305
+
1306
+ with slm:
1307
+ return slm.get_image()
1308
+
1309
+ @overload
1310
+ def setSLMPixelsTo(self, intensity: int, /) -> None: ...
1311
+ @overload
1312
+ def setSLMPixelsTo(self, red: int, green: int, blue: int, /) -> None: ...
1313
+ @overload
1314
+ def setSLMPixelsTo(
1315
+ self, slmLabel: DeviceLabel | str, intensity: int, /
1316
+ ) -> None: ...
1317
+ @overload
1318
+ def setSLMPixelsTo(
1319
+ self, slmLabel: DeviceLabel | str, red: int, green: int, blue: int, /
1320
+ ) -> None: ...
1321
+ def setSLMPixelsTo(self, *args: Any) -> None:
1322
+ """Set all pixels of the SLM to a uniform intensity or RGB values."""
1323
+ if len(args) < 1 or len(args) > 4: # pragma: no cover
1324
+ raise ValueError("setSLMPixelsTo requires 1 to 4 arguments.")
1325
+
1326
+ label = args[0] if len(args) in (2, 4) else self.getSLMDevice()
1327
+ if (slm := self._py_slm(label)) is None: # pragma: no cover
1328
+ return super().setSLMPixelsTo(*args)
1329
+
1330
+ with slm:
1331
+ shape = slm.shape()
1332
+ dtype = slm.dtype()
1333
+
1334
+ # Determine if we have RGB (3 or 4 args) or single intensity (1 or 2 args)
1335
+ if len(args) == 1: # setSLMPixelsTo(intensity)
1336
+ pixels = np.full(shape, args[0], dtype=dtype)
1337
+ elif len(args) == 2: # setSLMPixelsTo(slmLabel, intensity)
1338
+ pixels = np.full(shape, args[1], dtype=dtype)
1339
+ elif len(args) == 3: # setSLMPixelsTo(red, green, blue)
1340
+ rgb_values = args
1341
+ pixels = np.broadcast_to(rgb_values, (*shape[:2], 3))
1342
+ elif len(args) == 4: # setSLMPixelsTo(slmLabel, red, green, blue)
1343
+ rgb_values = args[1:4]
1344
+ pixels = np.broadcast_to(rgb_values, (*shape[:2], 3))
1345
+ if len(shape) == 2 and pixels.ndim == 3:
1346
+ # Grayscale SLM - convert RGB to grayscale (simple average)
1347
+ pixels = np.mean(pixels, axis=2, dtype=dtype).astype(dtype)
1348
+
1349
+ slm.set_image(pixels)
1350
+
1351
+ @overload
1352
+ def displaySLMImage(self) -> None: ...
1353
+ @overload
1354
+ def displaySLMImage(self, slmLabel: DeviceLabel | str, /) -> None: ...
1355
+ def displaySLMImage(self, slmLabel: DeviceLabel | str | None = None) -> None:
1356
+ """Command the SLM to display the loaded image."""
1357
+ label = slmLabel or self.getSLMDevice()
1358
+ if (slm := self._py_slm(label)) is None: # pragma: no cover
1359
+ if slmLabel is None:
1360
+ return super().displaySLMImage(label)
1361
+ return super().displaySLMImage(slmLabel)
1362
+
1363
+ with slm:
1364
+ slm.display_image()
1365
+
1366
+ # ------------------------------------------------------------------ exposure
1367
+
1368
+ @overload
1369
+ def setSLMExposure(self, interval_ms: float, /) -> None: ...
1370
+ @overload
1371
+ def setSLMExposure(
1372
+ self, slmLabel: DeviceLabel | str, interval_ms: float, /
1373
+ ) -> None: ...
1374
+ def setSLMExposure(self, *args: Any) -> None:
1375
+ """Command the SLM to turn off after a specified interval."""
1376
+ label, args = _ensure_label(args, min_args=2, getter=self.getSLMDevice)
1377
+ if (slm := self._py_slm(label)) is None: # pragma: no cover
1378
+ return super().setSLMExposure(label, *args)
1379
+
1380
+ with slm:
1381
+ slm.set_exposure(args[0])
1382
+
1383
+ @overload
1384
+ def getSLMExposure(self) -> float: ...
1385
+ @overload
1386
+ def getSLMExposure(self, slmLabel: DeviceLabel | str, /) -> float: ...
1387
+ def getSLMExposure(self, slmLabel: DeviceLabel | str | None = None) -> float:
1388
+ """Find out the exposure interval of an SLM."""
1389
+ if (slm := self._py_slm(slmLabel)) is None: # pragma: no cover
1390
+ label = slmLabel or self.getSLMDevice()
1391
+ return super().getSLMExposure(label)
1392
+
1393
+ with slm:
1394
+ return slm.get_exposure()
1395
+
1396
+ # ----------------------------------------------------------------- dimensions
1397
+
1398
+ @overload
1399
+ def getSLMWidth(self) -> int: ...
1400
+ @overload
1401
+ def getSLMWidth(self, slmLabel: DeviceLabel | str, /) -> int: ...
1402
+ def getSLMWidth(self, slmLabel: DeviceLabel | str | None = None) -> int:
1403
+ """Returns the width of the SLM in pixels."""
1404
+ if (slm := self._py_slm(slmLabel)) is None: # pragma: no cover
1405
+ label = slmLabel or self.getSLMDevice()
1406
+ return super().getSLMWidth(label)
1407
+
1408
+ with slm:
1409
+ return slm.shape()[1] # width is second dimension
1410
+
1411
+ @overload
1412
+ def getSLMHeight(self) -> int: ...
1413
+ @overload
1414
+ def getSLMHeight(self, slmLabel: DeviceLabel | str, /) -> int: ...
1415
+ def getSLMHeight(self, slmLabel: DeviceLabel | str | None = None) -> int:
1416
+ """Returns the height of the SLM in pixels."""
1417
+ if (slm := self._py_slm(slmLabel)) is None: # pragma: no cover
1418
+ label = slmLabel or self.getSLMDevice()
1419
+ return super().getSLMHeight(label)
1420
+
1421
+ with slm:
1422
+ return slm.shape()[0] # height is first dimension
1423
+
1424
+ @overload
1425
+ def getSLMNumberOfComponents(self) -> int: ...
1426
+ @overload
1427
+ def getSLMNumberOfComponents(self, slmLabel: DeviceLabel | str, /) -> int: ...
1428
+ def getSLMNumberOfComponents(
1429
+ self, slmLabel: DeviceLabel | str | None = None
1430
+ ) -> int:
1431
+ """Returns the number of color components (channels) in the SLM."""
1432
+ if (slm := self._py_slm(slmLabel)) is None: # pragma: no cover
1433
+ label = slmLabel or self.getSLMDevice()
1434
+ return super().getSLMNumberOfComponents(label)
1435
+
1436
+ with slm:
1437
+ shape = slm.shape()
1438
+ return 1 if len(shape) == 2 else shape[2]
1439
+
1440
+ @overload
1441
+ def getSLMBytesPerPixel(self) -> int: ...
1442
+ @overload
1443
+ def getSLMBytesPerPixel(self, slmLabel: DeviceLabel | str, /) -> int: ...
1444
+ def getSLMBytesPerPixel(self, slmLabel: DeviceLabel | str | None = None) -> int:
1445
+ """Returns the number of bytes per pixel for the SLM."""
1446
+ if (slm := self._py_slm(slmLabel)) is None: # pragma: no cover
1447
+ label = slmLabel or self.getSLMDevice()
1448
+ return super().getSLMBytesPerPixel(label)
1449
+
1450
+ with slm:
1451
+ dtype = np.dtype(slm.dtype())
1452
+ return dtype.itemsize
1453
+
1454
+ # ------------------------------------------------------------------ sequences
1455
+
1456
+ def getSLMSequenceMaxLength(self, slmLabel: DeviceLabel | str) -> int:
1457
+ """Get the maximum length of an image sequence that can be uploaded."""
1458
+ if (slm := self._py_slm(slmLabel)) is None: # pragma: no cover
1459
+ return super().getSLMSequenceMaxLength(slmLabel)
1460
+
1461
+ with slm:
1462
+ return slm.get_sequence_max_length()
1463
+
1464
+ def loadSLMSequence(
1465
+ self,
1466
+ slmLabel: DeviceLabel | str,
1467
+ imageSequence: Sequence[bytes | np.ndarray],
1468
+ ) -> None:
1469
+ """Load a sequence of images to the SLM."""
1470
+ if (slm := self._py_slm(slmLabel)) is None: # pragma: no cover
1471
+ return super().loadSLMSequence(slmLabel, imageSequence) # type: ignore[arg-type]
1472
+
1473
+ with slm:
1474
+ if (m := slm.get_sequence_max_length()) == 0:
1475
+ raise RuntimeError(f"SLM {slmLabel!r} does not support sequences.")
1476
+
1477
+ shape = slm.shape()
1478
+ dtype = np.dtype(slm.dtype())
1479
+
1480
+ np_arrays: list[np.ndarray] = []
1481
+ for i, img_bytes in enumerate(imageSequence):
1482
+ if isinstance(img_bytes, bytes):
1483
+ arr = np.frombuffer(img_bytes, dtype=dtype).reshape(shape)
1484
+ else:
1485
+ arr = np.asarray(img_bytes, dtype=dtype)
1486
+ if arr.shape != shape:
1487
+ raise ValueError(
1488
+ f"Image {i} shape {arr.shape} does not "
1489
+ f"match SLM shape {shape}"
1490
+ )
1491
+ np_arrays.append(arr)
1492
+ if len(np_arrays) > (m := slm.get_sequence_max_length()):
1493
+ raise ValueError(
1494
+ f"Sequence length {len(np_arrays)} exceeds maximum {m}."
1495
+ )
1496
+ slm.send_sequence(np_arrays)
1497
+
1498
+ def startSLMSequence(self, slmLabel: DeviceLabel | str) -> None:
1499
+ """Start a sequence of images on the SLM."""
1500
+ if (slm := self._py_slm(slmLabel)) is None: # pragma: no cover
1501
+ return super().startSLMSequence(slmLabel)
1502
+
1503
+ with slm:
1504
+ slm.start_sequence()
1505
+
1506
+ def stopSLMSequence(self, slmLabel: DeviceLabel | str) -> None:
1507
+ """Stop a sequence of images on the SLM."""
1508
+ if (slm := self._py_slm(slmLabel)) is None: # pragma: no cover
1509
+ return super().stopSLMSequence(slmLabel)
1510
+
1511
+ with slm:
1512
+ slm.stop_sequence()
1513
+
1514
+ # ########################################################################
1515
+ # ------------------------ State Device Methods -------------------------
1516
+ # ########################################################################
1517
+
1518
+ # --------------------------------------------------------------------- utils
1519
+
1520
+ def _py_state(self, stateLabel: str | None = None) -> StateDevice | None:
1521
+ """Return the *Python* State device for ``label``, else ``None``."""
1522
+ label = stateLabel or ""
1523
+ if label in self._pydevices:
1524
+ return self._pydevices.get_device_of_type(label, StateDevice)
1525
+ return None # pragma: no cover
1526
+
1527
+ # ------------------------------------------------------------------- setState
1528
+
1529
+ def setState(self, stateDeviceLabel: DeviceLabel | str, state: int) -> None:
1530
+ """Set state (position) on the specific device."""
1531
+ if (state_dev := self._py_state(stateDeviceLabel)) is None: # pragma: no cover
1532
+ return super().setState(stateDeviceLabel, state)
1533
+
1534
+ with state_dev:
1535
+ state_dev.set_position_or_label(state)
1536
+
1537
+ # ------------------------------------------------------------------- getState
1538
+
1539
+ def getState(self, stateDeviceLabel: DeviceLabel | str) -> int:
1540
+ """Return the current state (position) on the specific device."""
1541
+ if (state_dev := self._py_state(stateDeviceLabel)) is None: # pragma: no cover
1542
+ return super().getState(stateDeviceLabel)
1543
+
1544
+ with state_dev:
1545
+ return int(state_dev.get_property_value(KW.State))
1546
+
1547
+ # ---------------------------------------------------------------- getNumberOfStates
1548
+
1549
+ def getNumberOfStates(self, stateDeviceLabel: DeviceLabel | str) -> int:
1550
+ """Return the total number of available positions (states)."""
1551
+ if (state_dev := self._py_state(stateDeviceLabel)) is None: # pragma: no cover
1552
+ return super().getNumberOfStates(stateDeviceLabel)
1553
+
1554
+ with state_dev:
1555
+ return state_dev.get_property_info(KW.State).number_of_allowed_values
1556
+
1557
+ # ----------------------------------------------------------------- setStateLabel
1558
+
1559
+ def setStateLabel(
1560
+ self, stateDeviceLabel: DeviceLabel | str, stateLabel: str
1561
+ ) -> None:
1562
+ """Set device state using the previously assigned label (string)."""
1563
+ if (state_dev := self._py_state(stateDeviceLabel)) is None: # pragma: no cover
1564
+ return super().setStateLabel(stateDeviceLabel, stateLabel)
1565
+
1566
+ with state_dev:
1567
+ try:
1568
+ state_dev.set_position_or_label(stateLabel)
1569
+ except KeyError as e:
1570
+ raise RuntimeError(str(e)) from e # convert to RuntimeError
1571
+
1572
+ # ----------------------------------------------------------------- getStateLabel
1573
+
1574
+ def getStateLabel(self, stateDeviceLabel: DeviceLabel | str) -> StateLabel:
1575
+ """Return the current state as the label (string)."""
1576
+ if (state_dev := self._py_state(stateDeviceLabel)) is None: # pragma: no cover
1577
+ return super().getStateLabel(stateDeviceLabel)
1578
+
1579
+ with state_dev:
1580
+ return cast("StateLabel", state_dev.get_property_value(KW.Label))
1581
+
1582
+ # --------------------------------------------------------------- defineStateLabel
1583
+
1584
+ def defineStateLabel(
1585
+ self, stateDeviceLabel: DeviceLabel | str, state: int, label: str
1586
+ ) -> None:
1587
+ """Define a label for the specific state."""
1588
+ if (state_dev := self._py_state(stateDeviceLabel)) is None: # pragma: no cover
1589
+ return super().defineStateLabel(stateDeviceLabel, state, label)
1590
+
1591
+ with state_dev:
1592
+ state_dev.assign_label_to_position(state, label)
1593
+
1594
+ # ----------------------------------------------------------------- getStateLabels
1595
+
1596
+ def getStateLabels(
1597
+ self, stateDeviceLabel: DeviceLabel | str
1598
+ ) -> tuple[StateLabel, ...]:
1599
+ """Return labels for all states."""
1600
+ if (state_dev := self._py_state(stateDeviceLabel)) is None: # pragma: no cover
1601
+ return super().getStateLabels(stateDeviceLabel)
1602
+
1603
+ with state_dev:
1604
+ return tuple(state_dev.get_property_info(KW.Label).allowed_values or [])
1605
+
1606
+ # ------------------------------------------------------------- getStateFromLabel
1607
+
1608
+ def getStateFromLabel(
1609
+ self, stateDeviceLabel: DeviceLabel | str, stateLabel: str
1610
+ ) -> int:
1611
+ """Obtain the state for a given label."""
1612
+ if (state_dev := self._py_state(stateDeviceLabel)) is None: # pragma: no cover
1613
+ return super().getStateFromLabel(stateDeviceLabel, stateLabel)
1614
+
1615
+ with state_dev:
1616
+ try:
1617
+ return state_dev.get_position_for_label(stateLabel)
1618
+ except KeyError as e:
1619
+ raise RuntimeError(str(e)) from e # convert to RuntimeError
1620
+
1621
+ # ########################################################################
1622
+ # ------------------------ Shutter Device Methods ------------------------
1623
+ # ########################################################################
1624
+
1625
+ def _py_shutter(self, shutterLabel: str | None = None) -> ShutterDevice | None:
1626
+ """Return the *Python* Shutter device for ``label``, else ``None``."""
1627
+ label = shutterLabel or self.getShutterDevice()
1628
+ if label in self._pydevices:
1629
+ return self._pydevices.get_device_of_type(label, ShutterDevice)
1630
+ return None
1631
+
1632
+ def setShutterDevice(self, shutterLabel: DeviceLabel | str) -> None:
1633
+ label = self._set_current_if_pydevice(KW.CoreShutter, shutterLabel)
1634
+ super().setShutterDevice(label)
1635
+
1636
+ def getShutterDevice(self) -> DeviceLabel | Literal[""]:
1637
+ """Returns the label of the currently selected Shutter device.
1638
+
1639
+ Returns empty string if no Shutter device is selected.
1640
+ """
1641
+ return self._pycore.current(KW.CoreShutter) or super().getShutterDevice()
1642
+
1643
+ @overload
1644
+ def getShutterOpen(self) -> bool: ...
1645
+ @overload
1646
+ def getShutterOpen(self, shutterLabel: DeviceLabel | str) -> bool: ...
1647
+ def getShutterOpen(self, shutterLabel: DeviceLabel | str | None = None) -> bool:
1648
+ shutterLabel = shutterLabel or self.getShutterDevice()
1649
+ if (shutter := self._py_shutter(shutterLabel)) is None:
1650
+ return super().getShutterOpen(shutterLabel)
1651
+
1652
+ with shutter:
1653
+ return shutter.get_open()
1654
+
1655
+ def _do_shutter_open(self, shutterLabel: str, state: bool, /) -> None:
1656
+ """Open or close the shutter."""
1657
+ if (shutter := self._py_shutter(shutterLabel)) is None: # pragma: no cover
1658
+ return pymmcore.CMMCore.setShutterOpen(self, shutterLabel, state)
1659
+
1660
+ with shutter:
1661
+ shutter.set_open(state)
1662
+
1663
+
1664
+ # -------------------------------------------------------------------------------
1665
+
1666
+
1667
+ def _ensure_label(
1668
+ args: tuple[_T, ...], min_args: int, getter: Callable[[], str]
1669
+ ) -> tuple[str, tuple[_T, ...]]:
1670
+ """Ensure we have a device label.
1671
+
1672
+ Designed to be used with overloaded methods that MAY take a device label as the
1673
+ first argument.
1674
+
1675
+ If the number of arguments is less than `min_args`, the label is obtained from the
1676
+ getter function. If the number of arguments is greater than or equal to `min_args`,
1677
+ the label is the first argument and the remaining arguments are returned as a tuple
1678
+ """
1679
+ if len(args) < min_args:
1680
+ # we didn't get the label
1681
+ return getter(), args
1682
+ return cast("str", args[0]), args[1:]
1683
+
1684
+
1685
+ class PropertyStateCache(MutableMapping[tuple[str, str], Any]):
1686
+ """A thread-safe cache for property states.
1687
+
1688
+ Keys are tuples of (device_label, property_name), and values are the last known
1689
+ value of that property.
1690
+ """
1691
+
1692
+ def __init__(self) -> None:
1693
+ self._store: dict[tuple[str, str], Any] = {}
1694
+ self._lock = threading.Lock()
1695
+
1696
+ def __getitem__(self, key: tuple[str, str]) -> Any:
1697
+ with self._lock:
1698
+ try:
1699
+ return self._store[key]
1700
+ except KeyError: # pragma: no cover
1701
+ prop, dev = key
1702
+ raise KeyError(
1703
+ f"Property {prop!r} of device {dev!r} not found in cache"
1704
+ ) from None
1705
+
1706
+ def __setitem__(self, key: tuple[str, str], value: Any) -> None:
1707
+ with self._lock:
1708
+ self._store[key] = value
1709
+
1710
+ def __delitem__(self, key: tuple[str, str]) -> None:
1711
+ with self._lock:
1712
+ del self._store[key]
1713
+
1714
+ def __contains__(self, key: object) -> bool:
1715
+ with self._lock:
1716
+ return key in self._store
1717
+
1718
+ def __iter__(self) -> Iterator[tuple[str, str]]:
1719
+ with self._lock:
1720
+ return iter(self._store.copy()) # Prevent modifications during iteration
1721
+
1722
+ def __len__(self) -> int:
1723
+ with self._lock:
1724
+ return len(self._store)
1725
+
1726
+ def __repr__(self) -> str:
1727
+ with self._lock:
1728
+ return f"{self.__class__.__name__}({self._store!r})"
1729
+
1730
+
1731
+ # Threading ------------------------------------------------------
1732
+
1733
+
1734
+ class AcquisitionThread(threading.Thread):
1735
+ """A thread for running sequence acquisition in the background."""
1736
+
1737
+ def __init__(
1738
+ self,
1739
+ image_generator: Iterator[Mapping],
1740
+ finalize: Callable[[Mapping], None],
1741
+ label: str,
1742
+ stop_event: threading.Event,
1743
+ ) -> None:
1744
+ super().__init__(daemon=True)
1745
+ self.image_iterator = image_generator
1746
+ self.finalize = finalize
1747
+ self.label = label
1748
+ self.stop_event = stop_event
1749
+
1750
+ def run(self) -> None:
1751
+ """Run the sequence and handle the generator pattern."""
1752
+ try:
1753
+ for metadata in self.image_iterator:
1754
+ self.finalize(metadata)
1755
+ if self.stop_event.is_set():
1756
+ break
1757
+ except BufferOverflowStop:
1758
+ # Buffer overflow is a graceful stop condition, not an error
1759
+ # this was likely raised by the Unicore above in _start_sequence
1760
+ pass
1761
+ except BufferError:
1762
+ raise # pragma: no cover
1763
+ except Exception as e: # pragma: no cover
1764
+ raise RuntimeError(
1765
+ f"Error in device {self.label!r} during sequence acquisition: {e}"
1766
+ ) from e
1767
+
1768
+
1769
+ # -------------------------------------------------------------------------------