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
33pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub enum TimeUnit {
88 Seconds,
89 Milliseconds,
90 Microseconds,
91 Nanoseconds,
92}
93
94impl TimeUnit {
95 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 #[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 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 #[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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
194pub enum ColumnOrder {
195 #[default]
197 Txyp,
198 Xytp,
200}
201
202#[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 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
232fn 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
239fn is_header_line(line: &str) -> bool {
241 split_fields(line).any(|field| field.parse::<f64>().is_err())
242}
243
244fn 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
261fn 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
286fn 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 pub map: ColumnMap,
317 pub has_header: bool,
319}
320
321impl TextOptions {
322 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#[derive(Debug)]
338pub struct TextReader<R> {
339 reader: R,
340 options: TextOptions,
341 buffer: String,
342 line: usize,
343 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; }
414 return self.parse_line(trimmed).map(Some);
415 }
416 }
417}
418
419pub 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
427pub fn read_text(path: impl AsRef<Path>, options: TextOptions) -> Result<EventStream, IoError> {
429 read_all(open(path, options)?)
430}
431
432pub struct RawRow {
436 pub x: u16,
437 pub y: u16,
438 pub t: f64,
439 pub p: bool,
440}
441
442pub 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
466fn 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
480pub 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
502fn 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
513fn 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
530fn parse_coord(value: &str, field: &str, line: usize) -> Result<u16, IoError> {
537 if let Ok(coord) = value.parse::<u16>() {
538 return Ok(coord); }
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
556fn 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
569fn 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
612const TEXT_INDEX_STRIDE: usize = 4096;
615
616#[derive(Clone, Copy)]
617struct TextIndexEntry {
618 offset: u64,
619 count: usize,
620 t_us: i64,
621}
622
623pub 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
637pub 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; }
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; }
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 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, },
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 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; }
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 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; }
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 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); 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]); assert_eq!(stream.ps(), &[false]); }
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 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); 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]); 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]); std::fs::remove_dir_all(path.parent().unwrap()).ok();
1009 }
1010
1011 #[test]
1012 fn text_slice_source_matches_load() {
1013 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 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()); 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 let dense = super::TEXT_INDEX_STRIDE * 2 + 100;
1064 let mut data = String::from("0 0 0 1\n"); for _ in 0..dense {
1066 data.push_str("1000 1 1 1\n");
1067 }
1068 data.push_str("2000 2 2 0\n"); 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 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 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 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); 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 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 let path = write_temp("txtkeys", "1 2 1000 1\n3 0 2000 0\n"); 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()); 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 #[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 #[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 #[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 #[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}