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
@@ -0,0 +1,706 @@
1
+ """Config file loading and saving for UniMMCore.
2
+
3
+ This module provides Python-owned config file loading/saving that supports
4
+ both C++ and Python devices. Python device lines use a `#py ` prefix so they
5
+ are treated as comments by upstream C++/pymmcore implementations.
6
+
7
+ Format example:
8
+ # C++ devices
9
+ Device,Camera,DemoCamera,DCam
10
+ Property,Core,Initialize,1
11
+
12
+ # Python devices (hidden from upstream via comment prefix)
13
+ #py pyDevice,PyCamera,mypackage.cameras,MyCameraClass
14
+ #py Property,PyCamera,Exposure,50.0
15
+ #py Property,Core,Camera,PyCamera
16
+
17
+ # Config groups can mix both
18
+ ConfigGroup,Channel,DAPI,Dichroic,Label,400DCLP
19
+ #py ConfigGroup,Channel,DAPI,PyFilter,Position,Blue
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import warnings
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any, Callable
27
+
28
+ from pymmcore_plus import CFGCommand, CFGGroup, DeviceType, Keyword
29
+ from pymmcore_plus._util import timestamp
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Iterable, Sequence
33
+
34
+ from ._unicore import UniMMCore
35
+
36
+ __all__ = ["load_system_configuration", "save_system_configuration"]
37
+
38
+ # Prefix for Python device lines (treated as comment by C++/regular pymmcore)
39
+ PY_PREFIX = "#py "
40
+ # Custom command names (not in CFGCommand enum)
41
+ _PY_DEVICE_CMD = "pyDevice"
42
+
43
+
44
+ # =============================================================================
45
+ # Loading
46
+ # =============================================================================
47
+
48
+
49
+ def load_system_configuration(core: UniMMCore, filename: str | Path) -> None:
50
+ """Load system configuration from a file.
51
+
52
+ This is a Python implementation of MMCore::loadSystemConfigurationImpl
53
+ that supports both C++ and Python devices. Lines prefixed with `#py `
54
+ are processed as Python device commands.
55
+
56
+ Parameters
57
+ ----------
58
+ core : UniMMCore
59
+ The core instance to configure.
60
+ filename : str | Path
61
+ Path to the configuration file.
62
+ """
63
+ path = Path(filename).expanduser().resolve()
64
+ if not path.exists(): # pragma: no cover
65
+ raise FileNotFoundError(f"Configuration file not found: {path}")
66
+
67
+ load_from_string(core, path.read_text(), str(path))
68
+
69
+
70
+ def load_from_string(core: UniMMCore, text: str, source: str = "<string>") -> None:
71
+ """Load system configuration from a string.
72
+
73
+ Parameters
74
+ ----------
75
+ core : UniMMCore
76
+ The core instance to configure.
77
+ text : str
78
+ The configuration text.
79
+ source : str
80
+ Source identifier for error messages.
81
+ """
82
+ for line_num, line in enumerate(text.splitlines(), start=1):
83
+ # Strip Windows CR if present
84
+ if not (line := line.rstrip("\r").strip()):
85
+ continue
86
+
87
+ try:
88
+ # strip #py prefix (python devices)
89
+ if line.startswith(PY_PREFIX):
90
+ line = line[len(PY_PREFIX) :].lstrip()
91
+
92
+ if line.startswith("#"):
93
+ # Regular comment - skip
94
+ continue
95
+ else:
96
+ # Standard command
97
+ _run_command(core, line)
98
+ except Exception as e:
99
+ raise RuntimeError(
100
+ f"Error in configuration file {source!r} at line {line_num}: "
101
+ f"{line!r}\n{e}"
102
+ ) from e
103
+
104
+ # File parsing finished, apply startup configuration if defined
105
+ if core.isConfigDefined(CFGGroup.System, CFGGroup.System_Startup):
106
+ # Build system state cache before setConfig to avoid failures
107
+ core.waitForSystem()
108
+ core.updateSystemStateCache()
109
+ core.setConfig(CFGGroup.System, CFGGroup.System_Startup)
110
+
111
+ # Final sync after all configuration is applied
112
+ core.waitForSystem()
113
+ core.updateSystemStateCache()
114
+
115
+
116
+ def _run_command(core: UniMMCore, line: str) -> None:
117
+ """Execute a single configuration command.
118
+
119
+ Mirrors MMCore::loadSystemConfigurationImpl command processing.
120
+ """
121
+ tokens = line.split(CFGCommand.FieldDelimiters)
122
+ if not tokens: # pragma: no cover
123
+ return
124
+
125
+ cmd_name, *args = tokens
126
+
127
+ # Handle custom commands first
128
+ if cmd_name == _PY_DEVICE_CMD:
129
+ _exec_device(core, args)
130
+ return
131
+
132
+ try:
133
+ command = CFGCommand(cmd_name)
134
+ except ValueError: # pragma: no cover
135
+ raise ValueError(f"Unknown configuration command: {cmd_name!r}") from None
136
+
137
+ if command == CFGCommand.Configuration: # pragma: no cover
138
+ warnings.warn(
139
+ f"Obsolete command {cmd_name!r} ignored in configuration file",
140
+ UserWarning,
141
+ stacklevel=3,
142
+ )
143
+ return
144
+ if command == CFGCommand.Equipment: # pragma: no cover
145
+ raise ValueError("Equipment command has been removed from config format")
146
+ if command == CFGCommand.ImageSynchro: # pragma: no cover
147
+ raise ValueError("ImageSynchro command has been removed from config format")
148
+
149
+ executor = _COMMAND_EXECUTORS.get(command)
150
+ if executor is None: # pragma: no cover
151
+ # Unknown command in our executor map - should not happen for valid CFGCommand
152
+ return
153
+
154
+ try:
155
+ executor(core, args)
156
+ except Exception as e: # pragma: no cover
157
+ raise RuntimeError(
158
+ f"Error executing command {cmd_name!r} with arguments {args!r}: {e}"
159
+ ) from e
160
+
161
+
162
+ # =============================================================================
163
+ # Command Executors
164
+ # =============================================================================
165
+
166
+
167
+ def _exec_device(core: UniMMCore, args: Sequence[str]) -> None:
168
+ """Load a device: Device,<label>,<library>,<device_name>."""
169
+ if len(args) != 3: # pragma: no cover
170
+ raise ValueError(f"Device command requires 3 arguments, got {len(args)}")
171
+ label, library, device_name = args
172
+ core.loadDevice(label, library, device_name)
173
+
174
+
175
+ def _exec_property(core: UniMMCore, args: Sequence[str]) -> None:
176
+ """Set a property: Property,<device>,<property>,<value>."""
177
+ if len(args) not in (2, 3): # pragma: no cover
178
+ raise ValueError(f"Property command requires 2-3 arguments, got {len(args)}")
179
+
180
+ device, prop = args[0], args[1]
181
+ value = args[2] if len(args) > 2 else ""
182
+
183
+ # Special case: Core device properties
184
+ if device == Keyword.CoreDevice:
185
+ if prop == Keyword.CoreInitialize:
186
+ try:
187
+ init_val = int(value)
188
+ except (ValueError, TypeError): # pragma: no cover
189
+ raise ValueError(
190
+ f"Initialize value must be integer, got {value!r}"
191
+ ) from None
192
+ if init_val == 0:
193
+ # Unload all devices (reset state for fresh config load)
194
+ core.unloadAllDevices()
195
+ elif init_val == 1:
196
+ core.initializeAllDevices()
197
+ return
198
+ elif prop == Keyword.CoreCamera:
199
+ core.setCameraDevice(value)
200
+ return
201
+ elif prop == Keyword.CoreShutter:
202
+ core.setShutterDevice(value)
203
+ return
204
+ elif prop == Keyword.CoreFocus:
205
+ core.setFocusDevice(value)
206
+ return
207
+ elif prop == Keyword.CoreXYStage:
208
+ core.setXYStageDevice(value)
209
+ return
210
+ elif prop == Keyword.CoreAutoFocus:
211
+ core.setAutoFocusDevice(value)
212
+ return
213
+ elif prop == Keyword.CoreSLM:
214
+ core.setSLMDevice(value)
215
+ return
216
+ elif prop == Keyword.CoreGalvo:
217
+ core.setGalvoDevice(value)
218
+ return
219
+ elif prop == Keyword.CoreChannelGroup:
220
+ core.setChannelGroup(value)
221
+ return
222
+ elif prop == Keyword.CoreAutoShutter:
223
+ core.setAutoShutter(bool(int(value)))
224
+ return
225
+
226
+ core.setProperty(device, prop, value)
227
+
228
+
229
+ def _exec_delay(core: UniMMCore, args: Sequence[str]) -> None:
230
+ """Set device delay: Delay,<device>,<delay_ms>."""
231
+ if len(args) != 2: # pragma: no cover
232
+ raise ValueError(f"Delay command requires 2 arguments, got {len(args)}")
233
+ device, delay_str = args
234
+ try:
235
+ delay_ms = float(delay_str)
236
+ except ValueError: # pragma: no cover
237
+ raise ValueError(f"Delay must be a number, got {delay_str!r}") from None
238
+ core.setDeviceDelayMs(device, delay_ms)
239
+
240
+
241
+ def _exec_focus_direction(core: UniMMCore, args: Sequence[str]) -> None:
242
+ """Set focus direction: FocusDirection,<device>,<direction>."""
243
+ if len(args) != 2: # pragma: no cover
244
+ raise ValueError(
245
+ f"FocusDirection command requires 2 arguments, got {len(args)}"
246
+ )
247
+ device, direction_str = args
248
+ try:
249
+ direction = int(direction_str)
250
+ except ValueError: # pragma: no cover
251
+ raise ValueError(
252
+ f"FocusDirection must be an integer, got {direction_str!r}"
253
+ ) from None
254
+ core.setFocusDirection(device, direction)
255
+
256
+
257
+ def _exec_label(core: UniMMCore, args: Sequence[str]) -> None:
258
+ """Define state label: Label,<device>,<state>,<label>."""
259
+ if len(args) != 3: # pragma: no cover
260
+ raise ValueError(f"Label command requires 3 arguments, got {len(args)}")
261
+ device, state_str, label = args
262
+ try:
263
+ state = int(state_str)
264
+ except ValueError: # pragma: no cover
265
+ raise ValueError(f"State must be an integer, got {state_str!r}") from None
266
+ core.defineStateLabel(device, state, label)
267
+
268
+
269
+ def _exec_config_group(core: UniMMCore, args: Sequence[str]) -> None:
270
+ """Define config group/preset."""
271
+ if len(args) < 1:
272
+ raise ValueError("ConfigGroup command requires at least 1 argument")
273
+
274
+ group_name = args[0]
275
+
276
+ if len(args) == 1:
277
+ # Just define an empty group
278
+ core.defineConfigGroup(group_name)
279
+ elif len(args) in (4, 5):
280
+ # Define a config setting
281
+ preset_name = args[1]
282
+ device = args[2]
283
+ prop = args[3]
284
+ value = args[4] if len(args) > 4 else ""
285
+ core.defineConfig(group_name, preset_name, device, prop, value)
286
+ else: # pragma: no cover
287
+ raise ValueError(
288
+ f"ConfigGroup command requires 1, 4, or 5 arguments, got {len(args)}"
289
+ )
290
+
291
+
292
+ def _exec_config_pixel_size(core: UniMMCore, args: Sequence[str]) -> None:
293
+ """Define pixel size config: ConfigPixelSize,<preset>,<device>,<prop>,<value>."""
294
+ if len(args) != 4: # pragma: no cover
295
+ raise ValueError(
296
+ f"ConfigPixelSize command requires 4 arguments, got {len(args)}"
297
+ )
298
+ preset_name, device, prop, value = args
299
+ core.definePixelSizeConfig(preset_name, device, prop, value)
300
+
301
+
302
+ def _exec_pixel_size_um(core: UniMMCore, args: Sequence[str]) -> None:
303
+ """Set pixel size: PixelSize_um,<preset>,<size>."""
304
+ if len(args) != 2: # pragma: no cover
305
+ raise ValueError(f"PixelSize_um command requires 2 arguments, got {len(args)}")
306
+ preset_name, size_str = args
307
+ try:
308
+ size = float(size_str)
309
+ except ValueError: # pragma: no cover
310
+ raise ValueError(f"Pixel size must be a number, got {size_str!r}") from None
311
+ core.setPixelSizeUm(preset_name, size)
312
+
313
+
314
+ def _exec_pixel_size_affine(core: UniMMCore, args: Sequence[str]) -> None:
315
+ """Set pixel size affine transform."""
316
+ if len(args) != 7: # pragma: no cover
317
+ raise ValueError(
318
+ f"PixelSizeAffine command requires 7 arguments, got {len(args)}"
319
+ )
320
+ preset_name = args[0]
321
+ try:
322
+ affine = [float(x) for x in args[1:]]
323
+ except ValueError: # pragma: no cover
324
+ raise ValueError(f"Affine values must be numbers, got {args[1:]!r}") from None
325
+ core.setPixelSizeAffine(preset_name, affine)
326
+
327
+
328
+ def _exec_parent_id(core: UniMMCore, args: Sequence[str]) -> None:
329
+ """Set parent hub: Parent,<device>,<parent_hub>."""
330
+ if len(args) != 2: # pragma: no cover
331
+ raise ValueError(f"Parent command requires 2 arguments, got {len(args)}")
332
+ device, parent = args
333
+ core.setParentLabel(device, parent)
334
+
335
+
336
+ # Map commands to their executors
337
+ # Commands not in this map are silently ignored (Equipment, ImageSynchro, etc.)
338
+ _COMMAND_EXECUTORS: dict[CFGCommand, Callable[[UniMMCore, Sequence[str]], None]] = {
339
+ CFGCommand.Device: _exec_device,
340
+ CFGCommand.Property: _exec_property,
341
+ CFGCommand.Delay: _exec_delay,
342
+ CFGCommand.FocusDirection: _exec_focus_direction,
343
+ CFGCommand.Label: _exec_label,
344
+ CFGCommand.ConfigGroup: _exec_config_group,
345
+ CFGCommand.ConfigPixelSize: _exec_config_pixel_size,
346
+ CFGCommand.PixelSize_um: _exec_pixel_size_um,
347
+ CFGCommand.PixelSizeAffine: _exec_pixel_size_affine,
348
+ CFGCommand.ParentID: _exec_parent_id,
349
+ }
350
+
351
+ # Add optional pixel size commands if available
352
+ if hasattr(CFGCommand, "PixelSize_dxdz"):
353
+
354
+ def _exec_pixel_size_dxdz(core: UniMMCore, args: Sequence[str]) -> None:
355
+ if len(args) != 2: # pragma: no cover
356
+ raise ValueError(
357
+ f"PixelSize_dxdz command requires 2 arguments, got {len(args)}"
358
+ )
359
+ preset_name, value_str = args
360
+ core.setPixelSizedxdz(preset_name, float(value_str))
361
+
362
+ _COMMAND_EXECUTORS[CFGCommand.PixelSize_dxdz] = _exec_pixel_size_dxdz
363
+
364
+ if hasattr(CFGCommand, "PixelSize_dydz"):
365
+
366
+ def _exec_pixel_size_dydz(core: UniMMCore, args: Sequence[str]) -> None:
367
+ if len(args) != 2: # pragma: no cover
368
+ raise ValueError(
369
+ f"PixelSize_dydz command requires 2 arguments, got {len(args)}"
370
+ )
371
+ preset_name, value_str = args
372
+ core.setPixelSizedydz(preset_name, float(value_str))
373
+
374
+ _COMMAND_EXECUTORS[CFGCommand.PixelSize_dydz] = _exec_pixel_size_dydz
375
+
376
+ if hasattr(CFGCommand, "PixelSize_OptimalZUm"):
377
+
378
+ def _exec_pixel_size_optimal_z(core: UniMMCore, args: Sequence[str]) -> None:
379
+ if len(args) != 2: # pragma: no cover
380
+ raise ValueError(
381
+ f"PixelSize_OptimalZUm command requires 2 arguments, got {len(args)}"
382
+ )
383
+ preset_name, value_str = args
384
+ core.setPixelSizeOptimalZUm(preset_name, float(value_str))
385
+
386
+ _COMMAND_EXECUTORS[CFGCommand.PixelSize_OptimalZUm] = _exec_pixel_size_optimal_z
387
+
388
+
389
+ # =============================================================================
390
+ # Saving
391
+ # =============================================================================
392
+
393
+
394
+ def save_system_configuration(
395
+ core: UniMMCore, filename: str | Path, *, prefix_py_devices: bool = True
396
+ ) -> None:
397
+ """Save the current system configuration to a file.
398
+
399
+ This saves both C++ and Python devices.
400
+
401
+ Parameters
402
+ ----------
403
+ core : UniMMCore
404
+ The core instance to save configuration from.
405
+ filename : str | Path
406
+ Path to save the configuration file.
407
+ prefix_py_devices : bool, optional
408
+ If True (default), Python device lines are prefixed with `#py ` so they
409
+ are ignored by upstream C++/pymmcore implementations. If False, Python
410
+ device lines are saved without the prefix (config will only be loadable
411
+ by UniMMCore).
412
+ """
413
+ path = Path(filename).expanduser().resolve()
414
+
415
+ with open(path, "w") as f:
416
+ for section, lines in _iter_config_sections(core, prefix_py_devices):
417
+ f.write(f"# {section}\n")
418
+ for line in lines:
419
+ f.write(line + "\n")
420
+ f.write("\n")
421
+
422
+
423
+ def _serialize(
424
+ *args: Any, py_device: bool = False, prefix_py_devices: bool = True
425
+ ) -> str:
426
+ """Create a config line from arguments."""
427
+ line = CFGCommand.FieldDelimiters.join(str(a) for a in args)
428
+ return f"{PY_PREFIX}{line}" if (py_device and prefix_py_devices) else line
429
+
430
+
431
+ def _iter_config_sections(
432
+ core: UniMMCore, prefix_py_devices: bool = True
433
+ ) -> Iterable[tuple[str, Iterable[str]]]:
434
+ """Iterate over config sections, yielding (header, lines) tuples."""
435
+ pfx = prefix_py_devices # shorthand
436
+ yield f"Generated by pymmcore-plus UniMMCore on {timestamp()}", []
437
+
438
+ # Reset command
439
+ yield (
440
+ "Unload all devices",
441
+ [
442
+ _serialize(
443
+ CFGCommand.Property, Keyword.CoreDevice, Keyword.CoreInitialize, 0
444
+ )
445
+ ],
446
+ )
447
+
448
+ # Load devices
449
+ yield "Load devices", list(_iter_devices(core, pfx))
450
+
451
+ # Pre-initialization properties
452
+ yield "Pre-initialization properties", list(_iter_pre_init_props(core, pfx))
453
+
454
+ # Hub references
455
+ yield "Hub references", list(_iter_hub_refs(core))
456
+
457
+ # Initialize command
458
+ yield (
459
+ "Initialize",
460
+ [
461
+ _serialize(
462
+ CFGCommand.Property, Keyword.CoreDevice, Keyword.CoreInitialize, 1
463
+ )
464
+ ],
465
+ )
466
+
467
+ # Delays
468
+ yield "Delays", list(_iter_delays(core, pfx))
469
+
470
+ # Focus directions
471
+ yield "Stage focus directions", list(_iter_focus_directions(core, pfx))
472
+
473
+ # Labels
474
+ yield "Labels", list(_iter_labels(core, pfx))
475
+
476
+ # Config groups
477
+ yield "Configuration presets", list(_iter_config_groups(core, pfx))
478
+
479
+ # Pixel size configs
480
+ yield "PixelSize settings", list(_iter_pixel_size_configs(core, pfx))
481
+
482
+ # Core device roles
483
+ yield "Roles", list(_iter_roles(core, pfx))
484
+
485
+
486
+ def _iter_devices(core: UniMMCore, prefix_py_devices: bool = True) -> Iterable[str]:
487
+ """Iterate over device load commands."""
488
+ for label in core.getLoadedDevices():
489
+ if label == Keyword.CoreDevice: # type: ignore[comparison-overlap]
490
+ continue
491
+ is_py = core.isPyDevice(label)
492
+ library = core.getDeviceLibrary(label)
493
+ device_name = core.getDeviceName(label)
494
+ # Use pyDevice command for Python devices, Device for C++ devices
495
+ cmd = _PY_DEVICE_CMD if is_py else CFGCommand.Device
496
+ yield _serialize(
497
+ cmd,
498
+ label,
499
+ library,
500
+ device_name,
501
+ py_device=is_py,
502
+ prefix_py_devices=prefix_py_devices,
503
+ )
504
+
505
+
506
+ def _iter_pre_init_props(
507
+ core: UniMMCore, prefix_py_devices: bool = True
508
+ ) -> Iterable[str]:
509
+ """Iterate over pre-initialization property commands."""
510
+ for label in core.getLoadedDevices():
511
+ if label == Keyword.CoreDevice: # type: ignore[comparison-overlap]
512
+ continue
513
+ is_py = core.isPyDevice(label)
514
+ for prop_name in core.getDevicePropertyNames(label):
515
+ if core.isPropertyPreInit(label, prop_name):
516
+ value = core.getProperty(label, prop_name)
517
+ yield _serialize(
518
+ CFGCommand.Property,
519
+ label,
520
+ prop_name,
521
+ value,
522
+ py_device=is_py,
523
+ prefix_py_devices=prefix_py_devices,
524
+ )
525
+
526
+
527
+ def _iter_hub_refs(core: UniMMCore) -> Iterable[str]:
528
+ """Iterate over parent hub reference commands."""
529
+ for label in core.getLoadedDevices():
530
+ if label == Keyword.CoreDevice: # type: ignore[comparison-overlap]
531
+ continue
532
+ is_py = core.isPyDevice(label)
533
+ # Python devices don't have parent labels (yet)
534
+ if is_py:
535
+ continue
536
+ try:
537
+ parent = core.getParentLabel(label)
538
+ except RuntimeError:
539
+ continue
540
+ if parent:
541
+ yield _serialize(CFGCommand.ParentID, label, parent, py_device=is_py)
542
+
543
+
544
+ def _iter_delays(core: UniMMCore, prefix_py_devices: bool = True) -> Iterable[str]:
545
+ """Iterate over device delay commands."""
546
+ for label in core.getLoadedDevices():
547
+ if label == Keyword.CoreDevice: # type: ignore[comparison-overlap]
548
+ continue
549
+ delay = core.getDeviceDelayMs(label)
550
+ if delay > 0:
551
+ yield _serialize(
552
+ CFGCommand.Delay,
553
+ label,
554
+ delay,
555
+ py_device=core.isPyDevice(label),
556
+ prefix_py_devices=prefix_py_devices,
557
+ )
558
+
559
+
560
+ def _iter_focus_directions(
561
+ core: UniMMCore, prefix_py_devices: bool = True
562
+ ) -> Iterable[str]:
563
+ """Iterate over focus direction commands."""
564
+ for label in core.getLoadedDevicesOfType(DeviceType.Stage):
565
+ is_py = core.isPyDevice(label)
566
+ direction = core.getFocusDirection(label)
567
+ yield _serialize(
568
+ CFGCommand.FocusDirection,
569
+ label,
570
+ direction.value,
571
+ py_device=is_py,
572
+ prefix_py_devices=prefix_py_devices,
573
+ )
574
+
575
+
576
+ def _iter_labels(core: UniMMCore, prefix_py_devices: bool = True) -> Iterable[str]:
577
+ """Iterate over state device label commands."""
578
+ for label in core.getLoadedDevicesOfType(DeviceType.State):
579
+ is_py = core.isPyDevice(label)
580
+ labels = core.getStateLabels(label)
581
+ for state, state_label in enumerate(labels):
582
+ if state_label:
583
+ yield _serialize(
584
+ CFGCommand.Label,
585
+ label,
586
+ state,
587
+ state_label,
588
+ py_device=is_py,
589
+ prefix_py_devices=prefix_py_devices,
590
+ )
591
+
592
+
593
+ def _iter_config_groups(
594
+ core: UniMMCore, prefix_py_devices: bool = True
595
+ ) -> Iterable[str]:
596
+ """Iterate over config group commands."""
597
+ for group_name in core.getAvailableConfigGroups():
598
+ presets = core.getAvailableConfigs(group_name)
599
+ if not presets:
600
+ # Empty group
601
+ yield _serialize(CFGCommand.ConfigGroup, group_name)
602
+ continue
603
+
604
+ for preset_name in presets:
605
+ config = core.getConfigData(group_name, preset_name, native=True)
606
+ for i in range(config.size()):
607
+ setting = config.getSetting(i)
608
+ device = setting.getDeviceLabel()
609
+ prop = setting.getPropertyName()
610
+ value = setting.getPropertyValue()
611
+ is_py = core.isPyDevice(device)
612
+ yield _serialize(
613
+ CFGCommand.ConfigGroup,
614
+ group_name,
615
+ preset_name,
616
+ device,
617
+ prop,
618
+ value,
619
+ py_device=is_py,
620
+ prefix_py_devices=prefix_py_devices,
621
+ )
622
+
623
+
624
+ def _iter_pixel_size_configs(
625
+ core: UniMMCore, prefix_py_devices: bool = True
626
+ ) -> Iterable[str]:
627
+ """Iterate over pixel size config commands."""
628
+ for preset_name in core.getAvailablePixelSizeConfigs():
629
+ config = core.getPixelSizeConfigData(preset_name, native=True)
630
+ for i in range(config.size()):
631
+ setting = config.getSetting(i)
632
+ device = setting.getDeviceLabel()
633
+ prop = setting.getPropertyName()
634
+ value = setting.getPropertyValue()
635
+ # Pixel size configs typically only reference C++ devices
636
+ is_py = core.isPyDevice(device)
637
+ yield _serialize(
638
+ CFGCommand.ConfigPixelSize,
639
+ preset_name,
640
+ device,
641
+ prop,
642
+ value,
643
+ py_device=is_py,
644
+ prefix_py_devices=prefix_py_devices,
645
+ )
646
+
647
+ # Pixel size
648
+ size = core.getPixelSizeUmByID(preset_name)
649
+ yield _serialize(CFGCommand.PixelSize_um, preset_name, size)
650
+
651
+ # Affine transform
652
+ affine = core.getPixelSizeAffineByID(preset_name)
653
+ if affine and any(v != 0 for v in affine):
654
+ yield _serialize(CFGCommand.PixelSizeAffine, preset_name, *affine)
655
+
656
+ # Optional extended pixel size properties
657
+ if hasattr(core, "getPixelSizedxdz"):
658
+ dxdz = core.getPixelSizedxdz(preset_name)
659
+ if dxdz:
660
+ yield _serialize(CFGCommand.PixelSize_dxdz, preset_name, dxdz)
661
+ if hasattr(core, "getPixelSizedydz"):
662
+ dydz = core.getPixelSizedydz(preset_name)
663
+ if dydz:
664
+ yield _serialize(CFGCommand.PixelSize_dydz, preset_name, dydz)
665
+ if hasattr(core, "getPixelSizeOptimalZUm"):
666
+ optimal_z = core.getPixelSizeOptimalZUm(preset_name)
667
+ if optimal_z:
668
+ yield _serialize(
669
+ CFGCommand.PixelSize_OptimalZUm, preset_name, optimal_z
670
+ )
671
+
672
+
673
+ def _iter_roles(core: UniMMCore, prefix_py_devices: bool = True) -> Iterable[str]:
674
+ """Iterate over core device role commands."""
675
+ roles = [
676
+ (Keyword.CoreCamera, core.getCameraDevice),
677
+ (Keyword.CoreShutter, core.getShutterDevice),
678
+ (Keyword.CoreFocus, core.getFocusDevice),
679
+ (Keyword.CoreXYStage, core.getXYStageDevice),
680
+ (Keyword.CoreAutoFocus, core.getAutoFocusDevice),
681
+ (Keyword.CoreSLM, core.getSLMDevice),
682
+ (Keyword.CoreGalvo, core.getGalvoDevice),
683
+ ]
684
+
685
+ for role_keyword, getter in roles:
686
+ try:
687
+ if device := getter():
688
+ yield _serialize(
689
+ CFGCommand.Property,
690
+ Keyword.CoreDevice,
691
+ role_keyword,
692
+ device,
693
+ py_device=core.isPyDevice(device),
694
+ prefix_py_devices=prefix_py_devices,
695
+ )
696
+ except Exception: # pragma: no cover
697
+ # Some getters may not be available
698
+ pass
699
+
700
+ # AutoShutter setting
701
+ yield _serialize(
702
+ CFGCommand.Property,
703
+ Keyword.CoreDevice,
704
+ Keyword.CoreAutoShutter,
705
+ int(core.getAutoShutter()),
706
+ )