1use 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
192const FORMAT: &str = "KML";
194
195pub const MAX_TRACKS: usize = 256;
198
199pub const MAX_POINTS: usize = 1_000_000;
202
203pub const MAX_DOCUMENTS: usize = 8;
206
207pub const MAX_NODES: u32 = 4_000_000;
211
212pub const MAX_DEPTH: usize = 64;
217
218pub const MAX_MIDNIGHTS: i64 = 7;
222
223const ALTOS_STEP_S: std::ops::RangeInclusive<f64> = 0.5..=2.0;
227
228const PROLOG_ITEMS: usize = 64;
231
232const NS_PER_S: i64 = 1_000_000_000;
234
235const DAY_NS: i64 = 86_400 * NS_PER_S;
237
238pub(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
271fn 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
290pub fn read(text: &str) -> Result<FlightLog, LogError> {
299 read_within(text, MAX_NODES)
300}
301
302fn 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 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 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
359fn 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#[derive(Debug)]
392enum XmlFailure {
393 Deep { deepest: usize, line: usize },
395 Xml(roxmltree::Error),
397}
398
399fn 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
413fn parse(text: &str, nodes: u32) -> Result<Document<'_>, LogError> {
415 parse_xml(text, nodes).map_err(|failure| xml_error(&failure))
416}
417
418fn 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
448fn 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
512fn 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
526fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
543enum Mode {
544 Absolute,
546 RelativeToGround,
548 RelativeToSeaFloor,
550 Ignored(Option<&'static str>),
553 Unnamed,
555}
556
557impl Mode {
558 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576enum Clock {
577 Utc,
579 Local,
581 AltOs,
583 Seconds,
585 TimeOfDay,
587}
588
589impl Clock {
590 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#[derive(Debug, Clone, Copy, PartialEq)]
603enum AltosTime {
604 Absent,
606 Unset,
608 Seconds(f64),
610 Unsettled(f64),
612}
613
614#[derive(Debug, Clone, Copy, Default)]
616struct Midnights {
617 days: usize,
619 dropped: usize,
621}
622
623#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625struct Stamp<'a> {
626 clock: Clock,
627 ns: i64,
628 raw: &'a str,
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633enum Layout {
634 AltOs,
636 Seconds,
638 BeeLine,
640}
641
642#[derive(Debug, Clone, Copy, PartialEq)]
644struct Remark<'a> {
645 layout: Layout,
646 alt: f64,
648 time: Option<(i64, &'a str)>,
650 sats: f64,
652 offset: f64,
654}
655
656fn 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
707fn 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
740fn 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
761fn 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
814fn 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
824fn 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
836fn 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
854fn 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
864fn 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
887fn 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
893fn all_text(node: Node<'_, '_>) -> String {
895 node.children()
896 .filter(|child| child.is_text())
897 .filter_map(|child| child.text())
898 .collect()
899}
900
901fn clean(name: &str) -> String {
903 short(&name.split_whitespace().collect::<Vec<_>>().join(" "))
904}
905
906struct Track<'a> {
908 label: String,
910 placemark: Option<(usize, NodeId)>,
912 point: bool,
914 mode: Mode,
915 lat: Vec<f64>,
916 lon: Vec<f64>,
917 alt: Vec<f64>,
919 stamps: Vec<Option<Stamp<'a>>>,
920 remarks: Vec<Option<Remark<'a>>>,
921}
922
923#[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 other_geometries: usize,
935 empty_geometries: usize,
937 loose_comments: usize,
939 stray_comments: usize,
941 unknown_comments: usize,
943 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 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 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 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 fn read_coordinates(&mut self, coordinates: Node<'a, 'a>, track: &mut Track<'a>) {
1072 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 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 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 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 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(×).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 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 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 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 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 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 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 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 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 × {
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 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#[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#[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 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
1705fn 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#[derive(Default)]
1718struct Decided {
1719 meters: usize,
1721 barometric: usize,
1723 barometric_both: usize,
1726 barometric_differing: usize,
1727 gps_visualizer: usize,
1729 ignored: Vec<&'static str>,
1731 unnamed: usize,
1733 altos_alt: Option<bool>,
1735 seconds_alt: bool,
1737}
1738
1739impl Decided {
1740 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 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 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
1880fn 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
1891fn 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
1911fn 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 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 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 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 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 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 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 assert!(read_within(&format!("{one}\n{one}"), nodes).is_ok());
2369 }
2370
2371 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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}