diff --git a/README.md b/README.md index b78cbc5..363b52f 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,20 @@ pyevio hex sample.evio 0x100 --bytes --endian='>' pyevio ui sample.evio ``` +### GUI Viewer + +```bash +# Serve the viewer at http://localhost:8765, file browser starts in /path/to/runs +pyevio gui /path/to/runs + +# File-based EVIO is experimental: skip the streaming decode, show structure tree only +pyevio gui /path/to/runs --mode=file +``` + +Browse a file in the browser: file checks, channel occupancy, rate, charge and timing plots for CODA streaming fADC250 data, and a structure tree down to single hit words. Decoding runs in the browser. Only CODA streaming fADC250 EVIO is tested; file-based EVIO may not decode well yet. See [docs/web_viewer.md](docs/web_viewer.md). + +![GUI viewer overview](docs/images/web_viewer_overview.png) + ## Understanding EVIO File Structure EVIO files are organized hierarchically: diff --git a/docs/images/web_viewer_overview.png b/docs/images/web_viewer_overview.png new file mode 100644 index 0000000..702c053 Binary files /dev/null and b/docs/images/web_viewer_overview.png differ diff --git a/docs/images/web_viewer_plots_channel.png b/docs/images/web_viewer_plots_channel.png new file mode 100644 index 0000000..e6b721f Binary files /dev/null and b/docs/images/web_viewer_plots_channel.png differ diff --git a/docs/images/web_viewer_plots_file.png b/docs/images/web_viewer_plots_file.png new file mode 100644 index 0000000..8d0b9b0 Binary files /dev/null and b/docs/images/web_viewer_plots_file.png differ diff --git a/docs/images/web_viewer_plots_roc.png b/docs/images/web_viewer_plots_roc.png new file mode 100644 index 0000000..5d52243 Binary files /dev/null and b/docs/images/web_viewer_plots_roc.png differ diff --git a/docs/images/web_viewer_structure.png b/docs/images/web_viewer_structure.png new file mode 100644 index 0000000..0214bdc Binary files /dev/null and b/docs/images/web_viewer_structure.png differ diff --git a/docs/web_viewer.md b/docs/web_viewer.md new file mode 100644 index 0000000..4edf298 --- /dev/null +++ b/docs/web_viewer.md @@ -0,0 +1,73 @@ +# GUI viewer + +`pyevio gui` opens a page for browsing an EVIO v6 file: file checks, channel occupancy, rate, charge and timing plots, and a clickable structure tree from the file header down to single fADC hit words. Decoding runs in the browser, and the file is never uploaded. + +Plots are written for CODA streaming fADC250 files (0xFF60 built time frames); this is the only EVIO flavor tested so far. File-based (triggered) EVIO uses a different decoding method that hasn't been validated here and may hit hiccups on large or unexpected layouts — pass `--mode=file` to skip the streaming-specific decode and show the structure tree only. + +## Start it + +```bash +pyevio gui # file browser starts in the current folder +pyevio gui /path/to/runs # or in another folder +pyevio gui --port 9000 --no-browser +pyevio gui /path/to/runs --mode=file # file-based EVIO: structure tree only (experimental) +``` + +Pick a file with **Browse folders**, **Open .evio file**, or by dragging it onto the page. A 93 MB file loads in under a second. The whole file is held in browser memory, so multi-GB files may not fit. + +The page also works without the server: open `pyevio/gui/index.html` directly. Only the folder browser needs `pyevio gui`. + +**Files on a remote machine** (e.g. a farm node): run `pyevio gui /path/to/runs --no-browser` there, connect with `ssh -L 8765:localhost:8765 user@host`, and open http://localhost:8765 locally. The server listens on localhost only. + +## What it shows + +### Overview + +Totals, file checks (last-record bits, missing event index, compressed records, frame gaps, out-of-order frames) and notable channels. + +![Overview tab](images/web_viewer_overview.png) + +### Plots + +Three levels, from the whole file down to one channel. + +**File:** hits per frame, and total hit rate over time. Gaps show missing frames. + +![File-level plots](images/web_viewer_plots_file.png) + +**Per ROC:** one block per crate with its board × channel occupancy map, hit rate over time, charge spectrum and hit time within the frame. All maps share one color scale, and charts of the same kind share one y-axis, so crates can be compared directly. + +![Per-ROC plots](images/web_viewer_plots_roc.png) + +**Per channel:** pick ROC → port → channel, step with Prev / Next, or click a cell in a ROC map. + +![Per-channel plots](images/web_viewer_plots_channel.png) + +### Structure + +A collapsible tree, 100 records per page, with jump-to-record. Each node shows its decoded fields and raw words. + +![Structure tab](images/web_viewer_structure.png) + +## Data format assumed + +``` +Record (header 14 w) → Event 0xFF60 (built time frame, 65,536 ns) + ├─ 0xFF31 stream info: segment 0x32 = frame, timestamp; segment 0x42 = ROC list + └─ ROC bank (tag = ROC id) + ├─ 0xFF30 ROC stream info: segment 0x31 = frame, timestamp; segment 0x41 = payload ports + └─ payload bank (tag = port): length 1 → empty; length 1+N → N hit words +hit word: t = bits[30:17] × 4 ns, ch = bits[16:13], q = bits[12:0] +``` + +The hit-word layout was inferred from the data and checked for consistency, not taken from a spec. The Overview tab warns if a file breaks those checks. Compressed (LZ4/gzip) records are skipped in the browser and reported. + +## Convert to Parquet + +[examples/fadc250_stream_to_parquet.py](../examples/fadc250_stream_to_parquet.py) decodes the same hits with pyevio and numpy into two Parquet tables, for analysis outside the browser (requires `pip install pyarrow`): + +```bash +python examples/fadc250_stream_to_parquet.py run.evio # → out/run.hits.parquet, out/run.frames.parquet +``` + +Hits (`frame, roc, port, ch, t_ns, q`) are sorted by `(frame, t_ns)`. Rows `hit_offset … hit_offset + n_hits` of the hits table belong to that frame. diff --git a/examples/fadc250_stream_to_parquet.py b/examples/fadc250_stream_to_parquet.py new file mode 100644 index 0000000..0682847 --- /dev/null +++ b/examples/fadc250_stream_to_parquet.py @@ -0,0 +1,147 @@ +"""Decode CODA streaming fADC250 hits from an EVIO v6 file into Parquet. + +Container access goes through pyevio. The hit words are decoded with numpy. +Requires pyarrow (pip install pyarrow). Browse the same files with `pyevio gui`. + +Hit word layout (big-endian u32): + bits 30-17 time in 4 ns ticks from frame start + bits 16-13 channel + bits 12-0 charge q + +Usage: + python fadc250_stream_to_parquet.py run.evio # writes out/run.hits.parquet + out/run.frames.parquet + python fadc250_stream_to_parquet.py runs/*.evio --out out --force + +Read the output: + hits = pq.read_table("out/run.hits.parquet") # frame, roc, port, ch, t_ns, q (sorted by frame, t_ns) + frames = pq.read_table("out/run.frames.parquet") # frame, timestamp_ns, n_hits, hit_offset +""" +import argparse +import sys +import time +from pathlib import Path + +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq +from pyevio import EvioFile + +DECODER_VERSION = "1" +HIT_FORMAT = "fadc250-stream-v1: t=bits[30:17]*4ns ch=bits[16:13] q=bits[12:0]" + + +# ---------------------------------------------------------------- decoding +def decode(path): + """Return (hits dict of numpy arrays, frames dict, stats dict).""" + f = EvioFile(str(path)) + mm = f.mm + dt = np.dtype(f.header.endian + "u4") + words, b_frame, b_roc, b_port, b_n = [], [], [], [], [] # raw hit words + one entry per non-empty payload bank + frames, ts_list, nhits, payload_words = [], [], [], 0 + skipped = 0 + for ri in range(f.record_count): + for e in f.get_record(ri).get_events(): + w = np.frombuffer(mm, dtype=dt, count=e.length // 4, offset=e.offset) + if (w[1] >> 16) != 0xFF60 or (w[3] >> 16) != 0xFF31: + skipped += 1 + continue + frame = int(w[5]) + ts = int(w[6]) | (int(w[7]) << 32) + i = 2 + int(w[2]) + 1 # skip stream-info bank + nh = 0 + while i < len(w): + end = i + int(w[i]) + 1 + roc = int(w[i + 1] >> 16) + j = i + 2 + if (w[j + 1] >> 16) == 0xFF30: # skip ROC stream-info bank + j += int(w[j]) + 1 + while j < end: + plen = int(w[j]) + payload_words += plen - 1 + if plen > 1: + words.append(w[j + 2:j + plen + 1]) + b_frame.append(frame); b_roc.append(roc); b_port.append(int(w[j + 1] >> 16)); b_n.append(plen - 1) + nh += plen - 1 + j += plen + 1 + i = end + frames.append(frame) + ts_list.append(ts) + nhits.append(nh) + h = np.concatenate(words).astype(np.uint32) if words else np.empty(0, np.uint32) + del words + b_n = np.asarray(b_n, dtype=np.int64) + hits = { + "frame": np.repeat(np.asarray(b_frame, np.uint32), b_n), + "roc": np.repeat(np.asarray(b_roc, np.uint16), b_n), + "port": np.repeat(np.asarray(b_port, np.uint8), b_n), + "ch": ((h >> 13) & 0xF).astype(np.uint8), + "t_ns": (((h >> 17) & 0x3FFF) * 4).astype(np.uint16), + "q": (h & 0x1FFF).astype(np.uint16), + } + # sort by frame, then time in frame (the event builder delivers some frames out of order) + order = np.lexsort((hits["t_ns"], hits["frame"])) + hits = {k: v[order] for k, v in hits.items()} + frames, ts_list, nhits = map(np.asarray, (frames, ts_list, nhits)) + out_of_order = int((np.diff(frames) < 0).sum()) + order = np.argsort(frames, kind="stable") + frames, ts_list, nhits = frames[order], ts_list[order], nhits[order] + offsets = np.concatenate([[0], np.cumsum(nhits)[:-1]]) if len(nhits) else np.empty(0) + frame_tab = {"frame": frames.astype(np.uint32), "timestamp_ns": ts_list.astype(np.uint64), + "n_hits": nhits.astype(np.uint32), "hit_offset": offsets.astype(np.uint64)} + stats = {"records": f.record_count, "frames": len(frames), "hits": len(h), "skipped_events": skipped, + "out_of_order": out_of_order, "payload_words": payload_words} + return hits, frame_tab, stats + + +def validate(hits, frames, stats): + """Cheap self-consistency checks; raises AssertionError on failure.""" + assert stats["hits"] == stats["payload_words"], "hit count != sum of (payload bank length - 1)" + assert int(frames["n_hits"].sum()) == stats["hits"], "per-frame hit counts do not add up" + if stats["hits"]: + assert hits["t_ns"].max() < 65536, "hit time outside the 65.5 us frame" + f = frames["frame"] + if len(f): + return {"missing_frames": int(f[-1] - f[0] + 1 - len(np.unique(f))), "duplicate_frames": int(len(f) - len(np.unique(f)))} + return {} + + +def convert(path, out_dir, force=False): + path, out_dir = Path(path), Path(out_dir) + hits_p = out_dir / f"{path.stem}.hits.parquet" + frames_p = out_dir / f"{path.stem}.frames.parquet" + if not force and hits_p.exists() and hits_p.stat().st_mtime > path.stat().st_mtime: + print(f"skip {path.name} (already converted)") + return + t0 = time.time() + hits, frames, stats = decode(path) + checks = validate(hits, frames, stats) + meta = {"source_file": path.name, "source_bytes": str(path.stat().st_size), "decoder_version": DECODER_VERSION, + "hit_format": HIT_FORMAT, **{k: str(v) for k, v in {**stats, **checks}.items()}} + out_dir.mkdir(parents=True, exist_ok=True) + for table, p in ((hits, hits_p), (frames, frames_p)): + t = pa.table(table) + t = t.replace_schema_metadata({k.encode(): v.encode() for k, v in meta.items()}) + pq.write_table(t, p, compression="zstd") + print(f"done {path.name}: {stats['frames']:,} frames, {stats['hits']:,} hits, " + f"{stats['out_of_order']:,} out-of-order, {checks} -> {hits_p.name} " + f"({hits_p.stat().st_size / 1e6:.1f} MB) in {time.time() - t0:.1f} s") + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("files", nargs="+", help=".evio files to convert") + ap.add_argument("--out", default="out", help="output folder (default: ./out)") + ap.add_argument("--force", action="store_true", help="re-convert even if the Parquet is up to date") + args = ap.parse_args(argv) + failed = 0 + for p in args.files: + try: + convert(p, args.out, args.force) + except Exception as e: # keep going with the other files + failed += 1 + print(f"FAIL {p}: {e}", file=sys.stderr) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyevio/cli/__init__.py b/pyevio/cli/__init__.py index c63d425..f1b8e85 100644 --- a/pyevio/cli/__init__.py +++ b/pyevio/cli/__init__.py @@ -12,6 +12,7 @@ from pyevio.cli.hex import hex_command from pyevio.cli.ui import ui_command from pyevio.cli.ana import ana_command +from pyevio.cli.gui import gui_command @click.group(invoke_without_command=True) @@ -41,6 +42,7 @@ def cli(ctx, verbose): cli.add_command(hex_command) cli.add_command(ui_command) cli.add_command(ana_command) +cli.add_command(gui_command) # Entry point for the CLI diff --git a/pyevio/cli/gui.py b/pyevio/cli/gui.py new file mode 100644 index 0000000..21fde7a --- /dev/null +++ b/pyevio/cli/gui.py @@ -0,0 +1,21 @@ +import click + + +@click.command(name="gui") +@click.argument("start_dir", type=click.Path(exists=True, file_okay=False), default=".") +@click.option('--port', '-p', default=8765, show_default=True, help="Port to serve on (localhost only)") +@click.option('--no-browser', is_flag=True, help="Do not open a browser tab") +@click.option('--mode', type=click.Choice(["stream", "file"]), default="stream", show_default=True, + help="EVIO flavor to assume: 'stream' for CODA streaming fADC250 data (tested), " + "'file' for triggered/file-based EVIO (experimental, structure tree only)") +def gui_command(start_dir, port, no_browser, mode): + """Browse EVIO files in a browser page: checks, plots and structure tree. + + START_DIR is the folder the file browser opens in (default: current directory). + + Only CODA streaming fADC250 EVIO is fully tested. File-based EVIO uses a + different decoding path and may not work well; pass --mode=file to skip + the streaming-specific decoding and show the structure tree only. + """ + from pyevio.gui import serve + serve(start_dir, port, open_browser=not no_browser, mode=mode) diff --git a/pyevio/evio_file.py b/pyevio/evio_file.py index 0b71c4b..2a79d20 100644 --- a/pyevio/evio_file.py +++ b/pyevio/evio_file.py @@ -6,6 +6,7 @@ from pyevio.file_header import FileHeader from pyevio.record import Record +from pyevio.record_header import RecordHeader from pyevio.event import Event @@ -63,7 +64,7 @@ def __del__(self): self.mm.close() if hasattr(self, 'file') and self.file and not self.file.closed: self.file.close() - except (ValueError, AttributeError): + except (ValueError, AttributeError, BufferError): # Ignore errors during cleanup pass @@ -96,8 +97,10 @@ def _scan_record_offsets(self) -> List[int]: # Move to next record offset += record.size - # Stop if this was the last record - if record.header.is_last_record: + # Stop if this was the last record. Some CODA writers set the + # last-record bit on every record, so keep going if another + # record header follows. + if record.header.is_last_record and not self._is_record_at(offset): break except Exception as e: @@ -107,6 +110,13 @@ def _scan_record_offsets(self) -> List[int]: return record_offsets + def _is_record_at(self, offset: int) -> bool: + """Check whether a record header (magic word in word 7) starts at offset.""" + if offset + RecordHeader.HEADER_SIZE > self.file_size: + return False + magic = struct.unpack(self.header.endian + 'I', self.mm[offset + 28:offset + 32])[0] + return magic == RecordHeader.MAGIC_NUMBER + @property def record_count(self) -> int: """Get the number of records in this file.""" diff --git a/pyevio/gui/__init__.py b/pyevio/gui/__init__.py new file mode 100644 index 0000000..5fa3bae --- /dev/null +++ b/pyevio/gui/__init__.py @@ -0,0 +1,104 @@ +"""Local web server for the EVIO stream viewer (index.html), with a folder browser. + +The viewer decodes files in the browser. The server only lists folders and +streams file bytes, and listens on 127.0.0.1. To view files on a remote machine, +run `pyevio gui --no-browser` there and connect with +`ssh -L 8765:localhost:8765 user@host`. +""" +import functools +import http.server +import json +import os +import shutil +import webbrowser +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +HERE = Path(__file__).resolve().parent +EVIO_SUFFIXES = {".evio", ".dat"} + + +class Handler(http.server.SimpleHTTPRequestHandler): + start_dir = str(Path.cwd()) + mode = "stream" + + def do_GET(self): + url = urlparse(self.path) + q = {k: v[0] for k, v in parse_qs(url.query).items()} + if url.path == "/api/start": + return self._json({"dir": self.start_dir, "mode": self.mode}) + if url.path == "/api/ls": + return self._ls(q.get("dir", self.start_dir), q.get("all") == "1") + if url.path == "/api/file": + return self._file(q.get("path", "")) + if url.path not in ("/", "/index.html"): + return self.send_error(404) + return super().do_GET() + + def _json(self, obj, code=200): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _ls(self, d, show_all): + p = Path(os.path.expanduser(d)).resolve() + if p.is_file(): + p = p.parent + if not p.is_dir(): + return self._json({"error": f"Folder not found: {p}"}, 404) + dirs, files = [], [] + try: + for e in sorted(os.scandir(p), key=lambda e: e.name.lower()): + if e.name.startswith("."): + continue + try: + if e.is_dir(): + dirs.append(e.name) + elif e.is_file() and (show_all or Path(e.name).suffix.lower() in EVIO_SUFFIXES): + files.append({"name": e.name, "size": e.stat().st_size}) + except OSError: + continue + except PermissionError: + return self._json({"error": f"No permission to read {p}"}, 403) + parent = str(p.parent) if p.parent != p else None + return self._json({"dir": str(p), "parent": parent, "dirs": dirs, "files": files}) + + def _file(self, path): + p = Path(os.path.expanduser(path)).resolve() + if not p.is_file(): + return self._json({"error": f"File not found: {p}"}, 404) + size = p.stat().st_size + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(size)) + self.end_headers() + with open(p, "rb") as f: + shutil.copyfileobj(f, self.wfile, 1 << 20) + + def log_message(self, fmt, *args): # keep the terminal quiet except for errors + if args and str(args[1]).startswith(("4", "5")): + super().log_message(fmt, *args) + + +def serve(start_dir=".", port=8765, open_browser=True, mode="stream"): + """Serve the viewer at http://localhost: until Ctrl+C. + + mode: 'stream' decodes CODA streaming fADC250 data (tested); 'file' skips + that decoding and only shows the structure tree, for untested file-based EVIO. + """ + Handler.start_dir = str(Path(os.path.expanduser(start_dir)).resolve()) + Handler.mode = mode + handler = functools.partial(Handler, directory=str(HERE)) + with http.server.ThreadingHTTPServer(("127.0.0.1", port), handler) as httpd: + url = f"http://localhost:{port}/index.html" + print(f"Viewer: {url} (Ctrl+C to stop)\nFile browser starts in: {Handler.start_dir}, mode: {mode}") + if open_browser: + webbrowser.open(url) + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass diff --git a/pyevio/gui/index.html b/pyevio/gui/index.html new file mode 100644 index 0000000..65573a3 --- /dev/null +++ b/pyevio/gui/index.html @@ -0,0 +1,697 @@ + + + +EVIO Stream Viewer + + + + + +
+
+

