Coverage for src/evutils/io/buffer.py: 100%

90 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-15 09:33 +0000

1"""Event buffering module. 

2 

3Provides `EventAccumulator` for buffering and rotating event structures 

4in memory efficiently. 

5""" 

6 

7import numpy as np 

8 

9from ..types import EventArray, TriggerArray 

10from ._native_core import EventSoABuffers, TriggerSoABuffers 

11 

12 

13class EventAccumulator(): 

14 """Reused struct-of-arrays staging buffer that native decoders fill in place. 

15 

16 The whole point is to avoid a copy on the read path: instead of a decoder 

17 parsing into its own buffer and the reader copying that into a ring, the 

18 decoder's parser writes *directly* into this accumulator's storage (via 

19 :meth:`prepare` + ``decoder.parse_step``). Consumed events at the front are 

20 reclaimed by :meth:`_rotate` (moving only the small unconsumed remainder), so 

21 the backing arrays are allocated once and never re-faulted. Only the final 

22 window handed to the caller is copied (:meth:`slice_copy`). 

23 

24 Timestamps are stored as ``uint64`` (matching the native ``timestamp64_t``) 

25 and exposed as ``int64`` via a zero-copy ``.view`` (values are positive and 

26 in range). 

27 """ 

28 

29 def __init__(self, capacity: int): 

30 """Initialize the event accumulator. 

31  

32 Parameters 

33 ---------- 

34 capacity : int 

35 The initial capacity for the event buffer. 

36 

37 """ 

38 self._capacity = int(capacity) 

39 self._buf = EventSoABuffers(self._capacity) 

40 self._tr_capacity = max(self._capacity // 16, 1) 

41 self._tr = TriggerSoABuffers(self._tr_capacity) 

42 self._start = 0 # events before this index are consumed 

43 self._tr_start = 0 

44 

45 def __len__(self) -> int: 

46 return self._buf.size - self._start 

47 

48 def t_window(self) -> np.ndarray: 

49 """int64 view of the currently unconsumed timestamps (zero-copy). 

50  

51 Returns 

52 ------- 

53 np.ndarray 

54 The unconsumed timestamps as an int64 array. 

55 

56 """ 

57 s, e = self._start, self._buf.size 

58 return self._buf.t[s:e].view(np.int64) 

59 

60 def t_window_tr(self) -> np.ndarray: 

61 """int64 view of the currently unconsumed trigger timestamps (zero-copy). 

62 

63 Returns 

64 ------- 

65 np.ndarray 

66 The unconsumed trigger timestamps as an int64 array. 

67 

68 """ 

69 s, e = self._tr_start, self._tr.size 

70 return self._tr.t[s:e].view(np.int64) 

71 

72 def _rotate(self) -> None: 

73 """Move the unconsumed remainder to the front, freeing consumed space.""" 

74 s = self._start 

75 if s > 0: 

76 b = self._buf 

77 n = b.size - s 

78 if n > 0: 

79 b.t[:n] = b.t[s:b.size] 

80 b.x[:n] = b.x[s:b.size] 

81 b.y[:n] = b.y[s:b.size] 

82 b.p[:n] = b.p[s:b.size] 

83 b.c.size = n 

84 self._start = 0 

85 

86 s_tr = self._tr_start 

87 if s_tr > 0: 

88 t = self._tr 

89 n_tr = t.size - s_tr 

90 if n_tr > 0: 

91 t.t[:n_tr] = t.t[s_tr:t.size] 

92 t.id[:n_tr] = t.id[s_tr:t.size] 

93 t.p[:n_tr] = t.p[s_tr:t.size] 

94 t.c.size = n_tr 

95 self._tr_start = 0 

96 

97 def prepare(self, step: int) -> tuple[EventSoABuffers, TriggerSoABuffers]: 

98 """Ready the buffer for the decoder to append up to ``step`` events, and 

99 return ``(events_soa, triggers_soa)`` for ``decoder.parse_step``. 

100 

101 Rotates out consumed events if there is not enough tail room, then caps 

102 the SoA capacity the parser sees to ``size + step`` so a single step does 

103 not overshoot the requested window by more than one step's worth. 

104  

105 Parameters 

106 ---------- 

107 step : int 

108 The number of events the decoder is expected to append. 

109 

110 Returns 

111 ------- 

112 tuple 

113 A tuple of (events_soa, triggers_soa) buffers. 

114 

115 """ 

116 b = self._buf 

117 t = self._tr 

118 if self._capacity - b.size < step or t.capacity - t.size < step // 16: 

119 self._rotate() 

120 b.c.capacity = min(self._capacity, b.size + step) 

121 t.c.capacity = t.capacity 

122 return b, t 

123 

124 def append(self, data: EventArray, triggers: TriggerArray | None = None) -> None: 

125 """Copy an EventArray in (fallback path for non-native decoders). 

126 

127 Parameters 

128 ---------- 

129 data : EventArray 

130 The events to append. 

131 triggers : TriggerArray, optional 

132 The triggers to append. 

133 

134 """ 

135 n = len(data) 

136 if n > 0: 

137 b = self._buf 

138 if self._capacity - b.size < n: 

139 self._rotate() 

140 if self._capacity - b.size < n: 

141 raise ValueError( 

142 f"EventAccumulator full: can't append {n} ({self._capacity - b.size} free)" 

143 ) 

144 e = b.size 

145 b.t[e:e + n] = data.t 

146 b.x[e:e + n] = data.x 

147 b.y[e:e + n] = data.y 

148 b.p[e:e + n] = data.p 

149 b.c.size = e + n 

150 

151 if triggers is not None and len(triggers) > 0: 

152 n_tr = len(triggers) 

153 t = self._tr 

154 if t.capacity - t.size < n_tr: 

155 self._rotate() 

156 if t.capacity - t.size < n_tr: 

157 t.grow(t.size + n_tr) 

158 e_tr = t.size 

159 t.t[e_tr:e_tr + n_tr] = triggers.t 

160 t.id[e_tr:e_tr + n_tr] = triggers.id 

161 t.p[e_tr:e_tr + n_tr] = triggers.p 

162 t.c.size = e_tr + n_tr 

163 

164 def slice_copy(self, k: int, tr_k: int = 0) -> tuple[EventArray, TriggerArray]: 

165 """Return an independent copy of the first ``k`` unconsumed events and 

166 advance past them. 

167  

168 Parameters 

169 ---------- 

170 k : int 

171 The number of unconsumed events to slice and copy. 

172 tr_k : int, optional 

173 The number of unconsumed triggers to slice and copy. 

174 

175 Returns 

176 ------- 

177 tuple 

178 A tuple of (EventArray, TriggerArray) containing the copied slices. 

179 

180 """ 

181 s = self._buf 

182 i = self._start 

183 out = EventArray( 

184 s.t[i:i + k].view(np.int64), s.x[i:i + k], s.y[i:i + k], s.p[i:i + k] 

185 ).copy() 

186 self._start += k 

187 

188 t = self._tr 

189 j = self._tr_start 

190 out_tr = TriggerArray( 

191 t.t[j:j + tr_k].view(np.int64), t.p[j:j + tr_k], t.id[j:j + tr_k] 

192 ).copy() 

193 self._tr_start += tr_k 

194 

195 return out, out_tr 

196 

197 def reset(self) -> None: 

198 """Reset the buffer and trigger size to 0 and clear consumed offsets.""" 

199 self._buf.c.size = 0 

200 self._tr.c.size = 0 

201 self._start = 0 

202 self._tr_start = 0 

203