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.
- pymmcore_plus/__init__.py +2 -0
- pymmcore_plus/_accumulator.py +258 -0
- pymmcore_plus/_pymmcore.py +4 -2
- pymmcore_plus/core/__init__.py +34 -1
- pymmcore_plus/core/_constants.py +21 -3
- pymmcore_plus/core/_device.py +739 -19
- pymmcore_plus/core/_mmcore_plus.py +260 -47
- pymmcore_plus/core/events/_protocol.py +49 -34
- pymmcore_plus/core/events/_psygnal.py +2 -2
- pymmcore_plus/experimental/unicore/__init__.py +7 -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 +318 -0
- pymmcore_plus/experimental/unicore/core/_unicore.py +1702 -0
- pymmcore_plus/experimental/unicore/devices/_camera.py +196 -0
- pymmcore_plus/experimental/unicore/devices/_device.py +54 -28
- pymmcore_plus/experimental/unicore/devices/_properties.py +8 -1
- pymmcore_plus/experimental/unicore/devices/_slm.py +82 -0
- pymmcore_plus/experimental/unicore/devices/_state.py +152 -0
- pymmcore_plus/mda/events/_protocol.py +8 -8
- pymmcore_plus/mda/handlers/_tensorstore_handler.py +3 -1
- {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/METADATA +14 -37
- {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/RECORD +26 -20
- pymmcore_plus/experimental/unicore/_unicore.py +0 -703
- {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/WHEEL +0 -0
- {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/entry_points.txt +0 -0
- {pymmcore_plus-0.13.7.dist-info → pymmcore_plus-0.15.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
from
|
|
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:
|
|
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
|
|
37
|
+
class PSignal(Protocol):
|
|
27
38
|
"""Descriptor that returns a signal instance."""
|
|
28
39
|
|
|
29
|
-
|
|
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:
|
|
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:
|
|
41
|
+
def xYStagePositionChanged(self) -> SignalInstance:
|
|
42
42
|
return self.XYStagePositionChanged
|
|
43
43
|
|
|
44
44
|
@property
|
|
45
|
-
def sLMExposureChanged(self) -> SignalInstance:
|
|
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
|
-
#
|
|
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
|