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`].
246struct RawRow {
247    x: u16,
248    y: u16,
249    t: f64,
250    p: bool,
251}
252
253/// Loads a text file, inferring whichever of `sensor_size` (from the coordinate range)
254/// and `time_unit` (fractional value ⇒ seconds, else the span magnitude) the caller
255/// left unset. A fully-specified load streams without buffering; inference reads the
256/// rows once into memory.
257pub fn load_text(path: impl AsRef<Path>, options: &LoadOptions) -> Result<EventStream, IoError> {
258    if let (Some((width, height)), Some(time_unit)) = (options.sensor_size, options.time_unit) {
259        let text_options = TextOptions {
260            width,
261            height,
262            time_unit,
263            order: options.order,
264        };
265        return read_capped(open(path.as_ref(), text_options)?, options.max_events);
266    }
267
268    let rows = read_raw_rows(path.as_ref(), options.order)?;
269    let (width, height) = options
270        .sensor_size
271        .unwrap_or_else(|| infer_sensor_size(&rows));
272    let time_unit = options.time_unit.unwrap_or_else(|| infer_time_unit(&rows));
273    if width == 0 || height == 0 {
274        return Err(IoError::InvalidSensorSize);
275    }
276
277    let mut builder = EventStreamBuilder::new(width, height, 0.001);
278    for row in &rows {
279        builder.push(row.x, row.y, time_unit.to_microseconds(row.t), row.p);
280        if options.max_events.is_some_and(|max| builder.len() >= max) {
281            break;
282        }
283    }
284    Ok(builder.build())
285}
286
287/// Smallest sensor that holds every event: `(max_x + 1, max_y + 1)`, or `(1, 1)` when
288/// there are no events.
289fn infer_sensor_size(rows: &[RawRow]) -> (usize, usize) {
290    let width = rows.iter().map(|row| usize::from(row.x)).max();
291    let height = rows.iter().map(|row| usize::from(row.y)).max();
292    match (width, height) {
293        (Some(width), Some(height)) => (width + 1, height + 1),
294        _ => (1, 1),
295    }
296}
297
298/// A fractional timestamp means seconds; otherwise pick the unit from the span.
299fn infer_time_unit(rows: &[RawRow]) -> TimeUnit {
300    if rows.iter().any(|row| row.t.fract() != 0.0) {
301        return TimeUnit::Seconds;
302    }
303    let min = rows.iter().map(|row| row.t).fold(f64::INFINITY, f64::min);
304    let max = rows
305        .iter()
306        .map(|row| row.t)
307        .fold(f64::NEG_INFINITY, f64::max);
308    if min.is_finite() {
309        TimeUnit::infer_from_span((max - min) as i64)
310    } else {
311        TimeUnit::Seconds
312    }
313}
314
315/// Parses one non-blank line into a [`RawRow`] (no unit conversion or bounds check).
316/// Shared by the buffered [`load_text`] and the [`TextSliceSource`] index scan.
317fn parse_raw_row(trimmed: &str, order: ColumnOrder, number: usize) -> Result<RawRow, IoError> {
318    let mut fields = trimmed.split_whitespace();
319    let mut next = |name: &str| {
320        fields.next().ok_or(IoError::Parse {
321            line: number,
322            message: format!("missing {name}"),
323        })
324    };
325    let (t, x, y, p) = match order {
326        ColumnOrder::Txyp => {
327            let t = next("t")?;
328            (t, next("x")?, next("y")?, next("p")?)
329        }
330        ColumnOrder::Xytp => {
331            let x = next("x")?;
332            let y = next("y")?;
333            (next("t")?, x, y, next("p")?)
334        }
335    };
336    let parse = |value: &str, field: &str| {
337        value.parse::<f64>().map_err(|_| IoError::Parse {
338            line: number,
339            message: format!("invalid {field}: {value:?}"),
340        })
341    };
342    Ok(RawRow {
343        x: parse(x, "x")? as u16,
344        y: parse(y, "y")? as u16,
345        t: parse(t, "t")?,
346        p: parse(p, "p")? > 0.0,
347    })
348}
349
350fn read_raw_rows(path: &Path, order: ColumnOrder) -> Result<Vec<RawRow>, IoError> {
351    let reader = BufReader::new(File::open(path)?);
352    let mut rows = Vec::new();
353    for (index, line) in reader.lines().enumerate() {
354        let line = line?;
355        let trimmed = line.trim();
356        if trimmed.is_empty() || trimmed.starts_with('#') {
357            continue;
358        }
359        rows.push(parse_raw_row(trimmed, order, index + 1)?);
360    }
361    Ok(rows)
362}
363
364/// Number of events between sparse index samples — a slice reads at most this many extra
365/// rows past a sample before reaching its window.
366const TEXT_INDEX_STRIDE: usize = 4096;
367
368#[derive(Clone, Copy)]
369struct TextIndexEntry {
370    offset: u64,
371    count: usize,
372    t_us: i64,
373}
374
375/// In-place [`SliceSource`] for text files. Text isn't seekable by content, so `open`
376/// scans once to build a sparse `(byte offset, event count, timestamp)` index (one entry
377/// per [`TEXT_INDEX_STRIDE`] events); slices binary-search it, seek the file, and parse
378/// forward. Bounded memory; assumes events are time-ordered (errors otherwise).
379pub struct TextSliceSource {
380    path: PathBuf,
381    order: ColumnOrder,
382    time_unit: TimeUnit,
383    sensor: (usize, usize),
384    total: usize,
385    span_us: (i64, i64),
386    index: Vec<TextIndexEntry>,
387}
388
389/// Scans the file once to build a `TextSliceSource`, inferring `sensor_size`/`time_unit`
390/// when unset exactly as `load_text` does, and dropping out-of-bounds events (when the
391/// size is explicit) so the index counts the same events `load` would keep.
392pub fn open_text_slice(
393    path: impl AsRef<Path>,
394    options: &LoadOptions,
395) -> Result<TextSliceSource, IoError> {
396    let path = path.as_ref();
397    let mut reader = BufReader::new(File::open(path)?);
398    let mut buffer = String::new();
399    let mut offset = 0u64;
400    let mut line_no = 0usize;
401    let mut kept = 0usize;
402    let mut samples: Vec<(u64, usize, f64)> = Vec::new();
403    let (mut max_x, mut max_y) = (0u16, 0u16);
404    let (mut min_t, mut max_t) = (f64::INFINITY, f64::NEG_INFINITY);
405    let mut fractional = false;
406    let mut sorted = true;
407    let mut previous_t = f64::NEG_INFINITY;
408
409    loop {
410        buffer.clear();
411        let line_start = offset;
412        let bytes = reader.read_line(&mut buffer)?;
413        if bytes == 0 {
414            break;
415        }
416        offset += bytes as u64;
417        line_no += 1;
418        let trimmed = buffer.trim();
419        if trimmed.is_empty() || trimmed.starts_with('#') {
420            continue;
421        }
422        let row = parse_raw_row(trimmed, options.order, line_no)?;
423        if let Some((width, height)) = options.sensor_size {
424            if usize::from(row.x) >= width || usize::from(row.y) >= height {
425                continue; // matches the OOB drop a load with this size would do
426            }
427        }
428        if kept.is_multiple_of(TEXT_INDEX_STRIDE) {
429            samples.push((line_start, kept, row.t));
430        }
431        max_x = max_x.max(row.x);
432        max_y = max_y.max(row.y);
433        min_t = min_t.min(row.t);
434        max_t = max_t.max(row.t);
435        fractional |= row.t.fract() != 0.0;
436        sorted &= row.t >= previous_t;
437        previous_t = row.t;
438        kept += 1;
439    }
440
441    let sensor = options
442        .sensor_size
443        .unwrap_or((usize::from(max_x) + 1, usize::from(max_y) + 1));
444    if sensor.0 == 0 || sensor.1 == 0 {
445        return Err(IoError::InvalidSensorSize);
446    }
447    if !sorted {
448        return Err(IoError::Format(
449            "text timestamps are not sorted; in-place slicing requires time-ordered events"
450                .to_owned(),
451        ));
452    }
453    let time_unit = options.time_unit.unwrap_or_else(|| {
454        if fractional || !min_t.is_finite() {
455            TimeUnit::Seconds
456        } else {
457            TimeUnit::infer_from_span((max_t - min_t) as i64)
458        }
459    });
460
461    let index = samples
462        .into_iter()
463        .map(|(offset, count, t)| TextIndexEntry {
464            offset,
465            count,
466            t_us: time_unit.to_microseconds(t),
467        })
468        .collect();
469    let span_us = if kept == 0 {
470        (0, 0)
471    } else {
472        (
473            time_unit.to_microseconds(min_t),
474            time_unit.to_microseconds(max_t),
475        )
476    };
477    Ok(TextSliceSource {
478        path: path.to_path_buf(),
479        order: options.order,
480        time_unit,
481        sensor,
482        total: kept,
483        span_us,
484        index,
485    })
486}
487
488impl TextSliceSource {
489    /// Opens the file again seeked to `offset`, wrapped in a [`TextReader`] for parsing.
490    fn reader_at(&self, offset: u64) -> Result<TextReader<BufReader<File>>, IoError> {
491        let mut file = File::open(&self.path)?;
492        file.seek(SeekFrom::Start(offset))?;
493        TextReader::new(
494            BufReader::new(file),
495            TextOptions {
496                width: self.sensor.0,
497                height: self.sensor.1,
498                time_unit: self.time_unit,
499                order: self.order,
500            },
501        )
502    }
503
504    fn keeps(&self, x: u16, y: u16) -> bool {
505        usize::from(x) < self.sensor.0 && usize::from(y) < self.sensor.1
506    }
507}
508
509impl SliceSource for TextSliceSource {
510    fn sensor_size(&self) -> (usize, usize) {
511        self.sensor
512    }
513
514    fn timestamp_scale_ms(&self) -> f64 {
515        0.001
516    }
517
518    fn n_events(&self) -> usize {
519        self.total
520    }
521
522    fn time_span(&self) -> (i64, i64) {
523        self.span_us
524    }
525
526    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
527        let i0 = i0.min(self.total);
528        let i1 = i1.clamp(i0, self.total);
529        let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
530        if i0 == i1 || self.index.is_empty() {
531            return Ok(builder.build());
532        }
533        // index[0].count == 0, so partition_point is >= 1 and the subtraction is safe.
534        let entry = self.index[self.index.partition_point(|e| e.count <= i0) - 1];
535        let mut reader = self.reader_at(entry.offset)?;
536        let mut index = entry.count;
537        while index < i1 {
538            let Some(event) = reader.next_event()? else {
539                break;
540            };
541            if !self.keeps(event.x, event.y) {
542                continue; // dropped, not counted — keeps indices aligned with `load`
543            }
544            if index >= i0 {
545                builder.push(event.x, event.y, event.t, event.p);
546            }
547            index += 1;
548        }
549        Ok(builder.build())
550    }
551
552    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
553        let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
554        if self.index.is_empty() {
555            return Ok(builder.build());
556        }
557        // Start strictly before t0: when many events share t0 and span more than
558        // TEXT_INDEX_STRIDE, several index entries carry t_us == t0, so `<= t0` would
559        // seek past the earliest ones and drop them. `< t0` lands before them all.
560        let entry = self.index[self
561            .index
562            .partition_point(|e| e.t_us < t0)
563            .saturating_sub(1)];
564        let mut reader = self.reader_at(entry.offset)?;
565        while let Some(event) = reader.next_event()? {
566            if event.t >= t1 {
567                break; // events are time-ordered (checked at open)
568            }
569            if event.t >= t0 {
570                builder.push(event.x, event.y, event.t, event.p);
571            }
572        }
573        Ok(builder.build())
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use std::io::Cursor;
580
581    use super::{load_text, open_text_slice, ColumnOrder, TextOptions, TextReader, TimeUnit};
582    use crate::io::{read_all, IoError, LoadOptions, SliceSource};
583    use crate::EventStream;
584
585    fn read(data: &str, options: TextOptions) -> Result<EventStream, IoError> {
586        read_all(TextReader::new(Cursor::new(data), options).unwrap())
587    }
588
589    #[test]
590    fn parses_txyp_seconds_skips_noise_and_drops_out_of_bounds() {
591        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";
592        let stream = read(data, TextOptions::new(4, 5)).unwrap();
593
594        assert_eq!(stream.len(), 3); // (4, 0) dropped: x == width
595        assert_eq!(stream.xs(), &[1, 3, 0]);
596        assert_eq!(stream.ys(), &[2, 0, 4]);
597        assert_eq!(stream.ts(), &[0, 2, 10]);
598        assert_eq!(stream.ps(), &[true, false, true]);
599        assert_eq!(stream.sensor_size(), (4, 5));
600        assert_eq!(stream.timestamp_scale_ms(), 0.001);
601    }
602
603    #[test]
604    fn supports_xytp_order_and_negative_polarity() {
605        let options = TextOptions {
606            order: ColumnOrder::Xytp,
607            ..TextOptions::new(8, 8)
608        };
609        let stream = read("1 2 0.5 -1\n", options).unwrap();
610
611        assert_eq!(stream.xs(), &[1]);
612        assert_eq!(stream.ys(), &[2]);
613        assert_eq!(stream.ts(), &[500_000]); // 0.5 s -> 500000 us
614        assert_eq!(stream.ps(), &[false]); // -1 -> negative
615    }
616
617    #[test]
618    fn converts_time_units_to_microseconds() {
619        for (unit, raw, expected) in [
620            (TimeUnit::Microseconds, "7", 7_i64),
621            (TimeUnit::Milliseconds, "2", 2_000),
622            (TimeUnit::Nanoseconds, "2400", 2),
623        ] {
624            let data = format!("{raw} 0 0 1\n");
625            let options = TextOptions {
626                time_unit: unit,
627                ..TextOptions::new(4, 4)
628            };
629            assert_eq!(
630                read(&data, options).unwrap().ts(),
631                &[expected],
632                "unit {unit:?}"
633            );
634        }
635    }
636
637    #[test]
638    fn reports_parse_errors_with_line_numbers() {
639        let error = read("0.0 1 2 1\n0.0 nope 2 1\n", TextOptions::new(4, 4)).unwrap_err();
640        match error {
641            IoError::Parse { line, .. } => assert_eq!(line, 2),
642            other => panic!("expected parse error, got {other:?}"),
643        }
644    }
645
646    #[test]
647    fn reports_missing_fields() {
648        let error = read("0.0 1 2\n", TextOptions::new(4, 4)).unwrap_err();
649        assert!(matches!(error, IoError::Parse { line: 1, .. }));
650    }
651
652    #[test]
653    fn rejects_zero_sensor_size() {
654        let error = TextReader::new(Cursor::new(""), TextOptions::new(0, 4)).unwrap_err();
655        assert!(matches!(error, IoError::InvalidSensorSize));
656    }
657
658    #[test]
659    fn empty_input_yields_empty_stream() {
660        let stream = read("\n\n# only comments\n", TextOptions::new(4, 4)).unwrap();
661        assert!(stream.is_empty());
662        assert_eq!(stream.sensor_size(), (4, 4));
663    }
664
665    fn write_temp(tag: &str, contents: &str) -> std::path::PathBuf {
666        let dir = std::env::temp_dir().join(format!("eventcv-{tag}-{}", std::process::id()));
667        std::fs::create_dir_all(&dir).unwrap();
668        let path = dir.join("events.txt");
669        std::fs::write(&path, contents).unwrap();
670        path
671    }
672
673    #[test]
674    fn load_text_infers_size_and_microseconds() {
675        // Integer µs, txyp; coords up to (3, 2) -> 4x3; span 5e6 -> microseconds.
676        let path = write_temp("txtus", "1000000 0 0 1\n3000000 3 1 0\n6000000 1 2 1\n");
677        let stream = load_text(&path, &LoadOptions::default()).unwrap();
678
679        assert_eq!(stream.sensor_size(), (4, 3));
680        assert_eq!(stream.len(), 3); // nothing dropped: size came from the data
681        assert_eq!(stream.ts(), &[1_000_000, 3_000_000, 6_000_000]);
682        std::fs::remove_dir_all(path.parent().unwrap()).ok();
683    }
684
685    #[test]
686    fn load_text_infers_seconds_from_a_fractional_value() {
687        let path = write_temp("txtsec", "0.0 0 0 1\n0.5 1 1 0\n");
688        let stream = load_text(&path, &LoadOptions::default()).unwrap();
689
690        assert_eq!(stream.ts(), &[0, 500_000]); // 0.5 s -> 500000 µs
691        std::fs::remove_dir_all(path.parent().unwrap()).ok();
692    }
693
694    #[test]
695    fn explicit_options_override_inference() {
696        let path = write_temp("txtexp", "7 0 0 1\n");
697        let options = LoadOptions {
698            sensor_size: Some((4, 4)),
699            time_unit: Some(TimeUnit::Microseconds),
700            ..LoadOptions::default()
701        };
702        let stream = load_text(&path, &options).unwrap();
703
704        assert_eq!(stream.sensor_size(), (4, 4));
705        assert_eq!(stream.ts(), &[7]); // explicit µs, not inferred
706        std::fs::remove_dir_all(path.parent().unwrap()).ok();
707    }
708
709    #[test]
710    fn text_slice_source_matches_load() {
711        // 10 events, integer µs t = 0..9000 at distinct pixels (x = 0..9, y = i % 5).
712        let mut data = String::new();
713        for i in 0..10 {
714            data.push_str(&format!("{} {} {} {}\n", i * 1000, i, i % 5, i % 2));
715        }
716        let path = write_temp("txtslice", &data);
717        // Explicit µs so the small integer timestamps aren't inferred as milliseconds.
718        let options = LoadOptions {
719            time_unit: Some(TimeUnit::Microseconds),
720            ..LoadOptions::default()
721        };
722        let source = open_text_slice(&path, &options).unwrap();
723        let full = load_text(&path, &options).unwrap();
724
725        assert_eq!(source.n_events(), full.len());
726        assert_eq!(source.sensor_size(), full.sensor_size()); // (10, 5)
727        assert_eq!(source.time_span(), (0, 9000));
728
729        assert_eq!(
730            source.slice_time(2000, 6000).unwrap().ts(),
731            &[2000, 3000, 4000, 5000]
732        );
733        assert_eq!(
734            source.slice_index(3, 7).unwrap().ts(),
735            &[3000, 4000, 5000, 6000]
736        );
737
738        let whole = source.slice_index(0, source.n_events()).unwrap();
739        assert_eq!(whole.xs(), full.xs());
740        assert_eq!(whole.ts(), full.ts());
741        assert_eq!(whole.ps(), full.ps());
742        std::fs::remove_dir_all(path.parent().unwrap()).ok();
743    }
744
745    #[test]
746    fn text_slice_rejects_unsorted_timestamps() {
747        let path = write_temp("txtunsorted", "0 0 0 1\n5000 1 1 0\n2000 2 2 1\n");
748        match open_text_slice(&path, &LoadOptions::default()) {
749            Err(IoError::Format(message)) => assert!(message.contains("not sorted")),
750            Err(other) => panic!("expected a not-sorted error, got {other:?}"),
751            Ok(_) => panic!("expected unsorted text to be rejected"),
752        }
753        std::fs::remove_dir_all(path.parent().unwrap()).ok();
754    }
755
756    #[test]
757    fn text_slice_keeps_same_timestamp_events_spanning_the_index_stride() {
758        // A single timestamp t = 1000 carries more events than TEXT_INDEX_STRIDE, so the
759        // sparse index holds several entries with t_us == 1000. slice_time(1000, ..) must
760        // seek *before* all of them and keep every event at t == 1000, not just the tail.
761        let dense = super::TEXT_INDEX_STRIDE * 2 + 100;
762        let mut data = String::from("0 0 0 1\n"); // one earlier event
763        for _ in 0..dense {
764            data.push_str("1000 1 1 1\n");
765        }
766        data.push_str("2000 2 2 0\n"); // one later event
767        let path = write_temp("txtdense", &data);
768        let options = LoadOptions {
769            time_unit: Some(TimeUnit::Microseconds),
770            ..LoadOptions::default()
771        };
772        let source = open_text_slice(&path, &options).unwrap();
773
774        assert_eq!(source.slice_time(1000, 2000).unwrap().len(), dense);
775        // Full tiling must recover every event with no drops or duplicates.
776        let tiled = source.slice_time(0, 1000).unwrap().len()
777            + source.slice_time(1000, 2000).unwrap().len()
778            + source.slice_time(2000, 3000).unwrap().len();
779        assert_eq!(tiled, source.n_events());
780        std::fs::remove_dir_all(path.parent().unwrap()).ok();
781    }
782
783    #[test]
784    fn text_slice_empty_file() {
785        let path = write_temp("txtsliceempty", "\n# only a comment\n");
786        let source = open_text_slice(&path, &LoadOptions::default()).unwrap();
787
788        assert_eq!(source.n_events(), 0);
789        assert_eq!(source.time_span(), (0, 0));
790        assert!(source.slice_time(0, 1000).unwrap().is_empty());
791        assert!(source.slice_index(0, 10).unwrap().is_empty());
792        std::fs::remove_dir_all(path.parent().unwrap()).ok();
793    }
794
795    #[test]
796    fn write_text_stream_round_trips_at_event_level() {
797        let mut builder = crate::EventStreamBuilder::new(16, 12, 0.001);
798        for &(x, y, t, p) in &[(0u16, 0u16, 5i64, true), (15, 11, 2_500_000, false)] {
799            builder.push(x, y, t, p);
800        }
801        let stream = builder.build();
802
803        let dir = std::env::temp_dir().join(format!("eventcv-txtrt-{}", std::process::id()));
804        std::fs::create_dir_all(&dir).unwrap();
805        let path = dir.join("events.txt");
806        super::write_text_stream(&path, &stream).unwrap();
807
808        // Loaded back as microseconds with an explicit grid, the events match exactly (txt
809        // carries no metadata header, so size/unit come from options or inference).
810        let options = LoadOptions {
811            sensor_size: Some((16, 12)),
812            time_unit: Some(TimeUnit::Microseconds),
813            ..LoadOptions::default()
814        };
815        let loaded = load_text(&path, &options).unwrap();
816        assert_eq!(loaded.xs(), stream.xs());
817        assert_eq!(loaded.ys(), stream.ys());
818        assert_eq!(loaded.ts(), stream.ts());
819        assert_eq!(loaded.ps(), stream.ps());
820        assert_eq!(loaded.sensor_size(), (16, 12));
821        std::fs::remove_dir_all(&dir).ok();
822    }
823}