Coverage for tests/io/test_seek.py: 91%

297 statements  

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

1"""Tests for timestamp / event-index random access (``EventReader.seek``). 

2 

3Synthetic, CI-safe: a known ramp (``t = i*dt``) is written in each seekable 

4format and seeked by time, event index, relatively and backward. The ramp 

5timestamps exceed the EVT3 TIME_HIGH wrap period (2**24 µs), so the post-seek 

6wrap correction is exercised. A separate test validates the Metavision 

7``.tmp_index`` reader against the real recordings in ``data/`` when present. 

8""" 

9from pathlib import Path 

10 

11import numpy as np 

12import pytest 

13 

14from evutils.io import EventReader, EventWriter 

15from evutils.types import Event_dtype 

16 

17# (format, extension). HDF5 needs the optional h5py dependency. 

18FORMATS = [ 

19 ("evt3", "raw"), ("evt4", "raw"), ("evt2", "raw"), ("evt21", "raw"), 

20 ("dat", "dat"), ("csv", "csv"), ("npz", "npz"), ("hdf5", "hdf5"), 

21] 

22 

23N = 20_000 

24DT = 1_000 # max t = 20_000_000 > 2**24 (16_777_216): EVT wrap is exercised. 

25 

26 

27def _ramp(n: int = N, dt: int = DT): 

28 ev = np.zeros(n, dtype=Event_dtype) 

29 ev["t"] = np.arange(n, dtype=np.int64) * dt 

30 ev["x"] = np.arange(n) % 1280 

31 ev["y"] = np.arange(n) % 720 

32 ev["p"] = np.arange(n) % 2 

33 return ev 

34 

35 

36def _write(path, fmt, ev, tr=None): 

37 if fmt in ("evt3", "evt4", "evt2", "evt21"): 

38 with EventWriter(path, format=fmt) as w: 

39 w.write(ev) 

40 if tr is not None: 

41 w.write_triggers(tr) 

42 else: # dispatched by extension; needs geometry for evt/hdf5-style writers 

43 with EventWriter(path, width=1280, height=720) as w: 

44 w.write(ev) 

45 if tr is not None: 

46 w.write_triggers(tr) 

47 

48 

49@pytest.fixture(params=FORMATS, ids=[f[0] for f in FORMATS]) 

50def recording(request, tmp_path): 

51 fmt, ext = request.param 

52 if fmt == "hdf5": 

53 pytest.importorskip("h5py") 

54 ev = _ramp() 

55 p = tmp_path / f"ramp_{fmt}.{ext}" 

56 _write(p, fmt, ev) 

57 return p, ev 

58 

59 

60def test_seek_by_time(recording): 

61 p, ev = recording 

62 T = 10_000_000 

63 exp = int(np.searchsorted(ev["t"], T)) 

64 with EventReader(p, n_events=3000) as r: 

65 landed = r.seek(t=T) 

66 c = r.read() 

67 assert landed.ts == int(ev["t"][exp]) 

68 assert int(c.t[0]) == int(ev["t"][exp]) 

69 assert int(c.t[0]) >= T 

70 

71 

72def test_seek_by_event_index(recording): 

73 p, ev = recording 

74 n = 7000 

75 with EventReader(p, n_events=3000) as r: 

76 landed = r.seek(n=n) 

77 c = r.read() 

78 assert landed.ts == int(ev["t"][n]) 

79 assert int(c.t[0]) == int(ev["t"][n]) 

80 assert int(c.x[0]) == int(ev["x"][n]) 

81 assert int(c.y[0]) == int(ev["y"][n]) 

82 

83 

84def test_seek_backward(recording): 

85 p, ev = recording 

86 with EventReader(p, n_events=3000) as r: 

87 r.seek(n=7000) 

88 r.seek(n=200) # backward 

89 c = r.read() 

90 assert int(c.t[0]) == int(ev["t"][200]) 

91 

92 

93def test_seek_relative(recording): 

94 p, ev = recording 

95 with EventReader(p, n_events=3000) as r: 

96 r.seek(t=5_000_000) 

97 landed = r.seek(t=5_000_000, relative=True) # -> 10_000_000 

98 c = r.read() 

99 exp = int(np.searchsorted(ev["t"], 10_000_000)) 

100 assert landed.ts == int(ev["t"][exp]) 

101 assert int(c.t[0]) == int(ev["t"][exp]) 

102 

103 

104def test_seek_then_drain_is_contiguous_tail(recording): 

105 p, ev = recording 

106 T = 8_000_000 

107 exp = int(np.searchsorted(ev["t"], T)) 

