Skip to main content

hpr_flightdata/
kml.rs

1//! KML files holding a GPS track, as AltOS, Missile Works' RTx, Big Red Bee's BeeLine, fw2kml, GPS
2//! Visualizer and others write a flight's path for Google Earth.
3//!
4//! KML (Keyhole Markup Language) is XML, standardized as OGC 07-147r2 (*OGC KML*, version 2.2.0,
5//! 2008). A flight's path is a `Placemark` holding a geometry: a `LineString`, whose
6//! `coordinates` list one tuple per point, or Google's extension `gx:Track`, which pairs each
7//! point's `when` with its `gx:coord`:
8//!
9//! ```text
10//! <Placemark><name>Flight Track</name>
11//!   <LineString><altitudeMode>absolute</altitudeMode>
12//!     <coordinates>-97.10000,35.20000,402 -97.10001,35.20002,415</coordinates>
13//!   </LineString>
14//! </Placemark>
15//! <Placemark><name>fw2kml - Flight 0</name>
16//!   <gx:Track><altitudeMode>absolute</altitudeMode>
17//!     <when>2026-05-23T10:15:00.000Z</when><gx:coord>-97.10000 35.20000 402.0</gx:coord>
18//!   </gx:Track>
19//! </Placemark>
20//! ```
21//!
22//! **What the standard sets.** A tuple is longitude, latitude and an optional altitude, comma
23//! separated, tuples separated by whitespace (§16.9.1); degrees are WGS84's, and "altitude is
24//! measured from the vertical datum, which is the WGS84 EGM96 Geoid" (§6.2), in meters
25//! (§16.1.1). How a viewer places the altitude is the geometry's `altitudeMode` (§9.17, §16.1.1):
26//! `absolute`, "a value in meters relative to the vertical datum"; `relativeToGround`, "in meters
27//! relative to the terrain elevation"; `clampToGround`, the default, "ignore the altitude
28//! specification". Google adds `gx:altitudeMode`'s `relativeToSeaFloor` and `clampToSeaFloor`,
29//! and `gx:Track`, whose "number of `<when>` elements … must be equal to the number of
30//! `<gx:coord>` elements", each coordinate "three values for longitude, latitude, and altitude,
31//! with no comma separators" (Google, *KML Reference*, developers.google.com/kml/documentation/
32//! kmlreference, `<gx:Track>`). A `when` is an XML Schema date-time (§15.3.3.1, §16.10.1), with `Z`
33//! for UTC or a `±hh:mm` offset; one that states no zone is "the time in the timezone of some
34//! unspecified locality" (W3C, *XML Schema Part 2*, §3.2.7).
35//!
36//! **Tracks and channels.** A KML file holds tracks of positions, not an altimeter's record over
37//! time: even an altitude AltOS took from its barometer (below) is one value of a track's points.
38//! So the record's primary track is empty and every value is a channel, with a note, as the GPS
39//! logs' decision, [ADR-254][adr-254] §2, has it. Each geometry, in the file's order, inside a
40//! `MultiGeometry` too, is a track: each `LineString`, `gx:Track` and `Point`. A track's channels
41//! are named after its `Placemark`'s `name`, or `track` if it has none; a placemark holding several
42//! geometries numbers them, `<name> part 2`, and a name already used gains ` (2)`, ` (3)` …, so no
43//! two tracks share a name:
44//!
45//! | Channel | Quantity | From |
46//! |---|---|---|
47//! | `<name> latitude`, `<name> longitude` | [`Quantity::Latitude`], [`Quantity::Longitude`] | the tuple's second and first numbers |
48//! | `<name> altitude` | [`Quantity::GnssAltitude`], m | the third number, in mode `absolute` |
49//! | `<name> altitude` | [`Quantity::AltitudeMsl`], m | the same, in an AltOS track whose altitude is the barometer's (below) |
50//! | `<name> height above ground`, `<name> height above sea floor` | [`Quantity::Distance`], m | the third number, in mode `relativeToGround` or `relativeToSeaFloor`: a height above the terrain, not above sea level |
51//! | `<name> altitude` | [`Quantity::Unrecognized`], as written | the third number, in a mode that tells a viewer to ignore it (`clampToGround`, `clampToSeaFloor`, or none stated), in a mode the standard doesn't name, or from GPS Visualizer (below) |
52//! | `<name> GPS altitude` | [`Quantity::GnssAltitude`], m | AltOS's comment `alt`, where it differs from the tuple's, in a file where it equals the tuple's at some point |
53//! | `<name> GPS altitude` | [`Quantity::Unrecognized`], as written | the same, in a file where it equals the tuple's at no point, so nothing in the file shows its unit |
54//! | `<name> Alt` | [`Quantity::Unrecognized`], as written | the `Seconds` layout's `Alt`, where it differs from the tuple's |
55//! | `<name> time` | [`Quantity::Unrecognized`], as written | AltOS's comment `time`, where its steps don't show it is in seconds (below; none of the samples) |
56//! | `<name> satellites` | [`Quantity::Number`] | a comment's `sats` |
57//! | `<name> offset` | [`Quantity::Number`] | the BeeLine comment's first number |
58//!
59//! A track whose tuples carry no altitude (a two-number tuple's altitude is absent, not 0: the
60//! standard calls it optional) has no altitude channel. A `Point` is a track of one sample. A
61//! tuple that isn't two or three numbers, or a latitude or longitude out of range, is a gap,
62//! `NaN`, counted in a note.
63//!
64//! **Times.** A point's time is the one the file gives it ([ADR-254][adr-254] §3): a `gx:Track`'s
65//! `when`; a `Point` placemark's `TimeStamp`; or, where a writer puts a comment right after a
66//! tuple, the time in the comment. The log's zero is the first point the file times, and a note
67//! names it. A point the file gives no time keeps its place with its time a gap, `NaN` (§4), as
68//! every point of a path without times does, with a note. Times stay in the file's order; a note
69//! says how often and how far they go back. A `Point` with a time is also an [`Event`] named after
70//! its placemark. Times on two kinds of clock in one file (none of the samples) can't share a zero:
71//! those not on the first timed point's clock are gaps, with a note. A time of day that steps back
72//! by more than 12 h has passed midnight, and a day is added from there on, at most
73//! [`MAX_MIDNIGHTS`] times; the times of day after that are gaps, with a note.
74//!
75//! **Comments beside points.** Three layouts are read, case aside:
76//!
77//! - AltOS: `<!-- alt 1000.0000000 time 2.5000000 sats 6 -->`: the GPS receiver's altitude, a
78//!   time and its satellite count.
79//! - `<!-- Alt 250.0 Seconds 9.5 -->`, in files styled after AltOS's but not named by it.
80//! - Big Red Bee's BeeLine: `<!-- 1003 sats:8 UTC 12:34:56 -->`: a number that steps by 15 a
81//!   point, the record's place in the logger's memory as far as the files show, the satellite
82//!   count, and the UTC time of day.
83//!
84//! A comment is a point's only if it follows the point's tuple with nothing but whitespace
85//! between, and before the next tuple; other comments are counted in a note and not read, nor is
86//! their text quoted.
87//!
88//! **The writers, among the project's sample logs.** As of 2026-10-12, 43 distinct KML files:
89//!
90//! - *fw2kml* (14), the community converter of Featherweight GPS logs (a Rocketry Forum post,
91//!   2023-01-06; github.com/pirate21213/fw2kml): one `Placemark` named `fw2kml - Flight N`. 12 hold
92//!   a `LineString` with no times; 2 a `gx:Track` with a `when` per point, in UTC to the tenth of
93//!   a second, and a `gx:Tour` of camera views, which isn't a track. One of the two is the same
94//!   KML document four times over: a later document identical to the first is skipped with a
95//!   note. A later document that differs (none of the samples) is read as more tracks, with a
96//!   note, up to [`MAX_DOCUMENTS`].
97//! - *Missile Works' RTx* (12): document `RTx/GPS Flight`, KML 2.1's namespace, a byte-order mark
98//!   and CR LF; a `Folder` holding `Flight Track`, a path of whole meters with no times, and in 3
99//!   files a second path, `Ground Track`, the same positions with no altitude, `clampToGround`.
100//!   The RTx's manual says its GPS altitude "is always expressed as Mean Sea Level" (*RTx User
101//!   Manual*, 2016-11-26, p13). In 10 of the 12, all from pads near sea level, the altitude reads
102//!   exactly 0 for the first hundreds of points, and in none does it go below 0, as a receiver's
103//!   altitude clamped at sea level would; it is kept as written.
104//! - *AltOS* (11), AltosUI's KML export (Altus Metrum, *AltOS* manual, §12.5.2): a document named
105//!   `AO Flight#N S/N: M`, from which the flight number and serial number are read, and a comment
106//!   beside every point. 7 files hold one `GPS Altitude` path, whose tuple altitude equals the
107//!   comment's `alt`; their `Snippet` names the product and firmware, read into the record. 3
108//!   older files hold one path per flight state (`pad`, `boost`, …) and an unset date
109//!   (`2147483647-…`, or `0000-00-00`). 1 holds a `Barometric Altitude` path and a `GPS Altitude`
110//!   path, each with `Point` placemarks at the flight's events (`Boost (Baro)` …), and a byte that
111//!   isn't UTF-8 in its text; AltOS's release notes say that since 1.8.3 the "KML export now
112//!   reports both barometric and GPS altitude data". In the flight-state paths the tuple's
113//!   altitude climbs while the comment's `alt` holds still with few satellites, and in the
114//!   barometric path the two differ at every point: the tuple's is the barometer's. So in an AltOS
115//!   track whose tuple altitude differs from its comments' `alt` at more than half of the points
116//!   that carry both, the tuple altitude is read as a barometric altitude above sea level,
117//!   [`Quantity::AltitudeMsl`], and the comments' `alt` as the GPS altitude, a channel of its own;
118//!   otherwise the tuple's is the GPS altitude, and the comments' `alt` a channel only if it
119//!   differs somewhere. In every sample track the two differ at all the points or at none, so no
120//!   single point decides a track. The comments' `alt` is read in meters where it equals the
121//!   tuple's, meters by the standard, at some point of the file, as in the GPS path beside the
122//!   barometric one; in the flight-state files it equals it nowhere, so nothing in them shows its
123//!   unit and it is kept as written. The `time` is 0 in every comment of the 7 GPS-only files and
124//!   the barometric one, which this reader takes as unset: those points have no time. In the
125//!   flight-state files it runs on, stepping by about a second between GPS fixes, from an
126//!   unstated zero (a negative time on the pad in one, hundreds of seconds in the others). It is
127//!   read in seconds where the median step between a track's points of distinct times is 0.5 to
128//!   2 s, as a receiver reporting once a second gives and a time in tenths, milliseconds or
129//!   minutes wouldn't; otherwise those points have no time, and the `time` is a channel kept as
130//!   written. The `Snippet`'s `Max baro height` is read as the stated apogee.
131//! - *Big Red Bee's BeeLine* (2): KML 2.0's namespace, CR LF, one `Placemark` named `gps` with a
132//!   `MultiGeometry` path of zero-padded whole meters (`00850`), the BeeLine comment beside each
133//!   point, in one file comments of the flight's particulars before the first point, and after
134//!   the document a comment opening `BRBDATA` holding the logger's memory in hexadecimal, 128
135//!   KiB of it: none of these is read, each counted in a note. Big Red Bee's manuals say the
136//!   logger records "latitude, longitude, altitude, GPS time, and number of satellites in view"
137//!   once a second (*BeeLine GPS*, 2024-11, p3) and writes a KML file for Google Earth (p8); no
138//!   manual describes the comments.
139//! - *Unnamed, AltOS-styled* (2): styles named as AltOS names them (`ao-flightstate-…`), a
140//!   document named by a hexadecimal id, one `Path` in mode `relativeToGround`, and the
141//!   `Alt`/`Seconds` comment beside each point; `Alt` equals the tuple's altitude. `Seconds`
142//!   runs from a small negative number, a time from liftoff. The logger is `KML`.
143//! - *GPS Visualizer* (1), named in the document's `Snippet`: one placemark of 48 paths in a
144//!   `MultiGeometry`, 47 of a single point.
145//! - *A map drawn in Google Earth* (1): predicted tracks and pins in nested documents, not a
146//!   flight log; it is read as any KML file is (the survey of sample logs files it apart, by
147//!   id). The logger is `KML`: the file names no writer of its own.
148//!
149//! **The altitude's unit.** Meters, from the standard, but for GPS Visualizer's. Against a
150//! barometric logger flown on the same flight, among the project's sample logs, the track's rise
151//! above its first point is 0.99 to 1.06 times the logger's peak in meters in 9 of 11 fw2kml
152//! files, 0.73 to 1.15 in 11 of 12 RTx files, 0.76 to 1.06 in 8 of 10 AltOS files and 1.05 in a
153//! BeeLine file, where feet would make it 3.28 times. The others are a track whose receiver lost
154//! the rocket, two pairings with what may be another flight, and two AltOS files whose first path
155//! is the pad's alone. The GPS Visualizer file's rise is 3.24 times its flight's
156//! barometric peak in meters: its altitudes are feet written as meters. GPS Visualizer reads a
157//! source's altitude "as meters unless "(ft.)" or "(feet)" is included in the field name"
158//! (gpsvisualizer.com/tutorials/waypoints.php), so its KML holds meters only when its source
159//! said which unit it used, and the file can't say whether it did: its altitude is kept as
160//! written, [`Quantity::Unrecognized`]. The two unnamed files' rise is 0.42 times their flight's
161//! barometric peak, which neither unit explains; their altitude is read in meters, from the
162//! standard, as a height above the ground.
163//!
164//! Each decision is a note in one grammar, the same before the colon for every file decided
165//! alike, such as `` `coordinates` read in meters, from the KML standard: … ``, with this file's
166//! evidence after it.
167//!
168//! **Hostile input.** `roxmltree` parses the XML, with its default refusal of a document type
169//! declaration, so no entity can expand, and a limit of [`MAX_NODES`] nodes over all the file's
170//! documents together. The parser descends the tree on the stack, so the text's nesting is
171//! counted first and a file nesting elements deeper than [`MAX_DEPTH`] is refused. At most
172//! [`MAX_TRACKS`] tracks and [`MAX_POINTS`] points are read, the rest counted in a note; notes
173//! about single places are capped as every reader's are. Every track's channels hold one value
174//! per point read, so the record never holds more than the file wrote.
175//!
176//! **Where this comes from.** The standard and Google's reference as cited, and the files
177//! themselves; no KML reader was ported. Debrief, the project owner's own earlier tool, writes
178//! KML but doesn't read it.
179//!
180//! [adr-254]: https://github.com/nrdptel/fusionspace-eridanus/blob/main/docs/decisions/0254-gps-tracks-first-among-the-other-importers.md
181
182use std::borrow::Cow;
183use std::collections::HashMap;
184
185use roxmltree::{Document, Node, NodeId, ParsingOptions};
186
187use crate::error::LogError;
188use crate::log::{Channel, Event, FlightLog, LogFormat, Quantity, Stated};
189use crate::perfectflite::FOOT_M;
190use crate::table::{Notes, number, short};
191
192/// The format's name in errors.
193const FORMAT: &str = "KML";
194
195/// The most tracks (geometries) one file gives; past it, the rest are counted in a note. The
196/// sample files hold at most 48.
197pub const MAX_TRACKS: usize = 256;
198
199/// The most points read from one file, over all its tracks; past it, the rest are counted in a
200/// note. The sample files hold at most 8,736.
201pub const MAX_POINTS: usize = 1_000_000;
202
203/// The most KML documents read from one file, one after another; past it, the rest are counted in
204/// a note. A later document identical to the first doesn't count.
205pub const MAX_DOCUMENTS: usize = 8;
206
207/// The most XML nodes the parser builds for one file, over all the documents it holds together, so
208/// a file of many tiny elements, in one document or several, can't fill memory. The sample files
209/// hold well under 100,000.
210pub const MAX_NODES: u32 = 4_000_000;
211
212/// The deepest the XML may nest its elements. The parser underneath, `roxmltree`, descends the tree
213/// on the stack, so a file nesting far enough would exhaust it, a crash where an error is due: the
214/// nesting is counted before the text is parsed, as the `.ork` reader's is (`hpr_io::ork`). The
215/// sample files nest at most 9 deep; 64 is as deep as the `.ork` reader allows.
216pub const MAX_DEPTH: usize = 64;
217
218/// The most times a time of day is carried past midnight in one file; the times of day after are
219/// gaps. A BeeLine's memory holds a few hours of points at one a second, so a flight's file passes
220/// midnight once at most, and the bound keeps the days added from overflowing on a hostile file.
221pub const MAX_MIDNIGHTS: i64 = 7;
222
223/// The steps between a track's AltOS `time` values, in seconds, whose median shows the `time` is
224/// in seconds: a GPS receiver reporting once a second steps by 1 s, where a time in tenths would
225/// step by 10, in milliseconds by 1,000 and in minutes by 1/60. The samples' median is about 1 s.
226const ALTOS_STEP_S: std::ops::RangeInclusive<f64> = 0.5..=2.0;
227
228/// The most declarations and comments looked past before the root element when recognizing a
229/// file.
230const PROLOG_ITEMS: usize = 64;
231
232/// Nanoseconds in a second.
233const NS_PER_S: i64 = 1_000_000_000;
234
235/// Nanoseconds in a day.
236const DAY_NS: i64 = 86_400 * NS_PER_S;
237
238/// Whether the text is a KML document, judged from its content, never the file's name: its root
239/// element is `kml`, after an optional byte-order mark, XML declaration, comments and document
240/// type declaration. A file with a document type declaration is recognized so that [`read`]
241/// refuses it by name, rather than the importer calling it a layout it doesn't know.
242pub(crate) fn recognizes(text: &str) -> bool {
243    let mut rest = text.strip_prefix('\u{feff}').unwrap_or(text);
244    for _ in 0..PROLOG_ITEMS {
245        rest = rest.trim_start();
246        let (open, close) = if rest.starts_with("<?") {
247            ("<?", "?>")
248        } else if rest.starts_with("<!--") {
249            ("<!--", "-->")
250        } else if rest.starts_with("<!DOCTYPE") {
251            match doctype_length(rest) {
252                Some(length) => {
253                    rest = &rest[length..];
254                    continue;
255                }
256                None => return false,
257            }
258        } else {
259            break;
260        };
261        match rest[open.len()..].find(close) {
262            Some(end) => rest = &rest[open.len() + end + close.len()..],
263            None => return false,
264        }
265    }
266    rest.strip_prefix("<kml").is_some_and(|after| {
267        after.starts_with(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/')
268    })
269}
270
271/// The length of a document type declaration at the start of the text, `<!DOCTYPE` to its closing
272/// `>`, past an internal subset in brackets and quoted text; `None` if it doesn't close.
273fn doctype_length(text: &str) -> Option<usize> {
274    let mut quote: Option<u8> = None;
275    let mut brackets = 0usize;
276    for (index, byte) in text.bytes().enumerate() {
277        match (quote, byte) {
278            (Some(open), _) if byte == open => quote = None,
279            (Some(_), _) => {}
280            (None, b'"' | b'\'') => quote = Some(byte),
281            (None, b'[') => brackets += 1,
282            (None, b']') => brackets = brackets.saturating_sub(1),
283            (None, b'>') if brackets == 0 => return Some(index + 1),
284            (None, _) => {}
285        }
286    }
287    None
288}
289
290/// Reads a KML file's tracks into a flight record.
291///
292/// # Errors
293///
294/// [`LogError::Syntax`] if the XML isn't well formed, declares a document type, nests elements
295/// deeper than [`MAX_DEPTH`], or holds more than [`MAX_NODES`] nodes over its documents;
296/// [`LogError::NotThisFormat`] if its root element isn't `kml`; [`LogError::NoData`] if it holds
297/// no point.
298pub fn read(text: &str) -> Result<FlightLog, LogError> {
299    read_within(text, MAX_NODES)
300}
301
302/// [`read`], with `max_nodes` XML nodes for the whole file, so tests can reach the limit cheaply.
303fn read_within(text: &str, max_nodes: u32) -> Result<FlightLog, LogError> {
304    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
305    let (pieces, more) = split_documents(text, max_nodes)?;
306    let mut copies = 0;
307    let mut left_out = usize::from(more);
308    let mut first: Option<(&str, Document<'_>)> = None;
309    let mut later: Vec<Document<'_>> = Vec::new();
310    // The nodes the documents held at once may still take: one budget for the file, not one a
311    // document, so that several documents can't hold more than one may.
312    let mut nodes_left = max_nodes;
313    let mut parse_within = |piece| {
314        let document = parse(piece, nodes_left)?;
315        let used = u32::try_from(document.descendants().count()).unwrap_or(u32::MAX);
316        nodes_left = nodes_left.saturating_sub(used);
317        Ok::<_, LogError>(document)
318    };
319    for piece in pieces {
320        if let Some((first_text, _)) = &first {
321            if piece.trim() == first_text.trim() {
322                copies += 1;
323            } else if later.len() + 1 >= MAX_DOCUMENTS {
324                left_out += 1;
325            } else {
326                later.push(parse_within(piece)?);
327            }
328        } else {
329            first = Some((piece, parse_within(piece)?));
330        }
331    }
332    let Some((_, first)) = first else {
333        // `split_documents` gives at least one piece, or an error.
334        return Err(LogError::NoData { format: FORMAT });
335    };
336    let mut reader = Reader {
337        copies,
338        documents_left_out: left_out,
339        later_documents: later.len(),
340        ..Reader::default()
341    };
342    for (index, document) in std::iter::once(&first).chain(&later).enumerate() {
343        let root = document.root_element();
344        if root.tag_name().name() != "kml" {
345            return Err(LogError::NotThisFormat {
346                format: FORMAT,
347                message: format!(
348                    "the root element is {:?}, not `kml`",
349                    short(root.tag_name().name())
350                ),
351            });
352        }
353        reader.read_document(document, index);
354    }
355    let about = About::of(&first, &reader);
356    reader.finish(about)
357}
358
359/// The file's text cut into its KML documents, as a writer may write several one after another,
360/// each with its own XML declaration; and whether more follow past [`MAX_DOCUMENTS`] × 2 pieces.
361/// Each trial parse is dropped before the next, so no more than one is held at a time.
362fn split_documents(text: &str, max_nodes: u32) -> Result<(Vec<&str>, bool), LogError> {
363    let mut pieces = Vec::new();
364    let mut rest = text;
365    loop {
366        match parse_xml(rest, max_nodes) {
367            Ok(_) => {
368                pieces.push(rest);
369                return Ok((pieces, false));
370            }
371            Err(XmlFailure::Xml(roxmltree::Error::UnexpectedDeclaration(position))) => {
372                let at = offset_of(rest, position)
373                    .filter(|&at| at > 0 && rest[at..].starts_with("<?xml"))
374                    .ok_or_else(|| {
375                        xml_error(&XmlFailure::Xml(roxmltree::Error::UnexpectedDeclaration(
376                            position,
377                        )))
378                    })?;
379                pieces.push(&rest[..at]);
380                rest = &rest[at..];
381                if pieces.len() >= 2 * MAX_DOCUMENTS {
382                    return Ok((pieces, true));
383                }
384            }
385            Err(failure) => return Err(xml_error(&failure)),
386        }
387    }
388}
389
390/// Why a text didn't parse.
391#[derive(Debug)]
392enum XmlFailure {
393    /// It nests elements deeper than [`MAX_DEPTH`]: how deep, and the line where it first does.
394    Deep { deepest: usize, line: usize },
395    /// The parser's own error.
396    Xml(roxmltree::Error),
397}
398
399/// The text parsed as XML, once its nesting is found no deeper than [`MAX_DEPTH`], with at most
400/// `nodes` nodes and no document type declaration.
401fn parse_xml(text: &str, nodes: u32) -> Result<Document<'_>, XmlFailure> {
402    let (deepest, line) = nesting(text);
403    if deepest > MAX_DEPTH {
404        return Err(XmlFailure::Deep { deepest, line });
405    }
406    let options = ParsingOptions {
407        nodes_limit: nodes,
408        ..ParsingOptions::default()
409    };
410    Document::parse_with_options(text, options).map_err(XmlFailure::Xml)
411}
412
413/// [`parse_xml`], its error the reader's.
414fn parse(text: &str, nodes: u32) -> Result<Document<'_>, LogError> {
415    parse_xml(text, nodes).map_err(|failure| xml_error(&failure))
416}
417
418/// A failure to parse as the reader's error.
419fn xml_error(failure: &XmlFailure) -> LogError {
420    let (line, message) = match failure {
421        XmlFailure::Deep { deepest, line } => (
422            *line,
423            format!(
424                "the XML nests elements {deepest} deep, past the {MAX_DEPTH} this reader reads, \
425                 so that the parser can't exhaust its stack"
426            ),
427        ),
428        XmlFailure::Xml(error) => (
429            usize::try_from(error.pos().row).unwrap_or(usize::MAX),
430            match error {
431                roxmltree::Error::DtdDetected => "the file declares a document type, which this \
432                     reader refuses so that no entity can expand"
433                    .to_owned(),
434                roxmltree::Error::NodesLimitReached => {
435                    format!("the file holds more than {MAX_NODES} XML nodes over its documents")
436                }
437                error => format!("the XML isn't well formed: {error}"),
438            },
439        ),
440    };
441    LogError::Syntax {
442        format: FORMAT,
443        line,
444        message,
445    }
446}
447
448/// How deep the text nests its elements, counted without parsing it, and the 1-based line where
449/// the nesting first passes [`MAX_DEPTH`] (0 if it doesn't). Comments, CDATA sections, processing
450/// instructions and declarations change no depth; a `>` inside a quoted attribute value doesn't
451/// end its tag. Adapted from the `.ork` reader's count (`hpr_io::ork`), the same project's code.
452fn nesting(text: &str) -> (usize, usize) {
453    let bytes = text.as_bytes();
454    let mut index = 0;
455    let mut depth = 0usize;
456    let mut deepest = 0usize;
457    let mut past_at = None;
458    while index < bytes.len() {
459        if bytes[index] != b'<' {
460            index += 1;
461            continue;
462        }
463        let rest = &bytes[index..];
464        if let Some(skip) = skipped(rest, b"<!--", b"-->")
465            .or_else(|| skipped(rest, b"<![CDATA[", b"]]>"))
466            .or_else(|| skipped(rest, b"<?", b"?>"))
467        {
468            index += skip;
469            continue;
470        }
471        let closing = rest.starts_with(b"</");
472        let declaration = rest.starts_with(b"<!");
473        let mut cursor = index + 1;
474        let mut quote: Option<u8> = None;
475        let mut last = 0u8;
476        while cursor < bytes.len() {
477            let byte = bytes[cursor];
478            match quote {
479                Some(open) if byte == open => quote = None,
480                Some(_) => {}
481                None if byte == b'"' || byte == b'\'' => quote = Some(byte),
482                None if byte == b'>' => break,
483                None => {}
484            }
485            if !byte.is_ascii_whitespace() {
486                last = byte;
487            }
488            cursor += 1;
489        }
490        if !declaration {
491            if closing {
492                depth = depth.saturating_sub(1);
493            } else {
494                depth += 1;
495                deepest = deepest.max(depth);
496                if depth > MAX_DEPTH && past_at.is_none() {
497                    past_at = Some(index);
498                }
499                if last == b'/' {
500                    depth -= 1;
501                }
502            }
503        }
504        index = cursor + 1;
505    }
506    let line = past_at.map_or(0, |at| {
507        bytes[..at].iter().filter(|&&byte| byte == b'\n').count() + 1
508    });
509    (deepest, line)
510}
511
512/// When `rest` starts with `opening`, the length up to and including the next `closing`, or to the
513/// end of the text when there is none.
514fn skipped(rest: &[u8], opening: &[u8], closing: &[u8]) -> Option<usize> {
515    if !rest.starts_with(opening) {
516        return None;
517    }
518    let from = opening.len();
519    let found = rest[from..]
520        .windows(closing.len())
521        .position(|window| window == closing)
522        .map(|at| from + at + closing.len());
523    Some(found.unwrap_or(rest.len()))
524}
525
526/// The byte offset of a parser's position (1-based row, by `\n`, and column, in characters).
527fn offset_of(text: &str, position: roxmltree::TextPos) -> Option<usize> {
528    let mut line_start = 0;
529    for _ in 1..position.row {
530        line_start += text.get(line_start..)?.find('\n')? + 1;
531    }
532    let line = text.get(line_start..)?;
533    let column = usize::try_from(position.col).ok()?.checked_sub(1)?;
534    line.char_indices()
535        .map(|(index, _)| index)
536        .chain(std::iter::once(line.len()))
537        .nth(column)
538        .map(|index| line_start + index)
539}
540
541/// How a viewer places a track's altitude: its geometry's `altitudeMode` or `gx:altitudeMode`.
542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
543enum Mode {
544    /// `absolute`: above the vertical datum, sea level.
545    Absolute,
546    /// `relativeToGround`: above the terrain.
547    RelativeToGround,
548    /// `relativeToSeaFloor`: above the sea floor, or the terrain on land.
549    RelativeToSeaFloor,
550    /// A mode in which a viewer ignores the altitude: as written, or `None` where none is stated
551    /// and the default, `clampToGround`, holds.
552    Ignored(Option<&'static str>),
553    /// A mode the standard doesn't name.
554    Unnamed,
555}
556
557impl Mode {
558    /// The geometry's mode, from its child `altitudeMode` in either namespace.
559    fn of(geometry: Node<'_, '_>) -> Self {
560        let Some(text) = child(geometry, "altitudeMode").map(|node| all_text(node)) else {
561            return Self::Ignored(None);
562        };
563        match text.trim() {
564            "absolute" => Self::Absolute,
565            "relativeToGround" => Self::RelativeToGround,
566            "relativeToSeaFloor" => Self::RelativeToSeaFloor,
567            "clampToGround" => Self::Ignored(Some("clampToGround")),
568            "clampToSeaFloor" => Self::Ignored(Some("clampToSeaFloor")),
569            _ => Self::Unnamed,
570        }
571    }
572}
573
574/// Which clock a point's time is on.
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576enum Clock {
577    /// A `when` with its zone: UTC.
578    Utc,
579    /// A `when` with no zone: a local time in a zone the file doesn't state.
580    Local,
581    /// AltOS's comment `time`, seconds from an unstated zero.
582    AltOs,
583    /// The `Seconds` of a comment, from an unstated zero.
584    Seconds,
585    /// The BeeLine comment's UTC time of day.
586    TimeOfDay,
587}
588
589impl Clock {
590    /// The word the file names the time by, for notes.
591    fn word(self) -> &'static str {
592        match self {
593            Self::Utc | Self::Local => "`when`",
594            Self::AltOs => "`time`",
595            Self::Seconds => "`Seconds`",
596            Self::TimeOfDay => "`UTC`",
597        }
598    }
599}
600
601/// What AltOS's comment `time` is in a file.
602#[derive(Debug, Clone, Copy, PartialEq)]
603enum AltosTime {
604    /// The file holds no AltOS comment.
605    Absent,
606    /// 0 beside every point: unset.
607    Unset,
608    /// In seconds, from the median step between a track's points of distinct times, s.
609    Seconds(f64),
610    /// In a unit its steps don't show: the median step read as seconds, `NaN` if there is none.
611    Unsettled(f64),
612}
613
614/// How often a time of day was carried past midnight.
615#[derive(Debug, Clone, Copy, Default)]
616struct Midnights {
617    /// The days added.
618    days: usize,
619    /// The times of day made gaps past [`MAX_MIDNIGHTS`].
620    dropped: usize,
621}
622
623/// A point's time as the file writes it: nanoseconds on a clock, and the text it came from.
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625struct Stamp<'a> {
626    clock: Clock,
627    ns: i64,
628    raw: &'a str,
629}
630
631/// The layout of a comment beside a point.
632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633enum Layout {
634    /// `alt X time Y sats N`.
635    AltOs,
636    /// `Alt X Seconds Y`.
637    Seconds,
638    /// `OFFSET sats:N UTC HH:MM:SS`.
639    BeeLine,
640}
641
642/// What a comment beside a point says.
643#[derive(Debug, Clone, Copy, PartialEq)]
644struct Remark<'a> {
645    layout: Layout,
646    /// The comment's altitude, `NaN` if it has none.
647    alt: f64,
648    /// The comment's time, nanoseconds, and its text.
649    time: Option<(i64, &'a str)>,
650    /// The satellite count, `NaN` if none.
651    sats: f64,
652    /// The BeeLine's first number, `NaN` if none.
653    offset: f64,
654}
655
656/// A comment beside a point, read; `None` if it is in none of the three layouts.
657fn remark(text: &str) -> Option<Remark<'_>> {
658    let mut tokens = [""; 6];
659    let mut count = 0;
660    for token in text.split_ascii_whitespace() {
661        *tokens.get_mut(count)? = token;
662        count += 1;
663    }
664    let tokens = &tokens[..count];
665    if let [offset, sats, utc, time] = tokens
666        && utc.eq_ignore_ascii_case("UTC")
667        && let Some(sats) = sats.strip_prefix("sats:")
668    {
669        return Some(Remark {
670            layout: Layout::BeeLine,
671            alt: f64::NAN,
672            time: Some((time_of_day_ns(time)?, *time)),
673            sats: number(sats, false)?,
674            offset: number(offset, false)?,
675        });
676    }
677    match tokens {
678        [alt_key, alt, time_key, time, sats_key, sats]
679            if alt_key.eq_ignore_ascii_case("alt")
680                && time_key.eq_ignore_ascii_case("time")
681                && sats_key.eq_ignore_ascii_case("sats") =>
682        {
683            Some(Remark {
684                layout: Layout::AltOs,
685                alt: number(alt, false)?,
686                time: Some((decimal_ns(time)?, *time)),
687                sats: number(sats, false)?,
688                offset: f64::NAN,
689            })
690        }
691        [alt_key, alt, seconds_key, seconds]
692            if alt_key.eq_ignore_ascii_case("alt")
693                && seconds_key.eq_ignore_ascii_case("seconds") =>
694        {
695            Some(Remark {
696                layout: Layout::Seconds,
697                alt: number(alt, false)?,
698                time: Some((decimal_ns(seconds)?, *seconds)),
699                sats: f64::NAN,
700                offset: f64::NAN,
701            })
702        }
703        _ => None,
704    }
705}
706
707/// A decimal number of seconds as nanoseconds, exactly to the ninth decimal (further digits are
708/// dropped): an optional sign, digits, an optional point and digits, no exponent. `None` if it
709/// isn't one, or is beyond ±9 × 10⁹ s.
710fn decimal_ns(text: &str) -> Option<i64> {
711    let (negative, digits) = match text.as_bytes().first()? {
712        b'-' => (true, &text[1..]),
713        b'+' => (false, &text[1..]),
714        _ => (false, text),
715    };
716    let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
717    if whole.is_empty() && fraction.is_empty()
718        || digits.ends_with('.')
719        || !whole
720            .bytes()
721            .chain(fraction.bytes())
722            .all(|b| b.is_ascii_digit())
723        || whole.len() > 10
724    {
725        return None;
726    }
727    let seconds: i64 = if whole.is_empty() {
728        0
729    } else {
730        whole.parse().ok()?
731    };
732    let mut nanos: i64 = 0;
733    for digit in fraction.bytes().chain(std::iter::repeat(b'0')).take(9) {
734        nanos = nanos * 10 + i64::from(digit - b'0');
735    }
736    let total = seconds.checked_mul(NS_PER_S)?.checked_add(nanos)?;
737    Some(if negative { -total } else { total })
738}
739
740/// A time of day, `HH:MM:SS` with optional decimals, as nanoseconds after midnight.
741fn time_of_day_ns(text: &str) -> Option<i64> {
742    let mut parts = text.split(':');
743    let (hours, minutes, seconds) = (parts.next()?, parts.next()?, parts.next()?);
744    if parts.next().is_some()
745        || hours.len() != 2
746        || minutes.len() != 2
747        || !hours
748            .bytes()
749            .chain(minutes.bytes())
750            .all(|b| b.is_ascii_digit())
751        || seconds.split('.').next().map(str::len) != Some(2)
752    {
753        return None;
754    }
755    let hours: i64 = hours.parse().ok()?;
756    let minutes: i64 = minutes.parse().ok()?;
757    let seconds = decimal_ns(seconds).filter(|ns| (0..61 * NS_PER_S).contains(ns))?;
758    (hours < 24 && minutes < 60).then_some((hours * 3_600 + minutes * 60) * NS_PER_S + seconds)
759}
760
761/// A `when` as nanoseconds since 1970-01-01T00:00:00 on its clock: `YYYY-MM-DDThh:mm:ss`, optional
762/// decimals, then `Z`, `±hh:mm` or nothing ([`Clock::Local`]). `None` for a date without a time
763/// of day (XML Schema's `date`, `gYearMonth`, `gYear`), which places no point in a flight, or for
764/// anything else.
765fn when_ns(text: &str) -> Option<(Clock, i64)> {
766    let text = text.trim();
767    let (date, rest) = text.split_once('T')?;
768    let mut fields = date.split('-');
769    let (year, month, day) = (fields.next()?, fields.next()?, fields.next()?);
770    if fields.next().is_some()
771        || year.len() != 4
772        || month.len() != 2
773        || day.len() != 2
774        || !year
775            .bytes()
776            .chain(month.bytes())
777            .chain(day.bytes())
778            .all(|b| b.is_ascii_digit())
779    {
780        return None;
781    }
782    let (year, month, day): (i64, i64, i64) =
783        (year.parse().ok()?, month.parse().ok()?, day.parse().ok()?);
784    if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
785        return None;
786    }
787    let (time, clock, offset_s) = if let Some(time) = rest.strip_suffix('Z') {
788        (time, Clock::Utc, 0)
789    } else if let Some(at) = rest.rfind(['+', '-']).filter(|&at| at >= 8) {
790        let (time, zone) = rest.split_at(at);
791        let sign = if zone.starts_with('-') { -1 } else { 1 };
792        let (hours, minutes) = zone[1..].split_once(':')?;
793        if hours.len() != 2 || minutes.len() != 2 {
794            return None;
795        }
796        let hours: i64 = hours.parse().ok()?;
797        let minutes: i64 = minutes.parse().ok()?;
798        if hours > 14 || minutes > 59 {
799            return None;
800        }
801        (time, Clock::Utc, sign * (hours * 3_600 + minutes * 60))
802    } else {
803        (rest, Clock::Local, 0)
804    };
805    let of_day = time_of_day_ns(time)?;
806    let days = days_from_civil(year, month, day);
807    let ns = days
808        .checked_mul(DAY_NS)?
809        .checked_add(of_day)?
810        .checked_sub(offset_s.checked_mul(NS_PER_S)?)?;
811    Some((clock, ns))
812}
813
814/// Days in a month of the Gregorian calendar.
815fn days_in_month(year: i64, month: i64) -> i64 {
816    match month {
817        2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
818        2 => 28,
819        4 | 6 | 9 | 11 => 30,
820        _ => 31,
821    }
822}
823
824/// Days from 1970-01-01 to a date of the proleptic Gregorian calendar (H. Hinnant,
825/// *chrono-Compatible Low-Level Date Algorithms*, `days_from_civil`, public domain).
826fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
827    let year = if month <= 2 { year - 1 } else { year };
828    let era = year.div_euclid(400);
829    let year_of_era = year - era * 400;
830    let shifted_month = (month + 9) % 12;
831    let day_of_year = (153 * shifted_month + 2) / 5 + day - 1;
832    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
833    era * 146_097 + day_of_era - 719_468
834}
835
836/// A coordinate tuple: longitude, latitude and an optional altitude, from `lon,lat[,alt]` (a
837/// `coordinates` tuple) or, if `gx`, `lon lat [alt]` separated by whitespace (a `gx:coord`). `None`
838/// if it isn't two or three finite numbers.
839fn tuple(text: &str, gx: bool) -> Option<(f64, f64, Option<f64>)> {
840    let mut parts: [&str; 3] = [""; 3];
841    let count = if gx {
842        fill(&mut parts, text.split_ascii_whitespace())?
843    } else {
844        fill(&mut parts, text.split(','))?
845    };
846    let value = |part: &str| number(part, false);
847    match count {
848        2 => Some((value(parts[0])?, value(parts[1])?, None)),
849        3 => Some((value(parts[0])?, value(parts[1])?, Some(value(parts[2])?))),
850        _ => None,
851    }
852}
853
854/// Puts the parts in the slots, in order; how many there were, or `None` if more than slots.
855fn fill<'t>(slots: &mut [&'t str], parts: impl Iterator<Item = &'t str>) -> Option<usize> {
856    let mut count = 0;
857    for part in parts {
858        *slots.get_mut(count)? = part;
859        count += 1;
860    }
861    Some(count)
862}
863
864/// The tuples of a `coordinates` text: split at whitespace, with whitespace beside a comma taken
865/// as part of the tuple, as some writers write `lon, lat, alt`.
866fn tuples(text: &str) -> Vec<Cow<'_, str>> {
867    let mut out: Vec<Cow<'_, str>> = Vec::new();
868    let mut current: Option<Cow<'_, str>> = None;
869    for token in text.split_ascii_whitespace() {
870        current = Some(match current.take() {
871            Some(previous) if previous.ends_with(',') || token.starts_with(',') => {
872                let mut joined = previous.into_owned();
873                joined.push_str(token);
874                Cow::Owned(joined)
875            }
876            Some(previous) => {
877                out.push(previous);
878                Cow::Borrowed(token)
879            }
880            None => Cow::Borrowed(token),
881        });
882    }
883    out.extend(current);
884    out
885}
886
887/// An element's first child element of a local name, in any namespace.
888fn child<'a, 'input>(node: Node<'a, 'input>, name: &str) -> Option<Node<'a, 'input>> {
889    node.children()
890        .find(|child| child.is_element() && child.tag_name().name() == name)
891}
892
893/// An element's text, its text and CDATA children joined.
894fn all_text(node: Node<'_, '_>) -> String {
895    node.children()
896        .filter(|child| child.is_text())
897        .filter_map(|child| child.text())
898        .collect()
899}
900
901/// A name as a channel's: its runs of whitespace one space, cut to the length a note quotes.
902fn clean(name: &str) -> String {
903    short(&name.split_whitespace().collect::<Vec<_>>().join(" "))
904}
905
906/// One geometry read: a path, a point or a timed track.
907struct Track<'a> {
908    /// Its placemark's name, or `track`.
909    label: String,
910    /// Its placemark, by document and node, to number several geometries in one.
911    placemark: Option<(usize, NodeId)>,
912    /// Whether it is a `Point`.
913    point: bool,
914    mode: Mode,
915    lat: Vec<f64>,
916    lon: Vec<f64>,
917    /// Each point's altitude, `NaN` where the tuple has none.
918    alt: Vec<f64>,
919    stamps: Vec<Option<Stamp<'a>>>,
920    remarks: Vec<Option<Remark<'a>>>,
921}
922
923/// What the reader gathers over the file's documents.
924#[derive(Default)]
925struct Reader<'a> {
926    tracks: Vec<Track<'a>>,
927    tracks_left_out: usize,
928    points: usize,
929    points_left_out: usize,
930    copies: usize,
931    later_documents: usize,
932    documents_left_out: usize,
933    /// Geometries that aren't tracks: polygons and models.
934    other_geometries: usize,
935    /// Geometries with no point.
936    empty_geometries: usize,
937    /// Comments in a document outside any `coordinates`.
938    loose_comments: usize,
939    /// Comments in a `coordinates` beside no point.
940    stray_comments: usize,
941    /// Comments beside a point in no known layout.
942    unknown_comments: usize,
943    /// Comments outside a document's root element: count, longest, and whether one opens
944    /// `BRBDATA`.
945    outside_comments: usize,
946    longest_outside: usize,
947    memory_dump: bool,
948    bad_tuples: usize,
949    bad_tuple_example: Option<String>,
950    out_of_range: usize,
951    bad_whens: usize,
952    bad_when_example: Option<String>,
953    notes: Notes,
954}
955
956impl<'a> Reader<'a> {
957    /// Reads one document's tracks and counts its comments.
958    fn read_document(&mut self, document: &'a Document<'a>, index: usize) {
959        for node in document.root().children() {
960            if node.is_comment() {
961                let text = node.text().unwrap_or_default();
962                self.outside_comments += 1;
963                self.longest_outside = self.longest_outside.max(text.chars().count());
964                self.memory_dump |= text.trim_start().starts_with("BRBDATA");
965            }
966        }
967        for node in document.root_element().descendants() {
968            if node.is_comment() {
969                if !node
970                    .parent()
971                    .is_some_and(|parent| parent.tag_name().name() == "coordinates")
972                {
973                    self.loose_comments += 1;
974                }
975                continue;
976            }
977            if !node.is_element() {
978                continue;
979            }
980            match node.tag_name().name() {
981                "LineString" | "Point" | "Track" => self.read_geometry(node, index),
982                "Polygon" | "Model" => self.other_geometries += 1,
983                _ => {}
984            }
985        }
986    }
987
988    /// Reads a geometry into a track.
989    fn read_geometry(&mut self, node: Node<'a, 'a>, document: usize) {
990        if self.tracks.len() >= MAX_TRACKS {
991            self.tracks_left_out += 1;
992            return;
993        }
994        let placemark = node
995            .ancestors()
996            .skip(1)
997            .find(|ancestor| ancestor.is_element() && ancestor.tag_name().name() == "Placemark");
998        let label = placemark
999            .and_then(|placemark| child(placemark, "name"))
1000            .map(|name| clean(&all_text(name)))
1001            .filter(|name| !name.is_empty())
1002            .unwrap_or_else(|| "track".to_owned());
1003        let point = node.tag_name().name() == "Point";
1004        let mut track = Track {
1005            label,
1006            placemark: placemark.map(|placemark| (document, placemark.id())),
1007            point,
1008            mode: Mode::of(node),
1009            lat: Vec::new(),
1010            lon: Vec::new(),
1011            alt: Vec::new(),
1012            stamps: Vec::new(),
1013            remarks: Vec::new(),
1014        };
1015        if node.tag_name().name() == "Track" {
1016            self.read_gx_track(node, &mut track);
1017        } else if let Some(coordinates) = child(node, "coordinates") {
1018            self.read_coordinates(coordinates, &mut track);
1019        }
1020        if point
1021            && let Some(when) = placemark
1022                .and_then(|placemark| child(placemark, "TimeStamp"))
1023                .and_then(|stamp| child(stamp, "when"))
1024                .and_then(|when| when.text())
1025        {
1026            let stamp = self.stamp_of_when(when);
1027            track.stamps.fill(stamp);
1028        }
1029        if track.lat.is_empty() {
1030            self.empty_geometries += 1;
1031        } else {
1032            self.tracks.push(track);
1033        }
1034    }
1035
1036    /// Adds a point to a track, if the file's points aren't yet [`MAX_POINTS`]; whether it did.
1037    fn push(
1038        &mut self,
1039        track: &mut Track<'a>,
1040        raw: &str,
1041        read: Option<(f64, f64, Option<f64>)>,
1042    ) -> bool {
1043        if self.points >= MAX_POINTS {
1044            self.points_left_out += 1;
1045            return false;
1046        }
1047        self.points += 1;
1048        let (lon, lat, alt) = read.unwrap_or_else(|| {
1049            self.bad_tuples += 1;
1050            if self.bad_tuple_example.is_none() {
1051                self.bad_tuple_example = Some(short(raw));
1052            }
1053            (f64::NAN, f64::NAN, Some(f64::NAN))
1054        });
1055        let (lon, lat) =
1056            if lon.is_nan() || ((-180.0..=180.0).contains(&lon) && (-90.0..=90.0).contains(&lat)) {
1057                (lon, lat)
1058            } else {
1059                self.out_of_range += 1;
1060                (f64::NAN, f64::NAN)
1061            };
1062        track.lon.push(lon);
1063        track.lat.push(lat);
1064        track.alt.push(alt.unwrap_or(f64::NAN));
1065        track.stamps.push(None);
1066        track.remarks.push(None);
1067        true
1068    }
1069
1070    /// Reads a `coordinates` element's tuples and the comments beside them.
1071    fn read_coordinates(&mut self, coordinates: Node<'a, 'a>, track: &mut Track<'a>) {
1072        // Whether the last tuple read may still take the comment after it.
1073        let mut open = false;
1074        for node in coordinates.children() {
1075            if node.is_text() {
1076                for raw in tuples(node.text().unwrap_or_default()) {
1077                    open = self.push(track, &raw, tuple(&raw, false));
1078                }
1079            } else if node.is_comment() {
1080                let text = node.text().unwrap_or_default();
1081                if !open {
1082                    self.stray_comments += 1;
1083                    continue;
1084                }
1085                open = false;
1086                match (remark(text), track.remarks.last_mut()) {
1087                    (Some(read), Some(slot)) => *slot = Some(read),
1088                    _ => self.unknown_comments += 1,
1089                }
1090            }
1091        }
1092    }
1093
1094    /// Reads a `gx:Track`'s `when` and `gx:coord` pairs.
1095    fn read_gx_track(&mut self, node: Node<'a, 'a>, track: &mut Track<'a>) {
1096        let named = |name: &'static str| {
1097            node.children()
1098                .filter(move |child| child.is_element() && child.tag_name().name() == name)
1099        };
1100        let (whens, coords) = (named("when").count(), named("coord").count());
1101        if whens != coords {
1102            self.notes.push(format!(
1103                "a `gx:Track` of {:?} holds {whens} `when` and {coords} `gx:coord`, where the \
1104                 standard has as many of each: the first {} of each are paired, the rest left out",
1105                track.label,
1106                whens.min(coords)
1107            ));
1108        }
1109        for (when, coord) in named("when").zip(named("coord")) {
1110            let raw = coord.text().unwrap_or_default();
1111            if !self.push(track, raw, tuple(raw, true)) {
1112                break;
1113            }
1114            let stamp = self.stamp_of_when(when.text().unwrap_or_default());
1115            if let Some(slot) = track.stamps.last_mut() {
1116                *slot = stamp;
1117            }
1118        }
1119    }
1120
1121    /// A `when` as a point's time, counting one that isn't a time of day.
1122    fn stamp_of_when(&mut self, raw: &'a str) -> Option<Stamp<'a>> {
1123        let read = when_ns(raw).map(|(clock, ns)| Stamp {
1124            clock,
1125            ns,
1126            raw: raw.trim(),
1127        });
1128        if read.is_none() {
1129            self.bad_whens += 1;
1130            if self.bad_when_example.is_none() {
1131                self.bad_when_example = Some(short(raw.trim()));
1132            }
1133        }
1134        read
1135    }
1136
1137    /// The record: each track's channels, the events, the notes.
1138    fn finish(mut self, about: About) -> Result<FlightLog, LogError> {
1139        if self.tracks.is_empty() {
1140            return Err(LogError::NoData { format: FORMAT });
1141        }
1142        let mut notes = vec![
1143            "the record's altitude track is empty: a KML file holds tracks of positions, not an \
1144             altimeter's record over time, so every value is a channel"
1145                .to_owned(),
1146        ];
1147        notes.extend(about.note.clone());
1148        self.document_notes(&mut notes);
1149
1150        let altos_time = self.altos_time();
1151        if altos_time == AltosTime::Unset {
1152            notes.push(
1153                "the comments' `time` is 0 beside every point, as AltOS leaves it unset in \
1154                 most of its exports: those points have no time"
1155                    .to_owned(),
1156            );
1157        }
1158        self.stamp_from_remarks(matches!(altos_time, AltosTime::Seconds(_)));
1159        let midnights = self.pass_midnight();
1160        let times = self.times(&mut notes, midnights);
1161        let names = self.names();
1162        // AltOS's `alt` shows its unit where it equals the tuple's, meters by the standard.
1163        let altos_alt_meters = agreement(&self.tracks, Layout::AltOs).0 > 0;
1164
1165        let mut channels = Vec::new();
1166        let mut events = Vec::new();
1167        let mut decided = Decided::default();
1168        for ((track, time_s), name) in self.tracks.iter().zip(&times).zip(&names) {
1169            if track.point
1170                && let Some(&time) = time_s.first()
1171                && time.is_finite()
1172            {
1173                events.push(Event {
1174                    time_s: time,
1175                    name: track.label.clone(),
1176                });
1177            }
1178            let mut add = |suffix: &str, quantity: Quantity, values: Vec<f64>| {
1179                channels.push(Channel {
1180                    column: format!("{name} {suffix}"),
1181                    quantity,
1182                    time_s: time_s.clone(),
1183                    values,
1184                });
1185            };
1186            add("latitude", Quantity::Latitude, track.lat.clone());
1187            add("longitude", Quantity::Longitude, track.lon.clone());
1188            let altitudes = track.alt.iter().filter(|alt| !alt.is_nan()).count();
1189            let remark_alt: Vec<f64> = track
1190                .remarks
1191                .iter()
1192                .map(|remark| remark.map_or(f64::NAN, |remark| remark.alt))
1193                .collect();
1194            let differs =
1195                track.alt.iter().zip(&remark_alt).any(|(tuple, comment)| {
1196                    !comment.is_nan() && !(tuple - comment).abs().le(&1e-6)
1197                });
1198            let layout = track
1199                .remarks
1200                .iter()
1201                .flatten()
1202                .map(|remark| remark.layout)
1203                .next();
1204            // The points carrying both altitudes, and those where the two differ: an AltOS track
1205            // is the barometer's where they differ at more than half (all or none in the samples).
1206            let (both, differing) = track
1207                .alt
1208                .iter()
1209                .zip(&remark_alt)
1210                .filter(|(tuple, comment)| !tuple.is_nan() && !comment.is_nan())
1211                .fold((0, 0), |(both, differing), (tuple, comment)| {
1212                    (
1213                        both + 1,
1214                        differing + usize::from((tuple - comment).abs() > 1e-6),
1215                    )
1216                });
1217            let barometric = (layout == Some(Layout::AltOs) && differing * 2 > both)
1218                .then_some((both, differing));
1219            if altitudes > 0 {
1220                let (suffix, quantity) = decided.altitude(track, &about, barometric);
1221                add(suffix, quantity, track.alt.clone());
1222            }
1223            if differs {
1224                let (suffix, quantity) = decided.remark_altitude(layout, altos_alt_meters);
1225                add(suffix, quantity, remark_alt);
1226            }
1227            if matches!(altos_time, AltosTime::Unsettled(_)) && layout == Some(Layout::AltOs) {
1228                // The `time` as written: nanoseconds back to the decimal the comment wrote.
1229                let written: Vec<f64> = track
1230                    .remarks
1231                    .iter()
1232                    .map(|remark| {
1233                        remark
1234                            .and_then(|remark| remark.time)
1235                            .map_or(f64::NAN, |(ns, _)| ns as f64 / NS_PER_S as f64)
1236                    })
1237                    .collect();
1238                add("time", Quantity::Unrecognized, written);
1239            }
1240            let sats: Vec<f64> = track
1241                .remarks
1242                .iter()
1243                .map(|remark| remark.map_or(f64::NAN, |remark| remark.sats))
1244                .collect();
1245            if sats.iter().any(|sats| !sats.is_nan()) {
1246                add("satellites", Quantity::Number, sats);
1247            }
1248            let offsets: Vec<f64> = track
1249                .remarks
1250                .iter()
1251                .map(|remark| remark.map_or(f64::NAN, |remark| remark.offset))
1252                .collect();
1253            if offsets.iter().any(|offset| !offset.is_nan()) {
1254                add("offset", Quantity::Number, offsets);
1255            }
1256        }
1257        notes.extend(decided.notes(&self.tracks, altos_time));
1258        notes.extend(self.notes.into_vec());
1259
1260        Ok(FlightLog {
1261            format: LogFormat::Kml,
1262            logger: about.logger,
1263            serial_number: about.serial_number,
1264            firmware: about.firmware,
1265            flight_number: about.flight_number,
1266            stated: Stated {
1267                apogee_m: about.apogee_m,
1268                ..Stated::default()
1269            },
1270            time_s: Vec::new(),
1271            altitude_m: Vec::new(),
1272            vertical_speed_m_s: None,
1273            temperature_k: None,
1274            battery_v: None,
1275            channels,
1276            events,
1277            notes,
1278        })
1279    }
1280
1281    /// Notes on the documents, the comments and the points left out.
1282    fn document_notes(&self, notes: &mut Vec<String>) {
1283        if self.copies > 0 {
1284            notes.push(format!(
1285                "the file holds its KML document {} times over, one after another, each the same: \
1286                 it is read once",
1287                self.copies + 1
1288            ));
1289        }
1290        if self.later_documents > 0 {
1291            notes.push(format!(
1292                "the file holds {} more KML documents after the first, different from it: their \
1293                 tracks are read after the first's",
1294                self.later_documents
1295            ));
1296        }
1297        if self.documents_left_out > 0 {
1298            notes.push(format!(
1299                "the file holds more KML documents than the {MAX_DOCUMENTS} read: the rest are \
1300                 left out"
1301            ));
1302        }
1303        if self.outside_comments > 0 {
1304            let whose = if self.memory_dump {
1305                ", as Big Red Bee's BeeLine writes its memory in hexadecimal there"
1306            } else {
1307                ""
1308            };
1309            notes.push(format!(
1310                "the file holds {} comments outside its document, the longest {} characters{whose}: \
1311                 they are not read",
1312                self.outside_comments, self.longest_outside
1313            ));
1314        }
1315        if self.loose_comments > 0 {
1316            notes.push(format!(
1317                "the document holds {} comments outside any `coordinates`: they are not read",
1318                self.loose_comments
1319            ));
1320        }
1321        if self.stray_comments > 0 {
1322            notes.push(format!(
1323                "{} comments in `coordinates` follow no point's tuple: they are not read",
1324                self.stray_comments
1325            ));
1326        }
1327        if self.unknown_comments > 0 {
1328            notes.push(format!(
1329                "{} comments beside points are in no layout this reader knows: they are not read",
1330                self.unknown_comments
1331            ));
1332        }
1333        if self.bad_tuples > 0 {
1334            notes.push(format!(
1335                "{} coordinate tuples aren't two or three numbers, such as {:?}: their points are \
1336                 gaps",
1337                self.bad_tuples,
1338                self.bad_tuple_example.as_deref().unwrap_or_default()
1339            ));
1340        }
1341        if self.out_of_range > 0 {
1342            notes.push(format!(
1343                "{} points have a latitude beyond ±90° or a longitude beyond ±180°: their \
1344                 positions are gaps",
1345                self.out_of_range
1346            ));
1347        }
1348        if self.bad_whens > 0 {
1349            notes.push(format!(
1350                "{} `when` aren't a date and time of day, such as {:?}: those points have no time",
1351                self.bad_whens,
1352                self.bad_when_example.as_deref().unwrap_or_default()
1353            ));
1354        }
1355        if self.other_geometries > 0 {
1356            notes.push(format!(
1357                "the file holds {} polygons or models, which aren't tracks: they are not read",
1358                self.other_geometries
1359            ));
1360        }
1361        if self.empty_geometries > 0 {
1362            notes.push(format!(
1363                "{} geometries hold no point: they are left out",
1364                self.empty_geometries
1365            ));
1366        }
1367        if self.tracks_left_out > 0 {
1368            notes.push(format!(
1369                "the file holds {} more geometries than the {MAX_TRACKS} read: they are left out",
1370                self.tracks_left_out
1371            ));
1372        }
1373        if self.points_left_out > 0 {
1374            notes.push(format!(
1375                "the file holds {} more points than the {MAX_POINTS} read: they are left out",
1376                self.points_left_out
1377            ));
1378        }
1379    }
1380
1381    /// What AltOS's comment `time` is in this file: absent, unset (0 beside every point), or in
1382    /// seconds where the median step between a track's points of distinct times is within
1383    /// [`ALTOS_STEP_S`].
1384    fn altos_time(&self) -> AltosTime {
1385        let tracks = || {
1386            self.tracks.iter().map(|track| {
1387                track
1388                    .remarks
1389                    .iter()
1390                    .flatten()
1391                    .filter(|remark| remark.layout == Layout::AltOs)
1392                    .filter_map(|remark| remark.time.map(|(ns, _)| ns))
1393            })
1394        };
1395        let mut all = tracks().flatten().peekable();
1396        if all.peek().is_none() {
1397            return AltosTime::Absent;
1398        }
1399        if all.all(|ns| ns == 0) {
1400            return AltosTime::Unset;
1401        }
1402        let mut steps: Vec<i64> = Vec::new();
1403        for times in tracks() {
1404            let times: Vec<i64> = times.collect();
1405            steps.extend(
1406                times
1407                    .windows(2)
1408                    .map(|pair| pair[1].saturating_sub(pair[0]))
1409                    .filter(|step| *step > 0),
1410            );
1411        }
1412        steps.sort_unstable();
1413        let median = steps
1414            .get(steps.len() / 2)
1415            .map_or(f64::NAN, |step| *step as f64 / NS_PER_S as f64);
1416        if ALTOS_STEP_S.contains(&median) {
1417            AltosTime::Seconds(median)
1418        } else {
1419            AltosTime::Unsettled(median)
1420        }
1421    }
1422
1423    /// Gives each point without a time the one its comment states, if any; AltOS's only if
1424    /// `altos_seconds`.
1425    fn stamp_from_remarks(&mut self, altos_seconds: bool) {
1426        for track in &mut self.tracks {
1427            for (stamp, remark) in track.stamps.iter_mut().zip(&track.remarks) {
1428                let Some(remark) = remark else { continue };
1429                let clock = match remark.layout {
1430                    Layout::AltOs if !altos_seconds => continue,
1431                    Layout::AltOs => Clock::AltOs,
1432                    Layout::Seconds => Clock::Seconds,
1433                    Layout::BeeLine => Clock::TimeOfDay,
1434                };
1435                if stamp.is_none()
1436                    && let Some((ns, raw)) = remark.time
1437                {
1438                    *stamp = Some(Stamp { clock, ns, raw });
1439                }
1440            }
1441        }
1442    }
1443
1444    /// Carries a time of day past midnight: where it steps back by more than 12 h, a day is
1445    /// added from there on, up to [`MAX_MIDNIGHTS`] days. From a step back past that bound on,
1446    /// the times of day are gaps, as their day can't be told.
1447    fn pass_midnight(&mut self) -> Midnights {
1448        let mut days: i64 = 0;
1449        let mut previous: Option<i64> = None;
1450        let mut passed = Midnights::default();
1451        for stamp in self
1452            .tracks
1453            .iter_mut()
1454            .flat_map(|track| track.stamps.iter_mut())
1455            .filter(|stamp| stamp.is_some_and(|stamp| stamp.clock == Clock::TimeOfDay))
1456        {
1457            if passed.dropped > 0 {
1458                *stamp = None;
1459                passed.dropped += 1;
1460                continue;
1461            }
1462            let Some(read) = stamp.as_mut() else { continue };
1463            // A time of day is under 24 h and 61 s, and `days` at most `MAX_MIDNIGHTS`, so no
1464            // sum here comes near overflowing.
1465            let mut ns = read.ns + days * DAY_NS;
1466            if previous.is_some_and(|previous| ns < previous - DAY_NS / 2) {
1467                if days >= MAX_MIDNIGHTS {
1468                    *stamp = None;
1469                    passed.dropped = 1;
1470                    continue;
1471                }
1472                days += 1;
1473                ns += DAY_NS;
1474            }
1475            read.ns = ns;
1476            previous = Some(ns);
1477        }
1478        passed.days = usize::try_from(days).unwrap_or(0);
1479        passed
1480    }
1481
1482    /// Each track's times, s from the log's zero, and the notes on them.
1483    fn times(&self, notes: &mut Vec<String>, midnights: Midnights) -> Vec<Vec<f64>> {
1484        let all = || {
1485            self.tracks
1486                .iter()
1487                .flat_map(|track| track.stamps.iter().flatten())
1488        };
1489        let zero = all().next().copied();
1490        let points: usize = self.tracks.iter().map(|track| track.lat.len()).sum();
1491        let mut other_clock = 0;
1492        let times: Vec<Vec<f64>> = self
1493            .tracks
1494            .iter()
1495            .map(|track| {
1496                track
1497                    .stamps
1498                    .iter()
1499                    .map(|stamp| match (stamp, zero) {
1500                        (Some(stamp), Some(zero)) if stamp.clock == zero.clock => stamp
1501                            .ns
1502                            .checked_sub(zero.ns)
1503                            .map_or(f64::NAN, |ns| ns as f64 / NS_PER_S as f64),
1504                        (Some(_), _) => {
1505                            other_clock += 1;
1506                            f64::NAN
1507                        }
1508                        (None, _) => f64::NAN,
1509                    })
1510                    .collect()
1511            })
1512            .collect();
1513        let untimed = times.iter().flatten().filter(|time| time.is_nan()).count();
1514        match zero {
1515            None => notes.push(format!(
1516                "the file gives none of its {points} points a time: their times are gaps"
1517            )),
1518            Some(zero) => {
1519                let zone = match zero.clock {
1520                    Clock::TimeOfDay => " UTC",
1521                    _ => "",
1522                };
1523                notes.push(format!(
1524                    "the log's zero is the first point the file times: {} {}{zone}",
1525                    zero.clock.word(),
1526                    short(zero.raw)
1527                ));
1528                if untimed > 0 {
1529                    notes.push(format!(
1530                        "{untimed} of the file's {points} points have no time: their times are \
1531                         gaps"
1532                    ));
1533                }
1534            }
1535        }
1536        if other_clock > 0 {
1537            notes.push(format!(
1538                "{other_clock} points are timed on another clock than the first timed point's, \
1539                 so they can't share its zero: their times are gaps"
1540            ));
1541        }
1542        if midnights.days > 0 {
1543            notes.push(format!(
1544                "the time of day steps back past midnight {} times: a day is added at each",
1545                midnights.days
1546            ));
1547        }
1548        if midnights.dropped > 0 {
1549            notes.push(format!(
1550                "the time of day steps back past midnight more than {MAX_MIDNIGHTS} times, more \
1551                 than a logger's memory holds: the {} times of day from there on are gaps",
1552                midnights.dropped
1553            ));
1554        }
1555        let mut backs = 0;
1556        let mut largest: f64 = 0.0;
1557        for track in &times {
1558            let mut previous = f64::NAN;
1559            for &time in track.iter().filter(|time| !time.is_nan()) {
1560                if time < previous {
1561                    backs += 1;
1562                    largest = largest.max(previous - time);
1563                }
1564                previous = time;
1565            }
1566        }
1567        if backs > 0 {
1568            notes.push(format!(
1569                "the times go back {backs} times within a track, by up to {largest:.3} s: the \
1570                 points are kept in the file's order"
1571            ));
1572        }
1573        times
1574    }
1575
1576    /// Each track's name for its channels: its placemark's, numbered by part where a placemark
1577    /// holds several geometries, with ` (2)`, ` (3)` … where a name repeats.
1578    fn names(&self) -> Vec<String> {
1579        let mut parts: HashMap<(usize, NodeId), usize> = HashMap::new();
1580        for key in self.tracks.iter().filter_map(|track| track.placemark) {
1581            *parts.entry(key).or_default() += 1;
1582        }
1583        let mut seen_parts: HashMap<(usize, NodeId), usize> = HashMap::new();
1584        let mut used: HashMap<String, usize> = HashMap::new();
1585        self.tracks
1586            .iter()
1587            .map(|track| {
1588                let mut name = track.label.clone();
1589                if let Some(key) = track.placemark
1590                    && parts.get(&key).copied().unwrap_or(0) > 1
1591                {
1592                    let part = seen_parts.entry(key).or_default();
1593                    *part += 1;
1594                    name = format!("{name} part {part}");
1595                }
1596                let count = used.entry(name.clone()).or_default();
1597                *count += 1;
1598                if *count > 1 {
1599                    name = format!("{name} ({count})");
1600                }
1601                name
1602            })
1603            .collect()
1604    }
1605}
1606
1607/// What the file says about its writer and logger.
1608#[derive(Debug, Default)]
1609struct About {
1610    logger: String,
1611    writer: Writer,
1612    serial_number: Option<String>,
1613    firmware: Option<String>,
1614    flight_number: Option<u32>,
1615    apogee_m: Option<f64>,
1616    note: Option<String>,
1617}
1618
1619/// The program that wrote the file, as far as the file tells.
1620#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1621enum Writer {
1622    Fw2kml,
1623    Rtx,
1624    AltOs,
1625    BeeLine,
1626    GpsVisualizer,
1627    #[default]
1628    Unknown,
1629}
1630
1631impl About {
1632    /// The writer and logger, from the first document and the comments read.
1633    fn of(document: &Document<'_>, reader: &Reader<'_>) -> Self {
1634        let root = document.root_element();
1635        let top = child(root, "Document").unwrap_or(root);
1636        let title = child(top, "name").map(|name| clean(&all_text(name)));
1637        let snippet = child(top, "Snippet").map(|node| all_text(node));
1638        let mut about = Self::default();
1639        if let Some(rest) = title
1640            .as_deref()
1641            .and_then(|title| title.strip_prefix("AO Flight#"))
1642        {
1643            about.writer = Writer::AltOs;
1644            let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
1645            about.flight_number = digits.parse().ok();
1646            about.serial_number = rest
1647                .split_once("S/N:")
1648                .map(|(_, serial)| serial.trim().to_owned())
1649                .filter(|serial| !serial.is_empty());
1650            let mut product = None;
1651            for line in snippet.as_deref().unwrap_or_default().lines() {
1652                let Some((key, value)) = line.split_once(':') else {
1653                    continue;
1654                };
1655                let value = value.trim();
1656                match key.trim() {
1657                    "Product" if !value.is_empty() => product = Some(short(value)),
1658                    "Firmware" if !value.is_empty() => about.firmware = Some(short(value)),
1659                    "Max baro height" => about.apogee_m = stated_height_m(value),
1660                    _ => {}
1661                }
1662            }
1663            about.logger = product.map_or_else(|| "AltOS".to_owned(), |p| format!("AltOS {p}"));
1664            return about;
1665        }
1666        about.writer = if title.as_deref() == Some("RTx/GPS Flight") {
1667            Writer::Rtx
1668        } else if root.descendants().any(|node| {
1669            node.is_element()
1670                && node.tag_name().name() == "name"
1671                && node
1672                    .parent()
1673                    .is_some_and(|parent| parent.tag_name().name() == "Placemark")
1674                && all_text(node).trim_start().starts_with("fw2kml")
1675        }) {
1676            Writer::Fw2kml
1677        } else if snippet
1678            .as_deref()
1679            .is_some_and(|text| text.contains("GPS Visualizer"))
1680        {
1681            Writer::GpsVisualizer
1682        } else if reader.memory_dump
1683            || reader
1684                .tracks
1685                .iter()
1686                .flat_map(|track| track.remarks.iter().flatten())
1687                .any(|remark| remark.layout == Layout::BeeLine)
1688        {
1689            Writer::BeeLine
1690        } else {
1691            Writer::Unknown
1692        };
1693        about.logger = match about.writer {
1694            Writer::Fw2kml => "fw2kml",
1695            Writer::Rtx => "Missile Works RTx",
1696            Writer::BeeLine => "Big Red Bee BeeLine",
1697            Writer::GpsVisualizer => "GPS Visualizer",
1698            Writer::AltOs | Writer::Unknown => "KML",
1699        }
1700        .to_owned();
1701        about
1702    }
1703}
1704
1705/// A height AltOS's summary states, such as `5000 ft` or `1500 m`, in meters.
1706fn stated_height_m(text: &str) -> Option<f64> {
1707    let (value, unit) = text.trim().split_once(char::is_whitespace)?;
1708    let value = number(value, false)?;
1709    match unit.trim() {
1710        "ft" => Some(value * FOOT_M),
1711        "m" => Some(value),
1712        _ => None,
1713    }
1714}
1715
1716/// The unit decisions made over the file's tracks, for their notes.
1717#[derive(Default)]
1718struct Decided {
1719    /// Tracks whose altitude is read in meters from the standard.
1720    meters: usize,
1721    /// Of those, AltOS tracks read as the barometer's altitude.
1722    barometric: usize,
1723    /// Over those barometric tracks, the points carrying both altitudes and those where the two
1724    /// differ.
1725    barometric_both: usize,
1726    barometric_differing: usize,
1727    /// Tracks whose altitude GPS Visualizer wrote, kept as written.
1728    gps_visualizer: usize,
1729    /// Tracks whose altitude a viewer ignores, by mode.
1730    ignored: Vec<&'static str>,
1731    /// Tracks in a mode the standard doesn't name.
1732    unnamed: usize,
1733    /// Whether AltOS's comment `alt` became a channel, and if so whether in meters.
1734    altos_alt: Option<bool>,
1735    /// Whether the `Seconds` layout's `Alt` became a channel.
1736    seconds_alt: bool,
1737}
1738
1739impl Decided {
1740    /// A track's altitude channel: its name's suffix and quantity. `barometric` holds, for an
1741    /// AltOS track whose tuple altitude differs from its comments' `alt` at more than half of the
1742    /// points carrying both, how many carry both and how many differ.
1743    fn altitude(
1744        &mut self,
1745        track: &Track<'_>,
1746        about: &About,
1747        barometric: Option<(usize, usize)>,
1748    ) -> (&'static str, Quantity) {
1749        if about.writer == Writer::GpsVisualizer {
1750            self.gps_visualizer += 1;
1751            return ("altitude", Quantity::Unrecognized);
1752        }
1753        match track.mode {
1754            Mode::Absolute => {
1755                self.meters += 1;
1756                if let Some((both, differing)) = barometric {
1757                    self.barometric += 1;
1758                    self.barometric_both += both;
1759                    self.barometric_differing += differing;
1760                    ("altitude", Quantity::AltitudeMsl)
1761                } else {
1762                    ("altitude", Quantity::GnssAltitude)
1763                }
1764            }
1765            Mode::RelativeToGround => {
1766                self.meters += 1;
1767                ("height above ground", Quantity::Distance)
1768            }
1769            Mode::RelativeToSeaFloor => {
1770                self.meters += 1;
1771                ("height above sea floor", Quantity::Distance)
1772            }
1773            Mode::Ignored(mode) => {
1774                self.ignored
1775                    .push(mode.unwrap_or("clampToGround, the default, as none is stated"));
1776                ("altitude", Quantity::Unrecognized)
1777            }
1778            Mode::Unnamed => {
1779                self.unnamed += 1;
1780                ("altitude", Quantity::Unrecognized)
1781            }
1782        }
1783    }
1784
1785    /// The channel of a comment's altitude that differs from the tuple's: its name's suffix and
1786    /// quantity. AltOS's `alt` is the GPS altitude, in meters if `altos_meters` (it equals the
1787    /// tuple's somewhere in the file), else as written; the `Seconds` layout's `Alt` is kept as
1788    /// written.
1789    fn remark_altitude(
1790        &mut self,
1791        layout: Option<Layout>,
1792        altos_meters: bool,
1793    ) -> (&'static str, Quantity) {
1794        if layout == Some(Layout::AltOs) {
1795            self.altos_alt = Some(altos_meters);
1796            let quantity = if altos_meters {
1797                Quantity::GnssAltitude
1798            } else {
1799                Quantity::Unrecognized
1800            };
1801            ("GPS altitude", quantity)
1802        } else {
1803            self.seconds_alt = true;
1804            ("Alt", Quantity::Unrecognized)
1805        }
1806    }
1807
1808    /// One note per decision, with this file's evidence.
1809    fn notes(&self, tracks: &[Track<'_>], altos_time: AltosTime) -> Vec<String> {
1810        let mut notes = Vec::new();
1811        let total = tracks.len();
1812        if self.meters > 0 {
1813            notes.push(format!(
1814                "`coordinates` read in meters, from the KML standard: OGC 07-147r2 states a \
1815                 tuple's altitude in meters, and {} of the file's {total} tracks carry one",
1816                self.meters
1817            ));
1818        }
1819        if self.barometric > 0 {
1820            notes.push(format!(
1821                "`coordinates` read in meters as the barometer's altitude above sea level, from \
1822                 the GPS altitude AltOS writes beside each point: in {} tracks the tuple's altitude \
1823                 differs from the comment's `alt` at {} of the {} points that carry both, more \
1824                 than half, as AltOS's KML export has reported both altitudes since AltOS 1.8.3",
1825                self.barometric, self.barometric_differing, self.barometric_both
1826            ));
1827        }
1828        if self.gps_visualizer > 0 {
1829            let (first, highest) = extent(tracks.iter().flat_map(|track| track.alt.iter()));
1830            notes.push(format!(
1831                "`coordinates` kept as written, as GPS Visualizer takes a source's altitude as \
1832                 meters unless the source's header names feet: {} tracks carry an altitude, the \
1833                 highest {:.0} above the first",
1834                self.gps_visualizer,
1835                highest - first
1836            ));
1837        }
1838        if !self.ignored.is_empty() {
1839            let mut modes = self.ignored.clone();
1840            modes.sort_unstable();
1841            modes.dedup();
1842            notes.push(format!(
1843                "`coordinates` kept as written, in a track whose altitude mode tells a viewer to \
1844                 ignore its altitude: {} tracks, in mode {}",
1845                self.ignored.len(),
1846                modes.join(", ")
1847            ));
1848        }
1849        if self.unnamed > 0 {
1850            notes.push(format!(
1851                "`coordinates` kept as written, in an altitude mode the KML standard doesn't \
1852                 name: {} tracks",
1853                self.unnamed
1854            ));
1855        }
1856        let (equal, both) = agreement(tracks, Layout::AltOs);
1857        match self.altos_alt {
1858            Some(true) => notes.push(format!(
1859                "`alt` read in meters, from the coordinates: it equals the tuple's altitude, in \
1860                 meters by the KML standard, at {equal} of the {both} points that carry both"
1861            )),
1862            Some(false) => notes.push(format!(
1863                "`alt` kept as written, as it equals the tuple's altitude at no point, so nothing \
1864                 in the file shows its unit: {both} points carry both"
1865            )),
1866            None => {}
1867        }
1868        if self.seconds_alt {
1869            let (equal, both) = agreement(tracks, Layout::Seconds);
1870            notes.push(format!(
1871                "`Alt` kept as written, in a unit the file doesn't state: it equals the tuple's \
1872                 altitude at {equal} of the {both} points that carry both"
1873            ));
1874        }
1875        notes.extend(clock_notes(tracks, altos_time));
1876        notes
1877    }
1878}
1879
1880/// The first and highest of a run of values that aren't gaps (0 and 0 if none).
1881fn extent<'v>(values: impl Iterator<Item = &'v f64>) -> (f64, f64) {
1882    let mut first = None;
1883    let mut highest = f64::NEG_INFINITY;
1884    for &value in values.filter(|value| !value.is_nan()) {
1885        first.get_or_insert(value);
1886        highest = highest.max(value);
1887    }
1888    first.map_or((0.0, 0.0), |first| (first, highest))
1889}
1890
1891/// How a layout's comment altitude agrees with the tuples': at how many points it equals the
1892/// tuple's, of the points carrying both.
1893fn agreement(tracks: &[Track<'_>], layout: Layout) -> (usize, usize) {
1894    let mut both = 0;
1895    let mut equal = 0;
1896    for track in tracks {
1897        for (tuple, remark) in track.alt.iter().zip(&track.remarks) {
1898            if let Some(remark) = remark
1899                && remark.layout == layout
1900                && !remark.alt.is_nan()
1901                && !tuple.is_nan()
1902            {
1903                both += 1;
1904                equal += usize::from((tuple - remark.alt).abs() <= 1e-6);
1905            }
1906        }
1907    }
1908    (equal, both)
1909}
1910
1911/// The notes deciding each clock's unit, with this file's evidence.
1912fn clock_notes(tracks: &[Track<'_>], altos_time: AltosTime) -> Vec<String> {
1913    let mut notes = Vec::new();
1914    let stamps = || {
1915        tracks
1916            .iter()
1917            .flat_map(|track| track.stamps.iter().flatten())
1918    };
1919    let count = |clock: Clock| stamps().filter(|stamp| stamp.clock == clock).count();
1920    let span = |clock: Clock| {
1921        let (low, high) = stamps()
1922            .filter(|stamp| stamp.clock == clock)
1923            .fold((i64::MAX, i64::MIN), |(low, high), stamp| {
1924                (low.min(stamp.ns), high.max(stamp.ns))
1925            });
1926        if low > high {
1927            0.0
1928        } else {
1929            high.saturating_sub(low) as f64 / NS_PER_S as f64
1930        }
1931    };
1932    let utc = count(Clock::Utc);
1933    if utc > 0 {
1934        notes.push(format!(
1935            "`when` read in UTC, from the KML standard: {utc} times state their zone, `Z` or an \
1936             offset, as XML Schema's date-time allows"
1937        ));
1938    }
1939    let local = count(Clock::Local);
1940    if local > 0 {
1941        notes.push(format!(
1942            "`when` kept as written, in a zone the file doesn't state: {local} times state none, \
1943             so the log's clock has an unknown offset from UTC"
1944        ));
1945    }
1946    let (low, high) = (ALTOS_STEP_S.start(), ALTOS_STEP_S.end());
1947    match altos_time {
1948        AltosTime::Seconds(median) => notes.push(format!(
1949            "`time` read in seconds, from the steps between points: the median step between a \
1950             track's points of distinct times is {median:.2} s, within the {low} to {high} s a \
1951             GPS receiver reporting once a second gives"
1952        )),
1953        AltosTime::Unsettled(median) if median.is_nan() => notes.push(
1954            "`time` kept as written, as its steps between points don't show its unit: no track \
1955             holds two points of distinct times, so those points have no time"
1956                .to_owned(),
1957        ),
1958        AltosTime::Unsettled(median) => notes.push(format!(
1959            "`time` kept as written, as its steps between points don't show its unit: the \
1960             median step between a track's points of distinct times is {median:.3} read as \
1961             seconds, outside the {low} to {high} s a GPS receiver reporting once a second \
1962             gives, so those points have no time"
1963        )),
1964        AltosTime::Absent | AltosTime::Unset => {}
1965    }
1966    if count(Clock::Seconds) > 0 {
1967        notes.push(format!(
1968            "`Seconds` read in seconds, from its name: the points run {:.3} s from first to last",
1969            span(Clock::Seconds)
1970        ));
1971    }
1972    if count(Clock::TimeOfDay) > 0 {
1973        notes.push(format!(
1974            "`UTC` read in hours, minutes and seconds of the UTC day, from its name and layout: \
1975             the points run {:.0} s from first to last",
1976            span(Clock::TimeOfDay)
1977        ));
1978    }
1979    notes
1980}
1981
1982#[cfg(test)]
1983mod tests {
1984    use super::*;
1985
1986    /// A one-path document around a `coordinates` text.
1987    fn path(mode: &str, coordinates: &str) -> String {
1988        format!(
1989            "<?xml version=\"1.0\"?><kml xmlns=\"http://www.opengis.net/kml/2.2\"><Document>\
1990             <Placemark><name>Flight</name><LineString>{mode}<coordinates>{coordinates}\
1991             </coordinates></LineString></Placemark></Document></kml>"
1992        )
1993    }
1994
1995    #[test]
1996    fn a_kml_root_is_recognized_after_its_prolog() {
1997        for text in [
1998            "<kml xmlns=\"http://www.opengis.net/kml/2.2\">",
1999            "\u{feff}<?xml version=\"1.0\"?>\r\n<!-- a -->\n<!--b--><kml>",
2000            "  <kml/>",
2001        ] {
2002            assert!(recognizes(text), "{text:?}");
2003        }
2004        for text in [
2005            "<kmlx>",
2006            "<gpx>",
2007            "<?xml version=\"1.0\"?><gpx><kml/></gpx>",
2008            "<!-- unclosed <kml>",
2009            "Time,Altitude\n",
2010            "",
2011        ] {
2012            assert!(!recognizes(text), "{text:?}");
2013        }
2014    }
2015
2016    #[test]
2017    fn whens_read_with_their_zone() {
2018        let base = days_from_civil(2026, 5, 23) * DAY_NS;
2019        assert_eq!(days_from_civil(1970, 1, 1), 0);
2020        assert_eq!(days_from_civil(2000, 3, 1), 11_017);
2021        let hour = 3_600 * NS_PER_S;
2022        let cases = [
2023            (
2024                "2026-05-23T10:15:00Z",
2025                Clock::Utc,
2026                base + 10 * hour + 900 * NS_PER_S,
2027            ),
2028            (
2029                "2026-05-23T10:15:00.25Z",
2030                Clock::Utc,
2031                base + 10 * hour + 900_250_000_000,
2032            ),
2033            (
2034                "2026-05-23T12:15:00+02:00",
2035                Clock::Utc,
2036                base + 10 * hour + 900 * NS_PER_S,
2037            ),
2038            (
2039                "2026-05-23T05:15:00-05:00",
2040                Clock::Utc,
2041                base + 10 * hour + 900 * NS_PER_S,
2042            ),
2043            (
2044                " 2026-05-23T10:15:00 ",
2045                Clock::Local,
2046                base + 10 * hour + 900 * NS_PER_S,
2047            ),
2048            (
2049                "2024-02-29T00:00:00Z",
2050                Clock::Utc,
2051                days_from_civil(2024, 2, 29) * DAY_NS,
2052            ),
2053        ];
2054        for (text, clock, ns) in cases {
2055            assert_eq!(when_ns(text), Some((clock, ns)), "{text}");
2056        }
2057        for text in [
2058            "2026",
2059            "2026-05",
2060            "2026-05-23",
2061            "2026-02-29T00:00:00Z",
2062            "2026-13-01T00:00:00Z",
2063            "2026-05-23T24:00:00Z",
2064            "2026-05-23T10:15Z",
2065            "2026-05-23T10:15:00+2:00",
2066            "2026-05-23T10:15:00+15:00",
2067            "2026-05-23 10:15:00Z",
2068            "26-05-23T10:15:00Z",
2069            "2026-05-23T10:15:00.Z",
2070            "2026-05-23T10:15:00.5e3Z",
2071            "",
2072        ] {
2073            assert_eq!(when_ns(text), None, "{text}");
2074        }
2075    }
2076
2077    #[test]
2078    fn decimals_read_exactly_to_the_nanosecond() {
2079        assert_eq!(decimal_ns("123.4500000"), Some(123_450_000_000));
2080        assert_eq!(decimal_ns("-0.25"), Some(-250_000_000));
2081        assert_eq!(decimal_ns("1"), Some(NS_PER_S));
2082        assert_eq!(decimal_ns(".5"), Some(500_000_000));
2083        assert_eq!(decimal_ns("0.1234567899"), Some(123_456_789));
2084        for text in ["", "-", ".", "1e3", "1.2.3", "nan", "12345678901", "+-1"] {
2085            assert_eq!(decimal_ns(text), None, "{text}");
2086        }
2087        assert_eq!(time_of_day_ns("12:34:56"), Some(45_296 * NS_PER_S));
2088        assert_eq!(time_of_day_ns("00:00:00.5"), Some(NS_PER_S / 2));
2089        for text in [
2090            "24:00:00",
2091            "1:00:00",
2092            "12:60:00",
2093            "12:00:61",
2094            "12:00",
2095            "12:00:00:00",
2096        ] {
2097            assert_eq!(time_of_day_ns(text), None, "{text}");
2098        }
2099    }
2100
2101    #[test]
2102    fn tuples_take_two_or_three_numbers_whatever_the_spacing() {
2103        let read: Vec<Option<(f64, f64, Option<f64>)>> =
2104            tuples(" -97.1,35.2,402\n\t-97.1, 35.2 ,  415\r\n-97.2,35.3 -97.3,35.4,-0.5 ")
2105                .iter()
2106                .map(|raw| tuple(raw, false))
2107                .collect();
2108        assert_eq!(
2109            read,
2110            [
2111                Some((-97.1, 35.2, Some(402.0))),
2112                Some((-97.1, 35.2, Some(415.0))),
2113                Some((-97.2, 35.3, None)),
2114                Some((-97.3, 35.4, Some(-0.5))),
2115            ]
2116        );
2117        assert_eq!(
2118            tuple("-97.1,35.2,00850", false),
2119            Some((-97.1, 35.2, Some(850.0)))
2120        );
2121        for raw in [
2122            "-97.1",
2123            "-97.1,35.2,1,2",
2124            "-97.1,,1",
2125            "a,b",
2126            "-97.1,35.2,",
2127            "1,inf",
2128        ] {
2129            assert_eq!(tuple(raw, false), None, "{raw}");
2130        }
2131        assert_eq!(
2132            tuple(" -97.1  35.2 402.5 ", true),
2133            Some((-97.1, 35.2, Some(402.5)))
2134        );
2135        assert_eq!(tuple("-97.1 35.2", true), Some((-97.1, 35.2, None)));
2136        for raw in ["", "-97.1,35.2,402", "1 2 3 4"] {
2137            assert_eq!(tuple(raw, true), None, "{raw}");
2138        }
2139    }
2140
2141    #[test]
2142    fn each_comment_layout_reads() {
2143        let altos = remark(" alt    7.5000000 time   -0.2500000 sats 5 ").unwrap();
2144        assert_eq!(
2145            (altos.layout, altos.alt, altos.time, altos.sats),
2146            (Layout::AltOs, 7.5, Some((-250_000_000, "-0.2500000")), 5.0)
2147        );
2148        assert!(altos.offset.is_nan());
2149        let seconds = remark(" Alt 250.125 Seconds 9.5 ").unwrap();
2150        assert_eq!(
2151            (seconds.layout, seconds.alt, seconds.time),
2152            (Layout::Seconds, 250.125, Some((9_500_000_000, "9.5")))
2153        );
2154        assert!(seconds.sats.is_nan());
2155        let beeline = remark(" 1003 sats:8 UTC 12:34:56 ").unwrap();
2156        assert_eq!(
2157            (beeline.layout, beeline.offset, beeline.sats, beeline.time),
2158            (
2159                Layout::BeeLine,
2160                1_003.0,
2161                8.0,
2162                Some((45_296 * NS_PER_S, "12:34:56"))
2163            )
2164        );
2165        assert!(beeline.alt.is_nan());
2166        for text in [
2167            " Name       : a rocket ",
2168            " Capture Date: 01/02/2030 ",
2169            " base    : 0m or     0 feet ",
2170            " alt 9 time x sats 5 ",
2171            " alt 9 time 1 sats 5 extra ",
2172            " 1 sats:x UTC 12:34:56 ",
2173            " 1 sats:4 UTC 25:00:00 ",
2174            "",
2175        ] {
2176            assert_eq!(remark(text), None, "{text:?}");
2177        }
2178    }
2179
2180    #[test]
2181    fn a_comment_belongs_to_the_tuple_right_before_it() {
2182        let log = read(&path(
2183            "<altitudeMode>absolute</altitudeMode>",
2184            "<!-- header -->\n-97.1,35.2,400 <!-- alt 401 time 0.5 sats 4 -->\n\
2185             -97.1,35.2,410 <!-- alt 411 time 1.5 sats 5 --> <!-- second -->\n-97.1,35.2,420",
2186        ))
2187        .unwrap();
2188        let columns: Vec<&str> = log.channels.iter().map(|c| c.column.as_str()).collect();
2189        assert_eq!(
2190            columns,
2191            [
2192                "Flight latitude",
2193                "Flight longitude",
2194                "Flight altitude",
2195                "Flight GPS altitude",
2196                "Flight satellites"
2197            ]
2198        );
2199        let gps = &log.channels[3];
2200        assert_eq!(gps.values[..2], [401.0, 411.0]);
2201        assert!(gps.values[2].is_nan());
2202        assert_eq!(gps.time_s[..2], [0.0, 1.0]);
2203        assert!(gps.time_s[2].is_nan());
2204        assert_eq!(log.channels[2].quantity, Quantity::AltitudeMsl);
2205        // `header` precedes every tuple and `second` follows a comment: neither is a point's.
2206        for expected in [
2207            "2 comments in `coordinates` follow no point's",
2208            "1 of the file's 3 points",
2209        ] {
2210            assert!(
2211                log.notes.iter().any(|note| note.contains(expected)),
2212                "{expected}: {:?}",
2213                log.notes
2214            );
2215        }
2216    }
2217
2218    #[test]
2219    fn the_altitude_mode_sets_the_quantity() {
2220        let quantity = |mode: &str| {
2221            let log = read(&path(mode, "-97.1,35.2,400 -97.1,35.2,410")).unwrap();
2222            (log.channels[2].column.clone(), log.channels[2].quantity)
2223        };
2224        assert_eq!(
2225            quantity("<altitudeMode>absolute</altitudeMode>"),
2226            ("Flight altitude".to_owned(), Quantity::GnssAltitude)
2227        );
2228        assert_eq!(
2229            quantity("<altitudeMode>relativeToGround</altitudeMode>"),
2230            ("Flight height above ground".to_owned(), Quantity::Distance)
2231        );
2232        assert_eq!(
2233            quantity(
2234                "<gx:altitudeMode xmlns:gx=\"http://www.google.com/kml/ext/2.2\">relativeToSeaFloor</gx:altitudeMode>"
2235            ),
2236            (
2237                "Flight height above sea floor".to_owned(),
2238                Quantity::Distance
2239            )
2240        );
2241        for mode in [
2242            "",
2243            "<altitudeMode>clampToGround</altitudeMode>",
2244            "<altitudeMode>up</altitudeMode>",
2245        ] {
2246            assert_eq!(
2247                quantity(mode),
2248                ("Flight altitude".to_owned(), Quantity::Unrecognized),
2249                "{mode}"
2250            );
2251        }
2252        // Two numbers a tuple: no altitude channel.
2253        let log = read(&path("", "-97.1,35.2 -97.1,35.3")).unwrap();
2254        assert_eq!(log.channels.len(), 2);
2255    }
2256
2257    #[test]
2258    fn concatenated_documents_split_at_each_declaration() {
2259        let one = path("<altitudeMode>absolute</altitudeMode>", "-97.1,35.2,400");
2260        let twice = format!("{one}\n{one}\n");
2261        let (pieces, more) = split_documents(&twice, MAX_NODES).unwrap();
2262        assert_eq!(pieces.len(), 2);
2263        assert!(!more);
2264        assert!(pieces.iter().all(|piece| piece.trim() == one));
2265        let log = read(&format!("{one}\n{one}\n{one}")).unwrap();
2266        assert_eq!(log.channels.len(), 3);
2267        assert!(
2268            log.notes.iter().any(|n| n.contains("3 times over")),
2269            "{:?}",
2270            log.notes
2271        );
2272        let other = path("<altitudeMode>absolute</altitudeMode>", "-97.2,35.3,500");
2273        let log = read(&format!("{one}{other}")).unwrap();
2274        let columns: Vec<&str> = log.channels.iter().map(|c| c.column.as_str()).collect();
2275        assert_eq!(columns[3], "Flight (2) latitude");
2276        assert!(log.notes.iter().any(|n| n.contains("1 more KML documents")));
2277        // Trailing text that isn't a document is refused, not skipped.
2278        assert!(matches!(
2279            read(&format!("{one}<kml/>")),
2280            Err(LogError::Syntax { .. })
2281        ));
2282    }
2283
2284    #[test]
2285    fn hostile_documents_are_refused() {
2286        let dtd = "<?xml version=\"1.0\"?><!DOCTYPE kml [<!ENTITY a \"aaaa\">]><kml>&a;</kml>";
2287        let error = read(dtd).unwrap_err();
2288        assert!(error.to_string().contains("document type"), "{error}");
2289        assert!(matches!(
2290            read("<kml><Placemark>"),
2291            Err(LogError::Syntax { .. })
2292        ));
2293        assert!(matches!(read("<kml/>"), Err(LogError::NoData { .. })));
2294        assert!(matches!(
2295            read("<gpx/>"),
2296            Err(LogError::NotThisFormat { .. })
2297        ));
2298    }
2299
2300    #[test]
2301    fn tracks_past_the_cap_are_counted() {
2302        let pins: String = (0..MAX_TRACKS + 3)
2303            .map(|index| {
2304                format!("<Placemark><Point><coordinates>-100,40,{index}</coordinates></Point></Placemark>")
2305            })
2306            .collect();
2307        let log = read(&format!("<kml><Document>{pins}</Document></kml>")).unwrap();
2308        // Latitude, longitude and an altitude kept as written, no mode being stated.
2309        assert_eq!(log.channels.len(), 3 * MAX_TRACKS);
2310        assert_eq!(log.channels[3].column, "track (2) latitude");
2311        assert!(
2312            log.notes
2313                .iter()
2314                .any(|note| note.starts_with("the file holds 3 more geometries than the 256")),
2315            "{:?}",
2316            log.notes
2317        );
2318    }
2319
2320    #[test]
2321    fn a_document_type_in_the_prolog_is_stepped_past() {
2322        for text in [
2323            "<?xml version=\"1.0\"?><!DOCTYPE kml><kml/>",
2324            "<!DOCTYPE kml [<!ENTITY a \"x>y\"> <!ENTITY b 'z]'>]>\n<!-- c --><kml>&a;</kml>",
2325        ] {
2326            assert!(recognizes(text), "{text:?}");
2327        }
2328        for text in ["<!DOCTYPE kml [<!ENTITY a \"x\">", "<!DOCTYPE gpx><gpx/>"] {
2329            assert!(!recognizes(text), "{text:?}");
2330        }
2331    }
2332
2333    #[test]
2334    fn nesting_is_counted_past_comments_quotes_and_closed_tags() {
2335        assert_eq!(
2336            nesting("<a><b/><c x='>' y=\"<d>\"><!-- <e> --></c></a>"),
2337            (2, 0)
2338        );
2339        assert_eq!(nesting("<a><![CDATA[<b><c>]]><?p <q>?></a>"), (1, 0));
2340        let deep = format!("<k>\n{}", "<a>".repeat(MAX_DEPTH));
2341        assert_eq!(nesting(&deep), (MAX_DEPTH + 1, 2));
2342        let error = read(&deep).unwrap_err();
2343        assert!(
2344            matches!(&error, LogError::Syntax { line: 2, message, .. }
2345                if message.contains(&format!("nests elements {} deep", MAX_DEPTH + 1))),
2346            "{error:?}"
2347        );
2348    }
2349
2350    #[test]
2351    fn the_node_limit_is_the_files_over_all_its_documents() {
2352        let one = path("<altitudeMode>absolute</altitudeMode>", "-97.1,35.2,400");
2353        let other = path("<altitudeMode>absolute</altitudeMode>", "-97.2,35.3,500");
2354        let nodes = u32::try_from(Document::parse(&one).unwrap().descendants().count()).unwrap();
2355        // Each document fits alone; the two together don't, nor do they one node short.
2356        assert!(read_within(&one, nodes).is_ok());
2357        assert!(read_within(&other, nodes).is_ok());
2358        let both = format!("{one}{other}");
2359        for budget in [nodes, nodes + 1, 2 * nodes - 1] {
2360            let error = read_within(&both, budget).unwrap_err();
2361            assert!(
2362                error.to_string().contains("XML nodes over its documents"),
2363                "{budget}: {error}"
2364            );
2365        }
2366        assert!(read_within(&both, 2 * nodes).is_ok());
2367        // A copy of the first document isn't parsed again, so it takes nothing from the budget.
2368        assert!(read_within(&format!("{one}\n{one}"), nodes).is_ok());
2369    }
2370
2371    /// A BeeLine path whose comments give its points these times of day, in turn.
2372    fn beeline<'t>(times: impl Iterator<Item = &'t str>) -> String {
2373        let coordinates: String = times
2374            .enumerate()
2375            .map(|(index, time)| format!("-100,40,{index} <!-- {index} sats:8 UTC {time} -->\n"))
2376            .collect();
2377        path("<altitudeMode>absolute</altitudeMode>", &coordinates)
2378    }
2379
2380    #[test]
2381    fn midnight_is_passed_at_most_max_midnights_times() {
2382        let alternate = |count| beeline(["23:00:00", "00:00:00"].into_iter().cycle().take(count));
2383        let day_s = 86_400.0;
2384        // 15 points pass midnight 7 times, the bound: every point keeps its time.
2385        let log = read(&alternate(15)).unwrap();
2386        let time = &log.channels[0].time_s;
2387        assert!(time.iter().all(|time| time.is_finite()), "{time:?}");
2388        assert_eq!(time[13], 7.0 * day_s - 23.0 * 3_600.0);
2389        assert_eq!(time[14], 7.0 * day_s);
2390        assert!(
2391            log.notes
2392                .iter()
2393                .any(|note| note.contains("midnight 7 times"))
2394        );
2395        // A 16th point would pass it an 8th time: its time is a gap, with a note.
2396        let log = read(&alternate(16)).unwrap();
2397        assert!(log.channels[0].time_s[15].is_nan());
2398        assert!(log.channels[0].time_s[..15].iter().all(|t| t.is_finite()));
2399        assert!(
2400            log.notes.iter().any(|note| note.starts_with(
2401                "the time of day steps back past midnight more than 7 times, more than a \
2402                 logger's memory holds: the 1 times of day from there on are gaps"
2403            )),
2404            "{:?}",
2405            log.notes
2406        );
2407        // 230,000 points stepping back at every other one: no day count overflows, and every
2408        // time past the bound is a gap.
2409        let log = read(&alternate(230_000)).unwrap();
2410        let time = &log.channels[0].time_s;
2411        assert_eq!(time.iter().filter(|time| time.is_finite()).count(), 15);
2412        assert!(
2413            log.notes
2414                .iter()
2415                .any(|note| note.contains("the 229985 times of day from there on are gaps")),
2416            "{:?}",
2417            log.notes
2418        );
2419    }
2420
2421    /// A three-point AltOS path, the tuples' altitudes 100, 200 and 300, with these comment
2422    /// altitudes and times.
2423    fn altos(alts: [&str; 3], times: [&str; 3]) -> String {
2424        let coordinates: String = (0..3)
2425            .map(|index| {
2426                format!(
2427                    "-100,40,{} <!-- alt {} time {} sats 5 -->\n",
2428                    100 * (index + 1),
2429                    alts[index],
2430                    times[index]
2431                )
2432            })
2433            .collect();
2434        path("<altitudeMode>absolute</altitudeMode>", &coordinates)
2435    }
2436
2437    /// The quantity of a log's channel by its column, if it has one.
2438    fn quantity_of(log: &FlightLog, column: &str) -> Option<Quantity> {
2439        log.channels
2440            .iter()
2441            .find(|channel| channel.column == column)
2442            .map(|channel| channel.quantity)
2443    }
2444
2445    /// Whether a note opens with the text.
2446    fn noted(log: &FlightLog, opening: &str) -> bool {
2447        log.notes.iter().any(|note| note.starts_with(opening))
2448    }
2449
2450    #[test]
2451    fn an_altos_track_is_the_barometers_where_most_of_its_points_differ() {
2452        let seconds = ["1", "2", "3"];
2453        let barometric = "`coordinates` read in meters as the barometer's altitude above sea \
2454                          level, from the GPS altitude AltOS writes beside each point: ";
2455        // One point of three differs: the tuple's is the GPS altitude, and the comment's a
2456        // channel in meters, as it equals the tuple's elsewhere.
2457        let log = read(&altos(["100", "200", "333"], seconds)).unwrap();
2458        assert_eq!(
2459            quantity_of(&log, "Flight altitude"),
2460            Some(Quantity::GnssAltitude)
2461        );
2462        assert_eq!(
2463            quantity_of(&log, "Flight GPS altitude"),
2464            Some(Quantity::GnssAltitude)
2465        );
2466        assert!(!noted(&log, barometric), "{:?}", log.notes);
2467        assert!(noted(
2468            &log,
2469            "`alt` read in meters, from the coordinates: it equals the tuple's altitude, in meters \
2470             by the KML standard, at 2 of the 3 points that carry both"
2471        ));
2472        // Two of three differ: the tuple's is the barometer's.
2473        let log = read(&altos(["100", "222", "333"], seconds)).unwrap();
2474        assert_eq!(
2475            quantity_of(&log, "Flight altitude"),
2476            Some(Quantity::AltitudeMsl)
2477        );
2478        assert_eq!(
2479            quantity_of(&log, "Flight GPS altitude"),
2480            Some(Quantity::GnssAltitude)
2481        );
2482        assert!(
2483            noted(
2484                &log,
2485                &format!(
2486                    "{barometric}in 1 tracks the tuple's altitude differs from the comment's \
2487                     `alt` at 2 of the 3 points that carry both"
2488                )
2489            ),
2490            "{:?}",
2491            log.notes
2492        );
2493        // None equal: the comment's altitude shows no unit, and is kept as written.
2494        let log = read(&altos(["111", "222", "333"], seconds)).unwrap();
2495        assert_eq!(
2496            quantity_of(&log, "Flight GPS altitude"),
2497            Some(Quantity::Unrecognized)
2498        );
2499        assert!(noted(
2500            &log,
2501            "`alt` kept as written, as it equals the tuple's altitude at no point, so nothing in \
2502             the file shows its unit: 3 points carry both"
2503        ));
2504        // All equal: the comment's altitude adds nothing.
2505        let log = read(&altos(["100", "200", "300"], seconds)).unwrap();
2506        assert_eq!(
2507            quantity_of(&log, "Flight altitude"),
2508            Some(Quantity::GnssAltitude)
2509        );
2510        assert_eq!(quantity_of(&log, "Flight GPS altitude"), None);
2511        assert!(!log.notes.iter().any(|note| note.starts_with("`alt`")));
2512    }
2513
2514    #[test]
2515    fn altos_time_is_read_in_seconds_only_where_its_steps_are_about_a_second() {
2516        let alts = ["100", "200", "300"];
2517        let log = read(&altos(alts, ["10", "11", "12"])).unwrap();
2518        assert_eq!(log.channels[0].time_s, [0.0, 1.0, 2.0]);
2519        assert_eq!(quantity_of(&log, "Flight time"), None);
2520        assert!(noted(
2521            &log,
2522            "`time` read in seconds, from the steps between points: the median step between a \
2523             track's points of distinct times is 1.00 s, within the 0.5 to 2 s"
2524        ));
2525        // The bounds read; just past them, and steps of 100, don't.
2526        for (times, seconds) in [
2527            (["0", "0.5", "1"], true),
2528            (["0", "2", "4"], true),
2529            (["0", "0.49", "0.98"], false),
2530            (["0", "2.01", "4.02"], false),
2531            (["100", "200", "300"], false),
2532        ] {
2533            let log = read(&altos(alts, times)).unwrap();
2534            let timed = log.channels[0].time_s.iter().all(|time| time.is_finite());
2535            assert_eq!(timed, seconds, "{times:?}");
2536            assert_eq!(
2537                quantity_of(&log, "Flight time").is_none(),
2538                seconds,
2539                "{times:?}"
2540            );
2541        }
2542        // Kept as written: a channel of its own, no time, and a note.
2543        let log = read(&altos(alts, ["100", "200", "300"])).unwrap();
2544        assert!(log.channels[0].time_s.iter().all(|time| time.is_nan()));
2545        let time = log
2546            .channels
2547            .iter()
2548            .find(|channel| channel.column == "Flight time")
2549            .unwrap();
2550        assert_eq!(
2551            (time.quantity, &time.values[..]),
2552            (Quantity::Unrecognized, &[100.0, 200.0, 300.0][..])
2553        );
2554        assert!(noted(
2555            &log,
2556            "`time` kept as written, as its steps between points don't show its unit: the median \
2557             step between a track's points of distinct times is 100.000 read as seconds"
2558        ));
2559        // A single point has no step to show it.
2560        let log = read(&path(
2561            "<altitudeMode>absolute</altitudeMode>",
2562            "-100,40,100 <!-- alt 100 time 5 sats 5 -->",
2563        ))
2564        .unwrap();
2565        assert!(noted(
2566            &log,
2567            "`time` kept as written, as its steps between points don't show its unit: no track \
2568             holds two points of distinct times"
2569        ));
2570    }
2571
2572    proptest::proptest! {
2573        /// Any text is read or refused, never a panic; what is read has a time and a value for
2574        /// each sample on every channel, and events at finite times.
2575        #[test]
2576        fn any_text_is_read_or_refused(
2577            mode in "(absolute|relativeToGround|clampToGround|x|)",
2578            coordinates in "[-0-9.,:\\s]{0,60}",
2579            comment in "(alt|Alt|time|Seconds|sats|sats:|UTC|[0-9.:-]| ){0,40}",
2580            when in "[-0-9:TZ.+]{0,30}",
2581            raw in "\\PC{0,80}",
2582        ) {
2583            let texts = [
2584                path(&format!("<altitudeMode>{mode}</altitudeMode>"), &format!("{coordinates}<!--{comment}-->{coordinates}")),
2585                format!("<kml><Placemark><name>p</name><gx:Track xmlns:gx=\"g\"><when>{when}</when><gx:coord>{coordinates}</gx:coord></gx:Track></Placemark></kml>"),
2586                format!("<kml><Placemark><TimeStamp><when>{when}</when></TimeStamp><Point><coordinates>{coordinates}</coordinates></Point></Placemark></kml>"),
2587                format!("<kml>{raw}</kml>"),
2588                raw.clone(),
2589            ];
2590            for text in texts {
2591                if let Ok(log) = read(&text) {
2592                    proptest::prop_assert!(log.time_s.is_empty() && log.altitude_m.is_empty());
2593                    for channel in &log.channels {
2594                        proptest::prop_assert_eq!(channel.time_s.len(), channel.values.len());
2595                    }
2596                    for event in &log.events {
2597                        proptest::prop_assert!(event.time_s.is_finite());
2598                    }
2599                }
2600            }
2601        }
2602    }
2603}