1use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
122
123use crate::error::LogError;
124use crate::log::{Channel, Event, FlightLog, LogFormat, Quantity, Stated};
125use crate::perfectflite::FOOT_M;
126use crate::table::{Notes, number, numbered_lines, short};
127
128const FORMAT: &str = "Eggtimer CSV";
130
131const MILLISECONDS_ABOVE: f64 = 5.0;
135
136const SPEED_MATCH_RELATIVE: f64 = 0.01;
140const SPEED_MATCH_FT_S: f64 = 1.0;
141
142const SPEED_MATCH_SHARE: f64 = 0.9;
148
149pub const MAX_EVENTS_PER_MARKER: usize = 16;
154
155const GRAVITY_READING_G: f64 = 0.5;
158
159const MAX_LISTED: usize = 6;
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164enum Kind {
165 Altitude,
167 FilteredAltitude,
169 RawSpeed,
171 FilteredSpeed,
173 AccelerationG,
175 AccelerationFtS2,
177 InertialSpeed,
179 InertialAltitude,
181 Marker,
184 Unknown,
186}
187
188impl Kind {
189 fn quantity(self) -> (Quantity, f64) {
191 match self {
192 Self::Altitude | Self::FilteredAltitude => (Quantity::AltitudeAboveGround, FOOT_M),
193 Self::RawSpeed | Self::FilteredSpeed | Self::InertialSpeed => (Quantity::Speed, FOOT_M),
194 Self::AccelerationG => (Quantity::Acceleration, STANDARD_GRAVITY_MPS2),
195 Self::AccelerationFtS2 => (Quantity::Acceleration, FOOT_M),
196 Self::InertialAltitude => (Quantity::InertialAltitude, FOOT_M),
197 Self::Marker | Self::Unknown => (Quantity::Unrecognized, 1.0),
198 }
199 }
200}
201
202const KNOWN: [(&str, Kind); 21] = [
205 ("Alt", Kind::Altitude),
206 ("FAlt", Kind::FilteredAltitude),
207 ("Filt_Alt", Kind::FilteredAltitude),
208 ("KAlt", Kind::FilteredAltitude),
209 ("Veloc", Kind::RawSpeed),
210 ("VRaw", Kind::RawSpeed),
211 ("FVeloc", Kind::FilteredSpeed),
212 ("KVeloc", Kind::FilteredSpeed),
213 ("VFilt", Kind::FilteredSpeed),
214 ("Accel", Kind::AccelerationG),
215 ("AccelG", Kind::AccelerationG),
216 ("FAccelG", Kind::AccelerationG),
217 ("AccelFPSS", Kind::AccelerationFtS2),
218 ("FAccelFPSS", Kind::AccelerationFtS2),
219 ("VAccel", Kind::InertialSpeed),
220 ("VelocAcc", Kind::InertialSpeed),
221 ("FAccelVel", Kind::InertialSpeed),
222 ("AltAccel", Kind::InertialAltitude),
223 ("AltAcc", Kind::InertialAltitude),
224 ("FAccelAlt", Kind::InertialAltitude),
225 ("LDA", Kind::Marker),
227];
228
229const MARKERS: [&str; 11] = [
232 "LowV", "Apogee", "N-O", "Drogue", "Main", "AUX", "BurnT", "CHA", "CHB", "MaxV", "MaxA",
233];
234
235fn kind_of(name: &str) -> Option<Kind> {
237 if let Some((_, kind)) = KNOWN.iter().find(|(known, _)| *known == name) {
238 return Some(*kind);
239 }
240 let numbered = |prefix: &str| {
241 name.strip_prefix(prefix)
242 .is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
243 };
244 (MARKERS.contains(&name) || numbered("CH") || numbered("BO#") || numbered("Boost#"))
245 .then_some(Kind::Marker)
246}
247
248fn is_header(line: &str) -> bool {
251 let mut names = line.split(',').map(str::trim);
252 names.next() == Some("T")
253 && names.next() == Some("Alt")
254 && names.any(|name| kind_of(name).is_some())
255}
256
257pub(crate) fn recognizes(text: &str) -> bool {
260 numbered_lines(text)
261 .map(|(_, line)| line.trim())
262 .find(|line| !line.is_empty())
263 .is_some_and(is_header)
264}
265
266struct Column {
268 name: String,
269 kind: Kind,
270}
271
272impl Column {
273 fn names_its_unit(&self) -> bool {
276 match self.kind {
277 Kind::AccelerationG => self.name.ends_with('G'),
278 Kind::AccelerationFtS2 => self.name.ends_with("FPSS"),
279 _ => false,
280 }
281 }
282}
283
284pub fn read(text: &str) -> Result<FlightLog, LogError> {
294 let mut lines = numbered_lines(text).filter(|(_, line)| !line.trim().is_empty());
295 let Some((header_line, header)) = lines.next() else {
296 return Err(LogError::NotThisFormat {
297 format: FORMAT,
298 message: "the file is empty".to_owned(),
299 });
300 };
301 if !is_header(header) {
302 return Err(LogError::NotThisFormat {
303 format: FORMAT,
304 message: format!(
305 "its first line, {:?}, isn't an Eggtimer header row: `T`, `Alt` and the \
306 Eggtimer's columns, such as `Veloc` or `LDA`",
307 short(header)
308 ),
309 });
310 }
311 let mut notes = Notes::default();
312 let columns = header_columns(header, header_line, &mut notes)?;
313
314 let mut rows: Vec<(usize, Vec<f64>)> = Vec::new();
316 let mut cells_written = 0_usize;
317 let mut blank_rows = 0_usize;
318 for (number, line) in lines {
319 let cells: Vec<&str> = line.split(',').map(str::trim).collect();
320 if cells.iter().all(|cell| cell.is_empty()) {
321 blank_rows += 1;
322 continue;
323 }
324 if cells.len() > columns.len() + 1 {
325 return Err(syntax(
326 number,
327 format!(
328 "the row has {} cells and the header names {}",
329 cells.len(),
330 columns.len() + 1
331 ),
332 ));
333 }
334 let values = row_values(&cells, &columns, number)?;
335 cells_written = cells_written.saturating_add(cells.len());
336 rows.push((number, values));
337 if crate::log::too_sparse(columns.len() + 1, rows.len(), cells_written) {
338 return Err(syntax(
339 number,
340 format!(
341 "{} rows hold {cells_written} of the {} cells the header's {} columns name: \
342 too few for the header to be this file's",
343 rows.len(),
344 rows.len().saturating_mul(columns.len() + 1),
345 columns.len() + 1
346 ),
347 ));
348 }
349 }
350 if rows.is_empty() {
351 return Err(LogError::NoData { format: FORMAT });
352 }
353 build(columns, &rows, blank_rows, notes)
354}
355
356fn header_columns(header: &str, line: usize, notes: &mut Notes) -> Result<Vec<Column>, LogError> {
359 let mut columns = Vec::new();
360 let mut after_lda = false;
361 for (index, name) in header.split(',').map(str::trim).enumerate().skip(1) {
362 if name.is_empty() {
363 return Err(syntax(
364 line,
365 format!("the header's column {} has no name", index + 1),
366 ));
367 }
368 let kind = match kind_of(name) {
369 Some(kind) => kind,
370 None if after_lda => {
371 notes.push(format!(
372 "the column {:?} isn't one this reader knows; it comes after `LDA`, where the \
373 manuals put only event markers, and was read as one",
374 short(name)
375 ));
376 Kind::Marker
377 }
378 None => {
379 notes.push(format!(
380 "the column {:?} isn't one this reader knows, and was kept as written",
381 short(name)
382 ));
383 Kind::Unknown
384 }
385 };
386 after_lda |= name == "LDA";
387 columns.push(Column {
388 name: name.to_owned(),
389 kind,
390 });
391 }
392 Ok(columns)
393}
394
395fn row_values(cells: &[&str], columns: &[Column], line: usize) -> Result<Vec<f64>, LogError> {
398 let mut values = Vec::with_capacity(cells.len());
399 for (index, cell) in cells.iter().enumerate() {
400 if cell.is_empty() {
401 if index == 0 {
402 return Err(syntax(
403 line,
404 "the time, the row's first cell, is empty".to_owned(),
405 ));
406 }
407 values.push(f64::NAN);
408 continue;
409 }
410 let Some(value) = number(cell, false) else {
411 let under = match index.checked_sub(1).and_then(|at| columns.get(at)) {
412 Some(column) => short(&column.name),
413 None => "T".to_owned(),
414 };
415 return Err(syntax(
416 line,
417 format!(
418 "{:?} in cell {}, under {under:?}, isn't a number",
419 short(cell),
420 index + 1
421 ),
422 ));
423 };
424 values.push(value);
425 }
426 Ok(values)
427}
428
429fn build(
431 mut columns: Vec<Column>,
432 rows: &[(usize, Vec<f64>)],
433 blank_rows: usize,
434 mut notes: Notes,
435) -> Result<FlightLog, LogError> {
436 let mut written_time = Vec::with_capacity(rows.len());
438 for (number, values) in rows {
439 let time = values.first().copied().unwrap_or(f64::NAN);
441 if let Some(&last) = written_time.last()
442 && time <= last
443 {
444 return Err(syntax(
445 *number,
446 format!("the time {time} comes at or before the one above it, {last}"),
447 ));
448 }
449 written_time.push(time);
450 }
451 let mut unit_notes = Vec::new();
452 let units_per_second = time_unit(&written_time, &mut unit_notes);
453 let time_s: Vec<f64> = written_time
454 .iter()
455 .map(|time| time / units_per_second)
456 .collect();
457
458 let written: Vec<Vec<f64>> = (1..=columns.len())
460 .map(|index| {
461 rows.iter()
462 .map(|(_, values)| values.get(index).copied().unwrap_or(f64::NAN))
463 .collect()
464 })
465 .collect();
466 let step_s: Vec<f64> = time_s.windows(2).map(|pair| pair[1] - pair[0]).collect();
467 let checks = speed_checks(&mut columns, &written, &step_s, &mut notes);
468
469 unit_notes.push(altitude_note(&columns, &written));
471 let mut noted: Vec<&str> = Vec::new();
472 for (column, check) in columns.iter().zip(&checks) {
473 if noted.contains(&column.name.as_str()) {
474 continue;
475 }
476 if let Some(note) = unit_note(column, *check, &columns) {
477 noted.push(&column.name);
478 unit_notes.push(note);
479 }
480 }
481 unit_notes.extend(gravity_note(&columns, &written, &written_time));
482 unit_notes.extend(marker_note(&columns));
483
484 let mut log = FlightLog {
485 format: LogFormat::EggtimerCsv,
486 logger: "Eggtimer".to_owned(),
487 serial_number: None,
488 firmware: None,
489 flight_number: None,
490 stated: Stated::default(),
491 time_s: time_s.clone(),
492 altitude_m: Vec::new(),
493 vertical_speed_m_s: None,
494 temperature_k: None,
495 battery_v: None,
496 channels: Vec::with_capacity(columns.len()),
497 events: Vec::new(),
498 notes: unit_notes,
499 };
500 for (column, values) in columns.iter().zip(&written) {
501 let gaps = values.iter().filter(|value| value.is_nan()).count();
502 if gaps > 0 {
503 notes.push(format!(
504 "the column {:?} leaves {gaps} cells empty: they are gaps",
505 short(&column.name)
506 ));
507 }
508 let (quantity, factor) = column.kind.quantity();
509 log.channels.push(Channel {
510 column: column.name.clone(),
511 quantity,
512 time_s: time_s.clone(),
513 values: values.iter().map(|value| value * factor).collect(),
514 });
515 }
516 log.events = events(&columns, &written, &time_s, &mut notes);
517
518 log.altitude_m = log
521 .channels
522 .first()
523 .map(|channel| channel.values.clone())
524 .unwrap_or_default();
525 let speed = |kind: Kind| {
526 columns
527 .iter()
528 .zip(&log.channels)
529 .find(|(column, _)| column.kind == kind)
530 .map(|(column, channel)| (column.name.as_str(), channel.values.clone()))
531 };
532 if let Some((_, values)) = speed(Kind::RawSpeed) {
533 log.vertical_speed_m_s = Some(values);
534 } else if let Some((name, values)) = speed(Kind::FilteredSpeed) {
535 log.notes.push(format!(
536 "the file has no raw velocity, `Veloc`, read as a speed; the vertical speed is its \
537 filtered barometric {name:?}"
538 ));
539 log.vertical_speed_m_s = Some(values);
540 }
541 if blank_rows > 0 {
542 log.notes.push(format!(
543 "{blank_rows} rows hold only empty cells, and were skipped"
544 ));
545 }
546 log.notes.extend(notes.into_vec());
547 Ok(log)
548}
549
550#[derive(Debug, Clone, Copy)]
553struct SpeedCheck {
554 altitude: usize,
555 matching: usize,
556 rows: usize,
557}
558
559fn speed_checks(
565 columns: &mut [Column],
566 written: &[Vec<f64>],
567 step_s: &[f64],
568 notes: &mut Notes,
569) -> Vec<Option<SpeedCheck>> {
570 let filtered = columns
571 .iter()
572 .position(|column| column.kind == Kind::FilteredAltitude);
573 let mut checks = vec![None; columns.len()];
574 for (index, check) in checks.iter_mut().enumerate() {
575 let altitude = match columns.get(index).map(|column| column.kind) {
577 Some(Kind::RawSpeed) => Some(0),
578 Some(Kind::FilteredSpeed) => filtered,
579 _ => None,
580 };
581 let (Some(altitude), Some(speed)) = (altitude, written.get(index)) else {
582 continue;
583 };
584 let Some(altitude_values) = written.get(altitude) else {
585 continue;
586 };
587 let (matching, rows) = speed_matches(speed, altitude_values, step_s);
588 let enough = rows == 0 || matching as f64 >= SPEED_MATCH_SHARE * rows as f64;
590 if enough {
591 *check = Some(SpeedCheck {
592 altitude,
593 matching,
594 rows,
595 });
596 continue;
597 }
598 let altitude_name = columns
599 .get(altitude)
600 .map(|column| short(&column.name))
601 .unwrap_or_default();
602 if let Some(column) = columns.get_mut(index) {
603 notes.push(format!(
604 "`{}` kept as written, not read as a speed: it is the change in \
605 `{altitude_name}` over the step, within 1 % or 1 ft/s, in only {matching} of \
606 {rows} rows after the first, under the 90 % the reader asks",
607 short(&column.name)
608 ));
609 column.kind = Kind::Unknown;
610 }
611 }
612 checks
613}
614
615fn speed_matches(speed: &[f64], altitude: &[f64], step_s: &[f64]) -> (usize, usize) {
618 let (mut rows, mut matching) = (0_usize, 0_usize);
619 for (row, step) in step_s.iter().enumerate() {
620 let (Some(&low), Some(&high), Some(&value)) =
621 (altitude.get(row), altitude.get(row + 1), speed.get(row + 1))
622 else {
623 continue;
624 };
625 let change = (high - low) / step;
626 if !(change.is_finite() && value.is_finite()) {
627 continue;
628 }
629 rows += 1;
630 if (value - change).abs() <= (SPEED_MATCH_RELATIVE * change.abs()).max(SPEED_MATCH_FT_S) {
631 matching += 1;
632 }
633 }
634 (matching, rows)
635}
636
637fn events(
641 columns: &[Column],
642 written: &[Vec<f64>],
643 time_s: &[f64],
644 notes: &mut Notes,
645) -> Vec<Event> {
646 struct Marker<'a> {
647 name: String,
648 values: &'a [f64],
649 kept: usize,
650 left_out: usize,
651 }
652 let mut markers: Vec<Marker> = columns
653 .iter()
654 .zip(written)
655 .filter(|(column, _)| column.kind == Kind::Marker)
656 .map(|(column, values)| Marker {
657 name: short(&column.name),
658 values,
659 kept: 0,
660 left_out: 0,
661 })
662 .collect();
663 let marked = |value: f64| value != 0.0 && !value.is_nan();
664 let mut events = Vec::new();
665 for (row, time) in time_s.iter().enumerate() {
666 for marker in &mut markers {
667 let value = marker.values.get(row).copied().unwrap_or(f64::NAN);
668 let before = row
669 .checked_sub(1)
670 .and_then(|above| marker.values.get(above))
671 .is_some_and(|&above| marked(above));
672 if !marked(value) || before {
673 continue;
674 }
675 if marker.kept < MAX_EVENTS_PER_MARKER {
676 marker.kept += 1;
677 events.push(Event {
678 time_s: *time,
679 name: marker.name.clone(),
680 });
681 } else {
682 marker.left_out += 1;
683 }
684 }
685 }
686 for marker in markers.iter().filter(|marker| marker.left_out > 0) {
687 notes.push(format!(
688 "the marker {:?} rises {} more times than the {MAX_EVENTS_PER_MARKER} kept as events",
689 marker.name, marker.left_out
690 ));
691 }
692 events
693}
694
695fn time_unit(written: &[f64], unit_notes: &mut Vec<String>) -> f64 {
699 let mut steps: Vec<f64> = written.windows(2).map(|pair| pair[1] - pair[0]).collect();
700 if steps.is_empty() {
701 unit_notes.push(
702 "`T` read in seconds, assumed: the file holds one row, so there is no step between \
703 rows to tell seconds from milliseconds"
704 .to_owned(),
705 );
706 return 1.0;
707 }
708 steps.sort_by(f64::total_cmp);
709 let middle = steps.len() / 2;
712 let median = if steps.len().is_multiple_of(2) {
713 (steps[middle - 1] + steps[middle]) / 2.0
714 } else {
715 steps[middle]
716 };
717 let (unit, per_second) = if median > MILLISECONDS_ABOVE {
718 ("milliseconds", 1000.0)
719 } else {
720 ("seconds", 1.0)
721 };
722 unit_notes.push(format!(
723 "`T` read in {unit}, from the step: the median step between rows is {}, and the reader \
724 reads one above 5 as milliseconds (at the manuals' 1 to 33 samples a second, a step is \
725 0.03 to 1 in seconds and 30 to 1,000 in milliseconds)",
726 plain(median, 4)
727 ));
728 per_second
729}
730
731fn altitude_note(columns: &[Column], written: &[Vec<f64>]) -> String {
734 let mut note = "`Alt` read in feet, from the manual: the Classic and TRS manuals (p37) state \
735 feet; the Quantum and Proton manuals give every height in feet and no \
736 Eggtimer manual offers meters"
737 .to_owned();
738 let find = |names: &[&str]| {
739 columns
740 .iter()
741 .zip(written)
742 .find(|(column, _)| names.contains(&column.name.as_str()))
743 };
744 if let (Some((g_column, g)), Some((ft_column, ft_s2))) =
745 (find(&["AccelG", "Accel"]), find(&["AccelFPSS"]))
746 {
747 let (mut product, mut square) = (0.0, 0.0);
749 for (g, ft_s2) in g.iter().zip(ft_s2) {
750 if g.is_finite() && ft_s2.is_finite() {
751 product += g * ft_s2;
752 square += g * g;
753 }
754 }
755 let ratio = product / square;
756 if ratio.is_finite() {
757 note.push_str(&format!(
758 "; the file's `{}` is {} times its `{}`, and one g is 32.17 ft/s²",
759 ft_column.name,
760 plain(ratio, 2),
761 g_column.name
762 ));
763 }
764 }
765 note
766}
767
768fn unit_note(column: &Column, check: Option<SpeedCheck>, columns: &[Column]) -> Option<String> {
771 let name = short(&column.name);
772 let evidence = |what: &str| match check {
773 Some(check) if check.rows > 0 => format!(
774 "it is the change in `{}` over the step, within 1 % or 1 ft/s, in {} of {} rows after \
775 the first, where the reader asks 90 %",
776 columns
777 .get(check.altitude)
778 .map(|altitude| short(&altitude.name))
779 .unwrap_or_default(),
780 check.matching,
781 check.rows
782 ),
783 Some(_) => format!("the file has one row, so no step to check it against {what} on"),
784 None => format!("the file has no {what} to check it against"),
785 };
786 let note = match column.kind {
787 Kind::FilteredAltitude => format!(
788 "`{name}` read in feet, from the manual: it is the logger's filtered `Alt` (Quantum \
789 p38, Proton p45), in the same unit"
790 ),
791 Kind::RawSpeed => format!(
792 "`{name}` read in feet per second, from the manual: the Classic and TRS manuals (p37) \
793 give the velocity in ft/sec, and the Proton's (p45) as the change in `Alt` over the \
794 step; {}",
795 evidence("`Alt`")
796 ),
797 Kind::FilteredSpeed => format!(
798 "`{name}` read in feet per second, from the manual: the Proton's manual (p46) gives \
799 it as the change in the filtered altitude over the step, and the Excel guide (p7) \
800 graphs the velocity in ft/sec; {}",
801 evidence("filtered altitude")
802 ),
803 Kind::AccelerationG if !column.names_its_unit() => format!(
804 "`{name}` read in g, from the manual: the Proton's manual (p45) gives the \
805 accelerometer's raw value in G's"
806 ),
807 Kind::AccelerationFtS2 if !column.names_its_unit() => format!(
808 "`{name}` read in feet per second squared, from the manual: the Proton's manual \
809 (p45) gives the accelerometer's data in ft/sec^2"
810 ),
811 Kind::InertialSpeed => format!(
812 "`{name}` read in feet per second, from the manual: the Proton's manual (p45) gives \
813 the accelerometer's velocity in ft/sec"
814 ),
815 Kind::InertialAltitude => format!(
816 "`{name}` read in feet, from the manual: the Proton's manual (p45) gives the \
817 accelerometer's distance traveled in ft"
818 ),
819 Kind::Altitude
820 | Kind::AccelerationG
821 | Kind::AccelerationFtS2
822 | Kind::Marker
823 | Kind::Unknown => return None,
824 };
825 Some(note)
826}
827
828fn gravity_note(columns: &[Column], written: &[Vec<f64>], written_time: &[f64]) -> Option<String> {
834 let ft_s2_per_g = STANDARD_GRAVITY_MPS2 / FOOT_M;
835 let mut accelerations = columns
836 .iter()
837 .zip(written)
838 .filter_map(|(column, values)| match column.kind {
839 Kind::AccelerationG => Some((column, values, 1.0)),
840 Kind::AccelerationFtS2 => Some((column, values, ft_s2_per_g)),
841 _ => None,
842 });
843 let (column, values, per_g) = accelerations.next()?;
844 let others = if accelerations.next().is_some() {
845 "; the file's other acceleration columns are the same accelerometer's"
846 } else {
847 ""
848 };
849 let name = short(&column.name);
850 let mut pad: Vec<f64> = written_time
851 .iter()
852 .zip(values)
853 .filter(|(time, value)| **time < 0.0 && value.is_finite())
854 .map(|(_, value)| value / per_g)
855 .collect();
856 let source = "the Proton's manual doesn't say, and its release notes (2.01C, p8) have it \
857 calibrate the accelerometer as it is armed, standing up";
858 if pad.is_empty() {
859 return Some(format!(
860 "`{name}` may or may not hold gravity, not known: the file has no rows before the \
861 start of flight (`T` below 0) to read the pad on; {source}{others}"
862 ));
863 }
864 pad.sort_by(f64::total_cmp);
865 let middle = pad.len() / 2;
868 let median = if pad.len().is_multiple_of(2) {
869 (pad[middle - 1] + pad[middle]) / 2.0
870 } else {
871 pad[middle]
872 };
873 let reading = format!(
874 "its median over the {} rows before the start of flight (`T` below 0) is {} g, where an \
875 accelerometer that keeps gravity reads 1 g standing still and one that takes it out 0; \
876 {source}{others}",
877 pad.len(),
878 plain(median, 2)
879 );
880 Some(if median.abs() < GRAVITY_READING_G {
881 format!("`{name}` has gravity taken out, from the pad: {reading}")
882 } else if (median - 1.0).abs() < GRAVITY_READING_G {
883 format!("`{name}` holds gravity, from the pad: {reading}")
884 } else {
885 format!("`{name}` may or may not hold gravity, not known: {reading}")
886 })
887}
888
889fn marker_note(columns: &[Column]) -> Option<String> {
891 let markers: Vec<String> = columns
892 .iter()
893 .filter(|column| column.kind == Kind::Marker)
894 .map(|column| format!("`{}`", short(&column.name)))
895 .take(MAX_LISTED + 1)
896 .collect();
897 let count = columns
898 .iter()
899 .filter(|column| column.kind == Kind::Marker)
900 .count();
901 if count == 0 {
902 return None;
903 }
904 let speeds = if columns
905 .iter()
906 .any(|column| column.name == "MaxV" || column.name == "MaxA")
907 {
908 ", and the Proton's release notes (1.02N, p13) the top speed, ft/s, in `MaxV` and the \
909 top acceleration, ft/s², in `MaxA`"
910 } else {
911 ""
912 };
913 Some(format!(
914 "the marker columns, kept as written: {}; each is 0 but at the sample that marks its \
915 event, where the Quantum's and Proton's manuals (p38, p46) put the raw altitude, in \
916 feet{speeds}",
917 list(&markers, count)
918 ))
919}
920
921fn list(names: &[String], count: usize) -> String {
924 if count > MAX_LISTED {
925 let shown = MAX_LISTED - 1;
926 return format!(
927 "{} and {} more columns",
928 names.get(..shown).unwrap_or(names).join(", "),
929 count - shown
930 );
931 }
932 match names {
933 [] => String::new(),
934 [only] => only.clone(),
935 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
936 }
937}
938
939fn plain(value: f64, decimals: usize) -> String {
941 let text = format!("{value:.decimals$}");
942 if text.contains('.') {
943 text.trim_end_matches('0').trim_end_matches('.').to_owned()
944 } else {
945 text
946 }
947}
948
949fn syntax(line: usize, message: String) -> LogError {
950 LogError::Syntax {
951 format: FORMAT,
952 line,
953 message,
954 }
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960
961 const TRS: &str = "T,Alt,Veloc,FAlt,FVeloc,LDA,LowV,Apogee,N-O,Drogue,Main";
962
963 fn close(a: f64, b: f64) -> bool {
964 (a - b).abs() <= 1e-12 * b.abs().max(1.0)
965 }
966
967 fn prefix(note: &str) -> &str {
970 note.split(": ").next().unwrap_or(note)
971 }
972
973 #[test]
974 fn recognizes_the_eggtimer_headers_and_no_others() {
975 for header in [
976 TRS,
977 "T,Alt,Veloc,Filt_Alt,FVeloc,LDA,LowV,Apogee,N-O,Drogue,Main",
978 "T,Alt,FAlt,FVeloc,LDA,LowV,Apogee,N-O,Drogue,Main,AUX",
979 "T,Alt,Veloc,KAlt,KVeloc,LDA,BurnT,LowV,Apogee,N-O,CHA,CHB",
980 "T, Alt, VRaw, VFilt",
981 "T,Alt,Veloc,AccelG,AccelFPSS,VAccel,AltAccel,FAlt,FVeloc,FAccelG,FAccelFPSS,\
982 FAccelVel,FAccelAlt,LDA,MaxV,MaxA,BO#1,LowV,Apogee,N-O,CH1,CH2",
983 "T,Alt,Apogee",
984 ] {
985 assert!(recognizes(&format!("\r\n{header}\r\n0,0\r\n")), "{header}");
986 }
987 for text in [
988 "",
989 "T,Alt\n",
990 "T,Alt,Foo\n",
991 "T\tAlt\tVeloc\n",
992 "t,alt,veloc\n",
993 "Time,Altitude,Velocity\n",
994 "Time,Altitude,Pressure,Velocity,Temperature,Events,Voltages\n",
995 "Alt,T,Veloc\n",
996 "# T,Alt,Veloc\n",
997 ] {
998 assert!(!recognizes(text), "{text:?}");
999 }
1000 assert_eq!(kind_of("CH12"), Some(Kind::Marker));
1001 assert_eq!(kind_of("Boost#3"), Some(Kind::Marker));
1002 assert_eq!(kind_of("CH"), None);
1003 assert_eq!(kind_of("BO#x"), None);
1004 }
1005
1006 #[test]
1009 fn reads_the_layout_in_seconds() {
1010 let text = format!(
1011 "{TRS}\n\
1012 0,0,0,0,0,0,0,0,0,0,0\n\
1013 0.05,10,200,2,40,0,0,0,0,0,0\n\
1014 0.1,25,300,7,100,25,0,0,0,0,0\n\
1015 0.15,30,100,13.5,130,0,0,30,0,0,0\n\
1016 0.2,29,-20,18,90,0,0,0,29,29,0\n"
1017 );
1018 let log = read(&text).unwrap();
1019 assert_eq!(log.format, LogFormat::EggtimerCsv);
1020 assert_eq!(log.logger, "Eggtimer");
1021 assert_eq!(log.time_s, [0.0, 0.05, 0.1, 0.15, 0.2]);
1022 assert!(close(log.altitude_m[2], 25.0 * 0.3048));
1023 let speed = log.vertical_speed_m_s.as_deref().unwrap();
1024 assert!(close(speed[1], 200.0 * 0.3048));
1025 assert_eq!(log.channels.len(), 10);
1026 assert_eq!(log.channels[2].quantity, Quantity::AltitudeAboveGround);
1027 assert!(close(log.channels[2].values[3], 13.5 * 0.3048));
1028 assert_eq!(log.channels[3].quantity, Quantity::Speed);
1029 assert_eq!(log.channels[4].column, "LDA");
1031 assert_eq!(log.channels[4].quantity, Quantity::Unrecognized);
1032 assert_eq!(log.channels[4].values, [0.0, 0.0, 25.0, 0.0, 0.0]);
1033 let events: Vec<(f64, &str)> = log
1034 .events
1035 .iter()
1036 .map(|event| (event.time_s, event.name.as_str()))
1037 .collect();
1038 assert_eq!(
1039 events,
1040 [
1041 (0.1, "LDA"),
1042 (0.15, "Apogee"),
1043 (0.2, "N-O"),
1044 (0.2, "Drogue")
1045 ]
1046 );
1047 assert_eq!(log.notes.len(), 6, "{:?}", log.notes);
1048 assert_eq!(
1049 log.notes[0],
1050 "`T` read in seconds, from the step: the median step between rows is 0.05, and the \
1051 reader reads one above 5 as milliseconds (at the manuals' 1 to 33 samples a second, \
1052 a step is 0.03 to 1 in seconds and 30 to 1,000 in milliseconds)"
1053 );
1054 assert_eq!(
1055 log.notes[1],
1056 "`Alt` read in feet, from the manual: the Classic and TRS manuals (p37) state feet; \
1057 the Quantum and Proton manuals give every height in feet and no Eggtimer manual \
1058 offers meters"
1059 );
1060 assert_eq!(
1061 log.notes[2],
1062 "`Veloc` read in feet per second, from the manual: the Classic and TRS manuals (p37) \
1063 give the velocity in ft/sec, and the Proton's (p45) as the change in `Alt` over the \
1064 step; it is the change in `Alt` over the step, within 1 % or 1 ft/s, in 4 of 4 rows \
1065 after the first, where the reader asks 90 %"
1066 );
1067 assert!(log.notes[3].starts_with("`FAlt` read in feet, from the manual: "));
1068 assert!(
1069 log.notes[4].starts_with("`FVeloc` read in feet per second, from the manual: ")
1070 && log.notes[4].contains("the change in `FAlt` over the step")
1071 && log.notes[4].contains("in 4 of 4 rows after the first"),
1072 "{}",
1073 log.notes[4]
1074 );
1075 assert_eq!(
1076 log.notes[5],
1077 "the marker columns, kept as written: `LDA`, `LowV`, `Apogee`, `N-O`, `Drogue` and \
1078 `Main`; each is 0 but at the sample that marks its event, where the Quantum's \
1079 and Proton's manuals (p38, p46) put the raw altitude, in feet"
1080 );
1081 }
1082
1083 #[test]
1086 fn the_step_decides_the_time_unit() {
1087 let rows = |step: f64| -> String {
1088 (0..4)
1089 .map(|i| format!("{},{},{}\n", f64::from(i) * step, i * 10, 0))
1090 .collect()
1091 };
1092 let millis = read(&format!("T,Alt,LDA\n{}", rows(50.0))).unwrap();
1093 assert_eq!(millis.time_s, [0.0, 0.05, 0.1, 0.15]);
1094 assert!(millis.notes[0].starts_with("`T` read in milliseconds, from the step: "));
1095 assert!(millis.notes[0].contains("median step between rows is 50,"));
1096 let slow = read(&format!("T,Alt,LDA\n{}", rows(1000.0))).unwrap();
1097 assert_eq!(prefix(&slow.notes[0]), prefix(&millis.notes[0]));
1098 assert!(slow.notes[0].contains("is 1000,"));
1099
1100 let at_the_line = read(&format!("T,Alt,LDA\n{}", rows(5.0))).unwrap();
1101 assert_eq!(at_the_line.time_s, [0.0, 5.0, 10.0, 15.0]);
1102 let above = read(&format!("T,Alt,LDA\n{}", rows(5.01))).unwrap();
1103 assert!(close(above.time_s[1], 0.00501));
1104 let seconds = read(&format!("T,Alt,LDA\n{}", rows(0.5))).unwrap();
1105 assert_eq!(
1106 prefix(&seconds.notes[0]),
1107 "`T` read in seconds, from the step"
1108 );
1109 assert_eq!(prefix(&at_the_line.notes[0]), prefix(&seconds.notes[0]));
1110
1111 let paused = read("T,Alt,LDA\n0,0,0\n0.05,1,0\n0.1,2,0\n100,3,0\n").unwrap();
1113 assert_eq!(paused.time_s, [0.0, 0.05, 0.1, 100.0]);
1114
1115 let one = read("T,Alt,LDA\n120,4,0\n").unwrap();
1116 assert_eq!(one.time_s, [120.0]);
1117 assert!(one.notes[0].starts_with("`T` read in seconds, assumed: "));
1118 }
1119
1120 #[test]
1123 fn reads_the_accelerometer_columns() {
1124 let text = "T,Alt,Veloc,AccelG,AccelFPSS,VAccel,AltAccel,LDA,MaxV,MaxA,CH1\r\
1125 -0.1,0,0,0,0,0,0,0,0,0,0\r\
1126 0,1,10,2,64.4,0,0,0,0,0,0\r\
1127 0.1,12,110,10,321.7,40,2,0,0,321,0\r\
1128 0.2,40,280,5,160.9,80,9,40,80,0,0\r";
1129 let log = read(text).unwrap();
1130 assert_eq!(log.time_s, [-0.1, 0.0, 0.1, 0.2]);
1131 let quantities: Vec<Quantity> = log.channels.iter().map(|c| c.quantity).collect();
1132 assert_eq!(
1133 quantities,
1134 [
1135 Quantity::AltitudeAboveGround,
1136 Quantity::Speed,
1137 Quantity::Acceleration,
1138 Quantity::Acceleration,
1139 Quantity::Speed,
1140 Quantity::InertialAltitude,
1141 Quantity::Unrecognized,
1142 Quantity::Unrecognized,
1143 Quantity::Unrecognized,
1144 Quantity::Unrecognized,
1145 ]
1146 );
1147 assert!(close(log.channels[2].values[2], 10.0 * 9.806_65));
1148 assert!(close(log.channels[3].values[2], 321.7 * 0.3048));
1149 assert!(close(log.channels[4].values[3], 80.0 * 0.3048));
1150 assert!(close(log.channels[5].values[3], 9.0 * 0.3048));
1151 assert_eq!(log.channels[7].values, [0.0, 0.0, 0.0, 80.0]);
1152 let names: Vec<&str> = log.events.iter().map(|event| event.name.as_str()).collect();
1153 assert_eq!(names, ["MaxA", "LDA", "MaxV"]);
1154 assert!(
1155 log.notes[1].ends_with(
1156 "; the file's `AccelFPSS` is 32.17 times its `AccelG`, and one g is 32.17 ft/s²"
1157 ),
1158 "{}",
1159 log.notes[1]
1160 );
1161 assert!(log.notes[2].starts_with("`Veloc` read in feet per second, from the manual: "));
1163 assert!(log.notes[3].starts_with("`VAccel` read in feet per second, from the manual: "));
1164 assert!(log.notes[4].starts_with("`AltAccel` read in feet, from the manual: "));
1165 assert_eq!(
1166 log.notes[5],
1167 "`AccelG` has gravity taken out, from the pad: its median over the 1 rows before the \
1168 start of flight (`T` below 0) is 0 g, where an accelerometer that keeps gravity reads \
1169 1 g standing still and one that takes it out 0; the Proton's manual doesn't say, and \
1170 its release notes (2.01C, p8) have it calibrate the accelerometer as it is armed, \
1171 standing up; the file's other acceleration columns are the same accelerometer's"
1172 );
1173 assert!(
1174 log.notes[6].starts_with(
1175 "the marker columns, kept as written: `LDA`, `MaxV`, `MaxA` and `CH1`; "
1176 ) && log.notes[6].ends_with(
1177 ", and the Proton's release notes (1.02N, p13) the top speed, ft/s, in `MaxV` \
1178 and the top acceleration, ft/s², in `MaxA`"
1179 ),
1180 "{}",
1181 log.notes[6]
1182 );
1183 assert_eq!(log.notes.len(), 7, "{:?}", log.notes);
1184 }
1185
1186 #[test]
1190 fn the_acceleration_unit_and_gravity_are_noted() {
1191 let log = read(
1192 "T,Alt,Accel,AccelFPSS,LDA
1193-0.1,0,1,32.2,0
11940,0,1.1,35.4,0
11950.1,3,6,193,3
1196",
1197 )
1198 .unwrap();
1199 let notes = log.notes.join("\n");
1200 assert!(
1201 notes.contains(
1202 "`Accel` read in g, from the manual: the Proton's manual (p45) gives the \
1203 accelerometer's raw value in G's"
1204 ),
1205 "{notes}"
1206 );
1207 assert!(
1208 notes.contains("the file's `AccelFPSS` is 32.17 times its `Accel`"),
1209 "{notes}"
1210 );
1211 assert!(
1212 notes.contains(
1213 "`Accel` holds gravity, from the pad: its median over the 1 rows before the start \
1214 of flight (`T` below 0) is 1 g"
1215 ),
1216 "{notes}"
1217 );
1218 assert!(!notes.contains("AccelG"), "{notes}");
1219 assert!(close(log.channels[1].values[0], 9.806_65));
1220
1221 let unsure = read(
1222 "T,Alt,AccelG,LDA
1223-0.2,0,3,0
1224-0.1,0,3,0
12250,1,4,0
1226",
1227 )
1228 .unwrap();
1229 assert!(
1230 unsure.notes.iter().any(|note| note.starts_with(
1231 "`AccelG` may or may not hold gravity, not known: its median over the 2 rows \
1232 before the start of flight (`T` below 0) is 3 g"
1233 )),
1234 "{:?}",
1235 unsure.notes
1236 );
1237 let no_pad = read(
1238 "T,Alt,FAccelFPSS,LDA
12390,0,0,0
12400.1,1,64,0
1241",
1242 )
1243 .unwrap();
1244 assert!(
1245 no_pad.notes.iter().any(|note| note.starts_with(
1246 "`FAccelFPSS` may or may not hold gravity, not known: the file has no rows before \
1247 the start of flight"
1248 )),
1249 "{:?}",
1250 no_pad.notes
1251 );
1252 let in_ft = read(
1254 "T,Alt,AccelFPSS,LDA
1255-0.1,0,32.2,0
12560,1,40,0
1257",
1258 )
1259 .unwrap();
1260 assert!(
1261 in_ft
1262 .notes
1263 .iter()
1264 .any(|note| note.starts_with("`AccelFPSS` holds gravity, from the pad: ")),
1265 "{:?}",
1266 in_ft.notes
1267 );
1268 }
1269
1270 #[test]
1273 fn a_speed_that_isnt_its_altitudes_change_is_kept_as_written() {
1274 let text = "T,Alt,Veloc,FAlt,FVeloc,LDA
12770,0,0,0,0,0
12781,10,10000,5,5,0
12792,30,20000,15,10,0
1280";
1281 let log = read(text).unwrap();
1282 assert_eq!(log.channels[1].column, "Veloc");
1283 assert_eq!(log.channels[1].quantity, Quantity::Unrecognized);
1284 assert_eq!(log.channels[1].values, [0.0, 10000.0, 20000.0]);
1285 assert_eq!(log.channels[3].quantity, Quantity::Speed);
1286 assert_eq!(
1287 log.vertical_speed_m_s.as_deref(),
1288 Some(&[0.0, 5.0 * 0.3048, 10.0 * 0.3048][..])
1289 );
1290 let notes = log.notes.join("\n");
1291 assert!(
1292 notes.contains(
1293 "`Veloc` kept as written, not read as a speed: it is the change in `Alt` over the \
1294 step, within 1 % or 1 ft/s, in only 0 of 2 rows after the first, under the 90 % \
1295 the reader asks"
1296 ),
1297 "{notes}"
1298 );
1299 assert!(!notes.contains("`Veloc` read in"), "{notes}");
1300 assert!(notes.contains("the file has no raw velocity, `Veloc`, read as a speed"));
1301
1302 let rows = |misses: usize| -> String {
1304 (0..=10)
1305 .map(|i| {
1306 let speed = if i > 0 && i <= misses {
1307 99
1308 } else {
1309 10 * usize::from(i > 0)
1310 };
1311 format!("{i},{},{speed},0\n", 10 * i)
1312 })
1313 .collect()
1314 };
1315 let nine = read(&format!("T,Alt,Veloc,LDA\n{}", rows(1))).unwrap();
1316 assert_eq!(nine.channels[1].quantity, Quantity::Speed);
1317 assert!(
1318 nine.notes
1319 .iter()
1320 .any(|note| note.contains("in 9 of 10 rows after the first"))
1321 );
1322 let eight = read(&format!("T,Alt,Veloc,LDA\n{}", rows(2))).unwrap();
1323 assert_eq!(eight.channels[1].quantity, Quantity::Unrecognized);
1324 assert!(eight.vertical_speed_m_s.is_none());
1325 }
1326
1327 #[test]
1329 fn a_repeated_name_is_noted_once() {
1330 let log = read(
1331 "T,Alt,Veloc,Veloc,LDA
13320,0,0,0,0
13330.1,1,10,10,0
1334",
1335 )
1336 .unwrap();
1337 let veloc = log
1338 .notes
1339 .iter()
1340 .filter(|note| note.starts_with("`Veloc` read in"))
1341 .count();
1342 assert_eq!(veloc, 1, "{:?}", log.notes);
1343 assert_eq!(log.channels[2].quantity, Quantity::Speed);
1344 }
1345
1346 #[test]
1350 fn a_long_marker_name_rising_often_is_bounded() {
1351 let name = "X".repeat(100_000);
1352 let rows: String = (0..4000).map(|i| format!("{i},0,0,{}\n", i % 2)).collect();
1353 let text = format!("T,Alt,LDA,{name}\n{rows}");
1354 let log = read(&text).unwrap();
1355 assert_eq!(log.events.len(), MAX_EVENTS_PER_MARKER);
1356 assert!(
1357 log.events
1358 .iter()
1359 .all(|event| event.name.chars().count() <= 80)
1360 );
1361 assert!(log.events[0].name.ends_with('…'));
1362 assert_eq!(log.events[15].time_s, 31.0);
1363 let notes = log.notes.join("\n");
1364 assert!(
1365 notes.contains(&format!(
1366 "rises {} more times than the 16 kept as events",
1367 2000 - MAX_EVENTS_PER_MARKER
1368 )),
1369 "{}",
1370 short(¬es)
1371 );
1372 let bytes: usize = log.notes.iter().map(String::len).sum::<usize>()
1373 + log
1374 .events
1375 .iter()
1376 .map(|event| event.name.len())
1377 .sum::<usize>();
1378 assert!(bytes < 10_000, "{bytes} bytes of notes and event names");
1379 assert_eq!(log.channels[2].column.len(), 100_000);
1381 }
1382
1383 #[test]
1385 fn the_marker_note_stays_short() {
1386 let markers: String = (1..=40).map(|i| format!(",CH{i}")).collect();
1387 let text = format!(
1388 "T,Alt,LDA{markers}
13890,0{}
1390",
1391 ",0".repeat(41)
1392 );
1393 let log = read(&text).unwrap();
1394 let note = log
1395 .notes
1396 .iter()
1397 .find(|note| note.starts_with("the marker columns, kept as written: "))
1398 .unwrap();
1399 assert!(
1400 note.starts_with(
1401 "the marker columns, kept as written: `LDA`, `CH1`, `CH2`, `CH3`, `CH4` and 36 more \
1402 columns; "
1403 ),
1404 "{note}"
1405 );
1406 assert!(
1407 read(
1408 "T,Alt,Veloc
14090,0,0
1410"
1411 )
1412 .unwrap()
1413 .notes
1414 .iter()
1415 .all(|note| !note.contains("marker columns"))
1416 );
1417 }
1418
1419 #[test]
1421 fn each_run_of_a_marker_is_one_event() {
1422 let text = "T,Alt,Veloc,Apogee,Main\n\
1423 0,100,0,0,0\n0.5,101,2,101,0\n1,100,-2,101,0\n1.5,99,-2,0,0\n\
1424 2,98,-2,98,98\n2.5,97,-2,0,98\n";
1425 let log = read(text).unwrap();
1426 let events: Vec<(f64, &str)> = log
1427 .events
1428 .iter()
1429 .map(|event| (event.time_s, event.name.as_str()))
1430 .collect();
1431 assert_eq!(events, [(0.5, "Apogee"), (2.0, "Apogee"), (2.0, "Main")]);
1432 }
1433
1434 #[test]
1437 fn the_filtered_velocity_stands_in_for_the_raw_one() {
1438 let log = read("T,Alt,FAlt,FVeloc,LDA\n0,0,0,0,0\n0.05,2,1,20,0\n").unwrap();
1439 assert_eq!(
1440 log.vertical_speed_m_s.as_deref(),
1441 Some(&[0.0, 20.0 * 0.3048][..])
1442 );
1443 assert!(
1444 log.notes
1445 .iter()
1446 .any(|note| note
1447 .contains("the vertical speed is its filtered barometric \"FVeloc\""))
1448 );
1449 let old = read("T, Alt, VRaw, VFilt\n0, 0, 0, 0\n50, 1, 20, 4\n100, 3, 40, 12\n").unwrap();
1450 assert_eq!(old.time_s, [0.0, 0.05, 0.1]);
1451 assert_eq!(
1452 old.vertical_speed_m_s.as_deref(),
1453 Some(&[0.0, 20.0 * 0.3048, 40.0 * 0.3048][..])
1454 );
1455 assert!(old.notes[2].starts_with("`VRaw` read in feet per second, from the manual: "));
1456 assert!(
1457 old.notes[3].ends_with("the file has no filtered altitude to check it against"),
1458 "{}",
1459 old.notes[3]
1460 );
1461 assert!(old.notes[3].starts_with("`VFilt` read in feet per second, from the manual: "));
1462 assert!(
1463 read("T,Alt,LDA\n0,0,0\n")
1464 .unwrap()
1465 .vertical_speed_m_s
1466 .is_none()
1467 );
1468 }
1469
1470 #[test]
1472 fn unknown_columns_are_kept_and_named_channels_are_markers() {
1473 let log = read("T,Alt,Veloc,Baro,LDA,Apogee,Drogue.1\n0,0,0,7,0,0,0\n0.05,4,80,8,4,0,4\n")
1474 .unwrap();
1475 assert_eq!(log.channels[2].quantity, Quantity::Unrecognized);
1476 assert_eq!(log.channels[2].values, [7.0, 8.0]);
1477 let names: Vec<&str> = log.events.iter().map(|event| event.name.as_str()).collect();
1478 assert_eq!(names, ["LDA", "Drogue.1"]);
1479 let notes = log.notes.join("\n");
1480 assert!(notes.contains("\"Baro\" isn't one this reader knows, and was kept as written"));
1481 assert!(notes.contains("\"Drogue.1\" isn't one this reader knows; it comes after `LDA`"));
1482 }
1483
1484 #[test]
1486 fn empty_rows_are_skipped_and_empty_cells_are_gaps() {
1487 let text = format!(
1488 "{TRS}\r\n0,0,0,0,0,0,0,0,0,0,0\r\n0.05,2,,1,20,0,0,0,0,0,0\r\n0.1,5,60\r\n\
1489 ,,,,,,,,,,\r\n,,,,,,,,,,\r\n , ,\r\n"
1490 );
1491 let log = read(&text).unwrap();
1492 assert_eq!(log.time_s, [0.0, 0.05, 0.1]);
1493 assert!(log.channels[1].values[1].is_nan());
1494 assert!(log.channels[2].values[2].is_nan());
1495 let notes = log.notes.join("\n");
1496 assert!(
1497 notes.contains("3 rows hold only empty cells, and were skipped"),
1498 "{notes}"
1499 );
1500 assert!(notes.contains("the column \"Veloc\" leaves 1 cells empty: they are gaps"));
1501 assert!(notes.contains("the column \"Main\" leaves 1 cells empty: they are gaps"));
1502 }
1503
1504 #[test]
1506 fn malformed_files_are_refused_where_they_go_wrong() {
1507 let cases = [
1508 (String::new(), "the file is empty"),
1509 ("\r\n\r\n".to_owned(), "the file is empty"),
1510 (
1511 "Time,Altitude\n0,0\n".to_owned(),
1512 "isn't an Eggtimer header row",
1513 ),
1514 (format!("{TRS}\n"), "no data rows"),
1515 (format!("{TRS}\n,,,,,,,,,,\n"), "no data rows"),
1516 (
1517 "T,Alt,,LDA\n".to_owned(),
1518 "line 1: the header's column 3 has no name",
1519 ),
1520 (
1521 "T,Alt,LDA\n0,0,0,0\n".to_owned(),
1522 "line 2: the row has 4 cells and the header names 3",
1523 ),
1524 (
1525 "T,Alt,LDA\n0,0,0\n0.05,x,0\n".to_owned(),
1526 "line 3: \"x\" in cell 2, under \"Alt\", isn't a number",
1527 ),
1528 (
1529 "T,Alt,LDA\n0,0,0\n0.05,1,NaN\n".to_owned(),
1530 "line 3: \"NaN\" in cell 3, under \"LDA\"",
1531 ),
1532 (
1533 "T,Alt,LDA\nx,0,0\n".to_owned(),
1534 "line 2: \"x\" in cell 1, under \"T\"",
1535 ),
1536 (
1537 "T,Alt,LDA\n,0,0\n".to_owned(),
1538 "line 2: the time, the row's first cell, is empty",
1539 ),
1540 (
1541 "T,Alt,LDA\n0,0,0\n0,1,0\n".to_owned(),
1542 "line 3: the time 0 comes at or before the one above it, 0",
1543 ),
1544 (
1545 "T,Alt,LDA\n\n1,0,0\n\n0.5,1,0\n".to_owned(),
1546 "line 5: the time 0.5 comes at or before",
1547 ),
1548 ];
1549 for (text, expected) in cases {
1550 let error = read(&text).unwrap_err().to_string();
1551 assert!(error.contains(expected), "{text:?}: {error}");
1552 }
1553 }
1554
1555 #[test]
1557 fn a_wide_header_over_short_rows_is_refused() {
1558 let names: String = (0..2000).map(|i| format!(",u{i}")).collect();
1559 let rows = "0\n".repeat(200);
1560 let text = format!("T,Alt,LDA{names}\n{rows}");
1561 let error = read(&text).unwrap_err().to_string();
1562 assert!(
1563 error.contains("too few for the header to be this file's"),
1564 "{error}"
1565 );
1566 }
1567
1568 #[test]
1570 fn line_ends() {
1571 let rows = ["T,Alt,Veloc,LDA", "0,0,0,0", "0.05,2,40,0", "0.1,6,80,6"];
1572 let reference = read(&rows.join("\n")).unwrap();
1573 for end in ["\r", "\n", "\r\n", "\r\r\n"] {
1574 let text = format!("{}{end}", rows.join(end));
1575 assert_eq!(read(&text).unwrap(), reference, "{end:?}");
1576 }
1577 assert_eq!(reference.events.len(), 1);
1578 }
1579
1580 #[test]
1583 fn notes_stay_few() {
1584 let unknown: String = (0..100).map(|i| format!(",u{i}")).collect();
1585 let text = format!("T,Alt,Veloc{unknown}\n0,0,0{}\n", ",1".repeat(100));
1586 let log = read(&text).unwrap();
1587 assert_eq!(log.channels.len(), 102);
1588 assert_eq!(log.notes.len(), 3 + 20 + 1, "{:?}", log.notes);
1590 assert!(
1591 log.notes[23].starts_with("and 80 more notes"),
1592 "{:?}",
1593 log.notes[23]
1594 );
1595 }
1596
1597 #[test]
1599 fn a_long_line_is_quoted_short() {
1600 let junk = "\u{fffd}".repeat(100_000);
1601 let error = read(&junk).unwrap_err().to_string();
1602 assert!(error.len() < 400, "{} bytes", error.len());
1603 }
1604
1605 #[test]
1606 fn numbers_are_written_plainly() {
1607 assert_eq!(plain(0.050_000_000_000_000_71, 4), "0.05");
1608 assert_eq!(plain(50.0, 4), "50");
1609 assert_eq!(plain(32.166, 2), "32.17");
1610 assert_eq!(plain(-0.0, 2), "-0");
1611 }
1612
1613 proptest::proptest! {
1614 #[test]
1617 fn any_text_is_read_or_refused(
1618 rows in proptest::collection::vec("[-0-9.,x \r]{0,30}", 0..12)
1619 ) {
1620 let text = format!("{TRS}\n{}", rows.join("\n"));
1621 if let Ok(log) = read(&text) {
1622 proptest::prop_assert!(log.time_s.windows(2).all(|pair| pair[1] > pair[0]));
1623 proptest::prop_assert_eq!(log.time_s.len(), log.altitude_m.len());
1624 for channel in &log.channels {
1625 proptest::prop_assert_eq!(channel.time_s.len(), channel.values.len());
1626 }
1627 for event in &log.events {
1628 proptest::prop_assert!(log.time_s.contains(&event.time_s));
1629 }
1630 }
1631 }
1632 }
1633}