Skip to main content

hpr_sim/
export.rs

1//! Writing a flight out: a [`Recorder`]'s rows as CSV or JSON (or Parquet, with the `parquet`
2//! feature), and the center of mass's path with the landings as GeoJSON or KML for a map.
3//!
4//! Every function builds the file's contents (text, or bytes for Parquet) and returns them; none
5//! writes a file (the core crates do no I/O). Text numbers are written in Rust's shortest
6//! round-trip form and Parquet stores each `f64`'s own 8 bytes, so each reads back as the exact
7//! value recorded. A value that isn't finite is refused rather than written as something a reader
8//! would misread (`NaN`, or JSON's `null`).
9//!
10//! The two map formats put heights on different datums, as their specifications require:
11//!
12//! - **GeoJSON** (RFC 7946, section 4): longitude, latitude and height in meters above the WGS 84
13//!   ellipsoid.
14//! - **KML** (OGC 07-147r2, `altitudeMode` `absolute`): longitude, latitude and height above mean
15//!   sea level (KML's geoid is EGM96), which is the ellipsoidal height less the site's geoid
16//!   undulation ([`Environment::geoid_undulation_m`]). The undulation is taken as constant over the
17//!   flight; the geoid's slope, about 5 cm/km and up to some 30 cm/km in mountains, moves it by
18//!   centimeters to decimeters over a rocket's few kilometers.
19//!
20//! Neither cuts a path that crosses the antimeridian (±180° longitude), which RFC 7946 section 3.1.9
21//! asks for; a flight there draws a line the long way round the globe.
22//!
23//! Each file names the program that wrote it, its version and its designation in the FusionSpace
24//! product system ([`hpr_core::tool`]), as a drawing's title block names the tool that made it:
25//! JSON and GeoJSON in a top-level `tool` object, `{"name", "version", "designation"}` (in
26//! GeoJSON a foreign member, which RFC 7946 section 6.1 allows); KML in an XML comment after the
27//! declaration; Parquet in its key-value metadata. CSV is left as bare rows, so it opens cleanly
28//! in a spreadsheet: a program writing one keeps the stamp beside it.
29
30use hpr_core::DVec3;
31use serde_json::{Value, json};
32
33use crate::environment::Environment;
34use crate::error::SimError;
35use crate::metrics::{FlightSummary, Landing};
36use crate::recorder::Recorder;
37
38#[cfg(feature = "parquet")]
39mod parquet;
40#[cfg(feature = "parquet")]
41pub use parquet::{PARQUET_PAGE_ROWS, parquet};
42
43/// The `tool` object a JSON or GeoJSON export opens with: the program's name, version and
44/// designation, from [`hpr_core::tool`].
45#[derive(serde::Serialize)]
46struct Tool {
47    name: &'static str,
48    version: &'static str,
49    designation: &'static str,
50}
51
52const TOOL: Tool = Tool {
53    name: hpr_core::tool::NAME,
54    version: hpr_core::tool::VERSION,
55    designation: hpr_core::tool::DESIGNATION,
56};
57
58/// A JSON export of the recorded rows, its keys in this order.
59#[derive(serde::Serialize)]
60struct Rows<'a> {
61    tool: Tool,
62    columns: &'a [String],
63    rows: &'a [Vec<f64>],
64}
65
66/// A GeoJSON `FeatureCollection`, its keys in this order: `tool` is a foreign member.
67#[derive(serde::Serialize)]
68struct FeatureCollection {
69    #[serde(rename = "type")]
70    kind: &'static str,
71    tool: Tool,
72    features: Vec<Value>,
73}
74
75/// `value` as compact JSON text.
76fn to_text(value: &impl serde::Serialize) -> Result<String, SimError> {
77    // Every value here is finite (checked before) and every key a string, the only two things
78    // `serde_json` refuses; the error is mapped rather than unwrapped all the same.
79    serde_json::to_string(value).map_err(|_| SimError::Unsupported {
80        what: "a value JSON can't hold",
81    })
82}
83
84/// One point of the center of mass's path, placed on the Earth.
85#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
86pub struct TrackPoint {
87    /// Time since launch, s.
88    pub time_s: f64,
89    /// Geodetic latitude, degrees north (WGS 84).
90    pub latitude_deg: f64,
91    /// Longitude, degrees east.
92    pub longitude_deg: f64,
93    /// Height above the WGS 84 ellipsoid, m.
94    pub height_above_ellipsoid_m: f64,
95    /// Height above mean sea level, m: the ellipsoidal height less the site's geoid undulation.
96    pub height_above_sea_level_m: f64,
97}
98
99fn finite(what: &'static str, value: f64) -> Result<f64, SimError> {
100    if value.is_finite() {
101        Ok(value)
102    } else {
103        Err(SimError::Domain { what, value })
104    }
105}
106
107/// The recorded rows as CSV (RFC 4180): a header of the column names, which carry their units,
108/// then one line per row, lines ending in CRLF.
109///
110/// # Errors
111///
112/// [`SimError::Domain`] for a value that isn't finite.
113pub fn csv(recorder: &Recorder) -> Result<String, SimError> {
114    let mut out = recorder.columns().join(",");
115    out.push_str("\r\n");
116    for row in recorder.rows() {
117        let mut cells = Vec::with_capacity(row.len());
118        for &value in row {
119            cells.push(format!("{:?}", finite("recorded value", value)?));
120        }
121        out.push_str(&cells.join(","));
122        out.push_str("\r\n");
123    }
124    Ok(out)
125}
126
127/// The recorded rows as JSON: `{"tool": {...}, "columns": [names], "rows": [[values], ...]}`,
128/// each row in the columns' order. `tool` names the program that wrote the file:
129/// `{"name": "hpr-sim", "version": "0.1.0", "designation": "FS · SW · TOOL 005"}`. A
130/// [`FlightSummary`] is serializable on its own (`serde_json::to_string`).
131///
132/// # Errors
133///
134/// [`SimError::Domain`] for a value that isn't finite.
135pub fn json(recorder: &Recorder) -> Result<String, SimError> {
136    for row in recorder.rows() {
137        for &value in row {
138            finite("recorded value", value)?;
139        }
140    }
141    to_text(&Rows {
142        tool: TOOL,
143        columns: &recorder.columns(),
144        rows: recorder.rows(),
145    })
146}
147
148/// The center of mass's path from a recorder that kept [`crate::Channel::Time`] and
149/// [`crate::Channel::CgPosition`], placed on `environment`'s ellipsoid.
150///
151/// # Errors
152///
153/// [`SimError::Unsupported`] if the recorder lacks either channel; [`SimError::Core`] if a
154/// position has no geodetic coordinates.
155pub fn track(recorder: &Recorder, environment: &Environment) -> Result<Vec<TrackPoint>, SimError> {
156    let columns = recorder.columns();
157    let index = |name: &str| columns.iter().position(|c| c == name);
158    let (Some(t), Some(e), Some(n), Some(u)) = (
159        index("time_s"),
160        index("cg_east_m"),
161        index("cg_north_m"),
162        index("cg_up_m"),
163    ) else {
164        return Err(SimError::Unsupported {
165            what: "a track from a recorder without the time and CG position channels",
166        });
167    };
168    let frame = environment.earth.frame();
169    recorder
170        .rows()
171        .iter()
172        .map(|row| {
173            let place = frame.geodetic_from_enu(DVec3::new(row[e], row[n], row[u]))?;
174            Ok(TrackPoint {
175                time_s: row[t],
176                latitude_deg: place.latitude_rad.to_degrees(),
177                longitude_deg: place.longitude_rad.to_degrees(),
178                height_above_ellipsoid_m: place.height_m,
179                height_above_sea_level_m: place.height_m - environment.geoid_undulation_m,
180            })
181        })
182        .collect()
183}
184
185/// The flight's landing, then each separated body's, as `(label, landing)`.
186fn landings(summary: &FlightSummary) -> Vec<(String, &Landing)> {
187    let flight = summary.landing.iter().map(|l| ("landing".to_owned(), l));
188    let bodies = summary.body_landings.iter().map(|l| {
189        let label = match l.body {
190            Some(body) => format!("body {body} landing"),
191            None => "landing".to_owned(),
192        };
193        (label, l)
194    });
195    flight.chain(bodies).collect()
196}
197
198fn too_short(track: &[TrackPoint]) -> Result<(), SimError> {
199    if track.len() < 2 {
200        return Err(SimError::Unsupported {
201            what: "a path of fewer than two points (a line needs two)",
202        });
203    }
204    Ok(())
205}
206
207/// The path and the landings as a GeoJSON `FeatureCollection` (RFC 7946): a `LineString` of
208/// `[longitude, latitude, height above the ellipsoid]` whose `properties.time_s` lists each
209/// point's time, and a `Point` per landing of `summary` with its time, body, distance and descent
210/// rate. A top-level `tool` object names the program that wrote it, as [`json()`]'s does: a
211/// foreign member, which RFC 7946 section 6.1 allows and readers pass over.
212///
213/// # Errors
214///
215/// [`SimError::Unsupported`] for a track of fewer than two points (a `LineString` needs two);
216/// [`SimError::Domain`] for a coordinate that isn't finite.
217pub fn geojson(track: &[TrackPoint], summary: &FlightSummary) -> Result<String, SimError> {
218    too_short(track)?;
219    let mut coordinates = Vec::with_capacity(track.len());
220    let mut times = Vec::with_capacity(track.len());
221    for p in track {
222        coordinates.push(json!([
223            finite("longitude", p.longitude_deg)?,
224            finite("latitude", p.latitude_deg)?,
225            finite("height", p.height_above_ellipsoid_m)?,
226        ]));
227        times.push(finite("time", p.time_s)?);
228    }
229    let mut features = vec![json!({
230        "type": "Feature",
231        "geometry": {"type": "LineString", "coordinates": coordinates},
232        "properties": {"name": "flight path", "time_s": times},
233    })];
234    for (label, l) in landings(summary) {
235        features.push(json!({
236            "type": "Feature",
237            "geometry": {"type": "Point", "coordinates": [
238                finite("longitude", l.longitude_deg)?,
239                finite("latitude", l.latitude_deg)?,
240            ]},
241            "properties": {
242                "name": label,
243                "body": l.body,
244                "time_s": finite("time", l.time_s)?,
245                "distance_m": finite("distance", l.distance_m)?,
246                "descent_rate_m_s": finite("descent rate", l.descent_rate_m_s)?,
247            },
248        }));
249    }
250    to_text(&FeatureCollection {
251        kind: "FeatureCollection",
252        tool: TOOL,
253        features,
254    })
255}
256
257/// `text` with XML's five special characters escaped.
258///
259/// # Errors
260///
261/// [`SimError::Unsupported`] for a control character XML 1.0 forbids (all below U+0020 but tab,
262/// line feed and carriage return; U+FFFE and U+FFFF).
263fn escape(text: &str) -> Result<String, SimError> {
264    let forbidden = |c: char| {
265        (c < ' ' && !matches!(c, '\t' | '\n' | '\r')) || matches!(c, '\u{FFFE}' | '\u{FFFF}')
266    };
267    if text.chars().any(forbidden) {
268        return Err(SimError::Unsupported {
269            what: "a KML name with a control character XML 1.0 forbids",
270        });
271    }
272    let mut out = String::with_capacity(text.len());
273    for c in text.chars() {
274        match c {
275            '&' => out.push_str("&amp;"),
276            '<' => out.push_str("&lt;"),
277            '>' => out.push_str("&gt;"),
278            '"' => out.push_str("&quot;"),
279            '\'' => out.push_str("&apos;"),
280            _ => out.push(c),
281        }
282    }
283    Ok(out)
284}
285
286/// The path and the landings as a KML 2.2 document (OGC 07-147r2) named `name`: a `LineString`
287/// of `longitude,latitude,height above mean sea level` with `altitudeMode` `absolute`, and a
288/// ground-clamped `Point` per landing of `summary`. An XML comment after the declaration names
289/// the program that wrote it, `<!-- hpr-sim 0.1.0 · FS · SW · TOOL 005 -->`
290/// ([`hpr_core::tool::stamp`]).
291///
292/// # Errors
293///
294/// [`SimError::Unsupported`] for a track of fewer than two points or a name with a control
295/// character XML forbids; [`SimError::Domain`] for a coordinate that isn't finite.
296pub fn kml(track: &[TrackPoint], summary: &FlightSummary, name: &str) -> Result<String, SimError> {
297    too_short(track)?;
298    let mut path = Vec::with_capacity(track.len());
299    for p in track {
300        path.push(format!(
301            "{:?},{:?},{:?}",
302            finite("longitude", p.longitude_deg)?,
303            finite("latitude", p.latitude_deg)?,
304            finite("height", p.height_above_sea_level_m)?,
305        ));
306    }
307    let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
308    // The stamp holds no `--` and doesn't end in `-`, which an XML comment can't (XML 1.0 §2.5).
309    out.push_str(&format!("<!-- {} -->\n", hpr_core::tool::stamp()));
310    out.push_str("<kml xmlns=\"http://www.opengis.net/kml/2.2\">\n<Document>\n");
311    out.push_str(&format!("<name>{}</name>\n", escape(name)?));
312    out.push_str("<Placemark>\n<name>flight path</name>\n<LineString>\n");
313    out.push_str("<altitudeMode>absolute</altitudeMode>\n<coordinates>\n");
314    out.push_str(&path.join("\n"));
315    out.push_str("\n</coordinates>\n</LineString>\n</Placemark>\n");
316    for (label, l) in landings(summary) {
317        out.push_str(&format!(
318            "<Placemark>\n<name>{}</name>\n<description>t = {:?} s</description>\n<Point>\n\
319             <coordinates>{:?},{:?}</coordinates>\n</Point>\n</Placemark>\n",
320            escape(&label)?,
321            finite("time", l.time_s)?,
322            finite("longitude", l.longitude_deg)?,
323            finite("latitude", l.latitude_deg)?,
324        ));
325    }
326    out.push_str("</Document>\n</kml>\n");
327    Ok(out)
328}
329
330#[cfg(test)]
331mod tests {
332    use hpr_atmos::ConstantWind;
333
334    use super::*;
335    use crate::flight::{FlightSettings, Simulation};
336    use crate::metrics::FlightMetrics;
337    use crate::rail::Rail;
338    use crate::recorder::Channel;
339    use crate::recovery::{Device, DeviceDrag, Trigger};
340    use crate::testing::{design, windy_environment};
341
342    /// Valetudo under a canopy from apogee, carried east by a 6 m/s west wind: its recording at
343    /// 1 s, its path and its summary.
344    pub(super) fn flown() -> (Recorder, Vec<TrackPoint>, FlightSummary, Environment) {
345        let wind = ConstantWind::new(6.0, 1.5 * std::f64::consts::PI).unwrap();
346        let canopy = Device::new(
347            "main",
348            DeviceDrag::DragArea { cd_s_m2: 0.5 },
349            Trigger::Apogee,
350        );
351        let sim = Simulation::new(
352            &design("rocketpy-valetudo"),
353            "example",
354            windy_environment(wind).with_geoid_undulation_m(-25.0),
355            Rail::vertical(3.0),
356            FlightSettings {
357                max_time_s: 600.0,
358                ..FlightSettings::default()
359            },
360        )
361        .unwrap()
362        .with_recovery(vec![canopy])
363        .unwrap();
364        let recorder = Recorder::new(
365            vec![Channel::Time, Channel::CgPosition, Channel::Mach],
366            Some(1.0),
367        )
368        .unwrap();
369        let mut watchers = (FlightMetrics::new(), recorder);
370        let result = sim.run(&mut watchers).unwrap();
371        let (metrics, recorder) = watchers;
372        let summary = metrics.summary(&result, sim.environment()).unwrap();
373        let track = track(&recorder, sim.environment()).unwrap();
374        (recorder, track, summary, sim.environment().clone())
375    }
376
377    #[test]
378    fn csv_and_json_hold_every_recorded_value_exactly() {
379        let (recorder, ..) = flown();
380        assert!(recorder.rows().len() > 20);
381        let text = csv(&recorder).unwrap();
382        let mut lines = text.split("\r\n");
383        let header: Vec<&str> = lines.next().unwrap().split(',').collect();
384        assert_eq!(header, recorder.columns());
385        let rows: Vec<Vec<f64>> = lines
386            .filter(|l| !l.is_empty())
387            .map(|l| l.split(',').map(|c| c.parse().unwrap()).collect())
388            .collect();
389        assert_eq!(rows, recorder.rows());
390        assert!(text.ends_with("\r\n"));
391
392        let value: Value = serde_json::from_str(&json(&recorder).unwrap()).unwrap();
393        let columns: Vec<String> = serde_json::from_value(value["columns"].clone()).unwrap();
394        let rows: Vec<Vec<f64>> = serde_json::from_value(value["rows"].clone()).unwrap();
395        assert_eq!(columns, recorder.columns());
396        assert_eq!(rows, recorder.rows());
397    }
398
399    /// The object every JSON export carries: name, version and designation.
400    fn assert_names_the_tool(value: &Value) {
401        assert_eq!(
402            value["tool"],
403            json!({
404                "name": "hpr-sim",
405                "version": env!("CARGO_PKG_VERSION"),
406                "designation": "FS · SW · TOOL 005",
407            })
408        );
409    }
410
411    /// Each export but CSV names the program that wrote it, its version and its designation, as
412    /// a title block does: JSON and GeoJSON in a `tool` object written first and second, after
413    /// GeoJSON's `type`, and KML in a comment after the declaration. CSV stays bare rows, so a
414    /// spreadsheet opens it with the header first.
415    #[test]
416    fn every_export_but_csv_names_the_program_version_and_designation() {
417        let (recorder, track, summary, _) = flown();
418        let version = env!("CARGO_PKG_VERSION");
419        let object = format!(
420            "\"tool\":{{\"name\":\"hpr-sim\",\"version\":\"{version}\",\
421             \"designation\":\"FS · SW · TOOL 005\"}}"
422        );
423
424        let text = json(&recorder).unwrap();
425        assert!(
426            text.starts_with(&format!("{{{object},\"columns\":")),
427            "{text:.200}"
428        );
429        let value: Value = serde_json::from_str(&text).unwrap();
430        assert_names_the_tool(&value);
431        assert_eq!(value.as_object().unwrap().len(), 3);
432
433        let text = geojson(&track, &summary).unwrap();
434        assert!(
435            text.starts_with(&format!(
436                "{{\"type\":\"FeatureCollection\",{object},\"features\":"
437            )),
438            "{text:.200}"
439        );
440        let value: Value = serde_json::from_str(&text).unwrap();
441        assert_names_the_tool(&value);
442        assert!(geojson_validator().is_valid(&value));
443
444        let text = kml(&track, &summary, "x").unwrap();
445        let stamp = format!("<!-- hpr-sim {version} · FS · SW · TOOL 005 -->");
446        assert_eq!(stamp, format!("<!-- {} -->", hpr_core::tool::stamp()));
447        assert!(
448            text.starts_with(&format!(
449                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{stamp}\n<kml "
450            )),
451            "{text:.200}"
452        );
453        let document = roxmltree::Document::parse(&text).unwrap();
454        let comments: Vec<&str> = document
455            .root()
456            .children()
457            .filter(roxmltree::Node::is_comment)
458            .filter_map(|n| n.text())
459            .collect();
460        assert_eq!(comments, [format!(" {} ", hpr_core::tool::stamp())]);
461
462        let text = csv(&recorder).unwrap();
463        assert!(text.starts_with("time_s,"), "{text:.200}");
464        assert!(!text.contains("hpr-sim") && !text.contains("TOOL"));
465    }
466
467    #[test]
468    fn a_value_that_is_not_finite_is_refused() {
469        let point = TrackPoint {
470            time_s: 0.0,
471            latitude_deg: 32.0,
472            longitude_deg: f64::NAN,
473            height_above_ellipsoid_m: 0.0,
474            height_above_sea_level_m: 0.0,
475        };
476        let (.., summary, _) = flown();
477        let track = [
478            point,
479            TrackPoint {
480                time_s: 1.0,
481                ..point
482            },
483        ];
484        for result in [geojson(&track, &summary), kml(&track, &summary, "x")] {
485            assert!(matches!(
486                result,
487                Err(SimError::Domain { what: "longitude", value }) if value.is_nan()
488            ));
489        }
490        for result in [
491            geojson(&track[..1], &summary),
492            kml(&track[..1], &summary, "x"),
493        ] {
494            assert!(matches!(
495                result,
496                Err(SimError::Unsupported { what }) if what.contains("fewer than two")
497            ));
498        }
499        let fine = [
500            TrackPoint {
501                longitude_deg: -106.97,
502                ..point
503            },
504            TrackPoint {
505                time_s: 1.0,
506                longitude_deg: -106.97,
507                ..point
508            },
509        ];
510        assert!(matches!(
511            kml(&fine, &summary, "a\u{1}b"),
512            Err(SimError::Unsupported { what }) if what.contains("control character")
513        ));
514        assert!(kml(&fine, &summary, "tab\tok").is_ok());
515
516        let recorder = Recorder::with_rows(vec![Channel::Time], vec![vec![0.0], vec![f64::NAN]]);
517        for result in [csv(&recorder), json(&recorder)] {
518            assert!(matches!(
519                result,
520                Err(SimError::Domain { what: "recorded value", value }) if value.is_nan()
521            ));
522        }
523    }
524
525    #[test]
526    fn track_places_each_row_on_the_ellipsoid_and_the_geoid() {
527        let (recorder, track, summary, environment) = flown();
528        assert_eq!(track.len(), recorder.rows().len());
529        let frame = environment.earth.frame();
530        for (point, row) in track.iter().zip(recorder.rows()) {
531            let place = frame
532                .geodetic_from_enu(DVec3::new(row[1], row[2], row[3]))
533                .unwrap();
534            assert_eq!(point.time_s, row[0]);
535            assert_eq!(point.latitude_deg, place.latitude_rad.to_degrees());
536            assert_eq!(point.longitude_deg, place.longitude_rad.to_degrees());
537            assert_eq!(point.height_above_ellipsoid_m, place.height_m);
538            // h = H + N with N = −25 m: sea-level heights are 25 m above ellipsoidal ones.
539            assert!((point.height_above_sea_level_m - place.height_m - 25.0).abs() < 1e-9);
540        }
541        // The wind carries it east of the site, and it lands where the summary says.
542        let last = track.last().unwrap();
543        let landing = summary.landing.unwrap();
544        assert!(last.longitude_deg > track[0].longitude_deg);
545        assert!(landing.east_m > 100.0);
546
547        let without = Recorder::new(vec![Channel::Time], None).unwrap();
548        assert!(matches!(
549            super::track(&without, &environment),
550            Err(SimError::Unsupported { .. })
551        ));
552    }
553
554    /// The published GeoJSON `FeatureCollection` schema (geojson/schema, MIT, commit 268ba0a).
555    fn geojson_validator() -> jsonschema::Validator {
556        let schema: Value = serde_json::from_str(include_str!(
557            "../tests/data/geojson-feature-collection.schema.json"
558        ))
559        .unwrap();
560        jsonschema::draft7::new(&schema).unwrap()
561    }
562
563    #[test]
564    fn geojson_passes_the_published_schema_in_longitude_latitude_order() {
565        let (_, track, summary, _) = flown();
566        let validator = geojson_validator();
567        let value: Value = serde_json::from_str(&geojson(&track, &summary).unwrap()).unwrap();
568        let errors: Vec<String> = validator
569            .iter_errors(&value)
570            .map(|e| e.to_string())
571            .collect();
572        assert!(errors.is_empty(), "{errors:?}");
573
574        let features = value["features"].as_array().unwrap();
575        assert_eq!(features.len(), 2);
576        let path = features[0]["geometry"]["coordinates"].as_array().unwrap();
577        assert_eq!(path.len(), track.len());
578        for (position, point) in path.iter().zip(&track) {
579            let p: [f64; 3] = serde_json::from_value(position.clone()).unwrap();
580            assert_eq!(
581                p,
582                [
583                    point.longitude_deg,
584                    point.latitude_deg,
585                    point.height_above_ellipsoid_m
586                ]
587            );
588        }
589        let landing = summary.landing.unwrap();
590        let spot: [f64; 2] =
591            serde_json::from_value(features[1]["geometry"]["coordinates"].clone()).unwrap();
592        assert_eq!(spot, [landing.longitude_deg, landing.latitude_deg]);
593
594        // The validator does reject a broken document: a one-number position.
595        let mut broken = value.clone();
596        broken["features"][1]["geometry"]["coordinates"] = json!([landing.longitude_deg]);
597        assert!(!validator.is_valid(&broken));
598    }
599
600    #[test]
601    fn kml_parses_as_kml_2_2_with_every_position() {
602        let (_, track, summary, _) = flown();
603        let text = kml(&track, &summary, "Valetudo & <friends>").unwrap();
604        let document = roxmltree::Document::parse(&text).unwrap();
605        let root = document.root_element();
606        assert_eq!(root.tag_name().name(), "kml");
607        assert_eq!(
608            root.tag_name().namespace(),
609            Some("http://www.opengis.net/kml/2.2")
610        );
611        let named = |tag: &'static str| {
612            let found: Vec<roxmltree::Node<'_, '_>> = document
613                .descendants()
614                .filter(|n| n.tag_name().name() == tag)
615                .collect();
616            found.into_iter()
617        };
618        let name = named("name").next().unwrap().text();
619        assert_eq!(name, Some("Valetudo & <friends>"));
620        assert_eq!(
621            named("altitudeMode").next().unwrap().text(),
622            Some("absolute")
623        );
624        let lists: Vec<&str> = named("coordinates").map(|n| n.text().unwrap()).collect();
625        assert_eq!(lists.len(), 2);
626        let path: Vec<Vec<f64>> = lists[0]
627            .split_whitespace()
628            .map(|t| t.split(',').map(|c| c.parse().unwrap()).collect())
629            .collect();
630        assert_eq!(path.len(), track.len());
631        for (position, point) in path.iter().zip(&track) {
632            assert_eq!(
633                position,
634                &[
635                    point.longitude_deg,
636                    point.latitude_deg,
637                    point.height_above_sea_level_m
638                ]
639            );
640        }
641        let landing = summary.landing.unwrap();
642        let spot: Vec<f64> = lists[1]
643            .trim()
644            .split(',')
645            .map(|c| c.parse().unwrap())
646            .collect();
647        assert_eq!(spot, [landing.longitude_deg, landing.latitude_deg]);
648    }
649}