Skip to main content

eventcv_core/io/
text.rs

1use std::fs::File;
2use std::io::{BufRead, BufReader, BufWriter, Seek, SeekFrom, Write};
3use std::path::{Path, PathBuf};
4use std::str::{FromStr, SplitWhitespace};
5
6use super::{read_all, read_capped, EventSource, IoError, LoadOptions, RawEvent, SliceSource};
7use crate::{EventStream, EventStreamBuilder};
8
9/// Writes a stream as whitespace-separated `t x y p` lines (the reader's default
10/// [`ColumnOrder::Txyp`]), `t` in raw microseconds and `p` as `0`/`1`. Loading it back with
11/// `time_unit="us"` reproduces the events exactly; sensor size is inferred or passed as an
12/// option (txt carries no metadata header). The frame-domain counterpart lives in npz/HDF5.
13pub fn write_text_stream(path: impl AsRef<Path>, stream: &EventStream) -> Result<(), IoError> {
14    let mut writer = BufWriter::new(File::create(path).map_err(IoError::Io)?);
15    let (xs, ys, ts, ps) = (stream.xs(), stream.ys(), stream.ts(), stream.ps());
16    for index in 0..stream.len() {
17        writeln!(
18            writer,
19            "{} {} {} {}",
20            ts[index],
21            xs[index],
22            ys[index],
23            u8::from(ps[index])
24        )
25        .map_err(IoError::Io)?;
26    }
27    writer.flush().map_err(IoError::Io)
28}
29
30/// Unit of the timestamp column. Events are stored internally in microseconds, so
31/// [`TextReader`] always reports `timestamp_scale_ms() == 0.001`; sub-microsecond
32/// precision is rounded.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum TimeUnit {
35    Seconds,
36    Milliseconds,
37    Microseconds,
38    Nanoseconds,
39}
40
41impl TimeUnit {
42    fn to_microseconds(self, value: f64) -> i64 {
43        let microseconds = match self {
44            Self::Seconds => value * 1e6,
45            Self::Milliseconds => value * 1e3,
46            Self::Microseconds => value,
47            Self::Nanoseconds => value / 1e3,
48        };
49        microseconds.round() as i64
50    }
51
52    /// Maps a stored `timestamp_scale_ms` (milliseconds per raw unit) back to the matching
53    /// unit, when it is one of the standard powers of 1000 — lets the HDF5 reader honour the
54    /// scale a stream was saved with instead of re-inferring it. `None` for other scales.
55    #[cfg_attr(not(feature = "hdf5"), allow(dead_code))]
56    pub(crate) fn from_scale_ms(scale_ms: f64) -> Option<TimeUnit> {
57        for (unit, expected) in [
58            (TimeUnit::Nanoseconds, 1e-6),
59            (TimeUnit::Microseconds, 1e-3),
60            (TimeUnit::Milliseconds, 1.0),
61            (TimeUnit::Seconds, 1e3),
62        ] {
63            if (scale_ms - expected).abs() <= expected * 1e-9 {
64                return Some(unit);
65            }
66        }
67        None
68    }
69
70    /// Guesses the unit of an integer timestamp column from the recording's raw span
71    /// (`max - min`). Event recordings run ~seconds to hours, so we pick the *finest*
72    /// unit whose total duration is at least one second — e.g. a span of 6.5e11 reads as
73    /// nanoseconds (651 s), not microseconds (7.5 days). Assumes a recording ≥ ~1 s;
74    /// callers pass an explicit unit to override. A fractional text value means seconds.
75    pub(crate) fn infer_from_span(span: i64) -> TimeUnit {
76        let span = span.max(0) as f64;
77        if span * 1e-9 >= 1.0 {
78            TimeUnit::Nanoseconds
79        } else if span * 1e-6 >= 1.0 {
80            TimeUnit::Microseconds
81        } else if span * 1e-3 >= 1.0 {
82            TimeUnit::Milliseconds
83        } else {
84            TimeUnit::Seconds
85        }
86    }
87
88    /// Converts an integer timestamp column (e.g. from HDF5) to microseconds,
89    /// saturating rather than overflowing if the wrong unit is supplied.
90    #[cfg(feature = "hdf5")]
91    pub(crate) fn microseconds_from_int(self, value: i64) -> i64 {
92        let value = i128::from(value);
93        let microseconds = match self {
94            Self::Seconds => value * 1_000_000,
95            Self::Milliseconds => value * 1_000,
96            Self::Microseconds => value,
97            Self::Nanoseconds => value / 1_000,
98        };
99        microseconds.clamp(i64::MIN as i128, i64::MAX as i128) as i64
100    }
101}
102
103/// Column order of each whitespace-separated line.
104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub enum ColumnOrder {
106    /// `t x y p` (e.g. EV-IMO, RPG datasets).
107    #[default]
108    Txyp,
109    /// `x y t p`.
110    Xytp,
111}
112
113#[derive(Clone, Copy, Debug)]
114pub struct TextOptions {
115    pub width: usize,
116    pub height: usize,
117    pub time_unit: TimeUnit,
118    pub order: ColumnOrder,
119}
120
121impl TextOptions {
122    /// Defaults to seconds timestamps in `t x y p` order (the EV-IMO layout).
123    pub fn new(width: usize, height: usize) -> Self {
124        Self {
125            width,
126            height,
127            time_unit: TimeUnit::Seconds,
128            order: ColumnOrder::Txyp,
129        }
130    }
131}
132
133/// Streams events from whitespace-separated text, one event per line. Blank lines
134/// and `#` comments are skipped; polarity is positive when its value is greater
135/// than zero (handles both `0/1` and `-1/1` conventions). Extra columns are ignored.
136#[derive(Debug)]
137pub struct TextReader<R> {
138    reader: R,
139    options: TextOptions,
140    buffer: String,
141    line: usize,
142}
143
144impl<R: BufRead> TextReader<R> {
145    pub fn new(reader: R, options: TextOptions) -> Result<Self, IoError> {
146        if options.width == 0 || options.height == 0 {
147            return Err(IoError::InvalidSensorSize);
148        }
149        Ok(Self {
150            reader,
151            options,
152            buffer: String::new(),
153            line: 0,
154        })
155    }
156
157    fn parse_line(&self, line: &str) -> Result<RawEvent, IoError> {
158        let mut fields = line.split_whitespace();
159        let (t, x, y, p) = match self.options.order {
160            ColumnOrder::Txyp => {
161                let t = self.field(&mut fields, "t")?;
162                (
163                    t,
164                    self.field(&mut fields, "x")?,
165                    self.field(&mut fields, "y")?,
166                    self.field(&mut fields, "p")?,
167                )
168            }
169            ColumnOrder::Xytp => {
170                let x = self.field(&mut fields, "x")?;
171                let y = self.field(&mut fields, "y")?;
172                (
173                    self.field(&mut fields, "t")?,
174                    x,
175                    y,
176                    self.field(&mut fields, "p")?,
177                )
178            }
179        };
180        Ok(RawEvent {
181            x: self.parse(x, "x")?,
182            y: self.parse(y, "y")?,
183            t: self
184                .options
185                .time_unit
186                .to_microseconds(self.parse::<f64>(t, "t")?),
187            p: self.parse::<i32>(p, "p")? > 0,
188        })
189    }
190
191    fn field<'a>(&self, fields: &mut SplitWhitespace<'a>, name: &str) -> Result<&'a str, IoError> {
192        fields.next().ok_or_else(|| IoError::Parse {
193            line: self.line,
194            message: format!("missing {name}"),
195        })
196    }
197
198    fn parse<T: FromStr>(&self, value: &str, field: &str) -> Result<T, IoError> {
199        value.parse().map_err(|_| IoError::Parse {
200            line: self.line,
201            message: format!("invalid {field}: {value:?}"),
202        })
203    }
204}
205
206impl<R: BufRead> EventSource for TextReader<R> {
207    fn sensor_size(&self) -> (usize, usize) {
208        (self.options.width, self.options.height)
209    }
210
211    fn timestamp_scale_ms(&self) -> f64 {
212        0.001
213    }
214
215    fn next_event(&mut self) -> Result<Option<RawEvent>, IoError> {
216        loop {
217            self.buffer.clear();
218            self.line += 1;
219            if self.reader.read_line(&mut self.buffer)? == 0 {
220                return Ok(None);
221            }
222            let trimmed = self.buffer.trim();
223            if trimmed.is_empty() || trimmed.starts_with('#') {
224                continue;
225            }
226            return self.parse_line(trimmed).map(Some);
227        }
228    }
229}
230
231/// Opens a text file as a streaming [`TextReader`].
232pub fn open(
233    path: impl AsRef<Path>,
234    options: TextOptions,
235) -> Result<TextReader<BufReader<File>>, IoError> {
236    TextReader::new(BufReader::new(File::open(path)?), options)
237}
238
239/// Reads an entire text file into an [`EventStream`].
240pub fn read_text(path: impl AsRef<Path>, options: TextOptions) -> Result<EventStream, IoError> {
241    read_all(open(path, options)?)
242}
243
244/// One parsed row before unit conversion / bounds filtering. The inference path needs
245/// the *raw* timestamp (to detect the unit), so it can't go through [`TextReader`].
246/// Also the input to [`load_rows`], the in-memory (`from_numpy`) loader.
247pub struct RawRow {
248    pub x: u16,
249    pub y: u16,
250    pub t: f64,
251    pub p: bool,
252}
253
254/// Loads a text file, inferring whichever of `sensor_size` (from the coordinate range)
255/// and `time_unit` (fractional value ⇒ seconds, else the span magnitude) the caller
256/// left unset. A fully-specified load streams without buffering; inference reads the
257/// rows once into memory.
258pub fn load_text(path: impl AsRef<Path>, options: &LoadOptions) -> Result<EventStream, IoError> {
259    if let (Some((width, height)), Some(time_unit)) = (options.sensor_size, options.time_unit) {
260        let text_options = TextOptions {
261            width,
262            height,
263            time_unit,
264            order: options.order,
265        };
266        return read_capped(open(path.as_ref(), text_options)?, options.max_events);
267    }
268
269    let rows = read_raw_rows(path.as_ref(), options.order)?;
270    load_rows(&rows, options)
271}
272
273/// Builds an [`EventStream`] from already-parsed rows, inferring whichever of
274/// `sensor_size`/`time_unit` the caller left unset (the in-memory twin of
275/// [`load_text`], shared with `eventcv.from_numpy`).
276pub fn load_rows(rows: &[RawRow], options: &LoadOptions) -> Result<EventStream, IoError> {
277    let (width, height) = options
278        .sensor_size
279        .unwrap_or_else(|| infer_sensor_size(rows));
280    let time_unit = options.time_unit.unwrap_or_else(|| infer_time_unit(rows));
281    if width == 0 || height == 0 {
282        return Err(IoError::InvalidSensorSize);
283    }
284
285    let mut builder = EventStreamBuilder::new(width, height, 0.001);
286    for row in rows {
287        builder.push(row.x, row.y, time_unit.to_microseconds(row.t), row.p);
288        if options.max_events.is_some_and(|max| builder.len() >= max) {
289            break;
290        }
291    }
292    Ok(builder.build())
293}
294
295/// Smallest sensor that holds every event: `(max_x + 1, max_y + 1)`, or `(1, 1)` when
296/// there are no events.
297fn infer_sensor_size(rows: &[RawRow]) -> (usize, usize) {
298    let width = rows.iter().map(|row| usize::from(row.x)).max();
299    let height = rows.iter().map(|row| usize::from(row.y)).max();
300    match (width, height) {
301        (Some(width), Some(height)) => (width + 1, height + 1),
302        _ => (1, 1),
303    }
304}
305
306/// A fractional timestamp means seconds; otherwise pick the unit from the span.
307fn infer_time_unit(rows: &[RawRow]) -> TimeUnit {
308    if rows.iter().any(|row| row.t.fract() != 0.0) {
309        return TimeUnit::Seconds;
310    }
311    let min = rows.iter().map(|row| row.t).fold(f64::INFINITY, f64::min);
312    let max = rows
313        .iter()
314        .map(|row| row.t)
315        .fold(f64::NEG_INFINITY, f64::max);
316    if min.is_finite() {
317        TimeUnit::infer_from_span((max - min) as i64)
318    } else {
319        TimeUnit::Seconds
320    }
321}
322
323/// Parses one non-blank line into a [`RawRow`] (no unit conversion or bounds check).
324/// Shared by the buffered [`load_text`] and the [`TextSliceSource`] index scan.
325fn parse_raw_row(trimmed: &str, order: ColumnOrder, number: usize) -> Result<RawRow, IoError> {
326    let mut fields = trimmed.split_whitespace();
327    let mut next = |name: &str| {
328        fields.next().ok_or(IoError::Parse {
329            line: number,
330            message: format!("missing {name}"),
331        })
332    };
333    let (t, x, y, p) = match order {
334        ColumnOrder::Txyp => {
335            let t = next("t")?;
336            (t, next("x")?, next("y")?, next("p")?)
337        }
338        ColumnOrder::Xytp => {
339            let x = next("x")?;
340            let y = next("y")?;
341            (next("t")?, x, y, next("p")?)
342        }
343    };
344    let parse = |value: &str, field: &str| {
345        value.parse::<f64>().map_err(|_| IoError::Parse {
346            line: number,
347            message: format!("invalid {field}: {value:?}"),
348        })
349    };
350    Ok(RawRow {
351        x: parse(x, "x")? as u16,
352        y: parse(y, "y")? as u16,
353        t: parse(t, "t")?,
354        p: parse(p, "p")? > 0.0,
355    })
356}
357
358fn read_raw_rows(path: &Path, order: ColumnOrder) -> Result<Vec<RawRow>, IoError> {
359    let reader = BufReader::new(File::open(path)?);
360    let mut rows = Vec::new();
361    for (index, line) in reader.lines().enumerate() {
362        let line = line?;
363        let trimmed = line.trim();
364        if trimmed.is_empty() || trimmed.starts_with('#') {
365            continue;
366        }
367        rows.push(parse_raw_row(trimmed, order, index + 1)?);
368    }
369    Ok(rows)
370}
371
372/// Number of events between sparse index samples — a slice reads at most this many extra
373/// rows past a sample before reaching its window.
374const TEXT_INDEX_STRIDE: usize = 4096;
375
376#[derive(Clone, Copy)]
377struct TextIndexEntry {
378    offset: u64,
379    count: usize,
380    t_us: i64,
381}
382
383/// In-place [`SliceSource`] for text files. Text isn't seekable by content, so `open`
384/// scans once to build a sparse `(byte offset, event count, timestamp)` index (one entry
385/// per [`TEXT_INDEX_STRIDE`] events); slices binary-search it, seek the file, and parse
386/// forward. Bounded memory; assumes events are time-ordered (errors otherwise).
387pub struct TextSliceSource {
388    path: PathBuf,
389    order: ColumnOrder,
390    time_unit: TimeUnit,
391    sensor: (usize, usize),
392    total: usize,
393    span_us: (i64, i64),
394    index: Vec<TextIndexEntry>,
395}
396
397/// Scans the file once to build a `TextSliceSource`, inferring `sensor_size`/`time_unit`
398/// when unset exactly as `load_text` does, and dropping out-of-bounds events (when the
399/// size is explicit) so the index counts the same events `load` would keep.
400pub fn open_text_slice(
401    path: impl AsRef<Path>,
402    options: &LoadOptions,
403) -> Result<TextSliceSource, IoError> {
404    let path = path.as_ref();
405    let mut reader = BufReader::new(File::open(path)?);
406    let mut buffer = String::new();
407    let mut offset = 0u64;
408    let mut line_no = 0usize;
409    let mut kept = 0usize;
410    let mut samples: Vec<(u64, usize, f64)> = Vec::new();
411    let (mut max_x, mut max_y) = (0u16, 0u16);
412    let (mut min_t, mut max_t) = (f64::INFINITY, f64::NEG_INFINITY);
413    let mut fractional = false;
414    let mut sorted = true;
415    let mut previous_t = f64::NEG_INFINITY;
416
417    loop {
418        buffer.clear();
419        let line_start = offset;
420        let bytes = reader.read_line(&mut buffer)?;
421        if bytes == 0 {
422            break;
423        }
424        offset += bytes as u64;
425        line_no += 1;
426        let trimmed = buffer.trim();
427        if trimmed.is_empty() || trimmed.starts_with('#') {
428            continue;
429        }
430        let row = parse_raw_row(trimmed, options.order, line_no)?;
431        if let Some((width, height)) = options.sensor_size {
432            if usize::from(row.x) >= width || usize::from(row.y) >= height {
433                continue; // matches the OOB drop a load with this size would do
434            }
435        }
436        if kept.is_multiple_of(TEXT_INDEX_STRIDE) {
437            samples.push((line_start, kept, row.t));
438        }
439        max_x = max_x.max(row.x);
440        max_y = max_y.max(row.y);
441        min_t = min_t.min(row.t);
442        max_t = max_t.max(row.t);
443        fractional |= row.t.fract() != 0.0;
444        sorted &= row.t >= previous_t;
445        previous_t = row.t;
446        kept += 1;
447    }
448
449    let sensor = options
450        .sensor_size
451        .unwrap_or((usize::from(max_x) + 1, usize::from(max_y) + 1));
452    if sensor.0 == 0 || sensor.1 == 0 {
453        return Err(IoError::InvalidSensorSize);
454    }
455    if !sorted {
456        return Err(IoError::Format(
457            "text timestamps are not sorted; in-place slicing requires time-ordered events"
458                .to_owned(),
459        ));
460    }
461    let time_unit = options.time_unit.unwrap_or_else(|| {
462        if fractional || !min_t.is_finite() {
463            TimeUnit::Seconds
464        } else {
465            TimeUnit::infer_from_span((max_t - min_t) as i64)
466        }
467    });
468
469    let index = samples
470        .into_iter()
471        .map(|(offset, count, t)| TextIndexEntry {
472            offset,
473            count,
474            t_us: time_unit.to_microseconds(t),
475        })
476        .collect();
477    let span_us = if kept == 0 {
478        (0, 0)
479    } else {
480        (
481            time_unit.to_microseconds(min_t),
482            time_unit.to_microseconds(max_t),
483        )
484    };
485    Ok(TextSliceSource {
486        path: path.to_path_buf(),
487        order: options.order,
488        time_unit,
489        sensor,
490        total: kept,
491        span_us,
492        index,
493    })
494}
495
496impl TextSliceSource {
497    /// Opens the file again seeked to `offset`, wrapped in a [`TextReader`] for parsing.
498    fn reader_at(&self, offset: u64) -> Result<TextReader<BufReader<File>>, IoError> {
499        let mut file = File::open(&self.path)?;
500        file.seek(SeekFrom::Start(offset))?;
501        TextReader::new(
502            BufReader::new(file),
503            TextOptions {
504                width: self.sensor.0,
505                height: self.sensor.1,
506                time_unit: self.time_unit,
507                order: self.order,
508            },
509        )
510    }
511
512    fn keeps(&self, x: u16, y: u16) -> bool {
513        usize::from(x) < self.sensor.0 && usize::from(y) < self.sensor.1
514    }
515}
516
517impl SliceSource for TextSliceSource {
518    fn sensor_size(&self) -> (usize, usize) {
519        self.sensor
520    }
521
522    fn timestamp_scale_ms(&self) -> f64 {
523        0.001
524    }
525
526    fn n_events(&self) -> usize {
527        self.total
528    }
529
530    fn time_span(&self) -> (i64, i64) {
531        self.span_us
532    }
533
534    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
535        let i0 = i0.min(self.total);
536        let i1 = i1.clamp(i0, self.total);
537        let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
538        if i0 == i1 || self.index.is_empty() {
539            return Ok(builder.build());
540        }
541        // index[0].count == 0, so partition_point is >= 1 and the subtraction is safe.
542        let entry = self.index[self.index.partition_point(|e| e.count <= i0) - 1];
543        let mut reader = self.reader_at(entry.offset)?;
544        let mut index = entry.count;
545        while index < i1 {
546            let Some(event) = reader.next_event()? else {
547                break;
548            };
549            if !self.keeps(event.x, event.y) {
550                continue; // dropped, not counted — keeps indices aligned with `load`
551            }
552            if index >= i0 {
553                builder.push(event.x, event.y, event.t, event.p);
554            }
555            index += 1;
556        }
557        Ok(builder.build())
558    }
559
560    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
561        let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
562        if self.index.is_empty() {
563            return Ok(builder.build());
564        }
565        // Start strictly before t0: when many events share t0 and span more than
566        // TEXT_INDEX_STRIDE, several index entries carry t_us == t0, so `<= t0` would
567        // seek past the earliest ones and drop them. `< t0` lands before them all.
568        let entry = self.index[self
569            .index
570            .partition_point(|e| e.t_us < t0)
571            .saturating_sub(1)];
572        let mut reader = self.reader_at(entry.offset)?;
573        while let Some(event) = reader.next_event()? {
574            if event.t >= t1 {
575                break; // events are time-ordered (checked at open)
576            }
577            if event.t >= t0 {
578                builder.push(event.x, event.y, event.t, event.p);
579            }
580        }
581        Ok(builder.build())
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use std::io::Cursor;
588
589    use super::{load_text, open_text_slice, ColumnOrder, TextOptions, TextReader, TimeUnit};
590    use crate::io::{read_all, IoError, LoadOptions, SliceSource};
591    use crate::EventStream;
592
593    fn read(data: &str, options: TextOptions) -> Result<EventStream, IoError> {
594        read_all(TextReader::new(Cursor::new(data), options).unwrap())
595    }
596
597    #[test]
598    fn parses_txyp_seconds_skips_noise_and_drops_out_of_bounds() {
599        let data = "0.0 1 2 1\n0.000002 3 0 0\n\n# comment\n0.00001 0 4 1\n0.00002 4 0 1\n";
600        let stream = read(data, TextOptions::new(4, 5)).unwrap();
601
602        assert_eq!(stream.len(), 3); // (4, 0) dropped: x == width
603        assert_eq!(stream.xs(), &[1, 3, 0]);
604        assert_eq!(stream.ys(), &[2, 0, 4]);
605        assert_eq!(stream.ts(), &[0, 2, 10]);
606        assert_eq!(stream.ps(), &[true, false, true]);
607        assert_eq!(stream.sensor_size(), (4, 5));
608        assert_eq!(stream.timestamp_scale_ms(), 0.001);
609    }
610
611    #[test]
612    fn supports_xytp_order_and_negative_polarity() {
613        let options = TextOptions {
614            order: ColumnOrder::Xytp,
615            ..TextOptions::new(8, 8)
616        };
617        let stream = read("1 2 0.5 -1\n", options).unwrap();
618
619        assert_eq!(stream.xs(), &[1]);
620        assert_eq!(stream.ys(), &[2]);
621        assert_eq!(stream.ts(), &[500_000]); // 0.5 s -> 500000 us
622        assert_eq!(stream.ps(), &[false]); // -1 -> negative
623    }
624
625    #[test]
626    fn converts_time_units_to_microseconds() {
627        for (unit, raw, expected) in [
628            (TimeUnit::Microseconds, "7", 7_i64),
629            (TimeUnit::Milliseconds, "2", 2_000),
630            (TimeUnit::Nanoseconds, "2400", 2),
631        ] {
632            let data = format!("{raw} 0 0 1\n");
633            let options = TextOptions {
634                time_unit: unit,
635                ..TextOptions::new(4, 4)
636            };
637            assert_eq!(
638                read(&data, options).unwrap().ts(),
639                &[expected],
640                "unit {unit:?}"
641            );
642        }
643    }
644
645    #[test]
646    fn reports_parse_errors_with_line_numbers() {
647        let error = read("0.0 1 2 1\n0.0 nope 2 1\n", TextOptions::new(4, 4)).unwrap_err();
648        match error {
649            IoError::Parse { line, .. } => assert_eq!(line, 2),
650            other => panic!("expected parse error, got {other:?}"),
651        }
652    }
653
654    #[test]
655    fn reports_missing_fields() {
656        let error = read("0.0 1 2\n", TextOptions::new(4, 4)).unwrap_err();
657        assert!(matches!(error, IoError::Parse { line: 1, .. }));
658    }
659
660    #[test]
661    fn rejects_zero_sensor_size() {
662        let error = TextReader::new(Cursor::new(""), TextOptions::new(0, 4)).unwrap_err();
663        assert!(matches!(error, IoError::InvalidSensorSize));
664    }
665
666    #[test]
667    fn empty_input_yields_empty_stream() {
668        let stream = read("\n\n# only comments\n", TextOptions::new(4, 4)).unwrap();
669        assert!(stream.is_empty());
670        assert_eq!(stream.sensor_size(), (4, 4));
671    }
672
673    fn write_temp(tag: &str, contents: &str) -> std::path::PathBuf {
674        let dir = std::env::temp_dir().join(format!("eventcv-{tag}-{}", std::process::id()));
675        std::fs::create_dir_all(&dir).unwrap();
676        let path = dir.join("events.txt");
677        std::fs::write(&path, contents).unwrap();
678        path
679    }
680
681    #[test]
682    fn load_text_infers_size_and_microseconds() {
683        // Integer µs, txyp; coords up to (3, 2) -> 4x3; span 5e6 -> microseconds.
684        let path = write_temp("txtus", "1000000 0 0 1\n3000000 3 1 0\n6000000 1 2 1\n");
685        let stream = load_text(&path, &LoadOptions::default()).unwrap();
686
687        assert_eq!(stream.sensor_size(), (4, 3));
688        assert_eq!(stream.len(), 3); // nothing dropped: size came from the data
689        assert_eq!(stream.ts(), &[1_000_000, 3_000_000, 6_000_000]);
690        std::fs::remove_dir_all(path.parent().unwrap()).ok();
691    }
692
693    #[test]
694    fn load_text_infers_seconds_from_a_fractional_value() {
695        let path = write_temp("txtsec", "0.0 0 0 1\n0.5 1 1 0\n");
696        let stream = load_text(&path, &LoadOptions::default()).unwrap();
697
698        assert_eq!(stream.ts(), &[0, 500_000]); // 0.5 s -> 500000 µs
699        std::fs::remove_dir_all(path.parent().unwrap()).ok();
700    }
701
702    #[test]
703    fn explicit_options_override_inference() {
704        let path = write_temp("txtexp", "7 0 0 1\n");
705        let options = LoadOptions {
706            sensor_size: Some((4, 4)),
707            time_unit: Some(TimeUnit::Microseconds),
708            ..LoadOptions::default()
709        };
710        let stream = load_text(&path, &options).unwrap();
711
712        assert_eq!(stream.sensor_size(), (4, 4));
713        assert_eq!(stream.ts(), &[7]); // explicit µs, not inferred
714        std::fs::remove_dir_all(path.parent().unwrap()).ok();
715    }
716
717    #[test]
718    fn text_slice_source_matches_load() {
719        // 10 events, integer µs t = 0..9000 at distinct pixels (x = 0..9, y = i % 5).
720        let mut data = String::new();
721        for i in 0..10 {
722            data.push_str(&format!("{} {} {} {}\n", i * 1000, i, i % 5, i % 2));
723        }
724        let path = write_temp("txtslice", &data);
725        // Explicit µs so the small integer timestamps aren't inferred as milliseconds.
726        let options = LoadOptions {
727            time_unit: Some(TimeUnit::Microseconds),
728            ..LoadOptions::default()
729        };
730        let source = open_text_slice(&path, &options).unwrap();
731        let full = load_text(&path, &options).unwrap();
732
733        assert_eq!(source.n_events(), full.len());
734        assert_eq!(source.sensor_size(), full.sensor_size()); // (10, 5)
735        assert_eq!(source.time_span(), (0, 9000));
736
737        assert_eq!(
738            source.slice_time(2000, 6000).unwrap().ts(),
739            &[2000, 3000, 4000, 5000]
740        );
741        assert_eq!(
742            source.slice_index(3, 7).unwrap().ts(),
743            &[3000, 4000, 5000, 6000]
744        );
745
746        let whole = source.slice_index(0, source.n_events()).unwrap();
747        assert_eq!(whole.xs(), full.xs());
748        assert_eq!(whole.ts(), full.ts());
749        assert_eq!(whole.ps(), full.ps());
750        std::fs::remove_dir_all(path.parent().unwrap()).ok();
751    }
752
753    #[test]
754    fn text_slice_rejects_unsorted_timestamps() {
755        let path = write_temp("txtunsorted", "0 0 0 1\n5000 1 1 0\n2000 2 2 1\n");
756        match open_text_slice(&path, &LoadOptions::default()) {
757            Err(IoError::Format(message)) => assert!(message.contains("not sorted")),
758            Err(other) => panic!("expected a not-sorted error, got {other:?}"),
759            Ok(_) => panic!("expected unsorted text to be rejected"),
760        }
761        std::fs::remove_dir_all(path.parent().unwrap()).ok();
762    }
763
764    #[test]
765    fn text_slice_keeps_same_timestamp_events_spanning_the_index_stride() {
766        // A single timestamp t = 1000 carries more events than TEXT_INDEX_STRIDE, so the
767        // sparse index holds several entries with t_us == 1000. slice_time(1000, ..) must
768        // seek *before* all of them and keep every event at t == 1000, not just the tail.
769        let dense = super::TEXT_INDEX_STRIDE * 2 + 100;
770        let mut data = String::from("0 0 0 1\n"); // one earlier event
771        for _ in 0..dense {
772            data.push_str("1000 1 1 1\n");
773        }
774        data.push_str("2000 2 2 0\n"); // one later event
775        let path = write_temp("txtdense", &data);
776        let options = LoadOptions {
777            time_unit: Some(TimeUnit::Microseconds),
778            ..LoadOptions::default()
779        };
780        let source = open_text_slice(&path, &options).unwrap();
781
782        assert_eq!(source.slice_time(1000, 2000).unwrap().len(), dense);
783        // Full tiling must recover every event with no drops or duplicates.
784        let tiled = source.slice_time(0, 1000).unwrap().len()
785            + source.slice_time(1000, 2000).unwrap().len()
786            + source.slice_time(2000, 3000).unwrap().len();
787        assert_eq!(tiled, source.n_events());
788        std::fs::remove_dir_all(path.parent().unwrap()).ok();
789    }
790
791    #[test]
792    fn text_slice_empty_file() {
793        let path = write_temp("txtsliceempty", "\n# only a comment\n");
794        let source = open_text_slice(&path, &LoadOptions::default()).unwrap();
795
796        assert_eq!(source.n_events(), 0);
797        assert_eq!(source.time_span(), (0, 0));
798        assert!(source.slice_time(0, 1000).unwrap().is_empty());
799        assert!(source.slice_index(0, 10).unwrap().is_empty());
800        std::fs::remove_dir_all(path.parent().unwrap()).ok();
801    }
802
803    #[test]
804    fn write_text_stream_round_trips_at_event_level() {
805        let mut builder = crate::EventStreamBuilder::new(16, 12, 0.001);
806        for &(x, y, t, p) in &[(0u16, 0u16, 5i64, true), (15, 11, 2_500_000, false)] {
807            builder.push(x, y, t, p);
808        }
809        let stream = builder.build();
810
811        let dir = std::env::temp_dir().join(format!("eventcv-txtrt-{}", std::process::id()));
812        std::fs::create_dir_all(&dir).unwrap();
813        let path = dir.join("events.txt");
814        super::write_text_stream(&path, &stream).unwrap();
815
816        // Loaded back as microseconds with an explicit grid, the events match exactly (txt
817        // carries no metadata header, so size/unit come from options or inference).
818        let options = LoadOptions {
819            sensor_size: Some((16, 12)),
820            time_unit: Some(TimeUnit::Microseconds),
821            ..LoadOptions::default()
822        };
823        let loaded = load_text(&path, &options).unwrap();
824        assert_eq!(loaded.xs(), stream.xs());
825        assert_eq!(loaded.ys(), stream.ys());
826        assert_eq!(loaded.ts(), stream.ts());
827        assert_eq!(loaded.ps(), stream.ps());
828        assert_eq!(loaded.sensor_size(), (16, 12));
829        std::fs::remove_dir_all(&dir).ok();
830    }
831}