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 fn to_microseconds(self, value: f64) -> i64 {
46 let microseconds = match self {
47 Self::Seconds => value * 1e6,
48 Self::Milliseconds => value * 1e3,
49 Self::Microseconds => value,
50 Self::Nanoseconds => value / 1e3,
51 };
52 microseconds.round() as i64
53 }
54
55 #[cfg_attr(not(feature = "hdf5"), allow(dead_code))]
59 pub(crate) fn from_scale_ms(scale_ms: f64) -> Option<TimeUnit> {
60 for (unit, expected) in [
61 (TimeUnit::Nanoseconds, 1e-6),
62 (TimeUnit::Microseconds, 1e-3),
63 (TimeUnit::Milliseconds, 1.0),
64 (TimeUnit::Seconds, 1e3),
65 ] {
66 if (scale_ms - expected).abs() <= expected * 1e-9 {
67 return Some(unit);
68 }
69 }
70 None
71 }
72
73 pub(crate) fn infer_from_span(span: i64) -> TimeUnit {
79 let span = span.max(0) as f64;
80 if span * 1e-9 >= 1.0 {
81 TimeUnit::Nanoseconds
82 } else if span * 1e-6 >= 1.0 {
83 TimeUnit::Microseconds
84 } else if span * 1e-3 >= 1.0 {
85 TimeUnit::Milliseconds
86 } else {
87 TimeUnit::Seconds
88 }
89 }
90
91 #[cfg(feature = "hdf5")]
94 pub(crate) fn microseconds_from_int(self, value: i64) -> i64 {
95 let value = i128::from(value);
96 let microseconds = match self {
97 Self::Seconds => value * 1_000_000,
98 Self::Milliseconds => value * 1_000,
99 Self::Microseconds => value,
100 Self::Nanoseconds => value / 1_000,
101 };
102 microseconds.clamp(i64::MIN as i128, i64::MAX as i128) as i64
103 }
104}
105
106#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
108pub enum ColumnOrder {
109 #[default]
111 Txyp,
112 Xytp,
114}
115
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub struct ColumnMap {
120 pub t: usize,
121 pub x: usize,
122 pub y: usize,
123 pub p: usize,
124}
125
126impl ColumnMap {
127 fn from_order(order: ColumnOrder) -> Self {
129 match order {
130 ColumnOrder::Txyp => Self {
131 t: 0,
132 x: 1,
133 y: 2,
134 p: 3,
135 },
136 ColumnOrder::Xytp => Self {
137 x: 0,
138 y: 1,
139 t: 2,
140 p: 3,
141 },
142 }
143 }
144}
145
146fn split_fields(line: &str) -> impl Iterator<Item = &str> {
149 line.split(|c: char| c == ',' || c.is_whitespace())
150 .filter(|field| !field.is_empty())
151}
152
153fn is_header_line(line: &str) -> bool {
155 split_fields(line).any(|field| field.parse::<f64>().is_err())
156}
157
158fn header_map(line: &str) -> Option<ColumnMap> {
161 let mut indices = [None; 4];
162 for (index, field) in split_fields(line).enumerate() {
163 if let Some(role) = role_of(field) {
164 indices[role].get_or_insert(index);
165 }
166 }
167 Some(ColumnMap {
168 x: indices[X]?,
169 y: indices[Y]?,
170 t: indices[T]?,
171 p: indices[P]?,
172 })
173}
174
175fn keys_map(keys: &EventKeys, header: Option<&str>) -> Result<ColumnMap, IoError> {
177 let resolve = |value: &str, field: &str| -> Result<usize, IoError> {
178 if let Ok(index) = value.parse::<usize>() {
179 return Ok(index);
180 }
181 if let Some(names) = header {
182 if let Some(index) =
183 split_fields(names).position(|name| name.eq_ignore_ascii_case(value))
184 {
185 return Ok(index);
186 }
187 }
188 Err(IoError::Format(format!(
189 "text key for '{field}' ({value:?}) is neither a column index nor a header name"
190 )))
191 };
192 Ok(ColumnMap {
193 x: resolve(&keys.x, "x")?,
194 y: resolve(&keys.y, "y")?,
195 t: resolve(&keys.t, "t")?,
196 p: resolve(&keys.p, "p")?,
197 })
198}
199
200fn resolve_column_map(
204 first_line: Option<&str>,
205 order: ColumnOrder,
206 keys: Option<&EventKeys>,
207) -> Result<(ColumnMap, bool), IoError> {
208 let has_header = first_line.is_some_and(is_header_line);
209 let header = has_header.then(|| first_line.unwrap());
210 let map = match keys {
211 Some(keys) => keys_map(keys, header)?,
212 None if has_header => header_map(header.unwrap()).ok_or_else(|| {
213 IoError::Format(format!(
214 "text header {:?} does not name all of x/y/t/p; pass order= or keys= to map the \
215 columns",
216 header.unwrap()
217 ))
218 })?,
219 None => ColumnMap::from_order(order),
220 };
221 Ok((map, has_header))
222}
223
224#[derive(Clone, Copy, Debug)]
225pub struct TextOptions {
226 pub width: usize,
227 pub height: usize,
228 pub time_unit: TimeUnit,
229 pub map: ColumnMap,
231 pub has_header: bool,
233}
234
235impl TextOptions {
236 pub fn new(width: usize, height: usize) -> Self {
238 Self {
239 width,
240 height,
241 time_unit: TimeUnit::Seconds,
242 map: ColumnMap::from_order(ColumnOrder::Txyp),
243 has_header: false,
244 }
245 }
246}
247
248#[derive(Debug)]
252pub struct TextReader<R> {
253 reader: R,
254 options: TextOptions,
255 buffer: String,
256 line: usize,
257 header_pending: bool,
260}
261
262impl<R: BufRead> TextReader<R> {
263 pub fn new(reader: R, options: TextOptions) -> Result<Self, IoError> {
264 if options.width == 0 || options.height == 0 {
265 return Err(IoError::InvalidSensorSize);
266 }
267 Ok(Self {
268 reader,
269 header_pending: options.has_header,
270 options,
271 buffer: String::new(),
272 line: 0,
273 })
274 }
275
276 fn parse_line(&self, line: &str) -> Result<RawEvent, IoError> {
277 let fields: Vec<&str> = split_fields(line).collect();
278 let map = self.options.map;
279 let pick = |index: usize, name: &str| -> Result<&str, IoError> {
280 fields.get(index).copied().ok_or_else(|| IoError::Parse {
281 line: self.line,
282 message: format!("missing {name}"),
283 })
284 };
285 Ok(RawEvent {
286 x: self.parse(pick(map.x, "x")?, "x")?,
287 y: self.parse(pick(map.y, "y")?, "y")?,
288 t: self
289 .options
290 .time_unit
291 .to_microseconds(self.parse::<f64>(pick(map.t, "t")?, "t")?),
292 p: self.parse::<i32>(pick(map.p, "p")?, "p")? > 0,
293 })
294 }
295
296 fn parse<T: FromStr>(&self, value: &str, field: &str) -> Result<T, IoError> {
297 value.parse().map_err(|_| IoError::Parse {
298 line: self.line,
299 message: format!("invalid {field}: {value:?}"),
300 })
301 }
302}
303
304impl<R: BufRead> EventSource for TextReader<R> {
305 fn sensor_size(&self) -> (usize, usize) {
306 (self.options.width, self.options.height)
307 }
308
309 fn timestamp_scale_ms(&self) -> f64 {
310 0.001
311 }
312
313 fn next_event(&mut self) -> Result<Option<RawEvent>, IoError> {
314 loop {
315 self.buffer.clear();
316 self.line += 1;
317 if self.reader.read_line(&mut self.buffer)? == 0 {
318 return Ok(None);
319 }
320 let trimmed = self.buffer.trim();
321 if trimmed.is_empty() || trimmed.starts_with('#') {
322 continue;
323 }
324 if self.header_pending {
325 self.header_pending = false;
326 continue; }
328 return self.parse_line(trimmed).map(Some);
329 }
330 }
331}
332
333pub fn open(
335 path: impl AsRef<Path>,
336 options: TextOptions,
337) -> Result<TextReader<BufReader<File>>, IoError> {
338 TextReader::new(BufReader::new(File::open(path)?), options)
339}
340
341pub fn read_text(path: impl AsRef<Path>, options: TextOptions) -> Result<EventStream, IoError> {
343 read_all(open(path, options)?)
344}
345
346pub struct RawRow {
350 pub x: u16,
351 pub y: u16,
352 pub t: f64,
353 pub p: bool,
354}
355
356pub fn load_text(path: impl AsRef<Path>, options: &LoadOptions) -> Result<EventStream, IoError> {
361 let path = path.as_ref();
362 let first_line = peek_first_line(path)?;
363 let (map, has_header) =
364 resolve_column_map(first_line.as_deref(), options.order, options.keys.as_ref())?;
365 if let (Some((width, height)), Some(time_unit)) = (options.sensor_size, options.time_unit) {
366 let text_options = TextOptions {
367 width,
368 height,
369 time_unit,
370 map,
371 has_header,
372 };
373 return read_capped(open(path, text_options)?, options.max_events);
374 }
375
376 let rows = read_raw_rows(path, map, has_header)?;
377 load_rows(&rows, options)
378}
379
380fn peek_first_line(path: &Path) -> Result<Option<String>, IoError> {
383 let reader = BufReader::new(File::open(path)?);
384 for line in reader.lines() {
385 let line = line?;
386 let trimmed = line.trim();
387 if !trimmed.is_empty() && !trimmed.starts_with('#') {
388 return Ok(Some(trimmed.to_owned()));
389 }
390 }
391 Ok(None)
392}
393
394pub fn load_rows(rows: &[RawRow], options: &LoadOptions) -> Result<EventStream, IoError> {
398 let (width, height) = options
399 .sensor_size
400 .unwrap_or_else(|| infer_sensor_size(rows));
401 let time_unit = options.time_unit.unwrap_or_else(|| infer_time_unit(rows));
402 if width == 0 || height == 0 {
403 return Err(IoError::InvalidSensorSize);
404 }
405
406 let mut builder = EventStreamBuilder::new(width, height, 0.001);
407 for row in rows {
408 builder.push(row.x, row.y, time_unit.to_microseconds(row.t), row.p);
409 if options.max_events.is_some_and(|max| builder.len() >= max) {
410 break;
411 }
412 }
413 Ok(builder.build())
414}
415
416fn infer_sensor_size(rows: &[RawRow]) -> (usize, usize) {
419 let width = rows.iter().map(|row| usize::from(row.x)).max();
420 let height = rows.iter().map(|row| usize::from(row.y)).max();
421 match (width, height) {
422 (Some(width), Some(height)) => (width + 1, height + 1),
423 _ => (1, 1),
424 }
425}
426
427fn infer_time_unit(rows: &[RawRow]) -> TimeUnit {
429 if rows.iter().any(|row| row.t.fract() != 0.0) {
430 return TimeUnit::Seconds;
431 }
432 let min = rows.iter().map(|row| row.t).fold(f64::INFINITY, f64::min);
433 let max = rows
434 .iter()
435 .map(|row| row.t)
436 .fold(f64::NEG_INFINITY, f64::max);
437 if min.is_finite() {
438 TimeUnit::infer_from_span((max - min) as i64)
439 } else {
440 TimeUnit::Seconds
441 }
442}
443
444fn parse_raw_row(trimmed: &str, map: ColumnMap, number: usize) -> Result<RawRow, IoError> {
447 let fields: Vec<&str> = split_fields(trimmed).collect();
448 let pick = |index: usize, name: &str| -> Result<&str, IoError> {
449 fields.get(index).copied().ok_or(IoError::Parse {
450 line: number,
451 message: format!("missing {name}"),
452 })
453 };
454 let parse = |value: &str, field: &str| {
455 value.parse::<f64>().map_err(|_| IoError::Parse {
456 line: number,
457 message: format!("invalid {field}: {value:?}"),
458 })
459 };
460 Ok(RawRow {
461 x: parse(pick(map.x, "x")?, "x")? as u16,
462 y: parse(pick(map.y, "y")?, "y")? as u16,
463 t: parse(pick(map.t, "t")?, "t")?,
464 p: parse(pick(map.p, "p")?, "p")? > 0.0,
465 })
466}
467
468fn read_raw_rows(path: &Path, map: ColumnMap, has_header: bool) -> Result<Vec<RawRow>, IoError> {
469 let reader = BufReader::new(File::open(path)?);
470 let mut rows = Vec::new();
471 let mut header_pending = has_header;
472 for (index, line) in reader.lines().enumerate() {
473 let line = line?;
474 let trimmed = line.trim();
475 if trimmed.is_empty() || trimmed.starts_with('#') {
476 continue;
477 }
478 if header_pending {
479 header_pending = false;
480 continue;
481 }
482 rows.push(parse_raw_row(trimmed, map, index + 1)?);
483 }
484 Ok(rows)
485}
486
487const TEXT_INDEX_STRIDE: usize = 4096;
490
491#[derive(Clone, Copy)]
492struct TextIndexEntry {
493 offset: u64,
494 count: usize,
495 t_us: i64,
496}
497
498pub struct TextSliceSource {
503 path: PathBuf,
504 map: ColumnMap,
505 time_unit: TimeUnit,
506 sensor: (usize, usize),
507 total: usize,
508 span_us: (i64, i64),
509 index: Vec<TextIndexEntry>,
510}
511
512pub fn open_text_slice(
516 path: impl AsRef<Path>,
517 options: &LoadOptions,
518) -> Result<TextSliceSource, IoError> {
519 let path = path.as_ref();
520 let (map, has_header) = resolve_column_map(
521 peek_first_line(path)?.as_deref(),
522 options.order,
523 options.keys.as_ref(),
524 )?;
525 let mut reader = BufReader::new(File::open(path)?);
526 let mut buffer = String::new();
527 let mut offset = 0u64;
528 let mut line_no = 0usize;
529 let mut kept = 0usize;
530 let mut header_pending = has_header;
531 let mut samples: Vec<(u64, usize, f64)> = Vec::new();
532 let (mut max_x, mut max_y) = (0u16, 0u16);
533 let (mut min_t, mut max_t) = (f64::INFINITY, f64::NEG_INFINITY);
534 let mut fractional = false;
535 let mut sorted = true;
536 let mut previous_t = f64::NEG_INFINITY;
537
538 loop {
539 buffer.clear();
540 let line_start = offset;
541 let bytes = reader.read_line(&mut buffer)?;
542 if bytes == 0 {
543 break;
544 }
545 offset += bytes as u64;
546 line_no += 1;
547 let trimmed = buffer.trim();
548 if trimmed.is_empty() || trimmed.starts_with('#') {
549 continue;
550 }
551 if header_pending {
552 header_pending = false;
553 continue; }
555 let row = parse_raw_row(trimmed, map, line_no)?;
556 if let Some((width, height)) = options.sensor_size {
557 if usize::from(row.x) >= width || usize::from(row.y) >= height {
558 continue; }
560 }
561 if kept.is_multiple_of(TEXT_INDEX_STRIDE) {
562 samples.push((line_start, kept, row.t));
563 }
564 max_x = max_x.max(row.x);
565 max_y = max_y.max(row.y);
566 min_t = min_t.min(row.t);
567 max_t = max_t.max(row.t);
568 fractional |= row.t.fract() != 0.0;
569 sorted &= row.t >= previous_t;
570 previous_t = row.t;
571 kept += 1;
572 }
573
574 let sensor = options
575 .sensor_size
576 .unwrap_or((usize::from(max_x) + 1, usize::from(max_y) + 1));
577 if sensor.0 == 0 || sensor.1 == 0 {
578 return Err(IoError::InvalidSensorSize);
579 }
580 if !sorted {
581 return Err(IoError::Format(
582 "text timestamps are not sorted; in-place slicing requires time-ordered events"
583 .to_owned(),
584 ));
585 }
586 let time_unit = options.time_unit.unwrap_or_else(|| {
587 if fractional || !min_t.is_finite() {
588 TimeUnit::Seconds
589 } else {
590 TimeUnit::infer_from_span((max_t - min_t) as i64)
591 }
592 });
593
594 let index = samples
595 .into_iter()
596 .map(|(offset, count, t)| TextIndexEntry {
597 offset,
598 count,
599 t_us: time_unit.to_microseconds(t),
600 })
601 .collect();
602 let span_us = if kept == 0 {
603 (0, 0)
604 } else {
605 (
606 time_unit.to_microseconds(min_t),
607 time_unit.to_microseconds(max_t),
608 )
609 };
610 Ok(TextSliceSource {
611 path: path.to_path_buf(),
612 map,
613 time_unit,
614 sensor,
615 total: kept,
616 span_us,
617 index,
618 })
619}
620
621impl TextSliceSource {
622 fn reader_at(&self, offset: u64) -> Result<TextReader<BufReader<File>>, IoError> {
624 let mut file = File::open(&self.path)?;
625 file.seek(SeekFrom::Start(offset))?;
626 TextReader::new(
627 BufReader::new(file),
628 TextOptions {
629 width: self.sensor.0,
630 height: self.sensor.1,
631 time_unit: self.time_unit,
632 map: self.map,
633 has_header: false, },
635 )
636 }
637
638 fn keeps(&self, x: u16, y: u16) -> bool {
639 usize::from(x) < self.sensor.0 && usize::from(y) < self.sensor.1
640 }
641}
642
643impl SliceSource for TextSliceSource {
644 fn sensor_size(&self) -> (usize, usize) {
645 self.sensor
646 }
647
648 fn timestamp_scale_ms(&self) -> f64 {
649 0.001
650 }
651
652 fn n_events(&self) -> usize {
653 self.total
654 }
655
656 fn time_span(&self) -> (i64, i64) {
657 self.span_us
658 }
659
660 fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
661 let i0 = i0.min(self.total);
662 let i1 = i1.clamp(i0, self.total);
663 let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
664 if i0 == i1 || self.index.is_empty() {
665 return Ok(builder.build());
666 }
667 let entry = self.index[self.index.partition_point(|e| e.count <= i0) - 1];
669 let mut reader = self.reader_at(entry.offset)?;
670 let mut index = entry.count;
671 while index < i1 {
672 let Some(event) = reader.next_event()? else {
673 break;
674 };
675 if !self.keeps(event.x, event.y) {
676 continue; }
678 if index >= i0 {
679 builder.push(event.x, event.y, event.t, event.p);
680 }
681 index += 1;
682 }
683 Ok(builder.build())
684 }
685
686 fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
687 let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
688 if self.index.is_empty() {
689 return Ok(builder.build());
690 }
691 let entry = self.index[self
695 .index
696 .partition_point(|e| e.t_us < t0)
697 .saturating_sub(1)];
698 let mut reader = self.reader_at(entry.offset)?;
699 while let Some(event) = reader.next_event()? {
700 if event.t >= t1 {
701 break; }
703 if event.t >= t0 {
704 builder.push(event.x, event.y, event.t, event.p);
705 }
706 }
707 Ok(builder.build())
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use std::io::Cursor;
714
715 use super::{
716 load_text, open_text_slice, ColumnMap, ColumnOrder, TextOptions, TextReader, TimeUnit,
717 };
718 use crate::io::{read_all, IoError, LoadOptions, SliceSource};
719 use crate::EventStream;
720
721 fn read(data: &str, options: TextOptions) -> Result<EventStream, IoError> {
722 read_all(TextReader::new(Cursor::new(data), options).unwrap())
723 }
724
725 #[test]
726 fn parses_txyp_seconds_skips_noise_and_drops_out_of_bounds() {
727 let data = "0.0 1 2 1\n0.000002 3 0 0\n\n# comment\n0.00001 0 4 1\n0.00002 4 0 1\n";
728 let stream = read(data, TextOptions::new(4, 5)).unwrap();
729
730 assert_eq!(stream.len(), 3); assert_eq!(stream.xs(), &[1, 3, 0]);
732 assert_eq!(stream.ys(), &[2, 0, 4]);
733 assert_eq!(stream.ts(), &[0, 2, 10]);
734 assert_eq!(stream.ps(), &[true, false, true]);
735 assert_eq!(stream.sensor_size(), (4, 5));
736 assert_eq!(stream.timestamp_scale_ms(), 0.001);
737 }
738
739 #[test]
740 fn supports_xytp_order_and_negative_polarity() {
741 let options = TextOptions {
742 map: ColumnMap::from_order(ColumnOrder::Xytp),
743 ..TextOptions::new(8, 8)
744 };
745 let stream = read("1 2 0.5 -1\n", options).unwrap();
746
747 assert_eq!(stream.xs(), &[1]);
748 assert_eq!(stream.ys(), &[2]);
749 assert_eq!(stream.ts(), &[500_000]); assert_eq!(stream.ps(), &[false]); }
752
753 #[test]
754 fn converts_time_units_to_microseconds() {
755 for (unit, raw, expected) in [
756 (TimeUnit::Microseconds, "7", 7_i64),
757 (TimeUnit::Milliseconds, "2", 2_000),
758 (TimeUnit::Nanoseconds, "2400", 2),
759 ] {
760 let data = format!("{raw} 0 0 1\n");
761 let options = TextOptions {
762 time_unit: unit,
763 ..TextOptions::new(4, 4)
764 };
765 assert_eq!(
766 read(&data, options).unwrap().ts(),
767 &[expected],
768 "unit {unit:?}"
769 );
770 }
771 }
772
773 #[test]
774 fn reports_parse_errors_with_line_numbers() {
775 let error = read("0.0 1 2 1\n0.0 nope 2 1\n", TextOptions::new(4, 4)).unwrap_err();
776 match error {
777 IoError::Parse { line, .. } => assert_eq!(line, 2),
778 other => panic!("expected parse error, got {other:?}"),
779 }
780 }
781
782 #[test]
783 fn reports_missing_fields() {
784 let error = read("0.0 1 2\n", TextOptions::new(4, 4)).unwrap_err();
785 assert!(matches!(error, IoError::Parse { line: 1, .. }));
786 }
787
788 #[test]
789 fn rejects_zero_sensor_size() {
790 let error = TextReader::new(Cursor::new(""), TextOptions::new(0, 4)).unwrap_err();
791 assert!(matches!(error, IoError::InvalidSensorSize));
792 }
793
794 #[test]
795 fn empty_input_yields_empty_stream() {
796 let stream = read("\n\n# only comments\n", TextOptions::new(4, 4)).unwrap();
797 assert!(stream.is_empty());
798 assert_eq!(stream.sensor_size(), (4, 4));
799 }
800
801 fn write_temp(tag: &str, contents: &str) -> std::path::PathBuf {
802 let dir = std::env::temp_dir().join(format!("eventcv-{tag}-{}", std::process::id()));
803 std::fs::create_dir_all(&dir).unwrap();
804 let path = dir.join("events.txt");
805 std::fs::write(&path, contents).unwrap();
806 path
807 }
808
809 #[test]
810 fn load_text_infers_size_and_microseconds() {
811 let path = write_temp("txtus", "1000000 0 0 1\n3000000 3 1 0\n6000000 1 2 1\n");
813 let stream = load_text(&path, &LoadOptions::default()).unwrap();
814
815 assert_eq!(stream.sensor_size(), (4, 3));
816 assert_eq!(stream.len(), 3); assert_eq!(stream.ts(), &[1_000_000, 3_000_000, 6_000_000]);
818 std::fs::remove_dir_all(path.parent().unwrap()).ok();
819 }
820
821 #[test]
822 fn load_text_infers_seconds_from_a_fractional_value() {
823 let path = write_temp("txtsec", "0.0 0 0 1\n0.5 1 1 0\n");
824 let stream = load_text(&path, &LoadOptions::default()).unwrap();
825
826 assert_eq!(stream.ts(), &[0, 500_000]); std::fs::remove_dir_all(path.parent().unwrap()).ok();
828 }
829
830 #[test]
831 fn explicit_options_override_inference() {
832 let path = write_temp("txtexp", "7 0 0 1\n");
833 let options = LoadOptions {
834 sensor_size: Some((4, 4)),
835 time_unit: Some(TimeUnit::Microseconds),
836 ..LoadOptions::default()
837 };
838 let stream = load_text(&path, &options).unwrap();
839
840 assert_eq!(stream.sensor_size(), (4, 4));
841 assert_eq!(stream.ts(), &[7]); std::fs::remove_dir_all(path.parent().unwrap()).ok();
843 }
844
845 #[test]
846 fn text_slice_source_matches_load() {
847 let mut data = String::new();
849 for i in 0..10 {
850 data.push_str(&format!("{} {} {} {}\n", i * 1000, i, i % 5, i % 2));
851 }
852 let path = write_temp("txtslice", &data);
853 let options = LoadOptions {
855 time_unit: Some(TimeUnit::Microseconds),
856 ..LoadOptions::default()
857 };
858 let source = open_text_slice(&path, &options).unwrap();
859 let full = load_text(&path, &options).unwrap();
860
861 assert_eq!(source.n_events(), full.len());
862 assert_eq!(source.sensor_size(), full.sensor_size()); assert_eq!(source.time_span(), (0, 9000));
864
865 assert_eq!(
866 source.slice_time(2000, 6000).unwrap().ts(),
867 &[2000, 3000, 4000, 5000]
868 );
869 assert_eq!(
870 source.slice_index(3, 7).unwrap().ts(),
871 &[3000, 4000, 5000, 6000]
872 );
873
874 let whole = source.slice_index(0, source.n_events()).unwrap();
875 assert_eq!(whole.xs(), full.xs());
876 assert_eq!(whole.ts(), full.ts());
877 assert_eq!(whole.ps(), full.ps());
878 std::fs::remove_dir_all(path.parent().unwrap()).ok();
879 }
880
881 #[test]
882 fn text_slice_rejects_unsorted_timestamps() {
883 let path = write_temp("txtunsorted", "0 0 0 1\n5000 1 1 0\n2000 2 2 1\n");
884 match open_text_slice(&path, &LoadOptions::default()) {
885 Err(IoError::Format(message)) => assert!(message.contains("not sorted")),
886 Err(other) => panic!("expected a not-sorted error, got {other:?}"),
887 Ok(_) => panic!("expected unsorted text to be rejected"),
888 }
889 std::fs::remove_dir_all(path.parent().unwrap()).ok();
890 }
891
892 #[test]
893 fn text_slice_keeps_same_timestamp_events_spanning_the_index_stride() {
894 let dense = super::TEXT_INDEX_STRIDE * 2 + 100;
898 let mut data = String::from("0 0 0 1\n"); for _ in 0..dense {
900 data.push_str("1000 1 1 1\n");
901 }
902 data.push_str("2000 2 2 0\n"); let path = write_temp("txtdense", &data);
904 let options = LoadOptions {
905 time_unit: Some(TimeUnit::Microseconds),
906 ..LoadOptions::default()
907 };
908 let source = open_text_slice(&path, &options).unwrap();
909
910 assert_eq!(source.slice_time(1000, 2000).unwrap().len(), dense);
911 let tiled = source.slice_time(0, 1000).unwrap().len()
913 + source.slice_time(1000, 2000).unwrap().len()
914 + source.slice_time(2000, 3000).unwrap().len();
915 assert_eq!(tiled, source.n_events());
916 std::fs::remove_dir_all(path.parent().unwrap()).ok();
917 }
918
919 #[test]
920 fn text_slice_empty_file() {
921 let path = write_temp("txtsliceempty", "\n# only a comment\n");
922 let source = open_text_slice(&path, &LoadOptions::default()).unwrap();
923
924 assert_eq!(source.n_events(), 0);
925 assert_eq!(source.time_span(), (0, 0));
926 assert!(source.slice_time(0, 1000).unwrap().is_empty());
927 assert!(source.slice_index(0, 10).unwrap().is_empty());
928 std::fs::remove_dir_all(path.parent().unwrap()).ok();
929 }
930
931 #[test]
932 fn write_text_stream_round_trips_at_event_level() {
933 let mut builder = crate::EventStreamBuilder::new(16, 12, 0.001);
934 for &(x, y, t, p) in &[(0u16, 0u16, 5i64, true), (15, 11, 2_500_000, false)] {
935 builder.push(x, y, t, p);
936 }
937 let stream = builder.build();
938
939 let dir = std::env::temp_dir().join(format!("eventcv-txtrt-{}", std::process::id()));
940 std::fs::create_dir_all(&dir).unwrap();
941 let path = dir.join("events.txt");
942 super::write_text_stream(&path, &stream).unwrap();
943
944 let options = LoadOptions {
947 sensor_size: Some((16, 12)),
948 time_unit: Some(TimeUnit::Microseconds),
949 ..LoadOptions::default()
950 };
951 let loaded = load_text(&path, &options).unwrap();
952 assert_eq!(loaded.xs(), stream.xs());
953 assert_eq!(loaded.ys(), stream.ys());
954 assert_eq!(loaded.ts(), stream.ts());
955 assert_eq!(loaded.ps(), stream.ps());
956 assert_eq!(loaded.sensor_size(), (16, 12));
957 std::fs::remove_dir_all(&dir).ok();
958 }
959
960 #[test]
961 fn load_text_reads_csv_with_header_in_any_order() {
962 let path = write_temp("txtcsvhdr", "x,y,t,p\n1,2,1000,1\n3,0,2000,0\n0,1,3000,1\n");
964 let options = LoadOptions {
965 time_unit: Some(TimeUnit::Microseconds),
966 ..LoadOptions::default()
967 };
968 let stream = load_text(&path, &options).unwrap();
969
970 assert_eq!(stream.len(), 3); assert_eq!(stream.xs(), &[1, 3, 0]);
972 assert_eq!(stream.ys(), &[2, 0, 1]);
973 assert_eq!(stream.ts(), &[1000, 2000, 3000]);
974 assert_eq!(stream.ps(), &[true, false, true]);
975 std::fs::remove_dir_all(path.parent().unwrap()).ok();
976 }
977
978 #[test]
979 fn load_text_matches_synonym_header_names() {
980 let path = write_temp(
982 "txtsynhdr",
983 "timestamp x y polarity\n1000 1 2 1\n2000 3 0 0\n",
984 );
985 let options = LoadOptions {
986 time_unit: Some(TimeUnit::Microseconds),
987 ..LoadOptions::default()
988 };
989 let stream = load_text(&path, &options).unwrap();
990 assert_eq!(stream.xs(), &[1, 3]);
991 assert_eq!(stream.ts(), &[1000, 2000]);
992 std::fs::remove_dir_all(path.parent().unwrap()).ok();
993 }
994
995 #[test]
996 fn text_keys_override_selects_columns_by_index() {
997 let path = write_temp("txtkeys", "1 2 1000 1\n3 0 2000 0\n"); let options = LoadOptions {
1000 time_unit: Some(TimeUnit::Microseconds),
1001 keys: Some(crate::io::EventKeys {
1002 x: "0".to_owned(),
1003 y: "1".to_owned(),
1004 t: "2".to_owned(),
1005 p: "3".to_owned(),
1006 }),
1007 ..LoadOptions::default()
1008 };
1009 let stream = load_text(&path, &options).unwrap();
1010 assert_eq!(stream.xs(), &[1, 3]);
1011 assert_eq!(stream.ys(), &[2, 0]);
1012 assert_eq!(stream.ts(), &[1000, 2000]);
1013 std::fs::remove_dir_all(path.parent().unwrap()).ok();
1014 }
1015
1016 #[test]
1017 fn text_slice_over_headed_csv_matches_load() {
1018 let mut data = String::from("t,x,y,p\n");
1019 for i in 0..10 {
1020 data.push_str(&format!("{},{},{},{}\n", i * 1000, i, i % 5, i % 2));
1021 }
1022 let path = write_temp("txtslicehdr", &data);
1023 let options = LoadOptions {
1024 time_unit: Some(TimeUnit::Microseconds),
1025 ..LoadOptions::default()
1026 };
1027 let source = open_text_slice(&path, &options).unwrap();
1028 let full = load_text(&path, &options).unwrap();
1029
1030 assert_eq!(source.n_events(), full.len()); assert_eq!(source.time_span(), (0, 9000));
1032 assert_eq!(
1033 source.slice_time(2000, 5000).unwrap().ts(),
1034 &[2000, 3000, 4000]
1035 );
1036 std::fs::remove_dir_all(path.parent().unwrap()).ok();
1037 }
1038}