Coverage for src/evutils/io/_index.py: 89%

257 statements  

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

1"""Seek index for timestamp / event-index random access. 

2 

3A :class:`SeekIndex` is a coarse, monotonic map from *absolute timestamp* and 

4*cumulative event count* to a *word offset* into a decoder's payload. It lets a 

5seekable decoder jump close to a target (the nearest bookmark at or before it) 

6and then decode forward the small remainder to the exact position. 

7 

8Two ways to obtain one: 

9 

10* **Build it** from a fresh sequential decode pass (:func:`build_seek_index`), 

11 recording one bookmark per parse step. The timestamps recorded this way are 

12 the decoder's own absolute timestamps, so they already include any TIME_HIGH 

13 wrap accumulation -- which is exactly what a mid-file jump + parser reset 

14 would otherwise lose. 

15* **Read an OpenEB / Metavision** ``<file>.raw.tmp_index`` sidecar 

16 (:func:`read_metavision_index`), reusing an index another tool already built. 

17 Metavision timestamps are shifted by ``ts_shift_us`` relative to the raw 

18 stream; we add it back so bookmarks live in the same (raw-absolute) timeline 

19 the evutils EVT decoder produces. 

20 

21The wrap accumulator that a fresh ``parser.reset()`` cannot recover is restored 

22at seek time from a bookmark's absolute timestamp: ``W = bookmark_ts - 

23first_decoded_ts`` (a multiple of the format's wrap period), added to every 

24timestamp decoded after the jump. 

25""" 

26from __future__ import annotations 

27 

28import struct 

29from dataclasses import dataclass 

30from pathlib import Path 

31 

32import numpy as np 

33 

34 

35from typing import Protocol, TYPE_CHECKING 

36if TYPE_CHECKING: 

37 import numpy as np 

38 

39class SeekIndex(Protocol): 

40 """Monotonic (timestamp, cumulative-count) -> word-offset bookmarks.""" 

41 

42 @property 

43 def n_events(self) -> int | None: 

44 """Total event count of the indexed stream (None if not fully indexed).""" 

45 ... 

46 

47 def bookmark_for_time(self, t: int) -> tuple[int, int, int]: 

48 """Return (word_offset, cum_count, ts) for the last bookmark whose timestamp is <= t.""" 

49 ... 

50 

51 def bookmark_for_event(self, n: int) -> tuple[int, int, int]: 

52 """Return (word_offset, cum_count, ts) for the last bookmark whose cumulative count is <= n.""" 

53 ... 

54 

55@dataclass 

56class StaticSeekIndex: 

57 """A fully built, read-only seek index.""" 

58 ts: np.ndarray 

59 word_offset: np.ndarray 

60 cum_count: np.ndarray 

61 n_events: int 

62 

63 def bookmark_for_time(self, t: int) -> tuple[int, int, int]: 

64 if len(self.ts) == 0: 

65 return 0, 0, 0 

66 i = int(np.searchsorted(self.ts, t, side="right")) - 1 

67 i = max(i, 0) 

68 return int(self.word_offset[i]), int(self.cum_count[i]), int(self.ts[i]) 

69 

70 def bookmark_for_event(self, n: int) -> tuple[int, int, int]: 

71 if len(self.cum_count) == 0: 

72 return 0, 0, 0 

73 i = int(np.searchsorted(self.cum_count, n, side="right")) - 1 

74 i = max(i, 0) 

75 return int(self.word_offset[i]), int(self.cum_count[i]), int(self.ts[i]) 

76 

77 def __len__(self) -> int: 

78 return len(self.ts) 

79 

80class IncrementalSeekIndex: 

81 """A seek index that builds itself by decoding words on demand. 

82 

83 Bookmarks are aligned to TIME_HIGH words: each segment boundary is the 

84 first TIME_HIGH at/after the previous boundary plus ``stride_words``. This 

85 is what makes the seek-time wrap correction sound -- a parser reset at a 

86 bookmark re-establishes its absolute time base *before* decoding any 

87 event, so ``bookmark_ts - first_decoded_ts`` is an exact multiple of the 

88 format's wrap period. A bookmark at an arbitrary parse-step boundary does 

89 NOT have that property (events decoded before the next TIME_HIGH carry 

90 only their low timestamp bits, and the snap lands a whole wrap period 

91 off). 

92 

93 ``time_high`` is the ``(type-shift, type-code)`` descriptor of the 

94 format's TIME_HIGH word. Without it (no descriptor known) the index 

95 degrades to a single bookmark at ``start_offset`` -- correct, just no 

96 mid-file acceleration. 

97 """ 

