Coverage for src/evutils/chunking.py: 90%

185 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-18 05:24 +0000

1"""Splitting event streams into chunks. 

2 

3Slice a continuous event stream into fixed-size windows, either by event 

4count or by time interval. 

5""" 

6 

7import numpy as np 

8 

9import time 

10import queue 

11import threading 

12from typing import Iterator 

13from evutils.io.buffer import EventAccumulator 

14from evutils.types import EventArray 

15 

16def window_delta_t(events: np.ndarray, delta_t: int = 10_000) -> Iterator[np.ndarray]: 

17 """Returns a generator that chunks the events array into windows of size delta_t. 

18  

19 Parameters 

20 ---------- 

21 events : np.ndarray 

22 Array of events 

23 delta_t : int, optional 

24 Size of the window in microseconds, by default 10_000 

25 

26 Examples 

27 -------- 

28 >>> from evutils.random import random_events 

29 >>> from evutils.chunking import window_delta_t 

30 >>> events = random_events(1000, start_ts=0, end_ts=30_000) 

31 >>> chunks = list(window_delta_t(events, delta_t=10_000)) 

32 >>> len(chunks) > 0 

33 True 

34 """ 

35 if delta_t <= 0: 

36 raise ValueError("delta_t must be positive") 

37 if len(events) == 0: 

38 return 

39 

40 index_start = 0 

41 

42 ts = events["t"] 

43 current_ts = ts[0] 

44 

45 while index_start < len(events): 

46 next_index = np.searchsorted(ts[index_start:], current_ts + delta_t) 

47 

48 window = events[index_start:index_start + next_index] 

49 yield window 

50 

51 current_ts += delta_t 

52 index_start += next_index 

53 

54def sliding_window(events: np.ndarray, delta_t: int = 10_000, window_size: int = 20_000, full_window: bool = False) -> Iterator[np.ndarray]: 

55 """Returns a generator that chunks the events array into windows of size delta_t. 

56  

57 Parameters 

58 ---------- 

59 events : np.ndarray 

60 Array of events 

61 delta_t : int, optional 

62 Time delta between frames in microseconds, by default 10_000 

63 window_size : int, optional 

64 Size of the window in microseconds, by default 20_000 

65 can overlap with the next frame 

66 full_window : bool, optional 

67 If True, the last window will be full, by default False 

68 If False, the last window will be the remaining events 

69 

70 Examples 

71 -------- 

72 >>> from evutils.random import random_events 

73 >>> from evutils.chunking import sliding_window 

74 >>> events = random_events(1000, start_ts=0, end_ts=50_000) 

75 >>> chunks = list(sliding_window(events, delta_t=10_000, window_size=20_000)) 

76 >>> len(chunks) > 0 

77 True 

78 """ 

79 if delta_t <= 0 or window_size <= 0: 

80 raise ValueError("delta_t and window_size must be positive") 

81 if len(events) == 0: 

82 return 

83 

84 index_start = 0 

85 

86 ts = events["t"] 

87 current_ts = ts[0] 

88 

89 while index_start < len(events): 

90 

91 next_frame_index = np.searchsorted(ts[index_start:], current_ts + delta_t) 

92 next_window_index = np.searchsorted(ts[index_start:], current_ts + window_size) 

93 

94 

95 

96 # Exit if the next window index is not full 

97 if full_window and index_start + next_window_index >= len(events): 

98 break 

99 

100 window = events[index_start:index_start + next_window_index] 

101 yield window 

102 

103 current_ts += delta_t 

104 index_start += next_frame_index 

105 

106def sort_events(events: np.ndarray) -> np.ndarray: 

107 """Sorts the events array by timestamp. 

108  

109 Parameters 

110 ---------- 

111 events : np.ndarray 

112 Array of events 

113 

114 Examples 

115 -------- 

116 >>> import numpy as np 

117 >>> from evutils.chunking import sort_events 

118 >>> from evutils.random import random_events 

119 >>> events = random_events(10) 

120 >>> events["t"] = np.arange(10, 0, -1) 

121 >>> sorted_events = sort_events(events) 

122 >>> int(sorted_events["t"][0]) 

123 1 

124 """ 

125 return np.sort(events, order="t") 

126 

127def get_dt_events(events: np.ndarray, dt: int =10_000) -> np.ndarray: 

128 """Returns the events that are within a time window of dt from the first event's timestamp. 

129 

130 Parameters 

131 ---------- 

132 events : np.ndarray 

133 Array of events 

134 dt : int, optional 

135 Time window in microseconds, by default 10_000 

136 

137 Examples 

138 -------- 

139 >>> import numpy as np 

140 >>> from evutils.random import random_events 

141 >>> from evutils.chunking import get_dt_events 

142 >>> events = random_events(100, start_ts=0, end_ts=50_000) 

143 >>> sub_events = get_dt_events(events, dt=10_000) 

144 >>> bool((sub_events["t"] <= events["t"][0] + 10_000).all()) 

145 True 

146 """ 

