Coverage for src/evutils/io/_aedat.py: 87%

297 statements  

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

1"""AEDAT (jAER / cAER / DV) file decoder, versions 1.0 -- 4.0. 

2 

3All four on-disk layouts used by iniVation event cameras are supported for 

4reading (writing is not implemented yet): 

5 

6* **AEDAT 1.0** (jAER, 2008): optional ``#``-prefixed ASCII header, then 

7 6-byte big-endian records: ``uint16`` address + ``uint32`` timestamp (µs). 

8 DVS128 address layout: ``p = bit 0``, ``x = bits 1-7``, ``y = bits 8-14``. 

9* **AEDAT 2.0** (jAER, 2010): ``#!AER-DAT2.0`` header, then 8-byte big-endian 

10 records: ``uint32`` address + ``uint32`` timestamp (µs). The address layout 

11 depends on the camera -- see the ``layout`` parameter (default ``"davis"``: 

12 ``p = bit 11``, ``x = bits 12-21``, ``y = bits 22-30``; APS/IMU words with 

13 bit 31 set are skipped). 

14* **AEDAT 3.1** (cAER): header terminated by ``#!END-HEADER``, then 

15 little-endian packets with a 28-byte header; polarity-event packets carry 

16 8-byte events (``uint32`` data + ``uint32`` timestamp): validity ``bit 0``, 

17 ``p = bit 1``, ``y = bits 2-16``, ``x = bits 17-31``. The 31-bit packet 

18 timestamp is extended by the header's TS-overflow counter to 64 bits. 

19* **AEDAT 4.0** (DV framework): ``#!AER-DAT4.0`` version line, a FlatBuffer 

20 ``IOHeader`` (compression type, stream table), then packets of size-prefixed 

21 ``EventPacket`` FlatBuffers (identifier ``EVTS``), optionally LZ4- or 

22 Zstd-compressed, holding 16-byte event structs (``int64`` t, ``int16`` x, 

23 ``int16`` y, ``uint8`` p). Compressed files need the optional ``lz4`` / 

24 ``zstandard`` package (``pip install evutils[aedat]``). 

25 

26The byte order and record layouts for 1.0/2.0/3.1 follow the official 

27iniVation file-format documentation (jAER writes big-endian); the 4.0 layout 

28follows dv-processing (cross-checked against the evlib reference reader). 

29 

30Decoding streams packet-by-packet / chunk-by-chunk -- the whole recording is 

31never materialised. 

32 

33References 

34---------- 

35[1] https://docs.inivation.com/software/software-advanced-usage/file-formats/ 

36[2] https://github.com/tallamjr/evlib (aedat_reader.rs, aedat4_reader.rs) 

37""" 

38from __future__ import annotations 

39 

40import re 

41import struct 

42from typing import Any, Callable 

43 

44import numpy as np 

45 

46from ..types import EventArray, TriggerArray 

47from .common import EventDecoder, EventEncoder 

48from ._source import ByteSource 

49 

50_EMPTY_EVENTS = EventArray.empty() 

51 

52# ---------------------------------------------------------------------------# 

53# Record dtypes 

54# ---------------------------------------------------------------------------# 

55#: AEDAT 1.0: big-endian uint16 address + uint32 timestamp (6 bytes). 

56_V1_DTYPE = np.dtype([("a", ">u2"), ("t", ">u4")]) 

57#: AEDAT 2.0: big-endian uint32 address + uint32 timestamp (8 bytes). 

58_V2_DTYPE = np.dtype([("a", ">u4"), ("t", ">u4")]) 

59#: AEDAT 3.1 polarity event: little-endian uint32 data + uint32 timestamp. 

60_V3_EVENT_DTYPE = np.dtype([("d", "<u4"), ("t", "<u4")]) 

61#: AEDAT 3.1 packet header (28 bytes, little-endian). 

62_V3_HEADER = struct.Struct("<hhiiiiii") 

63#: AEDAT 4.0 event struct: int64 t, int16 x, int16 y, uint8 p, 3 pad bytes. 

64_V4_EVENT_DTYPE = np.dtype({ 

65 "names": ["t", "x", "y", "p"], 

66 "formats": ["<i8", "<i2", "<i2", "u1"], 

67 "offsets": [0, 8, 10, 12], 

68 "itemsize": 16, 

69}) 