98 def __init__(self, words: np.ndarray, start_offset: int, input_cls: type, parser_cls: type, 

99 tail_pad: int, word_dtype: np.dtype, chunk_cap: int = 65_536, 

100 time_high: "tuple[int, int] | None" = None, stride_words: int = 1 << 16): 

101 self._words = words 

102 self._start_offset = start_offset 

103 self._input_cls = input_cls 

104 self._parser = parser_cls() 

105 self._tail_pad = tail_pad 

106 self._word_dtype = word_dtype 

107 self._time_high = time_high 

108 self._stride = max(int(stride_words), 64) 

109 

110 cap = max(int(chunk_cap), 128) 

111 from ._native_core import EventSoABuffers, TriggerSoABuffers 

112 self._ev = EventSoABuffers(cap) 

113 self._tr = TriggerSoABuffers(max(cap // 16, 1)) 

114 

115 self._ts_list: list[int] = [] 

116 self._off_list: list[int] = [] 

117 self._cum_list: list[int] = [] 

118 

119 self._cum = 0 

120 self._off = int(start_offset) 

121 self._is_eof = False 

122 

123 self._ts_arr = np.empty(0, dtype=np.int64) 

124 self._off_arr = np.empty(0, dtype=np.int64) 

125 self._cum_arr = np.empty(0, dtype=np.int64) 

126 self._arrs_stale = False 

127 

128 @property 

129 def n_events(self) -> int | None: 

130 return self._cum if self._is_eof else None 

131 

132 def _update_arrs(self): 

133 if self._arrs_stale: 

134 self._ts_arr = np.asarray(self._ts_list, dtype=np.int64) 

135 self._off_arr = np.asarray(self._off_list, dtype=np.int64) 

136 self._cum_arr = np.asarray(self._cum_list, dtype=np.int64) 

137 self._arrs_stale = False 

138 

139 def _next_time_high(self, start: int) -> int: 

140 """Word offset of the first TIME_HIGH at/after ``start`` (or n_words). 

141 

142 Vectorized block scan, same shape as the decoder's 

143 ``_find_first_time_high``. 

144 """ 

145 n = len(self._words) 

146 if self._time_high is None: 

147 return n 

148 shift, code = self._time_high 

149 start = min(max(start, 0), n) 

150 block = 1 << 16 

151 while start < n: 

152 stop = min(start + block, n) 

153 seg = self._words[start:stop] 

154 hits = ((seg >> shift) & 0xF) == code 

155 i = int(np.argmax(hits)) 

156 if hits[i]: 

157 return start + i 

158 start = stop 

159 block = min(block * 4, 1 << 24) 

160 return n 

161 

162 def _build_until(self, target_t: int | None = None, target_n: int | None = None) -> None: 

163 if self._is_eof: 

164 return 

165 if target_t is not None and len(self._ts_list) > 0 and self._ts_list[-1] >= target_t: 

166 return 

167 if target_n is not None and self._cum >= target_n: 

168 return 

169 

170 from ._native_core import events_view, parse_step 

171 words = self._words 

172 n_words = len(words) 

173 while self._off < n_words: 

174 seg_start = self._off 

175 seg_end = self._next_time_high(seg_start + self._stride) 

176 first_ts: int | None = None 

177 cum0 = self._cum 

178 

179 # Decode the whole segment [seg_start, seg_end); parser state is 

180 # continuous across segments, only the *bookmark offsets* are 

181 # TIME_HIGH-aligned. 

182 while self._off < n_words and self._off < seg_end: 

183 final = seg_end >= n_words 

184 self._ev.reset() 

185 self._tr.reset() 

186 appended, new_off = parse_step( 

187 words if final else words[:seg_end], self._off, 

188 self._input_cls, self._parser, self._ev, self._tr, 

189 tail_pad=self._tail_pad if final else 0, 

190 word_dtype=self._word_dtype, 

191 ) 

192 if appended == 0 and new_off <= self._off: 

193 self._is_eof = True # zero progress on full input 

194 break 

195 if (not final and self._tail_pad and appended == 0 

196 and new_off >= seg_end): 

197 # Look-ahead formats (EVT3) stall a few words short of the 

198 # sliced boundary and parse_step would skip that residue. 

199 # Flush it through a zero-padded scratch copy: counts and 

200 # timestamps stay exact (only decoder x/y state, which the 

201 # index never records, can be perturbed by the pad words). 

202 tail = words[self._off:seg_end] 

203 if len(tail): 

204 scratch = np.zeros(len(tail) + self._tail_pad, 

205 dtype=self._word_dtype) 

206 scratch[:len(tail)] = tail 

207 self._ev.reset() 

208 self._tr.reset() 

209 parse_step(scratch, 0, self._input_cls, self._parser, 

210 self._ev, self._tr, tail_pad=0, 

211 word_dtype=self._word_dtype) 

212 appended = self._ev.size 

213 if appended and first_ts is None: 

214 first_ts = int(events_view(self._ev).t[0]) 

215 self._cum += appended 

216 self._off = seg_end 

217 break 

218 if appended: 

219 if first_ts is None: 

220 first_ts = int(events_view(self._ev).t[0]) 

221 self._cum += appended 

222 self._off = new_off 

223 

224 if first_ts is not None: 

225 self._ts_list.append(first_ts) 

226 self._off_list.append(seg_start) 

227 self._cum_list.append(cum0) 

228 self._arrs_stale = True 

229 

230 if self._is_eof or self._off >= n_words: 

231 self._is_eof = True 

232 break 

233 if target_t is not None and first_ts is not None and first_ts >= target_t: 

234 break 

235 if target_n is not None and self._cum >= target_n: 

236 break 

237 

238 def bookmark_for_time(self, t: int) -> tuple[int, int, int]: 

239 self._build_until(target_t=t) 

240 self._update_arrs() 

241 if len(self._ts_arr) == 0: 

242 return self._start_offset, 0, 0 

243 i = int(np.searchsorted(self._ts_arr, t, side="right")) - 1 

244 i = max(i, 0) 

245 return int(self._off_arr[i]), int(self._cum_arr[i]), int(self._ts_arr[i]) 

246 

247 def bookmark_for_event(self, n: int) -> tuple[int, int, int]: 

248 self._build_until(target_n=n) 

249 self._update_arrs() 

250 if len(self._cum_arr) == 0: 

251 return self._start_offset, 0, 0 

252 i = int(np.searchsorted(self._cum_arr, n, side="right")) - 1 

253 i = max(i, 0) 

254 return int(self._off_arr[i]), int(self._cum_arr[i]), int(self._ts_arr[i]) 

255 

256 def build_all(self) -> None: 

257 """Force the index to build every bookmark, all the way to EOF.""" 

258 # A target no real count can reach drives _build_until to the end of the 

259 # stream (it stops on EOF, not on the target). 

260 self._build_until(target_n=1 << 62) 

261 self._update_arrs() 

262 

263 def to_static(self) -> "StaticSeekIndex": 

264 """Fully build, then snapshot into an immutable :class:`StaticSeekIndex` 

265 (with exact evutils cumulative counts) -- the form persisted to a 

266 ``.evidx`` sidecar.""" 

267 self.build_all() 

268 return StaticSeekIndex( 

269 ts=self._ts_arr.copy(), 

270 word_offset=self._off_arr.copy(), 

271 cum_count=self._cum_arr.copy(), 

272 n_events=int(self._cum), 

273 ) 

274 

275 

276# --------------------------------------------------------------------------- # 

277# Metavision / OpenEB `<file>.raw.tmp_index` sidecar 

278# --------------------------------------------------------------------------- # 

279 

280#: On-disk bookmark record: (int64 timestamp, uint64 byte_offset, uint32 count). 

281#: Serialization order per OpenEB's serialize_bookmark (NOT struct order). 

282_MV_RECORD = np.dtype([("ts", "<i8"), ("byte_offset", "<u8"), ("count", "<u4")]) 

283_MV_RECORD_SIZE = 20 # packed; np.dtype above is already 20 (no padding) 

284 

285 

286def metavision_index_path(raw_path: "str | Path") -> Path: 

287 """Sidecar path for a raw file: ``<file>.raw.tmp_index``.""" 

288 p = Path(raw_path) 

289 return p.with_name(p.name + ".tmp_index") 

290 

291 

292def _parse_mv_header(buf: bytes) -> tuple[dict[str, str], int]: 

293 """Parse the ``% key value`` text header; return (fields, payload offset).""" 

294 fields: dict[str, str] = {} 

295 off = 0 

296 n = len(buf) 

297 while off < n and buf[off:off + 1] == b"%": 

298 nl = buf.find(b"\n", off) 

299 if nl < 0: 

300 break 

301 line = buf[off:nl].decode("ascii", "ignore").strip() 

302 off = nl + 1 

303 if line == "% end": 

304 break 

305 parts = line.split(None, 2) # "%", key, value 

306 if len(parts) >= 3: 

307 fields[parts[1].lower()] = parts[2] 

308 return fields, off 

309 

310 

311def read_metavision_index(index_path: "str | Path", raw_path: "str | Path", 

312 payload_off: int, word_size: int) -> "SeekIndex | None": 

313 """Read a Metavision ``.tmp_index`` sidecar into a :class:`SeekIndex`. 

314 

315 Returns ``None`` if the sidecar is missing or stale (its stored ``size`` 

316 does not match the raw file's current byte size, matching Metavision's 

317 freshness check). Byte offsets are converted to payload word offsets and 

318 timestamps are shifted by ``ts_shift_us`` into the raw-stream timeline. 

319 """ 

320 index_path = Path(index_path) 

321 raw_path = Path(raw_path) 

322 if not index_path.is_file() or not raw_path.is_file(): 

323 return None 

324 

325 data = index_path.read_bytes() 

326 fields, body = _parse_mv_header(data) 

327 

328 # Freshness: stored size must equal the current raw byte size. 

329 try: 

330 if int(fields.get("size", "-1")) != raw_path.stat().st_size: 

331 return None 

332 except ValueError: 

333 return None 

334 

335 ts_shift = 0 

336 try: 

337 ts_shift = int(fields.get("ts_shift_us", "0")) 

338 except ValueError: 

339 ts_shift = 0 

340 

341 payload = data[body:] 

342 n_rec = len(payload) // _MV_RECORD_SIZE 

343 if n_rec <= 1: 

344 return None 

345 recs = np.frombuffer(payload, dtype=_MV_RECORD, count=n_rec) 

346 

347 # The final record is the magic-number completeness marker (random bytes), 

348 # not a bookmark -- drop it. Then drop leading pre-time-base bookmarks 

349 # (ts < 0) and shift the rest into the raw-absolute timeline. 

350 recs = recs[:-1] 

351 recs = recs[recs["ts"] >= 0] 

352 if len(recs) == 0: 

353 return None 

354 

355 ts = recs["ts"].astype(np.int64) + ts_shift 

356 byte_off = recs["byte_offset"].astype(np.int64) 

357 word_offset = (byte_off - int(payload_off)) // int(word_size) 

358 cum_count = np.cumsum(recs["count"].astype(np.int64)) - recs["count"].astype(np.int64) 

359 

360 return StaticSeekIndex( 

361 ts=ts, 

362 word_offset=word_offset, 

363 cum_count=cum_count, 

364 n_events=int(cum_count[-1] + recs["count"][-1]), 

365 ) 

366 

367 

368# --------------------------------------------------------------------------- # 

369# evutils own exact index `<file>.evidx` sidecar (persist across runs) 

370# --------------------------------------------------------------------------- # 

371 

372#: Magic + version + freshness header for the `.evidx` sidecar. The header 

373#: stores the raw file's byte size AND mtime; a mismatch on either means the 

374#: sidecar is stale and is ignored (rebuilt), mirroring the Metavision reader's 

375#: size check but a touch stricter. 

376_EVIDX_MAGIC = b"EVUTLIDX" 

377_EVIDX_VERSION = 1 

378#: magic(8) + version(u32) + raw_size(i64) + raw_mtime_ns(i64) + n_events(i64) 

379#: + n_bookmarks(i64), little-endian. 

380_EVIDX_HEADER = struct.Struct("<8sIqqqq") 

381 

382 

383def evutils_index_path(raw_path: "str | Path") -> Path: 

384 """Sidecar path for evutils' own exact index: ``<file>.evidx``. 

385 

386 Distinct from the Metavision ``.tmp_index`` sidecar so the two never 

387 collide -- this one holds evutils' TIME_HIGH-aligned bookmarks with exact 

388 (evutils-counted) cumulative counts. 

389 """ 

390 p = Path(raw_path) 

391 return p.with_name(p.name + ".evidx") 

392 

393 

394def save_seek_index(index: "SeekIndex", index_path: "str | Path", 

395 raw_path: "str | Path") -> None: 

396 """Serialize a fully-built seek index to a ``.evidx`` sidecar. 

397 

398 ``index`` may be an :class:`IncrementalSeekIndex` (fully built and snapshot 

399 first) or an already-static :class:`StaticSeekIndex`. The raw file's current 

400 size and mtime are stored in the header for the load-time freshness check. 

401 """ 

402 if isinstance(index, IncrementalSeekIndex): 

403 index = index.to_static() 

404 if not isinstance(index, StaticSeekIndex): 

405 raise TypeError("save_seek_index requires a Static/IncrementalSeekIndex") 

406 

407 raw = Path(raw_path) 

408 st = raw.stat() 

409 ts = np.ascontiguousarray(index.ts, dtype="<i8") 

410 off = np.ascontiguousarray(index.word_offset, dtype="<i8") 

411 cum = np.ascontiguousarray(index.cum_count, dtype="<i8") 

412 n_bm = len(ts) 

413 

414 header = _EVIDX_HEADER.pack( 

415 _EVIDX_MAGIC, _EVIDX_VERSION, int(st.st_size), int(st.st_mtime_ns), 

416 int(index.n_events), int(n_bm), 

417 ) 

418 # Write to a temp file then atomically replace, so a crash mid-write never 

419 # leaves a truncated sidecar that would later read back as corrupt. 

420 tmp = Path(index_path).with_name(Path(index_path).name + ".tmp") 

421 with open(tmp, "wb") as f: 

422 f.write(header) 

423 f.write(ts.tobytes()) 

424 f.write(off.tobytes()) 

425 f.write(cum.tobytes()) 

426 tmp.replace(index_path) 

427 

428 

429def load_seek_index(index_path: "str | Path", 

430 raw_path: "str | Path") -> "StaticSeekIndex | None": 

431 """Load a ``.evidx`` sidecar into a :class:`StaticSeekIndex`. 

432 

433 Returns ``None`` if the sidecar is missing, unreadable, has the wrong magic 

434 / version, is truncated, or is stale (its stored raw-file size or mtime no 

435 longer matches the raw file) -- in every case the caller rebuilds. 

436 """ 

437 index_path = Path(index_path) 

438 raw_path = Path(raw_path) 

439 if not index_path.is_file() or not raw_path.is_file(): 

440 return None 

441 

442 data = index_path.read_bytes() 

443 if len(data) < _EVIDX_HEADER.size: 

444 return None 

445 magic, version, raw_size, raw_mtime, n_events, n_bm = _EVIDX_HEADER.unpack_from(data, 0) 

446 if magic != _EVIDX_MAGIC or version != _EVIDX_VERSION: 

447 return None 

448 

449 # Freshness: the raw file must be byte-for-byte the one this index was built 

450 # from (size AND mtime). Any change => stale => rebuild. 

451 st = raw_path.stat() 

452 if raw_size != st.st_size or raw_mtime != st.st_mtime_ns: 

453 return None 

454 

455 if n_bm < 0 or n_events < 0: 

456 return None 

457 body = np.frombuffer(data, dtype="<i8", offset=_EVIDX_HEADER.size) 

458 if len(body) != 3 * n_bm: 

459 return None 

460 ts = np.ascontiguousarray(body[:n_bm], dtype=np.int64) 

461 word_offset = np.ascontiguousarray(body[n_bm:2 * n_bm], dtype=np.int64) 

462 cum_count = np.ascontiguousarray(body[2 * n_bm:], dtype=np.int64) 

463 return StaticSeekIndex( 

464 ts=ts, word_offset=word_offset, cum_count=cum_count, n_events=int(n_events), 

465 )