1use std::fs::File;
2use std::io::{BufRead, BufReader, BufWriter, Seek, SeekFrom, Write};
3use std::path::{Path, PathBuf};
4use std::str::{FromStr, SplitWhitespace};
5
6use super::{read_all, read_capped, EventSource, IoError, LoadOptions, RawEvent, SliceSource};
7use crate::{EventStream, EventStreamBuilder};
8
9pub fn write_text_stream(path: impl AsRef<Path>, stream: &EventStream) -> Result<(), IoError> {
14 let mut writer = BufWriter::new(File::create(path).map_err(IoError::Io)?);
15 let (xs, ys, ts, ps) = (stream.xs(), stream.ys(), stream.ts(), stream.ps());
16 for index in 0..stream.len() {
17 writeln!(
18 writer,
19 "{} {} {} {}",
20 ts[index],
21 xs[index],
22 ys[index],
23 u8::from(ps[index])
24 )
25 .map_err(IoError::Io)?;
26 }
27 writer.flush().map_err(IoError::Io)
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum TimeUnit {
35 Seconds,
36 Milliseconds,
37 Microseconds,
38 Nanoseconds,
39}
40
41impl TimeUnit {
42 fn to_microseconds(self, value: f64) -> i64 {
43 let microseconds = match self {
44 Self::Seconds => value * 1e6,
45 Self::Milliseconds => value * 1e3,
46 Self::Microseconds => value,
47 Self::Nanoseconds => value / 1e3,
48 };
49 microseconds.round() as i64
50 }
51
52 #[cfg_attr(not(feature = "hdf5"), allow(dead_code))]
56 pub(crate) fn from_scale_ms(scale_ms: f64) -> Option<TimeUnit> {
57 for (unit, expected) in [
58 (TimeUnit::Nanoseconds, 1e-6),
59 (TimeUnit::Microseconds, 1e-3),
60 (TimeUnit::Milliseconds, 1.0),
61 (TimeUnit::Seconds, 1e3),
62 ] {
63 if (scale_ms - expected).abs() <= expected * 1e-9 {
64 return Some(unit);
65 }
66 }
67 None
68 }
69
70 pub(crate) fn infer_from_span(span: i64) -> TimeUnit {
76 let span = span.max(0) as f64;
77 if span * 1e-9 >= 1.0 {
78 TimeUnit::Nanoseconds
79 } else if span * 1e-6 >= 1.0 {
80 TimeUnit::Microseconds
81 } else if span * 1e-3 >= 1.0 {
82 TimeUnit::Milliseconds
83 } else {
84 TimeUnit::Seconds
85 }
86 }
87
88 #[cfg(feature = "hdf5")]
91 pub(crate) fn microseconds_from_int(self, value: i64) -> i64 {
92 let value = i128::from(value);
93 let microseconds = match self {
94 Self::Seconds => value * 1_000_000,
95 Self::Milliseconds => value * 1_000,
96 Self::Microseconds => value,
97 Self::Nanoseconds => value / 1_000,
98 };
99 microseconds.clamp(i64::MIN as i128, i64::MAX as i128) as i64
100 }
101}
102
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub enum ColumnOrder {
106 #[default]
108 Txyp,
109 Xytp,
111}
112
113#[derive(Clone, Copy, Debug)]
114pub struct TextOptions {
115 pub width: usize,
116 pub height: usize,
117 pub time_unit: TimeUnit,
118 pub order: ColumnOrder,
119}
120
121impl TextOptions {
122 pub fn new(width: usize, height: usize) -> Self {
124 Self {
125 width,
126 height,
127 time_unit: TimeUnit::Seconds,
128 order: ColumnOrder::Txyp,
129 }
130 }
131}
132
133#[derive(Debug)]
137pub struct TextReader<R> {
138 reader: R,
139 options: TextOptions,
140 buffer: String,
141 line: usize,
142}
143
144impl<R: BufRead> TextReader<R> {
145 pub fn new(reader: R, options: TextOptions) -> Result<Self, IoError> {
146 if options.width == 0 || options.height == 0 {
147 return Err(IoError::InvalidSensorSize);
148 }
149 Ok(Self {
150 reader,
151 options,
152 buffer: String::new(),
153 line: 0,
154 })
155 }
156
157 fn parse_line(&self, line: &str) -> Result<RawEvent, IoError> {
158 let mut fields = line.split_whitespace();
159 let (t, x, y, p) = match self.options.order {
160 ColumnOrder::Txyp => {
161 let t = self.field(&mut fields, "t")?;
162 (
163 t,
164 self.field(&mut fields, "x")?,
165 self.field(&mut fields, "y")?,
166 self.field(&mut fields, "p")?,
167 )
168 }
169 ColumnOrder::Xytp => {
170 let x = self.field(&mut fields, "x")?;
171 let y = self.field(&mut fields, "y")?;
172 (
173 self.field(&mut fields, "t")?,
174 x,
175 y,
176 self.field(&mut fields, "p")?,
177 )
178 }
179 };
180 Ok(RawEvent {
181 x: self.parse(x, "x")?,
182 y: self.parse(y, "y")?,
183 t: self
184 .options
185 .time_unit
186 .to_microseconds(self.parse::<f64>(t, "t")?),
187 p: self.parse::<i32>(p, "p")? > 0,
188 })
189 }
190
191 fn field<'a>(&self, fields: &mut SplitWhitespace<'a>, name: &str) -> Result<&'a str, IoError> {
192 fields.next().ok_or_else(|| IoError::Parse {
193 line: self.line,
194 message: format!("missing {name}"),
195 })
196 }
197
198 fn parse<T: FromStr>(&self, value: &str, field: &str) -> Result<T, IoError> {
199 value.parse().map_err(|_| IoError::Parse {
200 line: self.line,
201 message: format!("invalid {field}: {value:?}"),
202 })
203 }
204}
205
206impl<R: BufRead> EventSource for TextReader<R> {
207 fn sensor_size(&self) -> (usize, usize) {
208 (self.options.width, self.options.height)
209 }
210
211 fn timestamp_scale_ms(&self) -> f64 {
212 0.001
213 }
214
215 fn next_event(&mut self) -> Result<Option<RawEvent>, IoError> {
216 loop {
217 self.buffer.clear();
218 self.line += 1;
219 if self.reader.read_line(&mut self.buffer)? == 0 {
220 return Ok(None);
221 }
222 let trimmed = self.buffer.trim();
223 if trimmed.is_empty() || trimmed.starts_with('#') {
224 continue;
225 }
226 return self.parse_line(trimmed).map(Some);
227 }
228 }
229}
230
231pub fn open(
233 path: impl AsRef<Path>,
234 options: TextOptions,
235) -> Result<TextReader<BufReader<File>>, IoError> {
236 TextReader::new(BufReader::new(File::open(path)?), options)
237}
238
239pub fn read_text(path: impl AsRef<Path>, options: TextOptions) -> Result<EventStream, IoError> {
241 read_all(open(path, options)?)
242}
243
244pub struct RawRow {
248 pub x: u16,
249 pub y: u16,
250 pub t: f64,
251 pub p: bool,
252}
253
254pub fn load_text(path: impl AsRef<Path>, options: &LoadOptions) -> Result<EventStream, IoError> {
259 if let (Some((width, height)), Some(time_unit)) = (options.sensor_size, options.time_unit) {
260 let text_options = TextOptions {
261 width,
262 height,
263 time_unit,
264 order: options.order,
265 };
266 return read_capped(open(path.as_ref(), text_options)?, options.max_events);
267 }
268
269 let rows = read_raw_rows(path.as_ref(), options.order)?;
270 load_rows(&rows, options)
271}
272
273pub fn load_rows(rows: &[RawRow], options: &LoadOptions) -> Result<EventStream, IoError> {
277 let (width, height) = options
278 .sensor_size
279 .unwrap_or_else(|| infer_sensor_size(rows));
280 let time_unit = options.time_unit.unwrap_or_else(|| infer_time_unit(rows));
281 if width == 0 || height == 0 {
282 return Err(IoError::InvalidSensorSize);
283 }
284
285 let mut builder = EventStreamBuilder::new(width, height, 0.001);
286 for row in rows {
287 builder.push(row.x, row.y, time_unit.to_microseconds(row.t), row.p);
288 if options.max_events.is_some_and(|max| builder.len() >= max) {
289 break;
290 }
291 }
292 Ok(builder.build())
293}
294
295fn infer_sensor_size(rows: &[RawRow]) -> (usize, usize) {
298 let width = rows.iter().map(|row| usize::from(row.x)).max();
299 let height = rows.iter().map(|row| usize::from(row.y)).max();
300 match (width, height) {
301 (Some(width), Some(height)) => (width + 1, height + 1),
302 _ => (1, 1),
303 }
304}
305
306fn infer_time_unit(rows: &[RawRow]) -> TimeUnit {
308 if rows.iter().any(|row| row.t.fract() != 0.0) {
309 return TimeUnit::Seconds;
310 }
311 let min = rows.iter().map(|row| row.t).fold(f64::INFINITY, f64::min);
312 let max = rows
313 .iter()
314 .map(|row| row.t)
315 .fold(f64::NEG_INFINITY, f64::max);
316 if min.is_finite() {
317 TimeUnit::infer_from_span((max - min) as i64)
318 } else {
319 TimeUnit::Seconds
320 }
321}
322
323fn parse_raw_row(trimmed: &str, order: ColumnOrder, number: usize) -> Result<RawRow, IoError> {
326 let mut fields = trimmed.split_whitespace();
327 let mut next = |name: &str| {
328 fields.next().ok_or(IoError::Parse {
329 line: number,
330 message: format!("missing {name}"),
331 })
332 };
333 let (t, x, y, p) = match order {
334 ColumnOrder::Txyp => {
335 let t = next("t")?;
336 (t, next("x")?, next("y")?, next("p")?)
337 }
338 ColumnOrder::Xytp => {
339 let x = next("x")?;
340 let y = next("y")?;
341 (next("t")?, x, y, next("p")?)
342 }
343 };
344 let parse = |value: &str, field: &str| {
345 value.parse::<f64>().map_err(|_| IoError::Parse {
346 line: number,
347 message: format!("invalid {field}: {value:?}"),
348 })
349 };
350 Ok(RawRow {
351 x: parse(x, "x")? as u16,
352 y: parse(y, "y")? as u16,
353 t: parse(t, "t")?,
354 p: parse(p, "p")? > 0.0,
355 })
356}
357
358fn read_raw_rows(path: &Path, order: ColumnOrder) -> Result<Vec<RawRow>, IoError> {
359 let reader = BufReader::new(File::open(path)?);
360 let mut rows = Vec::new();
361 for (index, line) in reader.lines().enumerate() {
362 let line = line?;
363 let trimmed = line.trim();
364 if trimmed.is_empty() || trimmed.starts_with('#') {
365 continue;
366 }
367 rows.push(parse_raw_row(trimmed, order, index + 1)?);
368 }
369 Ok(rows)
370}
371
372const TEXT_INDEX_STRIDE: usize = 4096;
375
376#[derive(Clone, Copy)]
377struct TextIndexEntry {
378 offset: u64,
379 count: usize,
380 t_us: i64,
381}
382
383pub struct TextSliceSource {
388 path: PathBuf,
389 order: ColumnOrder,
390 time_unit: TimeUnit,
391 sensor: (usize, usize),
392 total: usize,
393 span_us: (i64, i64),
394 index: Vec<TextIndexEntry>,
395}
396
397pub fn open_text_slice(
401 path: impl AsRef<Path>,
402 options: &LoadOptions,
403) -> Result<TextSliceSource, IoError> {
404 let path = path.as_ref();
405 let mut reader = BufReader::new(File::open(path)?);
406 let mut buffer = String::new();
407 let mut offset = 0u64;
408 let mut line_no = 0usize;
409 let mut kept = 0usize;
410 let mut samples: Vec<(u64, usize, f64)> = Vec::new();
411 let (mut max_x, mut max_y) = (0u16, 0u16);
412 let (mut min_t, mut max_t) = (f64::INFINITY, f64::NEG_INFINITY);
413 let mut fractional = false;
414 let mut sorted = true;
415 let mut previous_t = f64::NEG_INFINITY;
416
417 loop {
418 buffer.clear();
419 let line_start = offset;
420 let bytes = reader.read_line(&mut buffer)?;
421 if bytes == 0 {
422 break;
423 }
424 offset += bytes as u64;
425 line_no += 1;
426 let trimmed = buffer.trim();
427 if trimmed.is_empty() || trimmed.starts_with('#') {
428 continue;
429 }
430 let row = parse_raw_row(trimmed, options.order, line_no)?;
431 if let Some((width, height)) = options.sensor_size {
432 if usize::from(row.x) >= width || usize::from(row.y) >= height {
433 continue; }
435 }
436 if kept.is_multiple_of(TEXT_INDEX_STRIDE) {
437 samples.push((line_start, kept, row.t));
438 }
439 max_x = max_x.max(row.x);
440 max_y = max_y.max(row.y);
441 min_t = min_t.min(row.t);
442 max_t = max_t.max(row.t);
443 fractional |= row.t.fract() != 0.0;
444 sorted &= row.t >= previous_t;
445 previous_t = row.t;
446 kept += 1;
447 }
448
449 let sensor = options
450 .sensor_size
451 .unwrap_or((usize::from(max_x) + 1, usize::from(max_y) + 1));
452 if sensor.0 == 0 || sensor.1 == 0 {
453 return Err(IoError::InvalidSensorSize);
454 }
455 if !sorted {
456 return Err(IoError::Format(
457 "text timestamps are not sorted; in-place slicing requires time-ordered events"
458 .to_owned(),
459 ));
460 }
461 let time_unit = options.time_unit.unwrap_or_else(|| {
462 if fractional || !min_t.is_finite() {
463 TimeUnit::Seconds
464 } else {
465 TimeUnit::infer_from_span((max_t - min_t) as i64)
466 }
467 });
468
469 let index = samples
470 .into_iter()
471 .map(|(offset, count, t)| TextIndexEntry {
472 offset,
473 count,
474 t_us: time_unit.to_microseconds(t),
475 })
476 .collect();
477 let span_us = if kept == 0 {
478 (0, 0)
479 } else {
480 (
481 time_unit.to_microseconds(min_t),
482 time_unit.to_microseconds(max_t),
483 )
484 };
485 Ok(TextSliceSource {
486 path: path.to_path_buf(),
487 order: options.order,
488 time_unit,
489 sensor,
490 total: kept,
491 span_us,
492 index,
493 })
494}
495
496impl TextSliceSource {
497 fn reader_at(&self, offset: u64) -> Result<TextReader<BufReader<File>>, IoError> {
499 let mut file = File::open(&self.path)?;
500 file.seek(SeekFrom::Start(offset))?;
501 TextReader::new(
502 BufReader::new(file),
503 TextOptions {
504 width: self.sensor.0,
505 height: self.sensor.1,
506 time_unit: self.time_unit,
507 order: self.order,
508 },
509 )
510 }
511
512 fn keeps(&self, x: u16, y: u16) -> bool {
513 usize::from(x) < self.sensor.0 && usize::from(y) < self.sensor.1
514 }
515}
516
517impl SliceSource for TextSliceSource {
518 fn sensor_size(&self) -> (usize, usize) {
519 self.sensor
520 }
521
522 fn timestamp_scale_ms(&self) -> f64 {
523 0.001
524 }
525
526 fn n_events(&self) -> usize {
527 self.total
528 }
529
530 fn time_span(&self) -> (i64, i64) {
531 self.span_us
532 }
533
534 fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
535 let i0 = i0.min(self.total);
536 let i1 = i1.clamp(i0, self.total);
537 let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
538 if i0 == i1 || self.index.is_empty() {
539 return Ok(builder.build());
540 }
541 let entry = self.index[self.index.partition_point(|e| e.count <= i0) - 1];
543 let mut reader = self.reader_at(entry.offset)?;
544 let mut index = entry.count;
545 while index < i1 {
546 let Some(event) = reader.next_event()? else {
547 break;
548 };
549 if !self.keeps(event.x, event.y) {
550 continue; }
552 if index >= i0 {
553 builder.push(event.x, event.y, event.t, event.p);
554 }
555 index += 1;
556 }
557 Ok(builder.build())
558 }
559
560 fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
561 let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
562 if self.index.is_empty() {
563 return Ok(builder.build());
564 }
565 let entry = self.index[self
569 .index
570 .partition_point(|e| e.t_us < t0)
571 .saturating_sub(1)];
572 let mut reader = self.reader_at(entry.offset)?;
573 while let Some(event) = reader.next_event()? {
574 if event.t >= t1 {
575 break; }
577 if event.t >= t0 {
578 builder.push(event.x, event.y, event.t, event.p);
579 }
580 }
581 Ok(builder.build())
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use std::io::Cursor;
588
589 use super::{load_text, open_text_slice, ColumnOrder, TextOptions, TextReader, TimeUnit};
590 use crate::io::{read_all, IoError, LoadOptions, SliceSource};
591 use crate::EventStream;
592
593 fn read(data: &str, options: TextOptions) -> Result<EventStream, IoError> {
594 read_all(TextReader::new(Cursor::new(data), options).unwrap())
595 }
596
597 #[test]
598 fn parses_txyp_seconds_skips_noise_and_drops_out_of_bounds() {
599 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";
600 let stream = read(data, TextOptions::new(4, 5)).unwrap();
601
602 assert_eq!(stream.len(), 3); assert_eq!(stream.xs(), &[1, 3, 0]);
604 assert_eq!(stream.ys(), &[2, 0, 4]);
605 assert_eq!(stream.ts(), &[0, 2, 10]);
606 assert_eq!(stream.ps(), &[true, false, true]);
607 assert_eq!(stream.sensor_size(), (4, 5));
608 assert_eq!(stream.timestamp_scale_ms(), 0.001);
609 }
610
611 #[test]
612 fn supports_xytp_order_and_negative_polarity() {
613 let options = TextOptions {
614 order: ColumnOrder::Xytp,
615 ..TextOptions::new(8, 8)
616 };
617 let stream = read("1 2 0.5 -1\n", options).unwrap();
618
619 assert_eq!(stream.xs(), &[1]);
620 assert_eq!(stream.ys(), &[2]);
621 assert_eq!(stream.ts(), &[500_000]); assert_eq!(stream.ps(), &[false]); }
624
625 #[test]
626 fn converts_time_units_to_microseconds() {
627 for (unit, raw, expected) in [
628 (TimeUnit::Microseconds, "7", 7_i64),
629 (TimeUnit::Milliseconds, "2", 2_000),
630 (TimeUnit::Nanoseconds, "2400", 2),
631 ] {
632 let data = format!("{raw} 0 0 1\n");
633 let options = TextOptions {
634 time_unit: unit,
635 ..TextOptions::new(4, 4)
636 };
637 assert_eq!(
638 read(&data, options).unwrap().ts(),
639 &[expected],
640 "unit {unit:?}"
641 );
642 }
643 }
644
645 #[test]
646 fn reports_parse_errors_with_line_numbers() {
647 let error = read("0.0 1 2 1\n0.0 nope 2 1\n", TextOptions::new(4, 4)).unwrap_err();
648 match error {
649 IoError::Parse { line, .. } => assert_eq!(line, 2),
650 other => panic!("expected parse error, got {other:?}"),
651 }
652 }
653
654 #[test]
655 fn reports_missing_fields() {
656 let error = read("0.0 1 2\n", TextOptions::new(4, 4)).unwrap_err();
657 assert!(matches!(error, IoError::Parse { line: 1, .. }));
658 }
659
660 #[test]
661 fn rejects_zero_sensor_size() {
662 let error = TextReader::new(Cursor::new(""), TextOptions::new(0, 4)).unwrap_err();
663 assert!(matches!(error, IoError::InvalidSensorSize));
664 }
665
666 #[test]
667 fn empty_input_yields_empty_stream() {
668 let stream = read("\n\n# only comments\n", TextOptions::new(4, 4)).unwrap();
669 assert!(stream.is_empty());
670 assert_eq!(stream.sensor_size(), (4, 4));
671 }
672
673 fn write_temp(tag: &str, contents: &str) -> std::path::PathBuf {
674 let dir = std::env::temp_dir().join(format!("eventcv-{tag}-{}", std::process::id()));
675 std::fs::create_dir_all(&dir).unwrap();
676 let path = dir.join("events.txt");
677 std::fs::write(&path, contents).unwrap();
678 path
679 }
680
681 #[test]
682 fn load_text_infers_size_and_microseconds() {
683 let path = write_temp("txtus", "1000000 0 0 1\n3000000 3 1 0\n6000000 1 2 1\n");
685 let stream = load_text(&path, &LoadOptions::default()).unwrap();
686
687 assert_eq!(stream.sensor_size(), (4, 3));
688 assert_eq!(stream.len(), 3); assert_eq!(stream.ts(), &[1_000_000, 3_000_000, 6_000_000]);
690 std::fs::remove_dir_all(path.parent().unwrap()).ok();
691 }
692
693 #[test]
694 fn load_text_infers_seconds_from_a_fractional_value() {
695 let path = write_temp("txtsec", "0.0 0 0 1\n0.5 1 1 0\n");
696 let stream = load_text(&path, &LoadOptions::default()).unwrap();
697
698 assert_eq!(stream.ts(), &[0, 500_000]); std::fs::remove_dir_all(path.parent().unwrap()).ok();
700 }
701
702 #[test]
703 fn explicit_options_override_inference() {
704 let path = write_temp("txtexp", "7 0 0 1\n");
705 let options = LoadOptions {
706 sensor_size: Some((4, 4)),
707 time_unit: Some(TimeUnit::Microseconds),
708 ..LoadOptions::default()
709 };
710 let stream = load_text(&path, &options).unwrap();
711
712 assert_eq!(stream.sensor_size(), (4, 4));
713 assert_eq!(stream.ts(), &[7]); std::fs::remove_dir_all(path.parent().unwrap()).ok();
715 }
716
717 #[test]
718 fn text_slice_source_matches_load() {
719 let mut data = String::new();
721 for i in 0..10 {
722 data.push_str(&format!("{} {} {} {}\n", i * 1000, i, i % 5, i % 2));
723 }
724 let path = write_temp("txtslice", &data);
725 let options = LoadOptions {
727 time_unit: Some(TimeUnit::Microseconds),
728 ..LoadOptions::default()
729 };
730 let source = open_text_slice(&path, &options).unwrap();
731 let full = load_text(&path, &options).unwrap();
732
733 assert_eq!(source.n_events(), full.len());
734 assert_eq!(source.sensor_size(), full.sensor_size()); assert_eq!(source.time_span(), (0, 9000));
736
737 assert_eq!(
738 source.slice_time(2000, 6000).unwrap().ts(),
739 &[2000, 3000, 4000, 5000]
740 );
741 assert_eq!(
742 source.slice_index(3, 7).unwrap().ts(),
743 &[3000, 4000, 5000, 6000]
744 );
745
746 let whole = source.slice_index(0, source.n_events()).unwrap();
747 assert_eq!(whole.xs(), full.xs());
748 assert_eq!(whole.ts(), full.ts());
749 assert_eq!(whole.ps(), full.ps());
750 std::fs::remove_dir_all(path.parent().unwrap()).ok();
751 }
752
753 #[test]
754 fn text_slice_rejects_unsorted_timestamps() {
755 let path = write_temp("txtunsorted", "0 0 0 1\n5000 1 1 0\n2000 2 2 1\n");
756 match open_text_slice(&path, &LoadOptions::default()) {
757 Err(IoError::Format(message)) => assert!(message.contains("not sorted")),
758 Err(other) => panic!("expected a not-sorted error, got {other:?}"),
759 Ok(_) => panic!("expected unsorted text to be rejected"),
760 }
761 std::fs::remove_dir_all(path.parent().unwrap()).ok();
762 }
763
764 #[test]
765 fn text_slice_keeps_same_timestamp_events_spanning_the_index_stride() {
766 let dense = super::TEXT_INDEX_STRIDE * 2 + 100;
770 let mut data = String::from("0 0 0 1\n"); for _ in 0..dense {
772 data.push_str("1000 1 1 1\n");
773 }
774 data.push_str("2000 2 2 0\n"); let path = write_temp("txtdense", &data);
776 let options = LoadOptions {
777 time_unit: Some(TimeUnit::Microseconds),
778 ..LoadOptions::default()
779 };
780 let source = open_text_slice(&path, &options).unwrap();
781
782 assert_eq!(source.slice_time(1000, 2000).unwrap().len(), dense);
783 let tiled = source.slice_time(0, 1000).unwrap().len()
785 + source.slice_time(1000, 2000).unwrap().len()
786 + source.slice_time(2000, 3000).unwrap().len();
787 assert_eq!(tiled, source.n_events());
788 std::fs::remove_dir_all(path.parent().unwrap()).ok();
789 }
790
791 #[test]
792 fn text_slice_empty_file() {
793 let path = write_temp("txtsliceempty", "\n# only a comment\n");
794 let source = open_text_slice(&path, &LoadOptions::default()).unwrap();
795
796 assert_eq!(source.n_events(), 0);
797 assert_eq!(source.time_span(), (0, 0));
798 assert!(source.slice_time(0, 1000).unwrap().is_empty());
799 assert!(source.slice_index(0, 10).unwrap().is_empty());
800 std::fs::remove_dir_all(path.parent().unwrap()).ok();
801 }
802
803 #[test]
804 fn write_text_stream_round_trips_at_event_level() {
805 let mut builder = crate::EventStreamBuilder::new(16, 12, 0.001);
806 for &(x, y, t, p) in &[(0u16, 0u16, 5i64, true), (15, 11, 2_500_000, false)] {
807 builder.push(x, y, t, p);
808 }
809 let stream = builder.build();
810
811 let dir = std::env::temp_dir().join(format!("eventcv-txtrt-{}", std::process::id()));
812 std::fs::create_dir_all(&dir).unwrap();
813 let path = dir.join("events.txt");
814 super::write_text_stream(&path, &stream).unwrap();
815
816 let options = LoadOptions {
819 sensor_size: Some((16, 12)),
820 time_unit: Some(TimeUnit::Microseconds),
821 ..LoadOptions::default()
822 };
823 let loaded = load_text(&path, &options).unwrap();
824 assert_eq!(loaded.xs(), stream.xs());
825 assert_eq!(loaded.ys(), stream.ys());
826 assert_eq!(loaded.ts(), stream.ts());
827 assert_eq!(loaded.ps(), stream.ps());
828 assert_eq!(loaded.sensor_size(), (16, 12));
829 std::fs::remove_dir_all(&dir).ok();
830 }
831}