Skip to main content

datui_lib/gps/
nmea.rs

1//! NMEA 0183 logs: the sentences a GPS receiver writes, one per line.
2//!
3//! [`NmeaReader`] takes the log as bytes in whatever pieces they arrive and hands rows
4//! back a batch at a time, keeping only the line in progress and the fix being merged.
5//! Nothing about it needs the whole file, so a log being written can be read on as it
6//! grows (`push` the new bytes, take what is ready); only [`NmeaReader::finish`] says
7//! there will be no more.
8//!
9//! Every length comes from the reader, not the file: a line is at most [`MAX_LINE`]
10//! bytes (the standard's is 82), so a hostile file costs one line of memory.
11
12use chrono::NaiveDate;
13use polars::prelude::*;
14
15use super::table::{Builder, Cell, Kind};
16
17/// The longest line read as a sentence. The standard allows 82 characters; vendors'
18/// own sentences run longer. A longer line is not NMEA and is skipped.
19pub const MAX_LINE: usize = 1024;
20/// Rows held before they are handed over as a batch.
21pub const BATCH_ROWS: usize = 65_536;
22/// How many distinct sentence types are counted by name; the rest are counted together.
23const MAX_TYPES: usize = 64;
24/// Knots to meters per second.
25const KNOTS: f64 = 1852.0 / 3600.0;
26const DAY_MS: i64 = 86_400_000;
27const HALF_DAY_MS: i64 = DAY_MS / 2;
28/// How far a fix's time may step back and still have a `gap`: a receiver's clock
29/// settling. Further back is a reset or a log spliced together, and has none.
30pub(crate) const BACK_MS: i64 = 5_000;
31/// The longest pause measured between fixes not yet dated: past it, whole days may
32/// have gone by unseen.
33const UNDATED_MS: i64 = 3_600_000;
34
35/// The tables an NMEA log opens as: the fixes merged from its sentences, each sentence
36/// type alone, or every sentence as it stands.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Table {
39    Fixes,
40    Gga,
41    Rmc,
42    Vtg,
43    Gsa,
44    Gsv,
45    Gll,
46    Zda,
47    Sentences,
48}
49
50impl Table {
51    pub const ALL: [Table; 9] = [
52        Table::Fixes,
53        Table::Gga,
54        Table::Rmc,
55        Table::Vtg,
56        Table::Gsa,
57        Table::Gsv,
58        Table::Gll,
59        Table::Zda,
60        Table::Sentences,
61    ];
62
63    /// The name `--table` takes, as the help and errors spell it.
64    pub fn name(self) -> &'static str {
65        match self {
66            Table::Fixes => "fixes",
67            Table::Gga => "GGA",
68            Table::Rmc => "RMC",
69            Table::Vtg => "VTG",
70            Table::Gsa => "GSA",
71            Table::Gsv => "GSV",
72            Table::Gll => "GLL",
73            Table::Zda => "ZDA",
74            Table::Sentences => "sentences",
75        }
76    }
77
78    /// The table `name` names, in any case.
79    pub fn from_name(name: &str) -> Option<Self> {
80        Self::ALL
81            .into_iter()
82            .find(|t| t.name().eq_ignore_ascii_case(name.trim()))
83    }
84
85    /// The sentence type this table is the rows of, for the tables that are one.
86    fn sentence(self) -> Option<&'static str> {
87        match self {
88            Table::Fixes | Table::Sentences => None,
89            other => Some(other.name()),
90        }
91    }
92
93    /// The table's columns. `time` is first in every one.
94    pub fn columns(self) -> Vec<(&'static str, Kind)> {
95        use Kind::*;
96        match self {
97            Table::Fixes => vec![
98                ("time", Time),
99                ("lat", F64),
100                ("lon", F64),
101                ("alt", F64),
102                ("speed", F64),
103                ("course", F64),
104                ("sats", U32),
105                ("hdop", F64),
106                ("fix", Str),
107                ("gap", F64),
108                ("checksum_ok", Bool),
109            ],
110            Table::Gga => vec![
111                ("time", Time),
112                ("talker", Str),
113                ("lat", F64),
114                ("lon", F64),
115                ("quality", U32),
116                ("sats", U32),
117                ("hdop", F64),
118                ("alt", F64),
119                ("geoid_sep", F64),
120                ("dgps_age", F64),
121                ("dgps_station", Str),
122                ("checksum_ok", Bool),
123            ],
124            Table::Rmc => vec![
125                ("time", Time),
126                ("talker", Str),
127                ("status", Str),
128                ("lat", F64),
129                ("lon", F64),
130                ("speed", F64),
131                ("course", F64),
132                ("mag_var", F64),
133                ("mode", Str),
134                ("checksum_ok", Bool),
135            ],
136            Table::Vtg => vec![
137                ("time", Time),
138                ("talker", Str),
139                ("course", F64),
140                ("course_mag", F64),
141                ("speed", F64),
142                ("mode", Str),
143                ("checksum_ok", Bool),
144            ],
145            Table::Gsa => vec![
146                ("time", Time),
147                ("talker", Str),
148                ("mode", Str),
149                ("fix_type", U32),
150                ("sats", ListU32),
151                ("pdop", F64),
152                ("hdop", F64),
153                ("vdop", F64),
154                ("system", U32),
155                ("checksum_ok", Bool),
156            ],
157            Table::Gsv => vec![
158                ("time", Time),
159                ("talker", Str),
160                ("messages", U32),
161                ("message", U32),
162                ("in_view", U32),
163                ("prn", U32),
164                ("elevation", F64),
165                ("azimuth", F64),
166                ("snr", F64),
167                ("signal", Str),
168                ("checksum_ok", Bool),
169            ],
170            Table::Gll => vec![
171                ("time", Time),
172                ("talker", Str),
173                ("lat", F64),
174                ("lon", F64),
175                ("status", Str),
176                ("mode", Str),
177                ("checksum_ok", Bool),
178            ],
179            Table::Zda => vec![
180                ("time", Time),
181                ("talker", Str),
182                ("zone_hours", I32),
183                ("zone_minutes", I32),
184                ("checksum_ok", Bool),
185            ],
186            Table::Sentences => vec![
187                ("time", Time),
188                ("line", U64),
189                ("talker", Str),
190                ("type", Str),
191                ("fields", Str),
192                ("checksum_ok", Bool),
193            ],
194        }
195    }
196
197    /// The schema of the table's frames.
198    pub fn schema(self) -> Schema {
199        self.columns()
200            .into_iter()
201            .map(|(name, kind)| Field::new(name.into(), kind.dtype()))
202            .collect()
203    }
204}
205
206/// One sentence, borrowed from its line.
207#[derive(Debug)]
208struct Sentence<'a> {
209    /// `GP`, `GN`, `GL`…; `P` for a vendor's own sentence.
210    talker: &'a str,
211    /// `GGA`, `RMC`…; for a vendor sentence, what follows the `P`.
212    kind: &'a str,
213    /// Everything between the address and the checksum, as written.
214    body: &'a str,
215    fields: Vec<&'a str>,
216    /// Whether the checksum matches; `None` when the sentence has none.
217    checksum_ok: Option<bool>,
218}
219
220impl Sentence<'_> {
221    fn field(&self, i: usize) -> &str {
222        self.fields.get(i).copied().unwrap_or("")
223    }
224
225    /// The type as counted: `GGA`, or `PUBX` for a vendor sentence.
226    fn type_name(&self) -> String {
227        if self.talker == "P" {
228            format!("P{}", self.kind)
229        } else {
230            self.kind.to_string()
231        }
232    }
233}
234
235/// The sentence on `line`, if it holds one. It starts at the first `$` or `!`, so a
236/// logger's own prefix before it (a timestamp) is passed over.
237fn parse_sentence(line: &[u8]) -> Option<Sentence<'_>> {
238    let start = line.iter().position(|&b| b == b'$' || b == b'!')?;
239    let text = std::str::from_utf8(&line[start + 1..]).ok()?;
240    let text = text.trim_end();
241    let (data, checksum_ok) = match text.rfind('*') {
242        Some(star) => {
243            let given = &text[star + 1..];
244            let data = &text[..star];
245            let ok = given.len() == 2
246                && u8::from_str_radix(given, 16)
247                    .is_ok_and(|sum| data.bytes().fold(0u8, |acc, b| acc ^ b) == sum);
248            (data, Some(ok))
249        }
250        None => (text, None),
251    };
252    let (address, body) = data.split_once(',').unwrap_or((data, ""));
253    let valid = (3..=10).contains(&address.len())
254        && address.as_bytes()[0].is_ascii_uppercase()
255        && address
256            .bytes()
257            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit());
258    if !valid {
259        return None;
260    }
261    let (talker, kind) = if let Some(rest) = address.strip_prefix('P') {
262        ("P", rest)
263    } else {
264        address.split_at(2)
265    };
266    Some(Sentence {
267        talker,
268        kind,
269        body,
270        fields: if data.contains(',') {
271            body.split(',').collect()
272        } else {
273            Vec::new()
274        },
275        checksum_ok,
276    })
277}
278
279/// Sentence types a receiver writes, by which a sentence with no checksum is known.
280const KNOWN: [&str; 14] = [
281    "GGA", "RMC", "VTG", "GSA", "GSV", "GLL", "ZDA", "GNS", "GST", "GBS", "TXT", "HDT", "DTM",
282    "GRS",
283];
284
285/// Whether the first bytes of a file are an NMEA log: its first line that is not blank
286/// is a sentence at the start of the line, either with a checksum that matches or of a
287/// type receivers write. A CSV header such as `$USD,$EUR` has the shape of a sentence
288/// and is neither. A capture from a serial port starts wherever the port was in its
289/// output, so a first line that is the tail of a sentence (`...,M,,*47`) is passed over.
290pub fn looks_like(head: &[u8]) -> bool {
291    let head = head.strip_prefix(b"\xef\xbb\xbf").unwrap_or(head);
292    let mut lines = head
293        .split(|&b| b == b'\n')
294        .map(|l| l.trim_ascii())
295        .filter(|l| !l.is_empty());
296    let sentence = |line: &[u8]| {
297        line.first() == Some(&b'$')
298            && parse_sentence(line).is_some_and(|s| {
299                s.checksum_ok == Some(true) || (s.talker.len() == 2 && KNOWN.contains(&s.kind))
300            })
301    };
302    let tail = |line: &[u8]| {
303        line.len() >= 3
304            && line[line.len() - 3] == b'*'
305            && line[line.len() - 2..].iter().all(u8::is_ascii_hexdigit)
306    };
307    match lines.next() {
308        Some(first) if sentence(first) => true,
309        Some(first) if first.first() != Some(&b'$') && tail(first) => {
310            lines.next().is_some_and(sentence)
311        }
312        _ => false,
313    }
314}
315
316fn number(s: &str) -> Option<f64> {
317    let s = s.trim();
318    // `inf` and `NaN` parse as floats; they are not readings.
319    if s.is_empty()
320        || !s
321            .bytes()
322            .all(|b| b.is_ascii_digit() || b"+-.eE".contains(&b))
323    {
324        return None;
325    }
326    s.parse::<f64>().ok().filter(|v| v.is_finite())
327}
328
329fn unsigned(s: &str) -> Option<u32> {
330    s.trim().parse().ok()
331}
332
333fn text(s: &str) -> Option<String> {
334    let s = s.trim();
335    (!s.is_empty()).then(|| s.to_string())
336}
337
338/// A latitude or longitude written `ddmm.mmmm` (`dddmm.mmmm`), in decimal degrees,
339/// negative south and west. `None` for a value that is not one.
340fn degrees(value: &str, hemisphere: &str, limit: f64) -> Option<f64> {
341    let v = number(value).filter(|v| *v >= 0.0)?;
342    let whole = (v / 100.0).floor();
343    let minutes = v - whole * 100.0;
344    if minutes >= 60.0 {
345        return None;
346    }
347    let deg = whole + minutes / 60.0;
348    if deg > limit {
349        return None;
350    }
351    match hemisphere.trim() {
352        "N" | "E" => Some(deg),
353        "S" | "W" => Some(-deg),
354        _ => None,
355    }
356}
357
358fn latitude(value: &str, hemisphere: &str) -> Option<f64> {
359    degrees(value, hemisphere, 90.0).filter(|_| matches!(hemisphere.trim(), "N" | "S"))
360}
361
362fn longitude(value: &str, hemisphere: &str) -> Option<f64> {
363    degrees(value, hemisphere, 180.0).filter(|_| matches!(hemisphere.trim(), "E" | "W"))
364}
365
366/// `hhmmss` or `hhmmss.sss`, as milliseconds into the day.
367fn time_of_day(s: &str) -> Option<u32> {
368    let s = s.trim();
369    let b = s.as_bytes();
370    if b.len() < 6 || !b[..6].iter().all(u8::is_ascii_digit) {
371        return None;
372    }
373    let two = |i: usize| u32::from(b[i] - b'0') * 10 + u32::from(b[i + 1] - b'0');
374    let (h, m, sec) = (two(0), two(2), two(4));
375    if h > 23 || m > 59 || sec > 60 {
376        return None;
377    }
378    let mut ms = 0;
379    if b.len() > 6 {
380        let frac = s[6..].strip_prefix('.')?;
381        if !frac.bytes().all(|c| c.is_ascii_digit()) {
382            return None;
383        }
384        // Milliseconds: the first three digits, padded.
385        for (i, c) in frac.bytes().take(3).enumerate() {
386            ms += u32::from(c - b'0') * [100, 10, 1][i];
387        }
388    }
389    Some(((h * 60 + m) * 60 + sec) * 1000 + ms)
390}
391
392/// Days since 1970-01-01 of a calendar date.
393fn days(year: i32, month: u32, day: u32) -> Option<i64> {
394    let date = NaiveDate::from_ymd_opt(year, month, day)?;
395    Some(
396        date.signed_duration_since(NaiveDate::from_ymd_opt(1970, 1, 1)?)
397            .num_days(),
398    )
399}
400
401/// An RMC date, `ddmmyy`. Two-digit years from 80 on are the 1900s.
402fn rmc_date(s: &str) -> Option<i64> {
403    let s = s.trim();
404    if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_digit()) {
405        return None;
406    }
407    let n = |i: usize| s[i..i + 2].parse::<u32>().ok();
408    let yy = n(4)? as i32;
409    days(if yy >= 80 { 1900 + yy } else { 2000 + yy }, n(2)?, n(0)?)
410}
411
412/// The day the log is on. NMEA puts the date in RMC and ZDA only; every other
413/// sentence carries the time of day, and is dated by the last date seen, a day
414/// later when its time has gone back past midnight.
415#[derive(Debug, Default)]
416struct Clock {
417    /// The last day and time of day known.
418    anchor: Option<(i64, u32)>,
419}
420
421impl Clock {
422    /// The day a time of day `tod` falls on, near `anchor`.
423    fn near(anchor: (i64, u32), tod: u32) -> i64 {
424        let (day, last) = (anchor.0, i64::from(anchor.1));
425        let tod = i64::from(tod);
426        if tod + HALF_DAY_MS < last {
427            day + 1
428        } else if tod > last + HALF_DAY_MS {
429            day - 1
430        } else {
431            day
432        }
433    }
434
435    fn date(&mut self, day: i64, tod: u32) {
436        self.anchor = Some((day, tod));
437    }
438
439    /// Milliseconds since the epoch of `tod`; the clock moves on with it.
440    fn resolve(&mut self, tod: u32) -> Option<i64> {
441        let anchor = self.anchor?;
442        let day = Self::near(anchor, tod);
443        if day > anchor.0 || (day == anchor.0 && tod > anchor.1) {
444            self.anchor = Some((day, tod));
445        }
446        Some(day * DAY_MS + i64::from(tod))
447    }
448}
449
450/// The fix being merged: one epoch's GGA, RMC, VTG and GLL.
451#[derive(Debug, Default)]
452struct Fix {
453    tod: Option<u32>,
454    /// The sentence types merged, as bits: GGA, RMC, VTG, GLL.
455    has: u8,
456    lat: Option<f64>,
457    lon: Option<f64>,
458    alt: Option<f64>,
459    speed: Option<f64>,
460    course: Option<f64>,
461    sats: Option<u32>,
462    hdop: Option<f64>,
463    fix: Option<&'static str>,
464    checksum_ok: Option<bool>,
465}
466
467fn gga_fix(quality: Option<u32>) -> Option<&'static str> {
468    Some(match quality? {
469        0 => "none",
470        1 => "gps",
471        2 => "dgps",
472        3 => "pps",
473        4 => "rtk",
474        5 => "rtk float",
475        6 => "estimated",
476        7 => "manual",
477        8 => "simulated",
478        _ => return None,
479    })
480}
481
482/// What RMC says of the fix: its status, and its mode where it has one.
483fn rmc_fix(status: &str, mode: &str) -> Option<&'static str> {
484    if status.trim() == "V" {
485        return Some("none");
486    }
487    Some(match mode.trim() {
488        "A" => "gps",
489        "D" => "dgps",
490        "E" => "estimated",
491        "F" => "rtk float",
492        "R" => "rtk",
493        "M" => "manual",
494        "S" => "simulated",
495        "N" => "none",
496        "" if status.trim() == "A" => "gps",
497        _ => return None,
498    })
499}
500
501/// VTG's course and speed: the field letters (`T`, `M`, `N`, `K`) of NMEA 2.3, or the
502/// four bare values before it.
503fn vtg_values(s: &Sentence) -> (Option<f64>, Option<f64>, Option<f64>, Option<String>) {
504    let speed = |knots: &str, kmh: &str| {
505        number(knots)
506            .map(|v| v * KNOTS)
507            .or_else(|| number(kmh).map(|v| v / 3.6))
508    };
509    if s.field(1).trim() == "T" || s.fields.len() >= 8 {
510        (
511            number(s.field(0)),
512            number(s.field(2)),
513            speed(s.field(4), s.field(6)),
514            text(s.field(8)),
515        )
516    } else {
517        (
518            number(s.field(0)),
519            number(s.field(1)),
520            speed(s.field(2), s.field(3)),
521            None,
522        )
523    }
524}
525
526fn fix_bit(kind: &str) -> u8 {
527    match kind {
528        "GGA" => 1,
529        "RMC" => 2,
530        "VTG" => 4,
531        "GLL" => 8,
532        _ => 0,
533    }
534}
535
536fn and_checksum(a: Option<bool>, b: Option<bool>) -> Option<bool> {
537    match (a, b) {
538        (None, x) | (x, None) => x,
539        (Some(x), Some(y)) => Some(x && y),
540    }
541}
542
543/// Milliseconds from the fix at `last` to the one at `now`, each a time of day and its
544/// time when dated. `None` when time stepped back more than [`BACK_MS`], or when a fix
545/// is undated and more than [`UNDATED_MS`] passed, in which whole days could hide.
546/// Undated, a time of day earlier than the last is taken as past midnight.
547fn gap_ms(last: (u32, Option<i64>), now: (u32, Option<i64>)) -> Option<i64> {
548    let ms = match (last.1, now.1) {
549        (Some(last), Some(now)) => return Some(now - last).filter(|ms| *ms >= -BACK_MS),
550        _ => i64::from(now.0) - i64::from(last.0),
551    };
552    let ms = if ms < -BACK_MS { ms + DAY_MS } else { ms };
553    (-BACK_MS..=UNDATED_MS).contains(&ms).then_some(ms)
554}
555
556/// What a read counted, for the notes.
557#[derive(Debug, Default, Clone, PartialEq, Eq)]
558pub struct Stats {
559    pub lines: u64,
560    pub sentences: u64,
561    /// Lines that are not blank and hold no sentence.
562    pub skipped: u64,
563    pub bad_checksums: u64,
564    /// Sentences of each type, in the order first seen.
565    pub types: Vec<(String, u64)>,
566    /// Sentences past the [`MAX_TYPES`] types counted by name.
567    pub other_types: u64,
568    /// Rows the table holds.
569    pub rows: u64,
570    /// Whether the log said what day it is (RMC or ZDA).
571    pub dated: bool,
572}
573
574impl Stats {
575    fn count(&mut self, name: String) {
576        if let Some((_, n)) = self.types.iter_mut().find(|(t, _)| *t == name) {
577            *n += 1;
578        } else if self.types.len() < MAX_TYPES {
579            self.types.push((name, 1));
580        } else {
581            self.other_types += 1;
582        }
583    }
584
585    /// Count another log's sentences in with these.
586    pub fn absorb(&mut self, other: &Stats) {
587        self.lines += other.lines;
588        self.sentences += other.sentences;
589        self.skipped += other.skipped;
590        self.bad_checksums += other.bad_checksums;
591        self.other_types += other.other_types;
592        self.rows += other.rows;
593        self.dated &= other.dated;
594        for (name, n) in &other.types {
595            if let Some((_, have)) = self.types.iter_mut().find(|(t, _)| t == name) {
596                *have += n;
597            } else if self.types.len() < MAX_TYPES {
598                self.types.push((name.clone(), *n));
599            } else {
600                self.other_types += n;
601            }
602        }
603    }
604
605    /// How many sentences of `kind` were read.
606    pub fn of(&self, kind: &str) -> u64 {
607        self.types
608            .iter()
609            .find(|(t, _)| t == kind)
610            .map_or(0, |(_, n)| *n)
611    }
612}
613
614/// An NMEA log read into one [`Table`], a piece at a time.
615pub struct NmeaReader {
616    table: Table,
617    rows: Builder,
618    line: Vec<u8>,
619    /// The line in progress is past [`MAX_LINE`]: the rest of it is passed over.
620    overlong: bool,
621    clock: Clock,
622    /// Rows of this batch read before the log gave a date, and their times of day.
623    undated: Vec<(usize, u32)>,
624    /// The time of day of the epoch the last sentences belong to.
625    epoch: Option<u32>,
626    /// The time of day of the last fix that had one, and its time when dated, for the
627    /// next fix's `gap`.
628    last_fix: Option<(u32, Option<i64>)>,
629    fix: Option<Fix>,
630    stats: Stats,
631}
632
633impl NmeaReader {
634    pub fn new(table: Table) -> Self {
635        Self {
636            table,
637            rows: Builder::new(&table.columns()),
638            line: Vec::new(),
639            overlong: false,
640            clock: Clock::default(),
641            undated: Vec::new(),
642            epoch: None,
643            last_fix: None,
644            fix: None,
645            stats: Stats::default(),
646        }
647    }
648
649    pub fn stats(&self) -> &Stats {
650        &self.stats
651    }
652
653    /// Read `bytes`, the next piece of the log. A line cut off at the end waits for
654    /// the rest.
655    pub fn push(&mut self, mut bytes: &[u8]) {
656        while !bytes.is_empty() {
657            match bytes.iter().position(|&b| b == b'\n') {
658                Some(end) => {
659                    self.extend_line(&bytes[..end]);
660                    self.end_line();
661                    bytes = &bytes[end + 1..];
662                }
663                None => {
664                    self.extend_line(bytes);
665                    break;
666                }
667            }
668        }
669    }
670
671    fn extend_line(&mut self, piece: &[u8]) {
672        if self.overlong {
673            return;
674        }
675        if self.line.len() + piece.len() > MAX_LINE {
676            self.overlong = true;
677            self.line.clear();
678        } else {
679            self.line.extend_from_slice(piece);
680        }
681    }
682
683    fn end_line(&mut self) {
684        let line = std::mem::take(&mut self.line);
685        self.stats.lines += 1;
686        if std::mem::take(&mut self.overlong) {
687            self.stats.skipped += 1;
688        } else if !line.trim_ascii().is_empty() {
689            match parse_sentence(&line) {
690                Some(sentence) => self.sentence(&sentence),
691                None => self.stats.skipped += 1,
692            }
693        }
694        // The buffer is kept for the next line.
695        self.line = line;
696        self.line.clear();
697    }
698
699    /// A full batch, once one is held. Rows of it still waiting for a date are let go
700    /// undated.
701    pub fn take_batch(&mut self) -> PolarsResult<Option<DataFrame>> {
702        if self.rows.len() < BATCH_ROWS {
703            return Ok(None);
704        }
705        self.take().map(Some)
706    }
707
708    fn take(&mut self) -> PolarsResult<DataFrame> {
709        self.undated.clear();
710        self.rows.take()
711    }
712
713    /// The end of the log: the last line and fix, and the rows not yet taken.
714    pub fn finish(&mut self) -> PolarsResult<DataFrame> {
715        if !self.line.is_empty() || self.overlong {
716            self.end_line();
717        }
718        self.flush_fix();
719        self.take()
720    }
721
722    /// The time of a row: its own time of day when it has one, else its epoch's.
723    fn row_time(&mut self, tod: Option<u32>) -> Cell {
724        let Some(tod) = tod.or(self.epoch) else {
725            return Cell::Time(None);
726        };
727        match self.clock.resolve(tod) {
728            Some(ms) => Cell::Time(Some(ms)),
729            None => {
730                // Dated once the log says what day it is.
731                self.undated.push((self.rows.len(), tod));
732                Cell::Time(None)
733            }
734        }
735    }
736
737    /// The log gave a date: the rows read before it are dated from it.
738    fn dated(&mut self, day: i64, tod: u32) {
739        let first = self.clock.anchor.is_none();
740        self.clock.date(day, tod);
741        self.stats.dated = true;
742        if first {
743            let anchor = (day, tod);
744            for (row, tod) in std::mem::take(&mut self.undated) {
745                let ms = Clock::near(anchor, tod) * DAY_MS + i64::from(tod);
746                self.rows.set_time(0, row, ms);
747            }
748        }
749    }
750
751    fn emit(&mut self, row: Vec<Cell>) {
752        self.rows.push(row);
753        self.stats.rows += 1;
754    }
755
756    fn sentence(&mut self, s: &Sentence) {
757        self.stats.sentences += 1;
758        if s.checksum_ok == Some(false) {
759            self.stats.bad_checksums += 1;
760        }
761        self.stats.count(s.type_name());
762        let standard = s.talker != "P";
763        let kind = if standard { s.kind } else { "" };
764        let tod = match kind {
765            "GGA" | "RMC" | "ZDA" => time_of_day(s.field(0)),
766            "GLL" => time_of_day(s.field(4)),
767            _ => None,
768        };
769        // A new epoch starts with a sentence of another time, so the fix of the last
770        // one is complete; flushed before the date below moves the clock on.
771        let bit = fix_bit(kind);
772        if self.table == Table::Fixes && bit != 0 {
773            let starts_new = self.fix.as_ref().is_some_and(|fix| {
774                fix.has & bit != 0 || matches!((fix.tod, tod), (Some(a), Some(b)) if a != b)
775            });
776            if starts_new {
777                self.flush_fix();
778            }
779        }
780        match kind {
781            "RMC" => {
782                if let (Some(day), Some(tod)) = (rmc_date(s.field(8)), tod) {
783                    self.dated(day, tod);
784                }
785            }
786            "ZDA" => {
787                let n = |i: usize| s.field(i).trim().parse::<u32>().ok();
788                if let (Some(d), Some(m), Some(y), Some(tod)) = (n(1), n(2), n(3), tod)
789                    && let Some(day) = i32::try_from(y).ok().and_then(|y| days(y, m, d))
790                {
791                    self.dated(day, tod);
792                }
793            }
794            _ => {}
795        }
796        if tod.is_some() {
797            self.epoch = tod;
798        }
799        match self.table {
800            Table::Fixes => {
801                if bit != 0 {
802                    self.merge(s, kind, bit, tod);
803                }
804            }
805            Table::Sentences => {
806                let time = self.row_time(tod);
807                let row = vec![
808                    time,
809                    Cell::U64(Some(self.stats.lines)),
810                    Cell::Str(Some(s.talker.to_string())),
811                    Cell::Str(Some(s.kind.to_string())),
812                    Cell::Str(Some(s.body.to_string())),
813                    Cell::Bool(s.checksum_ok),
814                ];
815                self.emit(row);
816            }
817            table if table.sentence() == Some(kind) => self.sentence_rows(s, kind, tod),
818            _ => {}
819        }
820    }
821
822    /// Fold a GGA, RMC, VTG or GLL into the fix of its epoch.
823    fn merge(&mut self, s: &Sentence, kind: &str, bit: u8, tod: Option<u32>) {
824        let fix = self.fix.get_or_insert_with(Fix::default);
825        fix.has |= bit;
826        fix.tod = fix.tod.or(tod);
827        fix.checksum_ok = and_checksum(fix.checksum_ok, s.checksum_ok);
828        let (lat, lon) = match kind {
829            "GGA" => (
830                latitude(s.field(1), s.field(2)),
831                longitude(s.field(3), s.field(4)),
832            ),
833            "RMC" => (
834                latitude(s.field(2), s.field(3)),
835                longitude(s.field(4), s.field(5)),
836            ),
837            "GLL" => (
838                latitude(s.field(0), s.field(1)),
839                longitude(s.field(2), s.field(3)),
840            ),
841            _ => (None, None),
842        };
843        fix.lat = fix.lat.or(lat);
844        fix.lon = fix.lon.or(lon);
845        match kind {
846            "GGA" => {
847                let quality = unsigned(s.field(5));
848                // GGA's quality is the fullest account of the fix; it wins over RMC's.
849                fix.fix = gga_fix(quality).or(fix.fix);
850                fix.sats = unsigned(s.field(6));
851                fix.hdop = number(s.field(7));
852                fix.alt = number(s.field(8));
853            }
854            "RMC" => {
855                fix.speed = fix.speed.or(number(s.field(6)).map(|v| v * KNOTS));
856                fix.course = fix.course.or(number(s.field(7)));
857                if fix.has & 1 == 0 {
858                    fix.fix = rmc_fix(s.field(1), s.field(11)).or(fix.fix);
859                }
860            }
861            "VTG" => {
862                let (course, _, speed, _) = vtg_values(s);
863                fix.speed = fix.speed.or(speed);
864                fix.course = fix.course.or(course);
865            }
866            _ => {}
867        }
868    }
869
870    fn flush_fix(&mut self) {
871        let Some(fix) = self.fix.take() else {
872            return;
873        };
874        let time = match fix.tod {
875            Some(tod) => self.row_time(Some(tod)),
876            None => Cell::Time(None),
877        };
878        let dated = match time {
879            Cell::Time(ms) => ms,
880            _ => None,
881        };
882        let gap = fix.tod.and_then(|tod| {
883            let last = self.last_fix.replace((tod, dated))?;
884            gap_ms(last, (tod, dated)).map(|ms| ms as f64 / 1000.0)
885        });
886        self.emit(vec![
887            time,
888            Cell::F64(fix.lat),
889            Cell::F64(fix.lon),
890            Cell::F64(fix.alt),
891            Cell::F64(fix.speed),
892            Cell::F64(fix.course),
893            Cell::U32(fix.sats),
894            Cell::F64(fix.hdop),
895            Cell::Str(fix.fix.map(str::to_string)),
896            Cell::F64(gap),
897            Cell::Bool(fix.checksum_ok),
898        ]);
899    }
900
901    /// The rows one sentence of the table's own type gives: one, or for GSV one per
902    /// satellite it lists.
903    fn sentence_rows(&mut self, s: &Sentence, kind: &str, tod: Option<u32>) {
904        let (start, waiting) = (self.rows.len(), self.undated.len());
905        let time = self.row_time(tod);
906        let talker = Cell::Str(Some(s.talker.to_string()));
907        let ok = Cell::Bool(s.checksum_ok);
908        let f = |i: usize| s.field(i);
909        match kind {
910            "GGA" => self.emit(vec![
911                time,
912                talker,
913                Cell::F64(latitude(f(1), f(2))),
914                Cell::F64(longitude(f(3), f(4))),
915                Cell::U32(unsigned(f(5))),
916                Cell::U32(unsigned(f(6))),
917                Cell::F64(number(f(7))),
918                Cell::F64(number(f(8))),
919                Cell::F64(number(f(10))),
920                Cell::F64(number(f(12))),
921                Cell::Str(text(f(13))),
922                ok,
923            ]),
924            "RMC" => {
925                let mag_var = number(f(9)).map(|v| if f(10).trim() == "W" { -v } else { v });
926                self.emit(vec![
927                    time,
928                    talker,
929                    Cell::Str(text(f(1))),
930                    Cell::F64(latitude(f(2), f(3))),
931                    Cell::F64(longitude(f(4), f(5))),
932                    Cell::F64(number(f(6)).map(|v| v * KNOTS)),
933                    Cell::F64(number(f(7))),
934                    Cell::F64(mag_var),
935                    Cell::Str(text(f(11))),
936                    ok,
937                ]);
938            }
939            "VTG" => {
940                let (course, course_mag, speed, mode) = vtg_values(s);
941                self.emit(vec![
942                    time,
943                    talker,
944                    Cell::F64(course),
945                    Cell::F64(course_mag),
946                    Cell::F64(speed),
947                    Cell::Str(mode),
948                    ok,
949                ]);
950            }
951            "GSA" => {
952                let sats: Vec<u32> = (2..14).filter_map(|i| unsigned(f(i))).collect();
953                self.emit(vec![
954                    time,
955                    talker,
956                    Cell::Str(text(f(0))),
957                    Cell::U32(unsigned(f(1))),
958                    Cell::ListU32(Some(sats)),
959                    Cell::F64(number(f(14))),
960                    Cell::F64(number(f(15))),
961                    Cell::F64(number(f(16))),
962                    Cell::U32(unsigned(f(17))),
963                    ok,
964                ]);
965            }
966            "GSV" => {
967                // Up to four satellites of four fields each, then NMEA 4.10's signal.
968                let blocks = s.fields.len().saturating_sub(3);
969                let signal = (blocks % 4 == 1)
970                    .then(|| text(f(s.fields.len() - 1)))
971                    .flatten();
972                for block in 0..blocks / 4 {
973                    let at = 3 + block * 4;
974                    let Some(prn) = unsigned(f(at)) else {
975                        continue;
976                    };
977                    self.emit(vec![
978                        time.clone(),
979                        talker.clone(),
980                        Cell::U32(unsigned(f(0))),
981                        Cell::U32(unsigned(f(1))),
982                        Cell::U32(unsigned(f(2))),
983                        Cell::U32(Some(prn)),
984                        Cell::F64(number(f(at + 1))),
985                        Cell::F64(number(f(at + 2))),
986                        Cell::F64(number(f(at + 3))),
987                        Cell::Str(signal.clone()),
988                        ok.clone(),
989                    ]);
990                }
991            }
992            "GLL" => self.emit(vec![
993                time,
994                talker,
995                Cell::F64(latitude(f(0), f(1))),
996                Cell::F64(longitude(f(2), f(3))),
997                Cell::Str(text(f(5))),
998                Cell::Str(text(f(6))),
999                ok,
1000            ]),
1001            "ZDA" => {
1002                let zone = |i: usize| f(i).trim().parse::<i32>().ok();
1003                self.emit(vec![
1004                    time,
1005                    talker,
1006                    Cell::I32(zone(4)),
1007                    Cell::I32(zone(5)),
1008                    ok,
1009                ]);
1010            }
1011            _ => {}
1012        }
1013        // `row_time` recorded an undated row as the next one pushed. A GSV pushes one
1014        // per satellite, or none, and each waits for the date alike.
1015        if let Some(&(_, tod)) = self.undated.get(waiting) {
1016            self.undated.truncate(waiting);
1017            for row in start..self.rows.len() {
1018                self.undated.push((row, tod));
1019            }
1020        }
1021    }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027
1028    fn read(table: Table, text: &str) -> (DataFrame, Stats) {
1029        let mut reader = NmeaReader::new(table);
1030        // In small pieces, so lines arrive cut.
1031        for piece in text.as_bytes().chunks(7) {
1032            reader.push(piece);
1033        }
1034        let df = reader.finish().unwrap();
1035        (df, reader.stats().clone())
1036    }
1037
1038    fn with_checksum(body: &str) -> String {
1039        let sum = body.bytes().fold(0u8, |a, b| a ^ b);
1040        format!("${body}*{sum:02X}")
1041    }
1042
1043    fn f64s(df: &DataFrame, name: &str) -> Vec<Option<f64>> {
1044        df.column(name).unwrap().f64().unwrap().iter().collect()
1045    }
1046
1047    fn times(df: &DataFrame) -> Vec<Option<i64>> {
1048        df.column("time")
1049            .unwrap()
1050            .datetime()
1051            .unwrap()
1052            .physical()
1053            .iter()
1054            .collect()
1055    }
1056
1057    fn ms(date: &str, tod: &str) -> i64 {
1058        let day = rmc_date(date).unwrap();
1059        day * DAY_MS + i64::from(time_of_day(tod).unwrap())
1060    }
1061
1062    #[test]
1063    fn coordinates_are_decimal_degrees() {
1064        let lat = latitude("4807.038", "N").unwrap();
1065        assert!((lat - (48.0 + 7.038 / 60.0)).abs() < 1e-12);
1066        assert!(longitude("01131.000", "W").unwrap() < 0.0);
1067        assert_eq!(latitude("4867.000", "N"), None, "60 minutes or more");
1068        assert_eq!(latitude("9100.000", "N"), None);
1069        assert_eq!(latitude("4807.038", "E"), None, "a longitude's hemisphere");
1070        assert_eq!(latitude("", "N"), None);
1071        assert_eq!(latitude("inf", "N"), None);
1072        assert_eq!(time_of_day("123519.5"), Some(45_319_500));
1073        assert_eq!(time_of_day("246000"), None);
1074    }
1075
1076    #[test]
1077    fn checksums_and_addresses() {
1078        let good = with_checksum("GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,");
1079        let s = parse_sentence(good.as_bytes()).unwrap();
1080        assert_eq!((s.talker, s.kind, s.checksum_ok), ("GP", "GGA", Some(true)));
1081        let bad = good.replace("545.4", "545.5");
1082        assert_eq!(
1083            parse_sentence(bad.as_bytes()).unwrap().checksum_ok,
1084            Some(false)
1085        );
1086        let none = parse_sentence(b"$GPVTG,054.7,T,034.4,M,005.5,N,010.2,K").unwrap();
1087        assert_eq!(none.checksum_ok, None);
1088        let vendor = parse_sentence(b"$PUBX,00,1*00").unwrap();
1089        assert_eq!((vendor.talker, vendor.kind), ("P", "UBX"));
1090        assert_eq!(vendor.type_name(), "PUBX");
1091        let prefixed = parse_sentence(b"12:00:01 $GNRMC,,V,,,,,,,,,,N*4D").unwrap();
1092        assert_eq!(prefixed.kind, "RMC");
1093        assert!(parse_sentence(b"$gpgga,1").is_none());
1094        assert!(parse_sentence(b"hello").is_none());
1095        assert!(looks_like(b"\n$GPGGA,123519,48"));
1096        assert!(!looks_like(b"time,lat\n$GPGGA,1"));
1097        let vendor = with_checksum("PUBX,00,1");
1098        assert!(looks_like(vendor.as_bytes()), "a vendor's, by its checksum");
1099        let wrong = vendor.replace("PUBX,00", "PUBX,01");
1100        assert!(
1101            !looks_like(wrong.as_bytes()),
1102            "a vendor's, its checksum wrong"
1103        );
1104        assert!(!looks_like(b"$USD,$EUR\n1,2\n"), "a CSV header");
1105        assert!(!looks_like(b"$AMOUNT,QTY\n1,2\n"));
1106        assert!(
1107            looks_like(b"5.4,M,46.9,M,,*47\r\n$GNRMC,120000,A,4807.038,N"),
1108            "a capture that starts mid-sentence"
1109        );
1110        assert!(!looks_like(b"a,b*47\n$USD,$EUR\n"));
1111    }
1112
1113    /// A gap is measured forward, across midnight too, and is left empty where the
1114    /// log cannot say how long passed.
1115    #[test]
1116    fn a_gap_is_empty_where_time_cannot_say() {
1117        let h = 3_600_000;
1118        let day = DAY_MS;
1119        // Dated: the times say, however long the pause.
1120        assert_eq!(gap_ms((0, Some(day)), (0, Some(2 * day))), Some(day));
1121        assert_eq!(
1122            gap_ms((1000, Some(1000)), (0, Some(0))),
1123            Some(-1000),
1124            "settling"
1125        );
1126        assert_eq!(
1127            gap_ms((0, Some(10 * h)), (0, Some(6 * h))),
1128            None,
1129            "back an hour"
1130        );
1131        // Undated: midnight crossed, a short pause, a long one, a step back.
1132        let last_second = (DAY_MS - 1000) as u32;
1133        assert_eq!(gap_ms((last_second, None), (1000, None)), Some(2000));
1134        assert_eq!(gap_ms((0, None), (60_000, None)), Some(60_000));
1135        assert_eq!(
1136            gap_ms((0, None), (2 * h as u32, None)),
1137            None,
1138            "days may hide"
1139        );
1140        assert_eq!(
1141            gap_ms((5 * h as u32, None), (h as u32, None)),
1142            None,
1143            "back hours"
1144        );
1145    }
1146
1147    #[test]
1148    fn an_epoch_s_sentences_merge_into_one_fix() {
1149        let log = [
1150            with_checksum("GPRMC,235959.00,A,4807.038,N,01131.000,E,10.0,84.4,311223,003.1,W,A"),
1151            with_checksum("GPGGA,235959.00,4807.038,N,01131.000,E,4,12,0.8,545.4,M,46.9,M,,"),
1152            with_checksum("GPVTG,84.4,T,,M,10.0,N,18.5,K,A"),
1153            "not a sentence".to_string(),
1154            // Past midnight, without a date of its own.
1155            with_checksum("GPGGA,000000.00,4807.040,N,01131.000,E,1,07,1.0,546.0,M,46.9,M,,"),
1156            "$GPRMC,000000.00,A,4807.040,N,01131.000,E,9.0,84.0,010124,,,A*00".to_string(),
1157        ]
1158        .join("\r\n");
1159        let (df, stats) = read(Table::Fixes, &log);
1160        assert_eq!(df.height(), 2, "{df}");
1161        assert_eq!(
1162            times(&df),
1163            [Some(ms("311223", "235959")), Some(ms("010124", "000000"))]
1164        );
1165        let speed = f64s(&df, "speed");
1166        assert!(
1167            (speed[0].unwrap() - 10.0 * KNOTS).abs() < 1e-12,
1168            "knots to m/s"
1169        );
1170        assert_eq!(f64s(&df, "alt"), [Some(545.4), Some(546.0)]);
1171        assert_eq!(f64s(&df, "gap"), [None, Some(1.0)], "across midnight");
1172        let fix: Vec<_> = df.column("fix").unwrap().str().unwrap().iter().collect();
1173        assert_eq!(fix, [Some("rtk"), Some("gps")], "GGA's quality wins");
1174        let ok: Vec<_> = df
1175            .column("checksum_ok")
1176            .unwrap()
1177            .bool()
1178            .unwrap()
1179            .iter()
1180            .collect();
1181        assert_eq!(
1182            ok,
1183            [Some(true), Some(false)],
1184            "the second RMC's checksum is wrong"
1185        );
1186        assert_eq!(
1187            (stats.skipped, stats.bad_checksums, stats.sentences),
1188            (1, 1, 5)
1189        );
1190        assert_eq!(stats.of("GGA"), 2);
1191        assert!(stats.dated);
1192    }
1193
1194    #[test]
1195    fn rows_before_the_first_date_are_dated_by_it() {
1196        let log = [
1197            with_checksum("GPGGA,235958,4807.038,N,01131.000,E,1,08,0.9,545.4,M,,M,,"),
1198            with_checksum("GPGGA,000001,4807.038,N,01131.000,E,1,08,0.9,545.4,M,,M,,"),
1199            with_checksum("GPRMC,000001,A,4807.038,N,01131.000,E,1.0,0.0,010124,,,A"),
1200        ]
1201        .join("\n");
1202        let (df, _) = read(Table::Fixes, &log);
1203        assert_eq!(
1204            times(&df),
1205            [Some(ms("311223", "235958")), Some(ms("010124", "000001"))],
1206            "the day before, across midnight"
1207        );
1208        // A log with no date at all keeps its times empty.
1209        let (df, stats) = read(Table::Gga, log.lines().next().unwrap());
1210        assert_eq!(times(&df), [None]);
1211        assert!(!stats.dated);
1212    }
1213
1214    #[test]
1215    fn each_sentence_type_is_a_table() {
1216        let log = [
1217            with_checksum("GPRMC,120000,A,4807.038,N,01131.000,E,1.0,0.0,010124,,,A"),
1218            with_checksum("GPGSV,2,1,08,01,40,083,46,02,17,308,41,12,07,344,39,14,22,228,45"),
1219            with_checksum("GPGSV,2,2,08,15,,,,16,10,100,,,,,,,,,,1"),
1220            with_checksum("GPGSA,A,3,04,05,,09,12,,,24,,,,,2.5,1.3,2.1"),
1221            with_checksum("GPVTG,054.7,034.4,005.5,010.2"),
1222            with_checksum("PGRME,15.0,M,45.0,M,25.0,M"),
1223        ]
1224        .join("\n");
1225        let (gsv, _) = read(Table::Gsv, &log);
1226        assert_eq!(gsv.height(), 6, "a row per satellite listed: {gsv}");
1227        let snr = f64s(&gsv, "snr");
1228        assert_eq!(snr[..4], [Some(46.0), Some(41.0), Some(39.0), Some(45.0)]);
1229        let signal: Vec<_> = gsv
1230            .column("signal")
1231            .unwrap()
1232            .str()
1233            .unwrap()
1234            .iter()
1235            .collect();
1236        assert_eq!(signal[4..], [Some("1"), Some("1")]);
1237        assert!(
1238            times(&gsv)
1239                .iter()
1240                .all(|t| *t == Some(ms("010124", "120000")))
1241        );
1242        let (gsa, _) = read(Table::Gsa, &log);
1243        let sats = gsa
1244            .column("sats")
1245            .unwrap()
1246            .list()
1247            .unwrap()
1248            .get_as_series(0)
1249            .unwrap();
1250        assert_eq!(
1251            sats.u32().unwrap().into_no_null_iter().collect::<Vec<_>>(),
1252            [4, 5, 9, 12, 24]
1253        );
1254        let (vtg, _) = read(Table::Vtg, &log);
1255        assert_eq!(
1256            f64s(&vtg, "course_mag"),
1257            [Some(34.4)],
1258            "the bare pre-2.3 form"
1259        );
1260        let (all, stats) = read(Table::Sentences, &log);
1261        assert_eq!(all.height(), 6);
1262        assert_eq!(stats.of("PGRME"), 1);
1263        assert_eq!(Table::from_name("gsv"), Some(Table::Gsv));
1264        for table in Table::ALL {
1265            let (df, _) = read(table, &log);
1266            assert_eq!(df.schema().as_ref(), &table.schema(), "{}", table.name());
1267        }
1268    }
1269
1270    #[test]
1271    fn an_overlong_line_is_skipped_and_costs_one_line() {
1272        let mut reader = NmeaReader::new(Table::Sentences);
1273        let long = vec![b'x'; MAX_LINE * 4];
1274        reader.push(&long);
1275        reader.push(&long);
1276        assert!(reader.line.len() <= MAX_LINE);
1277        reader.push(b"\n$GPGGA,1*00\n");
1278        let df = reader.finish().unwrap();
1279        assert_eq!(df.height(), 1);
1280        assert_eq!(reader.stats().skipped, 1);
1281    }
1282}