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