70 

71_V3_POLARITY_EVENT = 1 # cAER event type id for polarity events 

72 

73# AEDAT 2.0 address layouts: name -> (extractor, aps_mask_bit31). 

74_V2_LAYOUTS = { 

75 # DAVIS (jAER): bit 31 = readout type (1 = APS/IMU, skip), p = bit 11, 

76 # x = bits 12-21 (10 bits), y = bits 22-30 (9 bits). 

77 "davis": lambda a: (((a >> 12) & 0x3FF), ((a >> 22) & 0x1FF), ((a >> 11) & 0x1)), 

78 # DVS128: same address layout as AEDAT 1.0, in the low 16 bits. 

79 "dvs128": lambda a: (((a >> 1) & 0x7F), ((a >> 8) & 0x7F), (a & 0x1)), 

80} 

81 

82 

83# ---------------------------------------------------------------------------# 

84# Minimal FlatBuffer field access (AEDAT 4.0). The two schemas involved are 

85# tiny and fixed, so the offsets are walked by hand instead of depending on a 

86# flatbuffers runtime (cf. the evlib reference reader). 

87# ---------------------------------------------------------------------------# 

88def _u16(b: bytes, o: int) -> int: 

89 return int(struct.unpack_from("<H", b, o)[0]) 

90 

91 

92def _i32(b: bytes, o: int) -> int: 

93 return int(struct.unpack_from("<i", b, o)[0]) 

94 

95 

96def _u32(b: bytes, o: int) -> int: 

97 return int(struct.unpack_from("<I", b, o)[0]) 

98 

99 

100def _i64(b: bytes, o: int) -> int: 

101 return int(struct.unpack_from("<q", b, o)[0]) 

102 

103 

104def _fb_field_pos(buf: bytes, table: int, index: int) -> int | None: 

105 """Absolute position of field ``index`` of the table at ``table``, or None 

106 if the field is absent from the vtable.""" 

107 vtable = table - _i32(buf, table) 

108 vtable_size = _u16(buf, vtable) 

109 slot = vtable + 4 + 2 * index 

110 if slot + 2 > vtable + vtable_size: 

111 return None 

112 voffset = _u16(buf, slot) 

113 return table + voffset if voffset else None 

114 

115 

116def _parse_io_header(buf: bytes) -> tuple[int, int, str]: 

117 """Parse the AEDAT4 ``IOHeader`` FlatBuffer. 

118 

119 Returns ``(compression, data_table_position, info_node_xml)``. 

120 Fields: 0 = compression (i32), 1 = dataTablePosition (i64), 2 = infoNode. 

121 """ 

122 table = _u32(buf, 0) 

123 compression = 0 

124 data_table_position = -1 

125 info_node = "" 

126 

127 pos = _fb_field_pos(buf, table, 0) 

128 if pos is not None: 

129 compression = _i32(buf, pos) 

130 pos = _fb_field_pos(buf, table, 1) 

131 if pos is not None: 

132 data_table_position = _i64(buf, pos) 

133 pos = _fb_field_pos(buf, table, 2) 

134 if pos is not None: 

135 str_pos = pos + _u32(buf, pos) 

136 str_len = _u32(buf, str_pos) 

137 info_node = bytes(buf[str_pos + 4:str_pos + 4 + str_len]).decode("utf-8", "replace") 

138 return compression, data_table_position, info_node 

139 

140 

141def _parse_event_packet(body: bytes) -> np.ndarray | None: 

142 """Extract the event structs from a size-prefixed ``EventPacket`` FlatBuffer. 

143 

144 Returns a ``_V4_EVENT_DTYPE`` view, or ``None`` when the body is not an 

145 ``EVTS`` packet (frame/IMU/trigger streams). 

146 """ 

147 if len(body) < 12 or body[8:12] != b"EVTS": 

148 return None 

149 root = 4 + _u32(body, 4) 

150 pos = _fb_field_pos(body, root, 0) # single field: `elements` vector 

151 if pos is None: 

152 return _np_empty_v4() 

153 vector = pos + _u32(body, pos) 

154 count = _u32(body, vector) 

155 start = vector + 4 

156 if start + count * _V4_EVENT_DTYPE.itemsize > len(body): 

157 raise ValueError("truncated AEDAT4 EventPacket") 