147 if len(events) == 0: 

148 return events 

149 

150 first_ts = events[0]['t'] 

151 last_ts = first_ts + dt 

152 

153 next_index = np.searchsorted(events['t'], last_ts) 

154 

155 return events[:next_index] 

156def stream_delta_t(raw_stream: Iterator[np.ndarray | EventArray], delta_t: int) -> Iterator[np.ndarray | EventArray]: 

157 """A pipeline generator that turns a raw stream into perfect delta_t chunks. 

158 This maintains a small internal buffer for events that cross boundaries. 

159 """ 

160 if delta_t <= 0: 

161 raise ValueError("delta_t must be positive") 

162 # Start small: the accumulator grows geometrically on demand, so a large 

163 # up-front capacity only page-faults ~GBs of memory for nothing. 

164 acc = EventAccumulator(capacity=1_000_000) 

165 current_ts = None 

166 has_triggers = False # set from the stream's items: tuple => (events, triggers) 

167 

168 for incoming in raw_stream: 

169 # Handle unpacking depending on if the stream yields triggers or not 

170 if isinstance(incoming, tuple): 

171 has_triggers = True 

172 ev, tr = incoming 

173 acc.append(ev, tr) 

174 else: 

175 acc.append(incoming, None) 

176 

177 if len(acc) == 0 and (not has_triggers or (acc._tr.size - acc._tr_start) == 0): 

178 continue 

179 

180 # Initialize our absolute time anchor from the very first event or trigger 

181 if current_ts is None: 

182 if len(acc) > 0: 

183 current_ts = int(acc.t_window()[0]) 

184 elif has_triggers and (acc._tr.size - acc._tr_start) > 0: 

185 current_ts = int(acc.t_window_tr()[0]) 

186 else: 

187 continue 

188 

189 # Yield as many full windows as we have accumulated 

190 while True: 

191 end_ts = current_ts + delta_t 

192 t = acc.t_window() 

193 

194 max_ts = -1 

195 if len(t) > 0: 

196 max_ts = t[-1] 

197 if has_triggers: 

198 tr_t = acc.t_window_tr() 

199 if len(tr_t) > 0 and tr_t[-1] > max_ts: 

200 max_ts = tr_t[-1] 

201 

202 if max_ts < end_ts: 

203 # Not enough data for a full window yet; fetch more chunks 

204 break 

205 

206 # Find boundary 

207 idx = int(np.searchsorted(t, end_ts, side='left')) if len(t) > 0 else 0 

208 

209 # Slice and yield. Mirror the input shape: only a trigger-carrying 

210 # stream ((events, triggers) tuples) yields tuples back. 

211 if has_triggers: 

212 tr_t = acc.t_window_tr() 

213 tr_idx = int(np.searchsorted(tr_t, end_ts, side='left')) if len(tr_t) > 0 else 0 

214 chunk_ev, chunk_tr = acc.slice_copy(idx, tr_idx) 

215 yield chunk_ev, chunk_tr 

216 else: 

217 chunk_ev, _ = acc.slice_copy(idx, 0) 

218 yield chunk_ev 

219 

220 current_ts += delta_t 

221 

222 # Stream finished! Yield whatever is leftover in the buffer 

223 if len(acc) > 0 or (has_triggers and acc._tr.size - acc._tr_start > 0): 

224 if has_triggers: 

225 yield acc.slice_copy(len(acc), acc._tr.size - acc._tr_start) 

226 else: 

227 yield acc.slice_copy(len(acc), 0)[0] 

228 

229def stream_n_events(raw_stream: Iterator[np.ndarray | EventArray], n_events: int) -> Iterator[np.ndarray | EventArray]: 

230 """Pipeline generator: chunks stream by event count.""" 

231 if n_events <= 0: 

232 raise ValueError("n_events must be positive") 

233 acc = EventAccumulator(capacity=max(1_000_000, n_events * 2)) 

234 has_triggers = False # set from the stream's items: tuple => (events, triggers) 

235 for incoming in raw_stream: 

236 if isinstance(incoming, tuple): 

237 has_triggers = True 

238 acc.append(incoming[0], incoming[1]) 

239 else: 

240 acc.append(incoming, None) 

241 

242 while len(acc) >= n_events: 

243 if has_triggers: 

244 if len(acc) == n_events: 

245 tr_idx = acc._tr.size - acc._tr_start 

246 else: 

247 tr_idx = int(np.searchsorted(acc.t_window_tr(), acc.t_window()[n_events], side='left')) 

248 yield acc.slice_copy(n_events, tr_idx) 

249 else: 

250 yield acc.slice_copy(n_events, 0)[0] 

251 

252 if len(acc) > 0 or (has_triggers and acc._tr.size - acc._tr_start > 0): 