108 with EventReader(p, n_events=1500) as r: 

109 r.seek(t=T) 

110 got = [] 

111 while True: 

112 c = r.read() 

113 if len(c) == 0: 

114 break 

115 got.append(np.asarray(c.t).copy()) 

116 tail = np.concatenate(got) 

117 assert np.array_equal(tail, ev["t"][exp:]) 

118 

119 

120def test_seek_in_delta_t_mode(recording): 

121 p, ev = recording 

122 T = 10_000_000 

123 exp = int(np.searchsorted(ev["t"], T)) 

124 with EventReader(p, delta_t=1_000_000) as r: 

125 r.seek(t=T) 

126 c = r.read() 

127 assert len(c) > 0 

128 assert int(c.t[0]) == int(ev["t"][exp]) 

129 assert int(c.t[-1]) < T + 1_000_000 

130 

131 

132def test_seek_index_disabled_matches(recording): 

133 """index=False (no sidecar) yields the same result as the default.""" 

134 p, ev = recording 

135 T = 12_345_000 

136 with EventReader(p, n_events=3000, index=False) as r: 

137 a = r.seek(t=T) 

138 with EventReader(p, n_events=3000) as r: 

139 b = r.seek(t=T) 

140 assert a.ts == b.ts == int(ev["t"][int(np.searchsorted(ev["t"], T))]) 

141 

142 

143def test_seek_out_of_range(recording): 

144 p, ev = recording 

145 with EventReader(p, n_events=3000) as r: 

146 r.seek(t=10**15) # far past the end 

147 assert len(r.read()) == 0 

148 

149 

150def test_seek_requires_exactly_one_axis(recording): 

151 p, _ = recording 

152 with EventReader(p, n_events=3000) as r: 

153 with pytest.raises(ValueError, match="exactly one"): 

154 r.seek() 

155 with pytest.raises(ValueError, match="exactly one"): 

156 r.seek(t=1, n=1) 

157 

158 

159# --------------------------------------------------------------------------- # 

160# Non-seekable source: linear iterate-and-skip fallback 

161# --------------------------------------------------------------------------- # 

162 

163class _NonSeekable: 

164 """A read-only, non-seekable byte stream (like a pipe) over a bytes payload.""" 

165 

166 def __init__(self, data: bytes): 

167 self._data = data 

168 self._pos = 0 

169 

170 def read(self, size: int = -1) -> bytes: 

171 if size < 0: 

172 size = len(self._data) - self._pos 

173 chunk = self._data[self._pos:self._pos + size] 

174 self._pos += len(chunk) 

175 return chunk 

176 

177 def readable(self) -> bool: 

178 return True 

179 

180 def seekable(self) -> bool: 

181 return False 

182 

183 def seek(self, *args): 

184 import io as _io 

185 raise _io.UnsupportedOperation("underlying stream is not seekable") 

186 

187 def tell(self) -> int: 

188 return self._pos 

189 

190 

191def test_seek_linear_fallback_non_seekable(tmp_path): 

192 from evutils.io._csv import EventDecoder_Csv 

193 

194 ev = _ramp() 

195 p = tmp_path / "ramp.csv" 

196 _write(p, "csv", ev) 

197 data = Path(p).read_bytes() 

198 

199 T = 6_000_000 # forward-only (backward on a non-seekable stream can't rewind) 

200 exp = int(np.searchsorted(ev["t"], T)) 

201 with EventReader(_NonSeekable(data), n_events=3000, 

202 file_decoder=EventDecoder_Csv) as r: 

203 landed = r.seek(t=T) 

204 c = r.read() 

205 assert landed.ts == int(ev["t"][exp]) 

206 assert int(c.t[0]) == int(ev["t"][exp]) 

207 

208 

209# --------------------------------------------------------------------------- # 

210# Metavision .tmp_index interop (gated on the local data/ fixtures) 

211# --------------------------------------------------------------------------- # 

212 

213_DATA = Path(__file__).resolve().parents[2] / "data" 

214 

215 

216@pytest.mark.skipif(not (_DATA / "short.raw.tmp_index").is_file(), 

217 reason="data/short.raw(.tmp_index) fixtures not present") 

218def test_metavision_index_matches_built_index(): 

219 """A sidecar-driven seek must land identically to a built-index seek.""" 

220 from evutils.io._evt import EventDecoder_EVT 

221 from evutils.io._index import metavision_index_path, read_metavision_index 

222 from evutils.io._source import make_source 

223 

224 raw = _DATA / "short.raw" 