158 return np.frombuffer(body, dtype=_V4_EVENT_DTYPE, count=count, offset=start) 

159 

160 

161def _np_empty_v4() -> np.ndarray: 

162 return np.empty(0, dtype=_V4_EVENT_DTYPE) 

163 

164 

165def _parse_streams_xml(xml: str) -> tuple[set[int], int | None, int | None]: 

166 """Extract event-stream ids and the sensor geometry from the ``IOHeader`` 

167 infoNode XML (a DV config tree). 

168 

169 Returns ``(event_stream_ids, width, height)``. Parsing is best-effort: 

170 on any failure the id set is empty and the caller falls back to 

171 identifying event packets by their FlatBuffer identifier. 

172 """ 

173 ids: set[int] = set() 

174 width: int | None = None 

175 height: int | None = None 

176 try: 

177 import xml.etree.ElementTree as ET 

178 root = ET.fromstring(xml) 

179 for node in root.iter("node"): 

180 name = node.get("name", "") 

181 if not name.lstrip("-").isdigit(): 

182 continue 

183 type_id = None 

184 size_x = size_y = None 

185 for attr in node.iter("attr"): 

186 key = attr.get("key") 

187 if key == "typeIdentifier": 

188 type_id = (attr.text or "").strip() 

189 elif key == "sizeX": 

190 size_x = int((attr.text or "0").strip()) 

191 elif key == "sizeY": 

192 size_y = int((attr.text or "0").strip()) 

193 if type_id == "EVTS": 

194 ids.add(int(name)) 

195 if width is None and size_x: 

196 width, height = size_x, size_y 

197 except Exception: 

198 return set(), None, None 

199 return ids, width, height 

200 

201 

202def _decompress_lz4(body: bytes) -> bytes: 

203 try: 

204 import lz4.frame 

205 except ImportError as exc: 

206 raise ImportError( 

207 "This AEDAT4 file uses LZ4-compressed packets: install " 

208 "`evutils[aedat]` (or `pip install lz4`)." 

209 ) from exc 

210 return bytes(lz4.frame.decompress(body)) 

211 

212 

213def _decompress_zstd(body: bytes) -> bytes: 

214 try: 

215 from compression import zstd # Python >= 3.14 

216 return bytes(zstd.decompress(body)) 

217 except ImportError: 

218 pass 

219 try: 

220 import zstandard 

221 except ImportError as exc: 

222 raise ImportError( 

223 "This AEDAT4 file uses Zstd-compressed packets: install " 

224 "`evutils[aedat]` (or `pip install zstandard`)." 

225 ) from exc 

226 return bytes(zstandard.ZstdDecompressor().decompress(body)) 

227 

228 

229#: DV CompressionType enum -> decompressor. LZ4_HIGH/ZSTD_HIGH share decoders. 

230_DECOMPRESSORS: dict[int, Callable[[bytes], bytes]] = { 

231 0: lambda b: b, # NONE 

232 1: _decompress_lz4, # LZ4 

233 2: _decompress_lz4, # LZ4_HIGH 

234 3: _decompress_zstd, # ZSTD 

235 4: _decompress_zstd, # ZSTD_HIGH 

236} 

237 

238 

239class EventDecoder_Aedat(EventDecoder): 

240 """Decode AEDAT 1.0 / 2.0 / 3.1 / 4.0 files into ``EventArray`` chunks. 

241 

242 The version is detected from the ``#!AER-DATx.y`` header line (a file 

243 with a bare ``#`` header, or none at all, is treated as AEDAT 1.0, per 

244 the jAER convention). 

245 

246 Parameters 

247 ---------- 

248 source 

249 Byte source to read from. 

250 chunk_size 

251 Maximum number of events produced per :meth:`read_chunk` call 

252 (AEDAT 3.1/4.0 packets are never split, so a chunk can exceed this 

253 by at most one packet's worth). 

254 layout : {"davis", "dvs128"}, default "davis" 

255 AEDAT 2.0 address layout (the 2.0 container does not name the 

256 camera). Ignored for the other versions. 

257 

258 """ 

259 

260 def __init__(self, source: ByteSource, chunk_size: int = 1_000_000, 

261 layout: str = "davis"): 

262 super().__init__(source, chunk_size) 

263 if layout not in _V2_LAYOUTS: 

