pymmcore-plus 0.14.0__py3-none-any.whl → 0.15.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. pymmcore_plus/__init__.py +25 -0
  2. pymmcore_plus/_ipy_completion.py +363 -0
  3. pymmcore_plus/_pymmcore.py +4 -2
  4. pymmcore_plus/core/_constants.py +25 -3
  5. pymmcore_plus/core/_mmcore_plus.py +110 -55
  6. pymmcore_plus/core/_sequencing.py +1 -1
  7. pymmcore_plus/core/events/_deprecated.py +67 -0
  8. pymmcore_plus/core/events/_protocol.py +64 -39
  9. pymmcore_plus/core/events/_psygnal.py +35 -6
  10. pymmcore_plus/core/events/_qsignals.py +34 -6
  11. pymmcore_plus/experimental/unicore/__init__.py +12 -2
  12. pymmcore_plus/experimental/unicore/_device_manager.py +1 -1
  13. pymmcore_plus/experimental/unicore/_proxy.py +20 -3
  14. pymmcore_plus/experimental/unicore/core/__init__.py +0 -0
  15. pymmcore_plus/experimental/unicore/core/_sequence_buffer.py +314 -0
  16. pymmcore_plus/experimental/unicore/core/_unicore.py +1769 -0
  17. pymmcore_plus/experimental/unicore/devices/_camera.py +201 -0
  18. pymmcore_plus/experimental/unicore/devices/{_device.py → _device_base.py} +54 -28
  19. pymmcore_plus/experimental/unicore/devices/_generic_device.py +12 -0
  20. pymmcore_plus/experimental/unicore/devices/_properties.py +9 -2
  21. pymmcore_plus/experimental/unicore/devices/_shutter.py +30 -0
  22. pymmcore_plus/experimental/unicore/devices/_slm.py +82 -0
  23. pymmcore_plus/experimental/unicore/devices/_stage.py +1 -1
  24. pymmcore_plus/experimental/unicore/devices/_state.py +152 -0
  25. pymmcore_plus/mda/events/_protocol.py +8 -8
  26. {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/METADATA +2 -2
  27. {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/RECORD +30 -21
  28. pymmcore_plus/experimental/unicore/_unicore.py +0 -703
  29. {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/WHEEL +0 -0
  30. {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/entry_points.txt +0 -0
  31. {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/licenses/LICENSE +0 -0
@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Optional
2
2
 
3
3
  from qtpy.QtCore import QObject, Signal
4
4
 
5
+ from ._deprecated import DeprecatedSignalProxy
5
6
  from ._prop_event_mixin import _PropertySignal
6
7
 
7
8
  if TYPE_CHECKING:
@@ -25,17 +26,23 @@ class QCoreSignaler(QObject):
25
26
  SLMExposureChanged = Signal(str, float)
26
27
  sLMExposureChanged = SLMExposureChanged # alias
27
28
 
29
+ # https://github.com/micro-manager/mmCoreAndDevices/pull/659
30
+ imageSnapped = Signal(str) # on snapImage()
31
+ # when (Continuous)SequenceAcquisition is stopped
32
+ sequenceAcquisitionStopped = Signal(str)
33
+ if TYPE_CHECKING: # see deprecated impl below
34
+ sequenceAcquisitionStarted = Signal(str)
35
+
28
36
  # added for CMMCorePlus
29
- imageSnapped = Signal() # on snapImage()
30
37
  mdaEngineRegistered = Signal(object, object) # new engine, old engine
31
38
  # when continuousSequenceAcquisition is started
32
39
  continuousSequenceAcquisitionStarting = Signal()
33
40
  continuousSequenceAcquisitionStarted = Signal()
34
- # when SequenceAcquisition is started
35
- sequenceAcquisitionStarting = Signal(str, int, float, bool)
36
- sequenceAcquisitionStarted = Signal(str, int, float, bool)
37
- # when (Continuous)SequenceAcquisition is stopped
38
- sequenceAcquisitionStopped = Signal(str)
41
+
42
+ if TYPE_CHECKING:
43
+ # when SequenceAcquisition is started
44
+ sequenceAcquisitionStarting = Signal(str)
45
+
39
46
  autoShutterSet = Signal(bool)
40
47
  configGroupDeleted = Signal(str)
41
48
  configDeleted = Signal(str, str)
@@ -77,3 +84,24 @@ class QCoreSignaler(QObject):
77
84
  """
78
85
  # type ignored: can't use _DevicePropertyEventMixin due to metaclass conflict
79
86
  return _PropertySignal(self, device, property)
87
+
88
+ if not TYPE_CHECKING:
89
+ _sequenceAcquisitionStarting = Signal(str)
90
+ _sequenceAcquisitionStarted = Signal(str)
91
+
92
+ # Deprecated signal wrappers for backwards compatibility
93
+ @property
94
+ def sequenceAcquisitionStarting(self):
95
+ return DeprecatedSignalProxy(
96
+ self._sequenceAcquisitionStarting,
97
+ current_n_args=1,
98
+ deprecated_posargs=(-1, 0, False),
99
+ )
100
+
101
+ @property
102
+ def sequenceAcquisitionStarted(self):
103
+ return DeprecatedSignalProxy(
104
+ self._sequenceAcquisitionStarted,
105
+ current_n_args=1,
106
+ deprecated_posargs=(-1, 0, False),
107
+ )
@@ -1,12 +1,22 @@
1
- from ._unicore import UniMMCore
2
- from .devices._device import Device
1
+ from .core._unicore import UniMMCore
2
+ from .devices._camera import CameraDevice
3
+ from .devices._device_base import Device
4
+ from .devices._generic_device import GenericDevice
3
5
  from .devices._properties import PropertyInfo, pymm_property
6
+ from .devices._shutter import ShutterDevice
7
+ from .devices._slm import SLMDevice
4
8
  from .devices._stage import StageDevice, XYStageDevice, XYStepperStageDevice
9
+ from .devices._state import StateDevice
5
10
 
6
11
  __all__ = [
12
+ "CameraDevice",
7
13
  "Device",
14
+ "GenericDevice",
8
15
  "PropertyInfo",
16
+ "SLMDevice",
17
+ "ShutterDevice",
9
18
  "StageDevice",
19
+ "StateDevice",
10
20
  "UniMMCore",
11
21
  "XYStageDevice",
12
22
  "XYStepperStageDevice",
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, TypeVar, cast
7
7
 
8
8
  from pymmcore_plus.core._constants import DeviceInitializationState, DeviceType
9
9
 
10
- from .devices._device import Device
10
+ from .devices._device_base import Device
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from collections.abc import Iterator
@@ -106,22 +106,39 @@ def create_proxy(
106
106
  ```
107
107
  """
108
108
  sub_proxies = sub_proxies or {}
109
+
110
+ # Get all public attribute names from the protocol (both from dir() and type hints)
109
111
  allowed_names = {
110
112
  x
111
113
  for x in chain(dir(protocol), get_type_hints(protocol))
112
- if not x.startswith("_")
114
+ if not x.startswith("_") # Exclude private/dunder attributes
113
115
  }
116
+
117
+ # Create an immutable module to act as our proxy object
114
118
  proxy = _ImmutableModule(protocol.__name__)
119
+
120
+ # Iterate through each allowed attribute name
115
121
  for attr_name in allowed_names:
122
+ # Get the actual attribute from the source object
116
123
  attr = getattr(obj, attr_name)
124
+
125
+ # Check if this attribute should be sub-proxied
117
126
  if subprotocol := sub_proxies.get(attr_name):
118
- # look for nested sub-proxies on attr_name, e.g. `attr_name.sub_attr`
127
+ # Look for nested sub-proxies on attr_name, e.g. `attr_name.sub_attr`
128
+ # Filter sub_proxies for keys that start with "attr_name."
119
129
  sub = {
120
- k.split(".", 1)[1]: v
130
+ k.split(".", 1)[1]: v # Remove the "attr_name." prefix
121
131
  for k, v in sub_proxies.items()
122
132
  if k.startswith(f"{attr_name}.")
123
133
  }
134
+ # Recursively create a proxy for this attribute
124
135
  attr = create_proxy(attr, subprotocol, sub)
136
+
137
+ # Set the attribute on our proxy object
125
138
  setattr(proxy, attr_name, attr)
139
+
140
+ # Freeze the proxy to prevent further modifications
126
141
  proxy.__frozen__ = True
142
+
143
+ # Return the proxy cast to the expected protocol type
127
144
  return cast("T", proxy)
File without changes
@@ -0,0 +1,314 @@
1
+ """High-throughput, zero-copy ring buffer for camera image streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections import deque
7
+ from typing import TYPE_CHECKING, Any, NamedTuple
8
+
9
+ import numpy as np
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Mapping, Sequence
13
+
14
+ from numpy.typing import DTypeLike, NDArray
15
+
16
+
17
+ class BufferSlot(NamedTuple):
18
+ """Record describing one frame held in the pool."""
19
+
20
+ array: NDArray[Any]
21
+ metadata: Mapping[str, Any] | None # metadata associated with this frame
22
+ nbytes_total: int # full span in the pool (data + padding)
23
+
24
+
25
+ # TODO: version that doesn't use contiguous memory,
26
+ # but rather uses a deuqe of numpy arrays.
27
+ class SequenceStack: ...
28
+
29
+
30
+ class SequenceBuffer:
31
+ """A lock-protected circular buffer backed by a single numpy byte array.
32
+
33
+ This buffer is designed for a single device. If you want to stream data from
34
+ multiple devices, it is recommended to use a separate `SequenceBuffer` for each
35
+ device (rather than to pack two streams into a single FIFO buffer).
36
+
37
+ Parameters
38
+ ----------
39
+ size_mb:
40
+ Pool capacity in **mebibytes** (binary - 1 MiB = 1,048,576 bytes).
41
+ overwrite_on_overflow:
42
+ When `True` (default) the oldest frame will be discarded to make space.
43
+ When `False`, an attempt to acquire a slot that does not fit raises
44
+ `BufferError`.
45
+ """
46
+
47
+ def __init__(self, size_mb: float, *, overwrite_on_overflow: bool = True) -> None:
48
+ self._size_bytes: int = int(size_mb * 1024 * 1024)
49
+ self._pool: NDArray[np.uint8] = np.empty(self._size_bytes, dtype=np.uint8)
50
+
51
+ # ring indices (bytes)
52
+ self._head: int = 0 # next write offset
53
+ self._tail: int = 0 # oldest frame offset
54
+ self._bytes_in_use: int = 0 # live bytes (includes padding)
55
+
56
+ # active frames in FIFO order (BufferSlot objects)
57
+ self._slots: deque[BufferSlot] = deque()
58
+
59
+ self._overwrite_on_overflow: bool = overwrite_on_overflow
60
+ self._overflow_occurred: bool = False
61
+
62
+ self._lock = threading.Lock() # not re-entrant, but slightly faster than RLock
63
+ self._pending_slot: deque[tuple[NDArray, int]] = deque()
64
+
65
+ # ---------------------------------------------------------------------
66
+ # Producer API - acquire a slot, fill it, finalize it
67
+ # ---------------------------------------------------------------------
68
+
69
+ def acquire_slot(
70
+ self, shape: Sequence[int], dtype: DTypeLike = np.uint8
71
+ ) -> NDArray[Any]:
72
+ """Return a **writable** view into the internal pool.
73
+
74
+ After filling the array, *must* call `finalize_slot`.
75
+ """
76
+ dtype_ = np.dtype(dtype)
77
+ nbytes_data = int(np.prod(shape, dtype=int) * dtype_.itemsize)
78
+
79
+ # ---------- NEW: explicit capacity check ---------------------------
80
+ if nbytes_data > self._size_bytes:
81
+ msg = (
82
+ f"Requested size ({nbytes_data} bytes) exceeds buffer capacity "
83
+ f"({self.size_mb} MiB)."
84
+ )
85
+ raise BufferError(msg)
86
+
87
+ # --- reserve space -------------------------------------------------
88
+
89
+ with self._lock:
90
+ # Calculate padding needed to align start address to dtype boundary
91
+ align_pad = (-self._head) % dtype_.itemsize
92
+ needed = nbytes_data + align_pad
93
+
94
+ # ensure capacity (may evict oldest frames if overwrite enabled)
95
+ self._ensure_space(needed)
96
+
97
+ # alignment may force wrapping to 0 => recompute alignment
98
+ if needed > self._contiguous_free_bytes:
99
+ # wrap head to start of buffer for contiguous allocation
100
+ self._head = 0
101
+ align_pad = 0 # new head is already aligned to buffer start
102
+ needed = nbytes_data # recalculated without padding
103
+ self._ensure_space(needed) # guaranteed to succeed after wrap
104
+
105
+ # Calculate actual start position after alignment padding
106
+ start = self._head + align_pad
107
+ # Advance head pointer past this allocation (with wraparound)
108
+ self._head = (start + nbytes_data) % self._size_bytes
109
+ # Track total bytes consumed (data + alignment padding)
110
+ self._bytes_in_use += needed
111
+
112
+ # Create zero-copy view into the pool buffer at calculated offset
113
+ arr: NDArray[Any] = np.ndarray(
114
+ shape, dtype_, buffer=self._pool, offset=start
115
+ )
116
+ self._pending_slot.append((arr, needed))
117
+ return arr
118
+
119
+ def finalize_slot(self, metadata: Mapping[str, Any] | None = None) -> None:
120
+ """Publish the frame acquired via `acquire_write_slot`.
121
+
122
+ This makes the frame available for retrieval via `pop_next` or
123
+ `peek_last`. If `metadata` is provided, it will be merged into the
124
+ slot's metadata dictionary.
125
+ """
126
+ with self._lock:
127
+ if not self._pending_slot:
128
+ msg = "No pending slot to finalize"
129
+ raise RuntimeError(msg)
130
+
131
+ arr, nbytes_total = self._pending_slot.popleft()
132
+ self._slots.append(BufferSlot(arr, metadata, nbytes_total))
133
+
134
+ # Convenience: copy-in one-shot insert ------------------------------
135
+
136
+ def insert_data(
137
+ self, data: NDArray[Any], metadata: Mapping[str, Any] | None = None
138
+ ) -> None:
139
+ """Insert data into the buffer, overwriting any existing frame.
140
+
141
+ This is a convenience method that acquires a slot, copies the data, and
142
+ finalizes the slot in one go.
143
+
144
+ For users who *can* write directly into our buffer, they should use
145
+ `acquire_slot` and `finalize_slot`. `insert_data` is for when the data already
146
+ exists in another NumPy array.
147
+ """
148
+ self.acquire_slot(data.shape, data.dtype)[:] = data
149
+ self.finalize_slot(metadata)
150
+
151
+ # ------------------------------------------------------------------
152
+ # Consumer API - pop frames, peek at frames
153
+ # ------------------------------------------------------------------
154
+
155
+ def pop_next(
156
+ self, *, out: np.ndarray | None = None
157
+ ) -> tuple[NDArray[Any], Mapping[str, Any]] | None:
158
+ """Remove and return the oldest frame.
159
+
160
+ If `copy` is `True`, a copy of the data is returned, otherwise a read-only
161
+ view into the internal buffer is returned. The metadata is always returned
162
+ as a copy to prevent external modification.
163
+ """
164
+ with self._lock:
165
+ if not self._slots:
166
+ return None
167
+ slot = self._slots.popleft()
168
+
169
+ if out is not None:
170
+ out[:] = slot.array
171
+ arr = out
172
+ else:
173
+ arr = slot.array.copy()
174
+ self._evict_slot(slot)
175
+
176
+ # return actual metadata, we're done with it.
177
+ return arr, (slot.metadata or {})
178
+
179
+ def peek_last(
180
+ self, *, out: np.ndarray | None = None
181
+ ) -> tuple[NDArray[Any], Mapping[str, Any]] | None:
182
+ """Return the newest frame without removing it."""
183
+ return self.peek_nth_from_last(0, out=out)
184
+
185
+ def peek_nth_from_last(
186
+ self, n: int, *, out: np.ndarray | None = None
187
+ ) -> tuple[NDArray[Any], dict[str, Any]] | None:
188
+ """Return the n-th most recent frame without removing it.
189
+
190
+ Last frame is n=0, second to last is n=1, etc.
191
+ """
192
+ with self._lock:
193
+ if n < 0 or n >= len(self._slots):
194
+ return None
195
+ slot = self._slots[-(n + 1)]
196
+ if out is not None:
197
+ out[:] = slot.array
198
+ arr = out
199
+ else:
200
+ arr = slot.array.copy()
201
+
202
+ # Return a copy of the metadata to avoid external modification
203
+ return arr, (dict(slot.metadata) if slot.metadata else {})
204
+
205
+ # ------------------------------------------------------------------
206
+ # Administrative helpers
207
+ # ------------------------------------------------------------------
208
+
209
+ def clear(self) -> None:
210
+ with self._lock:
211
+ self._slots.clear()
212
+ self._pending_slot.clear()
213
+ self._head = self._tail = self._bytes_in_use = 0
214
+ self._overflow_occurred = False
215
+
216
+ # ------------------------------------------------------------------
217
+ # Properties
218
+ # ------------------------------------------------------------------
219
+
220
+ @property
221
+ def size_mb(self) -> float: # human readable
222
+ return self._size_bytes / 1024**2
223
+
224
+ @property
225
+ def size_bytes(self) -> int:
226
+ """Return the buffer size in bytes."""
227
+ return self._size_bytes
228
+
229
+ @property
230
+ def used_bytes(self) -> int:
231
+ """Return the number of bytes currently in use."""
232
+ return self._bytes_in_use
233
+
234
+ @property
235
+ def free_bytes(self) -> int:
236
+ """Return the number of free bytes in the buffer."""
237
+ return self._size_bytes - self._bytes_in_use
238
+
239
+ @property
240
+ def free_mb(self) -> float:
241
+ """Get the free space in the buffer in mebibytes."""
242
+ return self.free_bytes / 1024**2
243
+
244
+ @property
245
+ def overwrite_on_overflow(self) -> bool:
246
+ """Return the overflow policy (immutable while data is present)."""
247
+ return self._overwrite_on_overflow
248
+
249
+ @overwrite_on_overflow.setter
250
+ def overwrite_on_overflow(self, value: bool) -> None:
251
+ """Set the overflow policy (immutable while data is present)."""
252
+ with self._lock:
253
+ if self._bytes_in_use > 0:
254
+ msg = "Cannot change overflow policy with active data in buffer."
255
+ raise RuntimeError(msg)
256
+ self._overwrite_on_overflow = value
257
+
258
+ def __len__(self) -> int:
259
+ """Return the number of frames currently in the buffer."""
260
+ return len(self._slots)
261
+
262
+ @property
263
+ def overflow_occurred(self) -> bool:
264
+ """Return whether an overflow occurred since the last clear."""
265
+ return self._overflow_occurred
266
+
267
+ def __repr__(self) -> str:
268
+ used_mb = self.used_bytes / 1024**2
269
+ name = self.__class__.__name__
270
+ return (
271
+ f"{name}(size_mb={self.size_mb:.1f}, slots={len(self)}, "
272
+ f"used_mb={used_mb:.1f}, overwrite={self._overwrite_on_overflow})"
273
+ )
274
+
275
+ # ------------------------------------------------------------------
276
+ # Internal helpers
277
+ # ------------------------------------------------------------------
278
+
279
+ def _evict_slot(self, slot: BufferSlot) -> None:
280
+ """Advance the tail pointer past `slot`, updating house-keeping."""
281
+ self._tail = (self._tail + slot.nbytes_total) % self._size_bytes
282
+ self._bytes_in_use -= slot.nbytes_total
283
+
284
+ @property
285
+ def _contiguous_free_bytes(self) -> int:
286
+ """Get the number of contiguous free bytes in the buffer."""
287
+ if self._bytes_in_use >= self._size_bytes:
288
+ return 0
289
+ if self._bytes_in_use == 0:
290
+ return self._size_bytes
291
+ if self._head >= self._tail:
292
+ return self._size_bytes - self._head
293
+ # it's very hard to make this case happen...
294
+ return self._tail - self._head # pragma: no cover
295
+
296
+ def _ensure_space(self, needed: int) -> None:
297
+ """Ensure `needed` bytes contiguous space is available."""
298
+ while self._contiguous_free_bytes < needed:
299
+ if not self._slots:
300
+ # Buffer empty but fragmented: just reset pointers
301
+ self._head = self._tail = self._bytes_in_use = 0
302
+ break
303
+ if not self._overwrite_on_overflow:
304
+ self._overflow_occurred = True
305
+ msg = "Buffer is full and overwrite is disabled."
306
+ raise BufferError(msg)
307
+ self._overflow_occurred = True
308
+ while self._slots and self._contiguous_free_bytes < needed:
309
+ slot = self._slots.popleft()
310
+ self._evict_slot(slot)
311
+
312
+ # If the buffer is now empty, reset head/tail to maximise contiguous space.
313
+ if self._bytes_in_use == 0:
314
+ self._head = self._tail = 0