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.
Files changed (35) hide show
  1. pymmcore_plus/__init__.py +20 -1
  2. pymmcore_plus/_accumulator.py +23 -5
  3. pymmcore_plus/_cli.py +44 -26
  4. pymmcore_plus/_discovery.py +344 -0
  5. pymmcore_plus/_ipy_completion.py +1 -1
  6. pymmcore_plus/_logger.py +3 -3
  7. pymmcore_plus/_util.py +9 -245
  8. pymmcore_plus/core/_device.py +57 -13
  9. pymmcore_plus/core/_mmcore_plus.py +20 -23
  10. pymmcore_plus/core/_property.py +35 -29
  11. pymmcore_plus/core/_sequencing.py +2 -0
  12. pymmcore_plus/core/events/_device_signal_view.py +8 -1
  13. pymmcore_plus/experimental/simulate/__init__.py +88 -0
  14. pymmcore_plus/experimental/simulate/_objects.py +670 -0
  15. pymmcore_plus/experimental/simulate/_render.py +510 -0
  16. pymmcore_plus/experimental/simulate/_sample.py +156 -0
  17. pymmcore_plus/experimental/unicore/__init__.py +2 -0
  18. pymmcore_plus/experimental/unicore/_device_manager.py +46 -13
  19. pymmcore_plus/experimental/unicore/core/_config.py +706 -0
  20. pymmcore_plus/experimental/unicore/core/_unicore.py +834 -18
  21. pymmcore_plus/experimental/unicore/devices/_device_base.py +13 -0
  22. pymmcore_plus/experimental/unicore/devices/_hub.py +50 -0
  23. pymmcore_plus/experimental/unicore/devices/_stage.py +46 -1
  24. pymmcore_plus/experimental/unicore/devices/_state.py +6 -0
  25. pymmcore_plus/install.py +149 -18
  26. pymmcore_plus/mda/_engine.py +268 -73
  27. pymmcore_plus/mda/handlers/_5d_writer_base.py +16 -5
  28. pymmcore_plus/mda/handlers/_tensorstore_handler.py +7 -1
  29. pymmcore_plus/metadata/_ome.py +553 -0
  30. pymmcore_plus/metadata/functions.py +2 -1
  31. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/METADATA +7 -4
  32. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/RECORD +35 -27
  33. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/WHEEL +1 -1
  34. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/entry_points.txt +0 -0
  35. {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 XYStageDevice, _BaseStage
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: PropertyStateCache) -> None:
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 = PropertyStateCache() # threadsafe cache for property states
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.waitForSystem()
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
- super().reset()
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
- # def waitForConfig
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 PropertyStateCache(MutableMapping[tuple[str, str], Any]):
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[tuple[str, str], Any] = {}
2494
+ self._store: dict[DevPropTuple, Any] = {}
1694
2495
  self._lock = threading.Lock()
1695
2496
 
1696
- def __getitem__(self, key: tuple[str, str]) -> Any:
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
- prop, dev = key
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: tuple[str, str], value: Any) -> None:
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: tuple[str, str]) -> None:
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[tuple[str, str]]:
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)