264 raise ValueError(f"layout must be one of {sorted(_V2_LAYOUTS)}, got {layout!r}") 

265 self._layout = layout 

266 

267 self._buf: Any = None 

268 self._version: int = 0 # 1, 2, 3 or 4 (x10: 31 -> 3, 40 -> 4) 

269 self._payload_off: int = 0 # first byte after the ASCII header 

270 self._cursor: int = 0 # current byte offset into _buf 

271 

272 # v1/v2 32-bit timestamp unwrap state. 

273 self._ts_wraps: int = 0 

274 self._last_raw_ts: int = -1 

275 

276 # v4 packet-region metadata. 

277 self._v4_compression: int = 0 

278 self._v4_region_end: int = 0 

279 self._v4_stream_ids: set[int] = set() 

280 

281 # ------------------------------------------------------------------ # 

282 # Header 

283 # ------------------------------------------------------------------ # 

284 def _parse_header(self) -> None: 

285 """Detect the version and consume the ASCII header.""" 

286 buf = self._buf 

287 head = bytes(buf[:16]) 

288 

289 if head.startswith(b"#!AER-DAT4.0"): 

290 self._version = 4 

291 self._parse_v4_header() 

292 return 

293 if head.startswith(b"#!AER-DAT3"): 

294 self._version = 3 

295 elif head.startswith(b"#!AER-DAT2.0"): 

296 self._version = 2 

297 else: 

298 # "#!AER-DAT1.0", a bare "#" header, or no header at all (raw 

299 # DVS128 dumps): all AEDAT 1.0 per the jAER convention. 

300 self._version = 1 

301 

302 # Consume '#'-prefixed header lines; harvest sizeX/sizeY when present. 

303 n = len(buf) 

304 off = 0 

305 header_lines = [] 

306 while off < n and buf[off] == 0x23: # '#' 

307 window = bytes(buf[off:off + 8192]) 

308 rel = window.find(b"\n") 

309 if rel < 0: 

310 off = n 

311 break 

312 line = window[:rel] 

313 header_lines.append(line) 

314 off += rel + 1 

315 if self._version == 3 and line.strip() == b"#!END-HEADER": 

316 break 

317 self._payload_off = off 

318 

319 header_text = b"\n".join(header_lines).decode("ascii", "replace") 

320 for key, attr in (("sizeX", "_width"), ("sizeY", "_height")): 

321 m = re.search(rf"{key}\s*[=:]?\s*(\d+)", header_text) 

322 if m: 

323 setattr(self, attr, int(m.group(1))) 

324 

325 def _parse_v4_header(self) -> None: 

326 """Parse the AEDAT4 version line + IOHeader FlatBuffer.""" 

327 buf = self._buf 

328 # 14-byte version line, then a u32-size-prefixed IOHeader FlatBuffer. 

329 header_size = _u32(bytes(buf[14:18]), 0) 

330 io_start, io_end = 18, 18 + header_size 

331 if io_end > len(buf): 

332 raise ValueError("truncated AEDAT4 IOHeader") 

333 compression, data_table_pos, info_node = _parse_io_header(bytes(buf[io_start:io_end])) 

334 

335 if compression not in _DECOMPRESSORS: 

336 raise ValueError(f"unknown AEDAT4 compression type {compression}") 

337 self._v4_compression = compression 

338 self._payload_off = io_end 

339 # Packets occupy the bytes between the IOHeader and the trailing 

340 # FileDataTable (when its position is known). 

341 if 0 <= data_table_pos <= len(buf): 

342 self._v4_region_end = int(data_table_pos) 

343 else: 

344 self._v4_region_end = len(buf) 

345 

346 ids, width, height = _parse_streams_xml(info_node) 

347 self._v4_stream_ids = ids 

348 if width: 

349 self._width = width 

350 if height: 

351 self._height = height 

352 

353 # ------------------------------------------------------------------ # 

354 # Lifecycle 

355 # ------------------------------------------------------------------ # 

356 def init(self) -> None: 

357 """Read the header and position the cursor at the first record/packet.""" 

358 if self._is_initialized: 

359 return 

360 

361 if self._source.mappable(): 

362 self._buf = self._source.buffer() 

363 else: 

364 self._buf = memoryview(self._source.read(-1)) 

365 

366 self._parse_header() 