225 d = EventDecoder_EVT(make_source(str(raw))) 

226 d.init() 

227 ws = np.dtype(d._word_dtype).itemsize 

228 idx = read_metavision_index(metavision_index_path(str(raw)), str(raw), 

229 d._payload_off, ws) 

230 d.close() 

231 assert idx is not None and len(idx) > 0 

232 assert bool(np.all(np.diff(idx.ts) >= 0)) # monotonic timeline 

233 

234 T = int(idx.ts[len(idx) // 2]) + 12_345 

235 

236 with EventReader(str(raw), n_events=1000, index="metavision") as r: # sidecar 

237 la = r.seek(t=T) 

238 ca = r.read() 

239 with EventReader(str(raw), n_events=1000, index=False) as r: # exact built 

240 lb = r.seek(t=T) 

241 cb = r.read() 

242 

243 assert la.ts == lb.ts 

244 assert int(ca.t[0]) == int(cb.t[0]) >= T 

245 assert int(ca.x[0]) == int(cb.x[0]) 

246 assert int(ca.y[0]) == int(cb.y[0]) 

247 

248 

249 

250def test_seek_with_normalize_ts(tmp_path): 

251 fmt, ext = "evt3", "raw" 

252 ev = _ramp() 

253 ev["t"] += 100_000 # start at 100k 

254 p = tmp_path / f"ramp_norm_{fmt}.{ext}" 

255 _write(p, fmt, ev) 

256 

257 T = 5_000_000 

258 with EventReader(p, n_events=3000, normalize_ts=True) as r: 

259 r.read() # Anchor first_ts so normalize_ts knows the start of the file 

260 landed = r.seek(t=T) 

261 c = r.read() 

262 

263 # EventReader.seek() expects/returns absolute timestamps 

264 assert landed.ts == T 

265 # EventReader.read() yields normalized timestamps (T - 100_000) 

266 assert int(c.t[0]) >= T - 100_000 

267 

268def test_repeated_seek_past_wrap(tmp_path): 

269 fmt, ext = "evt3", "raw" 

270 ev = _ramp() 

271 p = tmp_path / f"ramp_wrap_{fmt}.{ext}" 

272 _write(p, fmt, ev) 

273 

274 T1 = 17_000_000 # Past EVT3 wrap (16.7M) 

275 T2 = 18_000_000 

276 with EventReader(p, n_events=3000) as r: 

277 r.seek(t=T1) 

278 r.seek(t=T2) 

279 r.seek(t=T1) 

280 c = r.read() 

281 assert int(c.t[0]) >= T1 

282 

283def test_seek_past_eof(tmp_path): 

284 fmt, ext = "evt3", "raw" 

285 ev = _ramp() 

286 p = tmp_path / f"ramp_eof_{fmt}.{ext}" 

287 _write(p, fmt, ev) 

288 

289 with EventReader(p, n_events=3000) as r: 

290 r.seek(t=100_000_000) # Far past end 

291 c = r.read() 

292 assert len(c) == 0 

293 

294 

295# --------------------------------------------------------------------------- # 

296# SeekResult return unification + event_index / last_seek 

297# --------------------------------------------------------------------------- # 

298 

299def test_seek_returns_seekresult(recording): 

300 """seek() returns a SeekResult exposing .ts / .index / .eof (and stores it 

301 on .last_seek); .event_index tracks the landing index.""" 

302 from evutils.io.common import SeekResult 

303 

304 p, ev = recording 

305 n = 7000 

306 with EventReader(p, n_events=3000) as r: 

307 res = r.seek(n=n) 

308 assert isinstance(res, SeekResult) 

309 assert res.ts == int(ev["t"][n]) 

310 assert res.index == n 

311 assert res.eof is False 

312 assert res.ts == res[0] # ts is the first positional field 

313 assert r.last_seek == res # stored on the reader 

314 assert r.event_index == n # event-coordinate cursor 

315 

316 far = r.seek(t=10**15) # past the end 

317 assert far.eof is True 

318 assert r.last_seek == far 

319 

320 

321def test_seek_result_index_matches_read(recording): 

322 """The reported .index is the true 0-based index of the first event read. 

323 

324 CSV time-axis seeks use a byte-offset binary search that does not know the 

325 event index, so they report ``index == 0`` (only the landing is asserted). 

326 """ 

327 p, ev = recording 

328 T = 9_000_000 

329 exp = int(np.searchsorted(ev["t"], T)) 

330 with EventReader(p, n_events=1000) as r: 

331 res = r.seek(t=T) 

332 c = r.read() 

333 if p.suffix != ".csv": 

334 assert res.index == exp 

335 assert int(c.t[0]) == int(ev["t"][exp]) 

336 

337 

338# --------------------------------------------------------------------------- # 

339# evutils own `.evidx` persistable sidecar (index="persist") 

340# --------------------------------------------------------------------------- # 

341 

342@pytest.mark.parametrize("fmt", ["evt3", "evt2"]) 

343def test_evidx_persist_roundtrip(tmp_path, fmt): 

344 """index='persist' builds the exact index on the first seek and writes a 

345 `.evidx` sidecar; a re-open loads it (StaticSeekIndex) and lands identically.""" 

346 from evutils.io._index import evutils_index_path, StaticSeekIndex 

347 

348 n, dt = 200_000, 100 

349 ev = _ramp(n, dt) 

350 p = tmp_path / f"persist_{fmt}.raw" 

351 with EventWriter(str(p), format=fmt) as w: 

352 w.write(ev) 

353 side = evutils_index_path(str(p)) 

354 assert not side.exists() 

355 

356 T = 12_345_000 

357 exp = int(np.searchsorted(ev["t"], T)) 

358 

359 # First open: sidecar is created on the first seek, built exactly. 

360 with EventReader(str(p), n_events=3000, index="persist") as r: 

361 a = r.seek(t=T) 

362 ca = r.read() 

363 assert r._file_decoder._index_is_ours is True 

364 assert side.exists() 

365 assert a.ts == int(ev["t"][exp]) 

366 assert int(ca.t[0]) == int(ev["t"][exp]) 

367 

368 # Second open: the sidecar is loaded instead of rebuilt. 

369 with EventReader(str(p), n_events=3000, index="persist") as r: 

370 b = r.seek(t=T) 

371 cb = r.read() 

372 assert isinstance(r._file_decoder._index, StaticSeekIndex) 

373 assert b.ts == a.ts 

374 assert int(cb.t[0]) == int(ca.t[0]) 

375 

376 # Event-index seek works from the loaded (count-carrying) sidecar too. 

377 with EventReader(str(p), n_events=2000, index="persist") as r: 

378 r.seek(n=150_000) 

379 cc = r.read() 

380 assert int(cc.t[0]) == int(ev["t"][150_000]) 

381 

382 

383def test_evidx_persist_matches_built_index(tmp_path): 

384 """A persisted-index seek lands identically to the default built index.""" 

385 ev = _ramp(200_000, 100) 

386 p = tmp_path / "persist_match.raw" 

387 with EventWriter(str(p), format="evt3") as w: 

388 w.write(ev) 

389 

390 T = 7_654_000 

391 with EventReader(str(p), n_events=1000, index="persist") as r: 

392 a = r.seek(t=T) # builds + saves 

393 with EventReader(str(p), n_events=1000, index="persist") as r: 

394 b = r.seek(t=T) # loads sidecar 

395 with EventReader(str(p), n_events=1000, index=False) as r: 

396 c = r.seek(t=T) # exact built, no sidecar 

397 assert a.ts == b.ts == c.ts 

398 

399 

400def test_evidx_stale_sidecar_ignored(tmp_path): 

401 """A `.evidx` whose stored raw size no longer matches is ignored (rebuilt).""" 

402 import struct 

403 from evutils.io._index import evutils_index_path, load_seek_index 

404 

405 ev = _ramp(50_000, 200) 

406 p = tmp_path / "stale.raw" 

407 with EventWriter(str(p), format="evt3") as w: 

408 w.write(ev) 

409 side = evutils_index_path(str(p)) 

410 

411 with EventReader(str(p), n_events=1000, index="persist") as r: 

412 r.seek(t=3_000_000) 

413 assert side.exists() 

414 assert load_seek_index(side, str(p)) is not None # fresh -> loads 

415 

416 # Corrupt the stored raw size (header offset 12) -> stale. 

417 b = bytearray(side.read_bytes()) 

418 struct.pack_into("<q", b, 12, 424242) 

419 side.write_bytes(bytes(b)) 

420 assert load_seek_index(side, str(p)) is None # stale -> ignored 

421 

422 # A persist re-open silently rebuilds and still lands correctly. 

423 T = 3_000_000 

424 exp = int(np.searchsorted(ev["t"], T)) 

425 with EventReader(str(p), n_events=1000, index="persist") as r: 

426 res = r.seek(t=T) 

427 c = r.read() 

428 assert res.ts == int(ev["t"][exp]) 

429 assert int(c.t[0]) == int(ev["t"][exp]) 

430