Skip to main content

hpr_flightdata/
fwgps.rs

1//! Featherweight's GPS tracker logs, as the tracker's app and its ground station write them to
2//! comma-separated files: positions from a GPS receiver, with no barometer.
3//!
4//! Featherweight's GPS tracker radios its fixes to a ground station, which passes them to a phone
5//! app over Bluetooth; the tracker also keeps its own "flight data logging" (Featherweight,
6//! *Featherweight GPS Tracker User's Manual*, February 20, 2025, p17). As of 2026-10-12, among the
7//! project's sample logs, 40 distinct files hold one of four layouts, each a header line naming the
8//! columns, then one row per fix:
9//!
10//! | Layout | Files | Header |
11//! |---|---|---|
12//! | The tracker's log, older | 7 | `UTCTIME,UNIXTIME,ALT,LAT,LON,#SATS,FIX,HORZV,VERTV,HEAD,FLAGS,>40,>32,>24,RSSI,BATT` |
13//! | The tracker's log, newer | 21 | the same 16, then `Altitude AGL,Launch detection,Apogee detection,Landing detection,Distance (feet)` |
14//! | The ground station's log | 8 | `TRACKER,DATE,TIME,LAT,LON,ALT,FIX,HORZV,VERTV,HEAD,FLAGS,#TOT,>40,>32,>24,RSSI,BATT` |
15//! | The ground station's log with its radio's figures | 4 | `TRACKER,DATE,TIME,GS Lat,GS Lon,GS Alt asl,TRACKER Lat,TRACKER Lon,TRACKER Alt asl,…` (40 columns) |
16//!
17//! ```text
18//! UTCTIME,UNIXTIME,ALT,LAT,LON,#SATS,FIX,HORZV,VERTV,HEAD,FLAGS,>40,>32,>24,RSSI,BATT
19//! Jun 3 2024 15:20:00.100 UTC,1717446000.100,4000,40.1234567,-105.1234567,18,3,0,0,90,0x03,4,9,3,-40,4.010
20//! ```
21//!
22//! A file is one of these when its first line that isn't blank opens with `UTCTIME,UNIXTIME` and
23//! names `LAT` and `LON`, or opens with `TRACKER,DATE,TIME` and names `LAT` and `LON` or `TRACKER
24//! Lat` and `TRACKER Lon`. Columns are then mapped by name, not by place; a name is matched as
25//! written, its spaces at either end trimmed.
26//!
27//! **No barometer.** The files hold a GPS receiver's positions only, so the record's primary track
28//! is empty, as for a Blue Raven's high-rate file ([ADR-253][adr-253] §6, on the RRC3, Eggtimer
29//! and Blue Raven readers) and as [ADR-254][adr-254] §2 decided for GPS logs, and every column is
30//! a channel, with a note saying so. The readings withhold on such a log.
31//!
32//! [adr-253]: https://github.com/nrdptel/fusionspace-eridanus/blob/main/docs/decisions/0253-rrc3-eggtimer-and-blue-raven-csv-first.md
33//! [adr-254]: https://github.com/nrdptel/fusionspace-eridanus/blob/main/docs/decisions/0254-gps-tracks-first-among-the-other-importers.md
34//!
35//! **Channels.** Every column but the clock's parts, the tracker's name and `Found rocket ID` is a
36//! channel, in the file's order, in the record's SI unit ([`Quantity::unit`]):
37//!
38//! | Column | Quantity | Unit from |
39//! |---|---|---|
40//! | `UTCTIME`; `TIME` | [`Quantity::TimeOfDay`]: UTC; the ground station's clock | the clock (below) |
41//! | `UNIXTIME` | [`Quantity::Unrecognized`], as written | a note gives its offset from `UTCTIME` (below) |
42//! | `ALT`, `TRACKER Alt asl`, `GS Alt asl`, `Found rocket altitude` | [`Quantity::GnssAltitude`] | feet: the manual and the data (below) |
43//! | `Altitude AGL`, `GS_ASL_offset` | [`Quantity::Distance`]: a height, not above sea level | feet, with the altitude |
44//! | `LAT`, `LON` and each `… Lat`, `… Lon`, `… lat`, `… lon` | [`Quantity::Latitude`], [`Quantity::Longitude`] | degrees |
45//! | `HORZV`, `VERTV` | [`Quantity::Speed`] | ft/s: the manual and the data (below) |
46//! | `HEAD` | [`Quantity::Angle`] | degrees: the manual and the data (below) |
47//! | `BATT` | [`Quantity::Voltage`] | volts: the data (below) |
48//! | `GS_batt_v`, `Found rocket batt_v` | [`Quantity::Voltage`] | the header's `v` |
49//! | `Distance (feet)`, `Alt AGL (ft)`, `Horizontal range (ft)`, `Total range (ft)` | [`Quantity::Distance`] | the header's feet |
50//! | `LoRa Max Range (kft)` | [`Quantity::Distance`] | the header's thousands of feet |
51//! | `#SATS`, `#TOT`, `FIX`, `FLAGS`, `>40`, `>32`, `>24`, `RSSI`, `RSSI Down`, `RSSI Up`, `Found RSSI`, `Found rocket fix`, the radio's packet counts | [`Quantity::Number`], as written | no unit |
52//! | `LoRa Success Rate` | [`Quantity::Number`], in percent | written `67%`, "the packet success rate, expressed as a percentage, based on the last 50 packets" (*Featherweight Swift User's Manual*, April 10 2026, p10) |
53//! | `Launch detection`, `Apogee detection`, `Landing detection` | [`Quantity::Number`], 1 for `TRUE` and 0 for `FALSE` | the manual's "flag"s (p17) |
54//!
55//! `>40`, `>32` and `>24` are, in the manual's words, the "Number of GPS satellites with > 40
56//! signal strength" and so on (p17), its color table giving signal strengths in dB-Hz (p10). `FLAGS` is written in hexadecimal (`0x03`) in the
57//! older tracker log and both ground-station logs, and in decimal in the newer tracker log; either
58//! is read as the number it writes. A column the reader doesn't know is kept as written,
59//! [`Quantity::Unrecognized`], with a note.
60//!
61//! **Units no header states.** The manual lists the tracker's log as "Altitude (ASL, Feet)",
62//! "Horizontal velocity (feet/second)", "Vertical velocity (feet/second)", "Heading (degrees from
63//! North)" and "Tracker battery voltage" (p17), in the order of the tracker log's columns, and the
64//! ground station's serial packet gives the same units (Appendix A, p21). Each is checked against
65//! the file's own rows, and each file's record carries one note per kind: the decision and what
66//! decided it, the same words for every file decided alike, then after the colon this file's
67//! evidence, such as `` `ALT` read in feet, from the manual and the horizontal speed: … ``. The
68//! checks, and what they found as of 2026-10-12, among the project's 40 sample files:
69//!
70//! - *The file's length unit.* One rule decides it for every length and speed the manual gives
71//!   in feet: the heights, `HORZV` and `VERTV`. Only `HORZV` can be checked against an absolute
72//!   length, the great-circle distance between two fixes. Taking each tracker's rows in time
73//!   order, and each pair of rows more than 0.5 s and at most 2.5 s apart (from the last row whose
74//!   position differed, as the ground station repeats a fix it hasn't heard anew) at a speed of at
75//!   least 20 as written, the distance in feet a second over `HORZV` has a median between 0.95 and
76//!   1.32 in the 37 files with 10 such pairs or more (1.32 in one ground-station log, the rest
77//!   below 1.08); meters per second would give 3.28, knots 1.69, miles per hour 1.47. A median
78//!   inside 0.7 to 1.4 keeps the manual's feet (`from the manual and the distance between fixes`,
79//!   and for the heights `from the manual and the horizontal speed`); with fewer than 10 pairs, as
80//!   in 3 files, the manual decides alone (`from the manual alone`); a median outside the band
81//!   keeps every one of those columns as written, with a note. The band's upper edge sits 6% above
82//!   the highest sample median and 4.5% below miles per hour's 1.467, so a file in miles per hour
83//!   whose fixes ran 4.5% or more short of its speeds would be read in feet per second: the band
84//!   errs toward the manual's unit, not away from it. Kilometers per hour would give 0.91, inside
85//!   the band: the manual, not the check, rules them out. Pairs a tenth of a second apart are left
86//!   out: in one of the older tracker logs, whose rows step back and repeat, the distance over
87//!   such pairs runs 1.6 times `HORZV`, where over pairs a second apart it runs 1.02 times.
88//! - *Vertical speed.* `VERTV` against the change of the altitude a second, over the same pairs,
89//!   shows only that the two share a length unit, not which: a file all in meters agrees as well
90//!   as one all in feet. It decides only whether `VERTV` is in the altitude's unit a second
91//!   (`` `VERTV` read in feet per second, in the altitude's unit, from the change of altitude ``,
92//!   or `from the manual alone` with fewer than 10 pairs); where its median falls outside 0.7 to
93//!   1.4, `VERTV` and the heights are kept as written. The medians fall between 0.94 and 1.03 in
94//!   the 38 files with 10 such pairs or more.
95//! - *Heights.* In the 40-column ground-station log, `Alt AGL (ft)`, whose header says feet, is
96//!   `TRACKER Alt asl` less `GS Alt asl` and `GS_ASL_offset` to 0.15 ft on every row with a fix of
97//!   all 4 files, which ties those three to feet too: the heights there are kept as written if it
98//!   holds on under half the rows, and its note says `` and `Alt AGL (ft)` `` among what decided.
99//!   In the newer tracker log, `Altitude AGL` is `ALT` less one height, the pad's, to 0.05 ft (in
100//!   all 21 files, on all but one row), which shows the two share a unit but not which.
101//!   `Distance (feet)` is, by a median of 1.000 to 1.002 times, the great-circle distance from the
102//!   first row's position in the 21 newer tracker logs.
103//! - *Heading.* `HEAD` stays within −179 to 180 in every file: degrees, as the manual says. A file
104//!   with a value past ±360 keeps it as written.
105//! - *Battery.* `BATT` stays between 0 and 4.16 in every file, as one lithium cell's volts do
106//!   (the ground station's serial packet writes millivolts, p21, which would be thousands), and
107//!   reads 0 on every row of the 8 shorter ground-station logs. A value outside 0 to 5 keeps it as
108//!   written.
109//!
110//! The speeds' and heights' unit is also what fw2kml's KML of the same flights shows (the GPS
111//! logs' decision, [ADR-254][adr-254] §5), a check across files the reader can't make.
112//!
113//! **The clock.** The tracker's clock is `UTCTIME`, written `Jul 14 2031 17:04:31.250 UTC`: an
114//! English month's first three letters, the day, the year, the time of day and `UTC`. Read as UTC,
115//! it puts the first fix between 6.8 h and 15.4 h of local mean solar time at the fix's longitude
116//! in the 28 tracker files. `UNIXTIME` is a Unix time, but a whole number of hours, 4 to 8, ahead
117//! of `UTCTIME` on every row of every tracker file, as if the site's offset from UTC had been
118//! added where it should have been taken away: read as UTC, it would put a fix at up to 22.9 h of
119//! solar time. It is kept as a channel as written, with a note giving its offset. The ground
120//! station's clock is `DATE` (`2031-07-14`) and `TIME` (`14:27:09.375`), in a zone the file
121//! doesn't state. At the first fix, `TIME` falls between 6.6 h and 13.5 h in 11 of the 12
122//! ground-station files, as a clock on the site's zone would on a launch day, and read as UTC it
123//! would put 8 of them between 22.8 h and 4.0 h of local mean solar time, at night; in the
124//! twelfth, `TIME` is 0.2 h, 16.2 h of solar time read as UTC, as a clock on UTC would be. The
125//! times are kept on that clock as written, with a note, `` `DATE` and `TIME` kept as written, in a
126//! time zone the file doesn't state: … ``, giving this file's figures after the colon.
127//!
128//! The log's zero is the first row's time, in the file's order. Channels keep every row in the
129//! file's order, so a time may go back: in each of the 7 older tracker logs, rows step back 1 to
130//! 11 times, by 0.9 s to 9 s, and in two of them once by about 847 s and 4,407 s; 1 to 76 rows of
131//! each repeat a time already written. A note counts how often and how far times go back, and how
132//! many rows repeat a time. The rows of the ground-station logs and of the newer tracker logs run
133//! forward, but for one repeated time in 4 of the newer. A clock cell that isn't a time is
134//! refused, naming its line.
135//!
136//! **Trackers.** A ground station logs every tracker it hears; 3 of the 8 shorter ground-station
137//! logs and 1 of the 4 longer hold several (up to 5). Each tracker has its own set of channels, in
138//! the order the trackers first appear, each named with the tracker's trimmed name in brackets,
139//! such as `LAT [Rocket-1]`, when the file holds more than one; a file of one tracker keeps the
140//! header's names. Past [`MAX_TRACKERS`] trackers, or [`MAX_CHANNELS`] channels in all, a further
141//! tracker's rows are left out, counted in a note, so a file of many names can't make the reader
142//! hold a set of channels for each.
143//!
144//! **No fix.** Before it has a fix, a tracker writes 0 for its latitude, longitude and altitude:
145//! 372 rows of 6 shorter ground-station logs and 74 of a longer one do. A row whose latitude and
146//! longitude are both exactly 0 has them and the altitude beside them read as gaps, counted in a
147//! note, for the tracker's, the ground station's and the found rocket's positions alike, as
148//! [`crate::altos`] does for AltOS's (0, 0). A latitude beyond ±90° or a longitude beyond ±180° is
149//! a gap too, counted per column in a note, as [`crate::kml`] and [`crate::nmea`] read theirs; no
150//! sample file has one.
151//!
152//! **Cells and rows.** A cell is a decimal number, hexadecimal digits after `0x`, `TRUE` or
153//! `FALSE` (1 or 0), or a number followed by `%`. A cell that is empty or none of these, such as
154//! the literal `NaN` in `Distance (feet)` (27 cells over the 21 newer tracker logs), is a gap,
155//! counted per column in a note. A row with fewer cells than the header has the rest as gaps; a
156//! row with more is refused. A file whose rows, down to any row, hold under a quarter of the cells
157//! its header names (past the first 65,536) is refused at that row. Blank lines are skipped; lines
158//! may end in CR, LF or both (2 of the shorter ground-station logs end in CR LF); a byte-order mark
159//! is dropped.
160//!
161//! **What the file says about itself.** The logger is a `Featherweight GPS tracker` or a
162//! `Featherweight GPS ground station`; the files state no serial number, firmware, flight number,
163//! apogee or ground elevation.
164//!
165//! **Where this comes from.** The layouts and their quirks, from the sample files; the units,
166//! from the manual as cited and the files; the tracker log's clock, columns and feet, following
167//! Debrief's `lib/parsers/featherweightGps.ts` (MIT, the project owner's own; see
168//! `THIRD-PARTY-NOTICES.md`). Where this reader departs from Debrief's: it keeps every row in the
169//! file's order on `UTCTIME`, where Debrief sorts by `UNIXTIME` and drops repeated times; it reads
170//! the shorter ground-station log, which Debrief doesn't, and keeps each tracker apart, where
171//! Debrief reads every row as one tracker's; it keeps every column, where Debrief keeps a chosen
172//! few; and it reads a position's no-fix zeros as gaps by the zeros themselves, where Debrief reads
173//! them by the `FIX` column.
174
175use std::collections::HashMap;
176
177use crate::error::LogError;
178use crate::log::{Channel, FlightLog, LogFormat, Quantity, Stated, too_sparse};
179use crate::perfectflite::FOOT_M;
180use crate::table::{Notes, number, numbered_lines, short};
181
182/// The format's name in errors.
183const FORMAT: &str = "Featherweight GPS CSV";
184
185/// The most trackers one file's channels are kept for; past it, a tracker's rows are left out.
186/// The sample files hold at most 5.
187pub const MAX_TRACKERS: usize = 64;
188
189/// The most channels a file's trackers may have in all; past it, a further tracker's rows are left
190/// out. Two trackers of the 40-column log hold 74.
191pub const MAX_CHANNELS: usize = 4_096;
192
193/// The shortest step between two fixes a speed is checked over, s: over a tenth of a second, the
194/// older tracker log's rows, which step back and repeat, can pair fixes that weren't a tenth of a
195/// second apart.
196const MIN_STEP_S: f64 = 0.5;
197
198/// The longest step between two fixes a speed is checked over, s: the ground station logs about a
199/// fix a second, the tracker up to ten.
200const MAX_STEP_S: f64 = 2.5;
201
202/// The slowest speed, as written, a speed is checked at: below it a GPS receiver's noise is a
203/// large part of the change.
204const MIN_SPEED: f64 = 20.0;
205
206/// The fewest pairs of rows a speed check needs to overrule the manual.
207const MIN_PAIRS: usize = 10;
208
209/// The band a speed check's median ratio must fall in to agree with the manual: wide enough for
210/// the 0.94 to 1.32 seen in the samples, narrow enough to refuse miles per hour (1.47), knots
211/// (1.69) and meters per second (3.28). The upper edge is 4.5% below miles per hour's 1.467, so a
212/// file in miles per hour whose fixes run that much short of its speeds would read in feet per
213/// second: the band errs toward the manual's unit (the module's docs say more).
214const AGREES: (f64, f64) = (0.7, 1.4);
215
216/// The most a battery reads in volts: one lithium cell is full at about 4.2 V.
217const MAX_CELL_V: f64 = 5.0;
218
219/// The mean radius of the Earth, m: R₁ = 6,371,008.8 m of the Geodetic Reference System 1980
220/// (Moritz, *Geodetic Reference System 1980*, Journal of Geodesy 74, 2000). A great circle on this
221/// sphere is within half a percent of the ellipsoid's distance anywhere.
222const EARTH_RADIUS_M: f64 = 6_371_008.8;
223
224/// Nanoseconds in a second and in a day.
225const NS_PER_S: i64 = 1_000_000_000;
226const NS_PER_DAY: i64 = 86_400 * NS_PER_S;
227
228/// The years a clock cell may name: a GPS receiver's dates fall well inside, and the span keeps a
229/// difference of two times, in nanoseconds, inside an `i64`.
230const YEARS: std::ops::RangeInclusive<i64> = 1970..=2199;
231
232/// Whether the text is in this reader's layout, judged from its header, never the file's name.
233pub(crate) fn recognizes(text: &str) -> bool {
234    numbered_lines(text)
235        .map(|(_, line)| line)
236        .find(|line| !line.trim().is_empty())
237        .and_then(layout)
238        .is_some()
239}
240
241/// Which of Featherweight's logs a file is.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243enum Layout {
244    /// The tracker's own log, timed by `UTCTIME`.
245    Tracker,
246    /// The ground station's log, timed by `DATE` and `TIME`, one tracker's rows or several.
247    GroundStation,
248}
249
250/// A header line's layout, `None` if it is neither.
251///
252/// The opening names are checked first, one at a time, and the rest are walked without being
253/// collected, so that a long line of other text is turned away without a list of its names.
254fn layout(header: &str) -> Option<Layout> {
255    let mut names = header.split(',').map(str::trim);
256    let layout = match (names.next()?, names.next()?) {
257        ("UTCTIME", "UNIXTIME") => Layout::Tracker,
258        ("TRACKER", "DATE") if names.next()? == "TIME" => Layout::GroundStation,
259        _ => return None,
260    };
261    let (mut lat, mut lon, mut tracker_lat, mut tracker_lon) = (false, false, false, false);
262    for name in names {
263        match name {
264            "LAT" => lat = true,
265            "LON" => lon = true,
266            "TRACKER Lat" => tracker_lat = true,
267            "TRACKER Lon" => tracker_lon = true,
268            _ => {}
269        }
270    }
271    let position = (lat && lon) || (layout == Layout::GroundStation && tracker_lat && tracker_lon);
272    position.then_some(layout)
273}
274
275/// What a column is to this reader.
276#[derive(Debug, Clone, Copy, PartialEq)]
277enum Role {
278    /// `UTCTIME`: the tracker's clock, and a time-of-day channel.
279    Clock,
280    /// `DATE`: the ground station's clock's date.
281    Date,
282    /// `TIME`: the ground station's clock's time of day, and a time-of-day channel.
283    Time,
284    /// `TRACKER`: whose row it is.
285    Tracker,
286    /// Text that names, such as `Found rocket ID`: no channel.
287    Label,
288    /// A value of some kind.
289    Value(Kind),
290}
291
292/// The kinds of value column, each with how its unit is decided.
293#[derive(Debug, Clone, Copy, PartialEq)]
294enum Kind {
295    /// A height in feet by the manual, checked against the file: above sea level, or not.
296    Height { above_sea_level: bool },
297    /// `VERTV`.
298    VerticalSpeed,
299    /// `HORZV`.
300    HorizontalSpeed,
301    /// `HEAD`.
302    Heading,
303    /// `BATT`.
304    Battery,
305    /// `UNIXTIME`.
306    UnixTime,
307    /// A quantity whose unit the header states, or that has none, made SI by a factor.
308    Stated(Quantity, f64),
309    /// A name the reader doesn't know.
310    Unknown,
311}
312
313/// What a column is, by its trimmed name.
314fn role(name: &str) -> Role {
315    use Quantity as Q;
316    let value = |kind| Role::Value(kind);
317    let stated = |quantity, factor| Role::Value(Kind::Stated(quantity, factor));
318    match name {
319        "UTCTIME" => Role::Clock,
320        "DATE" => Role::Date,
321        "TIME" => Role::Time,
322        "TRACKER" => Role::Tracker,
323        "Found rocket ID" => Role::Label,
324        "UNIXTIME" => value(Kind::UnixTime),
325        "ALT" | "TRACKER Alt asl" | "GS Alt asl" | "Found rocket altitude" => value(Kind::Height {
326            above_sea_level: true,
327        }),
328        "Altitude AGL" | "GS_ASL_offset" => value(Kind::Height {
329            above_sea_level: false,
330        }),
331        "LAT" | "TRACKER Lat" | "GS Lat" | "Found rocket lat" => stated(Q::Latitude, 1.0),
332        "LON" | "TRACKER Lon" | "GS Lon" | "Found rocket lon" => stated(Q::Longitude, 1.0),
333        "HORZV" => value(Kind::HorizontalSpeed),
334        "VERTV" => value(Kind::VerticalSpeed),
335        "HEAD" => value(Kind::Heading),
336        "BATT" => value(Kind::Battery),
337        "GS_batt_v" | "Found rocket batt_v" => stated(Q::Voltage, 1.0),
338        "Distance (feet)" | "Alt AGL (ft)" | "Horizontal range (ft)" | "Total range (ft)" => {
339            stated(Q::Distance, FOOT_M)
340        }
341        "LoRa Max Range (kft)" => stated(Q::Distance, 1_000.0 * FOOT_M),
342        "#SATS" | "#TOT" | "FIX" | "FLAGS" | ">40" | ">32" | ">24" | "RSSI" | "RSSI Down"
343        | "RSSI Up" | "Found RSSI" | "Found rocket fix" | "Down Sent" | "Down Rcvd"
344        | "Found Sent" | "Up Sent" | "Up Rccvd" | "LoRa Success Rate" | "Launch detection"
345        | "Apogee detection" | "Landing detection" => stated(Q::Number, 1.0),
346        _ => value(Kind::Unknown),
347    }
348}
349
350/// One column of the header.
351struct Column {
352    name: String,
353    role: Role,
354    /// Cells that were empty or not a value, and the first of them as written.
355    gaps: usize,
356    gap_example: Option<String>,
357    /// Latitudes beyond ±90° or longitudes beyond ±180°, read as gaps.
358    out_of_range: usize,
359}
360
361/// One tracker's rows: their times and each column's values as written, the clock's columns
362/// holding the time of day.
363struct Set {
364    name: String,
365    times_ns: Vec<i64>,
366    values: Vec<Vec<f64>>,
367}
368
369/// A position's three columns, the altitude's if the file has it.
370#[derive(Debug, Clone, Copy)]
371struct Position {
372    lat: usize,
373    lon: usize,
374    alt: Option<usize>,
375    /// Rows read with the latitude and longitude both 0.
376    zeros: usize,
377}
378
379/// Reads a Featherweight GPS tracker's or ground station's CSV.
380///
381/// # Errors
382///
383/// [`LogError::NotThisFormat`] if the first line isn't one of the four headers;
384/// [`LogError::Syntax`] for a header column with no name, a row with more cells than the header,
385/// a row whose clock is missing or isn't a time, or rows holding under a quarter of the cells the
386/// header names; [`LogError::NoData`] if no row follows the header.
387pub fn read(text: &str) -> Result<FlightLog, LogError> {
388    let mut lines = numbered_lines(text).filter(|(_, line)| !line.trim().is_empty());
389    let Some((header_line, header)) = lines.next() else {
390        return Err(LogError::NotThisFormat {
391            format: FORMAT,
392            message: "the file is empty".to_owned(),
393        });
394    };
395    let Some(layout) = layout(header) else {
396        return Err(LogError::NotThisFormat {
397            format: FORMAT,
398            message: format!(
399                "its first line, {:?}, isn't a Featherweight GPS header, which opens \
400                 `UTCTIME,UNIXTIME` or `TRACKER,DATE,TIME`",
401                short(header)
402            ),
403        });
404    };
405    let mut notes = Notes::default();
406    let mut columns = Vec::new();
407    for (index, name) in header.split(',').map(str::trim).enumerate() {
408        if name.is_empty() {
409            return Err(syntax(
410                header_line,
411                format!("the header's column {} has no name", index + 1),
412            ));
413        }
414        let role = role(name);
415        if role == Role::Value(Kind::Unknown) {
416            notes.push(format!(
417                "the column {:?} isn't one this reader knows, and was kept as written",
418                short(name)
419            ));
420        }
421        columns.push(Column {
422            name: name.to_owned(),
423            role,
424            gaps: 0,
425            gap_example: None,
426            out_of_range: 0,
427        });
428    }
429    let find = |role: Role| columns.iter().position(|c| c.role == role);
430    // The layout's header check names these columns' places, so each is found.
431    let clock = match layout {
432        Layout::Tracker => Clock::Utc(find(Role::Clock).unwrap_or(0)),
433        Layout::GroundStation => Clock::DateTime {
434            date: find(Role::Date).unwrap_or(1),
435            time: find(Role::Time).unwrap_or(2),
436            tracker: find(Role::Tracker).unwrap_or(0),
437        },
438    };
439    let mut positions = positions(&columns);
440    let channels_per_set = columns
441        .iter()
442        .filter(|c| matches!(c.role, Role::Clock | Role::Time | Role::Value(_)))
443        .count();
444
445    let mut sets: Vec<Set> = Vec::new();
446    let mut set_of: HashMap<String, usize> = HashMap::new();
447    let mut left_out = LeftOut::default();
448    let (mut rows, mut cells_written) = (0usize, 0usize);
449    let (mut short_rows, mut first_short_row) = (0usize, 0usize);
450    let mut first_row: Option<(String, i64)> = None;
451    for (line_number, line) in lines {
452        let cells: Vec<&str> = line.split(',').map(str::trim).collect();
453        if cells.len() > columns.len() {
454            return Err(syntax(
455                line_number,
456                format!(
457                    "the row has {} cells, more than the header's {}",
458                    cells.len(),
459                    columns.len()
460                ),
461            ));
462        }
463        rows += 1;
464        cells_written += cells.len();
465        // Checked row by row, so that a wide header over short rows is refused before the gaps
466        // it would leave grow past a quarter more than the cells the file holds.
467        if too_sparse(columns.len(), rows, cells_written) {
468            return Err(syntax(
469                line_number,
470                format!(
471                    "the header names {} columns, and the {rows} rows down to this one hold only \
472                     {cells_written} cells",
473                    columns.len()
474                ),
475            ));
476        }
477        if cells.len() < columns.len() {
478            if short_rows == 0 {
479                first_short_row = line_number;
480            }
481            short_rows += 1;
482        }
483        let instant = clock.read(&cells, &columns, line_number)?;
484        if first_row.is_none() {
485            first_row = Some((clock.as_written(&cells), instant));
486        }
487        let tracker = clock.tracker(&cells);
488        let index = match set_of.get(tracker) {
489            Some(&index) => index,
490            None if sets.is_empty()
491                || (sets.len() < MAX_TRACKERS
492                    && (sets.len() + 1).saturating_mul(channels_per_set) <= MAX_CHANNELS) =>
493            {
494                set_of.insert(tracker.to_owned(), sets.len());
495                sets.push(Set {
496                    name: tracker.to_owned(),
497                    times_ns: Vec::new(),
498                    values: vec![Vec::new(); columns.len()],
499                });
500                sets.len() - 1
501            }
502            None => {
503                left_out.row(tracker);
504                continue;
505            }
506        };
507        let set = &mut sets[index];
508        set.times_ns.push(instant);
509        read_cells(set, &mut columns, &cells, instant);
510        for position in &mut positions {
511            no_fix(set, position);
512        }
513    }
514    let Some(first_row) = first_row else {
515        return Err(LogError::NoData { format: FORMAT });
516    };
517    let mut file_notes = Vec::new();
518    if short_rows > 0 {
519        file_notes.push(format!(
520            "{short_rows} rows, the first on line {first_short_row}, have fewer cells than the \
521             header's {}: the cells they leave out are gaps",
522            columns.len()
523        ));
524    }
525    if let Some(note) = left_out.note() {
526        file_notes.push(note);
527    }
528    Ok(build(
529        layout, &columns, sets, &positions, &first_row, notes, file_notes,
530    ))
531}
532
533/// Where a layout's clock is.
534#[derive(Debug, Clone, Copy)]
535enum Clock {
536    /// `UTCTIME`'s place.
537    Utc(usize),
538    /// `DATE`'s, `TIME`'s and `TRACKER`'s places.
539    DateTime {
540        date: usize,
541        time: usize,
542        tracker: usize,
543    },
544}
545
546impl Clock {
547    /// A row's time, ns from 1970-01-01 00:00 on the file's clock; a missing cell or one that
548    /// isn't a time is refused.
549    fn read(self, cells: &[&str], columns: &[Column], line: usize) -> Result<i64, LogError> {
550        let cell = |index: usize| {
551            cells.get(index).copied().ok_or_else(|| {
552                syntax(
553                    line,
554                    format!(
555                        "the row ends after {} cells, before its {:?}",
556                        cells.len(),
557                        short(&columns[index].name)
558                    ),
559                )
560            })
561        };
562        let refuse = |what: &str, cell: &str, index: usize| {
563            syntax(
564                line,
565                format!(
566                    "the {what} {:?} in cell {} isn't one",
567                    short(cell),
568                    index + 1
569                ),
570            )
571        };
572        match self {
573            Self::Utc(index) => {
574                let text = cell(index)?;
575                utc_time(text).ok_or_else(|| refuse("time", text, index))
576            }
577            Self::DateTime { date, time, .. } => {
578                let (date_text, time_text) = (cell(date)?, cell(time)?);
579                let day = civil_date(date_text).ok_or_else(|| refuse("date", date_text, date))?;
580                let of_day =
581                    time_of_day_ns(time_text).ok_or_else(|| refuse("time", time_text, time))?;
582                Ok(day * NS_PER_DAY + of_day)
583            }
584        }
585    }
586
587    /// The row's tracker: the trimmed `TRACKER`, or none for the tracker's own log.
588    fn tracker<'a>(self, cells: &[&'a str]) -> &'a str {
589        match self {
590            Self::Utc(_) => "",
591            Self::DateTime { tracker, .. } => cells.get(tracker).copied().unwrap_or_default(),
592        }
593    }
594
595    /// The clock's cells as the file writes them, cut short.
596    fn as_written(self, cells: &[&str]) -> String {
597        match self {
598            Self::Utc(index) => short(cells.get(index).copied().unwrap_or_default()),
599            Self::DateTime { date, time, .. } => short(&format!(
600                "{} {}",
601                cells.get(date).copied().unwrap_or_default(),
602                cells.get(time).copied().unwrap_or_default()
603            )),
604        }
605    }
606}
607
608/// Rows of trackers past the caps, counted.
609#[derive(Default)]
610struct LeftOut {
611    names: Vec<String>,
612    rows: usize,
613}
614
615impl LeftOut {
616    fn row(&mut self, tracker: &str) {
617        self.rows += 1;
618        // Counting names stops at the cap's size, so a file of many can't fill memory with them.
619        if self.names.len() < MAX_TRACKERS && !self.names.iter().any(|name| name == tracker) {
620            self.names.push(tracker.to_owned());
621        }
622    }
623
624    fn note(&self) -> Option<String> {
625        (self.rows > 0).then(|| {
626            let trackers = if self.names.len() < MAX_TRACKERS {
627                format!("{} more trackers", self.names.len())
628            } else {
629                format!("{MAX_TRACKERS} or more further trackers")
630            };
631            format!(
632                "{} rows of {trackers} were left out, past the first {MAX_TRACKERS} trackers or \
633                 {MAX_CHANNELS} channels, so that a file of many tracker names can't make the \
634                 reader hold a set of channels for each",
635                self.rows
636            )
637        })
638    }
639}
640
641/// Reads a row's cells into its tracker's columns as written: the clock's columns as the time of
642/// day, s, a value column as its cell's value or a gap.
643fn read_cells(set: &mut Set, columns: &mut [Column], cells: &[&str], instant: i64) {
644    for (index, column) in columns.iter_mut().enumerate() {
645        let value = match column.role {
646            Role::Date | Role::Tracker | Role::Label => continue,
647            Role::Clock => seconds_of_day(instant),
648            Role::Time => cells
649                .get(index)
650                .and_then(|cell| time_of_day_ns(cell))
651                .map_or(f64::NAN, |ns| ns as f64 / NS_PER_S as f64),
652            Role::Value(kind) => match cells.get(index) {
653                None => f64::NAN,
654                Some(cell) => match value(cell) {
655                    Some(value) if value.abs() > most_degrees(kind) => {
656                        column.out_of_range += 1;
657                        f64::NAN
658                    }
659                    Some(value) => value,
660                    None => {
661                        column.gaps += 1;
662                        if column.gap_example.is_none() {
663                            column.gap_example = Some(short(cell));
664                        }
665                        f64::NAN
666                    }
667                },
668            },
669        };
670        set.values[index].push(value);
671    }
672}
673
674/// The largest a value of a kind may be either side of 0: 90° for a latitude, 180° for a
675/// longitude, as kml.rs and nmea.rs read them; no bound for the rest.
676fn most_degrees(kind: Kind) -> f64 {
677    match kind {
678        Kind::Stated(Quantity::Latitude, _) => 90.0,
679        Kind::Stated(Quantity::Longitude, _) => 180.0,
680        _ => f64::INFINITY,
681    }
682}
683
684/// A cell's value as written: a decimal number, hexadecimal digits after `0x`, `TRUE` or `FALSE`
685/// as 1 or 0, or a number followed by `%`; `None` for anything else, `NaN` included.
686fn value(cell: &str) -> Option<f64> {
687    if let Some(value) = number(cell, false) {
688        return Some(value);
689    }
690    match cell {
691        "TRUE" => return Some(1.0),
692        "FALSE" => return Some(0.0),
693        _ => {}
694    }
695    if let Some(digits) = cell.strip_prefix("0x").or_else(|| cell.strip_prefix("0X")) {
696        if digits.is_empty() || digits.len() > 13 || !digits.bytes().all(|b| b.is_ascii_hexdigit())
697        {
698            return None;
699        }
700        // At most 13 hexadecimal digits, 52 bits, so the value is below 2⁵² and exact as an f64.
701        return u64::from_str_radix(digits, 16).ok().map(|bits| bits as f64);
702    }
703    cell.strip_suffix('%')
704        .filter(|rest| !rest.ends_with(char::is_whitespace))
705        .and_then(|rest| number(rest, false))
706}
707
708/// The positions the header holds: the tracker's, and in the longer ground-station log the ground
709/// station's and the found rocket's, each its latitude, longitude and altitude.
710fn positions(columns: &[Column]) -> Vec<Position> {
711    let place = |name: &str| columns.iter().position(|c| c.name == name);
712    [
713        ("LAT", "LON", "ALT"),
714        ("TRACKER Lat", "TRACKER Lon", "TRACKER Alt asl"),
715        ("GS Lat", "GS Lon", "GS Alt asl"),
716        (
717            "Found rocket lat",
718            "Found rocket lon",
719            "Found rocket altitude",
720        ),
721    ]
722    .into_iter()
723    .filter_map(|(lat, lon, alt)| {
724        Some(Position {
725            lat: place(lat)?,
726            lon: place(lon)?,
727            alt: place(alt),
728            zeros: 0,
729        })
730    })
731    .collect()
732}
733
734/// A no-fix row's position, its latitude and longitude both 0, read as gaps, and counted.
735fn no_fix(set: &mut Set, position: &mut Position) {
736    let last = |column: usize| set.values[column].last().copied();
737    if last(position.lat) == Some(0.0) && last(position.lon) == Some(0.0) {
738        position.zeros += 1;
739        for column in [Some(position.lat), Some(position.lon), position.alt]
740            .into_iter()
741            .flatten()
742        {
743            if let Some(value) = set.values[column].last_mut() {
744                *value = f64::NAN;
745            }
746        }
747    }
748}
749
750/// A tracker log's `UTCTIME`, such as `Jul 14 2031 17:04:31.250 UTC`, as ns from 1970-01-01 00:00
751/// UTC; `None` for anything else.
752fn utc_time(cell: &str) -> Option<i64> {
753    let mut parts = cell.split_whitespace();
754    let (month, day, year, time, zone) = (
755        parts.next()?,
756        parts.next()?,
757        parts.next()?,
758        parts.next()?,
759        parts.next()?,
760    );
761    if parts.next().is_some() || zone != "UTC" {
762        return None;
763    }
764    const MONTHS: [&str; 12] = [
765        "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
766    ];
767    let month = MONTHS
768        .iter()
769        .position(|name| name.eq_ignore_ascii_case(month))?;
770    let day = days(year, u32::try_from(month).ok()? + 1, day)?;
771    Some(day * NS_PER_DAY + time_of_day_ns(time)?)
772}
773
774/// A ground station's `DATE`, `YYYY-MM-DD`, as days from 1970-01-01; `None` for anything else.
775fn civil_date(cell: &str) -> Option<i64> {
776    let mut parts = cell.split('-');
777    let (year, month, day) = (parts.next()?, parts.next()?, parts.next()?);
778    if parts.next().is_some() || year.len() != 4 || !digits(month, 1..=2) {
779        return None;
780    }
781    days(year, month.parse().ok()?, day)
782}
783
784/// Whether text is all ASCII digits, as many as the range allows.
785fn digits(text: &str, count: std::ops::RangeInclusive<usize>) -> bool {
786    count.contains(&text.len()) && text.bytes().all(|b| b.is_ascii_digit())
787}
788
789/// A date's days from 1970-01-01, the year four digits within [`YEARS`] and the day one or two
790/// digits within the month (leap years by the Gregorian rule).
791fn days(year: &str, month: u32, day: &str) -> Option<i64> {
792    if !digits(year, 4..=4) || !digits(day, 1..=2) {
793        return None;
794    }
795    let year: i64 = year.parse().ok()?;
796    let day: u32 = day.parse().ok()?;
797    let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
798    let length = match month {
799        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
800        4 | 6 | 9 | 11 => 30,
801        2 if leap => 29,
802        2 => 28,
803        _ => return None,
804    };
805    (YEARS.contains(&year) && (1..=length).contains(&day))
806        .then(|| days_from_civil(year, month, day))
807}
808
809/// Days from 1970-01-01 to a date of the proleptic Gregorian calendar, by Howard Hinnant's
810/// `days_from_civil` ("chrono-Compatible Low-Level Date Algorithms", 2013, public domain): the
811/// year counted from March, so that February's leap day falls at its end.
812fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
813    let year = if month <= 2 { year - 1 } else { year };
814    let era = year.div_euclid(400);
815    let year_of_era = year - era * 400;
816    let day_of_year = (153 * i64::from((month + 9) % 12) + 2) / 5 + i64::from(day) - 1;
817    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
818    era * 146_097 + day_of_era - 719_468
819}
820
821/// A time of day, `H:MM:SS` or `HH:MM:SS` with up to nine decimals, as ns after midnight; `None`
822/// for anything else, or a time past 23:59:60.999 999 999 (a leap second's).
823fn time_of_day_ns(cell: &str) -> Option<i64> {
824    let mut parts = cell.split(':');
825    let (hours, minutes, seconds) = (parts.next()?, parts.next()?, parts.next()?);
826    if parts.next().is_some() {
827        return None;
828    }
829    let (whole, fraction) = seconds.split_once('.').unwrap_or((seconds, ""));
830    if !digits(hours, 1..=2)
831        || !digits(minutes, 2..=2)
832        || !digits(whole, 2..=2)
833        || !(fraction.is_empty() || digits(fraction, 1..=9))
834        || (seconds.contains('.') && fraction.is_empty())
835    {
836        return None;
837    }
838    let (hours, minutes, whole): (i64, i64, i64) = (
839        hours.parse().ok()?,
840        minutes.parse().ok()?,
841        whole.parse().ok()?,
842    );
843    if hours >= 24 || minutes >= 60 || whole > 60 {
844        return None;
845    }
846    let mut nanos: i64 = 0;
847    for (place, digit) in fraction.bytes().enumerate() {
848        // At most nine digits, each place a tenth of the one before, from 10⁸ ns.
849        nanos += i64::from(digit - b'0') * 10_i64.pow(8 - place as u32);
850    }
851    Some(((hours * 60 + minutes) * 60 + whole) * NS_PER_S + nanos)
852}
853
854/// A time's seconds after its midnight.
855fn seconds_of_day(instant: i64) -> f64 {
856    instant.rem_euclid(NS_PER_DAY) as f64 / NS_PER_S as f64
857}
858
859/// Seconds from one time to another, both ns on one clock.
860fn seconds_between(from: i64, to: i64) -> f64 {
861    // Both times lie within YEARS, so their difference fits an i64 of nanoseconds.
862    (to - from) as f64 / NS_PER_S as f64
863}
864
865/// The record: every tracker's channels, the decided units, and the notes.
866fn build(
867    layout: Layout,
868    columns: &[Column],
869    sets: Vec<Set>,
870    positions: &[Position],
871    first_row: &(String, i64),
872    notes: Notes,
873    file_notes: Vec<String>,
874) -> FlightLog {
875    let decisions = Decisions::from_file(columns, &sets);
876    let mut log = FlightLog {
877        format: LogFormat::FeatherweightGpsCsv,
878        logger: match layout {
879            Layout::Tracker => "Featherweight GPS tracker",
880            Layout::GroundStation => "Featherweight GPS ground station",
881        }
882        .to_owned(),
883        serial_number: None,
884        firmware: None,
885        flight_number: None,
886        stated: Stated::default(),
887        time_s: Vec::new(),
888        altitude_m: Vec::new(),
889        vertical_speed_m_s: None,
890        temperature_k: None,
891        battery_v: None,
892        channels: Vec::new(),
893        events: Vec::new(),
894        notes: decisions.notes.clone(),
895    };
896    log.notes.push(
897        "the log has no barometer, so the record's altitude track is empty: a GPS tracker's file \
898         holds positions only, and every column is a channel"
899            .to_owned(),
900    );
901    log.notes
902        .push(clock_note(layout, &sets, positions, first_row));
903    if let Some(note) = steps_back_note(&sets) {
904        log.notes.push(note);
905    }
906    if layout == Layout::GroundStation {
907        log.notes.push(trackers_note(&sets));
908    }
909    for position in positions.iter().filter(|p| p.zeros > 0) {
910        let names: Vec<String> = [Some(position.lat), Some(position.lon), position.alt]
911            .into_iter()
912            .flatten()
913            .map(|index| format!("`{}`", columns[index].name))
914            .collect();
915        log.notes.push(format!(
916            "{} rows have `{}` and `{}` both 0, as a receiver writes before it has a fix: their \
917             {} are read as gaps",
918            position.zeros,
919            columns[position.lat].name,
920            columns[position.lon].name,
921            list(&names)
922        ));
923    }
924    log.notes.extend(file_notes);
925    let mut notes = notes;
926    for column in columns.iter().filter(|c| c.gaps > 0) {
927        notes.push(format!(
928            "the column {:?} holds {} cells that are empty or not a number, such as {:?}: they \
929             are gaps",
930            short(&column.name),
931            column.gaps,
932            column.gap_example.as_deref().unwrap_or_default()
933        ));
934    }
935    for column in columns.iter().filter(|c| c.out_of_range > 0) {
936        let (what, most) = match column.role {
937            Role::Value(Kind::Stated(Quantity::Latitude, _)) => ("latitudes", "±90°"),
938            _ => ("longitudes", "±180°"),
939        };
940        notes.push(format!(
941            "the column {:?} holds {} {what} beyond {most}: they are gaps",
942            short(&column.name),
943            column.out_of_range
944        ));
945    }
946    log.notes.extend(notes.into_vec());
947
948    let zero = first_row.1;
949    let qualify = sets.len() > 1;
950    for set in sets {
951        let time_s: Vec<f64> = set
952            .times_ns
953            .iter()
954            .map(|&instant| seconds_between(zero, instant))
955            .collect();
956        for (column, values) in columns.iter().zip(set.values) {
957            let Some(quantity) = decisions.quantity(column.role) else {
958                continue;
959            };
960            let factor = decisions.factor(column.role);
961            let name = if qualify {
962                format!("{} [{}]", short(&column.name), short(&set.name))
963            } else {
964                column.name.clone()
965            };
966            log.channels.push(Channel {
967                column: name,
968                quantity,
969                time_s: time_s.clone(),
970                values: values.into_iter().map(|value| value * factor).collect(),
971            });
972        }
973    }
974    log
975}
976
977/// The note on the log's clock: its zero, and for the ground station the zone it doesn't state.
978fn clock_note(
979    layout: Layout,
980    sets: &[Set],
981    positions: &[Position],
982    first_row: &(String, i64),
983) -> String {
984    match layout {
985        Layout::Tracker => format!(
986            "the log's clock is `UTCTIME`, read as UTC, and its zero is the first row's, {:?}",
987            first_row.0
988        ),
989        Layout::GroundStation => {
990            let solar = first_fix(sets, positions).map_or_else(
991                || "the file has no fix to compare its `TIME` with the sun's".to_owned(),
992                |(instant, longitude)| {
993                    format!(
994                        "at the first fix, `TIME` is {}, which read as UTC would be {:.1} h of \
995                         local mean solar time at the fix's longitude",
996                        clock_text(instant),
997                        solar_hours(instant, longitude)
998                    )
999                },
1000            );
1001            format!(
1002                "`DATE` and `TIME` kept as written, in a time zone the file doesn't state: they \
1003                 are the log's clock, and its zero is the first row's, {:?}; {solar}",
1004                first_row.0
1005            )
1006        }
1007    }
1008}
1009
1010/// The first row, in the file's order, whose tracker position has a fix: its time and longitude.
1011fn first_fix(sets: &[Set], positions: &[Position]) -> Option<(i64, f64)> {
1012    let position = positions.first()?;
1013    sets.iter()
1014        .filter_map(|set| {
1015            (0..set.times_ns.len())
1016                .find(|&row| {
1017                    set.values[position.lat][row].is_finite()
1018                        && set.values[position.lon][row].is_finite()
1019                })
1020                .map(|row| (set.times_ns[row], set.values[position.lon][row]))
1021        })
1022        .min_by_key(|(instant, _)| *instant)
1023}
1024
1025/// A time's time of day, `HH:MM:SS.fff`, to the millisecond below it.
1026fn clock_text(instant: i64) -> String {
1027    let millis = instant.rem_euclid(NS_PER_DAY) / 1_000_000;
1028    format!(
1029        "{:02}:{:02}:{:02}.{:03}",
1030        millis / 3_600_000,
1031        millis / 60_000 % 60,
1032        millis / 1_000 % 60,
1033        millis % 1_000
1034    )
1035}
1036
1037/// The local mean solar time at a longitude, h, of a time read as UTC: the time of day plus the
1038/// longitude at 15° an hour, the Earth's mean turn, without the equation of time's quarter hour.
1039fn solar_hours(instant: i64, longitude_deg: f64) -> f64 {
1040    (seconds_of_day(instant) / 3_600.0 + longitude_deg / 15.0).rem_euclid(24.0)
1041}
1042
1043/// The note on times that go back or repeat within a tracker's rows, if any do.
1044fn steps_back_note(sets: &[Set]) -> Option<String> {
1045    let (mut backs, mut largest_ns, mut repeats) = (0usize, 0i64, 0usize);
1046    for set in sets {
1047        for pair in set.times_ns.windows(2) {
1048            if pair[1] < pair[0] {
1049                backs += 1;
1050                largest_ns = largest_ns.max(pair[0] - pair[1]);
1051            }
1052        }
1053        let mut sorted = set.times_ns.clone();
1054        sorted.sort_unstable();
1055        repeats += sorted.windows(2).filter(|pair| pair[0] == pair[1]).count();
1056    }
1057    let kept = "every row is kept, in the file's order";
1058    if backs > 0 {
1059        Some(format!(
1060            "times go back {backs} times from one row to the next, by up to {:.3} s, and {repeats} \
1061             rows repeat a time already written: {kept}",
1062            largest_ns as f64 / NS_PER_S as f64
1063        ))
1064    } else {
1065        (repeats > 0).then(|| format!("{repeats} rows repeat a time already written: {kept}"))
1066    }
1067}
1068
1069/// The note naming a ground station's trackers.
1070fn trackers_note(sets: &[Set]) -> String {
1071    match sets {
1072        [one] => format!(
1073            "the file holds one tracker's rows, {:?}, so its channels keep the header's names",
1074            short(&one.name)
1075        ),
1076        _ => {
1077            let names: Vec<String> = sets
1078                .iter()
1079                .map(|set| format!("{:?} ({} rows)", short(&set.name), set.times_ns.len()))
1080                .collect();
1081            format!(
1082                "the file holds {} trackers' rows, each its own set of channels named with the \
1083                 tracker in brackets, in the order they first appear: {}",
1084                sets.len(),
1085                list(&names)
1086            )
1087        }
1088    }
1089}
1090
1091/// The most names a note lists; past it, the rest are counted.
1092const MAX_LISTED: usize = 6;
1093
1094/// Names joined as `a`, `a and b`, or `a, b and c`; past [`MAX_LISTED`], the first few and a
1095/// count of the rest.
1096fn list(names: &[String]) -> String {
1097    if names.len() > MAX_LISTED {
1098        let shown = MAX_LISTED - 1;
1099        return format!(
1100            "{} and {} more",
1101            names[..shown].join(", "),
1102            names.len() - shown
1103        );
1104    }
1105    match names {
1106        [] => String::new(),
1107        [only] => only.clone(),
1108        [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
1109    }
1110}
1111
1112/// What a speed check found.
1113#[derive(Debug, Clone, Copy, PartialEq)]
1114enum Check {
1115    /// The median ratio and the pairs it is over, in the band.
1116    Agrees(f64, usize),
1117    /// The same, outside the band.
1118    Disagrees(f64, usize),
1119    /// Fewer than [`MIN_PAIRS`] pairs, or no column to check.
1120    TooFew(usize),
1121}
1122
1123impl Check {
1124    fn from_ratios(mut ratios: Vec<f64>) -> Self {
1125        if ratios.len() < MIN_PAIRS {
1126            return Self::TooFew(ratios.len());
1127        }
1128        ratios.sort_by(f64::total_cmp);
1129        let median = ratios[ratios.len() / 2];
1130        if (AGREES.0..=AGREES.1).contains(&median) {
1131            Self::Agrees(median, ratios.len())
1132        } else {
1133            Self::Disagrees(median, ratios.len())
1134        }
1135    }
1136
1137    /// What the check found, such as `the change of `ALT` a second is a median of 1.012 times
1138    /// `VERTV` over 432 pairs of rows`, the change named by `change`.
1139    fn words(self, change: &str, speed: &str) -> String {
1140        match self {
1141            Self::Agrees(median, pairs) | Self::Disagrees(median, pairs) => format!(
1142                "{change} is a median of {median:.3} times `{speed}` over {pairs} pairs of rows"
1143            ),
1144            Self::TooFew(pairs) => format!(
1145                "the file has too few pairs of moving rows to check `{speed}` over, {pairs} of the \
1146                 {MIN_PAIRS} needed"
1147            ),
1148        }
1149    }
1150}
1151
1152/// What a speed is checked against.
1153#[derive(Debug, Clone, Copy)]
1154enum Track {
1155    /// An altitude column's change, as written.
1156    Height(usize),
1157    /// A position's great-circle distance, ft.
1158    Position(usize, usize),
1159}
1160
1161impl Track {
1162    /// The row's point, `None` if it has a gap.
1163    fn at(self, set: &Set, row: usize) -> Option<(f64, f64)> {
1164        let (a, b) = match self {
1165            Self::Height(column) => (set.values[column][row], 0.0),
1166            Self::Position(lat, lon) => (set.values[lat][row], set.values[lon][row]),
1167        };
1168        (a.is_finite() && b.is_finite()).then_some((a, b))
1169    }
1170
1171    /// The change from one point to another: up, as written, or a distance, ft.
1172    fn change(self, from: (f64, f64), to: (f64, f64)) -> f64 {
1173        match self {
1174            Self::Height(_) => to.0 - from.0,
1175            Self::Position(..) => great_circle_m(from, to) / FOOT_M,
1176        }
1177    }
1178}
1179
1180/// The great-circle distance between two positions in degrees, m, on a sphere of
1181/// [`EARTH_RADIUS_M`], by the haversine formula (Sinnott, "Virtues of the Haversine", *Sky and
1182/// Telescope* 68(2), 1984, p159).
1183fn great_circle_m(from: (f64, f64), to: (f64, f64)) -> f64 {
1184    let (lat1, lat2) = (from.0.to_radians(), to.0.to_radians());
1185    let (half_dlat, half_dlon) = ((lat2 - lat1) / 2.0, (to.1 - from.1).to_radians() / 2.0);
1186    let h = half_dlat.sin().powi(2) + lat1.cos() * lat2.cos() * half_dlon.sin().powi(2);
1187    2.0 * EARTH_RADIUS_M * h.sqrt().min(1.0).asin()
1188}
1189
1190/// The change of a track a second over a speed column, for each pair of rows a fix apart taken in
1191/// time order within each tracker: from the last row whose point differed, as the ground station
1192/// repeats a fix it hasn't heard anew, to the next that differs, more than [`MIN_STEP_S`] and
1193/// at most [`MAX_STEP_S`] later, at a speed of at least [`MIN_SPEED`] as written.
1194fn speed_check(sets: &[Set], track: Track, speed: usize) -> Check {
1195    let mut ratios = Vec::new();
1196    for set in sets {
1197        let mut order: Vec<usize> = (0..set.times_ns.len()).collect();
1198        order.sort_by_key(|&row| set.times_ns[row]);
1199        let mut start: Option<(usize, (f64, f64))> = None;
1200        for row in order {
1201            let Some(point) = track.at(set, row) else {
1202                start = None;
1203                continue;
1204            };
1205            if let Some((from, from_point)) = start {
1206                if point == from_point {
1207                    continue;
1208                }
1209                let step_s = seconds_between(set.times_ns[from], set.times_ns[row]);
1210                let speed = set.values[speed][row];
1211                if step_s > MIN_STEP_S && step_s <= MAX_STEP_S && speed.abs() >= MIN_SPEED {
1212                    ratios.push(track.change(from_point, point) / step_s / speed);
1213                }
1214            }
1215            start = Some((row, point));
1216        }
1217    }
1218    Check::from_ratios(ratios)
1219}
1220
1221/// Each decided kind: whether it is read in its unit, and the file's notes on them.
1222struct Decisions {
1223    heights: bool,
1224    vertical: bool,
1225    horizontal: bool,
1226    heading: bool,
1227    battery: bool,
1228    notes: Vec<String>,
1229}
1230
1231impl Decisions {
1232    fn from_file(columns: &[Column], sets: &[Set]) -> Self {
1233        let place = |name: &str| columns.iter().position(|c| c.name == name);
1234        let named = |kind: Kind| -> Vec<String> {
1235            columns
1236                .iter()
1237                .filter(|c| c.role == Role::Value(kind))
1238                .map(|c| format!("`{}`", short(&c.name)))
1239                .collect()
1240        };
1241        let values = |kind: Kind| {
1242            columns
1243                .iter()
1244                .enumerate()
1245                .filter(move |(_, c)| c.role == Role::Value(kind))
1246                .flat_map(move |(index, _)| sets.iter().flat_map(move |s| s.values[index].iter()))
1247                .copied()
1248                .filter(|value| value.is_finite())
1249        };
1250        let mut notes = Vec::new();
1251        let altitude = place("ALT").or_else(|| place("TRACKER Alt asl"));
1252        let position = place("LAT")
1253            .zip(place("LON"))
1254            .or_else(|| place("TRACKER Lat").zip(place("TRACKER Lon")));
1255        let altitude_name = altitude.map_or("ALT", |index| columns[index].name.as_str());
1256
1257        // One rule decides the file's lengths. `HORZV` against the distance between fixes is the
1258        // only check tied to an absolute length, so it and the manual decide the unit of every
1259        // length and speed the manual gives in feet; `VERTV` against the change of altitude only
1260        // shows whether `VERTV` is in the altitude's unit a second.
1261        let horizontal = match (position, place("HORZV")) {
1262            (Some((lat, lon)), Some(speed)) => speed_check(sets, Track::Position(lat, lon), speed),
1263            _ => Check::TooFew(0),
1264        };
1265        let vertical = match (altitude, place("VERTV")) {
1266            (Some(altitude), Some(speed)) => speed_check(sets, Track::Height(altitude), speed),
1267            _ => Check::TooFew(0),
1268        };
1269        let alt_agl = alt_agl_check(columns, sets);
1270        let feet = !matches!(horizontal, Check::Disagrees(..));
1271        let kept = if !feet {
1272            Some(Kept::HorizontalSpeed)
1273        } else if alt_agl.is_some_and(|check| !check.agrees()) {
1274            Some(Kept::AltAgl)
1275        } else if matches!(vertical, Check::Disagrees(..)) {
1276            Some(Kept::VerticalSpeed)
1277        } else {
1278            None
1279        };
1280        let horizontal_words =
1281            horizontal.words("the distance between fixes a second, in feet,", "HORZV");
1282        let vertical_words = vertical.words(
1283            &format!("the change of `{altitude_name}` a second"),
1284            "VERTV",
1285        );
1286
1287        if place("HORZV").is_some() {
1288            notes.push(match horizontal {
1289                Check::Agrees(..) => format!(
1290                    "`HORZV` read in feet per second, from the manual and the distance between \
1291                     fixes: {horizontal_words}"
1292                ),
1293                Check::TooFew(_) => format!(
1294                    "`HORZV` read in feet per second, from the manual alone: {horizontal_words}"
1295                ),
1296                Check::Disagrees(..) => format!(
1297                    "`HORZV` kept as written, the distance between fixes disagrees with the \
1298                     manual's feet per second: {horizontal_words}; every length and speed the \
1299                     manual gives in feet is kept as written with it"
1300                ),
1301            });
1302        }
1303
1304        let mut heights = named(Kind::Height {
1305            above_sea_level: true,
1306        });
1307        heights.extend(named(Kind::Height {
1308            above_sea_level: false,
1309        }));
1310        if !heights.is_empty() {
1311            let names = list(&heights);
1312            let mut evidence = vec![horizontal_words.clone()];
1313            if place("VERTV").is_some() {
1314                evidence.push(vertical_words.clone());
1315            }
1316            evidence.extend(alt_agl.map(AltAgl::words));
1317            evidence.extend(altitude_agl_words(columns, sets));
1318            let evidence = evidence.join("; ");
1319            notes.push(match kept {
1320                Some(why) => format!("{names} kept as written, {}: {evidence}", why.words()),
1321                None => {
1322                    let by_horizontal = matches!(horizontal, Check::Agrees(..));
1323                    let by_alt_agl = alt_agl.is_some_and(|check| check.rows > 0);
1324                    let source = match (by_horizontal, by_alt_agl) {
1325                        (true, false) => "from the manual and the horizontal speed",
1326                        (false, false) => "from the manual alone",
1327                        (true, true) => "from the manual, the horizontal speed and `Alt AGL (ft)`",
1328                        (false, true) => "from the manual and `Alt AGL (ft)`",
1329                    };
1330                    format!("{names} read in feet, {source}: {evidence}")
1331                }
1332            });
1333        }
1334
1335        if place("VERTV").is_some() {
1336            notes.push(match (kept, altitude, vertical) {
1337                (Some(Kept::VerticalSpeed), ..) => format!(
1338                    "`VERTV` kept as written, not in the altitude's unit a second: \
1339                     {vertical_words}"
1340                ),
1341                (Some(why), ..) => {
1342                    format!("`VERTV` kept as written, {}: {vertical_words}", why.words())
1343                }
1344                (None, None, _) => "`VERTV` read in feet per second, from the manual alone: the \
1345                                    file has no altitude to compare it with"
1346                    .to_owned(),
1347                (None, Some(_), Check::TooFew(_)) => format!(
1348                    "`VERTV` read in feet per second, in the altitude's unit, from the \
1349                     manual alone: {vertical_words}"
1350                ),
1351                (None, Some(_), _) => format!(
1352                    "`VERTV` read in feet per second, in the altitude's unit, from the \
1353                     change of altitude: {vertical_words}"
1354                ),
1355            });
1356        }
1357        let heights_read = kept.is_none();
1358
1359        let heading = values(Kind::Heading).fold(None, range);
1360        let heading_read = heading.is_none_or(|(lo, hi): (f64, f64)| lo >= -360.0 && hi <= 360.0);
1361        if place("HEAD").is_some() {
1362            let evidence = heading.map_or_else(
1363                || "the column holds no value; the manual gives degrees from north".to_owned(),
1364                |(lo, hi)| format!("it runs from {lo} to {hi}"),
1365            );
1366            notes.push(if heading_read {
1367                format!("`HEAD` read in degrees, from the manual and its range: {evidence}")
1368            } else {
1369                format!(
1370                    "`HEAD` kept as written, its range disagrees with the manual's degrees: \
1371                     {evidence}"
1372                )
1373            });
1374        }
1375
1376        let battery = values(Kind::Battery).fold(None, range);
1377        let battery_read = battery.is_none_or(|(lo, hi): (f64, f64)| lo >= 0.0 && hi <= MAX_CELL_V);
1378        if place("BATT").is_some() {
1379            let evidence = match battery {
1380                None => "the column holds no value".to_owned(),
1381                Some((lo, hi)) if lo == 0.0 && hi == 0.0 => {
1382                    "it reads 0 on every row, which no unit changes".to_owned()
1383                }
1384                Some((lo, hi)) => format!(
1385                    "it runs from {lo} to {hi}, where one lithium cell reads up to about 4.2 V and \
1386                     the ground station's serial millivolts would be thousands"
1387                ),
1388            };
1389            notes.push(if battery_read {
1390                format!("`BATT` read in volts, from its range: {evidence}")
1391            } else {
1392                format!(
1393                    "`BATT` kept as written, its range isn't one lithium cell's volts: {evidence}"
1394                )
1395            });
1396        }
1397
1398        if let Some(note) = unix_note(columns, sets) {
1399            notes.push(note);
1400        }
1401        Self {
1402            heights: heights_read,
1403            vertical: heights_read,
1404            horizontal: feet,
1405            heading: heading_read,
1406            battery: battery_read,
1407            notes,
1408        }
1409    }
1410
1411    /// A column's quantity, `None` for one that is no channel.
1412    fn quantity(&self, role: Role) -> Option<Quantity> {
1413        let decided = |read: bool, quantity| {
1414            if read {
1415                quantity
1416            } else {
1417                Quantity::Unrecognized
1418            }
1419        };
1420        Some(match role {
1421            Role::Date | Role::Tracker | Role::Label => return None,
1422            Role::Clock | Role::Time => Quantity::TimeOfDay,
1423            Role::Value(kind) => match kind {
1424                Kind::Height {
1425                    above_sea_level: true,
1426                } => decided(self.heights, Quantity::GnssAltitude),
1427                Kind::Height {
1428                    above_sea_level: false,
1429                } => decided(self.heights, Quantity::Distance),
1430                Kind::VerticalSpeed => decided(self.vertical, Quantity::Speed),
1431                Kind::HorizontalSpeed => decided(self.horizontal, Quantity::Speed),
1432                Kind::Heading => decided(self.heading, Quantity::Angle),
1433                Kind::Battery => decided(self.battery, Quantity::Voltage),
1434                Kind::UnixTime | Kind::Unknown => Quantity::Unrecognized,
1435                Kind::Stated(quantity, _) => quantity,
1436            },
1437        })
1438    }
1439
1440    /// A column's factor into SI, 1 for one kept as written.
1441    fn factor(&self, role: Role) -> f64 {
1442        match role {
1443            Role::Value(Kind::Height { .. }) if self.heights => FOOT_M,
1444            Role::Value(Kind::VerticalSpeed) if self.vertical => FOOT_M,
1445            Role::Value(Kind::HorizontalSpeed) if self.horizontal => FOOT_M,
1446            Role::Value(Kind::Stated(_, factor)) => factor,
1447            _ => 1.0,
1448        }
1449    }
1450}
1451
1452/// The lowest and highest of a list, folded.
1453fn range(acc: Option<(f64, f64)>, value: f64) -> Option<(f64, f64)> {
1454    Some(acc.map_or((value, value), |(lo, hi)| (lo.min(value), hi.max(value))))
1455}
1456
1457/// Why the heights and `VERTV` are kept as written, in the order the rule asks.
1458#[derive(Debug, Clone, Copy, PartialEq)]
1459enum Kept {
1460    /// `HORZV` disagrees with the distance between fixes in feet: the file's lengths aren't the
1461    /// manual's feet.
1462    HorizontalSpeed,
1463    /// `Alt AGL (ft)`, whose header says feet, isn't the heights' difference.
1464    AltAgl,
1465    /// `VERTV` isn't the change of the altitude a second.
1466    VerticalSpeed,
1467}
1468
1469impl Kept {
1470    /// Why, as a heights' note says before its colon.
1471    fn words(self) -> &'static str {
1472        match self {
1473            Self::HorizontalSpeed => "the horizontal speed disagrees with the manual's feet",
1474            Self::AltAgl => "`Alt AGL (ft)` disagrees with the manual's feet",
1475            Self::VerticalSpeed => "`VERTV` isn't their change a second",
1476        }
1477    }
1478}
1479
1480/// The longer ground-station log's check of its heights: on how many rows `Alt AGL (ft)`, whose
1481/// header says feet, is `TRACKER Alt asl` less `GS Alt asl` and `GS_ASL_offset`, to 0.15 ft.
1482#[derive(Debug, Clone, Copy, PartialEq)]
1483struct AltAgl {
1484    held: usize,
1485    rows: usize,
1486}
1487
1488impl AltAgl {
1489    /// Whether the heights are the header's feet: the difference holds on half the rows or more,
1490    /// or there is no row to check.
1491    fn agrees(self) -> bool {
1492        self.rows == 0 || self.held * 2 >= self.rows
1493    }
1494
1495    fn words(self) -> String {
1496        format!(
1497            "`Alt AGL (ft)` is `TRACKER Alt asl` less `GS Alt asl` and `GS_ASL_offset`, to 0.15 ft, \
1498             on {} of {} rows",
1499            self.held, self.rows
1500        )
1501    }
1502}
1503
1504/// The longer ground-station log's [`AltAgl`] check, `None` for a file without its four columns.
1505fn alt_agl_check(columns: &[Column], sets: &[Set]) -> Option<AltAgl> {
1506    let place = |name: &str| columns.iter().position(|c| c.name == name);
1507    let ((agl, tracker), (station, offset)) =
1508        place("Alt AGL (ft)")
1509            .zip(place("TRACKER Alt asl"))
1510            .zip(place("GS Alt asl").zip(place("GS_ASL_offset")))?;
1511    let (mut held, mut rows) = (0usize, 0usize);
1512    for set in sets {
1513        for row in 0..set.times_ns.len() {
1514            let at = |column: usize| set.values[column][row];
1515            let residual = at(agl) - (at(tracker) - at(station) - at(offset));
1516            if residual.is_finite() {
1517                rows += 1;
1518                held += usize::from(residual.abs() <= 0.15);
1519            }
1520        }
1521    }
1522    Some(AltAgl { held, rows })
1523}
1524
1525/// What the newer tracker log's `Altitude AGL` shows: on how many rows it is `ALT` less one
1526/// height, the pad's. It doesn't decide a unit, as both are in the file's one unit.
1527fn altitude_agl_words(columns: &[Column], sets: &[Set]) -> Option<String> {
1528    let place = |name: &str| columns.iter().position(|c| c.name == name);
1529    let (altitude, agl) = place("ALT").zip(place("Altitude AGL"))?;
1530    let mut differences: Vec<i64> = sets
1531        .iter()
1532        .flat_map(|set| {
1533            set.values[altitude]
1534                .iter()
1535                .zip(&set.values[agl])
1536                .map(|(alt, agl)| alt - agl)
1537        })
1538        .filter(|difference| difference.is_finite())
1539        // Tenths of a foot, as the newer log writes both; a difference past an i64's span
1540        // saturates and so matches no other.
1541        .map(|difference| (difference * 10.0).round() as i64)
1542        .collect();
1543    differences.sort_unstable();
1544    let rows = differences.len();
1545    let most = differences
1546        .chunk_by(|a, b| a == b)
1547        .map(<[i64]>::len)
1548        .max()
1549        .unwrap_or(0);
1550    Some(format!(
1551        "`Altitude AGL` is `ALT` less one height, to 0.05 ft, on {most} of {rows} rows, as a \
1552         height above the pad is"
1553    ))
1554}
1555
1556/// The note on `UNIXTIME`: how far it runs from `UTCTIME`, and where each would put the sun.
1557fn unix_note(columns: &[Column], sets: &[Set]) -> Option<String> {
1558    let unix = columns
1559        .iter()
1560        .position(|c| c.role == Role::Value(Kind::UnixTime))?;
1561    // The offsets are taken from each row's time, which is `UTCTIME`'s in a tracker's log.
1562    columns.iter().find(|c| c.role == Role::Clock)?;
1563    let lon = columns.iter().position(|c| c.name == "LON");
1564    let mut offsets: Vec<f64> = Vec::new();
1565    let mut solar: Option<(f64, f64)> = None;
1566    for set in sets {
1567        for (row, &instant) in set.times_ns.iter().enumerate() {
1568            let written = set.values[unix][row];
1569            if !written.is_finite() {
1570                continue;
1571            }
1572            let utc_s = instant.div_euclid(NS_PER_S) as f64
1573                + instant.rem_euclid(NS_PER_S) as f64 / NS_PER_S as f64;
1574            offsets.push(written - utc_s);
1575            if solar.is_none()
1576                && let Some(longitude) = lon.map(|lon| set.values[lon][row])
1577                && longitude.is_finite()
1578            {
1579                let unix_of_day = written.rem_euclid(86_400.0) / 3_600.0;
1580                solar = Some((
1581                    solar_hours(instant, longitude),
1582                    (unix_of_day + longitude / 15.0).rem_euclid(24.0),
1583                ));
1584            }
1585        }
1586    }
1587    let hours = offsets.first().map(|offset| (offset / 3_600.0).round())?;
1588    // Cells written to the millisecond, so a whole number of hours is met to well within 0.01 s.
1589    let whole = offsets
1590        .iter()
1591        .all(|offset| (offset - hours * 3_600.0).abs() < 0.01);
1592    let sun = solar.map_or_else(String::new, |(utc, unix)| {
1593        format!(
1594            "; read as UTC at the first fix's longitude, `UTCTIME` gives {utc:.1} h of local mean \
1595             solar time and `UNIXTIME` {unix:.1} h"
1596        )
1597    });
1598    Some(if whole {
1599        let offset = if hours >= 0.0 {
1600            format!("{hours} h ahead of")
1601        } else {
1602            format!("{} h behind", -hours)
1603        };
1604        format!(
1605            "`UNIXTIME` kept as written, off `UTCTIME` by whole hours: it runs {offset} \
1606             `UTCTIME` on every row{sun}"
1607        )
1608    } else {
1609        let (lo, hi) = offsets
1610            .iter()
1611            .copied()
1612            .fold(None, range)
1613            .unwrap_or_default();
1614        format!(
1615            "`UNIXTIME` kept as written, off `UTCTIME` by other than one whole number of hours: \
1616             it runs from {lo:.3} s to {hi:.3} s ahead of `UTCTIME`{sun}"
1617        )
1618    })
1619}
1620
1621fn syntax(line: usize, message: String) -> LogError {
1622    LogError::Syntax {
1623        format: FORMAT,
1624        line,
1625        message,
1626    }
1627}
1628
1629#[cfg(test)]
1630mod tests {
1631    use super::*;
1632
1633    const OLDER: &str =
1634        "UTCTIME,UNIXTIME,ALT,LAT,LON,#SATS,FIX,HORZV,VERTV,HEAD,FLAGS,>40,>32,>24,RSSI,BATT";
1635    const GROUND: &str =
1636        "TRACKER,DATE,TIME,LAT,LON,ALT,FIX,HORZV,VERTV,HEAD,FLAGS,#TOT,>40,>32,>24,RSSI,BATT";
1637    const LONGER: &str = "TRACKER,DATE,TIME,GS Lat,GS Lon,GS Alt asl,TRACKER Lat,TRACKER Lon,\
1638        TRACKER Alt asl,FIX,HORZV,VERTV,HEAD,FLAGS,#TOT,>40,>32,>24,RSSI Down,RSSI Up,\
1639        Horizontal range (ft),Alt AGL (ft),Total range (ft),LoRa Max Range (kft),\
1640        LoRa Success Rate,BATT,GS_batt_v,Down Sent,Down Rcvd,Found Sent,Up Sent,Up Rccvd,\
1641        Found rocket ID,Found rocket lat,Found rocket lon,Found rocket altitude,Found rocket fix,\
1642        Found rocket batt_v,Found RSSI,GS_ASL_offset";
1643
1644    /// Meters per degree of latitude on the reader's sphere.
1645    fn degree_m() -> f64 {
1646        EARTH_RADIUS_M.to_radians()
1647    }
1648
1649    /// An older tracker log of rows a second apart: each row's altitude, its upward and
1650    /// horizontal speeds as written, moving north by 30 ft a row.
1651    fn tracker_file(rows: usize, alt: impl Fn(usize) -> f64, vertv: f64, horzv: f64) -> String {
1652        tracker_file_with(rows, alt, vertv, horzv, 90, "4.0")
1653    }
1654
1655    /// [`tracker_file`] with a heading and a battery cell.
1656    fn tracker_file_with(
1657        rows: usize,
1658        alt: impl Fn(usize) -> f64,
1659        vertv: f64,
1660        horzv: f64,
1661        head: i32,
1662        batt: &str,
1663    ) -> String {
1664        moving_file(rows, alt, vertv, horzv, 30.0 * FOOT_M, head, batt)
1665    }
1666
1667    /// [`tracker_file_with`] moving north by `step_m` meters a row.
1668    fn moving_file(
1669        rows: usize,
1670        alt: impl Fn(usize) -> f64,
1671        vertv: f64,
1672        horzv: f64,
1673        step_m: f64,
1674        head: i32,
1675        batt: &str,
1676    ) -> String {
1677        let mut text = format!("{OLDER}\n");
1678        for row in 0..rows {
1679            let lat = 40.0 + step_m * row as f64 / degree_m();
1680            text.push_str(&format!(
1681                "Jun 1 2025 18:00:{row:02}.000 UTC,{}.000,{},{lat:.7},-105.0,12,3,{horzv},{vertv},\
1682                 {head},0x03,1,2,3,-50,{batt}\n",
1683                1_748_800_800 + row,
1684                alt(row)
1685            ));
1686        }
1687        text
1688    }
1689
1690    fn channel<'a>(log: &'a FlightLog, column: &str) -> &'a Channel {
1691        log.channels.iter().find(|c| c.column == column).unwrap()
1692    }
1693
1694    fn close(a: f64, b: f64) -> bool {
1695        (a - b).abs() <= 1e-9 * b.abs().max(1.0)
1696    }
1697
1698    fn climbing(row: usize) -> f64 {
1699        1000.0 + 100.0 * row as f64
1700    }
1701
1702    #[test]
1703    fn the_four_headers_are_recognized_and_nothing_else() {
1704        let newer = format!(
1705            "{OLDER},Altitude AGL,Launch detection,Apogee detection,Landing detection,\
1706             Distance (feet)"
1707        );
1708        for header in [OLDER, newer.as_str(), GROUND, LONGER] {
1709            assert!(
1710                recognizes(&format!("\u{feff}\r\n\n{header}\r\n")),
1711                "{header}"
1712            );
1713            assert!(recognizes(&header.replace(',', " , ")), "{header}");
1714        }
1715        for other in [
1716            "",
1717            "UTCTIME,UNIXTIME,ALT\n",
1718            "UNIXTIME,UTCTIME,ALT,LAT,LON\n",
1719            "utctime,unixtime,alt,lat,lon\n",
1720            "TRACKER,DATE,TIME,ALT,FIX\n",
1721            "TRACKER,DATE,TIME,GS Lat,GS Lon\n",
1722            "DATE,TIME,LAT,LON\n",
1723            "Time,Altitude,Velocity\n",
1724            "Year,Month,Day,Time,Flight_Time_(s),Sync,Gyro_X\n",
1725            "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\n",
1726        ] {
1727            assert!(!recognizes(other), "{other:?}");
1728        }
1729    }
1730
1731    /// `UTCTIME` against a hand computation, each month's name, leap days by the Gregorian rule,
1732    /// and a strict reading of everything else.
1733    #[test]
1734    fn tracker_times_are_read_strictly() {
1735        // 2031-07-14 17:04:31.250 UTC is 1,941,815,071.25 s after 1970.
1736        assert_eq!(
1737            utc_time("Jul 14 2031 17:04:31.250 UTC"),
1738            Some(1_941_815_071_250_000_000)
1739        );
1740        assert_eq!(utc_time("Jan 1 1970 00:00:00 UTC"), Some(0));
1741        assert_eq!(
1742            utc_time("jul  14 2031 17:04:31.250   UTC"),
1743            utc_time("Jul 14 2031 17:04:31.250 UTC")
1744        );
1745        let months = [
1746            "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1747        ];
1748        let mut last = i64::MIN;
1749        for month in months {
1750            let time = utc_time(&format!("{month} 1 2026 00:00:00 UTC")).unwrap();
1751            assert!(time > last, "{month}");
1752            last = time;
1753        }
1754        // 2024-02-29 is day 19,782 after 1970-01-01; the next day is March 1.
1755        assert_eq!(
1756            utc_time("Feb 29 2024 00:00:00 UTC"),
1757            Some(19_782 * NS_PER_DAY)
1758        );
1759        assert_eq!(
1760            utc_time("Mar 1 2024 00:00:00 UTC"),
1761            Some(19_783 * NS_PER_DAY)
1762        );
1763        assert!(
1764            utc_time("Feb 29 2000 12:00:00 UTC").is_some(),
1765            "divisible by 400"
1766        );
1767        for bad in [
1768            "Feb 29 2023 12:00:00 UTC",
1769            "Feb 29 2100 12:00:00 UTC",
1770            "Apr 31 2021 12:00:00 UTC",
1771            "Apr 0 2021 12:00:00 UTC",
1772            "Jul 14 2031 17:04:31.250",
1773            "Jul 14 2031 17:04:31.250 GMT",
1774            "Jul 14 2031 17:04:31.250 UTC x",
1775            "July 14 2031 17:04:31 UTC",
1776            "Jul 14 31 17:04:31 UTC",
1777            "Jul 14 1969 17:04:31 UTC",
1778            "Jul 14 2200 17:04:31 UTC",
1779            "Jul 100 2031 17:04:31 UTC",
1780            "Jul 14 2031 24:00:00 UTC",
1781            "",
1782        ] {
1783            assert_eq!(utc_time(bad), None, "{bad:?}");
1784        }
1785    }
1786
1787    #[test]
1788    fn ground_station_dates_and_times_are_read_strictly() {
1789        // 2000-03-01 is day 11,017; 2199-12-31, the last day read, day 84,005.
1790        assert_eq!(civil_date("2000-03-01"), Some(11_017));
1791        assert_eq!(civil_date("2199-12-31"), Some(84_005));
1792        assert_eq!(civil_date("2026-1-8"), civil_date("2026-01-08"));
1793        for bad in [
1794            "31-07-14",
1795            "2026-13-01",
1796            "2026-02-29",
1797            "2031-07-14-1",
1798            "2031/07/14",
1799            "",
1800        ] {
1801            assert_eq!(civil_date(bad), None, "{bad:?}");
1802        }
1803        assert_eq!(time_of_day_ns("0:00:00"), Some(0));
1804        assert_eq!(time_of_day_ns("14:27:09.375"), Some(52_029_375_000_000));
1805        assert_eq!(
1806            time_of_day_ns("23:59:60.123456789"),
1807            Some(86_400_123_456_789)
1808        );
1809        for bad in [
1810            "12:00",
1811            "12:00:00:00",
1812            "24:00:00",
1813            "12:60:00",
1814            "12:00:61",
1815            "12:5:00",
1816            "123:00:00",
1817            "12:00:00.",
1818            "12:00:00.1234567890",
1819            "12:00:00.x",
1820            "-1:00:00",
1821            "12:00:+1",
1822        ] {
1823            assert_eq!(time_of_day_ns(bad), None, "{bad:?}");
1824        }
1825        // A day's rollover is the date's: the last second of a year, then the first of the next.
1826        let before = civil_date("2025-12-31").unwrap() * NS_PER_DAY
1827            + time_of_day_ns("23:59:59.500").unwrap();
1828        let after = civil_date("2026-01-01").unwrap() * NS_PER_DAY
1829            + time_of_day_ns("00:00:00.250").unwrap();
1830        assert_eq!(seconds_between(before, after), 0.75);
1831        assert_eq!(clock_text(before), "23:59:59.500");
1832    }
1833
1834    #[test]
1835    fn cells_are_numbers_flags_hexadecimal_or_percentages() {
1836        assert_eq!(value("1.5"), Some(1.5));
1837        assert_eq!(value("-82"), Some(-82.0));
1838        assert_eq!(value("TRUE"), Some(1.0));
1839        assert_eq!(value("FALSE"), Some(0.0));
1840        assert_eq!(value("0x03"), Some(3.0));
1841        assert_eq!(value("0xff"), Some(255.0));
1842        assert_eq!(value("67%"), Some(67.0));
1843        assert_eq!(value("100%"), Some(100.0));
1844        for bad in [
1845            "",
1846            "NaN",
1847            "inf",
1848            "true",
1849            "0x",
1850            "0xg",
1851            "67 %",
1852            "%",
1853            "x",
1854            "0x12345678901234",
1855        ] {
1856            assert_eq!(value(bad), None, "{bad:?}");
1857        }
1858    }
1859
1860    /// The great-circle distance against a hand computation: a degree of latitude is
1861    /// 6,371,008.8 m × π / 180 = 111,195.08 m.
1862    #[test]
1863    fn a_degree_of_latitude_is_its_arc() {
1864        assert!((great_circle_m((40.0, -105.0), (41.0, -105.0)) - 111_195.08).abs() < 0.01);
1865        assert_eq!(great_circle_m((40.0, -105.0), (40.0, -105.0)), 0.0);
1866    }
1867
1868    /// A file whose speeds agree with the change of its altitude and position reads them in feet
1869    /// per second, and its altitude in feet.
1870    #[test]
1871    fn speeds_that_agree_are_read_in_feet_per_second() {
1872        let log = read(&tracker_file(14, climbing, 100.0, 30.0)).unwrap();
1873        let up = channel(&log, "VERTV");
1874        assert_eq!(up.quantity, Quantity::Speed);
1875        assert!(close(up.values[0], 30.48));
1876        assert!(close(channel(&log, "HORZV").values[0], 30.0 * FOOT_M));
1877        let alt = channel(&log, "ALT");
1878        assert_eq!(alt.quantity, Quantity::GnssAltitude);
1879        assert!(close(alt.values[0], 304.8), "1000 ft is 304.8 m");
1880        assert_eq!(
1881            log.notes[..3],
1882            [
1883                "`HORZV` read in feet per second, from the manual and the distance between fixes: \
1884                 the distance between fixes a second, in feet, is a median of 1.000 times `HORZV` \
1885                 over 13 pairs of rows",
1886                "`ALT` read in feet, from the manual and the horizontal speed: the distance between \
1887                 fixes a second, in feet, is a median of 1.000 times `HORZV` over 13 pairs of rows; \
1888                 the change of `ALT` a second is a median of 1.000 times `VERTV` over 13 pairs of \
1889                 rows",
1890                "`VERTV` read in feet per second, in the altitude's unit, from the change of altitude: the \
1891                 change of `ALT` a second is a median of 1.000 times `VERTV` over 13 pairs of rows",
1892            ],
1893            "{:#?}",
1894            log.notes
1895        );
1896    }
1897
1898    /// The review's file, all in meters: `ALT` from 1000 rising 50 a second, `VERTV` 50, and
1899    /// `HORZV` 25 over fixes 25 m apart, 30 rows a second apart. `VERTV` matches the change of
1900    /// `ALT`, as it would in feet; only `HORZV` against the distance between fixes, 3.28 times it
1901    /// in feet, shows the file isn't in feet, and every length and speed is kept as written.
1902    #[test]
1903    fn a_file_all_in_meters_keeps_its_lengths_as_written() {
1904        let rising = |row: usize| 1000.0 + 50.0 * row as f64;
1905        let log = read(&moving_file(30, rising, 50.0, 25.0, 25.0, 90, "4.0")).unwrap();
1906        for (column, written) in [("ALT", 1000.0), ("VERTV", 50.0), ("HORZV", 25.0)] {
1907            let channel = channel(&log, column);
1908            assert_eq!(channel.quantity, Quantity::Unrecognized, "{column}");
1909            assert_eq!(channel.values[0], written, "{column}");
1910        }
1911        let why = "the horizontal speed disagrees with the manual's feet: ";
1912        assert!(
1913            log.notes[0].starts_with(
1914                "`HORZV` kept as written, the distance between fixes disagrees with the manual's \
1915                 feet per second: the distance between fixes a second, in feet, is a median of \
1916                 3.280 times `HORZV` over 29 pairs of rows"
1917            ),
1918            "{:#?}",
1919            log.notes
1920        );
1921        assert!(log.notes[1].starts_with(&format!("`ALT` kept as written, {why}")));
1922        assert_eq!(
1923            log.notes[2],
1924            format!(
1925                "`VERTV` kept as written, {why}the change of `ALT` a second is a median of 1.000 \
1926                 times `VERTV` over 29 pairs of rows"
1927            )
1928        );
1929    }
1930
1931    /// The same flight written in feet, as the manual gives it: read in feet and feet per second.
1932    #[test]
1933    fn the_same_file_in_feet_reads_in_feet() {
1934        let ft = 1.0 / FOOT_M;
1935        let rising = |row: usize| (1000.0 + 50.0 * row as f64) / FOOT_M;
1936        let log = read(&moving_file(
1937            30,
1938            rising,
1939            50.0 * ft,
1940            25.0 * ft,
1941            25.0,
1942            90,
1943            "4.0",
1944        ))
1945        .unwrap();
1946        for (column, quantity, si) in [
1947            ("ALT", Quantity::GnssAltitude, 1000.0),
1948            ("VERTV", Quantity::Speed, 50.0),
1949            ("HORZV", Quantity::Speed, 25.0),
1950        ] {
1951            let channel = channel(&log, column);
1952            assert_eq!(channel.quantity, quantity, "{column}");
1953            assert!(
1954                close(channel.values[0], si),
1955                "{column}: {}",
1956                channel.values[0]
1957            );
1958        }
1959        assert!(
1960            log.notes[1].starts_with(
1961                "`ALT` read in feet, from the manual and the horizontal speed: the distance \
1962                 between fixes a second, in feet, is a median of 1.000 times `HORZV` over 29 pairs \
1963                 of rows"
1964            ),
1965            "{:#?}",
1966            log.notes
1967        );
1968    }
1969
1970    /// A file whose `VERTV` is in meters per second, 3.28 times smaller than the change of `ALT`
1971    /// in feet a second: `VERTV` and the heights kept as written, with notes saying so.
1972    #[test]
1973    fn a_vertical_speed_that_disagrees_is_kept_as_written() {
1974        let log = read(&tracker_file(14, climbing, 30.48, 30.0)).unwrap();
1975        let up = channel(&log, "VERTV");
1976        assert_eq!(up.quantity, Quantity::Unrecognized);
1977        assert_eq!(up.values[0], 30.48);
1978        let alt = channel(&log, "ALT");
1979        assert_eq!(alt.quantity, Quantity::Unrecognized);
1980        assert_eq!(alt.values[0], 1000.0);
1981        assert_eq!(
1982            log.notes[2],
1983            "`VERTV` kept as written, not in the altitude's unit a second: the change of `ALT` a second is \
1984             a median of 3.281 times `VERTV` over 13 pairs of rows"
1985        );
1986        assert!(
1987            log.notes[1]
1988                .starts_with("`ALT` kept as written, `VERTV` isn't their change a second: ")
1989        );
1990        // `HORZV` still agrees with the positions, so it is read on its own.
1991        assert_eq!(channel(&log, "HORZV").quantity, Quantity::Speed);
1992    }
1993
1994    /// A `HORZV` in miles per hour (1.467 times smaller than feet per second), a heading past
1995    /// ±360 and a battery in millivolts are each kept as written.
1996    #[test]
1997    fn horizontal_speed_heading_and_battery_that_disagree_are_kept() {
1998        let mph = 30.0 / 1.466_667;
1999        let log = read(&tracker_file_with(14, climbing, 100.0, mph, 400, "4010")).unwrap();
2000        let horizontal = channel(&log, "HORZV");
2001        assert_eq!(horizontal.quantity, Quantity::Unrecognized);
2002        assert!(close(horizontal.values[0], mph));
2003        assert_eq!(channel(&log, "HEAD").quantity, Quantity::Unrecognized);
2004        assert_eq!(channel(&log, "BATT").quantity, Quantity::Unrecognized);
2005        assert_eq!(channel(&log, "BATT").values[0], 4010.0);
2006        let notes = log.notes.join("\n");
2007        // A `HORZV` that isn't feet per second leaves the file's lengths as written too.
2008        assert_eq!(channel(&log, "ALT").quantity, Quantity::Unrecognized);
2009        assert_eq!(channel(&log, "VERTV").quantity, Quantity::Unrecognized);
2010        for expected in [
2011            "`HORZV` kept as written, the distance between fixes disagrees with the manual's feet \
2012             per second: the distance between fixes a second, in feet, is a median of 1.466 times",
2013            "`HEAD` kept as written, its range disagrees with the manual's degrees: it runs from \
2014             400 to 400",
2015            "`BATT` kept as written, its range isn't one lithium cell's volts: it runs from 4010 \
2016             to 4010",
2017        ] {
2018            assert!(notes.contains(expected), "{expected}\n{notes}");
2019        }
2020        // Just inside the band, the speed reads in feet per second; just outside, as written.
2021        let inside = read(&tracker_file(14, climbing, 100.0, 30.0 / 1.39)).unwrap();
2022        assert_eq!(channel(&inside, "HORZV").quantity, Quantity::Speed);
2023        let outside = read(&tracker_file(14, climbing, 100.0, 30.0 / 1.41)).unwrap();
2024        assert_eq!(channel(&outside, "HORZV").quantity, Quantity::Unrecognized);
2025    }
2026
2027    /// Too few pairs of moving rows leave the manual's unit, with a note saying the file can't
2028    /// tell.
2029    #[test]
2030    fn too_few_pairs_leave_the_manuals_unit() {
2031        let log = read(&tracker_file(5, climbing, 30.48, 9.0)).unwrap();
2032        assert_eq!(channel(&log, "VERTV").quantity, Quantity::Speed);
2033        assert_eq!(channel(&log, "ALT").quantity, Quantity::GnssAltitude);
2034        assert_eq!(
2035            log.notes[..3],
2036            [
2037                "`HORZV` read in feet per second, from the manual alone: the file has too few \
2038                 pairs of moving rows to check `HORZV` over, 0 of the 10 needed",
2039                "`ALT` read in feet, from the manual alone: the file has too few pairs of moving \
2040                 rows to check `HORZV` over, 0 of the 10 needed; the file has too few pairs of \
2041                 moving rows to check `VERTV` over, 4 of the 10 needed",
2042                "`VERTV` read in feet per second, in the altitude's unit, from the manual alone: the file \
2043                 has too few pairs of moving rows to check `VERTV` over, 4 of the 10 needed",
2044            ]
2045        );
2046    }
2047
2048    /// The longer ground-station log whose `Alt AGL (ft)` doesn't follow its heights keeps them as
2049    /// written.
2050    #[test]
2051    fn heights_that_disagree_with_alt_agl_are_kept() {
2052        let row = |agl: f64| {
2053            format!(
2054                "T,2026-03-08,09:15:10,20,-100,3000,20.001,-100,4000,3,4,0,45,0x01,24,6,12,2,-50,\
2055                 -55,364.8,{agl},0,12.5,100%,4.0,4.1,1,1,0,1,1,,0,0,0,0,0,-60,-5"
2056            )
2057        };
2058        let agrees = read(&format!("{LONGER}\n{}\n", row(1005.0))).unwrap();
2059        assert_eq!(
2060            channel(&agrees, "GS Alt asl").quantity,
2061            Quantity::GnssAltitude
2062        );
2063        let disagrees = read(&format!("{LONGER}\n{}\n", row(304.8))).unwrap();
2064        assert_eq!(
2065            channel(&disagrees, "GS Alt asl").quantity,
2066            Quantity::Unrecognized
2067        );
2068        assert_eq!(
2069            channel(&disagrees, "GS_ASL_offset").quantity,
2070            Quantity::Unrecognized
2071        );
2072        // `VERTV` is in the heights' unit a second, so it is kept with them.
2073        assert_eq!(channel(&agrees, "VERTV").quantity, Quantity::Speed);
2074        assert_eq!(
2075            channel(&disagrees, "VERTV").quantity,
2076            Quantity::Unrecognized
2077        );
2078        assert!(disagrees.notes.contains(
2079            &"`VERTV` kept as written, `Alt AGL (ft)` disagrees with the manual's feet: the file \
2080              has too few pairs of moving rows to check `VERTV` over, 0 of the 10 needed"
2081                .to_owned()
2082        ));
2083        assert!(disagrees.notes.iter().any(|note| note.starts_with(
2084            "`GS Alt asl`, `TRACKER Alt asl`, `Found rocket altitude` and `GS_ASL_offset` kept as \
2085             written, `Alt AGL (ft)` disagrees with the manual's feet: "
2086        )));
2087    }
2088
2089    #[test]
2090    fn unixtime_off_by_other_than_whole_hours_is_said() {
2091        let text = format!(
2092            "{OLDER}\nJun 1 2025 18:00:00.000 UTC,1748800800.500,1,40,-105,1,3,0,0,0,0x03,1,1,1,\
2093             1,4\n"
2094        );
2095        let log = read(&text).unwrap();
2096        assert!(
2097            log.notes.iter().any(|note| note
2098                == "`UNIXTIME` kept as written, off `UTCTIME` by other than one whole number of \
2099                    hours: it runs from 0.500 s to 0.500 s ahead of `UTCTIME`; read as UTC at the \
2100                    first fix's longitude, `UTCTIME` gives 11.0 h of local mean solar time and \
2101                    `UNIXTIME` 11.0 h"),
2102            "{:#?}",
2103            log.notes
2104        );
2105    }
2106
2107    /// A latitude beyond ±90° or a longitude beyond ±180° is a gap, counted per column; the
2108    /// edges themselves are read.
2109    #[test]
2110    fn positions_out_of_range_are_gaps() {
2111        let row = |lat: &str, lon: &str| {
2112            format!(
2113                "Jun 1 2025 18:00:00.000 UTC,1748800800,1,{lat},{lon},1,3,0,0,0,0x03,1,1,1,1,4\n"
2114            )
2115        };
2116        let text = format!(
2117            "{OLDER}\n{}{}{}{}",
2118            row("90", "-180"),
2119            row("90.5", "-105"),
2120            row("-91", "180.001"),
2121            row("40", "-181")
2122        );
2123        let log = read(&text).unwrap();
2124        let (lat, lon) = (channel(&log, "LAT"), channel(&log, "LON"));
2125        assert_eq!(lat.values[0], 90.0);
2126        assert_eq!(lon.values[0], -180.0);
2127        assert!(lat.values[1].is_nan() && lon.values[1] == -105.0);
2128        assert!(lat.values[2].is_nan() && lon.values[2].is_nan());
2129        assert!(lat.values[3] == 40.0 && lon.values[3].is_nan());
2130        for expected in [
2131            "the column \"LAT\" holds 2 latitudes beyond ±90°: they are gaps",
2132            "the column \"LON\" holds 2 longitudes beyond ±180°: they are gaps",
2133        ] {
2134            assert!(
2135                log.notes.iter().any(|note| note == expected),
2136                "{expected}\n{:#?}",
2137                log.notes
2138            );
2139        }
2140    }
2141
2142    /// A long line that doesn't open as a header is turned away by its first names.
2143    #[test]
2144    fn a_long_line_of_other_names_is_not_a_header() {
2145        let commas = ",".repeat(1 << 20);
2146        assert!(!recognizes(&commas));
2147        assert!(!recognizes(&format!("UTCTIME{commas}")));
2148        assert!(!recognizes(&format!("TRACKER,DATE{commas}")));
2149        assert!(recognizes(&format!("UTCTIME,UNIXTIME{commas}LAT,LON")));
2150    }
2151
2152    #[test]
2153    fn repeated_times_alone_are_noted() {
2154        let row = "Jun 1 2025 18:00:00.000 UTC,1748800800,1,40,-105,1,3,0,0,0,0x03,1,1,1,1,4";
2155        let log = read(&format!("{OLDER}\n{row}\n{row}\n")).unwrap();
2156        assert!(
2157            log.notes.contains(
2158                &"1 rows repeat a time already written: every row is kept, in the file's order"
2159                    .to_owned()
2160            )
2161        );
2162        assert_eq!(channel(&log, "ALT").time_s, [0.0, 0.0]);
2163    }
2164
2165    /// Past [`MAX_TRACKERS`] trackers, a further tracker's rows are left out and counted.
2166    #[test]
2167    fn trackers_past_the_cap_are_left_out() {
2168        let mut text = format!("{GROUND}\n");
2169        for tracker in 0..70 {
2170            text.push_str(&format!(
2171                "T{tracker},2026-01-01,10:00:{:02},1,1,1,3,0,0,0,0x03,1,1,1,1,1,0\n",
2172                tracker % 60
2173            ));
2174        }
2175        text.push_str("T69,2026-01-01,10:01:00,1,1,1,3,0,0,0,0x03,1,1,1,1,1,0\n");
2176        let log = read(&text).unwrap();
2177        assert_eq!(log.channels.len(), MAX_TRACKERS * 15);
2178        assert_eq!(log.channels[15].column, "TIME [T1]");
2179        assert!(
2180            log.notes.contains(
2181                &"7 rows of 6 more trackers were left out, past the first 64 trackers or 4096 \
2182                  channels, so that a file of many tracker names can't make the reader hold a \
2183                  set of channels for each"
2184                    .to_owned()
2185            ),
2186            "{:#?}",
2187            log.notes
2188        );
2189    }
2190
2191    /// Each refusal names its line and what is wrong.
2192    #[test]
2193    fn malformed_files_are_refused_where_they_go_wrong() {
2194        let row = "Jun 1 2025 18:00:00.000 UTC,1748800800,1,40,-105,1,3,0,0,0,0x03,1,1,1,1,4";
2195        let cases = [
2196            (String::new(), "the file is empty"),
2197            ("\n\r\n".to_owned(), "the file is empty"),
2198            (
2199                "Time,Altitude\n0,0\n".to_owned(),
2200                "isn't a Featherweight GPS header",
2201            ),
2202            (format!("{OLDER}\n"), "no data rows"),
2203            (
2204                "UTCTIME,UNIXTIME,LAT,LON,,x\n".to_owned(),
2205                "line 1: the header's column 5 has no name",
2206            ),
2207            (
2208                format!("{OLDER}\n{row},9\n"),
2209                "line 2: the row has 17 cells, more than the header's 16",
2210            ),
2211            (
2212                format!("{OLDER}\n{}\n", row.replace("Jun 1", "Jun 31")),
2213                "line 2: the time \"Jun 31 2025 18:00:00.000 UTC\" in cell 1 isn't one",
2214            ),
2215            (
2216                format!("{GROUND}\nT,2026-02-30,10:00:00,1,1,1\n"),
2217                "line 2: the date \"2026-02-30\" in cell 2 isn't one",
2218            ),
2219            (
2220                format!("{GROUND}\nT,2026-02-03,10:00\n"),
2221                "line 2: the time \"10:00\" in cell 3 isn't one",
2222            ),
2223            (
2224                format!("{GROUND}\nT,2026-02-03\n"),
2225                "line 2: the row ends after 2 cells, before its \"TIME\"",
2226            ),
2227        ];
2228        for (text, expected) in cases {
2229            let error = read(&text).unwrap_err().to_string();
2230            assert!(error.contains(expected), "{text:?}: {error}");
2231        }
2232        let LogError::NotThisFormat { format, .. } = read("x").unwrap_err() else {
2233            panic!("refused for another reason");
2234        };
2235        assert_eq!(format, FORMAT);
2236    }
2237
2238    /// A header naming far more columns than its rows fill is refused before the reader allocates
2239    /// a gap for every cell left out; short rows within the bound are gaps, counted.
2240    #[test]
2241    fn short_rows_are_gaps_and_a_wide_header_is_refused() {
2242        let wide: String = (0..20_000).map(|i| format!(",c{i}")).collect();
2243        let rows: String = (0..100)
2244            .map(|i| format!("Jun 1 2025 18:00:{:02}.000 UTC\n", i % 60))
2245            .collect();
2246        let error = read(&format!("UTCTIME,UNIXTIME,LAT,LON{wide}\n{rows}"))
2247            .unwrap_err()
2248            .to_string();
2249        assert!(
2250            error.contains(
2251                "line 5: the header names 20004 columns, and the 4 rows down to this one hold \
2252                 only 4 cells"
2253            ),
2254            "{error}"
2255        );
2256        let log = read(&format!(
2257            "{OLDER}\nJun 1 2025 18:00:00.000 UTC,1748800800,1\n"
2258        ))
2259        .unwrap();
2260        assert!(channel(&log, "LAT").values[0].is_nan());
2261        assert!(log.notes.contains(
2262            &"1 rows, the first on line 2, have fewer cells than the header's 16: the cells they \
2263              leave out are gaps"
2264                .to_owned()
2265        ));
2266    }
2267
2268    /// A column the reader doesn't know is kept as written, with a note; notes about columns stay
2269    /// few.
2270    #[test]
2271    fn unknown_columns_are_kept_and_their_notes_stay_few() {
2272        let unknown: String = (0..30).map(|i| format!(",u{i}")).collect();
2273        let row = format!(
2274            "Jun 1 2025 18:00:00.000 UTC,1748800800,1,40,-105{}",
2275            ",7".repeat(30)
2276        );
2277        let log = read(&format!("UTCTIME,UNIXTIME,ALT,LAT,LON{unknown}\n{row}\n")).unwrap();
2278        assert_eq!(channel(&log, "u29").quantity, Quantity::Unrecognized);
2279        assert_eq!(channel(&log, "u29").values, [7.0]);
2280        assert_eq!(
2281            log.notes
2282                .iter()
2283                .filter(|note| note.contains("isn't one this reader knows"))
2284                .count(),
2285            20
2286        );
2287        assert!(log.notes.last().unwrap().starts_with("and 10 more notes"));
2288    }
2289
2290    proptest::proptest! {
2291        /// Any text is read or refused, never a panic; what is read has a time for each value on
2292        /// every channel, and an empty primary track.
2293        #[test]
2294        fn any_text_is_read_or_refused(
2295            rows in proptest::collection::vec(
2296                (proptest::bool::ANY, "[-0-9.,: xaTRUEFLSNC%Jn]{0,60}"),
2297                0..10,
2298            ),
2299            layout in 0..3usize,
2300        ) {
2301            let header = [OLDER, GROUND, LONGER][layout];
2302            // Most rows open with a clock, so that the rest of the row is read too.
2303            let clock = if layout == 0 {
2304                "Jun 1 2025 18:00:00.000 UTC,"
2305            } else {
2306                "T,2026-01-01,10:00:00,"
2307            };
2308            let rows: Vec<String> = rows
2309                .into_iter()
2310                .map(|(timed, rest)| if timed { format!("{clock}{rest}") } else { rest })
2311                .collect();
2312            let text = format!("{header}\n{}", rows.join("\n"));
2313            if let Ok(log) = read(&text) {
2314                proptest::prop_assert!(log.time_s.is_empty() && log.altitude_m.is_empty());
2315                for channel in &log.channels {
2316                    proptest::prop_assert_eq!(channel.time_s.len(), channel.values.len());
2317                    proptest::prop_assert!(channel.time_s.iter().all(|t| t.is_finite()));
2318                }
2319            }
2320        }
2321    }
2322}