367 self._cursor = self._payload_off 

368 self._is_initialized = True 

369 

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

371 # Per-version batch decoding 

372 # ------------------------------------------------------------------ # 

373 def _unwrap_ts(self, ts_raw: np.ndarray) -> np.ndarray: 

374 """Extend raw 32-bit µs timestamps to int64, accumulating wraps 

375 (a drop of more than 2^31 between consecutive timestamps counts as a 

376 wrap; smaller decreases are genuine jitter and pass through). 

377 """ 

378 t = ts_raw.astype(np.int64) 

379 if len(t) == 0: 

380 return t 

381 prev = np.empty_like(t) 

382 prev[0] = self._last_raw_ts if self._last_raw_ts >= 0 else int(t[0]) 

383 prev[1:] = t[:-1] 

384 wraps = self._ts_wraps + np.cumsum((prev - t) > (1 << 31)) 

385 self._ts_wraps = int(wraps[-1]) 

386 self._last_raw_ts = int(t[-1]) 

387 return t + (wraps << np.int64(32)) 

388 

389 def _batch_v1_v2(self) -> EventArray | None: 

390 """Decode the next ``chunk_size`` fixed-size records (AEDAT 1.0/2.0).""" 

391 dtype = _V1_DTYPE if self._version == 1 else _V2_DTYPE 

392 remaining = (len(self._buf) - self._cursor) // dtype.itemsize 

393 if remaining <= 0: 

394 return None 

395 count = min(self._chunk_size, remaining) 

396 rec = np.frombuffer(self._buf, dtype=dtype, count=count, offset=self._cursor) 

397 self._cursor += count * dtype.itemsize 

398 

399 a = rec["a"] 

400 t = self._unwrap_ts(rec["t"]) 

401 if self._version == 1: 

402 x = (a >> 1) & np.uint16(0x7F) 

403 y = (a >> 8) & np.uint16(0x7F) 

404 p = (a & np.uint16(0x1)).astype(np.uint8) 

405 else: 

406 # Skip non-DVS words (APS samples / IMU, flagged by bit 31). 

407 dvs = (a >> np.uint32(31)) == 0 

408 if not dvs.all(): 

409 a, t = a[dvs], t[dvs] 

410 x, y, p = _V2_LAYOUTS[self._layout](a) 

411 p = p.astype(np.uint8) 

412 return EventArray(t, x.astype(np.uint16), y.astype(np.uint16), p) 

413 

414 def _batch_v3(self) -> EventArray | None: 

415 """Decode the next AEDAT 3.1 packet holding polarity events.""" 

416 buf = self._buf 

417 n = len(buf) 

418 while self._cursor + _V3_HEADER.size <= n: 

419 (ev_type, _source, ev_size, ts_offset, ts_overflow, 

420 capacity, number, _valid) = _V3_HEADER.unpack_from(buf, self._cursor) 

421 body_start = self._cursor + _V3_HEADER.size 

422 body_end = body_start + capacity * ev_size 

423 if ev_size <= 0 or body_end > n: 

424 # Corrupt/truncated trailing packet: stop cleanly. 

425 self._cursor = n 

426 return None 

427 self._cursor = body_end 

428 

429 if ev_type != _V3_POLARITY_EVENT or number == 0: 

430 continue # frame / IMU / trigger packet: skip 

431 

432 if ev_size != _V3_EVENT_DTYPE.itemsize or ts_offset != 4: 

433 raise ValueError( 

434 f"unsupported AEDAT3 polarity event layout " 

435 f"(eventSize={ev_size}, tsOffset={ts_offset})" 

436 ) 

437 rec = np.frombuffer(buf, dtype=_V3_EVENT_DTYPE, count=number, offset=body_start) 

438 d = rec["d"] 

439 valid = (d & np.uint32(0x1)) != 0 

440 if not valid.all(): 

441 rec, d = rec[valid], d[valid] 

442 if len(rec) == 0: 

443 continue 

444 # 31-bit in-packet timestamp + 64-bit overflow counter. 

445 t = rec["t"].astype(np.int64) + (np.int64(ts_overflow) << np.int64(31)) 

446 x = ((d >> np.uint32(17)) & np.uint32(0x7FFF)).astype(np.uint16) 

447 y = ((d >> np.uint32(2)) & np.uint32(0x7FFF)).astype(np.uint16) 