253 if has_triggers: 

254 yield acc.slice_copy(len(acc), acc._tr.size - acc._tr_start) 

255 else: 

256 yield acc.slice_copy(len(acc), 0)[0] 

257 

258def stream_skip_to_time(stream: Iterator[np.ndarray | EventArray], start_ts: int) -> Iterator[np.ndarray | EventArray]: 

259 """Pipeline generator: drops events until start_ts is reached.""" 

260 skipping = True 

261 for incoming in stream: 

262 ev = incoming[0] if isinstance(incoming, tuple) else incoming 

263 if skipping: 

264 # ["t"] works for both EventArray and plain structured ndarrays 

265 # (attribute access .t does not exist on the latter). 

266 if len(ev) == 0 or ev["t"][-1] < start_ts: 

267 continue # Drop whole chunk 

268 

269 # Found the boundary! Slice the chunk and stop skipping 

270 idx = int(np.searchsorted(ev["t"], start_ts)) 

271 skipping = False 

272 

273 if isinstance(incoming, tuple): 

274 tr_idx = int(np.searchsorted(incoming[1]["t"], start_ts)) 

275 yield incoming[0][idx:], incoming[1][tr_idx:] 

276 else: 

277 yield incoming[idx:] 

278 else: 

279 yield incoming 

280 

281def stream_skip_to_event(stream: Iterator[np.ndarray | EventArray], n: int) -> Iterator[np.ndarray | EventArray]: 

282 """Pipeline generator: drops the first ``n`` events, then passes through. 

283 

284 The event-index counterpart of :func:`stream_skip_to_time` -- the linear 

285 fallback for seeking by event index on a non-seekable stream. 

286 """ 

287 seen = 0 

288 skipping = True 

289 for incoming in stream: 

290 ev = incoming[0] if isinstance(incoming, tuple) else incoming 

291 if skipping: 

292 if seen + len(ev) <= n: 

293 seen += len(ev) 

294 continue # whole chunk is before the target 

295 idx = n - seen 

296 skipping = False 

297 if isinstance(incoming, tuple): 

298 # Slice triggers at the first kept event's timestamp, mirroring 

299 # stream_skip_to_time (triggers before the target are dropped). 

300 if len(incoming[1]) > 0 and idx < len(ev): 

301 tr_idx = int(np.searchsorted(incoming[1]["t"], ev["t"][idx])) 

302 else: 

303 tr_idx = 0 

304 yield incoming[0][idx:], incoming[1][tr_idx:] 

305 else: 

306 yield incoming[idx:] 

307 else: 

308 yield incoming 

309 

310def stream_async(stream: Iterator[np.ndarray | EventArray], maxsize: int = 5) -> Iterator[np.ndarray | EventArray]: 

311 """Pipeline generator: runs upstream decoding in a background thread.""" 

312 q: queue.Queue[np.ndarray | EventArray] = queue.Queue(maxsize=maxsize) 

313 

314 def worker() -> None: 

315 try: 

316 for item in stream: 

317 # IMPORTANT: C-parsers reuse internal buffers! We MUST copy the chunk 

318 # before placing it in the queue to prevent the next read_chunk()  

319 # from overwriting the memory of the chunk we just yielded! 

320 if isinstance(item, tuple): 

321 ev = item[0].copy() if item[0] is not None else None 

322 tr = item[1].copy() if item[1] is not None else None 

323 q.put((ev, tr)) 

324 else: 

325 q.put(item.copy()) 

326 except Exception as e: 

327 q.put(e) 

328 finally: 

329 q.put(None) # Sentinel 

330 

331 t = threading.Thread(target=worker, daemon=True) 

332 t.start() 

333 

334 while True: 

335 item = q.get() 

336 if item is None: 

337 break 

338 if isinstance(item, Exception): 

339 raise item 

340 yield item 

341 

342def stream_paced_playback(stream: Iterator[np.ndarray | EventArray], playback_speed: float = 1.0) -> Iterator[np.ndarray | EventArray]: 

343 """Pipeline generator: spaces out yielding chunks to match wall-clock real-time.""" 

344 start_wall = None 

345 start_ts = None 

346 

347 for incoming in stream: 

348 ev = incoming[0] if isinstance(incoming, tuple) else incoming 

349 if len(ev) == 0: 

350 yield incoming 

351 continue 

352 

353 if start_ts is None: 

354 start_ts = ev["t"][0] 

355 start_wall = time.perf_counter() 

356 

357 # How far into the stream is this chunk's end? 

358 stream_elapsed_us = ev["t"][-1] - start_ts 

359 expected_wall_elapsed = (stream_elapsed_us / 1_000_000) / playback_speed 

360 

361 target_wall = start_wall + expected_wall_elapsed 

362 now = time.perf_counter() 

363 

364 if target_wall > now: 

365 time.sleep(target_wall - now) 

366 

367 yield incoming