1use 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#[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#[derive(serde::Serialize)]
60struct Rows<'a> {
61 tool: Tool,
62 columns: &'a [String],
63 rows: &'a [Vec<f64>],
64}
65
66#[derive(serde::Serialize)]
68struct FeatureCollection {
69 #[serde(rename = "type")]
70 kind: &'static str,
71 tool: Tool,
72 features: Vec<Value>,
73}
74
75fn to_text(value: &impl serde::Serialize) -> Result<String, SimError> {
77 serde_json::to_string(value).map_err(|_| SimError::Unsupported {
80 what: "a value JSON can't hold",
81 })
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
86pub struct TrackPoint {
87 pub time_s: f64,
89 pub latitude_deg: f64,
91 pub longitude_deg: f64,
93 pub height_above_ellipsoid_m: f64,
95 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
107pub 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
127pub 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
148pub 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
185fn 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
207pub 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
257fn 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("&"),
276 '<' => out.push_str("<"),
277 '>' => out.push_str(">"),
278 '"' => out.push_str("""),
279 '\'' => out.push_str("'"),
280 _ => out.push(c),
281 }
282 }
283 Ok(out)
284}
285
286pub 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 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 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 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 #[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 assert!((point.height_above_sea_level_m - place.height_m - 25.0).abs() < 1e-9);
540 }
541 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 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 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}