448 p = ((d >> np.uint32(1)) & np.uint32(0x1)).astype(np.uint8) 

449 return EventArray(t, x, y, p) 

450 self._cursor = n 

451 return None 

452 

453 def _batch_v4(self) -> EventArray | None: 

454 """Decode the next AEDAT4 packet carrying events.""" 

455 buf = self._buf 

456 end = self._v4_region_end 

457 decompress = _DECOMPRESSORS[self._v4_compression] 

458 while self._cursor + 8 <= end: 

459 stream_id = _i32(buf, self._cursor) 

460 size = _i32(buf, self._cursor + 4) 

461 body_start = self._cursor + 8 

462 body_end = body_start + size 

463 if size < 0 or body_end > end: 

464 # Truncated trailing packet (or we ran into the data table). 

465 self._cursor = end 

466 return None 

467 self._cursor = body_end 

468 

469 # When the header named the event streams, filter cheaply by id; 

470 # otherwise decompress and check the FlatBuffer identifier. 

471 if self._v4_stream_ids and stream_id not in self._v4_stream_ids: 

472 continue 

473 body = decompress(bytes(buf[body_start:body_end])) 

474 rec = _parse_event_packet(body) 

475 if rec is None or len(rec) == 0: 

476 continue 

477 return EventArray( 

478 rec["t"], 

479 rec["x"].astype(np.uint16), 

480 rec["y"].astype(np.uint16), 

481 rec["p"].astype(np.uint8), 

482 ) 

483 self._cursor = end 

484 return None 

485 

486 # ------------------------------------------------------------------ # 

487 # EventDecoder interface 

488 # ------------------------------------------------------------------ # 

489 def read_chunk(self, delta_t_hint: int | None = None, 

490 n_events_hint: int | None = None) -> EventArray: 

491 if not self._is_initialized: 

492 self.init() 

493 

494 if self._version in (1, 2): 

495 batch = self._batch_v1_v2() 

496 elif self._version == 3: 

497 batch = self._batch_v3() 

498 else: 

499 batch = self._batch_v4() 

500 

501 if batch is None: 

502 self._eof = True 

503 return _EMPTY_EVENTS 

504 return batch 

505 

506 def reset(self) -> None: 

507 """Reset the reader to the first event.""" 

508 self._cursor = self._payload_off 

509 self._ts_wraps = 0 

510 self._last_raw_ts = -1 

511 self._eof = False 

512 

513 def tell(self) -> int: 

514 """Get the current byte offset. 

515 

516 Returns 

517 ------- 

518 int 

519 Current byte offset. 

520 

521 """ 

522 return self._cursor 

523 

524 def close(self) -> None: 

525 """Release the buffer view so the source can be closed.""" 

526 self._buf = None 

527 

528 

529class EventEncoder_Aedat(EventEncoder): 

530 """An encoder for writing events to AEDAT files. 

531 

532 .. note:: 

533 Not implemented yet -- constructing this class raises 

534 :class:`NotImplementedError`. (Reading AEDAT 1.0--4.0 is supported 

535 via :class:`EventDecoder_Aedat`.) 

536 

537 Parameters 

538 ---------- 

539 writable : io.BufferedIOBase 

540 Destination for writing events. 

541 **kwargs 

542 Additional encoder arguments (``width``, ``height``, ``dt``, ...). 

543 

544 Raises 

545 ------ 

546 NotImplementedError 

547 Always, until the format is implemented. 

548 

549 """ 

550 

551 _NOT_IMPLEMENTED = ( 

552 "Writing AEDAT files is not implemented yet. " 

553 "Write a supported format instead (RAW/EVT, DAT, AER, HDF5, NPZ, CSV)." 

554 ) 

555 

556 def __init__(self, writable: Any, **kwargs: Any): 

557 raise NotImplementedError(self._NOT_IMPLEMENTED) 

558 

559 def init(self) -> None: 

560 """Initialize the file for writing.""" 

561 raise NotImplementedError(self._NOT_IMPLEMENTED) 

562 

563 def write(self, events: 'np.ndarray | EventArray', triggers: 'np.ndarray | TriggerArray | None' = None) -> int: 

564 """Write a chunk of events.""" 

565 raise NotImplementedError(self._NOT_IMPLEMENTED)