pymmcore-plus 0.13.7__py3-none-any.whl → 0.15.0__py3-none-any.whl

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