1use std::collections::{BTreeMap, BTreeSet};
117
118use crate::error::LogError;
119use crate::log::{Channel, FlightLog, LogFormat, Quantity, Stated};
120use crate::table::{Notes, number, numbered_lines, short};
121
122const FORMAT: &str = "NMEA 0183";
124
125const RECOGNIZE_LINES: usize = 50;
127
128const RECOGNIZE_SENTENCES: usize = 3;
130
131const RECOGNIZE_LINE_BYTES: usize = 4096;
133
134pub const MAX_ADDRESSES: usize = 16;
137
138pub const MAX_SATELLITES: usize = 64;
141
142const MAX_OTHER_TYPES: usize = 8;
144
145const KNOT_M_S: f64 = 1852.0 / 3600.0;
148
149const DAY_NS: i128 = 86_400_000_000_000;
151
152pub(crate) fn recognizes(text: &str) -> bool {
157 let (mut lines, mut found) = (0usize, 0usize);
158 for (_, line) in numbered_lines(text) {
159 if line.trim().is_empty() {
160 continue;
161 }
162 lines += 1;
163 let opens = head(line.trim_start(), RECOGNIZE_LINE_BYTES)
164 .strip_prefix('$')
165 .and_then(|rest| rest.split('$').next())
166 .is_some_and(|piece| sentence(piece).is_ok());
167 if opens {
168 found += 1;
169 }
170 if found >= RECOGNIZE_SENTENCES {
171 return true;
172 }
173 if lines >= RECOGNIZE_LINES {
174 return false;
175 }
176 }
177 found > 0 && found == lines
178}
179
180fn head(line: &str, bytes: usize) -> &str {
182 if line.len() <= bytes {
183 return line;
184 }
185 let mut end = bytes;
186 while !line.is_char_boundary(end) {
187 end -= 1;
188 }
189 &line[..end]
190}
191
192#[derive(Debug, PartialEq)]
195struct Sentence<'a> {
196 address: &'a str,
197 fields: Vec<&'a str>,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq)]
202enum Failure {
203 CutOff,
205 Damaged,
208}
209
210#[derive(Debug, Default)]
212struct LineScan<'a> {
213 sentences: Vec<Sentence<'a>>,
214 noise: bool,
216 dollar: bool,
218 failure: Option<Failure>,
220}
221
222fn scan_line(line: &str) -> LineScan<'_> {
225 let mut scan = LineScan::default();
226 let mut pieces = line.split('$');
227 if pieces
228 .next()
229 .is_some_and(|before| !before.trim().is_empty())
230 {
231 scan.noise = true;
232 }
233 for piece in pieces {
234 scan.dollar = true;
235 match sentence(piece) {
236 Ok((sentence, rest)) => {
237 if !rest.trim().is_empty() {
238 scan.noise = true;
239 }
240 scan.sentences.push(sentence);
241 }
242 Err(failure) => {
243 scan.noise = true;
244 scan.failure.get_or_insert(failure);
245 }
246 }
247 }
248 scan
249}
250
251fn sentence(piece: &str) -> Result<(Sentence<'_>, &str), Failure> {
253 let star = piece.find('*').ok_or(Failure::CutOff)?;
254 let (body, after) = (&piece[..star], &piece[star + 1..]);
255 let digits = after
256 .get(..2)
257 .filter(|digits| digits.bytes().all(|b| b.is_ascii_hexdigit()))
258 .ok_or(Failure::CutOff)?;
259 let stated = u8::from_str_radix(digits, 16).map_err(|_| Failure::CutOff)?;
260 let allowed = |b: u8| (0x20..=0x7e).contains(&b) && !matches!(b, b'!' | b'\\' | b'~');
261 if !body.bytes().all(allowed) || checksum(body) != stated {
262 return Err(Failure::Damaged);
263 }
264 let mut fields = body.split(',');
265 let address = fields.next().unwrap_or_default();
267 let address_ok = (2..=16).contains(&address.len())
268 && address
269 .bytes()
270 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit());
271 if !address_ok {
272 return Err(Failure::Damaged);
273 }
274 Ok((
275 Sentence {
276 address,
277 fields: fields.collect(),
278 },
279 &after[2..],
280 ))
281}
282
283fn checksum(body: &str) -> u8 {
286 body.bytes().fold(0, |sum, b| sum ^ b)
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291enum Kind {
292 Gga,
293 Rmc,
294 Gsa,
295 Gsv,
296}
297
298impl Kind {
299 fn of(address: &str) -> Option<Self> {
300 if address.len() != 5 {
301 return None;
302 }
303 match &address[2..] {
304 "GGA" => Some(Self::Gga),
305 "RMC" => Some(Self::Rmc),
306 "GSA" => Some(Self::Gsa),
307 "GSV" => Some(Self::Gsv),
308 _ => None,
309 }
310 }
311
312 fn name(self) -> &'static str {
313 match self {
314 Self::Gga => "GGA",
315 Self::Rmc => "RMC",
316 Self::Gsa => "GSA",
317 Self::Gsv => "GSV",
318 }
319 }
320
321 fn fields(self) -> (usize, usize) {
324 match self {
325 Self::Gga => (14, 14),
327 Self::Rmc => (11, 12),
328 Self::Gsa => (17, 17),
329 Self::Gsv => (3, usize::MAX),
331 }
332 }
333}
334
335#[derive(Debug, Clone, Copy, PartialEq)]
337enum Rule {
338 Number,
340 Times(f64),
342 Coordinate {
344 most_degrees: f64,
345 negative: char,
346 positive: char,
347 },
348 EastWest,
350 Letter(&'static [(char, f64)]),
352}
353
354struct Track {
356 name: String,
357 quantity: Quantity,
358 time_s: Vec<f64>,
359 values: Vec<f64>,
360 optional: bool,
362 bad: usize,
364 bad_example: Option<String>,
365}
366
367impl Track {
368 fn new(name: String, quantity: Quantity, optional: bool) -> Self {
369 Self {
370 name,
371 quantity,
372 time_s: Vec::new(),
373 values: Vec::new(),
374 optional,
375 bad: 0,
376 bad_example: None,
377 }
378 }
379
380 fn push(&mut self, time_s: f64, value: f64) {
381 self.time_s.push(time_s);
382 self.values.push(value);
383 }
384
385 fn push_field(&mut self, time_s: f64, value: Option<Result<f64, &str>>) {
388 let value = match value {
389 None => f64::NAN,
390 Some(Ok(value)) => value,
391 Some(Err(written)) => {
392 self.bad += 1;
393 if self.bad_example.is_none() {
394 self.bad_example = Some(short(written));
395 }
396 f64::NAN
397 }
398 };
399 self.push(time_s, value);
400 }
401}
402
403type Layout = &'static [(&'static str, Quantity, usize, Rule, bool)];
406
407const LATITUDE: Rule = Rule::Coordinate {
408 most_degrees: 90.0,
409 negative: 'S',
410 positive: 'N',
411};
412const LONGITUDE: Rule = Rule::Coordinate {
413 most_degrees: 180.0,
414 negative: 'W',
415 positive: 'E',
416};
417
418const MODES: &[(char, f64)] = &[
420 ('N', 0.0),
421 ('A', 1.0),
422 ('D', 2.0),
423 ('P', 3.0),
424 ('R', 4.0),
425 ('F', 5.0),
426 ('E', 6.0),
427 ('M', 7.0),
428 ('S', 8.0),
429];
430
431const GGA: Layout = &[
432 ("latitude", Quantity::Latitude, 1, LATITUDE, false),
433 ("longitude", Quantity::Longitude, 3, LONGITUDE, false),
434 ("fix quality", Quantity::Number, 5, Rule::Number, false),
435 ("satellites used", Quantity::Number, 6, Rule::Number, false),
436 ("HDOP", Quantity::Number, 7, Rule::Number, false),
437 ("DGPS age", Quantity::Number, 12, Rule::Number, true),
439 ("DGPS station", Quantity::Number, 13, Rule::Number, true),
440];
441
442const RMC: Layout = &[
443 (
444 "status",
445 Quantity::Number,
446 1,
447 Rule::Letter(&[('A', 1.0), ('V', 0.0)]),
448 false,
449 ),
450 ("latitude", Quantity::Latitude, 2, LATITUDE, false),
451 ("longitude", Quantity::Longitude, 4, LONGITUDE, false),
452 ("speed", Quantity::Speed, 6, Rule::Times(KNOT_M_S), false),
453 ("course", Quantity::Angle, 7, Rule::Number, false),
454 (
455 "magnetic variation",
456 Quantity::Angle,
457 9,
458 Rule::EastWest,
459 true,
460 ),
461 ("mode", Quantity::Number, 11, Rule::Letter(MODES), true),
462];
463
464const GSA: Layout = &[
465 (
466 "selection",
467 Quantity::Number,
468 0,
469 Rule::Letter(&[('A', 1.0), ('M', 0.0)]),
470 false,
471 ),
472 ("fix type", Quantity::Number, 1, Rule::Number, false),
473 ("PDOP", Quantity::Number, 14, Rule::Number, false),
475 ("HDOP", Quantity::Number, 15, Rule::Number, false),
476 ("VDOP", Quantity::Number, 16, Rule::Number, false),
477];
478
479fn field_value<'a>(fields: &[&'a str], place: usize, rule: Rule) -> Option<Result<f64, &'a str>> {
481 let cell = fields.get(place).copied().unwrap_or_default().trim();
482 if cell.is_empty() {
483 return None;
484 }
485 let next = || fields.get(place + 1).copied().unwrap_or_default().trim();
486 let value = match rule {
487 Rule::Number => number(cell, false),
488 Rule::Times(factor) => number(cell, false).map(|value| value * factor),
489 Rule::Coordinate {
490 most_degrees,
491 negative,
492 positive,
493 } => coordinate(cell, most_degrees)
494 .and_then(|degrees| sign(next(), negative, positive, degrees)),
495 Rule::EastWest => number(cell, false).and_then(|degrees| sign(next(), 'W', 'E', degrees)),
496 Rule::Letter(codes) => {
497 let mut chars = cell.chars();
498 match (chars.next(), chars.next()) {
499 (Some(letter), None) => codes
500 .iter()
501 .find(|(code, _)| *code == letter)
502 .map(|(_, value)| *value),
503 _ => None,
504 }
505 }
506 };
507 Some(value.ok_or(cell))
508}
509
510fn sign(hemisphere: &str, negative: char, positive: char, value: f64) -> Option<f64> {
512 let mut chars = hemisphere.chars();
513 match (chars.next(), chars.next()) {
514 (Some(letter), None) if letter == positive => Some(value),
515 (Some(letter), None) if letter == negative => Some(-value),
516 _ => None,
517 }
518}
519
520fn coordinate(cell: &str, most_degrees: f64) -> Option<f64> {
525 let (whole, fraction) = split_decimal(cell)?;
526 let digits = |text: &str| text.bytes().all(|b| b.is_ascii_digit());
527 if whole.len() < 2 || whole.len() > 5 || !digits(whole) || !digits(fraction) {
528 return None;
529 }
530 let (degrees, minutes) = whole.split_at(whole.len() - 2);
531 let degrees: f64 = if degrees.is_empty() {
532 0.0
533 } else {
534 degrees.parse().ok()?
535 };
536 let minutes: f64 = if fraction.is_empty() {
537 minutes.parse().ok()?
538 } else {
539 format!("{minutes}.{fraction}").parse().ok()?
540 };
541 let value = degrees + minutes / 60.0;
542 (minutes < 60.0 && value <= most_degrees).then_some(value)
543}
544
545fn split_decimal(cell: &str) -> Option<(&str, &str)> {
547 match cell.split_once('.') {
548 Some((_, "")) => None,
549 Some(parts) => Some(parts),
550 None => Some((cell, "")),
551 }
552}
553
554fn time_of_day(cell: &str) -> Option<i64> {
557 let (whole, fraction) = split_decimal(cell)?;
558 if whole.len() != 6
559 || fraction.len() > 9
560 || !whole
561 .bytes()
562 .chain(fraction.bytes())
563 .all(|b| b.is_ascii_digit())
564 {
565 return None;
566 }
567 let part = |range: std::ops::Range<usize>| whole[range].parse::<i64>().ok();
568 let (hours, minutes, seconds) = (part(0..2)?, part(2..4)?, part(4..6)?);
569 if hours > 23 || minutes > 59 || seconds > 60 {
570 return None;
571 }
572 let mut nanos: i64 = 0;
573 for (place, digit) in fraction.bytes().enumerate() {
574 nanos += i64::from(digit - b'0') * 10_i64.pow(8 - place as u32);
575 }
576 Some(((hours * 60 + minutes) * 60 + seconds) * 1_000_000_000 + nanos)
577}
578
579fn date(cell: &str) -> Option<i64> {
582 if cell.len() != 6 || !cell.bytes().all(|b| b.is_ascii_digit()) {
583 return None;
584 }
585 let part = |range: std::ops::Range<usize>| cell[range].parse::<i64>().ok();
586 let (day, month, year) = (part(0..2)?, part(2..4)?, part(4..6)?);
587 let year = if year >= 80 { 1900 + year } else { 2000 + year };
588 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
589 let length = match month {
590 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
591 4 | 6 | 9 | 11 => 30,
592 2 if leap => 29,
593 2 => 28,
594 _ => return None,
595 };
596 (1..=length)
597 .contains(&day)
598 .then(|| days_from_civil(year, month, day))
599}
600
601fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
604 let year = if month <= 2 { year - 1 } else { year };
605 let era = year.div_euclid(400);
606 let year_of_era = year - era * 400;
607 let day_of_year = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
608 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
609 era * 146_097 + day_of_era - 719_468
610}
611
612fn civil_from_days(days: i64) -> (i64, i64, i64) {
614 let days = days + 719_468;
615 let era = days.div_euclid(146_097);
616 let day_of_era = days - era * 146_097;
617 let year_of_era =
618 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
619 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
620 let shifted = (5 * day_of_year + 2) / 153;
621 let day = day_of_year - (153 * shifted + 2) / 5 + 1;
622 let month = if shifted < 10 {
623 shifted + 3
624 } else {
625 shifted - 9
626 };
627 let year = year_of_era + era * 400 + i64::from(month <= 2);
628 (year, month, day)
629}
630
631#[derive(Default)]
633struct Clock {
634 zero_ns: Option<i64>,
636 day: i64,
638 last_tod_ns: Option<i64>,
639 last_abs_ns: Option<i128>,
640 last_date: Option<(i64, i64)>,
642 first_date: Option<(i64, i64)>,
644 epoch_s: f64,
646 backs: usize,
648 largest_back_s: f64,
649 midnights: usize,
651 settled: usize,
652}
653
654impl Clock {
655 fn new() -> Self {
656 Self {
657 epoch_s: f64::NAN,
658 ..Self::default()
659 }
660 }
661
662 fn time(&mut self, tod_ns: Option<i64>, date: Option<i64>) -> f64 {
665 let Some(tod_ns) = tod_ns else {
666 self.epoch_s = f64::NAN;
667 return f64::NAN;
668 };
669 if let Some(last) = self.last_tod_ns {
670 let step = i128::from(tod_ns) - i128::from(last);
671 if step < -DAY_NS / 2 {
672 self.day += 1;
673 self.midnights += 1;
674 } else if step > DAY_NS / 2 {
675 self.day -= 1;
676 self.midnights += 1;
677 }
678 }
679 if let Some(date) = date {
680 if let Some((last_date, last_day)) = self.last_date {
681 let day = last_day.saturating_add(date - last_date);
682 if day != self.day {
683 self.settled += 1;
684 self.day = day;
685 }
686 }
687 self.last_date = Some((date, self.day));
688 self.first_date.get_or_insert((date, self.day));
689 }
690 let zero = *self.zero_ns.get_or_insert(tod_ns);
691 let abs = i128::from(self.day) * DAY_NS + i128::from(tod_ns);
692 if let Some(last) = self.last_abs_ns
693 && abs < last
694 {
695 self.backs += 1;
696 self.largest_back_s = self.largest_back_s.max((last - abs) as f64 / 1e9);
697 }
698 self.last_abs_ns = Some(abs);
699 self.last_tod_ns = Some(tod_ns);
700 self.epoch_s = (abs - i128::from(zero)) as f64 / 1e9;
701 self.epoch_s
702 }
703}
704
705struct Address {
707 address: String,
708 kind: Kind,
709 fixed: Vec<usize>,
711 by_unit: Vec<(usize, String, usize)>,
713 satellites: Vec<(u16, [usize; 3])>,
715 satellites_left_out: BTreeSet<u16>,
716 groups_bad: usize,
717 sentences: usize,
718 short: usize,
719 extra: usize,
720}
721
722struct Reader {
724 tracks: Vec<Track>,
725 addresses: Vec<Address>,
726 clock: Clock,
727 addresses_left_out: usize,
729 other: BTreeMap<String, usize>,
731 other_left_out: usize,
732 empty_times: usize,
734 bad_times: usize,
735 bad_time_example: Option<String>,
736 untimed_before: usize,
738 void_dates: BTreeSet<i64>,
740 valid_dates: BTreeSet<i64>,
741 void_dated: usize,
742 rmc: usize,
743 bad_dates: usize,
744 last_gga_tod: Option<i64>,
746 pairs: usize,
747 pairs_agree: usize,
748}
749
750pub fn read(text: &str) -> Result<FlightLog, LogError> {
757 let mut reader = Reader {
758 tracks: Vec::new(),
759 addresses: Vec::new(),
760 clock: Clock::new(),
761 addresses_left_out: 0,
762 other: BTreeMap::new(),
763 other_left_out: 0,
764 empty_times: 0,
765 bad_times: 0,
766 bad_time_example: None,
767 untimed_before: 0,
768 void_dates: BTreeSet::new(),
769 valid_dates: BTreeSet::new(),
770 void_dated: 0,
771 rmc: 0,
772 bad_dates: 0,
773 last_gga_tod: None,
774 pairs: 0,
775 pairs_agree: 0,
776 };
777 let mut lines = LineCounts::default();
778 let mut sentences = 0usize;
779 for (number, line) in numbered_lines(text) {
780 let not_text = line
781 .chars()
782 .filter(|c| *c == '\0' || *c == char::REPLACEMENT_CHARACTER)
783 .count();
784 if not_text > 0 {
785 lines.not_text += not_text;
786 lines.not_text_lines += 1;
787 lines.last_not_text = number;
788 }
789 if line.trim().is_empty() {
790 continue;
791 }
792 let scan = scan_line(line);
793 if scan.sentences.is_empty() {
794 let kind = match (scan.dollar, scan.failure) {
795 (false, _) => &mut lines.no_start,
796 (true, Some(Failure::Damaged)) => &mut lines.damaged,
797 (true, _) => &mut lines.cut_off,
798 };
799 kind.count(number, line);
800 continue;
801 }
802 if scan.noise {
803 lines.noise.count(number, line);
804 }
805 for sentence in scan.sentences {
806 sentences += 1;
807 reader.sentence(&sentence);
808 }
809 }
810 if sentences == 0 {
811 return Err(LogError::NotThisFormat {
812 format: FORMAT,
813 message: "no line holds a sentence, a `$`, an address, fields and a checksum that \
814 checks out"
815 .to_owned(),
816 });
817 }
818 if reader.addresses.is_empty() {
819 return Err(LogError::NoData { format: FORMAT });
820 }
821 Ok(reader.build(&lines))
822}
823
824#[derive(Default)]
826struct LineKind {
827 count: usize,
828 first: Option<(usize, String)>,
829}
830
831impl LineKind {
832 fn count(&mut self, number: usize, line: &str) {
833 self.count += 1;
834 self.first.get_or_insert_with(|| (number, short(line)));
835 }
836}
837
838#[derive(Default)]
839struct LineCounts {
840 no_start: LineKind,
841 cut_off: LineKind,
842 damaged: LineKind,
843 noise: LineKind,
844 not_text: usize,
845 not_text_lines: usize,
846 last_not_text: usize,
847}
848
849impl Reader {
850 fn sentence(&mut self, sentence: &Sentence<'_>) {
852 let Some(kind) = Kind::of(sentence.address) else {
853 if let Some(count) = self.other.get_mut(sentence.address) {
854 *count += 1;
855 } else if self.other.len() < MAX_OTHER_TYPES {
856 self.other.insert(sentence.address.to_owned(), 1);
857 } else {
858 self.other_left_out += 1;
859 }
860 return;
861 };
862 let index = match self
863 .addresses
864 .iter()
865 .position(|a| a.address == sentence.address)
866 {
867 Some(index) => index,
868 None if self.addresses.len() < MAX_ADDRESSES => {
869 self.add_address(sentence.address, kind)
870 }
871 None => {
872 self.addresses_left_out += 1;
873 return;
874 }
875 };
876 let fields = &sentence.fields;
877 let time_s = self.time(kind, fields);
878 let address = &mut self.addresses[index];
879 address.sentences += 1;
880 let (layout_fields, most) = kind.fields();
881 if fields.len() < layout_fields {
882 address.short += 1;
883 }
884 if kind != Kind::Gsv && fields.len() > most {
885 address.extra += 1;
886 }
887 let layout = match kind {
888 Kind::Gga => GGA,
889 Kind::Rmc => RMC,
890 Kind::Gsa => GSA,
891 Kind::Gsv => &[],
892 };
893 for (slot, (_, _, place, rule, _)) in layout.iter().enumerate() {
894 let track = address.fixed[slot];
895 self.tracks[track].push_field(time_s, field_value(fields, *place, *rule));
896 }
897 match kind {
898 Kind::Gga => {
899 self.by_unit(index, time_s, fields, 8, "altitude", Quantity::GnssAltitude);
900 self.by_unit(
901 index,
902 time_s,
903 fields,
904 10,
905 "geoid separation",
906 Quantity::Distance,
907 );
908 }
909 Kind::Gsa => {
910 let used = (2..=13)
911 .filter(|place| fields.get(*place).is_some_and(|f| !f.trim().is_empty()))
912 .count();
913 let track = self.addresses[index].fixed[GSA.len()];
914 self.tracks[track].push(time_s, used as f64);
915 }
916 Kind::Gsv => self.gsv(index, time_s, fields),
917 Kind::Rmc => {}
918 }
919 }
920
921 fn time(&mut self, kind: Kind, fields: &[&str]) -> f64 {
923 if !matches!(kind, Kind::Gga | Kind::Rmc) {
924 if self.clock.zero_ns.is_none() {
925 self.untimed_before += 1;
926 }
927 return self.clock.epoch_s;
928 }
929 let cell = fields.first().copied().unwrap_or_default().trim();
930 let tod = if cell.is_empty() {
931 self.empty_times += 1;
932 None
933 } else {
934 let tod = time_of_day(cell);
935 if tod.is_none() {
936 self.bad_times += 1;
937 self.bad_time_example.get_or_insert_with(|| short(cell));
938 }
939 tod
940 };
941 let mut valid_date = None;
942 match kind {
943 Kind::Gga => self.last_gga_tod = tod,
944 Kind::Rmc => {
945 self.rmc += 1;
946 if let Some(gga) = self.last_gga_tod.take() {
947 self.pairs += 1;
948 if tod == Some(gga) {
949 self.pairs_agree += 1;
950 }
951 }
952 let status = fields.get(1).copied().unwrap_or_default().trim();
953 let cell = fields.get(8).copied().unwrap_or_default().trim();
954 if !cell.is_empty() {
955 match date(cell) {
956 Some(day) if status == "A" => {
957 valid_date = Some(day);
958 self.valid_dates.insert(day);
959 }
960 Some(day) => {
961 self.void_dated += 1;
962 self.void_dates.insert(day);
965 }
966 None => self.bad_dates += 1,
967 }
968 }
969 }
970 Kind::Gsa | Kind::Gsv => {}
971 }
972 self.clock.time(tod, valid_date)
973 }
974
975 fn add_address(&mut self, address: &str, kind: Kind) -> usize {
977 let layout = match kind {
978 Kind::Gga => GGA,
979 Kind::Rmc => RMC,
980 Kind::Gsa => GSA,
981 Kind::Gsv => &[],
982 };
983 let mut fixed = Vec::new();
984 for (name, quantity, _, _, optional) in layout {
985 fixed.push(self.tracks.len());
986 self.tracks.push(Track::new(
987 format!("{address} {name}"),
988 *quantity,
989 *optional,
990 ));
991 }
992 let extra = match kind {
993 Kind::Gsa => Some("satellites used"),
994 Kind::Gsv => Some("satellites in view"),
995 Kind::Gga | Kind::Rmc => None,
996 };
997 if let Some(name) = extra {
998 fixed.push(self.tracks.len());
999 self.tracks.push(Track::new(
1000 format!("{address} {name}"),
1001 Quantity::Number,
1002 false,
1003 ));
1004 }
1005 self.addresses.push(Address {
1006 address: address.to_owned(),
1007 kind,
1008 fixed,
1009 by_unit: Vec::new(),
1010 satellites: Vec::new(),
1011 satellites_left_out: BTreeSet::new(),
1012 groups_bad: 0,
1013 sentences: 0,
1014 short: 0,
1015 extra: 0,
1016 });
1017 self.addresses.len() - 1
1018 }
1019
1020 fn by_unit(
1024 &mut self,
1025 index: usize,
1026 time_s: f64,
1027 fields: &[&str],
1028 place: usize,
1029 name: &str,
1030 quantity: Quantity,
1031 ) {
1032 let cell = fields.get(place).copied().unwrap_or_default().trim();
1033 let unit = fields.get(place + 1).copied().unwrap_or_default().trim();
1034 let unit = if cell.is_empty() || unit == "M" {
1035 "M"
1036 } else {
1037 unit
1038 };
1039 let found = self.addresses[index]
1040 .by_unit
1041 .iter()
1042 .find(|(field, written, _)| *field == place && written == unit)
1043 .map(|(_, _, track)| *track);
1044 let track = match found {
1045 Some(track) => track,
1046 None => {
1047 let kinds = self.addresses[index]
1048 .by_unit
1049 .iter()
1050 .filter(|(field, _, _)| *field == place)
1051 .count();
1052 let address = &self.addresses[index].address;
1053 let (track_name, quantity) = if unit == "M" {
1054 (format!("{address} {name}"), quantity)
1055 } else if kinds < 4 {
1056 (
1057 format!("{address} {name}, unit {:?}", short(unit)),
1058 Quantity::Unrecognized,
1059 )
1060 } else {
1061 let meters = self.addresses[index]
1063 .by_unit
1064 .iter()
1065 .find(|(field, written, _)| *field == place && written == "M")
1066 .map(|(_, _, track)| *track);
1067 if let Some(track) = meters {
1068 self.tracks[track].push_field(time_s, Some(Err(unit)));
1069 }
1070 return;
1071 };
1072 let track = self.tracks.len();
1073 self.tracks.push(Track::new(track_name, quantity, false));
1074 self.addresses[index]
1075 .by_unit
1076 .push((place, unit.to_owned(), track));
1077 track
1078 }
1079 };
1080 self.tracks[track].push_field(time_s, field_value(fields, place, Rule::Number));
1081 }
1082
1083 fn gsv(&mut self, index: usize, time_s: f64, fields: &[&str]) {
1086 if fields.get(1).map(|f| f.trim()) == Some("1") {
1087 let track = self.addresses[index].fixed[0];
1088 self.tracks[track].push_field(time_s, field_value(fields, 2, Rule::Number));
1089 }
1090 let groups = fields.get(3..).unwrap_or_default();
1091 if groups.len() % 4 != 0 {
1092 self.addresses[index].extra += 1;
1094 }
1095 for group in groups.as_chunks::<4>().0 {
1096 let prn = group[0].trim();
1097 if prn.is_empty() {
1098 continue;
1099 }
1100 let Some(prn) = (prn.len() <= 3 && prn.bytes().all(|b| b.is_ascii_digit()))
1101 .then(|| prn.parse::<u16>().ok())
1102 .flatten()
1103 else {
1104 self.addresses[index].groups_bad += 1;
1105 continue;
1106 };
1107 let found = self.addresses[index]
1108 .satellites
1109 .iter()
1110 .find(|(id, _)| *id == prn)
1111 .map(|(_, tracks)| *tracks);
1112 let tracks = match found {
1113 Some(tracks) => tracks,
1114 None if self.addresses[index].satellites.len() < MAX_SATELLITES => {
1115 let address = self.addresses[index].address.clone();
1116 let mut tracks = [0; 3];
1117 for (slot, (name, quantity)) in [
1118 ("elevation", Quantity::Angle),
1119 ("azimuth", Quantity::Angle),
1120 ("SNR", Quantity::Number),
1121 ]
1122 .into_iter()
1123 .enumerate()
1124 {
1125 tracks[slot] = self.tracks.len();
1126 self.tracks.push(Track::new(
1127 format!("{address} PRN {prn} {name}"),
1128 quantity,
1129 false,
1130 ));
1131 }
1132 self.addresses[index].satellites.push((prn, tracks));
1133 tracks
1134 }
1135 None => {
1136 self.addresses[index].satellites_left_out.insert(prn);
1137 continue;
1138 }
1139 };
1140 for (slot, track) in tracks.into_iter().enumerate() {
1141 self.tracks[track]
1142 .push_field(time_s, field_value(&group[..], slot + 1, Rule::Number));
1143 }
1144 }
1145 }
1146
1147 fn build(self, lines: &LineCounts) -> FlightLog {
1149 let mut notes = vec![
1150 "the file holds a GNSS receiver's sentences and no barometer, so the record's \
1151 altitude track is empty: every value is a channel, named for its sentence's address \
1152 and field, such as `GPGGA altitude`"
1153 .to_owned(),
1154 ];
1155 notes.extend(self.clock_notes());
1156 notes.extend(self.file_notes(lines));
1157 let mut single = Notes::default();
1158 for track in &self.tracks {
1159 if track.bad > 0 {
1160 single.push(format!(
1161 "`{}` holds {} fields that aren't what it holds, such as {:?}: they are gaps",
1162 short(&track.name),
1163 track.bad,
1164 track.bad_example.as_deref().unwrap_or_default()
1165 ));
1166 }
1167 if track.quantity == Quantity::Unrecognized {
1168 let finite = track.values.iter().filter(|v| v.is_finite()).count();
1169 single.push(format!(
1170 "`{}` kept as written, in a unit NMEA 0183 doesn't give: {finite} values, \
1171 where the layout gives meters, `M`",
1172 short(&track.name)
1173 ));
1174 }
1175 }
1176 for address in &self.addresses {
1177 let (layout, _) = address.kind.fields();
1178 if address.short > 0 {
1179 single.push(format!(
1180 "{} of {} `{}` sentences have fewer fields than the layout's {layout}: the \
1181 fields they leave out are gaps",
1182 address.short, address.sentences, address.address
1183 ));
1184 }
1185 if address.extra > 0 {
1186 single.push(format!(
1187 "{} `{}` sentences hold fields past those this reader reads, such as NMEA \
1188 4.10's signal or system id: they are left out",
1189 address.extra, address.address
1190 ));
1191 }
1192 if address.groups_bad > 0 {
1193 single.push(format!(
1194 "{} satellites in `{}` sentences have a PRN that isn't a number of up to \
1195 three digits: they are left out",
1196 address.groups_bad, address.address
1197 ));
1198 }
1199 if !address.satellites_left_out.is_empty() {
1200 single.push(format!(
1201 "`{}` names {} satellites past the {MAX_SATELLITES} given channels: they are \
1202 left out",
1203 address.address,
1204 address.satellites_left_out.len()
1205 ));
1206 }
1207 }
1208 notes.extend(single.into_vec());
1209
1210 let mut talkers: Vec<&str> = self.addresses.iter().map(|a| &a.address[..2]).collect();
1211 talkers.sort_unstable();
1212 talkers.dedup();
1213 let logger = format!(
1214 "NMEA 0183 receiver (talker{} {})",
1215 if talkers.len() == 1 { "" } else { "s" },
1216 talkers.join(", ")
1217 );
1218 let channels = self
1219 .tracks
1220 .into_iter()
1221 .filter(|track| !(track.optional && track.values.iter().all(|v| v.is_nan())))
1222 .map(|track| Channel {
1223 column: track.name,
1224 quantity: track.quantity,
1225 time_s: track.time_s,
1226 values: track.values,
1227 })
1228 .collect();
1229 FlightLog {
1230 format: LogFormat::Nmea0183,
1231 logger,
1232 serial_number: None,
1233 firmware: None,
1234 flight_number: None,
1235 stated: Stated::default(),
1236 time_s: Vec::new(),
1237 altitude_m: Vec::new(),
1238 vertical_speed_m_s: None,
1239 temperature_k: None,
1240 battery_v: None,
1241 channels,
1242 events: Vec::new(),
1243 notes,
1244 }
1245 }
1246
1247 fn clock_notes(&self) -> Vec<String> {
1250 let clock = &self.clock;
1251 let mut notes = Vec::new();
1252 let zero = clock.zero_ns.map(|zero| {
1253 let (h, rest) = (zero / 3_600_000_000_000, zero % 3_600_000_000_000);
1254 let (m, rest) = (rest / 60_000_000_000, rest % 60_000_000_000);
1255 format!("{h:02}:{m:02}:{:06.3} UTC", rest as f64 / 1e9)
1256 });
1257 let void = self.void_dates.difference(&self.valid_dates).count();
1258 let void_text = if self.void_dated > 0 {
1259 format!(
1260 "; {} status-`V` `RMC` carry {} distinct dates, {void} of which no status-`A` one \
1261 gives, as a receiver writes a placeholder before its fix",
1262 self.void_dated,
1263 self.void_dates.len()
1264 )
1265 } else {
1266 String::new()
1267 };
1268 let evidence = match (&zero, clock.first_date) {
1269 (None, _) => {
1270 "no `GGA` or `RMC` holds a time, so every sample's time is a gap".to_owned()
1271 }
1272 (Some(zero), Some((first, day))) => {
1273 let (y, m, d) = civil_from_days(first - day);
1274 let back = match day {
1275 0 => ", ".to_owned(),
1276 1 => ", a day before ".to_owned(),
1277 _ if day < 0 => format!(", {} days after ", day.unsigned_abs()),
1278 _ => format!(", {day} days before "),
1279 };
1280 format!(
1281 "the log's zero, the first sentence with a time, is {zero} on \
1282 {y:04}-{m:02}-{d:02}{back}the date of the first `RMC` with status \
1283 `A`{void_text}"
1284 )
1285 }
1286 (Some(zero), None) if self.rmc == 0 => format!(
1287 "the file holds no `RMC`, so its times are times of day alone, from the first \
1288 sentence with a time, at {zero}"
1289 ),
1290 (Some(zero), None) => format!(
1291 "no `RMC` has status `A`, so the times are times of day alone, from the first \
1292 sentence with a time, at {zero}{void_text}"
1293 ),
1294 };
1295 notes.push(format!(
1296 "`RMC date` read in UTC days, from status-`A` sentences only: {evidence}"
1297 ));
1298 let untimed: Vec<&str> = [Kind::Gsa, Kind::Gsv]
1299 .into_iter()
1300 .filter(|kind| self.addresses.iter().any(|a| a.kind == *kind))
1301 .map(Kind::name)
1302 .collect();
1303 if !untimed.is_empty() {
1304 let order = if self.pairs > 0 {
1305 format!(
1306 "in {} of the file's {} `GGA` and `RMC` pairs, the `RMC` has the `GGA`'s time",
1307 self.pairs_agree, self.pairs
1308 )
1309 } else {
1310 "the file has no `GGA` followed by an `RMC` to show its order".to_owned()
1311 };
1312 let before = if self.untimed_before > 0 {
1313 format!(
1314 "; {} come before any sentence with a time, and their times are gaps",
1315 self.untimed_before
1316 )
1317 } else {
1318 String::new()
1319 };
1320 notes.push(format!(
1321 "`{}` read in the time of the `GGA` or `RMC` before them, from the sentence \
1322 order: {order}{before}",
1323 untimed.join("` and `")
1324 ));
1325 }
1326 if self.empty_times > 0 {
1327 notes.push(format!(
1328 "{} `GGA` and `RMC` sentences have an empty time, as a receiver writes before it \
1329 knows the time: their samples' times are gaps",
1330 self.empty_times
1331 ));
1332 }
1333 if self.bad_times > 0 {
1334 notes.push(format!(
1335 "{} `GGA` and `RMC` sentences have a time that isn't `hhmmss.sss`, such as {:?}: \
1336 their samples' times are gaps",
1337 self.bad_times,
1338 self.bad_time_example.as_deref().unwrap_or_default()
1339 ));
1340 }
1341 if self.bad_dates > 0 {
1342 notes.push(format!(
1343 "{} `RMC` sentences have a date that isn't one, `ddmmyy`: left out",
1344 self.bad_dates
1345 ));
1346 }
1347 if clock.backs > 0 {
1348 notes.push(format!(
1349 "the time goes back {} times, by up to {:.3} s: the samples are kept in the \
1350 file's order, each channel's times as its sentences give them; a receiver's UTC \
1351 may step a few minutes after a cold start, when the satellites' leap-second count \
1352 reaches it",
1353 clock.backs, clock.largest_back_s
1354 ));
1355 }
1356 if clock.midnights > 0 {
1357 notes.push(format!(
1358 "the time of day passes midnight {} times: without a date, a time of day more \
1359 than 12 hours before the last is the next day's, and one more than 12 hours \
1360 after it the day before's",
1361 clock.midnights
1362 ));
1363 }
1364 if clock.settled > 0 {
1365 notes.push(format!(
1366 "{} times, an `RMC`'s date moves the day the time of day alone gave: the date \
1367 settles it",
1368 clock.settled
1369 ));
1370 }
1371 notes
1372 }
1373
1374 fn file_notes(&self, lines: &LineCounts) -> Vec<String> {
1376 let mut notes = Vec::new();
1377 let kinds = [
1378 (
1379 &lines.no_start,
1380 "hold no sentence, no `$`: noise, or a sentence cut at its start",
1381 ),
1382 (
1383 &lines.cut_off,
1384 "hold a sentence cut off before its checksum",
1385 ),
1386 (
1387 &lines.damaged,
1388 "hold a sentence whose checksum fails, or with bytes NMEA 0183 doesn't allow",
1389 ),
1390 ];
1391 for (kind, what) in kinds {
1392 if let Some((number, text)) = &kind.first {
1393 notes.push(format!(
1394 "{} lines {what}, the first line {number}, {text:?}: skipped",
1395 kind.count
1396 ));
1397 }
1398 }
1399 if let Some((number, text)) = &lines.noise.first {
1400 notes.push(format!(
1401 "{} lines hold bytes beside their sentences, the first line {number}, {text:?}: \
1402 the sentences are read and the bytes left out",
1403 lines.noise.count
1404 ));
1405 }
1406 if lines.not_text > 0 {
1407 notes.push(format!(
1408 "the file holds {} NUL bytes, or stand-ins (U+FFFD) for bytes that aren't UTF-8, on \
1409 {} lines, the last line {}: left out",
1410 lines.not_text, lines.not_text_lines, lines.last_not_text
1411 ));
1412 }
1413 if !self.other.is_empty() {
1414 let named: Vec<String> = self
1415 .other
1416 .iter()
1417 .map(|(address, count)| format!("`{}` ({count})", short(address)))
1418 .collect();
1419 let more = if self.other_left_out > 0 {
1420 format!(" and {} sentences of other types", self.other_left_out)
1421 } else {
1422 String::new()
1423 };
1424 notes.push(format!(
1425 "sentences of types this reader doesn't read, {}{more}: their fields are left out",
1426 named.join(", ")
1427 ));
1428 }
1429 if self.addresses_left_out > 0 {
1430 notes.push(format!(
1431 "{} sentences at addresses past the {MAX_ADDRESSES} read: left out",
1432 self.addresses_left_out
1433 ));
1434 }
1435 let positions = self
1436 .tracks
1437 .iter()
1438 .filter(|t| t.quantity == Quantity::Latitude)
1439 .collect::<Vec<_>>();
1440 if !positions.is_empty()
1441 && positions
1442 .iter()
1443 .all(|t| t.values.iter().all(|v| v.is_nan()))
1444 {
1445 notes.push(
1446 "no `GGA` or `RMC` holds a position: the receiver never had a fix, so every \
1447 latitude and longitude is a gap"
1448 .to_owned(),
1449 );
1450 }
1451 notes
1452 }
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457 use super::*;
1458
1459 fn sentence_line(body: &str) -> String {
1461 format!("${body}*{:02X}", checksum(body))
1462 }
1463
1464 fn channel<'a>(log: &'a FlightLog, column: &str) -> &'a Channel {
1465 log.channels
1466 .iter()
1467 .find(|channel| channel.column == column)
1468 .unwrap_or_else(|| panic!("no {column}"))
1469 }
1470
1471 fn close(a: f64, b: f64) -> bool {
1472 (a - b).abs() <= 1e-12 * b.abs().max(1.0)
1473 }
1474
1475 #[test]
1478 fn checksums_check_out() {
1479 let gga = "GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,";
1480 assert_eq!(checksum(gga), 0x47);
1481 let line = format!("${gga}*47");
1482 let scan = scan_line(&line);
1483 assert_eq!(scan.sentences.len(), 1);
1484 assert_eq!(scan.sentences[0].address, "GPGGA");
1485 assert_eq!(scan.sentences[0].fields.len(), 14);
1486 assert!(!scan.noise);
1487 assert_eq!(scan_line(&format!("${gga}*47")).sentences.len(), 1);
1488 assert_eq!(
1489 scan_line(&format!("${gga}*48")).failure,
1490 Some(Failure::Damaged)
1491 );
1492 assert_eq!(scan_line(&format!("${gga}")).failure, Some(Failure::CutOff));
1493 assert_eq!(
1494 scan_line(&format!("${gga}*4")).failure,
1495 Some(Failure::CutOff)
1496 );
1497 assert_eq!(
1498 scan_line(&format!("${gga}*+7")).failure,
1499 Some(Failure::CutOff)
1500 );
1501 let damaged = sentence_line("GPGSA,A,\u{fffd}\u{fffd},,");
1502 assert_eq!(scan_line(&damaged).failure, Some(Failure::Damaged));
1503 let lower = sentence_line("gpgga,1");
1504 assert_eq!(scan_line(&lower).failure, Some(Failure::Damaged));
1505 assert_eq!(checksum("GPXXX,1"), 0x52);
1507 assert_eq!(scan_line("$GPXXX,1*52").sentences.len(), 1);
1508 assert_eq!(scan_line("$GPZDA,1*7c").sentences.len(), 0);
1509 let lower = format!("$GPZDA,1*{:02x}", checksum("GPZDA,1"));
1510 assert_eq!(scan_line(&lower).sentences.len(), 1);
1511 }
1512
1513 #[test]
1516 fn sentences_are_read_wherever_a_dollar_opens_one() {
1517 let a = sentence_line("GPGSA,A,1,,,,,,,,,,,,,99.99,99.99,99.99");
1518 let b = sentence_line("GPRMC,071812.50,V,,,,,,,,,,N");
1519 let glued = format!("\u{fffd}q$Z{a}{b}\0\0");
1520 let scan = scan_line(&glued);
1521 assert_eq!(scan.sentences.len(), 2);
1522 assert!(scan.noise);
1523 assert_eq!(scan.failure, Some(Failure::CutOff));
1524 let spaced = format!("{a} ");
1525 let scan = scan_line(&spaced);
1526 assert!(!scan.noise);
1527 let scan = scan_line(",,0,00,99.99,,,,,,*48");
1528 assert!(scan.sentences.is_empty() && !scan.dollar);
1529 }
1530
1531 #[test]
1532 fn coordinates_are_degrees_and_minutes() {
1533 assert!(close(coordinate("4807.038", 90.0).unwrap(), 48.1173));
1534 assert!(close(
1535 coordinate("01131.000", 180.0).unwrap(),
1536 11.516_666_666_666_667
1537 ));
1538 assert!(close(coordinate("0000.6", 90.0).unwrap(), 0.01));
1539 assert_eq!(coordinate("9000.000", 90.0), Some(90.0));
1540 assert!(close(
1541 coordinate("17959.99", 180.0).unwrap(),
1542 179.0 + 59.99 / 60.0
1543 ));
1544 for bad in [
1545 "", "4", "4807.", "4860.000", "9100.0", "18100.0", "48a7.0", "4807.0.1", "-4807.0",
1546 "123456.0",
1547 ] {
1548 assert_eq!(coordinate(bad, 90.0), None, "{bad:?}");
1549 }
1550 assert_eq!(coordinate("18100.0", 180.0), None);
1551 let fields = ["4807.038", "S", "01131.000", "W"];
1552 assert!(close(
1553 field_value(&fields, 0, LATITUDE).unwrap().unwrap(),
1554 -48.1173
1555 ));
1556 assert!(field_value(&fields, 2, LONGITUDE).unwrap().unwrap() < 0.0);
1557 assert_eq!(
1558 field_value(&["4807.038", ""], 0, LATITUDE),
1559 Some(Err("4807.038"))
1560 );
1561 assert_eq!(
1562 field_value(&["4807.038", "E"], 0, LATITUDE),
1563 Some(Err("4807.038"))
1564 );
1565 assert_eq!(field_value(&["", "N"], 0, LATITUDE), None);
1566 assert_eq!(
1567 field_value(&["3.5", "W"], 0, Rule::EastWest),
1568 Some(Ok(-3.5))
1569 );
1570 }
1571
1572 #[test]
1573 fn speeds_and_letters() {
1574 let speed = field_value(&["022.4"], 0, Rule::Times(KNOT_M_S))
1575 .unwrap()
1576 .unwrap();
1577 assert!(close(speed, 11.523_555_555_555_556), "{speed}");
1578 assert_eq!(field_value(&["E"], 0, Rule::Letter(MODES)), Some(Ok(6.0)));
1579 assert_eq!(field_value(&["N"], 0, Rule::Letter(MODES)), Some(Ok(0.0)));
1580 assert_eq!(field_value(&["X"], 0, Rule::Letter(MODES)), Some(Err("X")));
1581 assert_eq!(
1582 field_value(&["AA"], 0, Rule::Letter(MODES)),
1583 Some(Err("AA"))
1584 );
1585 assert_eq!(field_value(&[], 3, Rule::Number), None);
1586 }
1587
1588 #[test]
1589 fn times_and_dates_are_read_strictly() {
1590 assert_eq!(time_of_day("000000"), Some(0));
1591 assert_eq!(time_of_day("123519"), Some(45_319_000_000_000));
1592 assert_eq!(time_of_day("123519.969"), Some(45_319_969_000_000));
1593 assert_eq!(time_of_day("235960.123456789"), Some(86_400_123_456_789));
1594 for bad in [
1595 "",
1596 "12351",
1597 "1235190",
1598 "240000",
1599 "126000",
1600 "123561",
1601 "123519.",
1602 "123519.1234567890",
1603 "12:35:19",
1604 "+23519",
1605 "123519.x",
1606 ] {
1607 assert_eq!(time_of_day(bad), None, "{bad:?}");
1608 }
1609 assert_eq!(date("010170"), Some(days_from_civil(2070, 1, 1)));
1610 assert_eq!(date("230394"), Some(days_from_civil(1994, 3, 23)));
1611 assert_eq!(date("010180"), Some(3652));
1612 assert_eq!(date("290224"), Some(days_from_civil(2024, 2, 29)));
1613 assert_eq!(days_from_civil(1970, 1, 1), 0);
1614 assert_eq!(days_from_civil(2000, 3, 1), 11_017);
1615 for bad in [
1616 "", "290223", "310424", "001024", "011324", "0110245", "01-024",
1617 ] {
1618 assert_eq!(date(bad), None, "{bad:?}");
1619 }
1620 for days in [-1, 0, 59, 11_016, 11_017, 19_000, 40_000] {
1621 let (y, m, d) = civil_from_days(days);
1622 assert_eq!(days_from_civil(y, m, d), days);
1623 }
1624 assert_eq!(civil_from_days(11_017), (2000, 3, 1));
1625 }
1626
1627 #[test]
1629 fn the_clock_crosses_midnight_and_takes_dates() {
1630 let mut clock = Clock::new();
1631 let s = 1_000_000_000_i64;
1632 assert_eq!(clock.time(Some(86_399 * s), None), 0.0);
1633 assert_eq!(clock.time(Some(s), None), 2.0);
1634 assert_eq!(clock.time(Some(0), None), 1.0);
1635 assert_eq!(clock.backs, 1);
1636 assert_eq!(clock.time(Some(86_399 * s), None), 0.0);
1637 assert_eq!(clock.midnights, 2);
1638 assert!(clock.time(None, None).is_nan());
1639 assert!(clock.epoch_s.is_nan());
1640 let mut clock = Clock::new();
1641 clock.time(Some(10 * s), Some(100));
1642 assert_eq!(clock.time(Some(10 * s), Some(103)), 3.0 * 86_400.0);
1644 assert_eq!(clock.settled, 1);
1645 }
1646
1647 #[test]
1648 fn recognized_past_noise_and_not_otherwise() {
1649 let gga = sentence_line("GPGGA,,,,,,0,00,99.99,,,,,,");
1650 let gsa = sentence_line("GPGSA,A,1,,,,,,,,,,,,,99.99,99.99,99.99");
1651 assert!(recognizes(&format!(
1652 "R\u{fffd}j3q\r\n{gga}\r\n{gsa}\r\n{gga}\r\n"
1653 )));
1654 assert!(recognizes(&gga));
1655 assert!(!recognizes(&format!("noise\n{gga}\n")));
1656 assert!(!recognizes("Time,Altitude\n0,0\n"));
1657 assert!(!recognizes(""));
1658 let late = format!("{}{gga}\n{gga}\n{gga}\n", "x\n".repeat(60));
1659 assert!(!recognizes(&late));
1660 assert!(!recognizes(&format!("{}\n", "$GPGGA,1*00".repeat(10))));
1661 let quoted = format!("10:00:00.000: gps {{'sentence': '{gga}'}}\n");
1663 assert!(!recognizes("ed.repeat(5)));
1664 }
1665
1666 #[test]
1668 fn files_without_sentences_read_are_refused() {
1669 let vtg = sentence_line("GPVTG,054.7,T,034.4,M,005.5,N,010.2,K");
1670 assert_eq!(
1671 read(&format!("{vtg}\n{vtg}\n")).unwrap_err(),
1672 LogError::NoData { format: FORMAT }
1673 );
1674 let LogError::NotThisFormat { format, .. } = read("x\n$GPGGA,1*00\n").unwrap_err() else {
1675 panic!("refused for another reason");
1676 };
1677 assert_eq!(format, FORMAT);
1678 assert!(read("").is_err());
1679 }
1680
1681 #[test]
1684 fn reads_the_four_sentences() {
1685 let lines = [
1686 sentence_line("GPGGA,123519.000,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,0000"),
1687 sentence_line("GPGSA,A,3,04,05,,09,12,,,24,,,,,2.5,1.3,2.1"),
1688 sentence_line("GPGSV,2,1,05,04,45,120,40,05,30,200,35,09,10,300,,12,60,010,44"),
1689 sentence_line("GPGSV,2,2,05,24,05,080,20"),
1690 sentence_line("GPRMC,123519.000,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W,A"),
1691 sentence_line("GPVTG,084.4,T,,M,022.4,N,041.5,K,A"),
1692 sentence_line("GPGGA,123520.000,4807.040,N,01131.002,E,6,08,0.9,545.0,F,46.9,M,,0000"),
1693 ];
1694 let log = read(&lines.join("\r\n")).unwrap();
1695 assert_eq!(log.format, LogFormat::Nmea0183);
1696 assert_eq!(log.logger, "NMEA 0183 receiver (talker GP)");
1697 assert!(log.time_s.is_empty() && log.altitude_m.is_empty());
1698 let latitude = channel(&log, "GPGGA latitude");
1699 assert_eq!(latitude.quantity, Quantity::Latitude);
1700 assert!(close(latitude.values[0], 48.1173));
1701 assert_eq!(latitude.time_s, [0.0, 1.0]);
1702 let altitude = channel(&log, "GPGGA altitude");
1703 assert_eq!(altitude.quantity, Quantity::GnssAltitude);
1704 assert_eq!(altitude.values, [545.4]);
1705 let feet = channel(&log, "GPGGA altitude, unit \"F\"");
1706 assert_eq!(feet.quantity, Quantity::Unrecognized);
1707 assert_eq!(
1708 (feet.time_s.as_slice(), feet.values.as_slice()),
1709 (&[1.0][..], &[545.0][..])
1710 );
1711 assert_eq!(channel(&log, "GPGGA fix quality").values, [1.0, 6.0]);
1712 assert_eq!(channel(&log, "GPGGA DGPS station").values, [0.0, 0.0]);
1713 assert!(log.channels.iter().all(|c| c.column != "GPGGA DGPS age"));
1714 assert_eq!(channel(&log, "GPGSA satellites used").values, [5.0]);
1715 assert_eq!(channel(&log, "GPGSA VDOP").values, [2.1]);
1716 assert_eq!(channel(&log, "GPGSA selection").values, [1.0]);
1717 assert_eq!(channel(&log, "GPGSV satellites in view").values, [5.0]);
1718 let snr = channel(&log, "GPGSV PRN 9 SNR");
1719 assert!(snr.values[0].is_nan());
1720 assert_eq!(snr.time_s, [0.0]);
1721 assert_eq!(channel(&log, "GPGSV PRN 24 azimuth").values, [80.0]);
1722 let speed = channel(&log, "GPRMC speed");
1723 assert_eq!(speed.quantity, Quantity::Speed);
1724 assert!(close(speed.values[0], 11.523_555_555_555_556));
1725 assert_eq!(channel(&log, "GPRMC magnetic variation").values, [-3.1]);
1726 assert_eq!(channel(&log, "GPRMC mode").values, [1.0]);
1727 assert_eq!(channel(&log, "GPRMC status").values, [1.0]);
1728 let notes = log.notes.join("\n");
1729 for expected in [
1730 "`RMC date` read in UTC days, from status-`A` sentences only: the log's zero, the \
1731 first sentence with a time, is 12:35:19.000 UTC on 1994-03-23",
1732 "`GSA` and `GSV` read in the time of the `GGA` or `RMC` before them, from the \
1733 sentence order: in 1 of the file's 1 `GGA` and `RMC` pairs",
1734 "sentences of types this reader doesn't read, `GPVTG` (1)",
1735 "`GPGGA altitude, unit \"F\"` kept as written, in a unit NMEA 0183 doesn't give: 1 values",
1736 ] {
1737 assert!(notes.contains(expected), "{expected}\n{notes}");
1738 }
1739 }
1740
1741 #[test]
1743 fn bad_and_missing_fields_are_gaps() {
1744 let lines = [
1745 sentence_line("GPGGA,000001,48x7.0,N,01131.000,E,1"),
1746 sentence_line("GPRMC,000001,Q,,,,,,,,,,Z"),
1747 ];
1748 let log = read(&lines.join("\n")).unwrap();
1749 assert!(channel(&log, "GPGGA latitude").values[0].is_nan());
1750 assert!(channel(&log, "GPGGA HDOP").values[0].is_nan());
1751 let notes = log.notes.join("\n");
1752 for expected in [
1753 "`GPGGA latitude` holds 1 fields that aren't what it holds, such as \"48x7.0\"",
1754 "`GPRMC status` holds 1 fields",
1755 "`GPRMC mode` holds 1 fields",
1756 "1 of 1 `GPGGA` sentences have fewer fields than the layout's 14",
1757 ] {
1758 assert!(notes.contains(expected), "{expected}\n{notes}");
1759 }
1760 }
1761
1762 #[test]
1764 fn caps_hold() {
1765 let mut text = String::new();
1766 for prn in 1..=70 {
1767 text.push_str(&sentence_line(&format!("GPGSV,1,1,70,{prn},1,2,3")));
1768 text.push('\n');
1769 }
1770 for talker in 0..20 {
1771 text.push_str(&sentence_line(&format!(
1772 "G{}GGA,000001",
1773 (b'A' + talker) as char
1774 )));
1775 text.push('\n');
1776 }
1777 for other in 0..12 {
1778 text.push_str(&sentence_line(&format!("GPX{:02},1", other)));
1779 text.push('\n');
1780 }
1781 let log = read(&text).unwrap();
1782 let addresses: BTreeSet<&str> = log.channels.iter().map(|c| &c.column[..5]).collect();
1783 assert_eq!(addresses.len(), MAX_ADDRESSES);
1784 assert_eq!(
1785 log.channels
1786 .iter()
1787 .filter(|c| c.column.ends_with(" SNR"))
1788 .count(),
1789 MAX_SATELLITES
1790 );
1791 let notes = log.notes.join("\n");
1792 for expected in [
1793 "5 sentences at addresses past the 16 read",
1794 "`GPGSV` names 6 satellites past the 64 given channels",
1795 "and 4 sentences of other types",
1796 ] {
1797 assert!(notes.contains(expected), "{expected}\n{notes}");
1798 }
1799 assert!(
1800 log.logger
1801 .starts_with("NMEA 0183 receiver (talkers GA, GB,")
1802 );
1803 }
1804
1805 proptest::proptest! {
1806 #[test]
1808 fn any_text_is_read_or_refused(
1809 lines in proptest::collection::vec("[$GPAMCSVR*,.0-9A-F NWE\u{fffd}\0]{0,60}", 0..12),
1810 sign in proptest::bool::ANY,
1811 ) {
1812 let text: Vec<String> = lines
1814 .iter()
1815 .enumerate()
1816 .map(|(index, line)| {
1817 if sign && index % 2 == 0 {
1818 let body: String = line.chars().filter(|c| !matches!(c, '$' | '*')).collect();
1819 sentence_line(&format!("GPGGA,{body}"))
1820 } else {
1821 line.clone()
1822 }
1823 })
1824 .collect();
1825 let text = text.join("\r\n");
1826 let _ = recognizes(&text);
1827 if let Ok(log) = read(&text) {
1828 for channel in &log.channels {
1829 proptest::prop_assert_eq!(channel.time_s.len(), channel.values.len());
1830 }
1831 proptest::prop_assert!(log.time_s.is_empty());
1832 }
1833 }
1834
1835 #[test]
1837 fn fixes_read_back(
1838 seconds in 0u32..86_400,
1839 degrees in 0u32..90,
1840 minutes in 0u32..600_000,
1841 centiknots in 0u32..90_000,
1842 ) {
1843 let (h, m, s) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
1844 let body = format!(
1845 "GPRMC,{h:02}{m:02}{s:02}.000,A,{degrees:02}{:02}.{:04},S,00000.0,E,{}.{:02},0,\
1846 010124,,,A",
1847 minutes / 10_000,
1848 minutes % 10_000,
1849 centiknots / 100,
1850 centiknots % 100
1851 );
1852 let log = read(&sentence_line(&body)).unwrap();
1853 let latitude = channel(&log, "GPRMC latitude").values[0];
1854 let expected = f64::from(degrees) + f64::from(minutes) / 10_000.0 / 60.0;
1855 proptest::prop_assert!((latitude + expected).abs() < 1e-12);
1856 let speed = channel(&log, "GPRMC speed").values[0];
1857 let knots = f64::from(centiknots) / 100.0;
1858 proptest::prop_assert!((speed - knots * KNOT_M_S).abs() < 1e-9);
1859 proptest::prop_assert_eq!(&channel(&log, "GPRMC status").time_s, &vec![0.0]);
1860 }
1861 }
1862}