pymmcore-plus 0.15.4__py3-none-any.whl → 0.17.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.
- pymmcore_plus/__init__.py +20 -1
- pymmcore_plus/_accumulator.py +23 -5
- pymmcore_plus/_cli.py +44 -26
- pymmcore_plus/_discovery.py +344 -0
- pymmcore_plus/_ipy_completion.py +1 -1
- pymmcore_plus/_logger.py +3 -3
- pymmcore_plus/_util.py +9 -245
- pymmcore_plus/core/_device.py +57 -13
- pymmcore_plus/core/_mmcore_plus.py +20 -23
- pymmcore_plus/core/_property.py +35 -29
- pymmcore_plus/core/_sequencing.py +2 -0
- pymmcore_plus/core/events/_device_signal_view.py +8 -1
- pymmcore_plus/experimental/simulate/__init__.py +88 -0
- pymmcore_plus/experimental/simulate/_objects.py +670 -0
- pymmcore_plus/experimental/simulate/_render.py +510 -0
- pymmcore_plus/experimental/simulate/_sample.py +156 -0
- pymmcore_plus/experimental/unicore/__init__.py +2 -0
- pymmcore_plus/experimental/unicore/_device_manager.py +46 -13
- pymmcore_plus/experimental/unicore/core/_config.py +706 -0
- pymmcore_plus/experimental/unicore/core/_unicore.py +834 -18
- pymmcore_plus/experimental/unicore/devices/_device_base.py +13 -0
- pymmcore_plus/experimental/unicore/devices/_hub.py +50 -0
- pymmcore_plus/experimental/unicore/devices/_stage.py +46 -1
- pymmcore_plus/experimental/unicore/devices/_state.py +6 -0
- pymmcore_plus/install.py +149 -18
- pymmcore_plus/mda/_engine.py +268 -73
- pymmcore_plus/mda/handlers/_5d_writer_base.py +16 -5
- pymmcore_plus/mda/handlers/_tensorstore_handler.py +7 -1
- pymmcore_plus/metadata/_ome.py +553 -0
- pymmcore_plus/metadata/functions.py +2 -1
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/METADATA +7 -4
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/RECORD +35 -27
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/WHEEL +1 -1
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/entry_points.txt +0 -0
- {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -6,6 +6,7 @@ from collections.abc import Iterator, MutableMapping, Sequence
|
|
|
6
6
|
from contextlib import suppress
|
|
7
7
|
from datetime import datetime
|
|
8
8
|
from itertools import count
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
from time import perf_counter_ns
|
|
10
11
|
from typing import (
|
|
11
12
|
TYPE_CHECKING,
|
|
@@ -20,39 +21,59 @@ from typing import (
|
|
|
20
21
|
import numpy as np
|
|
21
22
|
|
|
22
23
|
import pymmcore_plus._pymmcore as pymmcore
|
|
23
|
-
from pymmcore_plus.core import CMMCorePlus, DeviceType, Keyword
|
|
24
|
+
from pymmcore_plus.core import CMMCorePlus, DeviceType, FocusDirection, Keyword
|
|
24
25
|
from pymmcore_plus.core import Keyword as KW
|
|
26
|
+
from pymmcore_plus.core._config import Configuration
|
|
25
27
|
from pymmcore_plus.core._constants import PixelType
|
|
26
28
|
from pymmcore_plus.experimental.unicore._device_manager import PyDeviceManager
|
|
27
29
|
from pymmcore_plus.experimental.unicore._proxy import create_core_proxy
|
|
28
30
|
from pymmcore_plus.experimental.unicore.devices._camera import CameraDevice
|
|
29
31
|
from pymmcore_plus.experimental.unicore.devices._device_base import Device
|
|
32
|
+
from pymmcore_plus.experimental.unicore.devices._hub import HubDevice
|
|
30
33
|
from pymmcore_plus.experimental.unicore.devices._shutter import ShutterDevice
|
|
31
34
|
from pymmcore_plus.experimental.unicore.devices._slm import SLMDevice
|
|
32
|
-
from pymmcore_plus.experimental.unicore.devices._stage import
|
|
35
|
+
from pymmcore_plus.experimental.unicore.devices._stage import (
|
|
36
|
+
StageDevice,
|
|
37
|
+
XYStageDevice,
|
|
38
|
+
_BaseStage,
|
|
39
|
+
)
|
|
33
40
|
from pymmcore_plus.experimental.unicore.devices._state import StateDevice
|
|
34
41
|
|
|
42
|
+
from ._config import load_system_configuration, save_system_configuration
|
|
35
43
|
from ._sequence_buffer import SequenceBuffer
|
|
36
44
|
|
|
37
45
|
if TYPE_CHECKING:
|
|
38
|
-
from collections.abc import Mapping, Sequence
|
|
46
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
39
47
|
from typing import Literal, NewType
|
|
40
48
|
|
|
41
49
|
from numpy.typing import DTypeLike
|
|
42
50
|
from pymmcore import (
|
|
43
51
|
AdapterName,
|
|
44
52
|
AffineTuple,
|
|
53
|
+
ConfigPresetName,
|
|
45
54
|
DeviceLabel,
|
|
46
55
|
DeviceName,
|
|
47
56
|
PropertyName,
|
|
48
57
|
StateLabel,
|
|
49
58
|
)
|
|
59
|
+
from typing_extensions import TypeAlias
|
|
50
60
|
|
|
51
61
|
from pymmcore_plus.core._constants import DeviceInitializationState, PropertyType
|
|
52
62
|
|
|
53
63
|
PyDeviceLabel = NewType("PyDeviceLabel", DeviceLabel)
|
|
54
64
|
_T = TypeVar("_T")
|
|
55
65
|
|
|
66
|
+
# =============================================================================
|
|
67
|
+
# Config Group Type Aliases
|
|
68
|
+
# =============================================================================
|
|
69
|
+
|
|
70
|
+
DevPropTuple = tuple[str, str]
|
|
71
|
+
ConfigDict: TypeAlias = "MutableMapping[DevPropTuple, Any]"
|
|
72
|
+
ConfigGroup: TypeAlias = "MutableMapping[ConfigPresetName, ConfigDict]"
|
|
73
|
+
# technically the keys are ConfigGroupName (a NewType from pymmcore)
|
|
74
|
+
# but to avoid all the casting, we use str here
|
|
75
|
+
ConfigGroups: TypeAlias = MutableMapping[str, ConfigGroup]
|
|
76
|
+
|
|
56
77
|
|
|
57
78
|
class BufferOverflowStop(Exception):
|
|
58
79
|
"""Exception raised to signal graceful stop on buffer overflow."""
|
|
@@ -78,7 +99,7 @@ class _CoreDevice:
|
|
|
78
99
|
determine which real device to use.
|
|
79
100
|
"""
|
|
80
101
|
|
|
81
|
-
def __init__(self, state_cache:
|
|
102
|
+
def __init__(self, state_cache: ThreadSafeConfig) -> None:
|
|
82
103
|
self._state_cache = state_cache
|
|
83
104
|
self._pycurrent: dict[Keyword, PyDeviceLabel | None] = {}
|
|
84
105
|
self.reset_current()
|
|
@@ -102,11 +123,15 @@ class UniMMCore(CMMCorePlus):
|
|
|
102
123
|
|
|
103
124
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
104
125
|
self._pydevices = PyDeviceManager() # manager for python devices
|
|
105
|
-
self._state_cache =
|
|
126
|
+
self._state_cache = ThreadSafeConfig() # threadsafe cache for property states
|
|
106
127
|
self._pycore = _CoreDevice(self._state_cache) # virtual core for python
|
|
107
128
|
self._stop_event: threading.Event = threading.Event()
|
|
108
129
|
self._acquisition_thread: AcquisitionThread | None = None # TODO: implement
|
|
109
130
|
self._seq_buffer = SequenceBuffer(size_mb=_DEFAULT_BUFFER_SIZE_MB)
|
|
131
|
+
# Storage for Python device settings in config groups.
|
|
132
|
+
# Groups and presets are ALWAYS also created in C++ (via super() calls).
|
|
133
|
+
# This dict only stores (device, property) -> value for Python devices.
|
|
134
|
+
self._py_config_groups: ConfigGroups = {}
|
|
110
135
|
|
|
111
136
|
super().__init__(*args, **kwargs)
|
|
112
137
|
|
|
@@ -130,10 +155,84 @@ class UniMMCore(CMMCorePlus):
|
|
|
130
155
|
|
|
131
156
|
def reset(self) -> None:
|
|
132
157
|
with suppress(TimeoutError):
|
|
133
|
-
self.
|
|
158
|
+
self._pydevices.wait_for_device_type(
|
|
159
|
+
DeviceType.AnyType, self.getTimeoutMs(), parallel=False
|
|
160
|
+
)
|
|
161
|
+
super().waitForDeviceType(DeviceType.AnyType)
|
|
134
162
|
self.unloadAllDevices()
|
|
135
163
|
self._pycore.reset_current()
|
|
136
|
-
|
|
164
|
+
self._py_config_groups.clear() # Clear Python device config settings
|
|
165
|
+
super().reset() # Clears C++ config groups and channel group
|
|
166
|
+
|
|
167
|
+
def loadSystemConfiguration(
|
|
168
|
+
self, fileName: str | Path = "MMConfig_demo.cfg"
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Load a system config file conforming to the MM `.cfg` format.
|
|
171
|
+
|
|
172
|
+
This is a Python implementation that supports both C++ and Python devices.
|
|
173
|
+
Lines prefixed with `#py ` are processed as Python device commands but
|
|
174
|
+
are ignored by upstream C++/pymmcore implementations.
|
|
175
|
+
|
|
176
|
+
Format example::
|
|
177
|
+
|
|
178
|
+
# C++ devices
|
|
179
|
+
Device, Camera, DemoCamera, DCam
|
|
180
|
+
Property, Core, Initialize, 1
|
|
181
|
+
|
|
182
|
+
# Python devices (hidden from upstream via comment prefix)
|
|
183
|
+
# py pyDevice,PyCamera,mypackage.cameras,MyCameraClass
|
|
184
|
+
# py Property,PyCamera,Exposure,50.0
|
|
185
|
+
|
|
186
|
+
https://micro-manager.org/Micro-Manager_Configuration_Guide#configuration-file-syntax
|
|
187
|
+
|
|
188
|
+
For relative paths, the current working directory is first checked, then
|
|
189
|
+
the device adapter path is checked.
|
|
190
|
+
|
|
191
|
+
Parameters
|
|
192
|
+
----------
|
|
193
|
+
fileName : str | Path
|
|
194
|
+
Path to the configuration file. Defaults to "MMConfig_demo.cfg".
|
|
195
|
+
"""
|
|
196
|
+
fpath = Path(fileName).expanduser()
|
|
197
|
+
if not fpath.exists() and not fpath.is_absolute() and self._mm_path:
|
|
198
|
+
fpath = Path(self._mm_path) / fileName
|
|
199
|
+
if not fpath.exists():
|
|
200
|
+
raise FileNotFoundError(f"Path does not exist: {fpath}")
|
|
201
|
+
|
|
202
|
+
cfg_path = str(fpath.resolve())
|
|
203
|
+
try:
|
|
204
|
+
load_system_configuration(self, cfg_path)
|
|
205
|
+
except Exception:
|
|
206
|
+
# On failure, unload all devices to avoid leaving loaded but
|
|
207
|
+
# uninitialized devices that could cause crashes
|
|
208
|
+
with suppress(Exception):
|
|
209
|
+
self.unloadAllDevices()
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
self._last_sys_config = cfg_path
|
|
213
|
+
# Emit system configuration loaded event
|
|
214
|
+
self.events.systemConfigurationLoaded.emit()
|
|
215
|
+
|
|
216
|
+
def saveSystemConfiguration(
|
|
217
|
+
self, filename: str | Path, *, prefix_py_devices: bool = True
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Save the current system configuration to a text file.
|
|
220
|
+
|
|
221
|
+
This saves both C++ and Python devices. Python device lines are prefixed
|
|
222
|
+
with `#py ` by default so they are ignored by upstream C++/pymmcore.
|
|
223
|
+
|
|
224
|
+
Parameters
|
|
225
|
+
----------
|
|
226
|
+
filename : str | Path
|
|
227
|
+
Path to save the configuration file.
|
|
228
|
+
prefix_py_devices : bool, optional
|
|
229
|
+
If True (default), Python device lines are prefixed with `#py ` so
|
|
230
|
+
they are ignored by upstream C++/pymmcore implementations, allowing
|
|
231
|
+
config files to work with regular pymmcore. If False, Python device
|
|
232
|
+
lines are saved without the prefix (config will only be loadable by
|
|
233
|
+
UniMMCore).
|
|
234
|
+
"""
|
|
235
|
+
save_system_configuration(self, filename, prefix_py_devices=prefix_py_devices)
|
|
137
236
|
|
|
138
237
|
# ------------------------------------------------------------------------
|
|
139
238
|
# ----------------- Functionality for All Devices ------------------------
|
|
@@ -160,11 +259,11 @@ class UniMMCore(CMMCorePlus):
|
|
|
160
259
|
try:
|
|
161
260
|
CMMCorePlus.loadDevice(self, label, moduleName, deviceName)
|
|
162
261
|
except RuntimeError as e:
|
|
163
|
-
# it was a C++ device, should have worked ... raise the error
|
|
164
262
|
if moduleName not in super().getDeviceAdapterNames():
|
|
165
263
|
pydev = self._get_py_device_instance(moduleName, deviceName)
|
|
166
264
|
self.loadPyDevice(label, pydev)
|
|
167
265
|
return
|
|
266
|
+
# it was a C++ device, should have worked ... raise the error
|
|
168
267
|
if exc := self._load_error_with_info(label, moduleName, deviceName, str(e)):
|
|
169
268
|
raise exc from e
|
|
170
269
|
|
|
@@ -209,6 +308,10 @@ class UniMMCore(CMMCorePlus):
|
|
|
209
308
|
|
|
210
309
|
load_py_device = loadPyDevice
|
|
211
310
|
|
|
311
|
+
def isPyDevice(self, label: DeviceLabel | str) -> bool:
|
|
312
|
+
"""Returns True if the specified device label corresponds to a Python device."""
|
|
313
|
+
return label in self._pydevices
|
|
314
|
+
|
|
212
315
|
def unloadDevice(self, label: DeviceLabel | str) -> None:
|
|
213
316
|
if label not in self._pydevices: # pragma: no cover
|
|
214
317
|
return super().unloadDevice(label)
|
|
@@ -237,7 +340,7 @@ class UniMMCore(CMMCorePlus):
|
|
|
237
340
|
|
|
238
341
|
def getLoadedDevicesOfType(self, devType: int) -> tuple[DeviceLabel, ...]:
|
|
239
342
|
pydevs = self._pydevices.get_labels_of_type(devType)
|
|
240
|
-
return pydevs + super().getLoadedDevicesOfType(devType)
|
|
343
|
+
return pydevs + tuple(super().getLoadedDevicesOfType(devType))
|
|
241
344
|
|
|
242
345
|
def getDeviceType(self, label: str) -> DeviceType:
|
|
243
346
|
if label not in self._pydevices: # pragma: no cover
|
|
@@ -259,6 +362,67 @@ class UniMMCore(CMMCorePlus):
|
|
|
259
362
|
return super().getDeviceDescription(label)
|
|
260
363
|
return self._pydevices[label].description()
|
|
261
364
|
|
|
365
|
+
# ---------------------------- Parent/Hub Relationships ---------------------------
|
|
366
|
+
|
|
367
|
+
def getParentLabel(
|
|
368
|
+
self, peripheralLabel: DeviceLabel | str
|
|
369
|
+
) -> DeviceLabel | Literal[""]:
|
|
370
|
+
if peripheralLabel not in self._pydevices: # pragma: no cover
|
|
371
|
+
return super().getParentLabel(peripheralLabel)
|
|
372
|
+
return self._pydevices[peripheralLabel].get_parent_label() # type: ignore[return-value]
|
|
373
|
+
|
|
374
|
+
def setParentLabel(
|
|
375
|
+
self, deviceLabel: DeviceLabel | str, parentHubLabel: DeviceLabel | str
|
|
376
|
+
) -> None:
|
|
377
|
+
if deviceLabel == KW.CoreDevice:
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# Reject cross-language hub/peripheral relationships
|
|
381
|
+
device_is_py = deviceLabel in self._pydevices
|
|
382
|
+
parent_is_py = parentHubLabel in self._pydevices
|
|
383
|
+
if parentHubLabel and device_is_py != parent_is_py:
|
|
384
|
+
raise RuntimeError( # pragma: no cover
|
|
385
|
+
"Cannot set cross-language parent/child relationship between C++ and "
|
|
386
|
+
"Python devices"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
if device_is_py:
|
|
390
|
+
self._pydevices[deviceLabel].set_parent_label(parentHubLabel)
|
|
391
|
+
else:
|
|
392
|
+
super().setParentLabel(deviceLabel, parentHubLabel)
|
|
393
|
+
|
|
394
|
+
def getInstalledDevices(
|
|
395
|
+
self, hubLabel: DeviceLabel | str
|
|
396
|
+
) -> tuple[DeviceName, ...]:
|
|
397
|
+
if hubLabel not in self._pydevices: # pragma: no cover
|
|
398
|
+
return tuple(super().getInstalledDevices(hubLabel))
|
|
399
|
+
|
|
400
|
+
with self._pydevices.get_device_of_type(hubLabel, HubDevice) as hub:
|
|
401
|
+
peripherals = hub.get_installed_peripherals()
|
|
402
|
+
return tuple(p[0] for p in peripherals if p[0]) # type: ignore[misc]
|
|
403
|
+
|
|
404
|
+
def getLoadedPeripheralDevices(
|
|
405
|
+
self, hubLabel: DeviceLabel | str
|
|
406
|
+
) -> tuple[DeviceLabel, ...]:
|
|
407
|
+
cpp_peripherals = super().getLoadedPeripheralDevices(hubLabel)
|
|
408
|
+
py_peripherals = self._pydevices.get_loaded_peripherals(hubLabel)
|
|
409
|
+
return tuple(cpp_peripherals) + py_peripherals
|
|
410
|
+
|
|
411
|
+
def getInstalledDeviceDescription(
|
|
412
|
+
self, hubLabel: DeviceLabel | str, peripheralLabel: DeviceName | str
|
|
413
|
+
) -> str:
|
|
414
|
+
if hubLabel not in self._pydevices:
|
|
415
|
+
return super().getInstalledDeviceDescription(hubLabel, peripheralLabel)
|
|
416
|
+
|
|
417
|
+
with self._pydevices.get_device_of_type(hubLabel, HubDevice) as hub:
|
|
418
|
+
for p in hub.get_installed_peripherals():
|
|
419
|
+
if p[0] == peripheralLabel:
|
|
420
|
+
return p[1] or "N/A"
|
|
421
|
+
raise RuntimeError( # pragma: no cover
|
|
422
|
+
f"No peripheral with name {peripheralLabel!r} installed in hub "
|
|
423
|
+
f"{hubLabel!r}"
|
|
424
|
+
)
|
|
425
|
+
|
|
262
426
|
# ---------------------------- Properties ---------------------------
|
|
263
427
|
|
|
264
428
|
def getDevicePropertyNames(
|
|
@@ -296,6 +460,12 @@ class UniMMCore(CMMCorePlus):
|
|
|
296
460
|
def setProperty(
|
|
297
461
|
self, label: str, propName: str, propValue: bool | float | int | str
|
|
298
462
|
) -> None:
|
|
463
|
+
# FIXME:
|
|
464
|
+
# this single case is probably just the tip of the iceberg when label is "Core"
|
|
465
|
+
if label == KW.CoreDevice and propName == KW.CoreChannelGroup:
|
|
466
|
+
self.setChannelGroup(str(propValue))
|
|
467
|
+
return
|
|
468
|
+
|
|
299
469
|
if label not in self._pydevices: # pragma: no cover
|
|
300
470
|
return super().setProperty(label, propName, propValue)
|
|
301
471
|
with self._pydevices[label] as dev:
|
|
@@ -415,7 +585,21 @@ class UniMMCore(CMMCorePlus):
|
|
|
415
585
|
return super().waitForDevice(label)
|
|
416
586
|
self._pydevices.wait_for(label, self.getTimeoutMs())
|
|
417
587
|
|
|
418
|
-
|
|
588
|
+
def waitForConfig(self, group: str, configName: str) -> None:
|
|
589
|
+
# Get config data (merged from C++ and Python)
|
|
590
|
+
cfg = self.getConfigData(group, configName, native=True)
|
|
591
|
+
|
|
592
|
+
# Wait for each unique device in the config
|
|
593
|
+
devs_to_await: set[str] = set()
|
|
594
|
+
for i in range(cfg.size()):
|
|
595
|
+
devs_to_await.add(cfg.getSetting(i).getDeviceLabel())
|
|
596
|
+
|
|
597
|
+
for device in devs_to_await:
|
|
598
|
+
try:
|
|
599
|
+
self.waitForDevice(device)
|
|
600
|
+
except Exception:
|
|
601
|
+
# Like C++, trap exceptions and keep quiet
|
|
602
|
+
pass
|
|
419
603
|
|
|
420
604
|
# probably only needed because C++ method is not virtual
|
|
421
605
|
def systemBusy(self) -> bool:
|
|
@@ -653,6 +837,192 @@ class UniMMCore(CMMCorePlus):
|
|
|
653
837
|
with self._pydevices.get_device_of_type(label, XYStageDevice) as dev:
|
|
654
838
|
dev.stop_sequence()
|
|
655
839
|
|
|
840
|
+
# ########################################################################
|
|
841
|
+
# ----------------------------- StageDevice ------------------------------
|
|
842
|
+
# ########################################################################
|
|
843
|
+
|
|
844
|
+
def getFocusDevice(self) -> DeviceLabel | Literal[""]:
|
|
845
|
+
"""Return the current Focus Device."""
|
|
846
|
+
return self._pycore.current(KW.CoreFocus) or super().getFocusDevice()
|
|
847
|
+
|
|
848
|
+
def setFocusDevice(self, focusLabel: str) -> None:
|
|
849
|
+
"""Set new current Focus Device."""
|
|
850
|
+
try:
|
|
851
|
+
super().setFocusDevice(focusLabel)
|
|
852
|
+
except Exception:
|
|
853
|
+
# python device
|
|
854
|
+
if focusLabel in self._pydevices:
|
|
855
|
+
if self.getDeviceType(focusLabel) == DeviceType.StageDevice:
|
|
856
|
+
# assign focus device
|
|
857
|
+
label = self._set_current_if_pydevice(KW.CoreFocus, focusLabel)
|
|
858
|
+
super().setFocusDevice(label)
|
|
859
|
+
# otherwise do nothing
|
|
860
|
+
|
|
861
|
+
@overload
|
|
862
|
+
def getPosition(self) -> float: ...
|
|
863
|
+
@overload
|
|
864
|
+
def getPosition(self, stageLabel: str) -> float: ...
|
|
865
|
+
def getPosition(self, stageLabel: str | None = None) -> float:
|
|
866
|
+
label = stageLabel or self.getFocusDevice()
|
|
867
|
+
if label not in self._pydevices:
|
|
868
|
+
return super().getPosition(label)
|
|
869
|
+
with self._pydevices.get_device_of_type(label, StageDevice) as device:
|
|
870
|
+
return device.get_position_um()
|
|
871
|
+
|
|
872
|
+
@overload
|
|
873
|
+
def setPosition(self, position: float, /) -> None: ...
|
|
874
|
+
@overload
|
|
875
|
+
def setPosition(
|
|
876
|
+
self, stageLabel: DeviceLabel | str, position: float, /
|
|
877
|
+
) -> None: ...
|
|
878
|
+
def setPosition(self, *args: Any) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
879
|
+
label, args = _ensure_label(args, min_args=2, getter=self.getFocusDevice)
|
|
880
|
+
if label not in self._pydevices: # pragma: no cover
|
|
881
|
+
return super().setPosition(label, *args)
|
|
882
|
+
with self._pydevices.get_device_of_type(label, StageDevice) as dev:
|
|
883
|
+
dev.set_position_um(*args)
|
|
884
|
+
|
|
885
|
+
def setFocusDirection(self, stageLabel: DeviceLabel | str, sign: int) -> None:
|
|
886
|
+
if stageLabel not in self._pydevices: # pragma: no cover
|
|
887
|
+
return super().setFocusDirection(stageLabel, sign)
|
|
888
|
+
with self._pydevices.get_device_of_type(stageLabel, StageDevice) as device:
|
|
889
|
+
device.set_focus_direction(sign)
|
|
890
|
+
|
|
891
|
+
def getFocusDirection(self, stageLabel: DeviceLabel | str) -> FocusDirection:
|
|
892
|
+
"""Get the current focus direction of the Z stage."""
|
|
893
|
+
if stageLabel not in self._pydevices: # pragma: no cover
|
|
894
|
+
return super().getFocusDirection(stageLabel)
|
|
895
|
+
with self._pydevices.get_device_of_type(stageLabel, StageDevice) as device:
|
|
896
|
+
return device.get_focus_direction()
|
|
897
|
+
|
|
898
|
+
@overload
|
|
899
|
+
def setOrigin(self) -> None: ...
|
|
900
|
+
@overload
|
|
901
|
+
def setOrigin(self, stageLabel: DeviceLabel | str) -> None: ...
|
|
902
|
+
def setOrigin(self, stageLabel: DeviceLabel | str | None = None) -> None:
|
|
903
|
+
"""Zero the current focus/Z stage's coordinates at the current position."""
|
|
904
|
+
label = stageLabel or self.getFocusDevice()
|
|
905
|
+
if label not in self._pydevices: # pragma: no cover
|
|
906
|
+
return super().setOrigin(label)
|
|
907
|
+
with self._pydevices.get_device_of_type(label, StageDevice) as device:
|
|
908
|
+
device.set_origin()
|
|
909
|
+
|
|
910
|
+
@overload
|
|
911
|
+
def setRelativePosition(self, d: float, /) -> None: ...
|
|
912
|
+
@overload
|
|
913
|
+
def setRelativePosition(
|
|
914
|
+
self, stageLabel: DeviceLabel | str, d: float, /
|
|
915
|
+
) -> None: ...
|
|
916
|
+
def setRelativePosition(self, *args: Any) -> None:
|
|
917
|
+
"""Sets the relative position of the stage in microns."""
|
|
918
|
+
label, args = _ensure_label(args, min_args=2, getter=self.getFocusDevice)
|
|
919
|
+
if label not in self._pydevices: # pragma: no cover
|
|
920
|
+
return super().setRelativePosition(label, *args)
|
|
921
|
+
with self._pydevices.get_device_of_type(label, StageDevice) as dev:
|
|
922
|
+
dev.set_relative_position_um(*args)
|
|
923
|
+
|
|
924
|
+
@overload
|
|
925
|
+
def setAdapterOrigin(self, newZUm: float, /) -> None: ...
|
|
926
|
+
@overload
|
|
927
|
+
def setAdapterOrigin(
|
|
928
|
+
self, stageLabel: DeviceLabel | str, newZUm: float, /
|
|
929
|
+
) -> None: ...
|
|
930
|
+
def setAdapterOrigin(self, *args: Any) -> None:
|
|
931
|
+
"""Enable software translation of coordinates for the current focus/Z stage.
|
|
932
|
+
|
|
933
|
+
The current position of the stage becomes Z = newZUm. Only some stages
|
|
934
|
+
support this functionality; it is recommended that setOrigin() be used
|
|
935
|
+
instead where available.
|
|
936
|
+
"""
|
|
937
|
+
label, args = _ensure_label(args, min_args=2, getter=self.getFocusDevice)
|
|
938
|
+
if label not in self._pydevices: # pragma: no cover
|
|
939
|
+
return super().setAdapterOrigin(label, *args)
|
|
940
|
+
with self._pydevices.get_device_of_type(label, StageDevice) as dev:
|
|
941
|
+
dev.set_adapter_origin_um(*args)
|
|
942
|
+
|
|
943
|
+
def isStageSequenceable(self, stageLabel: DeviceLabel | str) -> bool:
|
|
944
|
+
"""Queries stage if it can be used in a sequence."""
|
|
945
|
+
if stageLabel not in self._pydevices: # pragma: no cover
|
|
946
|
+
return super().isStageSequenceable(stageLabel)
|
|
947
|
+
dev = self._pydevices.get_device_of_type(stageLabel, StageDevice)
|
|
948
|
+
return dev.is_sequenceable()
|
|
949
|
+
|
|
950
|
+
def isStageLinearSequenceable(self, stageLabel: DeviceLabel | str) -> bool:
|
|
951
|
+
"""Queries if the stage can be used in a linear sequence.
|
|
952
|
+
|
|
953
|
+
A linear sequence is defined by a step size and number of slices.
|
|
954
|
+
"""
|
|
955
|
+
if stageLabel not in self._pydevices: # pragma: no cover
|
|
956
|
+
return super().isStageLinearSequenceable(stageLabel)
|
|
957
|
+
dev = self._pydevices.get_device_of_type(stageLabel, StageDevice)
|
|
958
|
+
return dev.is_linear_sequenceable()
|
|
959
|
+
|
|
960
|
+
def getStageSequenceMaxLength(self, stageLabel: DeviceLabel | str) -> int:
|
|
961
|
+
"""Gets the maximum length of a stage's position sequence."""
|
|
962
|
+
if stageLabel not in self._pydevices: # pragma: no cover
|
|
963
|
+
return super().getStageSequenceMaxLength(stageLabel)
|
|
964
|
+
dev = self._pydevices.get_device_of_type(stageLabel, StageDevice)
|
|
965
|
+
return dev.get_sequence_max_length()
|
|
966
|
+
|
|
967
|
+
def loadStageSequence(
|
|
968
|
+
self,
|
|
969
|
+
stageLabel: DeviceLabel | str,
|
|
970
|
+
positionSequence: Sequence[float],
|
|
971
|
+
) -> None:
|
|
972
|
+
"""Transfer a sequence of stage positions to the stage.
|
|
973
|
+
|
|
974
|
+
This should only be called for stages that are sequenceable.
|
|
975
|
+
"""
|
|
976
|
+
if stageLabel not in self._pydevices: # pragma: no cover
|
|
977
|
+
return super().loadStageSequence(stageLabel, positionSequence)
|
|
978
|
+
dev = self._pydevices.get_device_of_type(stageLabel, StageDevice)
|
|
979
|
+
if len(positionSequence) > dev.get_sequence_max_length():
|
|
980
|
+
raise ValueError(
|
|
981
|
+
f"Sequence is too long. Max length is {dev.get_sequence_max_length()}"
|
|
982
|
+
)
|
|
983
|
+
dev.send_sequence(tuple(positionSequence))
|
|
984
|
+
|
|
985
|
+
def startStageSequence(self, stageLabel: DeviceLabel | str) -> None:
|
|
986
|
+
"""Starts an ongoing sequence of triggered events in a stage.
|
|
987
|
+
|
|
988
|
+
This should only be called for stages that are sequenceable.
|
|
989
|
+
"""
|
|
990
|
+
if stageLabel not in self._pydevices: # pragma: no cover
|
|
991
|
+
return super().startStageSequence(stageLabel)
|
|
992
|
+
with self._pydevices.get_device_of_type(stageLabel, StageDevice) as dev:
|
|
993
|
+
dev.start_sequence()
|
|
994
|
+
|
|
995
|
+
def stopStageSequence(self, stageLabel: DeviceLabel | str) -> None:
|
|
996
|
+
"""Stops an ongoing sequence of triggered events in a stage.
|
|
997
|
+
|
|
998
|
+
This should only be called for stages that are sequenceable.
|
|
999
|
+
"""
|
|
1000
|
+
if stageLabel not in self._pydevices: # pragma: no cover
|
|
1001
|
+
return super().stopStageSequence(stageLabel)
|
|
1002
|
+
with self._pydevices.get_device_of_type(stageLabel, StageDevice) as dev:
|
|
1003
|
+
dev.stop_sequence()
|
|
1004
|
+
|
|
1005
|
+
def setStageLinearSequence(
|
|
1006
|
+
self, stageLabel: DeviceLabel | str, dZ_um: float, nSlices: int
|
|
1007
|
+
) -> None:
|
|
1008
|
+
"""Loads a linear sequence (defined by step size and nr. of steps)."""
|
|
1009
|
+
if nSlices < 0:
|
|
1010
|
+
raise ValueError("Linear sequence cannot have negative length")
|
|
1011
|
+
if stageLabel not in self._pydevices: # pragma: no cover
|
|
1012
|
+
return super().setStageLinearSequence(stageLabel, dZ_um, nSlices)
|
|
1013
|
+
with self._pydevices.get_device_of_type(stageLabel, StageDevice) as dev:
|
|
1014
|
+
dev.set_linear_sequence(dZ_um, nSlices)
|
|
1015
|
+
|
|
1016
|
+
def isContinuousFocusDrive(self, stageLabel: DeviceLabel | str) -> bool:
|
|
1017
|
+
"""Check if a stage has continuous focusing capability.
|
|
1018
|
+
|
|
1019
|
+
Returns True if positions can be set while continuous focus runs.
|
|
1020
|
+
"""
|
|
1021
|
+
if stageLabel not in self._pydevices: # pragma: no cover
|
|
1022
|
+
return super().isContinuousFocusDrive(stageLabel)
|
|
1023
|
+
dev = self._pydevices.get_device_of_type(stageLabel, StageDevice)
|
|
1024
|
+
return dev.is_continuous_focus_drive()
|
|
1025
|
+
|
|
656
1026
|
# -----------------------------------------------------------------------
|
|
657
1027
|
# ---------------------------- Any Stage --------------------------------
|
|
658
1028
|
# -----------------------------------------------------------------------
|
|
@@ -1660,6 +2030,437 @@ class UniMMCore(CMMCorePlus):
|
|
|
1660
2030
|
with shutter:
|
|
1661
2031
|
shutter.set_open(state)
|
|
1662
2032
|
|
|
2033
|
+
# ########################################################################
|
|
2034
|
+
# -------------------- Configuration Group Methods -----------------------
|
|
2035
|
+
# ########################################################################
|
|
2036
|
+
#
|
|
2037
|
+
# HYBRID PATTERN: Config groups exist in both C++ and Python.
|
|
2038
|
+
# - Groups and presets are ALWAYS created in C++ (via super())
|
|
2039
|
+
# - _config_groups only stores settings for PYTHON devices
|
|
2040
|
+
# - Methods merge results from both systems where appropriate
|
|
2041
|
+
#
|
|
2042
|
+
# This ensures:
|
|
2043
|
+
# - C++ CoreCallback can find configs and emit proper events
|
|
2044
|
+
# - defineStateLabel in C++ can update configs referencing C++ devices
|
|
2045
|
+
# - loadSystemConfiguration works correctly
|
|
2046
|
+
# - Python device settings are properly tracked and applied
|
|
2047
|
+
|
|
2048
|
+
# -------------------------------------------------------------------------
|
|
2049
|
+
# Group-level operations
|
|
2050
|
+
# -------------------------------------------------------------------------
|
|
2051
|
+
|
|
2052
|
+
def defineConfigGroup(self, groupName: str) -> None:
|
|
2053
|
+
# Create group in C++ (handles validation and events)
|
|
2054
|
+
super().defineConfigGroup(groupName)
|
|
2055
|
+
# Also create empty group in Python for potential Python device settings
|
|
2056
|
+
self._py_config_groups[groupName] = {}
|
|
2057
|
+
|
|
2058
|
+
def deleteConfigGroup(self, groupName: str) -> None:
|
|
2059
|
+
# Delete from C++ (handles validation and events)
|
|
2060
|
+
super().deleteConfigGroup(groupName)
|
|
2061
|
+
self._py_config_groups.pop(groupName, None)
|
|
2062
|
+
|
|
2063
|
+
def renameConfigGroup(self, oldGroupName: str, newGroupName: str) -> None:
|
|
2064
|
+
# Rename in C++ (handles validation)
|
|
2065
|
+
super().renameConfigGroup(oldGroupName, newGroupName)
|
|
2066
|
+
if oldGroupName in self._py_config_groups:
|
|
2067
|
+
self._py_config_groups[newGroupName] = self._py_config_groups.pop(
|
|
2068
|
+
oldGroupName
|
|
2069
|
+
)
|
|
2070
|
+
|
|
2071
|
+
# -------------------------------------------------------------------------
|
|
2072
|
+
# Preset-level operations
|
|
2073
|
+
# -------------------------------------------------------------------------
|
|
2074
|
+
|
|
2075
|
+
@overload
|
|
2076
|
+
def defineConfig(self, groupName: str, configName: str) -> None: ...
|
|
2077
|
+
@overload
|
|
2078
|
+
def defineConfig(
|
|
2079
|
+
self,
|
|
2080
|
+
groupName: str,
|
|
2081
|
+
configName: str,
|
|
2082
|
+
deviceLabel: str,
|
|
2083
|
+
propName: str,
|
|
2084
|
+
value: Any,
|
|
2085
|
+
) -> None: ...
|
|
2086
|
+
def defineConfig(
|
|
2087
|
+
self,
|
|
2088
|
+
groupName: str,
|
|
2089
|
+
configName: str,
|
|
2090
|
+
deviceLabel: str | None = None,
|
|
2091
|
+
propName: str | None = None,
|
|
2092
|
+
value: Any | None = None,
|
|
2093
|
+
) -> None:
|
|
2094
|
+
# Route to appropriate storage based on device type
|
|
2095
|
+
if deviceLabel is None or propName is None or value is None:
|
|
2096
|
+
# No device specified: just create empty group/preset in C++
|
|
2097
|
+
super().defineConfig(groupName, configName)
|
|
2098
|
+
# Also ensure Python storage has the structure
|
|
2099
|
+
group = self._py_config_groups.setdefault(groupName, {})
|
|
2100
|
+
group.setdefault(cast("ConfigPresetName", configName), {})
|
|
2101
|
+
|
|
2102
|
+
elif deviceLabel in self._pydevices:
|
|
2103
|
+
# Python device: store in our _config_groups
|
|
2104
|
+
# But first ensure the group/preset exists in C++ too
|
|
2105
|
+
if not super().isGroupDefined(groupName):
|
|
2106
|
+
super().defineConfigGroup(groupName)
|
|
2107
|
+
self._py_config_groups[groupName] = {}
|
|
2108
|
+
if not super().isConfigDefined(groupName, configName):
|
|
2109
|
+
super().defineConfig(groupName, configName)
|
|
2110
|
+
|
|
2111
|
+
# Store Python device setting locally
|
|
2112
|
+
group = self._py_config_groups.setdefault(groupName, {})
|
|
2113
|
+
preset = group.setdefault(cast("ConfigPresetName", configName), {})
|
|
2114
|
+
preset[(deviceLabel, propName)] = value
|
|
2115
|
+
# Emit event (C++ won't emit for Python device settings)
|
|
2116
|
+
self.events.configDefined.emit(
|
|
2117
|
+
groupName, configName, deviceLabel, propName, str(value)
|
|
2118
|
+
)
|
|
2119
|
+
else:
|
|
2120
|
+
# C++ device: let C++ handle it entirely
|
|
2121
|
+
# C++ expects string values, so convert
|
|
2122
|
+
super().defineConfig(
|
|
2123
|
+
groupName, configName, deviceLabel, propName, str(value)
|
|
2124
|
+
)
|
|
2125
|
+
# Ensure our Python storage has the group/preset structure
|
|
2126
|
+
group = self._py_config_groups.setdefault(groupName, {})
|
|
2127
|
+
group.setdefault(cast("ConfigPresetName", configName), {})
|
|
2128
|
+
|
|
2129
|
+
@overload
|
|
2130
|
+
def deleteConfig(self, groupName: str, configName: str) -> None: ...
|
|
2131
|
+
@overload
|
|
2132
|
+
def deleteConfig(
|
|
2133
|
+
self, groupName: str, configName: str, deviceLabel: str, propName: str
|
|
2134
|
+
) -> None: ...
|
|
2135
|
+
def deleteConfig(
|
|
2136
|
+
self,
|
|
2137
|
+
groupName: str,
|
|
2138
|
+
configName: str,
|
|
2139
|
+
deviceLabel: str | None = None,
|
|
2140
|
+
propName: str | None = None,
|
|
2141
|
+
) -> None:
|
|
2142
|
+
if deviceLabel is None or propName is None:
|
|
2143
|
+
# Deleting entire preset: delete from both C++ and Python storage
|
|
2144
|
+
py_group = self._py_config_groups.get(groupName, {})
|
|
2145
|
+
py_group.pop(configName, None) # type: ignore[call-overload]
|
|
2146
|
+
super().deleteConfig(groupName, configName)
|
|
2147
|
+
|
|
2148
|
+
# Deleting a specific property from a preset
|
|
2149
|
+
elif deviceLabel in self._pydevices:
|
|
2150
|
+
# Python device: remove from our storage
|
|
2151
|
+
py_group = self._py_config_groups.get(groupName, {})
|
|
2152
|
+
py_preset = py_group.get(configName, {}) # type: ignore[call-overload]
|
|
2153
|
+
key = (deviceLabel, propName)
|
|
2154
|
+
if key in py_preset:
|
|
2155
|
+
del py_preset[key]
|
|
2156
|
+
self.events.configDeleted.emit(groupName, configName)
|
|
2157
|
+
else:
|
|
2158
|
+
raise RuntimeError(
|
|
2159
|
+
f"Property '{propName}' not found in preset '{configName}'"
|
|
2160
|
+
)
|
|
2161
|
+
else:
|
|
2162
|
+
# C++ device: let C++ handle it
|
|
2163
|
+
super().deleteConfig(groupName, configName, deviceLabel, propName)
|
|
2164
|
+
|
|
2165
|
+
def renameConfig(
|
|
2166
|
+
self, groupName: str, oldConfigName: str, newConfigName: str
|
|
2167
|
+
) -> None:
|
|
2168
|
+
# Rename in C++ (handles validation)
|
|
2169
|
+
super().renameConfig(groupName, oldConfigName, newConfigName)
|
|
2170
|
+
# Also rename in Python storage if present
|
|
2171
|
+
py_group = self._py_config_groups.get(groupName, {})
|
|
2172
|
+
if oldConfigName in py_group:
|
|
2173
|
+
py_group[newConfigName] = py_group.pop(oldConfigName) # type: ignore
|
|
2174
|
+
|
|
2175
|
+
@overload
|
|
2176
|
+
def getConfigData(
|
|
2177
|
+
self, configGroup: str, configName: str, *, native: Literal[True]
|
|
2178
|
+
) -> pymmcore.Configuration: ...
|
|
2179
|
+
@overload
|
|
2180
|
+
def getConfigData(
|
|
2181
|
+
self, configGroup: str, configName: str, *, native: Literal[False] = False
|
|
2182
|
+
) -> Configuration: ...
|
|
2183
|
+
def getConfigData(
|
|
2184
|
+
self, configGroup: str, configName: str, *, native: bool = False
|
|
2185
|
+
) -> Configuration | pymmcore.Configuration:
|
|
2186
|
+
# Get C++ config data (includes all C++ device settings)
|
|
2187
|
+
cpp_cfg: pymmcore.Configuration = super().getConfigData(
|
|
2188
|
+
configGroup, configName, native=True
|
|
2189
|
+
)
|
|
2190
|
+
|
|
2191
|
+
# Add Python device settings from our storage
|
|
2192
|
+
py_group = self._py_config_groups.get(configGroup, {})
|
|
2193
|
+
py_preset = py_group.get(configName, {}) # type: ignore[call-overload]
|
|
2194
|
+
for (dev, prop), value in py_preset.items():
|
|
2195
|
+
cpp_cfg.addSetting(pymmcore.PropertySetting(dev, prop, str(value)))
|
|
2196
|
+
|
|
2197
|
+
if native:
|
|
2198
|
+
return cpp_cfg
|
|
2199
|
+
return Configuration.from_configuration(cpp_cfg)
|
|
2200
|
+
|
|
2201
|
+
# -------------------------------------------------------------------------
|
|
2202
|
+
# Applying configurations
|
|
2203
|
+
# -------------------------------------------------------------------------
|
|
2204
|
+
|
|
2205
|
+
def setConfig(self, groupName: str, configName: str) -> None:
|
|
2206
|
+
# Apply C++ device settings via super() - this handles validation,
|
|
2207
|
+
# error retry logic, and state cache updates for C++ devices
|
|
2208
|
+
super().setConfig(groupName, configName)
|
|
2209
|
+
|
|
2210
|
+
# Now apply Python device settings from our storage
|
|
2211
|
+
py_group = self._py_config_groups.get(groupName, {})
|
|
2212
|
+
py_preset = py_group.get(configName, {}) # type: ignore[call-overload]
|
|
2213
|
+
|
|
2214
|
+
if py_preset:
|
|
2215
|
+
failed: list[tuple[DevPropTuple, Any]] = []
|
|
2216
|
+
for (device, prop), value in py_preset.items():
|
|
2217
|
+
try:
|
|
2218
|
+
self.setProperty(device, prop, value)
|
|
2219
|
+
except Exception:
|
|
2220
|
+
failed.append(((device, prop), value))
|
|
2221
|
+
|
|
2222
|
+
# Retry failed properties (handles dependency chains)
|
|
2223
|
+
if failed:
|
|
2224
|
+
errors: list[str] = []
|
|
2225
|
+
for (device, prop), value in failed:
|
|
2226
|
+
try:
|
|
2227
|
+
self.setProperty(device, prop, value)
|
|
2228
|
+
except Exception as e:
|
|
2229
|
+
errors.append(f"{device}.{prop}={value}: {e}")
|
|
2230
|
+
if errors:
|
|
2231
|
+
raise RuntimeError("Failed to apply: " + "; ".join(errors))
|
|
2232
|
+
|
|
2233
|
+
# -------------------------------------------------------------------------
|
|
2234
|
+
# Current config detection
|
|
2235
|
+
# -------------------------------------------------------------------------
|
|
2236
|
+
|
|
2237
|
+
def getCurrentConfig(self, groupName: str) -> ConfigPresetName | Literal[""]:
|
|
2238
|
+
return self._getCurrentConfig(groupName, from_cache=False)
|
|
2239
|
+
|
|
2240
|
+
def getCurrentConfigFromCache(
|
|
2241
|
+
self, groupName: str
|
|
2242
|
+
) -> ConfigPresetName | Literal[""]:
|
|
2243
|
+
return self._getCurrentConfig(groupName, from_cache=True)
|
|
2244
|
+
|
|
2245
|
+
def _getCurrentConfig(
|
|
2246
|
+
self, groupName: str, from_cache: bool
|
|
2247
|
+
) -> ConfigPresetName | Literal[""]:
|
|
2248
|
+
"""Find the first preset whose settings all match current device state.
|
|
2249
|
+
|
|
2250
|
+
This checks both C++ device settings (via super()) and Python device settings.
|
|
2251
|
+
"""
|
|
2252
|
+
# Get C++ result first
|
|
2253
|
+
if from_cache:
|
|
2254
|
+
cpp_result = super().getCurrentConfigFromCache(groupName)
|
|
2255
|
+
else:
|
|
2256
|
+
cpp_result = super().getCurrentConfig(groupName)
|
|
2257
|
+
|
|
2258
|
+
# If no Python device settings exist for this group, C++ result is sufficient
|
|
2259
|
+
py_group = self._py_config_groups.get(groupName, {})
|
|
2260
|
+
has_py_settings = any(py_group.values())
|
|
2261
|
+
if not has_py_settings:
|
|
2262
|
+
return cpp_result
|
|
2263
|
+
|
|
2264
|
+
# We have Python device settings - need to verify they match too
|
|
2265
|
+
# Get current state of all Python device properties in this group
|
|
2266
|
+
getter = self.getPropertyFromCache if from_cache else self.getProperty
|
|
2267
|
+
current_py_state: ConfigDict = {}
|
|
2268
|
+
seen_keys: set[DevPropTuple] = set()
|
|
2269
|
+
for preset in py_group.values():
|
|
2270
|
+
for key in preset:
|
|
2271
|
+
if key not in seen_keys:
|
|
2272
|
+
seen_keys.add(key)
|
|
2273
|
+
with suppress(Exception):
|
|
2274
|
+
current_py_state[key] = getter(*key)
|
|
2275
|
+
|
|
2276
|
+
# Check each preset to see if Python device settings match
|
|
2277
|
+
for preset_name in self.getAvailableConfigs(groupName):
|
|
2278
|
+
py_preset = py_group.get(preset_name, {})
|
|
2279
|
+
if all(
|
|
2280
|
+
_values_match(current_py_state.get(k), v) for k, v in py_preset.items()
|
|
2281
|
+
):
|
|
2282
|
+
# Python settings match - but only return if C++ also matches
|
|
2283
|
+
# (or if there are no C++ settings for this preset)
|
|
2284
|
+
cpp_cfg = super().getConfigData(groupName, preset_name, native=True)
|
|
2285
|
+
if cpp_cfg.size() == 0:
|
|
2286
|
+
# No C++ settings, Python match is sufficient
|
|
2287
|
+
return preset_name
|
|
2288
|
+
# Check each C++ setting with numeric-aware comparison
|
|
2289
|
+
# (C++ getCurrentConfig uses strict string comparison which fails
|
|
2290
|
+
# for values like "50.0000" vs "50")
|
|
2291
|
+
all_cpp_match = True
|
|
2292
|
+
for i in range(cpp_cfg.size()):
|
|
2293
|
+
setting = cpp_cfg.getSetting(i)
|
|
2294
|
+
current_val = getter(
|
|
2295
|
+
setting.getDeviceLabel(), setting.getPropertyName()
|
|
2296
|
+
)
|
|
2297
|
+
if not _values_match(current_val, setting.getPropertyValue()):
|
|
2298
|
+
all_cpp_match = False
|
|
2299
|
+
break
|
|
2300
|
+
if all_cpp_match:
|
|
2301
|
+
return preset_name
|
|
2302
|
+
|
|
2303
|
+
return ""
|
|
2304
|
+
|
|
2305
|
+
# -------------------------------------------------------------------------
|
|
2306
|
+
# State queries
|
|
2307
|
+
# -------------------------------------------------------------------------
|
|
2308
|
+
|
|
2309
|
+
def getConfigState(
|
|
2310
|
+
self, group: str, config: str, *, native: bool = False
|
|
2311
|
+
) -> Configuration | pymmcore.Configuration:
|
|
2312
|
+
# Get C++ config state (current values for C++ device properties)
|
|
2313
|
+
cpp_state: pymmcore.Configuration = super().getConfigState(
|
|
2314
|
+
group, config, native=True
|
|
2315
|
+
)
|
|
2316
|
+
|
|
2317
|
+
# Add current values for Python device properties
|
|
2318
|
+
py_group = self._py_config_groups.get(group, {})
|
|
2319
|
+
py_preset = py_group.get(config, {}) # type: ignore[call-overload]
|
|
2320
|
+
for dev, prop in py_preset:
|
|
2321
|
+
current_value = self.getProperty(dev, prop)
|
|
2322
|
+
cpp_state.addSetting(
|
|
2323
|
+
pymmcore.PropertySetting(dev, prop, str(current_value))
|
|
2324
|
+
)
|
|
2325
|
+
|
|
2326
|
+
if native:
|
|
2327
|
+
return cpp_state
|
|
2328
|
+
return Configuration.from_configuration(cpp_state)
|
|
2329
|
+
|
|
2330
|
+
@overload
|
|
2331
|
+
def getConfigGroupState(
|
|
2332
|
+
self, group: str, *, native: Literal[True]
|
|
2333
|
+
) -> pymmcore.Configuration: ...
|
|
2334
|
+
@overload
|
|
2335
|
+
def getConfigGroupState(
|
|
2336
|
+
self, group: str, *, native: Literal[False] = False
|
|
2337
|
+
) -> Configuration: ...
|
|
2338
|
+
def getConfigGroupState(
|
|
2339
|
+
self, group: str, *, native: bool = False
|
|
2340
|
+
) -> Configuration | pymmcore.Configuration:
|
|
2341
|
+
return self._getConfigGroupState(group, from_cache=False, native=native)
|
|
2342
|
+
|
|
2343
|
+
@overload
|
|
2344
|
+
def getConfigGroupStateFromCache(
|
|
2345
|
+
self, group: str, *, native: Literal[True]
|
|
2346
|
+
) -> pymmcore.Configuration: ...
|
|
2347
|
+
@overload
|
|
2348
|
+
def getConfigGroupStateFromCache(
|
|
2349
|
+
self, group: str, *, native: Literal[False] = False
|
|
2350
|
+
) -> Configuration: ...
|
|
2351
|
+
def getConfigGroupStateFromCache(
|
|
2352
|
+
self, group: str, *, native: bool = False
|
|
2353
|
+
) -> Configuration | pymmcore.Configuration:
|
|
2354
|
+
return self._getConfigGroupState(group, from_cache=True, native=native)
|
|
2355
|
+
|
|
2356
|
+
def _getConfigGroupState(
|
|
2357
|
+
self, group: str, from_cache: bool, native: bool = False
|
|
2358
|
+
) -> Configuration | pymmcore.Configuration:
|
|
2359
|
+
"""Get current values for all properties in a group."""
|
|
2360
|
+
# Get C++ group state
|
|
2361
|
+
if from_cache:
|
|
2362
|
+
cpp_state: pymmcore.Configuration = super().getConfigGroupStateFromCache(
|
|
2363
|
+
group, native=True
|
|
2364
|
+
)
|
|
2365
|
+
else:
|
|
2366
|
+
cpp_state = super().getConfigGroupState(group, native=True)
|
|
2367
|
+
|
|
2368
|
+
# Add Python device property values
|
|
2369
|
+
py_group = self._py_config_groups.get(group, {})
|
|
2370
|
+
getter = self.getPropertyFromCache if from_cache else self.getProperty
|
|
2371
|
+
for preset in py_group.values():
|
|
2372
|
+
for device, prop in preset:
|
|
2373
|
+
value = str(getter(device, prop))
|
|
2374
|
+
cpp_state.addSetting(pymmcore.PropertySetting(device, prop, value))
|
|
2375
|
+
|
|
2376
|
+
if native:
|
|
2377
|
+
return cpp_state
|
|
2378
|
+
return Configuration.from_configuration(cpp_state)
|
|
2379
|
+
|
|
2380
|
+
# ########################################################################
|
|
2381
|
+
# ----------------------- System State Methods ---------------------------
|
|
2382
|
+
# ########################################################################
|
|
2383
|
+
|
|
2384
|
+
# currently we still allow C++ to cache the system state for C++ devices,
|
|
2385
|
+
# but we could choose to just own it all ourselves in the future.
|
|
2386
|
+
|
|
2387
|
+
def getSystemState(
|
|
2388
|
+
self, *, native: bool = False
|
|
2389
|
+
) -> Configuration | pymmcore.Configuration:
|
|
2390
|
+
"""Return the entire system state including Python device properties.
|
|
2391
|
+
|
|
2392
|
+
This method iterates through all devices (C++ and Python) and returns
|
|
2393
|
+
all property values. Following the C++ implementation pattern.
|
|
2394
|
+
"""
|
|
2395
|
+
return self._getSystemStateCache(cache=False, native=native)
|
|
2396
|
+
|
|
2397
|
+
@overload
|
|
2398
|
+
def getSystemStateCache(
|
|
2399
|
+
self, *, native: Literal[True]
|
|
2400
|
+
) -> pymmcore.Configuration: ...
|
|
2401
|
+
@overload
|
|
2402
|
+
def getSystemStateCache(
|
|
2403
|
+
self, *, native: Literal[False] = False
|
|
2404
|
+
) -> Configuration: ...
|
|
2405
|
+
def getSystemStateCache(
|
|
2406
|
+
self, *, native: bool = False
|
|
2407
|
+
) -> Configuration | pymmcore.Configuration:
|
|
2408
|
+
return self._getSystemStateCache(cache=True, native=native)
|
|
2409
|
+
|
|
2410
|
+
def _getSystemStateCache(
|
|
2411
|
+
self, cache: bool, native: bool = False
|
|
2412
|
+
) -> Configuration | pymmcore.Configuration:
|
|
2413
|
+
"""Return the entire system state from cache, including Python devices.
|
|
2414
|
+
|
|
2415
|
+
For Python devices, returns cached values from our state cache.
|
|
2416
|
+
Falls back to live values if not in cache.
|
|
2417
|
+
"""
|
|
2418
|
+
# Get the C++ system state cache first
|
|
2419
|
+
if cache:
|
|
2420
|
+
cpp_cfg: pymmcore.Configuration = super().getSystemStateCache(native=True)
|
|
2421
|
+
else:
|
|
2422
|
+
cpp_cfg = super().getSystemState(native=True)
|
|
2423
|
+
|
|
2424
|
+
# Add Python device properties from our cache
|
|
2425
|
+
for label in self._pydevices:
|
|
2426
|
+
with suppress(Exception): # Skip devices that can't be accessed
|
|
2427
|
+
with self._pydevices[label] as dev:
|
|
2428
|
+
for prop_name in dev.get_property_names():
|
|
2429
|
+
with suppress(Exception): # Skip properties that fail
|
|
2430
|
+
key = (label, prop_name)
|
|
2431
|
+
if cache and key in self._state_cache:
|
|
2432
|
+
value = self._state_cache[key]
|
|
2433
|
+
else:
|
|
2434
|
+
value = dev.get_property_value(prop_name)
|
|
2435
|
+
cpp_cfg.addSetting(
|
|
2436
|
+
pymmcore.PropertySetting(
|
|
2437
|
+
label,
|
|
2438
|
+
prop_name,
|
|
2439
|
+
str(value),
|
|
2440
|
+
dev.is_property_read_only(prop_name),
|
|
2441
|
+
)
|
|
2442
|
+
)
|
|
2443
|
+
|
|
2444
|
+
return cpp_cfg if native else Configuration.from_configuration(cpp_cfg)
|
|
2445
|
+
|
|
2446
|
+
def updateSystemStateCache(self) -> None:
|
|
2447
|
+
"""Update the system state cache for all devices including Python devices.
|
|
2448
|
+
|
|
2449
|
+
This populates our Python-side cache with current values from all
|
|
2450
|
+
Python devices, then calls the C++ updateSystemStateCache.
|
|
2451
|
+
"""
|
|
2452
|
+
# Update Python device properties in our cache
|
|
2453
|
+
for label in self._pydevices:
|
|
2454
|
+
with suppress(Exception): # Skip devices that can't be accessed
|
|
2455
|
+
with self._pydevices[label] as dev:
|
|
2456
|
+
for prop_name in dev.get_property_names():
|
|
2457
|
+
with suppress(Exception): # Skip properties that fail
|
|
2458
|
+
value = dev.get_property_value(prop_name)
|
|
2459
|
+
self._state_cache[(label, prop_name)] = value
|
|
2460
|
+
|
|
2461
|
+
# Call C++ updateSystemStateCache
|
|
2462
|
+
super().updateSystemStateCache()
|
|
2463
|
+
|
|
1663
2464
|
|
|
1664
2465
|
# -------------------------------------------------------------------------------
|
|
1665
2466
|
|
|
@@ -1682,7 +2483,7 @@ def _ensure_label(
|
|
|
1682
2483
|
return cast("str", args[0]), args[1:]
|
|
1683
2484
|
|
|
1684
2485
|
|
|
1685
|
-
class
|
|
2486
|
+
class ThreadSafeConfig(MutableMapping["DevPropTuple", Any]):
|
|
1686
2487
|
"""A thread-safe cache for property states.
|
|
1687
2488
|
|
|
1688
2489
|
Keys are tuples of (device_label, property_name), and values are the last known
|
|
@@ -1690,24 +2491,24 @@ class PropertyStateCache(MutableMapping[tuple[str, str], Any]):
|
|
|
1690
2491
|
"""
|
|
1691
2492
|
|
|
1692
2493
|
def __init__(self) -> None:
|
|
1693
|
-
self._store: dict[
|
|
2494
|
+
self._store: dict[DevPropTuple, Any] = {}
|
|
1694
2495
|
self._lock = threading.Lock()
|
|
1695
2496
|
|
|
1696
|
-
def __getitem__(self, key:
|
|
2497
|
+
def __getitem__(self, key: DevPropTuple) -> Any:
|
|
1697
2498
|
with self._lock:
|
|
1698
2499
|
try:
|
|
1699
2500
|
return self._store[key]
|
|
1700
2501
|
except KeyError: # pragma: no cover
|
|
1701
|
-
|
|
2502
|
+
dev, prop = key
|
|
1702
2503
|
raise KeyError(
|
|
1703
2504
|
f"Property {prop!r} of device {dev!r} not found in cache"
|
|
1704
2505
|
) from None
|
|
1705
2506
|
|
|
1706
|
-
def __setitem__(self, key:
|
|
2507
|
+
def __setitem__(self, key: DevPropTuple, value: Any) -> None:
|
|
1707
2508
|
with self._lock:
|
|
1708
2509
|
self._store[key] = value
|
|
1709
2510
|
|
|
1710
|
-
def __delitem__(self, key:
|
|
2511
|
+
def __delitem__(self, key: DevPropTuple) -> None:
|
|
1711
2512
|
with self._lock:
|
|
1712
2513
|
del self._store[key]
|
|
1713
2514
|
|
|
@@ -1715,7 +2516,7 @@ class PropertyStateCache(MutableMapping[tuple[str, str], Any]):
|
|
|
1715
2516
|
with self._lock:
|
|
1716
2517
|
return key in self._store
|
|
1717
2518
|
|
|
1718
|
-
def __iter__(self) -> Iterator[
|
|
2519
|
+
def __iter__(self) -> Iterator[DevPropTuple]:
|
|
1719
2520
|
with self._lock:
|
|
1720
2521
|
return iter(self._store.copy()) # Prevent modifications during iteration
|
|
1721
2522
|
|
|
@@ -1766,4 +2567,19 @@ class AcquisitionThread(threading.Thread):
|
|
|
1766
2567
|
) from e
|
|
1767
2568
|
|
|
1768
2569
|
|
|
1769
|
-
#
|
|
2570
|
+
# --------- helpers -------------------------------------------------------
|
|
2571
|
+
|
|
2572
|
+
|
|
2573
|
+
def _values_match(current: Any, expected: Any) -> bool:
|
|
2574
|
+
"""Compare property values, handling numeric string comparisons.
|
|
2575
|
+
|
|
2576
|
+
Unlike C++ MMCore which does strict string comparison, this performs
|
|
2577
|
+
numeric-aware comparison to handle cases like "50.0000" == 50.
|
|
2578
|
+
"""
|
|
2579
|
+
if current == expected:
|
|
2580
|
+
return True
|
|
2581
|
+
# Try numeric comparison
|
|
2582
|
+
try:
|
|
2583
|
+
return float(current) == float(expected)
|
|
2584
|
+
except (ValueError, TypeError):
|
|
2585
|
+
return str(current) == str(expected)
|