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