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.
- pymmcore_plus/__init__.py +25 -0
- pymmcore_plus/_ipy_completion.py +363 -0
- pymmcore_plus/_pymmcore.py +4 -2
- pymmcore_plus/core/_constants.py +25 -3
- pymmcore_plus/core/_mmcore_plus.py +110 -55
- pymmcore_plus/core/_sequencing.py +1 -1
- pymmcore_plus/core/events/_deprecated.py +67 -0
- pymmcore_plus/core/events/_protocol.py +64 -39
- pymmcore_plus/core/events/_psygnal.py +35 -6
- pymmcore_plus/core/events/_qsignals.py +34 -6
- pymmcore_plus/experimental/unicore/__init__.py +12 -2
- pymmcore_plus/experimental/unicore/_device_manager.py +1 -1
- pymmcore_plus/experimental/unicore/_proxy.py +20 -3
- pymmcore_plus/experimental/unicore/core/__init__.py +0 -0
- pymmcore_plus/experimental/unicore/core/_sequence_buffer.py +314 -0
- pymmcore_plus/experimental/unicore/core/_unicore.py +1769 -0
- pymmcore_plus/experimental/unicore/devices/_camera.py +201 -0
- pymmcore_plus/experimental/unicore/devices/{_device.py → _device_base.py} +54 -28
- pymmcore_plus/experimental/unicore/devices/_generic_device.py +12 -0
- pymmcore_plus/experimental/unicore/devices/_properties.py +9 -2
- pymmcore_plus/experimental/unicore/devices/_shutter.py +30 -0
- pymmcore_plus/experimental/unicore/devices/_slm.py +82 -0
- pymmcore_plus/experimental/unicore/devices/_stage.py +1 -1
- pymmcore_plus/experimental/unicore/devices/_state.py +152 -0
- pymmcore_plus/mda/events/_protocol.py +8 -8
- {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/METADATA +2 -2
- {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/RECORD +30 -21
- pymmcore_plus/experimental/unicore/_unicore.py +0 -703
- {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/WHEEL +0 -0
- {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.2.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
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
|