EVIO Stream Viewer

+ + + +
+
Drop an .evio file anywhere on this page. It is decoded in your browser and never uploaded. EVIO v6 with CODA streaming fADC250 data (0xFF60 time frames) gets full plots; any other EVIO v6 file shows the structure tree. File-based EVIO is untested; start with pyevio gui --mode=file to skip the streaming decode and go straight to the structure tree.
+ +
+
No file selected
+ +
Click Open .evio file or drag a file onto this page. When started with pyevio gui, you can also pick a file from any folder with Browse folders.
+
+ + +
+ + + + + diff --git a/pyevio/record.py b/pyevio/record.py index dbfdd63..ea4f41e 100644 --- a/pyevio/record.py +++ b/pyevio/record.py @@ -77,6 +77,15 @@ def scan_events(self) -> List[Tuple[int, int]]: # Update cumulative offset for next event current_offset += event_length + elif self.header.event_count > 0: + # No index array: walk events by the length word of each event bank + current_offset = self.data_start + while current_offset + 8 <= self.data_end and len(event_info) < self.header.event_count: + bank_length = struct.unpack(self.endian + 'I', self.mm[current_offset:current_offset+4])[0] + event_length = (bank_length + 1) * 4 + event_info.append((current_offset, event_length)) + current_offset += event_length + return event_info def get_events(self, start_event: Optional[int] = None, end_event: Optional[int] = None): diff --git a/pyproject.toml b/pyproject.toml index 5222f32..9a62e23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,4 +28,7 @@ build-backend = "setuptools.build_meta" version = {attr = "pyevio.__version__"} [project.scripts] -pyevio = "pyevio.cli:main" \ No newline at end of file +pyevio = "pyevio.cli:main" + +[tool.setuptools.package-data] +pyevio = ["gui/*.html"] diff --git a/tests/test_record_scanning.py b/tests/test_record_scanning.py new file mode 100644 index 0000000..bc3dd0b --- /dev/null +++ b/tests/test_record_scanning.py @@ -0,0 +1,63 @@ +import struct + +import pytest + +from pyevio.evio_file import EvioFile + +MAGIC = 0xc0da0100 +LAST_RECORD_BIT = 1 << 9 + + +def make_event(n_payload_words, tag=0xFF60): + """A bank: length word, tag/type/num word, then payload words.""" + words = [n_payload_words + 1, (tag << 16) | (0x10 << 8)] + list(range(n_payload_words)) + return struct.pack("<%dI" % len(words), *words) + + +def make_record(number, events, last, with_index): + index = struct.pack("<%dI" % len(events), *(len(e) for e in events)) if with_index else b"" + data = b"".join(events) + bit_info = LAST_RECORD_BIT if last else 0 + length_words = 14 + (len(index) + len(data)) // 4 + header = struct.pack("<14I", length_words, number, 14, len(events), len(index), + bit_info | 6, 0, MAGIC, len(data), 0, 0, 0, 0, 0) + return header + index + data + + +def write_file(path, records, trailer=b""): + header = struct.pack("<14I", 0x4556494F, 1, 14, len(records), 0, 6, 0, MAGIC, 0, 0, 0, 0, 0, 0) + path.write_bytes(header + b"".join(records) + trailer) + return str(path) + + +def event_lengths(evio_file): + return [[e.length for e in evio_file.get_record(i).get_events()] + for i in range(evio_file.record_count)] + + +def test_standard_file(tmp_path): + records = [make_record(1, [make_event(2), make_event(3)], last=False, with_index=True), + make_record(2, [make_event(1)], last=True, with_index=True)] + with EvioFile(write_file(tmp_path / "std.evio", records)) as f: + assert event_lengths(f) == [[16, 20], [12]] + + +def test_last_record_bit_on_every_record(tmp_path): + """CODA streaming files set the last-record bit on every record.""" + records = [make_record(i, [make_event(i)], last=True, with_index=True) for i in range(1, 4)] + with EvioFile(write_file(tmp_path / "every_last.evio", records)) as f: + assert f.record_count == 3 + + +def test_stops_at_last_record_before_non_record_data(tmp_path): + records = [make_record(1, [make_event(1)], last=True, with_index=True)] + with EvioFile(write_file(tmp_path / "trailer.evio", records, trailer=b"\0" * 64)) as f: + assert f.record_count == 1 + + +@pytest.mark.parametrize("last", [False, True]) +def test_records_without_index_array(tmp_path, last): + records = [make_record(1, [make_event(2), make_event(0), make_event(5)], last=last, with_index=False), + make_record(2, [make_event(1)], last=True, with_index=False)] + with EvioFile(write_file(tmp_path / "no_index.evio", records)) as f: + assert event_lengths(f) == [[16, 8, 28], [12]]