Skip to main content

hpr_flightdata/
nmea.rs

1//! A GPS receiver's NMEA 0183 sentences, as a logger writes them to a text file.
2//!
3//! An NMEA 0183 receiver writes text, one sentence a line: a `$`, a five-character address (a
4//! two-letter talker, `GP` for GPS, `GN` for several systems at once, then a three-letter sentence
5//! type), the sentence's fields separated by commas, a `*` and a two-digit hexadecimal checksum,
6//! the exclusive-or of every character between the `$` and the `*`, and CR LF:
7//!
8//! ```text
9//! $GPGGA,123519.000,4807.0380,N,01131.0000,E,1,08,0.9,545.4,M,46.9,M,,0000*59
10//! $GPGSA,A,3,04,05,,09,12,,,24,,,,,2.5,1.3,2.1*39
11//! $GPRMC,123519.000,A,4807.0380,N,01131.0000,E,022.4,084.4,230394,,,A*62
12//! ```
13//!
14//! The standard itself is sold by the National Marine Electronics Association; this reader works
15//! from the public description gpsd's authors keep, Eric S. Raymond's *NMEA Revealed*
16//! (<https://gpsd.gitlab.io/gpsd/NMEA.html>, as last updated 2026-10-11): the sentence layout and
17//! checksum under "NMEA Encoding Conventions" and "NMEA 0183 physical protocol layer", the four
18//! sentences read under "NMEA Standard Sentences" (`GGA`, `RMC`, `GSA`, `GSV`), the FAA mode
19//! letters under "Error status indications", and the clock under "Dates and times".
20//!
21//! **The sample files.** As of 2026-10-12, among the project's sample logs, 12 distinct files are
22//! NMEA 0183, all from one kind of receiver, all from the private collection of flights this
23//! project checks against. Each holds talker `GP` only, and one second's sentences in the same
24//! order: `GGA`, `GSA`, on one second in five a burst of three `GSV`, then `RMC`, the `RMC` with
25//! the `GGA`'s time in every one of them. Lines end in CR LF. Every complete sentence carries a
26//! checksum, and all but two check out. Their quirks, each handled below:
27//!
28//! - 10 open with a line that isn't a whole sentence: in 4, bytes of noise before a sentence's
29//!   `$`; in 5, a few bytes of noise alone; in 1, a sentence cut at its start.
30//! - 2 end in bytes that aren't text, NUL among them, on the line of their last sentence: in one
31//!   that sentence is cut off before its checksum, in the other its checksum fails.
32//! - 1 more holds a sentence with bytes that aren't text among its fields, whose checksum fails.
33//! - Before its first fix the receiver writes `GGA` and `RMC` with an empty time (in 11 files),
34//!   then a time with a placeholder date on `RMC` (one of two fixed dates, years before the
35//!   flights), then the real date while the status is still `V`, void, and only then status `A`, a
36//!   valid fix.
37//! - 9 step back 2 s once (one by 1.999 s): in 7 a few minutes after the first fix, in 2 before
38//!   it. Plausibly the receiver taking up the leap-second count the satellites broadcast "once
39//!   every 12.5 minutes", reported UTC being "slightly inaccurate between a cold boot … and the
40//!   following subframe broadcast" (*NMEA Revealed*, "Dates and times"); this is an inference.
41//! - `GGA`'s fix quality is 0, 1 or, in 9 files, 6, dead reckoning, when `RMC`'s mode is `E`.
42//! - One file, 80 lines and 10 s long, never gets a fix: its positions are all gaps.
43//! - None crosses midnight UTC.
44//!
45//! **Sentences.** A line may hold noise around a sentence, or two sentences run together where a
46//! line end was lost. So a sentence is read wherever a `$` opens one whose characters are
47//! printable ASCII (none of `*`, `!`, `\` or `~` among its fields), ending at the first `*`
48//! with two hexadecimal digits that equal its checksum; anything else on the line is left out,
49//! and counted in a note. A line that holds no such sentence is skipped and counted, by kind:
50//! no `$` at all; a sentence cut off before its checksum; or a checksum that fails, or bytes the
51//! standard doesn't allow. A sentence without a checksum isn't read: every sample has one, and
52//! *NMEA Revealed* calls it mandatory. Sentences longer than the standard's 82 characters are
53//! read all the same. NUL bytes, and bytes that aren't UTF-8 (each a U+FFFD after decoding), are
54//! counted in a note.
55//!
56//! A file is taken for NMEA 0183 when at least 3 of its first 50 lines that aren't blank open
57//! with a sentence whose checksum checks out (or every line does, in a shorter file), so that a
58//! first line of noise doesn't hide it, and a log of another kind that quotes sentences inside
59//! its own lines, as a ground station's may, isn't taken for one.
60//!
61//! **Channels.** The file has no barometer: the record's primary track is empty, with a note, and
62//! every value is a channel, named for its sentence's address and field, such as `GPGGA altitude`
63//! or `GPGSV PRN 12 SNR` (the PRN without its leading zeros). The address keeps a file mixing talkers, as a multi-system receiver
64//! writes `GNGGA` beside `GPGSV` and `GLGSV`, from mixing their channels. Each sentence read
65//! adds one sample to each of its channels, in the file's order; an empty field is a gap, `NaN`,
66//! and a field that isn't what its channel holds is a gap counted in a note.
67//!
68//! | Sentence | Channel | Quantity | From the field |
69//! |---|---|---|---|
70//! | `GGA` | `latitude`, `longitude` | [`Quantity::Latitude`], [`Quantity::Longitude`], degrees | `ddmm.mmmm` and `N`/`S`, `dddmm.mmmm` and `E`/`W`, made signed degrees, south and west negative |
71//! | `GGA` | `fix quality` | [`Quantity::Number`] | as written: 0 no fix, 1 GPS, 2 DGPS, 3 PPS, 4 RTK, 5 float RTK, 6 estimated (dead reckoning), 7 manual, 8 simulation |
72//! | `GGA` | `satellites used`, `HDOP` | [`Quantity::Number`] | as written |
73//! | `GGA` | `altitude` | [`Quantity::GnssAltitude`], m above mean sea level | meters, when the unit field is `M`, as the layout gives |
74//! | `GGA` | `geoid separation` | [`Quantity::Distance`], m | meters, when its unit field is `M` |
75//! | `GGA` | `DGPS age`, `DGPS station` | [`Quantity::Number`] | as written: seconds since the last differential correction, and the station's id |
76//! | `RMC` | `status` | [`Quantity::Number`] | 1 for `A`, a valid fix; 0 for `V`, void |
77//! | `RMC` | `latitude`, `longitude` | as `GGA`'s | as `GGA`'s |
78//! | `RMC` | `speed` | [`Quantity::Speed`], m/s | knots, × 1852/3600 (the international nautical mile, 1852 m exactly) |
79//! | `RMC` | `course` | [`Quantity::Angle`], degrees | degrees from true north, as written |
80//! | `RMC` | `magnetic variation` | [`Quantity::Angle`], degrees | degrees and `E`/`W`, east positive |
81//! | `RMC` | `mode` | [`Quantity::Number`] | the FAA mode letter as `GGA`'s fix quality code for the same mode: `N` 0, `A` 1, `D` 2, `P` 3, `R` 4, `F` 5, `E` 6, `M` 7, `S` 8 |
82//! | `GSA` | `selection` | [`Quantity::Number`] | 1 for `A`, automatic 2D/3D; 0 for `M`, manual |
83//! | `GSA` | `fix type` | [`Quantity::Number`] | as written: 1 no fix, 2 2D, 3 3D |
84//! | `GSA` | `satellites used` | [`Quantity::Number`] | how many of its 12 satellite fields are filled |
85//! | `GSA` | `PDOP`, `HDOP`, `VDOP` | [`Quantity::Number`] | as written |
86//! | `GSV` | `satellites in view` | [`Quantity::Number`] | as written, once a burst, from its first sentence |
87//! | `GSV` | `PRN n elevation`, `PRN n azimuth` | [`Quantity::Angle`], degrees | as written, azimuth from true north |
88//! | `GSV` | `PRN n SNR` | [`Quantity::Number`] | as written, dB, 00 to 99 |
89//!
90//! `RMC`'s magnetic variation and mode, and `GGA`'s DGPS age and station, are left out when every
91//! sentence leaves them empty (no sample fills the variation). A `GSV` sentence's message count
92//! and number order the burst, and `GSA`'s list of satellites used is kept as its count; fields
93//! past the ones in the table, such as NMEA 4.10's signal and system ids, are left out and
94//! counted in a note. An altitude or geoid separation whose unit field isn't `M` is kept apart,
95//! as written, in a channel such as `GPGGA altitude, unit "F"` ([`Quantity::Unrecognized`]), with
96//! a note. Sentence types other than these four are counted in a note, their fields left out.
97//!
98//! **Clocks.** `GGA` and `RMC` state the time of day in UTC, `hhmmss.sss`; `RMC` states the date,
99//! `ddmmyy` (a year `yy` of 80 or more is 19`yy`, else 20`yy`, as GPS began in 1980). A sample's
100//! time is its sentence's, in seconds from the log's zero, the first sentence with a time. The
101//! day is the date of the last `RMC` with status `A`: a void `RMC`'s date is a placeholder in the
102//! samples, so it is left out, with a note. Before such an `RMC`, and in a file without one,
103//! times are times of day alone. A time of day more than 12 hours before the last is the next
104//! day, and one more than 12 hours after it the day before; where an `RMC` with status `A` dates
105//! it, the date settles the day instead. `GSA` and `GSV` carry no time: each takes the time of the
106//! `GGA` or `RMC` before it, its second's, as the samples' order shows, with a note giving the
107//! file's own evidence; one before any time has a gap for its time, as does a `GGA` or `RMC` whose
108//! time is empty. Each channel keeps its samples in the file's order, so a time may go back,
109//! as the samples' 2 s step does: a note says how often and how far.
110//!
111//! **Hostile input.** Work is linear in the file: a line is cut at its `$`s once. At most
112//! [`MAX_ADDRESSES`] sentence addresses are read and [`MAX_SATELLITES`] satellites a `GSV`
113//! address, the rest counted; notes about single channels are capped; text quoted in a note is
114//! cut short.
115
116use 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
122/// The format's name in errors.
123const FORMAT: &str = "NMEA 0183";
124
125/// How many lines that aren't blank [`recognizes`] looks at, from the file's start.
126const RECOGNIZE_LINES: usize = 50;
127
128/// How many of those must hold a sentence whose checksum checks out.
129const RECOGNIZE_SENTENCES: usize = 3;
130
131/// The most characters of a line [`recognizes`] looks at.
132const RECOGNIZE_LINE_BYTES: usize = 4096;
133
134/// The most sentence addresses of the four types read, such as `GPGGA` and `GNGGA`; sentences at
135/// further addresses are counted in a note. The samples have four.
136pub const MAX_ADDRESSES: usize = 16;
137
138/// The most satellites one `GSV` address gives channels to; further satellites are counted in a
139/// note. The samples see at most 28.
140pub const MAX_SATELLITES: usize = 64;
141
142/// The most other sentence types a note names, and the most it counts apart.
143const MAX_OTHER_TYPES: usize = 8;
144
145/// Meters per second in a knot: the international nautical mile is 1852 m exactly (BIPM, *The
146/// International System of Units*, 9th ed., 2019, Table 8), so a knot is 1852/3600 m/s.
147const KNOT_M_S: f64 = 1852.0 / 3600.0;
148
149/// Nanoseconds in a day.
150const DAY_NS: i128 = 86_400_000_000_000;
151
152/// Whether the text is NMEA 0183: among its first [`RECOGNIZE_LINES`] lines that aren't blank, at
153/// least [`RECOGNIZE_SENTENCES`] open with a sentence whose checksum checks out, or, in a shorter
154/// file, every line does. A first line of noise, as most samples have, doesn't stop it; a file
155/// that quotes sentences inside lines of its own, as a ground station's log may, isn't NMEA.
156pub(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
180/// The start of a line, at most `bytes` long, cut at a character's boundary.
181fn 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/// A sentence whose checksum checks out: its address and its fields, the first field after the
193/// address first.
194#[derive(Debug, PartialEq)]
195struct Sentence<'a> {
196    address: &'a str,
197    fields: Vec<&'a str>,
198}
199
200/// Why a `$` doesn't open a sentence.
201#[derive(Debug, Clone, Copy, PartialEq)]
202enum Failure {
203    /// No `*` and two hexadecimal digits before the line or the next `$` ends it.
204    CutOff,
205    /// A checksum that fails, a character the standard doesn't allow, or an address that isn't
206    /// one.
207    Damaged,
208}
209
210/// What one line holds.
211#[derive(Debug, Default)]
212struct LineScan<'a> {
213    sentences: Vec<Sentence<'a>>,
214    /// Bytes on the line outside any sentence read, but for spaces.
215    noise: bool,
216    /// Whether the line holds a `$`.
217    dollar: bool,
218    /// The first `$` that doesn't open a sentence, and why.
219    failure: Option<Failure>,
220}
221
222/// The sentences on a line: each `$` that opens one whose checksum checks out. Each character is
223/// looked at a fixed number of times.
224fn 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
251/// The sentence a piece of a line after a `$` opens, and what follows its checksum.
252fn 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    // `split` yields at least one piece, so the address is always there.
266    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
283/// A sentence's checksum: the exclusive-or of its characters between the `$` and the `*` (*NMEA
284/// Revealed*, "NMEA Encoding Conventions").
285fn checksum(body: &str) -> u8 {
286    body.bytes().fold(0, |sum, b| sum ^ b)
287}
288
289/// The four sentence types read.
290#[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    /// The fields its layout has, and the most this reader reads; fields past the most are left
322    /// out, counted.
323    fn fields(self) -> (usize, usize) {
324        match self {
325            // NMEA 2.3 added RMC's mode; its layout without it is 11 fields.
326            Self::Gga => (14, 14),
327            Self::Rmc => (11, 12),
328            Self::Gsa => (17, 17),
329            // Three, then four for each satellite.
330            Self::Gsv => (3, usize::MAX),
331        }
332    }
333}
334
335/// How a field becomes a channel's value.
336#[derive(Debug, Clone, Copy, PartialEq)]
337enum Rule {
338    /// A number as written.
339    Number,
340    /// A number times a factor.
341    Times(f64),
342    /// A latitude or longitude, `ddmm.mmmm` or `dddmm.mmmm`, with its hemisphere field next.
343    Coordinate {
344        most_degrees: f64,
345        negative: char,
346        positive: char,
347    },
348    /// A number with an `E`/`W` field next, east positive.
349    EastWest,
350    /// A letter, as a code.
351    Letter(&'static [(char, f64)]),
352}
353
354/// One channel, as read so far.
355struct Track {
356    name: String,
357    quantity: Quantity,
358    time_s: Vec<f64>,
359    values: Vec<f64>,
360    /// Left out of the record if every value is a gap.
361    optional: bool,
362    /// Fields that weren't what the channel holds, and the first as written.
363    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    /// A field's value, a gap if empty; a field that isn't what the channel holds is a gap,
386    /// counted.
387    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
403/// `GGA`'s and `RMC`'s fixed channels: each one's name after the address, quantity, field place,
404/// rule, and whether it is left out when always empty.
405type 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
418/// The FAA mode letters, as `GGA`'s fix quality codes for the same mode.
419const 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    // The altitude and the geoid separation (places 8 and 10) are read apart, by their units.
438    ("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    // `satellites used` is counted from places 2 to 13.
474    ("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
479/// A field by its rule: `None` if empty, `Err` with what is written if it isn't the channel's.
480fn 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
510/// A value made negative by its hemisphere's letter; `None` for another letter or none.
511fn 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
520/// A latitude or longitude, `ddmm.mmmm` or `dddmm.mmmm`, as degrees: the two digits before the
521/// point and those after it are minutes, the digits before them degrees (*NMEA Revealed*, "NMEA
522/// Encoding Conventions"). `None` for anything else, minutes of 60 or more, or more degrees than
523/// `most_degrees`.
524fn 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
545/// A cell's digits before and after its point; `None` for a point with no digit after it.
546fn 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
554/// A time of day, `hhmmss` with up to nine decimals, as nanoseconds after midnight; `None` for
555/// anything else, or a time past 23:59:60.999 999 999.
556fn 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
579/// A date, `ddmmyy`, as days since 1970-01-01; a year `yy` of 80 or more is 19`yy`, else
580/// 20`yy`. `None` for anything else, or a day the month doesn't have.
581fn 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
601/// Days since 1970-01-01 of a date in the proleptic Gregorian calendar (H. Hinnant,
602/// "chrono-Compatible Low-Level Date Algorithms", `days_from_civil`, public domain).
603fn 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
612/// The date of a count of days since 1970-01-01 (Hinnant, `civil_from_days`).
613fn 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/// The log's clock, as the timed sentences set it.
632#[derive(Default)]
633struct Clock {
634    /// The first timed sentence's time of day, ns: the log's zero, on day 0.
635    zero_ns: Option<i64>,
636    /// The day of the last timed sentence, counted from the zero's.
637    day: i64,
638    last_tod_ns: Option<i64>,
639    last_abs_ns: Option<i128>,
640    /// The last valid date read, days since 1970, and its day count.
641    last_date: Option<(i64, i64)>,
642    /// The first valid date read, and its day count.
643    first_date: Option<(i64, i64)>,
644    /// The time the untimed sentences take: the last timed sentence's, `NaN` before one.
645    epoch_s: f64,
646    /// How often, and how far at most, the time goes back, s.
647    backs: usize,
648    largest_back_s: f64,
649    /// Days the time of day passed midnight, forward or back, and days a date settled.
650    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    /// The time, s from the log's zero, of a sentence stating a time of day and perhaps a valid
663    /// date; `NaN` without a time of day.
664    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
705/// The channels of one sentence address.
706struct Address {
707    address: String,
708    kind: Kind,
709    /// Indexes into the reader's tracks of the address's fixed channels, in [`Layout`] order.
710    fixed: Vec<usize>,
711    /// For `GGA`: the altitude's and geoid separation's tracks, by unit as written.
712    by_unit: Vec<(usize, String, usize)>,
713    /// For `GSV`: each satellite's three tracks, by PRN, and those left out past the cap.
714    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
722/// The reader's state.
723struct Reader {
724    tracks: Vec<Track>,
725    addresses: Vec<Address>,
726    clock: Clock,
727    /// Sentences at addresses past [`MAX_ADDRESSES`].
728    addresses_left_out: usize,
729    /// Other sentence types, by address, at most [`MAX_OTHER_TYPES`] apart, and the rest.
730    other: BTreeMap<String, usize>,
731    other_left_out: usize,
732    /// Timed sentences whose time is empty, or isn't a time.
733    empty_times: usize,
734    bad_times: usize,
735    bad_time_example: Option<String>,
736    /// Untimed sentences read before any time.
737    untimed_before: usize,
738    /// `RMC` dates: valid ones on void sentences that no valid sentence states, and bad ones.
739    void_dates: BTreeSet<i64>,
740    valid_dates: BTreeSet<i64>,
741    void_dated: usize,
742    rmc: usize,
743    bad_dates: usize,
744    /// `GGA` then `RMC` pairs, and those whose times agree.
745    last_gga_tod: Option<i64>,
746    pairs: usize,
747    pairs_agree: usize,
748}
749
750/// Reads a GPS receiver's NMEA 0183 sentences.
751///
752/// # Errors
753///
754/// [`LogError::NotThisFormat`] if no line holds a sentence whose checksum checks out;
755/// [`LogError::NoData`] if none of those is a `GGA`, `RMC`, `GSA` or `GSV`.
756pub 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/// Lines of one kind that hold no sentence, or noise beside one: how many, and the first.
825#[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    /// Reads one sentence into its address's channels.
851    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    /// The time of a sentence: its own for `GGA` and `RMC`, else its second's.
922    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                            // Distinct days are few; a hostile file's are bounded by the 36,525
963                            // a two-digit year spans.
964                            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    /// A new address's fixed channels.
976    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    /// A `GGA` value with a unit field after it: to the meters channel if the unit is `M` or the
1021    /// value empty, else to a channel for that unit, as written. Two units a sentence at most, a
1022    /// few kinds of unit an address: past four, a value in another unit is a gap, counted.
1023    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                    // Past the cap, into the meters channel as a gap, counted as not its own.
1062                    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    /// A `GSV` sentence: the count in view on a burst's first, and each satellite's three
1084    /// channels.
1085    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            // NMEA 4.10's signal id, or what isn't a whole satellite.
1093            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    /// The record: every channel kept, the empty primary track, and the notes.
1148    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    /// The notes on the clock: the date's and the untimed sentences' decisions, then what the
1248    /// time did.
1249    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    /// The notes on the file's lines, the sentences left out and a fix never had.
1375    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    /// A sentence with its checksum.
1460    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    /// The checksum is the exclusive-or between `$` and `*`, as *NMEA Revealed*'s examples
1476    /// state it; a wrong one, a cut sentence and a forbidden byte are told apart.
1477    #[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        // Lower-case hexadecimal digits are accepted.
1506        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    /// Noise before a `$`, after a checksum, and two sentences run together: each sentence read,
1514    /// the noise marked.
1515    #[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    /// Midnight without a date is the nearest day; a valid date settles the day.
1628    #[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        // Three days later by date, the same time of day.
1643        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        // Sentences quoted inside another log's lines don't make it NMEA.
1662        let quoted = format!("10:00:00.000: gps {{'sentence': '{gga}'}}\n");
1663        assert!(!recognizes(&quoted.repeat(5)));
1664    }
1665
1666    /// A file of sentences of other types reads as no data; a file of none isn't NMEA.
1667    #[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    /// The fields of every sentence read, its channels named for address and field, its untimed
1682    /// sentences on their second's time.
1683    #[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    /// Bad fields are gaps counted in a note; short sentences' missing fields are gaps.
1742    #[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    /// Past the caps, addresses, satellites and other types are counted, not kept.
1763    #[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        /// Any text is read or refused, never a panic; what is read has a time for each value.
1807        #[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            // Half the lines get a checksum that checks out, so that sentences are read.
1813            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        /// A valid fix's fields read back as the numbers written.
1836        #[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}