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