Skip to main content

eventcv_core/
io.rs

1use std::{error::Error, fmt, io, path::Path};
2
3use crate::{EventStream, EventStreamBuilder};
4
5mod aedat;
6mod bag;
7#[cfg(feature = "hdf5")]
8mod h5;
9mod npz;
10mod prophesee;
11mod text;
12
13pub use aedat::read_aedat;
14pub use bag::{open_bag_slice, read_bag, write_bag, BagSliceSource};
15#[cfg(feature = "hdf5")]
16pub use h5::{
17    open_hdf5_slice, read_hdf5, read_hdf5_frame, write_hdf5_frame, write_hdf5_stream,
18    Hdf5EventSink, Hdf5FrameSink, Hdf5SliceSource,
19};
20pub use npz::{read_npz, read_npz_frame, write_npz_frame, write_npz_stream};
21pub use prophesee::read_dat;
22pub use text::{
23    load_rows, open_text_slice, read_text, write_text_stream, ColumnOrder, RawRow, TextOptions,
24    TextReader, TimeUnit,
25};
26
27use crate::representation::EventFrame;
28use crate::viz::{render_frame, Colormap};
29
30/// A single event as produced by a reader, before it is placed on the sensor grid.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct RawEvent {
33    pub x: u16,
34    pub y: u16,
35    pub t: i64,
36    pub p: bool,
37}
38
39/// A bounded-memory source of events. Readers parse on demand so multi-gigabyte
40/// files never need to be resident; the consumer decides what to accumulate.
41pub trait EventSource {
42    fn sensor_size(&self) -> (usize, usize);
43    fn timestamp_scale_ms(&self) -> f64;
44    /// Returns the next event, or `None` at end of stream.
45    fn next_event(&mut self) -> Result<Option<RawEvent>, IoError>;
46}
47
48/// Drains a source into an in-memory [`EventStream`], dropping out-of-bounds events.
49pub fn read_all(source: impl EventSource) -> Result<EventStream, IoError> {
50    read_capped(source, None)
51}
52
53/// Like [`read_all`] but stops after `max` kept events (for previewing huge files).
54pub fn read_capped(
55    mut source: impl EventSource,
56    max: Option<usize>,
57) -> Result<EventStream, IoError> {
58    let (width, height) = source.sensor_size();
59    let mut builder = EventStreamBuilder::new(width, height, source.timestamp_scale_ms());
60    while let Some(event) = source.next_event()? {
61        builder.push(event.x, event.y, event.t, event.p);
62        if max.is_some_and(|max| builder.len() >= max) {
63            break;
64        }
65    }
66    Ok(builder.build())
67}
68
69/// Options for the unified [`load`] entry point. Most fields apply to a single
70/// format; readers ignore the ones they do not need.
71#[derive(Clone, Debug, Default)]
72pub struct LoadOptions {
73    /// `(width, height)`. `None` infers it from the data (coordinate range), or from the
74    /// message for rosbag. An explicit value overrides and, for HDF5, skips the scan.
75    pub sensor_size: Option<(usize, usize)>,
76    /// Timestamp unit. `None` infers it (HDF5/text): a fractional text value means
77    /// seconds, otherwise the unit is chosen from the recording span (see
78    /// `TimeUnit::infer_from_span`). Ignored for rosbag (always ROS `sec`+`nsec`).
79    pub time_unit: Option<TimeUnit>,
80    /// Text column order.
81    pub order: ColumnOrder,
82    /// Rosbag topic to read (defaults to `/davis/left/events`).
83    pub topic: Option<String>,
84    /// Cap on the number of events to read.
85    pub max_events: Option<usize>,
86    /// Absolute timestamp (µs, the file's own time base): events before it are skipped.
87    /// `max_events` then caps the events *after* the offset. `None`/`<= 0` reads from the start.
88    pub offset: Option<i64>,
89    /// Explicit x/y/t/p column names, overriding auto-detection when a file's layout can't
90    /// be guessed. For HDF5 each value is a dataset path (a compound field is `dataset/field`);
91    /// for text/CSV a header column name or 0-based index. `None` auto-detects. Readers that
92    /// don't need it (rosbag, aedat, dat) ignore it.
93    pub keys: Option<EventKeys>,
94}
95
96/// User-supplied names for the four event columns, the escape hatch when auto-detection
97/// can't identify them. Interpreted per format (HDF5 dataset paths, text header
98/// names/indices); see [`LoadOptions::keys`].
99#[derive(Clone, Debug)]
100pub struct EventKeys {
101    pub x: String,
102    pub y: String,
103    pub t: String,
104    pub p: String,
105}
106
107/// Column index of each event field in the `[x, y, t, p]` ordering the readers share.
108pub(crate) const X: usize = 0;
109pub(crate) const Y: usize = 1;
110pub(crate) const T: usize = 2;
111pub(crate) const P: usize = 3;
112
113/// Case-insensitive name synonyms for each event field, used by the HDF5 and text readers
114/// to identify x/y/t/p under varied headings (dataset names, CSV headers). Order within a
115/// list is the tie-break preference — earlier is more canonical.
116pub(crate) const ROLE_KEYS: [&[&str]; 4] = [
117    &[
118        "x",
119        "xs",
120        "x_coordinate",
121        "x_coordinates",
122        "u",
123        "col",
124        "cols",
125        "column",
126        "columns",
127    ],
128    &[
129        "y",
130        "ys",
131        "y_coordinate",
132        "y_coordinates",
133        "v",
134        "row",
135        "rows",
136    ],
137    &[
138        "t",
139        "ts",
140        "time",
141        "times",
142        "timestamp",
143        "timestamps",
144        "time_stamp",
145    ],
146    &[
147        "p",
148        "ps",
149        "pol",
150        "pols",
151        "polarity",
152        "polarities",
153        "polarity_bit",
154        "polarity_bits",
155        "sign",
156    ],
157];
158
159/// The event field (`X`/`Y`/`T`/`P`) a column or dataset base name denotes, matched
160/// case-insensitively against [`ROLE_KEYS`]; `None` if it matches none. The synonym lists
161/// are disjoint, so at most one role matches.
162pub(crate) fn role_of(name: &str) -> Option<usize> {
163    let lower = name.to_ascii_lowercase();
164    ROLE_KEYS
165        .iter()
166        .position(|keys| keys.contains(&lower.as_str()))
167}
168
169/// Rank of `name` within `role`'s synonym list (lower = more canonical), used to choose
170/// between several names in one group that map to the same role; `None` if it isn't one.
171#[cfg_attr(not(feature = "hdf5"), allow(dead_code))]
172pub(crate) fn role_rank(role: usize, name: &str) -> Option<usize> {
173    let lower = name.to_ascii_lowercase();
174    ROLE_KEYS[role].iter().position(|key| *key == lower)
175}
176
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178enum Format {
179    Npz,
180    Text,
181    Hdf5,
182    Rosbag,
183    Aedat,
184    Aedat4,
185    PropheseeDat,
186    PropheseeRaw,
187    Png,
188}
189
190fn detect_format(path: &Path) -> Result<Format, IoError> {
191    let extension = path
192        .extension()
193        .and_then(|extension| extension.to_str())
194        .map(str::to_ascii_lowercase);
195    match extension.as_deref() {
196        Some("npz") => Ok(Format::Npz),
197        Some("txt") | Some("csv") => Ok(Format::Text),
198        Some("h5") | Some("hdf5") => Ok(Format::Hdf5),
199        Some("bag") => Ok(Format::Rosbag),
200        Some("aedat") => Ok(Format::Aedat),
201        Some("aedat4") => Ok(Format::Aedat4),
202        Some("dat") => Ok(Format::PropheseeDat),
203        Some("raw") => Ok(Format::PropheseeRaw),
204        Some("png") => Ok(Format::Png),
205        Some(other) => Err(IoError::Unsupported(format!(
206            "unrecognised file extension: .{other}"
207        ))),
208        None => Err(IoError::Unsupported(
209            "file has no extension to detect its format".to_owned(),
210        )),
211    }
212}
213
214/// Whether `path`'s format can be written incrementally with [`Hdf5EventSink`] — currently only
215/// HDF5. The live recorder uses this to stream a camera to disk window-by-window when it can, and
216/// fall back to buffering the whole recording in memory (then [`save_stream`]) when it can't.
217pub fn supports_event_append(path: impl AsRef<Path>) -> bool {
218    matches!(detect_format(path.as_ref()), Ok(Format::Hdf5))
219}
220
221/// Loads events from any supported file, detected by extension — the OpenCV-style
222/// single entry point. Supported today: `.npz`, `.txt`/`.csv`, `.bag`, `.h5`/`.hdf5`,
223/// `.aedat` (AEDAT 2.0), and `.dat` (Prophesee CD).
224pub fn load(path: impl AsRef<Path>, options: LoadOptions) -> Result<EventStream, IoError> {
225    let path = path.as_ref();
226    let Some(cutoff) = options.offset.filter(|&offset| offset > 0) else {
227        return load_format(path, &options);
228    };
229    // The offset is an absolute timestamp, so the whole recording must be read before
230    // skipping; the cap then applies to the events at/after the offset.
231    let mut read_options = options.clone();
232    read_options.max_events = None;
233    load_format(path, &read_options).map(|stream| skip_before(stream, cutoff, options.max_events))
234}
235
236/// Drops events before the absolute timestamp `cutoff` (µs), then keeps at most `max` of the rest.
237fn skip_before(stream: EventStream, cutoff: i64, max: Option<usize>) -> EventStream {
238    let (ts, (xs, ys, ps)) = (stream.ts(), (stream.xs(), stream.ys(), stream.ps()));
239    if ts.is_empty() {
240        return stream;
241    }
242    let (width, height) = stream.sensor_size();
243    let mut builder = EventStreamBuilder::new(width, height, stream.timestamp_scale_ms());
244    for index in (0..ts.len()).filter(|&index| ts[index] >= cutoff) {
245        builder.push(xs[index], ys[index], ts[index], ps[index]);
246        if max.is_some_and(|max| builder.len() >= max) {
247            break;
248        }
249    }
250    builder.build()
251}
252
253fn load_format(path: &Path, options: &LoadOptions) -> Result<EventStream, IoError> {
254    match detect_format(path)? {
255        Format::Npz => npz::read_npz(path, options.sensor_size),
256        Format::Text => text::load_text(path, options),
257        Format::Rosbag => bag::read_bag(path, options),
258        Format::Hdf5 => {
259            #[cfg(feature = "hdf5")]
260            {
261                h5::read_hdf5(path, options)
262            }
263            #[cfg(not(feature = "hdf5"))]
264            {
265                Err(IoError::Unsupported(
266                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
267                ))
268            }
269        }
270        Format::Aedat => aedat::read_aedat(path, options),
271        Format::Aedat4 => Err(IoError::Unsupported(
272            "AEDAT4 (.aedat4, iniVation DV FlatBuffer/LZ4) reading is not implemented yet"
273                .to_owned(),
274        )),
275        Format::PropheseeDat => prophesee::read_dat(path, options),
276        Format::PropheseeRaw => Err(IoError::Unsupported(
277            "Prophesee .raw (EVT2/EVT3) reading is not implemented yet".to_owned(),
278        )),
279        Format::Png => Err(IoError::Unsupported(
280            "PNG is a frame export format, not an event stream; use save_frame".to_owned(),
281        )),
282    }
283}
284
285/// Random-access view over a file's events: fetch an arbitrary time or count range
286/// without materialising the whole stream. This backs the lazy [`open`] handle — the
287/// OpenCV `VideoCapture` to [`load`]'s `imread`. Each call returns a new [`EventStream`].
288pub trait SliceSource: Send {
289    fn sensor_size(&self) -> (usize, usize);
290    fn timestamp_scale_ms(&self) -> f64;
291    /// Total events in the file.
292    fn n_events(&self) -> usize;
293    /// `(t_min, t_max)` in microseconds across the whole file; `(0, 0)` when empty.
294    fn time_span(&self) -> (i64, i64);
295    /// Events whose index lies in `[i0, i1)` (clamped to the file).
296    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError>;
297    /// Events whose timestamp (µs) lies in the half-open window `[t0, t1)`.
298    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError>;
299
300    /// Per-pixel event counts over the whole file (row-major `width·height`), out-of-bounds
301    /// events dropped — what the reader's hot-pixel pre-scan needs. The default tallies through
302    /// `slice_index` in bounded chunks; a source that can read coordinates alone (HDF5) overrides
303    /// this to skip the `t`/`p` columns and stream construction, which is most of the work.
304    fn pixel_counts(&self) -> Result<Vec<u64>, IoError> {
305        const CHUNK: usize = 8_000_000;
306        let (width, height) = self.sensor_size();
307        let mut counts = vec![0u64; width * height];
308        let total = self.n_events();
309        let mut start = 0;
310        while start < total {
311            let end = (start + CHUNK).min(total);
312            self.slice_index(start, end)?.add_pixel_counts(&mut counts);
313            start = end;
314        }
315        Ok(counts)
316    }
317}
318
319/// A [`SliceSource`] backed by an already-loaded stream — the universal fallback for
320/// formats without native random access (npz/txt/bag today). Slicing is entirely
321/// in-RAM, so `open()` returns a working handle for every supported format from day one.
322pub struct MemorySliceSource {
323    stream: EventStream,
324}
325
326impl MemorySliceSource {
327    pub fn new(stream: EventStream) -> Self {
328        Self { stream }
329    }
330
331    fn rebuild(&self, indices: impl Iterator<Item = usize>) -> EventStream {
332        let (width, height) = self.stream.sensor_size();
333        let mut builder = EventStreamBuilder::new(width, height, self.stream.timestamp_scale_ms());
334        let (xs, ys, ts, ps) = (
335            self.stream.xs(),
336            self.stream.ys(),
337            self.stream.ts(),
338            self.stream.ps(),
339        );
340        for index in indices {
341            builder.push(xs[index], ys[index], ts[index], ps[index]);
342        }
343        builder.build()
344    }
345}
346
347impl SliceSource for MemorySliceSource {
348    fn sensor_size(&self) -> (usize, usize) {
349        self.stream.sensor_size()
350    }
351
352    fn timestamp_scale_ms(&self) -> f64 {
353        self.stream.timestamp_scale_ms()
354    }
355
356    fn n_events(&self) -> usize {
357        self.stream.len()
358    }
359
360    fn time_span(&self) -> (i64, i64) {
361        let ts = self.stream.ts();
362        match (ts.iter().min(), ts.iter().max()) {
363            (Some(&min), Some(&max)) => (min, max),
364            _ => (0, 0),
365        }
366    }
367
368    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
369        let i0 = i0.min(self.stream.len());
370        let i1 = i1.clamp(i0, self.stream.len());
371        Ok(self.rebuild(i0..i1))
372    }
373
374    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
375        let ts = self.stream.ts();
376        Ok(self.rebuild((0..ts.len()).filter(|&index| ts[index] >= t0 && ts[index] < t1)))
377    }
378
379    fn pixel_counts(&self) -> Result<Vec<u64>, IoError> {
380        // Already resident — tally straight from the stream, no chunked rebuilds.
381        let (width, height) = self.stream.sensor_size();
382        let mut counts = vec![0u64; width * height];
383        self.stream.add_pixel_counts(&mut counts);
384        Ok(counts)
385    }
386}
387
388/// A boxed [`SliceSource`] — the handle [`open`] returns.
389pub type Reader = Box<dyn SliceSource>;
390
391/// Opens a file for lazy slicing, detected by extension (the `VideoCapture` analogue to
392/// [`load`]'s `imread`). HDF5 is sliced in place by binary-searching its timestamp
393/// dataset; every other format is loaded once and sliced in memory. Same `LoadOptions`
394/// as [`load`] (`max_events` is ignored — slicing supersedes it).
395pub fn open(path: impl AsRef<Path>, options: LoadOptions) -> Result<Reader, IoError> {
396    let path = path.as_ref();
397    match detect_format(path)? {
398        Format::Hdf5 => {
399            #[cfg(feature = "hdf5")]
400            {
401                Ok(Box::new(h5::open_hdf5_slice(path, &options)?))
402            }
403            #[cfg(not(feature = "hdf5"))]
404            {
405                Err(IoError::Unsupported(
406                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
407                ))
408            }
409        }
410        Format::Text => Ok(Box::new(text::open_text_slice(path, &options)?)),
411        Format::Rosbag => Ok(Box::new(bag::open_bag_slice(path, &options)?)),
412        _ => Ok(Box::new(MemorySliceSource::new(load(path, options)?))),
413    }
414}
415
416/// Options for the [`save_stream`] / [`save_frame`] writers — the symmetric mirror of
417/// [`LoadOptions`]. Most formats ignore every field; readers and writers agree on the rest.
418#[derive(Clone, Debug, Default)]
419pub struct SaveOptions {
420    /// Rosbag topic to write the `dvs_msgs/EventArray` messages on (defaults to
421    /// `/davis/left/events`, matching the reader).
422    pub topic: Option<String>,
423    /// PNG frame export only: the colour map (default [`Colormap::Viridis`]).
424    pub colormap: Colormap,
425    /// PNG frame export only: auto-contrast the field to its data range. `None` = `true`.
426    pub normalize: Option<bool>,
427}
428
429/// Persists an [`EventStream`] to `path`, the format chosen by extension — the symmetric
430/// counterpart of [`load`]. npz/HDF5/rosbag round-trip exactly (metadata stored); txt
431/// stores `t x y p` and recovers sensor size / time unit on load via inference or options.
432pub fn save_stream(
433    path: impl AsRef<Path>,
434    stream: &EventStream,
435    options: &SaveOptions,
436) -> Result<(), IoError> {
437    let path = path.as_ref();
438    match detect_format(path)? {
439        Format::Npz => npz::write_npz_stream(path, stream),
440        Format::Text => text::write_text_stream(path, stream),
441        Format::Rosbag => bag::write_bag(path, stream, options.topic.as_deref()),
442        Format::Hdf5 => {
443            #[cfg(feature = "hdf5")]
444            {
445                h5::write_hdf5_stream(path, stream)
446            }
447            #[cfg(not(feature = "hdf5"))]
448            {
449                Err(IoError::Unsupported(
450                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
451                ))
452            }
453        }
454        other => Err(IoError::Unsupported(format!(
455            "saving an event stream as {other:?} is not supported"
456        ))),
457    }
458}
459
460/// Persists an [`EventFrame`] (a computed representation) to `path`, preserving its shape,
461/// dtype, `kind`, and `channel_names`. Supported: npz (default build) and HDF5.
462pub fn save_frame(
463    path: impl AsRef<Path>,
464    frame: &EventFrame,
465    options: &SaveOptions,
466) -> Result<(), IoError> {
467    let path = path.as_ref();
468    match detect_format(path)? {
469        Format::Npz => npz::write_npz_frame(path, frame),
470        Format::Hdf5 => {
471            #[cfg(feature = "hdf5")]
472            {
473                h5::write_hdf5_frame(path, frame)
474            }
475            #[cfg(not(feature = "hdf5"))]
476            {
477                Err(IoError::Unsupported(
478                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
479                ))
480            }
481        }
482        Format::Png => write_png_frame(path, frame, options),
483        other => Err(IoError::Unsupported(format!(
484            "saving an event frame as {other:?} is not supported"
485        ))),
486    }
487}
488
489/// Renders a frame through [`render_frame`] (colormapped 2-D view) and encodes it as an
490/// 8-bit RGB PNG. Unlike npz/HDF5 this is a *view*, not a round-trippable dump.
491fn write_png_frame(path: &Path, frame: &EventFrame, options: &SaveOptions) -> Result<(), IoError> {
492    let image = render_frame(frame, options.colormap, options.normalize.unwrap_or(true));
493    let file = std::fs::File::create(path)?;
494    let writer = std::io::BufWriter::new(file);
495    let mut encoder = png::Encoder::new(writer, image.width as u32, image.height as u32);
496    encoder.set_color(png::ColorType::Rgb);
497    encoder.set_depth(png::BitDepth::Eight);
498    encoder
499        .write_header()
500        .and_then(|mut writer| writer.write_image_data(&image.pixels))
501        .map_err(|error| match error {
502            png::EncodingError::IoError(error) => IoError::Io(error),
503            other => IoError::Format(other.to_string()),
504        })
505}
506
507/// Reads an [`EventFrame`] previously written by [`save_frame`], reconstructing its dtype,
508/// `kind`, and `channel_names`. Supported: npz (default build) and HDF5.
509pub fn load_frame(path: impl AsRef<Path>) -> Result<EventFrame, IoError> {
510    let path = path.as_ref();
511    match detect_format(path)? {
512        Format::Npz => npz::read_npz_frame(path),
513        Format::Hdf5 => {
514            #[cfg(feature = "hdf5")]
515            {
516                h5::read_hdf5_frame(path)
517            }
518            #[cfg(not(feature = "hdf5"))]
519            {
520                Err(IoError::Unsupported(
521                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
522                ))
523            }
524        }
525        other => Err(IoError::Unsupported(format!(
526            "loading an event frame from {other:?} is not supported"
527        ))),
528    }
529}
530
531#[derive(Debug)]
532pub enum IoError {
533    Io(io::Error),
534    Parse { line: usize, message: String },
535    Format(String),
536    InvalidSensorSize,
537    Unsupported(String),
538}
539
540impl fmt::Display for IoError {
541    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
542        match self {
543            Self::Io(error) => error.fmt(formatter),
544            Self::Parse { line, message } => write!(formatter, "line {line}: {message}"),
545            Self::Format(message) => formatter.write_str(message),
546            Self::InvalidSensorSize => {
547                formatter.write_str("sensor width and height must be positive")
548            }
549            Self::Unsupported(message) => formatter.write_str(message),
550        }
551    }
552}
553
554impl Error for IoError {
555    fn source(&self) -> Option<&(dyn Error + 'static)> {
556        match self {
557            Self::Io(error) => Some(error),
558            _ => None,
559        }
560    }
561}
562
563impl From<io::Error> for IoError {
564    fn from(error: io::Error) -> Self {
565        Self::Io(error)
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::{load, open, IoError, LoadOptions, MemorySliceSource, SliceSource};
572    use crate::EventStreamBuilder;
573
574    #[test]
575    fn unknown_extension_is_unsupported() {
576        let error = load("recording.mp4", LoadOptions::default()).unwrap_err();
577        assert!(matches!(error, IoError::Unsupported(_)));
578    }
579
580    #[test]
581    fn hdf5_extension_dispatches_per_feature() {
582        // `.h5` is always recognised; with the feature it reaches the reader (a missing
583        // file is then an IO error), without it the dispatch reports missing support.
584        let error = load("recording.h5", LoadOptions::default()).unwrap_err();
585        #[cfg(feature = "hdf5")]
586        assert!(matches!(error, IoError::Io(_)));
587        #[cfg(not(feature = "hdf5"))]
588        match error {
589            IoError::Unsupported(message) => assert!(message.contains("HDF5")),
590            other => panic!("expected unsupported error, got {other:?}"),
591        }
592    }
593
594    #[test]
595    fn text_missing_file_is_reported() {
596        // Text no longer requires sensor_size (it infers); a missing file is an IO error.
597        let error = load("events.txt", LoadOptions::default()).unwrap_err();
598        assert!(matches!(error, IoError::Io(_)));
599    }
600
601    #[test]
602    fn aedat_dispatches_to_the_reader() {
603        // `.aedat` reaches the AEDAT 2.0 reader, so a missing file is an IO error.
604        let error = load("recording.aedat", LoadOptions::default()).unwrap_err();
605        assert!(matches!(error, IoError::Io(_)));
606    }
607
608    #[test]
609    fn prophesee_dat_dispatches_to_the_reader() {
610        let error = load("recording.dat", LoadOptions::default()).unwrap_err();
611        assert!(matches!(error, IoError::Io(_)));
612    }
613
614    #[test]
615    fn aedat4_and_prophesee_raw_are_unsupported() {
616        // These extensions are recognised but their formats are not implemented yet.
617        for path in ["recording.aedat4", "recording.raw"] {
618            match load(path, LoadOptions::default()) {
619                Err(IoError::Unsupported(_)) => {}
620                other => panic!("expected unsupported for {path}, got {other:?}"),
621            }
622        }
623    }
624
625    fn sample_source() -> MemorySliceSource {
626        let mut builder = EventStreamBuilder::new(4, 4, 0.001);
627        builder.push(0, 0, 0, true);
628        builder.push(1, 1, 10, false);
629        builder.push(2, 2, 20, true);
630        builder.push(0, 1, 30, false);
631        MemorySliceSource::new(builder.build())
632    }
633
634    #[test]
635    fn memory_source_reports_span_and_count() {
636        let source = sample_source();
637        assert_eq!(source.n_events(), 4);
638        assert_eq!(source.time_span(), (0, 30));
639    }
640
641    #[test]
642    fn memory_source_slices_by_time_and_index() {
643        let source = sample_source();
644
645        // Half-open [10, 30) keeps t = 10 and 20.
646        assert_eq!(source.slice_time(10, 30).unwrap().ts(), &[10, 20]);
647        assert_eq!(source.slice_index(1, 3).unwrap().ts(), &[10, 20]);
648        assert_eq!(source.slice_index(2, 100).unwrap().len(), 2); // hi clamped to len
649        assert!(source.slice_time(100, 200).unwrap().is_empty());
650    }
651
652    #[test]
653    fn open_rejects_unknown_extension() {
654        // `Reader` is a trait object (not `Debug`), so match rather than `unwrap_err`.
655        match open("recording.mp4", LoadOptions::default()) {
656            Err(IoError::Unsupported(_)) => {}
657            Err(other) => panic!("expected unsupported error, got {other:?}"),
658            Ok(_) => panic!("expected an error for an unknown extension"),
659        }
660    }
661}