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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. pymmcore_plus/__init__.py +2 -0
  2. pymmcore_plus/_accumulator.py +258 -0
  3. pymmcore_plus/_pymmcore.py +4 -2
  4. pymmcore_plus/core/__init__.py +34 -1
  5. pymmcore_plus/core/_constants.py +21 -3
  6. pymmcore_plus/core/_device.py +739 -19
  7. pymmcore_plus/core/_mmcore_plus.py +260 -47
  8. pymmcore_plus/core/events/_protocol.py +49 -34
  9. pymmcore_plus/core/events/_psygnal.py +2 -2
  10. pymmcore_plus/experimental/unicore/__init__.py +7 -1
  11. pymmcore_plus/experimental/unicore/_proxy.py +20 -3
  12. pymmcore_plus/experimental/unicore/core/__init__.py +0 -0
  13. pymmcore_plus/experimental/unicore/core/_sequence_buffer.py +318 -0
  14. pymmcore_plus/experimental/unicore/core/_unicore.py +1702 -0
  15. pymmcore_plus/experimental/unicore/devices/_camera.py +196 -0
  16. pymmcore_plus/experimental/unicore/devices/_device.py +54 -28
  17. pymmcore_plus/experimental/unicore/devices/_properties.py +8 -1
  18. pymmcore_plus/experimental/unicore/devices/_slm.py +82 -0
  19. pymmcore_plus/experimental/unicore/devices/_state.py +152 -0
  20. pymmcore_plus/mda/events/_protocol.py +8 -8
  21. pymmcore_plus/mda/handlers/_tensorstore_handler.py +3 -1
  22. {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/METADATA +14 -37
  23. {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/RECORD +26 -20
  24. pymmcore_plus/experimental/unicore/_unicore.py +0 -703
  25. {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/WHEEL +0 -0
  26. {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/entry_points.txt +0 -0
  27. {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,4 +1,15 @@
1
- from typing import Any, Callable, Optional, Protocol, Union, runtime_checkable
1
+ from __future__ import annotations
2
+
3
+ from typing import (
4
+ Any,
5
+ Callable,
6
+ ClassVar,
7
+ Protocol,
8
+ overload,
9
+ runtime_checkable,
10
+ )
11
+
12
+ from typing_extensions import Self
2
13
 
3
14
 
4
15
  @runtime_checkable
@@ -12,7 +23,7 @@ class PSignalInstance(Protocol):
12
23
  def connect(self, slot: Callable) -> Any:
13
24
  """Connect slot to this signal."""
14
25
 
15
- def disconnect(self, slot: Optional[Callable] = None) -> Any:
26
+ def disconnect(self, slot: Callable | None = None) -> Any:
16
27
  """Disconnect slot from this signal.
17
28
 
18
29
  If `None`, all slots should be disconnected.
@@ -23,14 +34,18 @@ class PSignalInstance(Protocol):
23
34
 
24
35
 
25
36
  @runtime_checkable
26
- class PSignalDescriptor(Protocol):
37
+ class PSignal(Protocol):
27
38
  """Descriptor that returns a signal instance."""
28
39
 
29
- def __get__(self, instance: Optional[Any], owner: Any) -> PSignalInstance:
40
+ @overload
41
+ def __get__(self, instance: None, owner: type, /) -> Self: ...
42
+ @overload
43
+ def __get__(self, instance: Any, owner: type | None = None, /) -> Any: ...
44
+ def __get__(
45
+ self, instance: Any, owner: type | None = ..., /
46
+ ) -> PSignalInstance | PSignal:
30
47
  """Returns the signal instance for this descriptor."""
31
-
32
-
33
- PSignal = Union[PSignalDescriptor, PSignalInstance]
48
+ ...
34
49
 
35
50
 
36
51
  @runtime_checkable
@@ -91,102 +106,102 @@ class PCoreSignaler(Protocol):
91
106
  """
92
107
 
93
108
  # native MMCore callback events
94
- propertiesChanged: PSignal
109
+ propertiesChanged: ClassVar[PSignal]
95
110
  """Emits with no arguments when properties have changed."""
96
- propertyChanged: PSignal
111
+ propertyChanged: ClassVar[PSignal]
97
112
  """Emits `(name: str, : propName: str, propValue: str)` when a specific property has changed.""" # noqa: E501
98
- channelGroupChanged: PSignal
113
+ channelGroupChanged: ClassVar[PSignal]
99
114
  """Emits `(newChannelGroupName: str)` when a channel group has changed."""
100
- configGroupChanged: PSignal
115
+ configGroupChanged: ClassVar[PSignal]
101
116
  """Emits `(groupName: str, newConfigName: str)` when a config group has changed."""
102
- systemConfigurationLoaded: PSignal
117
+ systemConfigurationLoaded: ClassVar[PSignal]
103
118
  """Emits with no arguments when the system configuration has been loaded."""
104
- pixelSizeChanged: PSignal
119
+ pixelSizeChanged: ClassVar[PSignal]
105
120
  """Emits `(newPixelSizeUm: float)` when the pixel size has changed."""
106
- pixelSizeAffineChanged: PSignal
121
+ pixelSizeAffineChanged: ClassVar[PSignal]
107
122
  """Emits `(float, float, float, float, float, float)` when the pixel size affine has changed.""" # noqa: E501
108
- stagePositionChanged: PSignal
123
+ stagePositionChanged: ClassVar[PSignal]
109
124
  """Emits `(name: str, pos: float)` when a stage position has changed."""
110
- XYStagePositionChanged: PSignal
125
+ XYStagePositionChanged: ClassVar[PSignal]
111
126
  """Emits `(name: str, xpos: float, ypos: float)` when an XY stage position has changed.""" # noqa: E501
112
- xYStagePositionChanged: PSignal # alias
113
- exposureChanged: PSignal
127
+ xYStagePositionChanged: ClassVar[PSignal] # alias
128
+ exposureChanged: ClassVar[PSignal]
114
129
  """Emits `(name: str, newExposure: float)` when an exposure has changed."""
115
- SLMExposureChanged: PSignal
130
+ SLMExposureChanged: ClassVar[PSignal]
116
131
  """Emits `(name: str, newExposure: float)` when the exposure of the SLM device changes.""" # noqa: E501
117
- sLMExposureChanged: PSignal # alias
132
+ sLMExposureChanged: ClassVar[PSignal] # alias
118
133
 
119
134
  # added for CMMCorePlus
120
- configSet: PSignal
135
+ configSet: ClassVar[PSignal]
121
136
  """Emits `(str, str)` when a config has been set.
122
137
 
123
138
  > :sparkles: This signal is unique to `pymmcore-plus`.
124
139
  """
125
- imageSnapped: PSignal
140
+ imageSnapped: ClassVar[PSignal]
126
141
  """Emits with no arguments whenever snap is called.
127
142
 
128
143
  > :sparkles: This signal is unique to `pymmcore-plus`.
129
144
  """
130
- mdaEngineRegistered: PSignal
145
+ mdaEngineRegistered: ClassVar[PSignal]
131
146
  """Emits `(MDAEngine, MDAEngine)` when an MDAEngine is registered.
132
147
 
133
148
  > :sparkles: This signal is unique to `pymmcore-plus`.
134
149
  """
135
150
 
136
- continuousSequenceAcquisitionStarting: PSignal
151
+ continuousSequenceAcquisitionStarting: ClassVar[PSignal]
137
152
  """Emits with no arguments *before* continuous sequence acquisition is started.
138
153
 
139
154
  > :sparkles: This signal is unique to `pymmcore-plus`.
140
155
  """
141
- continuousSequenceAcquisitionStarted: PSignal
156
+ continuousSequenceAcquisitionStarted: ClassVar[PSignal]
142
157
  """Emits with no arguments *after* continuous sequence acquisition has started.
143
158
 
144
159
  > :sparkles: This signal is unique to `pymmcore-plus`.
145
160
  """
146
- sequenceAcquisitionStarting: PSignal
161
+ sequenceAcquisitionStarting: ClassVar[PSignal]
147
162
  """Emits `(str, int, float, bool)` *before* sequence acquisition is started.
148
163
 
149
164
  (cameraLabel, numImages, intervalMs, stopOnOverflow)
150
165
  > :sparkles: This signal is unique to `pymmcore-plus`.
151
166
  """
152
- sequenceAcquisitionStarted: PSignal
167
+ sequenceAcquisitionStarted: ClassVar[PSignal]
153
168
  """Emits `(str, int, float, bool)` *after* sequence acquisition has started.
154
169
 
155
170
  (cameraLabel, numImages, intervalMs, stopOnOverflow)
156
171
  > :sparkles: This signal is unique to `pymmcore-plus`.
157
172
  """
158
- sequenceAcquisitionStopped: PSignal
173
+ sequenceAcquisitionStopped: ClassVar[PSignal]
159
174
  """Emits `(str)` when sequence acquisition is stopped.
160
175
 
161
176
  > :sparkles: This signal is unique to `pymmcore-plus`.
162
177
  """
163
- autoShutterSet: PSignal
178
+ autoShutterSet: ClassVar[PSignal]
164
179
  """Emits `(bool)` when the auto shutter setting is changed.
165
180
 
166
181
  """
167
- configGroupDeleted: PSignal
182
+ configGroupDeleted: ClassVar[PSignal]
168
183
  """Emits `(str)` when a config group is deleted.
169
184
 
170
185
  > :sparkles: This signal is unique to `pymmcore-plus`.
171
186
  """
172
- configDeleted: PSignal
187
+ configDeleted: ClassVar[PSignal]
173
188
  """Emits `(str, str)` when a config is deleted.
174
189
 
175
190
  > :sparkles: This signal is unique to `pymmcore-plus`.
176
191
  """
177
- configDefined: PSignal
192
+ configDefined: ClassVar[PSignal]
178
193
  """Emits `(str, str, str, str, str)` when a config is defined.
179
194
 
180
195
  > :sparkles: This signal is unique to `pymmcore-plus`.
181
196
  """
182
- roiSet: PSignal
197
+ roiSet: ClassVar[PSignal]
183
198
  """Emits `(str, int, int, int, int)` when an ROI is set.
184
199
 
185
200
  > :sparkles: This signal is unique to `pymmcore-plus`.
186
201
  """
187
202
 
188
203
  def devicePropertyChanged(
189
- self, device: str, property: Optional[str] = None
204
+ self, device: str, property: str | None = None
190
205
  ) -> PSignalInstance:
191
206
  """Return object to connect/disconnect to device/property-specific changes.
192
207
 
@@ -38,9 +38,9 @@ class CMMCoreSignaler(SignalGroup, _DevicePropertyEventMixin):
38
38
 
39
39
  # aliases for lower casing
40
40
  @property
41
- def xYStagePositionChanged(self) -> SignalInstance: # type: ignore
41
+ def xYStagePositionChanged(self) -> SignalInstance:
42
42
  return self.XYStagePositionChanged
43
43
 
44
44
  @property
45
- def sLMExposureChanged(self) -> SignalInstance: # type: ignore
45
+ def sLMExposureChanged(self) -> SignalInstance:
46
46
  return self.SLMExposureChanged
@@ -1,12 +1,18 @@
1
- from ._unicore import UniMMCore
1
+ from .core._unicore import UniMMCore
2
+ from .devices._camera import Camera
2
3
  from .devices._device import Device
3
4
  from .devices._properties import PropertyInfo, pymm_property
5
+ from .devices._slm import SLMDevice
4
6
  from .devices._stage import StageDevice, XYStageDevice, XYStepperStageDevice
7
+ from .devices._state import StateDevice
5
8
 
6
9
  __all__ = [
10
+ "Camera",
7
11
  "Device",
8
12
  "PropertyInfo",
13
+ "SLMDevice",
9
14
  "StageDevice",
15
+ "StateDevice",
10
16
  "UniMMCore",
11
17
  "XYStageDevice",
12
18
  "XYStepperStageDevice",
@@ -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,318 @@
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
+ class SequenceBuffer:
26
+ """A lock-protected circular buffer backed by a single numpy byte array.
27
+
28
+ This buffer is designed for a single device. If you want to stream data from
29
+ multiple devices, it is recommended to use a separate `SequenceBuffer` for each
30
+ device (rather than to pack two streams into a single FIFO buffer).
31
+
32
+ Parameters
33
+ ----------
34
+ size_mb:
35
+ Pool capacity in **mebibytes** (binary - 1 MiB = 1,048,576 bytes).
36
+ overwrite_on_overflow:
37
+ When `True` (default) the oldest frame will be discarded to make space.
38
+ When `False`, an attempt to acquire a slot that does not fit raises
39
+ `BufferError`.
40
+ """
41
+
42
+ def __init__(self, size_mb: float, *, overwrite_on_overflow: bool = True) -> None:
43
+ self._size_bytes: int = int(size_mb * 1024 * 1024)
44
+ self._pool: NDArray[np.uint8] = np.empty(self._size_bytes, dtype=np.uint8)
45
+
46
+ # ring indices (bytes)
47
+ self._head: int = 0 # next write offset
48
+ self._tail: int = 0 # oldest frame offset
49
+ self._bytes_in_use: int = 0 # live bytes (includes padding)
50
+
51
+ # active frames in FIFO order (BufferSlot objects)
52
+ self._slots: deque[BufferSlot] = deque()
53
+
54
+ self._overwrite_on_overflow: bool = overwrite_on_overflow
55
+ self._overflow_occurred: bool = False
56
+
57
+ self._lock = threading.Lock() # not re-entrant, but slightly faster than RLock
58
+ self._pending_slot: tuple[NDArray, int] | None = None # only 1 outstanding slot
59
+
60
+ # ---------------------------------------------------------------------
61
+ # Producer API - acquire a slot, fill it, finalize it
62
+ # ---------------------------------------------------------------------
63
+
64
+ def acquire_slot(
65
+ self, shape: Sequence[int], dtype: DTypeLike = np.uint8
66
+ ) -> NDArray[Any]:
67
+ """Return a **writable** view into the internal pool.
68
+
69
+ After filling the array, *must* call `finalize_slot`.
70
+ """
71
+ dtype_ = np.dtype(dtype)
72
+ nbytes_data = int(np.prod(shape, dtype=int) * dtype_.itemsize)
73
+
74
+ # ---------- NEW: explicit capacity check ---------------------------
75
+ if nbytes_data > self._size_bytes:
76
+ msg = (
77
+ f"Requested size ({nbytes_data} bytes) exceeds buffer capacity "
78
+ f"({self.size_mb} MiB)."
79
+ )
80
+ raise BufferError(msg)
81
+
82
+ # --- reserve space -------------------------------------------------
83
+
84
+ with self._lock:
85
+ if self._pending_slot is not None:
86
+ msg = "Cannot acquire a new slot before finalizing the pending one."
87
+ raise RuntimeError(msg)
88
+
89
+ # Calculate padding needed to align start address to dtype boundary
90
+ align_pad = (-self._head) % dtype_.itemsize
91
+ needed = nbytes_data + align_pad
92
+
93
+ # ensure capacity (may evict oldest frames if overwrite enabled)
94
+ self._ensure_space(needed)
95
+
96
+ # alignment may force wrapping to 0 => recompute alignment
97
+ if needed > self._contiguous_free_bytes:
98
+ # wrap head to start of buffer for contiguous allocation
99
+ self._head = 0
100
+ align_pad = 0 # new head is already aligned to buffer start
101
+ needed = nbytes_data # recalculated without padding
102
+ self._ensure_space(needed) # guaranteed to succeed after wrap
103
+
104
+ # Calculate actual start position after alignment padding
105
+ start = self._head + align_pad
106
+ # Advance head pointer past this allocation (with wraparound)
107
+ self._head = (start + nbytes_data) % self._size_bytes
108
+ # Track total bytes consumed (data + alignment padding)
109
+ self._bytes_in_use += needed
110
+
111
+ # Create zero-copy view into the pool buffer at calculated offset
112
+ arr: NDArray[Any] = np.ndarray(
113
+ shape, dtype_, buffer=self._pool, offset=start
114
+ )
115
+ self._pending_slot = (arr, needed)
116
+ return arr
117
+
118
+ def finalize_slot(self, metadata: Mapping[str, Any] | None = None) -> None:
119
+ """Publish the frame acquired via `acquire_write_slot`.
120
+
121
+ This makes the frame available for retrieval via `pop_next` or
122
+ `peek_last`. If `metadata` is provided, it will be merged into the
123
+ slot's metadata dictionary.
124
+ """
125
+ with self._lock:
126
+ if (slot := self._pending_slot) is None:
127
+ msg = "No pending slot to finalize"
128
+ raise RuntimeError(msg)
129
+
130
+ self._pending_slot = None
131
+ arr, nbytes_total = slot
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, *, copy: bool = False
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
+ self._evict_slot(slot)
169
+
170
+ if copy:
171
+ arr = slot.array.copy()
172
+ else:
173
+ # even though we're popping, we still return a read-only view since
174
+ # the caller should be able to modify our _pool directly.
175
+ arr = slot.array.view()
176
+ arr.flags.writeable = False
177
+
178
+ # return actual metadata, we're done with it.
179
+ return arr, (slot.metadata or {})
180
+
181
+ def peek_last(
182
+ self, *, copy: bool = False
183
+ ) -> tuple[NDArray[Any], Mapping[str, Any]] | None:
184
+ """Return the newest frame without removing it."""
185
+ return self.peek_nth_from_last(0, copy=copy)
186
+
187
+ def peek_nth_from_last(
188
+ self, n: int, *, copy: bool = False
189
+ ) -> tuple[NDArray[Any], dict[str, Any]] | None:
190
+ """Return the n-th most recent frame without removing it.
191
+
192
+ Last frame is n=0, second to last is n=1, etc.
193
+ """
194
+ with self._lock:
195
+ if n < 0 or n >= len(self._slots):
196
+ return None
197
+ slot = self._slots[-(n + 1)]
198
+
199
+ if copy:
200
+ arr = slot.array.copy()
201
+ else:
202
+ # Return a read-only view to prevent modification of data in the buffer
203
+ arr = slot.array.view()
204
+ arr.flags.writeable = False
205
+
206
+ # Return a copy of the metadata to avoid external modification
207
+ return arr, (dict(slot.metadata) if slot.metadata else {})
208
+
209
+ # ------------------------------------------------------------------
210
+ # Administrative helpers
211
+ # ------------------------------------------------------------------
212
+
213
+ def clear(self) -> None:
214
+ with self._lock:
215
+ self._slots.clear()
216
+ self._pending_slot = None
217
+ self._head = self._tail = self._bytes_in_use = 0
218
+ self._overflow_occurred = False
219
+
220
+ # ------------------------------------------------------------------
221
+ # Properties
222
+ # ------------------------------------------------------------------
223
+
224
+ @property
225
+ def size_mb(self) -> float: # human readable
226
+ return self._size_bytes / 1024**2
227
+
228
+ @property
229
+ def size_bytes(self) -> int:
230
+ """Return the buffer size in bytes."""
231
+ return self._size_bytes
232
+
233
+ @property
234
+ def used_bytes(self) -> int:
235
+ """Return the number of bytes currently in use."""
236
+ return self._bytes_in_use
237
+
238
+ @property
239
+ def free_bytes(self) -> int:
240
+ """Return the number of free bytes in the buffer."""
241
+ return self._size_bytes - self._bytes_in_use
242
+
243
+ @property
244
+ def free_mb(self) -> float:
245
+ """Get the free space in the buffer in mebibytes."""
246
+ return self.free_bytes / 1024**2
247
+
248
+ @property
249
+ def overwrite_on_overflow(self) -> bool:
250
+ """Return the overflow policy (immutable while data is present)."""
251
+ return self._overwrite_on_overflow
252
+
253
+ @overwrite_on_overflow.setter
254
+ def overwrite_on_overflow(self, value: bool) -> None:
255
+ """Set the overflow policy (immutable while data is present)."""
256
+ with self._lock:
257
+ if self._bytes_in_use > 0:
258
+ msg = "Cannot change overflow policy with active data in buffer."
259
+ raise RuntimeError(msg)
260
+ self._overwrite_on_overflow = value
261
+
262
+ def __len__(self) -> int:
263
+ """Return the number of frames currently in the buffer."""
264
+ return len(self._slots)
265
+
266
+ @property
267
+ def overflow_occurred(self) -> bool:
268
+ """Return whether an overflow occurred since the last clear."""
269
+ return self._overflow_occurred
270
+
271
+ def __repr__(self) -> str:
272
+ used_mb = self.used_bytes / 1024**2
273
+ name = self.__class__.__name__
274
+ return (
275
+ f"{name}(size_mb={self.size_mb:.1f}, slots={len(self)}, "
276
+ f"used_mb={used_mb:.1f}, overwrite={self._overwrite_on_overflow})"
277
+ )
278
+
279
+ # ------------------------------------------------------------------
280
+ # Internal helpers
281
+ # ------------------------------------------------------------------
282
+
283
+ def _evict_slot(self, slot: BufferSlot) -> None:
284
+ """Advance the tail pointer past `slot`, updating house-keeping."""
285
+ self._tail = (self._tail + slot.nbytes_total) % self._size_bytes
286
+ self._bytes_in_use -= slot.nbytes_total
287
+
288
+ @property
289
+ def _contiguous_free_bytes(self) -> int:
290
+ """Get the number of contiguous free bytes in the buffer."""
291
+ if self._bytes_in_use >= self._size_bytes:
292
+ return 0
293
+ if self._bytes_in_use == 0:
294
+ return self._size_bytes
295
+ if self._head >= self._tail:
296
+ return self._size_bytes - self._head
297
+ # it's very hard to make this case happen...
298
+ return self._tail - self._head # pragma: no cover
299
+
300
+ def _ensure_space(self, needed: int) -> None:
301
+ """Ensure `needed` bytes contiguous space is available."""
302
+ while self._contiguous_free_bytes < needed:
303
+ if not self._slots:
304
+ # Buffer empty but fragmented: just reset pointers
305
+ self._head = self._tail = self._bytes_in_use = 0
306
+ break
307
+ if not self._overwrite_on_overflow:
308
+ self._overflow_occurred = True
309
+ msg = "Buffer is full and overwrite is disabled."
310
+ raise BufferError(msg)
311
+ self._overflow_occurred = True
312
+ while self._slots and self._contiguous_free_bytes < needed:
313
+ slot = self._slots.popleft()
314
+ self._evict_slot(slot)
315
+
316
+ # If the buffer is now empty, reset head/tail to maximise contiguous space.
317
+ if self._bytes_in_use == 0:
318
+ self._head = self._tail = 0