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
12pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum TimeUnit {
38 Seconds,
39 Milliseconds,
40 Microseconds,
41 Nanoseconds,
42}
43
44impl TimeUnit {
45 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 #[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 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 #[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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
144pub enum ColumnOrder {
145 #[default]
147 Txyp,
148 Xytp,
150}
151
152#[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 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
182fn 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
189fn is_header_line(line: &str) -> bool {
191 split_fields(line).any(|field| field.parse::<f64>().is_err())
192}
193
194fn 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
211fn 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
236fn 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 pub map: ColumnMap,
267 pub has_header: bool,
269}
270
271impl TextOptions {
272 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#[derive(Debug)]
288pub struct TextReader<R> {
289 reader: R,
290 options: TextOptions,
291 buffer: String,
292 line: usize,
293 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; }
364 return self.parse_line(trimmed).map(Some);
365 }
366 }
367}
368
369pub 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
377pub fn read_text(path: impl AsRef<Path>, options: TextOptions) -> Result<EventStream, IoError> {
379 read_all(open(path, options)?)
380}
381
382pub struct RawRow {
386 pub x: u16,
387 pub y: u16,
388 pub t: f64,
389 pub p: bool,
390}
391
392pub 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
416fn 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
430pub 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
452fn 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
463fn 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
480fn 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
523const TEXT_INDEX_STRIDE: usize = 4096;
526
527#[derive(Clone, Copy)]
528struct TextIndexEntry {
529 offset: u64,
530 count: usize,
531 t_us: i64,
532}
533
534pub 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
548pub 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; }
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; }
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 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, },
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 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; }
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 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; }
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 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); 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]); assert_eq!(stream.ps(), &[false]); }
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 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); 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]); 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]); std::fs::remove_dir_all(path.parent().unwrap()).ok();
920 }
921
922 #[test]
923 fn text_slice_source_matches_load() {
924 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 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()); 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 let dense = super::TEXT_INDEX_STRIDE * 2 + 100;
975 let mut data = String::from("0 0 0 1\n"); for _ in 0..dense {
977 data.push_str("1000 1 1 1\n");
978 }
979 data.push_str("2000 2 2 0\n"); 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 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 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 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); 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 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 let path = write_temp("txtkeys", "1 2 1000 1\n3 0 2000 0\n"); 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()); 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}