1use std::collections::HashMap;
176
177use crate::error::LogError;
178use crate::log::{Channel, FlightLog, LogFormat, Quantity, Stated, too_sparse};
179use crate::perfectflite::FOOT_M;
180use crate::table::{Notes, number, numbered_lines, short};
181
182const FORMAT: &str = "Featherweight GPS CSV";
184
185pub const MAX_TRACKERS: usize = 64;
188
189pub const MAX_CHANNELS: usize = 4_096;
192
193const MIN_STEP_S: f64 = 0.5;
197
198const MAX_STEP_S: f64 = 2.5;
201
202const MIN_SPEED: f64 = 20.0;
205
206const MIN_PAIRS: usize = 10;
208
209const AGREES: (f64, f64) = (0.7, 1.4);
215
216const MAX_CELL_V: f64 = 5.0;
218
219const EARTH_RADIUS_M: f64 = 6_371_008.8;
223
224const NS_PER_S: i64 = 1_000_000_000;
226const NS_PER_DAY: i64 = 86_400 * NS_PER_S;
227
228const YEARS: std::ops::RangeInclusive<i64> = 1970..=2199;
231
232pub(crate) fn recognizes(text: &str) -> bool {
234 numbered_lines(text)
235 .map(|(_, line)| line)
236 .find(|line| !line.trim().is_empty())
237 .and_then(layout)
238 .is_some()
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243enum Layout {
244 Tracker,
246 GroundStation,
248}
249
250fn layout(header: &str) -> Option<Layout> {
255 let mut names = header.split(',').map(str::trim);
256 let layout = match (names.next()?, names.next()?) {
257 ("UTCTIME", "UNIXTIME") => Layout::Tracker,
258 ("TRACKER", "DATE") if names.next()? == "TIME" => Layout::GroundStation,
259 _ => return None,
260 };
261 let (mut lat, mut lon, mut tracker_lat, mut tracker_lon) = (false, false, false, false);
262 for name in names {
263 match name {
264 "LAT" => lat = true,
265 "LON" => lon = true,
266 "TRACKER Lat" => tracker_lat = true,
267 "TRACKER Lon" => tracker_lon = true,
268 _ => {}
269 }
270 }
271 let position = (lat && lon) || (layout == Layout::GroundStation && tracker_lat && tracker_lon);
272 position.then_some(layout)
273}
274
275#[derive(Debug, Clone, Copy, PartialEq)]
277enum Role {
278 Clock,
280 Date,
282 Time,
284 Tracker,
286 Label,
288 Value(Kind),
290}
291
292#[derive(Debug, Clone, Copy, PartialEq)]
294enum Kind {
295 Height { above_sea_level: bool },
297 VerticalSpeed,
299 HorizontalSpeed,
301 Heading,
303 Battery,
305 UnixTime,
307 Stated(Quantity, f64),
309 Unknown,
311}
312
313fn role(name: &str) -> Role {
315 use Quantity as Q;
316 let value = |kind| Role::Value(kind);
317 let stated = |quantity, factor| Role::Value(Kind::Stated(quantity, factor));
318 match name {
319 "UTCTIME" => Role::Clock,
320 "DATE" => Role::Date,
321 "TIME" => Role::Time,
322 "TRACKER" => Role::Tracker,
323 "Found rocket ID" => Role::Label,
324 "UNIXTIME" => value(Kind::UnixTime),
325 "ALT" | "TRACKER Alt asl" | "GS Alt asl" | "Found rocket altitude" => value(Kind::Height {
326 above_sea_level: true,
327 }),
328 "Altitude AGL" | "GS_ASL_offset" => value(Kind::Height {
329 above_sea_level: false,
330 }),
331 "LAT" | "TRACKER Lat" | "GS Lat" | "Found rocket lat" => stated(Q::Latitude, 1.0),
332 "LON" | "TRACKER Lon" | "GS Lon" | "Found rocket lon" => stated(Q::Longitude, 1.0),
333 "HORZV" => value(Kind::HorizontalSpeed),
334 "VERTV" => value(Kind::VerticalSpeed),
335 "HEAD" => value(Kind::Heading),
336 "BATT" => value(Kind::Battery),
337 "GS_batt_v" | "Found rocket batt_v" => stated(Q::Voltage, 1.0),
338 "Distance (feet)" | "Alt AGL (ft)" | "Horizontal range (ft)" | "Total range (ft)" => {
339 stated(Q::Distance, FOOT_M)
340 }
341 "LoRa Max Range (kft)" => stated(Q::Distance, 1_000.0 * FOOT_M),
342 "#SATS" | "#TOT" | "FIX" | "FLAGS" | ">40" | ">32" | ">24" | "RSSI" | "RSSI Down"
343 | "RSSI Up" | "Found RSSI" | "Found rocket fix" | "Down Sent" | "Down Rcvd"
344 | "Found Sent" | "Up Sent" | "Up Rccvd" | "LoRa Success Rate" | "Launch detection"
345 | "Apogee detection" | "Landing detection" => stated(Q::Number, 1.0),
346 _ => value(Kind::Unknown),
347 }
348}
349
350struct Column {
352 name: String,
353 role: Role,
354 gaps: usize,
356 gap_example: Option<String>,
357 out_of_range: usize,
359}
360
361struct Set {
364 name: String,
365 times_ns: Vec<i64>,
366 values: Vec<Vec<f64>>,
367}
368
369#[derive(Debug, Clone, Copy)]
371struct Position {
372 lat: usize,
373 lon: usize,
374 alt: Option<usize>,
375 zeros: usize,
377}
378
379pub fn read(text: &str) -> Result<FlightLog, LogError> {
388 let mut lines = numbered_lines(text).filter(|(_, line)| !line.trim().is_empty());
389 let Some((header_line, header)) = lines.next() else {
390 return Err(LogError::NotThisFormat {
391 format: FORMAT,
392 message: "the file is empty".to_owned(),
393 });
394 };
395 let Some(layout) = layout(header) else {
396 return Err(LogError::NotThisFormat {
397 format: FORMAT,
398 message: format!(
399 "its first line, {:?}, isn't a Featherweight GPS header, which opens \
400 `UTCTIME,UNIXTIME` or `TRACKER,DATE,TIME`",
401 short(header)
402 ),
403 });
404 };
405 let mut notes = Notes::default();
406 let mut columns = Vec::new();
407 for (index, name) in header.split(',').map(str::trim).enumerate() {
408 if name.is_empty() {
409 return Err(syntax(
410 header_line,
411 format!("the header's column {} has no name", index + 1),
412 ));
413 }
414 let role = role(name);
415 if role == Role::Value(Kind::Unknown) {
416 notes.push(format!(
417 "the column {:?} isn't one this reader knows, and was kept as written",
418 short(name)
419 ));
420 }
421 columns.push(Column {
422 name: name.to_owned(),
423 role,
424 gaps: 0,
425 gap_example: None,
426 out_of_range: 0,
427 });
428 }
429 let find = |role: Role| columns.iter().position(|c| c.role == role);
430 let clock = match layout {
432 Layout::Tracker => Clock::Utc(find(Role::Clock).unwrap_or(0)),
433 Layout::GroundStation => Clock::DateTime {
434 date: find(Role::Date).unwrap_or(1),
435 time: find(Role::Time).unwrap_or(2),
436 tracker: find(Role::Tracker).unwrap_or(0),
437 },
438 };
439 let mut positions = positions(&columns);
440 let channels_per_set = columns
441 .iter()
442 .filter(|c| matches!(c.role, Role::Clock | Role::Time | Role::Value(_)))
443 .count();
444
445 let mut sets: Vec<Set> = Vec::new();
446 let mut set_of: HashMap<String, usize> = HashMap::new();
447 let mut left_out = LeftOut::default();
448 let (mut rows, mut cells_written) = (0usize, 0usize);
449 let (mut short_rows, mut first_short_row) = (0usize, 0usize);
450 let mut first_row: Option<(String, i64)> = None;
451 for (line_number, line) in lines {
452 let cells: Vec<&str> = line.split(',').map(str::trim).collect();
453 if cells.len() > columns.len() {
454 return Err(syntax(
455 line_number,
456 format!(
457 "the row has {} cells, more than the header's {}",
458 cells.len(),
459 columns.len()
460 ),
461 ));
462 }
463 rows += 1;
464 cells_written += cells.len();
465 if too_sparse(columns.len(), rows, cells_written) {
468 return Err(syntax(
469 line_number,
470 format!(
471 "the header names {} columns, and the {rows} rows down to this one hold only \
472 {cells_written} cells",
473 columns.len()
474 ),
475 ));
476 }
477 if cells.len() < columns.len() {
478 if short_rows == 0 {
479 first_short_row = line_number;
480 }
481 short_rows += 1;
482 }
483 let instant = clock.read(&cells, &columns, line_number)?;
484 if first_row.is_none() {
485 first_row = Some((clock.as_written(&cells), instant));
486 }
487 let tracker = clock.tracker(&cells);
488 let index = match set_of.get(tracker) {
489 Some(&index) => index,
490 None if sets.is_empty()
491 || (sets.len() < MAX_TRACKERS
492 && (sets.len() + 1).saturating_mul(channels_per_set) <= MAX_CHANNELS) =>
493 {
494 set_of.insert(tracker.to_owned(), sets.len());
495 sets.push(Set {
496 name: tracker.to_owned(),
497 times_ns: Vec::new(),
498 values: vec![Vec::new(); columns.len()],
499 });
500 sets.len() - 1
501 }
502 None => {
503 left_out.row(tracker);
504 continue;
505 }
506 };
507 let set = &mut sets[index];
508 set.times_ns.push(instant);
509 read_cells(set, &mut columns, &cells, instant);
510 for position in &mut positions {
511 no_fix(set, position);
512 }
513 }
514 let Some(first_row) = first_row else {
515 return Err(LogError::NoData { format: FORMAT });
516 };
517 let mut file_notes = Vec::new();
518 if short_rows > 0 {
519 file_notes.push(format!(
520 "{short_rows} rows, the first on line {first_short_row}, have fewer cells than the \
521 header's {}: the cells they leave out are gaps",
522 columns.len()
523 ));
524 }
525 if let Some(note) = left_out.note() {
526 file_notes.push(note);
527 }
528 Ok(build(
529 layout, &columns, sets, &positions, &first_row, notes, file_notes,
530 ))
531}
532
533#[derive(Debug, Clone, Copy)]
535enum Clock {
536 Utc(usize),
538 DateTime {
540 date: usize,
541 time: usize,
542 tracker: usize,
543 },
544}
545
546impl Clock {
547 fn read(self, cells: &[&str], columns: &[Column], line: usize) -> Result<i64, LogError> {
550 let cell = |index: usize| {
551 cells.get(index).copied().ok_or_else(|| {
552 syntax(
553 line,
554 format!(
555 "the row ends after {} cells, before its {:?}",
556 cells.len(),
557 short(&columns[index].name)
558 ),
559 )
560 })
561 };
562 let refuse = |what: &str, cell: &str, index: usize| {
563 syntax(
564 line,
565 format!(
566 "the {what} {:?} in cell {} isn't one",
567 short(cell),
568 index + 1
569 ),
570 )
571 };
572 match self {
573 Self::Utc(index) => {
574 let text = cell(index)?;
575 utc_time(text).ok_or_else(|| refuse("time", text, index))
576 }
577 Self::DateTime { date, time, .. } => {
578 let (date_text, time_text) = (cell(date)?, cell(time)?);
579 let day = civil_date(date_text).ok_or_else(|| refuse("date", date_text, date))?;
580 let of_day =
581 time_of_day_ns(time_text).ok_or_else(|| refuse("time", time_text, time))?;
582 Ok(day * NS_PER_DAY + of_day)
583 }
584 }
585 }
586
587 fn tracker<'a>(self, cells: &[&'a str]) -> &'a str {
589 match self {
590 Self::Utc(_) => "",
591 Self::DateTime { tracker, .. } => cells.get(tracker).copied().unwrap_or_default(),
592 }
593 }
594
595 fn as_written(self, cells: &[&str]) -> String {
597 match self {
598 Self::Utc(index) => short(cells.get(index).copied().unwrap_or_default()),
599 Self::DateTime { date, time, .. } => short(&format!(
600 "{} {}",
601 cells.get(date).copied().unwrap_or_default(),
602 cells.get(time).copied().unwrap_or_default()
603 )),
604 }
605 }
606}
607
608#[derive(Default)]
610struct LeftOut {
611 names: Vec<String>,
612 rows: usize,
613}
614
615impl LeftOut {
616 fn row(&mut self, tracker: &str) {
617 self.rows += 1;
618 if self.names.len() < MAX_TRACKERS && !self.names.iter().any(|name| name == tracker) {
620 self.names.push(tracker.to_owned());
621 }
622 }
623
624 fn note(&self) -> Option<String> {
625 (self.rows > 0).then(|| {
626 let trackers = if self.names.len() < MAX_TRACKERS {
627 format!("{} more trackers", self.names.len())
628 } else {
629 format!("{MAX_TRACKERS} or more further trackers")
630 };
631 format!(
632 "{} rows of {trackers} were left out, past the first {MAX_TRACKERS} trackers or \
633 {MAX_CHANNELS} channels, so that a file of many tracker names can't make the \
634 reader hold a set of channels for each",
635 self.rows
636 )
637 })
638 }
639}
640
641fn read_cells(set: &mut Set, columns: &mut [Column], cells: &[&str], instant: i64) {
644 for (index, column) in columns.iter_mut().enumerate() {
645 let value = match column.role {
646 Role::Date | Role::Tracker | Role::Label => continue,
647 Role::Clock => seconds_of_day(instant),
648 Role::Time => cells
649 .get(index)
650 .and_then(|cell| time_of_day_ns(cell))
651 .map_or(f64::NAN, |ns| ns as f64 / NS_PER_S as f64),
652 Role::Value(kind) => match cells.get(index) {
653 None => f64::NAN,
654 Some(cell) => match value(cell) {
655 Some(value) if value.abs() > most_degrees(kind) => {
656 column.out_of_range += 1;
657 f64::NAN
658 }
659 Some(value) => value,
660 None => {
661 column.gaps += 1;
662 if column.gap_example.is_none() {
663 column.gap_example = Some(short(cell));
664 }
665 f64::NAN
666 }
667 },
668 },
669 };
670 set.values[index].push(value);
671 }
672}
673
674fn most_degrees(kind: Kind) -> f64 {
677 match kind {
678 Kind::Stated(Quantity::Latitude, _) => 90.0,
679 Kind::Stated(Quantity::Longitude, _) => 180.0,
680 _ => f64::INFINITY,
681 }
682}
683
684fn value(cell: &str) -> Option<f64> {
687 if let Some(value) = number(cell, false) {
688 return Some(value);
689 }
690 match cell {
691 "TRUE" => return Some(1.0),
692 "FALSE" => return Some(0.0),
693 _ => {}
694 }
695 if let Some(digits) = cell.strip_prefix("0x").or_else(|| cell.strip_prefix("0X")) {
696 if digits.is_empty() || digits.len() > 13 || !digits.bytes().all(|b| b.is_ascii_hexdigit())
697 {
698 return None;
699 }
700 return u64::from_str_radix(digits, 16).ok().map(|bits| bits as f64);
702 }
703 cell.strip_suffix('%')
704 .filter(|rest| !rest.ends_with(char::is_whitespace))
705 .and_then(|rest| number(rest, false))
706}
707
708fn positions(columns: &[Column]) -> Vec<Position> {
711 let place = |name: &str| columns.iter().position(|c| c.name == name);
712 [
713 ("LAT", "LON", "ALT"),
714 ("TRACKER Lat", "TRACKER Lon", "TRACKER Alt asl"),
715 ("GS Lat", "GS Lon", "GS Alt asl"),
716 (
717 "Found rocket lat",
718 "Found rocket lon",
719 "Found rocket altitude",
720 ),
721 ]
722 .into_iter()
723 .filter_map(|(lat, lon, alt)| {
724 Some(Position {
725 lat: place(lat)?,
726 lon: place(lon)?,
727 alt: place(alt),
728 zeros: 0,
729 })
730 })
731 .collect()
732}
733
734fn no_fix(set: &mut Set, position: &mut Position) {
736 let last = |column: usize| set.values[column].last().copied();
737 if last(position.lat) == Some(0.0) && last(position.lon) == Some(0.0) {
738 position.zeros += 1;
739 for column in [Some(position.lat), Some(position.lon), position.alt]
740 .into_iter()
741 .flatten()
742 {
743 if let Some(value) = set.values[column].last_mut() {
744 *value = f64::NAN;
745 }
746 }
747 }
748}
749
750fn utc_time(cell: &str) -> Option<i64> {
753 let mut parts = cell.split_whitespace();
754 let (month, day, year, time, zone) = (
755 parts.next()?,
756 parts.next()?,
757 parts.next()?,
758 parts.next()?,
759 parts.next()?,
760 );
761 if parts.next().is_some() || zone != "UTC" {
762 return None;
763 }
764 const MONTHS: [&str; 12] = [
765 "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
766 ];
767 let month = MONTHS
768 .iter()
769 .position(|name| name.eq_ignore_ascii_case(month))?;
770 let day = days(year, u32::try_from(month).ok()? + 1, day)?;
771 Some(day * NS_PER_DAY + time_of_day_ns(time)?)
772}
773
774fn civil_date(cell: &str) -> Option<i64> {
776 let mut parts = cell.split('-');
777 let (year, month, day) = (parts.next()?, parts.next()?, parts.next()?);
778 if parts.next().is_some() || year.len() != 4 || !digits(month, 1..=2) {
779 return None;
780 }
781 days(year, month.parse().ok()?, day)
782}
783
784fn digits(text: &str, count: std::ops::RangeInclusive<usize>) -> bool {
786 count.contains(&text.len()) && text.bytes().all(|b| b.is_ascii_digit())
787}
788
789fn days(year: &str, month: u32, day: &str) -> Option<i64> {
792 if !digits(year, 4..=4) || !digits(day, 1..=2) {
793 return None;
794 }
795 let year: i64 = year.parse().ok()?;
796 let day: u32 = day.parse().ok()?;
797 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
798 let length = match month {
799 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
800 4 | 6 | 9 | 11 => 30,
801 2 if leap => 29,
802 2 => 28,
803 _ => return None,
804 };
805 (YEARS.contains(&year) && (1..=length).contains(&day))
806 .then(|| days_from_civil(year, month, day))
807}
808
809fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
813 let year = if month <= 2 { year - 1 } else { year };
814 let era = year.div_euclid(400);
815 let year_of_era = year - era * 400;
816 let day_of_year = (153 * i64::from((month + 9) % 12) + 2) / 5 + i64::from(day) - 1;
817 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
818 era * 146_097 + day_of_era - 719_468
819}
820
821fn time_of_day_ns(cell: &str) -> Option<i64> {
824 let mut parts = cell.split(':');
825 let (hours, minutes, seconds) = (parts.next()?, parts.next()?, parts.next()?);
826 if parts.next().is_some() {
827 return None;
828 }
829 let (whole, fraction) = seconds.split_once('.').unwrap_or((seconds, ""));
830 if !digits(hours, 1..=2)
831 || !digits(minutes, 2..=2)
832 || !digits(whole, 2..=2)
833 || !(fraction.is_empty() || digits(fraction, 1..=9))
834 || (seconds.contains('.') && fraction.is_empty())
835 {
836 return None;
837 }
838 let (hours, minutes, whole): (i64, i64, i64) = (
839 hours.parse().ok()?,
840 minutes.parse().ok()?,
841 whole.parse().ok()?,
842 );
843 if hours >= 24 || minutes >= 60 || whole > 60 {
844 return None;
845 }
846 let mut nanos: i64 = 0;
847 for (place, digit) in fraction.bytes().enumerate() {
848 nanos += i64::from(digit - b'0') * 10_i64.pow(8 - place as u32);
850 }
851 Some(((hours * 60 + minutes) * 60 + whole) * NS_PER_S + nanos)
852}
853
854fn seconds_of_day(instant: i64) -> f64 {
856 instant.rem_euclid(NS_PER_DAY) as f64 / NS_PER_S as f64
857}
858
859fn seconds_between(from: i64, to: i64) -> f64 {
861 (to - from) as f64 / NS_PER_S as f64
863}
864
865fn build(
867 layout: Layout,
868 columns: &[Column],
869 sets: Vec<Set>,
870 positions: &[Position],
871 first_row: &(String, i64),
872 notes: Notes,
873 file_notes: Vec<String>,
874) -> FlightLog {
875 let decisions = Decisions::from_file(columns, &sets);
876 let mut log = FlightLog {
877 format: LogFormat::FeatherweightGpsCsv,
878 logger: match layout {
879 Layout::Tracker => "Featherweight GPS tracker",
880 Layout::GroundStation => "Featherweight GPS ground station",
881 }
882 .to_owned(),
883 serial_number: None,
884 firmware: None,
885 flight_number: None,
886 stated: Stated::default(),
887 time_s: Vec::new(),
888 altitude_m: Vec::new(),
889 vertical_speed_m_s: None,
890 temperature_k: None,
891 battery_v: None,
892 channels: Vec::new(),
893 events: Vec::new(),
894 notes: decisions.notes.clone(),
895 };
896 log.notes.push(
897 "the log has no barometer, so the record's altitude track is empty: a GPS tracker's file \
898 holds positions only, and every column is a channel"
899 .to_owned(),
900 );
901 log.notes
902 .push(clock_note(layout, &sets, positions, first_row));
903 if let Some(note) = steps_back_note(&sets) {
904 log.notes.push(note);
905 }
906 if layout == Layout::GroundStation {
907 log.notes.push(trackers_note(&sets));
908 }
909 for position in positions.iter().filter(|p| p.zeros > 0) {
910 let names: Vec<String> = [Some(position.lat), Some(position.lon), position.alt]
911 .into_iter()
912 .flatten()
913 .map(|index| format!("`{}`", columns[index].name))
914 .collect();
915 log.notes.push(format!(
916 "{} rows have `{}` and `{}` both 0, as a receiver writes before it has a fix: their \
917 {} are read as gaps",
918 position.zeros,
919 columns[position.lat].name,
920 columns[position.lon].name,
921 list(&names)
922 ));
923 }
924 log.notes.extend(file_notes);
925 let mut notes = notes;
926 for column in columns.iter().filter(|c| c.gaps > 0) {
927 notes.push(format!(
928 "the column {:?} holds {} cells that are empty or not a number, such as {:?}: they \
929 are gaps",
930 short(&column.name),
931 column.gaps,
932 column.gap_example.as_deref().unwrap_or_default()
933 ));
934 }
935 for column in columns.iter().filter(|c| c.out_of_range > 0) {
936 let (what, most) = match column.role {
937 Role::Value(Kind::Stated(Quantity::Latitude, _)) => ("latitudes", "±90°"),
938 _ => ("longitudes", "±180°"),
939 };
940 notes.push(format!(
941 "the column {:?} holds {} {what} beyond {most}: they are gaps",
942 short(&column.name),
943 column.out_of_range
944 ));
945 }
946 log.notes.extend(notes.into_vec());
947
948 let zero = first_row.1;
949 let qualify = sets.len() > 1;
950 for set in sets {
951 let time_s: Vec<f64> = set
952 .times_ns
953 .iter()
954 .map(|&instant| seconds_between(zero, instant))
955 .collect();
956 for (column, values) in columns.iter().zip(set.values) {
957 let Some(quantity) = decisions.quantity(column.role) else {
958 continue;
959 };
960 let factor = decisions.factor(column.role);
961 let name = if qualify {
962 format!("{} [{}]", short(&column.name), short(&set.name))
963 } else {
964 column.name.clone()
965 };
966 log.channels.push(Channel {
967 column: name,
968 quantity,
969 time_s: time_s.clone(),
970 values: values.into_iter().map(|value| value * factor).collect(),
971 });
972 }
973 }
974 log
975}
976
977fn clock_note(
979 layout: Layout,
980 sets: &[Set],
981 positions: &[Position],
982 first_row: &(String, i64),
983) -> String {
984 match layout {
985 Layout::Tracker => format!(
986 "the log's clock is `UTCTIME`, read as UTC, and its zero is the first row's, {:?}",
987 first_row.0
988 ),
989 Layout::GroundStation => {
990 let solar = first_fix(sets, positions).map_or_else(
991 || "the file has no fix to compare its `TIME` with the sun's".to_owned(),
992 |(instant, longitude)| {
993 format!(
994 "at the first fix, `TIME` is {}, which read as UTC would be {:.1} h of \
995 local mean solar time at the fix's longitude",
996 clock_text(instant),
997 solar_hours(instant, longitude)
998 )
999 },
1000 );
1001 format!(
1002 "`DATE` and `TIME` kept as written, in a time zone the file doesn't state: they \
1003 are the log's clock, and its zero is the first row's, {:?}; {solar}",
1004 first_row.0
1005 )
1006 }
1007 }
1008}
1009
1010fn first_fix(sets: &[Set], positions: &[Position]) -> Option<(i64, f64)> {
1012 let position = positions.first()?;
1013 sets.iter()
1014 .filter_map(|set| {
1015 (0..set.times_ns.len())
1016 .find(|&row| {
1017 set.values[position.lat][row].is_finite()
1018 && set.values[position.lon][row].is_finite()
1019 })
1020 .map(|row| (set.times_ns[row], set.values[position.lon][row]))
1021 })
1022 .min_by_key(|(instant, _)| *instant)
1023}
1024
1025fn clock_text(instant: i64) -> String {
1027 let millis = instant.rem_euclid(NS_PER_DAY) / 1_000_000;
1028 format!(
1029 "{:02}:{:02}:{:02}.{:03}",
1030 millis / 3_600_000,
1031 millis / 60_000 % 60,
1032 millis / 1_000 % 60,
1033 millis % 1_000
1034 )
1035}
1036
1037fn solar_hours(instant: i64, longitude_deg: f64) -> f64 {
1040 (seconds_of_day(instant) / 3_600.0 + longitude_deg / 15.0).rem_euclid(24.0)
1041}
1042
1043fn steps_back_note(sets: &[Set]) -> Option<String> {
1045 let (mut backs, mut largest_ns, mut repeats) = (0usize, 0i64, 0usize);
1046 for set in sets {
1047 for pair in set.times_ns.windows(2) {
1048 if pair[1] < pair[0] {
1049 backs += 1;
1050 largest_ns = largest_ns.max(pair[0] - pair[1]);
1051 }
1052 }
1053 let mut sorted = set.times_ns.clone();
1054 sorted.sort_unstable();
1055 repeats += sorted.windows(2).filter(|pair| pair[0] == pair[1]).count();
1056 }
1057 let kept = "every row is kept, in the file's order";
1058 if backs > 0 {
1059 Some(format!(
1060 "times go back {backs} times from one row to the next, by up to {:.3} s, and {repeats} \
1061 rows repeat a time already written: {kept}",
1062 largest_ns as f64 / NS_PER_S as f64
1063 ))
1064 } else {
1065 (repeats > 0).then(|| format!("{repeats} rows repeat a time already written: {kept}"))
1066 }
1067}
1068
1069fn trackers_note(sets: &[Set]) -> String {
1071 match sets {
1072 [one] => format!(
1073 "the file holds one tracker's rows, {:?}, so its channels keep the header's names",
1074 short(&one.name)
1075 ),
1076 _ => {
1077 let names: Vec<String> = sets
1078 .iter()
1079 .map(|set| format!("{:?} ({} rows)", short(&set.name), set.times_ns.len()))
1080 .collect();
1081 format!(
1082 "the file holds {} trackers' rows, each its own set of channels named with the \
1083 tracker in brackets, in the order they first appear: {}",
1084 sets.len(),
1085 list(&names)
1086 )
1087 }
1088 }
1089}
1090
1091const MAX_LISTED: usize = 6;
1093
1094fn list(names: &[String]) -> String {
1097 if names.len() > MAX_LISTED {
1098 let shown = MAX_LISTED - 1;
1099 return format!(
1100 "{} and {} more",
1101 names[..shown].join(", "),
1102 names.len() - shown
1103 );
1104 }
1105 match names {
1106 [] => String::new(),
1107 [only] => only.clone(),
1108 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
1109 }
1110}
1111
1112#[derive(Debug, Clone, Copy, PartialEq)]
1114enum Check {
1115 Agrees(f64, usize),
1117 Disagrees(f64, usize),
1119 TooFew(usize),
1121}
1122
1123impl Check {
1124 fn from_ratios(mut ratios: Vec<f64>) -> Self {
1125 if ratios.len() < MIN_PAIRS {
1126 return Self::TooFew(ratios.len());
1127 }
1128 ratios.sort_by(f64::total_cmp);
1129 let median = ratios[ratios.len() / 2];
1130 if (AGREES.0..=AGREES.1).contains(&median) {
1131 Self::Agrees(median, ratios.len())
1132 } else {
1133 Self::Disagrees(median, ratios.len())
1134 }
1135 }
1136
1137 fn words(self, change: &str, speed: &str) -> String {
1140 match self {
1141 Self::Agrees(median, pairs) | Self::Disagrees(median, pairs) => format!(
1142 "{change} is a median of {median:.3} times `{speed}` over {pairs} pairs of rows"
1143 ),
1144 Self::TooFew(pairs) => format!(
1145 "the file has too few pairs of moving rows to check `{speed}` over, {pairs} of the \
1146 {MIN_PAIRS} needed"
1147 ),
1148 }
1149 }
1150}
1151
1152#[derive(Debug, Clone, Copy)]
1154enum Track {
1155 Height(usize),
1157 Position(usize, usize),
1159}
1160
1161impl Track {
1162 fn at(self, set: &Set, row: usize) -> Option<(f64, f64)> {
1164 let (a, b) = match self {
1165 Self::Height(column) => (set.values[column][row], 0.0),
1166 Self::Position(lat, lon) => (set.values[lat][row], set.values[lon][row]),
1167 };
1168 (a.is_finite() && b.is_finite()).then_some((a, b))
1169 }
1170
1171 fn change(self, from: (f64, f64), to: (f64, f64)) -> f64 {
1173 match self {
1174 Self::Height(_) => to.0 - from.0,
1175 Self::Position(..) => great_circle_m(from, to) / FOOT_M,
1176 }
1177 }
1178}
1179
1180fn great_circle_m(from: (f64, f64), to: (f64, f64)) -> f64 {
1184 let (lat1, lat2) = (from.0.to_radians(), to.0.to_radians());
1185 let (half_dlat, half_dlon) = ((lat2 - lat1) / 2.0, (to.1 - from.1).to_radians() / 2.0);
1186 let h = half_dlat.sin().powi(2) + lat1.cos() * lat2.cos() * half_dlon.sin().powi(2);
1187 2.0 * EARTH_RADIUS_M * h.sqrt().min(1.0).asin()
1188}
1189
1190fn speed_check(sets: &[Set], track: Track, speed: usize) -> Check {
1195 let mut ratios = Vec::new();
1196 for set in sets {
1197 let mut order: Vec<usize> = (0..set.times_ns.len()).collect();
1198 order.sort_by_key(|&row| set.times_ns[row]);
1199 let mut start: Option<(usize, (f64, f64))> = None;
1200 for row in order {
1201 let Some(point) = track.at(set, row) else {
1202 start = None;
1203 continue;
1204 };
1205 if let Some((from, from_point)) = start {
1206 if point == from_point {
1207 continue;
1208 }
1209 let step_s = seconds_between(set.times_ns[from], set.times_ns[row]);
1210 let speed = set.values[speed][row];
1211 if step_s > MIN_STEP_S && step_s <= MAX_STEP_S && speed.abs() >= MIN_SPEED {
1212 ratios.push(track.change(from_point, point) / step_s / speed);
1213 }
1214 }
1215 start = Some((row, point));
1216 }
1217 }
1218 Check::from_ratios(ratios)
1219}
1220
1221struct Decisions {
1223 heights: bool,
1224 vertical: bool,
1225 horizontal: bool,
1226 heading: bool,
1227 battery: bool,
1228 notes: Vec<String>,
1229}
1230
1231impl Decisions {
1232 fn from_file(columns: &[Column], sets: &[Set]) -> Self {
1233 let place = |name: &str| columns.iter().position(|c| c.name == name);
1234 let named = |kind: Kind| -> Vec<String> {
1235 columns
1236 .iter()
1237 .filter(|c| c.role == Role::Value(kind))
1238 .map(|c| format!("`{}`", short(&c.name)))
1239 .collect()
1240 };
1241 let values = |kind: Kind| {
1242 columns
1243 .iter()
1244 .enumerate()
1245 .filter(move |(_, c)| c.role == Role::Value(kind))
1246 .flat_map(move |(index, _)| sets.iter().flat_map(move |s| s.values[index].iter()))
1247 .copied()
1248 .filter(|value| value.is_finite())
1249 };
1250 let mut notes = Vec::new();
1251 let altitude = place("ALT").or_else(|| place("TRACKER Alt asl"));
1252 let position = place("LAT")
1253 .zip(place("LON"))
1254 .or_else(|| place("TRACKER Lat").zip(place("TRACKER Lon")));
1255 let altitude_name = altitude.map_or("ALT", |index| columns[index].name.as_str());
1256
1257 let horizontal = match (position, place("HORZV")) {
1262 (Some((lat, lon)), Some(speed)) => speed_check(sets, Track::Position(lat, lon), speed),
1263 _ => Check::TooFew(0),
1264 };
1265 let vertical = match (altitude, place("VERTV")) {
1266 (Some(altitude), Some(speed)) => speed_check(sets, Track::Height(altitude), speed),
1267 _ => Check::TooFew(0),
1268 };
1269 let alt_agl = alt_agl_check(columns, sets);
1270 let feet = !matches!(horizontal, Check::Disagrees(..));
1271 let kept = if !feet {
1272 Some(Kept::HorizontalSpeed)
1273 } else if alt_agl.is_some_and(|check| !check.agrees()) {
1274 Some(Kept::AltAgl)
1275 } else if matches!(vertical, Check::Disagrees(..)) {
1276 Some(Kept::VerticalSpeed)
1277 } else {
1278 None
1279 };
1280 let horizontal_words =
1281 horizontal.words("the distance between fixes a second, in feet,", "HORZV");
1282 let vertical_words = vertical.words(
1283 &format!("the change of `{altitude_name}` a second"),
1284 "VERTV",
1285 );
1286
1287 if place("HORZV").is_some() {
1288 notes.push(match horizontal {
1289 Check::Agrees(..) => format!(
1290 "`HORZV` read in feet per second, from the manual and the distance between \
1291 fixes: {horizontal_words}"
1292 ),
1293 Check::TooFew(_) => format!(
1294 "`HORZV` read in feet per second, from the manual alone: {horizontal_words}"
1295 ),
1296 Check::Disagrees(..) => format!(
1297 "`HORZV` kept as written, the distance between fixes disagrees with the \
1298 manual's feet per second: {horizontal_words}; every length and speed the \
1299 manual gives in feet is kept as written with it"
1300 ),
1301 });
1302 }
1303
1304 let mut heights = named(Kind::Height {
1305 above_sea_level: true,
1306 });
1307 heights.extend(named(Kind::Height {
1308 above_sea_level: false,
1309 }));
1310 if !heights.is_empty() {
1311 let names = list(&heights);
1312 let mut evidence = vec![horizontal_words.clone()];
1313 if place("VERTV").is_some() {
1314 evidence.push(vertical_words.clone());
1315 }
1316 evidence.extend(alt_agl.map(AltAgl::words));
1317 evidence.extend(altitude_agl_words(columns, sets));
1318 let evidence = evidence.join("; ");
1319 notes.push(match kept {
1320 Some(why) => format!("{names} kept as written, {}: {evidence}", why.words()),
1321 None => {
1322 let by_horizontal = matches!(horizontal, Check::Agrees(..));
1323 let by_alt_agl = alt_agl.is_some_and(|check| check.rows > 0);
1324 let source = match (by_horizontal, by_alt_agl) {
1325 (true, false) => "from the manual and the horizontal speed",
1326 (false, false) => "from the manual alone",
1327 (true, true) => "from the manual, the horizontal speed and `Alt AGL (ft)`",
1328 (false, true) => "from the manual and `Alt AGL (ft)`",
1329 };
1330 format!("{names} read in feet, {source}: {evidence}")
1331 }
1332 });
1333 }
1334
1335 if place("VERTV").is_some() {
1336 notes.push(match (kept, altitude, vertical) {
1337 (Some(Kept::VerticalSpeed), ..) => format!(
1338 "`VERTV` kept as written, not in the altitude's unit a second: \
1339 {vertical_words}"
1340 ),
1341 (Some(why), ..) => {
1342 format!("`VERTV` kept as written, {}: {vertical_words}", why.words())
1343 }
1344 (None, None, _) => "`VERTV` read in feet per second, from the manual alone: the \
1345 file has no altitude to compare it with"
1346 .to_owned(),
1347 (None, Some(_), Check::TooFew(_)) => format!(
1348 "`VERTV` read in feet per second, in the altitude's unit, from the \
1349 manual alone: {vertical_words}"
1350 ),
1351 (None, Some(_), _) => format!(
1352 "`VERTV` read in feet per second, in the altitude's unit, from the \
1353 change of altitude: {vertical_words}"
1354 ),
1355 });
1356 }
1357 let heights_read = kept.is_none();
1358
1359 let heading = values(Kind::Heading).fold(None, range);
1360 let heading_read = heading.is_none_or(|(lo, hi): (f64, f64)| lo >= -360.0 && hi <= 360.0);
1361 if place("HEAD").is_some() {
1362 let evidence = heading.map_or_else(
1363 || "the column holds no value; the manual gives degrees from north".to_owned(),
1364 |(lo, hi)| format!("it runs from {lo} to {hi}"),
1365 );
1366 notes.push(if heading_read {
1367 format!("`HEAD` read in degrees, from the manual and its range: {evidence}")
1368 } else {
1369 format!(
1370 "`HEAD` kept as written, its range disagrees with the manual's degrees: \
1371 {evidence}"
1372 )
1373 });
1374 }
1375
1376 let battery = values(Kind::Battery).fold(None, range);
1377 let battery_read = battery.is_none_or(|(lo, hi): (f64, f64)| lo >= 0.0 && hi <= MAX_CELL_V);
1378 if place("BATT").is_some() {
1379 let evidence = match battery {
1380 None => "the column holds no value".to_owned(),
1381 Some((lo, hi)) if lo == 0.0 && hi == 0.0 => {
1382 "it reads 0 on every row, which no unit changes".to_owned()
1383 }
1384 Some((lo, hi)) => format!(
1385 "it runs from {lo} to {hi}, where one lithium cell reads up to about 4.2 V and \
1386 the ground station's serial millivolts would be thousands"
1387 ),
1388 };
1389 notes.push(if battery_read {
1390 format!("`BATT` read in volts, from its range: {evidence}")
1391 } else {
1392 format!(
1393 "`BATT` kept as written, its range isn't one lithium cell's volts: {evidence}"
1394 )
1395 });
1396 }
1397
1398 if let Some(note) = unix_note(columns, sets) {
1399 notes.push(note);
1400 }
1401 Self {
1402 heights: heights_read,
1403 vertical: heights_read,
1404 horizontal: feet,
1405 heading: heading_read,
1406 battery: battery_read,
1407 notes,
1408 }
1409 }
1410
1411 fn quantity(&self, role: Role) -> Option<Quantity> {
1413 let decided = |read: bool, quantity| {
1414 if read {
1415 quantity
1416 } else {
1417 Quantity::Unrecognized
1418 }
1419 };
1420 Some(match role {
1421 Role::Date | Role::Tracker | Role::Label => return None,
1422 Role::Clock | Role::Time => Quantity::TimeOfDay,
1423 Role::Value(kind) => match kind {
1424 Kind::Height {
1425 above_sea_level: true,
1426 } => decided(self.heights, Quantity::GnssAltitude),
1427 Kind::Height {
1428 above_sea_level: false,
1429 } => decided(self.heights, Quantity::Distance),
1430 Kind::VerticalSpeed => decided(self.vertical, Quantity::Speed),
1431 Kind::HorizontalSpeed => decided(self.horizontal, Quantity::Speed),
1432 Kind::Heading => decided(self.heading, Quantity::Angle),
1433 Kind::Battery => decided(self.battery, Quantity::Voltage),
1434 Kind::UnixTime | Kind::Unknown => Quantity::Unrecognized,
1435 Kind::Stated(quantity, _) => quantity,
1436 },
1437 })
1438 }
1439
1440 fn factor(&self, role: Role) -> f64 {
1442 match role {
1443 Role::Value(Kind::Height { .. }) if self.heights => FOOT_M,
1444 Role::Value(Kind::VerticalSpeed) if self.vertical => FOOT_M,
1445 Role::Value(Kind::HorizontalSpeed) if self.horizontal => FOOT_M,
1446 Role::Value(Kind::Stated(_, factor)) => factor,
1447 _ => 1.0,
1448 }
1449 }
1450}
1451
1452fn range(acc: Option<(f64, f64)>, value: f64) -> Option<(f64, f64)> {
1454 Some(acc.map_or((value, value), |(lo, hi)| (lo.min(value), hi.max(value))))
1455}
1456
1457#[derive(Debug, Clone, Copy, PartialEq)]
1459enum Kept {
1460 HorizontalSpeed,
1463 AltAgl,
1465 VerticalSpeed,
1467}
1468
1469impl Kept {
1470 fn words(self) -> &'static str {
1472 match self {
1473 Self::HorizontalSpeed => "the horizontal speed disagrees with the manual's feet",
1474 Self::AltAgl => "`Alt AGL (ft)` disagrees with the manual's feet",
1475 Self::VerticalSpeed => "`VERTV` isn't their change a second",
1476 }
1477 }
1478}
1479
1480#[derive(Debug, Clone, Copy, PartialEq)]
1483struct AltAgl {
1484 held: usize,
1485 rows: usize,
1486}
1487
1488impl AltAgl {
1489 fn agrees(self) -> bool {
1492 self.rows == 0 || self.held * 2 >= self.rows
1493 }
1494
1495 fn words(self) -> String {
1496 format!(
1497 "`Alt AGL (ft)` is `TRACKER Alt asl` less `GS Alt asl` and `GS_ASL_offset`, to 0.15 ft, \
1498 on {} of {} rows",
1499 self.held, self.rows
1500 )
1501 }
1502}
1503
1504fn alt_agl_check(columns: &[Column], sets: &[Set]) -> Option<AltAgl> {
1506 let place = |name: &str| columns.iter().position(|c| c.name == name);
1507 let ((agl, tracker), (station, offset)) =
1508 place("Alt AGL (ft)")
1509 .zip(place("TRACKER Alt asl"))
1510 .zip(place("GS Alt asl").zip(place("GS_ASL_offset")))?;
1511 let (mut held, mut rows) = (0usize, 0usize);
1512 for set in sets {
1513 for row in 0..set.times_ns.len() {
1514 let at = |column: usize| set.values[column][row];
1515 let residual = at(agl) - (at(tracker) - at(station) - at(offset));
1516 if residual.is_finite() {
1517 rows += 1;
1518 held += usize::from(residual.abs() <= 0.15);
1519 }
1520 }
1521 }
1522 Some(AltAgl { held, rows })
1523}
1524
1525fn altitude_agl_words(columns: &[Column], sets: &[Set]) -> Option<String> {
1528 let place = |name: &str| columns.iter().position(|c| c.name == name);
1529 let (altitude, agl) = place("ALT").zip(place("Altitude AGL"))?;
1530 let mut differences: Vec<i64> = sets
1531 .iter()
1532 .flat_map(|set| {
1533 set.values[altitude]
1534 .iter()
1535 .zip(&set.values[agl])
1536 .map(|(alt, agl)| alt - agl)
1537 })
1538 .filter(|difference| difference.is_finite())
1539 .map(|difference| (difference * 10.0).round() as i64)
1542 .collect();
1543 differences.sort_unstable();
1544 let rows = differences.len();
1545 let most = differences
1546 .chunk_by(|a, b| a == b)
1547 .map(<[i64]>::len)
1548 .max()
1549 .unwrap_or(0);
1550 Some(format!(
1551 "`Altitude AGL` is `ALT` less one height, to 0.05 ft, on {most} of {rows} rows, as a \
1552 height above the pad is"
1553 ))
1554}
1555
1556fn unix_note(columns: &[Column], sets: &[Set]) -> Option<String> {
1558 let unix = columns
1559 .iter()
1560 .position(|c| c.role == Role::Value(Kind::UnixTime))?;
1561 columns.iter().find(|c| c.role == Role::Clock)?;
1563 let lon = columns.iter().position(|c| c.name == "LON");
1564 let mut offsets: Vec<f64> = Vec::new();
1565 let mut solar: Option<(f64, f64)> = None;
1566 for set in sets {
1567 for (row, &instant) in set.times_ns.iter().enumerate() {
1568 let written = set.values[unix][row];
1569 if !written.is_finite() {
1570 continue;
1571 }
1572 let utc_s = instant.div_euclid(NS_PER_S) as f64
1573 + instant.rem_euclid(NS_PER_S) as f64 / NS_PER_S as f64;
1574 offsets.push(written - utc_s);
1575 if solar.is_none()
1576 && let Some(longitude) = lon.map(|lon| set.values[lon][row])
1577 && longitude.is_finite()
1578 {
1579 let unix_of_day = written.rem_euclid(86_400.0) / 3_600.0;
1580 solar = Some((
1581 solar_hours(instant, longitude),
1582 (unix_of_day + longitude / 15.0).rem_euclid(24.0),
1583 ));
1584 }
1585 }
1586 }
1587 let hours = offsets.first().map(|offset| (offset / 3_600.0).round())?;
1588 let whole = offsets
1590 .iter()
1591 .all(|offset| (offset - hours * 3_600.0).abs() < 0.01);
1592 let sun = solar.map_or_else(String::new, |(utc, unix)| {
1593 format!(
1594 "; read as UTC at the first fix's longitude, `UTCTIME` gives {utc:.1} h of local mean \
1595 solar time and `UNIXTIME` {unix:.1} h"
1596 )
1597 });
1598 Some(if whole {
1599 let offset = if hours >= 0.0 {
1600 format!("{hours} h ahead of")
1601 } else {
1602 format!("{} h behind", -hours)
1603 };
1604 format!(
1605 "`UNIXTIME` kept as written, off `UTCTIME` by whole hours: it runs {offset} \
1606 `UTCTIME` on every row{sun}"
1607 )
1608 } else {
1609 let (lo, hi) = offsets
1610 .iter()
1611 .copied()
1612 .fold(None, range)
1613 .unwrap_or_default();
1614 format!(
1615 "`UNIXTIME` kept as written, off `UTCTIME` by other than one whole number of hours: \
1616 it runs from {lo:.3} s to {hi:.3} s ahead of `UTCTIME`{sun}"
1617 )
1618 })
1619}
1620
1621fn syntax(line: usize, message: String) -> LogError {
1622 LogError::Syntax {
1623 format: FORMAT,
1624 line,
1625 message,
1626 }
1627}
1628
1629#[cfg(test)]
1630mod tests {
1631 use super::*;
1632
1633 const OLDER: &str =
1634 "UTCTIME,UNIXTIME,ALT,LAT,LON,#SATS,FIX,HORZV,VERTV,HEAD,FLAGS,>40,>32,>24,RSSI,BATT";
1635 const GROUND: &str =
1636 "TRACKER,DATE,TIME,LAT,LON,ALT,FIX,HORZV,VERTV,HEAD,FLAGS,#TOT,>40,>32,>24,RSSI,BATT";
1637 const LONGER: &str = "TRACKER,DATE,TIME,GS Lat,GS Lon,GS Alt asl,TRACKER Lat,TRACKER Lon,\
1638 TRACKER Alt asl,FIX,HORZV,VERTV,HEAD,FLAGS,#TOT,>40,>32,>24,RSSI Down,RSSI Up,\
1639 Horizontal range (ft),Alt AGL (ft),Total range (ft),LoRa Max Range (kft),\
1640 LoRa Success Rate,BATT,GS_batt_v,Down Sent,Down Rcvd,Found Sent,Up Sent,Up Rccvd,\
1641 Found rocket ID,Found rocket lat,Found rocket lon,Found rocket altitude,Found rocket fix,\
1642 Found rocket batt_v,Found RSSI,GS_ASL_offset";
1643
1644 fn degree_m() -> f64 {
1646 EARTH_RADIUS_M.to_radians()
1647 }
1648
1649 fn tracker_file(rows: usize, alt: impl Fn(usize) -> f64, vertv: f64, horzv: f64) -> String {
1652 tracker_file_with(rows, alt, vertv, horzv, 90, "4.0")
1653 }
1654
1655 fn tracker_file_with(
1657 rows: usize,
1658 alt: impl Fn(usize) -> f64,
1659 vertv: f64,
1660 horzv: f64,
1661 head: i32,
1662 batt: &str,
1663 ) -> String {
1664 moving_file(rows, alt, vertv, horzv, 30.0 * FOOT_M, head, batt)
1665 }
1666
1667 fn moving_file(
1669 rows: usize,
1670 alt: impl Fn(usize) -> f64,
1671 vertv: f64,
1672 horzv: f64,
1673 step_m: f64,
1674 head: i32,
1675 batt: &str,
1676 ) -> String {
1677 let mut text = format!("{OLDER}\n");
1678 for row in 0..rows {
1679 let lat = 40.0 + step_m * row as f64 / degree_m();
1680 text.push_str(&format!(
1681 "Jun 1 2025 18:00:{row:02}.000 UTC,{}.000,{},{lat:.7},-105.0,12,3,{horzv},{vertv},\
1682 {head},0x03,1,2,3,-50,{batt}\n",
1683 1_748_800_800 + row,
1684 alt(row)
1685 ));
1686 }
1687 text
1688 }
1689
1690 fn channel<'a>(log: &'a FlightLog, column: &str) -> &'a Channel {
1691 log.channels.iter().find(|c| c.column == column).unwrap()
1692 }
1693
1694 fn close(a: f64, b: f64) -> bool {
1695 (a - b).abs() <= 1e-9 * b.abs().max(1.0)
1696 }
1697
1698 fn climbing(row: usize) -> f64 {
1699 1000.0 + 100.0 * row as f64
1700 }
1701
1702 #[test]
1703 fn the_four_headers_are_recognized_and_nothing_else() {
1704 let newer = format!(
1705 "{OLDER},Altitude AGL,Launch detection,Apogee detection,Landing detection,\
1706 Distance (feet)"
1707 );
1708 for header in [OLDER, newer.as_str(), GROUND, LONGER] {
1709 assert!(
1710 recognizes(&format!("\u{feff}\r\n\n{header}\r\n")),
1711 "{header}"
1712 );
1713 assert!(recognizes(&header.replace(',', " , ")), "{header}");
1714 }
1715 for other in [
1716 "",
1717 "UTCTIME,UNIXTIME,ALT\n",
1718 "UNIXTIME,UTCTIME,ALT,LAT,LON\n",
1719 "utctime,unixtime,alt,lat,lon\n",
1720 "TRACKER,DATE,TIME,ALT,FIX\n",
1721 "TRACKER,DATE,TIME,GS Lat,GS Lon\n",
1722 "DATE,TIME,LAT,LON\n",
1723 "Time,Altitude,Velocity\n",
1724 "Year,Month,Day,Time,Flight_Time_(s),Sync,Gyro_X\n",
1725 "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\n",
1726 ] {
1727 assert!(!recognizes(other), "{other:?}");
1728 }
1729 }
1730
1731 #[test]
1734 fn tracker_times_are_read_strictly() {
1735 assert_eq!(
1737 utc_time("Jul 14 2031 17:04:31.250 UTC"),
1738 Some(1_941_815_071_250_000_000)
1739 );
1740 assert_eq!(utc_time("Jan 1 1970 00:00:00 UTC"), Some(0));
1741 assert_eq!(
1742 utc_time("jul 14 2031 17:04:31.250 UTC"),
1743 utc_time("Jul 14 2031 17:04:31.250 UTC")
1744 );
1745 let months = [
1746 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1747 ];
1748 let mut last = i64::MIN;
1749 for month in months {
1750 let time = utc_time(&format!("{month} 1 2026 00:00:00 UTC")).unwrap();
1751 assert!(time > last, "{month}");
1752 last = time;
1753 }
1754 assert_eq!(
1756 utc_time("Feb 29 2024 00:00:00 UTC"),
1757 Some(19_782 * NS_PER_DAY)
1758 );
1759 assert_eq!(
1760 utc_time("Mar 1 2024 00:00:00 UTC"),
1761 Some(19_783 * NS_PER_DAY)
1762 );
1763 assert!(
1764 utc_time("Feb 29 2000 12:00:00 UTC").is_some(),
1765 "divisible by 400"
1766 );
1767 for bad in [
1768 "Feb 29 2023 12:00:00 UTC",
1769 "Feb 29 2100 12:00:00 UTC",
1770 "Apr 31 2021 12:00:00 UTC",
1771 "Apr 0 2021 12:00:00 UTC",
1772 "Jul 14 2031 17:04:31.250",
1773 "Jul 14 2031 17:04:31.250 GMT",
1774 "Jul 14 2031 17:04:31.250 UTC x",
1775 "July 14 2031 17:04:31 UTC",
1776 "Jul 14 31 17:04:31 UTC",
1777 "Jul 14 1969 17:04:31 UTC",
1778 "Jul 14 2200 17:04:31 UTC",
1779 "Jul 100 2031 17:04:31 UTC",
1780 "Jul 14 2031 24:00:00 UTC",
1781 "",
1782 ] {
1783 assert_eq!(utc_time(bad), None, "{bad:?}");
1784 }
1785 }
1786
1787 #[test]
1788 fn ground_station_dates_and_times_are_read_strictly() {
1789 assert_eq!(civil_date("2000-03-01"), Some(11_017));
1791 assert_eq!(civil_date("2199-12-31"), Some(84_005));
1792 assert_eq!(civil_date("2026-1-8"), civil_date("2026-01-08"));
1793 for bad in [
1794 "31-07-14",
1795 "2026-13-01",
1796 "2026-02-29",
1797 "2031-07-14-1",
1798 "2031/07/14",
1799 "",
1800 ] {
1801 assert_eq!(civil_date(bad), None, "{bad:?}");
1802 }
1803 assert_eq!(time_of_day_ns("0:00:00"), Some(0));
1804 assert_eq!(time_of_day_ns("14:27:09.375"), Some(52_029_375_000_000));
1805 assert_eq!(
1806 time_of_day_ns("23:59:60.123456789"),
1807 Some(86_400_123_456_789)
1808 );
1809 for bad in [
1810 "12:00",
1811 "12:00:00:00",
1812 "24:00:00",
1813 "12:60:00",
1814 "12:00:61",
1815 "12:5:00",
1816 "123:00:00",
1817 "12:00:00.",
1818 "12:00:00.1234567890",
1819 "12:00:00.x",
1820 "-1:00:00",
1821 "12:00:+1",
1822 ] {
1823 assert_eq!(time_of_day_ns(bad), None, "{bad:?}");
1824 }
1825 let before = civil_date("2025-12-31").unwrap() * NS_PER_DAY
1827 + time_of_day_ns("23:59:59.500").unwrap();
1828 let after = civil_date("2026-01-01").unwrap() * NS_PER_DAY
1829 + time_of_day_ns("00:00:00.250").unwrap();
1830 assert_eq!(seconds_between(before, after), 0.75);
1831 assert_eq!(clock_text(before), "23:59:59.500");
1832 }
1833
1834 #[test]
1835 fn cells_are_numbers_flags_hexadecimal_or_percentages() {
1836 assert_eq!(value("1.5"), Some(1.5));
1837 assert_eq!(value("-82"), Some(-82.0));
1838 assert_eq!(value("TRUE"), Some(1.0));
1839 assert_eq!(value("FALSE"), Some(0.0));
1840 assert_eq!(value("0x03"), Some(3.0));
1841 assert_eq!(value("0xff"), Some(255.0));
1842 assert_eq!(value("67%"), Some(67.0));
1843 assert_eq!(value("100%"), Some(100.0));
1844 for bad in [
1845 "",
1846 "NaN",
1847 "inf",
1848 "true",
1849 "0x",
1850 "0xg",
1851 "67 %",
1852 "%",
1853 "x",
1854 "0x12345678901234",
1855 ] {
1856 assert_eq!(value(bad), None, "{bad:?}");
1857 }
1858 }
1859
1860 #[test]
1863 fn a_degree_of_latitude_is_its_arc() {
1864 assert!((great_circle_m((40.0, -105.0), (41.0, -105.0)) - 111_195.08).abs() < 0.01);
1865 assert_eq!(great_circle_m((40.0, -105.0), (40.0, -105.0)), 0.0);
1866 }
1867
1868 #[test]
1871 fn speeds_that_agree_are_read_in_feet_per_second() {
1872 let log = read(&tracker_file(14, climbing, 100.0, 30.0)).unwrap();
1873 let up = channel(&log, "VERTV");
1874 assert_eq!(up.quantity, Quantity::Speed);
1875 assert!(close(up.values[0], 30.48));
1876 assert!(close(channel(&log, "HORZV").values[0], 30.0 * FOOT_M));
1877 let alt = channel(&log, "ALT");
1878 assert_eq!(alt.quantity, Quantity::GnssAltitude);
1879 assert!(close(alt.values[0], 304.8), "1000 ft is 304.8 m");
1880 assert_eq!(
1881 log.notes[..3],
1882 [
1883 "`HORZV` read in feet per second, from the manual and the distance between fixes: \
1884 the distance between fixes a second, in feet, is a median of 1.000 times `HORZV` \
1885 over 13 pairs of rows",
1886 "`ALT` read in feet, from the manual and the horizontal speed: the distance between \
1887 fixes a second, in feet, is a median of 1.000 times `HORZV` over 13 pairs of rows; \
1888 the change of `ALT` a second is a median of 1.000 times `VERTV` over 13 pairs of \
1889 rows",
1890 "`VERTV` read in feet per second, in the altitude's unit, from the change of altitude: the \
1891 change of `ALT` a second is a median of 1.000 times `VERTV` over 13 pairs of rows",
1892 ],
1893 "{:#?}",
1894 log.notes
1895 );
1896 }
1897
1898 #[test]
1903 fn a_file_all_in_meters_keeps_its_lengths_as_written() {
1904 let rising = |row: usize| 1000.0 + 50.0 * row as f64;
1905 let log = read(&moving_file(30, rising, 50.0, 25.0, 25.0, 90, "4.0")).unwrap();
1906 for (column, written) in [("ALT", 1000.0), ("VERTV", 50.0), ("HORZV", 25.0)] {
1907 let channel = channel(&log, column);
1908 assert_eq!(channel.quantity, Quantity::Unrecognized, "{column}");
1909 assert_eq!(channel.values[0], written, "{column}");
1910 }
1911 let why = "the horizontal speed disagrees with the manual's feet: ";
1912 assert!(
1913 log.notes[0].starts_with(
1914 "`HORZV` kept as written, the distance between fixes disagrees with the manual's \
1915 feet per second: the distance between fixes a second, in feet, is a median of \
1916 3.280 times `HORZV` over 29 pairs of rows"
1917 ),
1918 "{:#?}",
1919 log.notes
1920 );
1921 assert!(log.notes[1].starts_with(&format!("`ALT` kept as written, {why}")));
1922 assert_eq!(
1923 log.notes[2],
1924 format!(
1925 "`VERTV` kept as written, {why}the change of `ALT` a second is a median of 1.000 \
1926 times `VERTV` over 29 pairs of rows"
1927 )
1928 );
1929 }
1930
1931 #[test]
1933 fn the_same_file_in_feet_reads_in_feet() {
1934 let ft = 1.0 / FOOT_M;
1935 let rising = |row: usize| (1000.0 + 50.0 * row as f64) / FOOT_M;
1936 let log = read(&moving_file(
1937 30,
1938 rising,
1939 50.0 * ft,
1940 25.0 * ft,
1941 25.0,
1942 90,
1943 "4.0",
1944 ))
1945 .unwrap();
1946 for (column, quantity, si) in [
1947 ("ALT", Quantity::GnssAltitude, 1000.0),
1948 ("VERTV", Quantity::Speed, 50.0),
1949 ("HORZV", Quantity::Speed, 25.0),
1950 ] {
1951 let channel = channel(&log, column);
1952 assert_eq!(channel.quantity, quantity, "{column}");
1953 assert!(
1954 close(channel.values[0], si),
1955 "{column}: {}",
1956 channel.values[0]
1957 );
1958 }
1959 assert!(
1960 log.notes[1].starts_with(
1961 "`ALT` read in feet, from the manual and the horizontal speed: the distance \
1962 between fixes a second, in feet, is a median of 1.000 times `HORZV` over 29 pairs \
1963 of rows"
1964 ),
1965 "{:#?}",
1966 log.notes
1967 );
1968 }
1969
1970 #[test]
1973 fn a_vertical_speed_that_disagrees_is_kept_as_written() {
1974 let log = read(&tracker_file(14, climbing, 30.48, 30.0)).unwrap();
1975 let up = channel(&log, "VERTV");
1976 assert_eq!(up.quantity, Quantity::Unrecognized);
1977 assert_eq!(up.values[0], 30.48);
1978 let alt = channel(&log, "ALT");
1979 assert_eq!(alt.quantity, Quantity::Unrecognized);
1980 assert_eq!(alt.values[0], 1000.0);
1981 assert_eq!(
1982 log.notes[2],
1983 "`VERTV` kept as written, not in the altitude's unit a second: the change of `ALT` a second is \
1984 a median of 3.281 times `VERTV` over 13 pairs of rows"
1985 );
1986 assert!(
1987 log.notes[1]
1988 .starts_with("`ALT` kept as written, `VERTV` isn't their change a second: ")
1989 );
1990 assert_eq!(channel(&log, "HORZV").quantity, Quantity::Speed);
1992 }
1993
1994 #[test]
1997 fn horizontal_speed_heading_and_battery_that_disagree_are_kept() {
1998 let mph = 30.0 / 1.466_667;
1999 let log = read(&tracker_file_with(14, climbing, 100.0, mph, 400, "4010")).unwrap();
2000 let horizontal = channel(&log, "HORZV");
2001 assert_eq!(horizontal.quantity, Quantity::Unrecognized);
2002 assert!(close(horizontal.values[0], mph));
2003 assert_eq!(channel(&log, "HEAD").quantity, Quantity::Unrecognized);
2004 assert_eq!(channel(&log, "BATT").quantity, Quantity::Unrecognized);
2005 assert_eq!(channel(&log, "BATT").values[0], 4010.0);
2006 let notes = log.notes.join("\n");
2007 assert_eq!(channel(&log, "ALT").quantity, Quantity::Unrecognized);
2009 assert_eq!(channel(&log, "VERTV").quantity, Quantity::Unrecognized);
2010 for expected in [
2011 "`HORZV` kept as written, the distance between fixes disagrees with the manual's feet \
2012 per second: the distance between fixes a second, in feet, is a median of 1.466 times",
2013 "`HEAD` kept as written, its range disagrees with the manual's degrees: it runs from \
2014 400 to 400",
2015 "`BATT` kept as written, its range isn't one lithium cell's volts: it runs from 4010 \
2016 to 4010",
2017 ] {
2018 assert!(notes.contains(expected), "{expected}\n{notes}");
2019 }
2020 let inside = read(&tracker_file(14, climbing, 100.0, 30.0 / 1.39)).unwrap();
2022 assert_eq!(channel(&inside, "HORZV").quantity, Quantity::Speed);
2023 let outside = read(&tracker_file(14, climbing, 100.0, 30.0 / 1.41)).unwrap();
2024 assert_eq!(channel(&outside, "HORZV").quantity, Quantity::Unrecognized);
2025 }
2026
2027 #[test]
2030 fn too_few_pairs_leave_the_manuals_unit() {
2031 let log = read(&tracker_file(5, climbing, 30.48, 9.0)).unwrap();
2032 assert_eq!(channel(&log, "VERTV").quantity, Quantity::Speed);
2033 assert_eq!(channel(&log, "ALT").quantity, Quantity::GnssAltitude);
2034 assert_eq!(
2035 log.notes[..3],
2036 [
2037 "`HORZV` read in feet per second, from the manual alone: the file has too few \
2038 pairs of moving rows to check `HORZV` over, 0 of the 10 needed",
2039 "`ALT` read in feet, from the manual alone: the file has too few pairs of moving \
2040 rows to check `HORZV` over, 0 of the 10 needed; the file has too few pairs of \
2041 moving rows to check `VERTV` over, 4 of the 10 needed",
2042 "`VERTV` read in feet per second, in the altitude's unit, from the manual alone: the file \
2043 has too few pairs of moving rows to check `VERTV` over, 4 of the 10 needed",
2044 ]
2045 );
2046 }
2047
2048 #[test]
2051 fn heights_that_disagree_with_alt_agl_are_kept() {
2052 let row = |agl: f64| {
2053 format!(
2054 "T,2026-03-08,09:15:10,20,-100,3000,20.001,-100,4000,3,4,0,45,0x01,24,6,12,2,-50,\
2055 -55,364.8,{agl},0,12.5,100%,4.0,4.1,1,1,0,1,1,,0,0,0,0,0,-60,-5"
2056 )
2057 };
2058 let agrees = read(&format!("{LONGER}\n{}\n", row(1005.0))).unwrap();
2059 assert_eq!(
2060 channel(&agrees, "GS Alt asl").quantity,
2061 Quantity::GnssAltitude
2062 );
2063 let disagrees = read(&format!("{LONGER}\n{}\n", row(304.8))).unwrap();
2064 assert_eq!(
2065 channel(&disagrees, "GS Alt asl").quantity,
2066 Quantity::Unrecognized
2067 );
2068 assert_eq!(
2069 channel(&disagrees, "GS_ASL_offset").quantity,
2070 Quantity::Unrecognized
2071 );
2072 assert_eq!(channel(&agrees, "VERTV").quantity, Quantity::Speed);
2074 assert_eq!(
2075 channel(&disagrees, "VERTV").quantity,
2076 Quantity::Unrecognized
2077 );
2078 assert!(disagrees.notes.contains(
2079 &"`VERTV` kept as written, `Alt AGL (ft)` disagrees with the manual's feet: the file \
2080 has too few pairs of moving rows to check `VERTV` over, 0 of the 10 needed"
2081 .to_owned()
2082 ));
2083 assert!(disagrees.notes.iter().any(|note| note.starts_with(
2084 "`GS Alt asl`, `TRACKER Alt asl`, `Found rocket altitude` and `GS_ASL_offset` kept as \
2085 written, `Alt AGL (ft)` disagrees with the manual's feet: "
2086 )));
2087 }
2088
2089 #[test]
2090 fn unixtime_off_by_other_than_whole_hours_is_said() {
2091 let text = format!(
2092 "{OLDER}\nJun 1 2025 18:00:00.000 UTC,1748800800.500,1,40,-105,1,3,0,0,0,0x03,1,1,1,\
2093 1,4\n"
2094 );
2095 let log = read(&text).unwrap();
2096 assert!(
2097 log.notes.iter().any(|note| note
2098 == "`UNIXTIME` kept as written, off `UTCTIME` by other than one whole number of \
2099 hours: it runs from 0.500 s to 0.500 s ahead of `UTCTIME`; read as UTC at the \
2100 first fix's longitude, `UTCTIME` gives 11.0 h of local mean solar time and \
2101 `UNIXTIME` 11.0 h"),
2102 "{:#?}",
2103 log.notes
2104 );
2105 }
2106
2107 #[test]
2110 fn positions_out_of_range_are_gaps() {
2111 let row = |lat: &str, lon: &str| {
2112 format!(
2113 "Jun 1 2025 18:00:00.000 UTC,1748800800,1,{lat},{lon},1,3,0,0,0,0x03,1,1,1,1,4\n"
2114 )
2115 };
2116 let text = format!(
2117 "{OLDER}\n{}{}{}{}",
2118 row("90", "-180"),
2119 row("90.5", "-105"),
2120 row("-91", "180.001"),
2121 row("40", "-181")
2122 );
2123 let log = read(&text).unwrap();
2124 let (lat, lon) = (channel(&log, "LAT"), channel(&log, "LON"));
2125 assert_eq!(lat.values[0], 90.0);
2126 assert_eq!(lon.values[0], -180.0);
2127 assert!(lat.values[1].is_nan() && lon.values[1] == -105.0);
2128 assert!(lat.values[2].is_nan() && lon.values[2].is_nan());
2129 assert!(lat.values[3] == 40.0 && lon.values[3].is_nan());
2130 for expected in [
2131 "the column \"LAT\" holds 2 latitudes beyond ±90°: they are gaps",
2132 "the column \"LON\" holds 2 longitudes beyond ±180°: they are gaps",
2133 ] {
2134 assert!(
2135 log.notes.iter().any(|note| note == expected),
2136 "{expected}\n{:#?}",
2137 log.notes
2138 );
2139 }
2140 }
2141
2142 #[test]
2144 fn a_long_line_of_other_names_is_not_a_header() {
2145 let commas = ",".repeat(1 << 20);
2146 assert!(!recognizes(&commas));
2147 assert!(!recognizes(&format!("UTCTIME{commas}")));
2148 assert!(!recognizes(&format!("TRACKER,DATE{commas}")));
2149 assert!(recognizes(&format!("UTCTIME,UNIXTIME{commas}LAT,LON")));
2150 }
2151
2152 #[test]
2153 fn repeated_times_alone_are_noted() {
2154 let row = "Jun 1 2025 18:00:00.000 UTC,1748800800,1,40,-105,1,3,0,0,0,0x03,1,1,1,1,4";
2155 let log = read(&format!("{OLDER}\n{row}\n{row}\n")).unwrap();
2156 assert!(
2157 log.notes.contains(
2158 &"1 rows repeat a time already written: every row is kept, in the file's order"
2159 .to_owned()
2160 )
2161 );
2162 assert_eq!(channel(&log, "ALT").time_s, [0.0, 0.0]);
2163 }
2164
2165 #[test]
2167 fn trackers_past_the_cap_are_left_out() {
2168 let mut text = format!("{GROUND}\n");
2169 for tracker in 0..70 {
2170 text.push_str(&format!(
2171 "T{tracker},2026-01-01,10:00:{:02},1,1,1,3,0,0,0,0x03,1,1,1,1,1,0\n",
2172 tracker % 60
2173 ));
2174 }
2175 text.push_str("T69,2026-01-01,10:01:00,1,1,1,3,0,0,0,0x03,1,1,1,1,1,0\n");
2176 let log = read(&text).unwrap();
2177 assert_eq!(log.channels.len(), MAX_TRACKERS * 15);
2178 assert_eq!(log.channels[15].column, "TIME [T1]");
2179 assert!(
2180 log.notes.contains(
2181 &"7 rows of 6 more trackers were left out, past the first 64 trackers or 4096 \
2182 channels, so that a file of many tracker names can't make the reader hold a \
2183 set of channels for each"
2184 .to_owned()
2185 ),
2186 "{:#?}",
2187 log.notes
2188 );
2189 }
2190
2191 #[test]
2193 fn malformed_files_are_refused_where_they_go_wrong() {
2194 let row = "Jun 1 2025 18:00:00.000 UTC,1748800800,1,40,-105,1,3,0,0,0,0x03,1,1,1,1,4";
2195 let cases = [
2196 (String::new(), "the file is empty"),
2197 ("\n\r\n".to_owned(), "the file is empty"),
2198 (
2199 "Time,Altitude\n0,0\n".to_owned(),
2200 "isn't a Featherweight GPS header",
2201 ),
2202 (format!("{OLDER}\n"), "no data rows"),
2203 (
2204 "UTCTIME,UNIXTIME,LAT,LON,,x\n".to_owned(),
2205 "line 1: the header's column 5 has no name",
2206 ),
2207 (
2208 format!("{OLDER}\n{row},9\n"),
2209 "line 2: the row has 17 cells, more than the header's 16",
2210 ),
2211 (
2212 format!("{OLDER}\n{}\n", row.replace("Jun 1", "Jun 31")),
2213 "line 2: the time \"Jun 31 2025 18:00:00.000 UTC\" in cell 1 isn't one",
2214 ),
2215 (
2216 format!("{GROUND}\nT,2026-02-30,10:00:00,1,1,1\n"),
2217 "line 2: the date \"2026-02-30\" in cell 2 isn't one",
2218 ),
2219 (
2220 format!("{GROUND}\nT,2026-02-03,10:00\n"),
2221 "line 2: the time \"10:00\" in cell 3 isn't one",
2222 ),
2223 (
2224 format!("{GROUND}\nT,2026-02-03\n"),
2225 "line 2: the row ends after 2 cells, before its \"TIME\"",
2226 ),
2227 ];
2228 for (text, expected) in cases {
2229 let error = read(&text).unwrap_err().to_string();
2230 assert!(error.contains(expected), "{text:?}: {error}");
2231 }
2232 let LogError::NotThisFormat { format, .. } = read("x").unwrap_err() else {
2233 panic!("refused for another reason");
2234 };
2235 assert_eq!(format, FORMAT);
2236 }
2237
2238 #[test]
2241 fn short_rows_are_gaps_and_a_wide_header_is_refused() {
2242 let wide: String = (0..20_000).map(|i| format!(",c{i}")).collect();
2243 let rows: String = (0..100)
2244 .map(|i| format!("Jun 1 2025 18:00:{:02}.000 UTC\n", i % 60))
2245 .collect();
2246 let error = read(&format!("UTCTIME,UNIXTIME,LAT,LON{wide}\n{rows}"))
2247 .unwrap_err()
2248 .to_string();
2249 assert!(
2250 error.contains(
2251 "line 5: the header names 20004 columns, and the 4 rows down to this one hold \
2252 only 4 cells"
2253 ),
2254 "{error}"
2255 );
2256 let log = read(&format!(
2257 "{OLDER}\nJun 1 2025 18:00:00.000 UTC,1748800800,1\n"
2258 ))
2259 .unwrap();
2260 assert!(channel(&log, "LAT").values[0].is_nan());
2261 assert!(log.notes.contains(
2262 &"1 rows, the first on line 2, have fewer cells than the header's 16: the cells they \
2263 leave out are gaps"
2264 .to_owned()
2265 ));
2266 }
2267
2268 #[test]
2271 fn unknown_columns_are_kept_and_their_notes_stay_few() {
2272 let unknown: String = (0..30).map(|i| format!(",u{i}")).collect();
2273 let row = format!(
2274 "Jun 1 2025 18:00:00.000 UTC,1748800800,1,40,-105{}",
2275 ",7".repeat(30)
2276 );
2277 let log = read(&format!("UTCTIME,UNIXTIME,ALT,LAT,LON{unknown}\n{row}\n")).unwrap();
2278 assert_eq!(channel(&log, "u29").quantity, Quantity::Unrecognized);
2279 assert_eq!(channel(&log, "u29").values, [7.0]);
2280 assert_eq!(
2281 log.notes
2282 .iter()
2283 .filter(|note| note.contains("isn't one this reader knows"))
2284 .count(),
2285 20
2286 );
2287 assert!(log.notes.last().unwrap().starts_with("and 10 more notes"));
2288 }
2289
2290 proptest::proptest! {
2291 #[test]
2294 fn any_text_is_read_or_refused(
2295 rows in proptest::collection::vec(
2296 (proptest::bool::ANY, "[-0-9.,: xaTRUEFLSNC%Jn]{0,60}"),
2297 0..10,
2298 ),
2299 layout in 0..3usize,
2300 ) {
2301 let header = [OLDER, GROUND, LONGER][layout];
2302 let clock = if layout == 0 {
2304 "Jun 1 2025 18:00:00.000 UTC,"
2305 } else {
2306 "T,2026-01-01,10:00:00,"
2307 };
2308 let rows: Vec<String> = rows
2309 .into_iter()
2310 .map(|(timed, rest)| if timed { format!("{clock}{rest}") } else { rest })
2311 .collect();
2312 let text = format!("{header}\n{}", rows.join("\n"));
2313 if let Ok(log) = read(&text) {
2314 proptest::prop_assert!(log.time_s.is_empty() && log.altitude_m.is_empty());
2315 for channel in &log.channels {
2316 proptest::prop_assert_eq!(channel.time_s.len(), channel.values.len());
2317 proptest::prop_assert!(channel.time_s.iter().all(|t| t.is_finite()));
2318 }
2319 }
2320 }
2321 }
2322}