1use hpr_sim::metrics::Landing;
29use hpr_sim::{EnvelopeFlag, FlightSummary};
30use serde::{Deserialize, Serialize};
31
32use crate::error::AnalysisError;
33use crate::montecarlo::{Draw, FailedAt, Outcome, Run, Sample};
34
35type DrawList = fn(&Draw) -> &Vec<f64>;
37
38type LandingField = fn(&Landing) -> f64;
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct Column {
45 pub name: String,
47 pub values: Values,
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(tag = "kind", content = "values", rename_all = "snake_case")]
54pub enum Values {
55 Integers(Vec<u64>),
57 Numbers(Vec<Option<f64>>),
59 Text(Vec<String>),
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct RunTable {
66 rows: usize,
67 columns: Vec<Column>,
68}
69
70impl RunTable {
71 pub fn new(run: &Run) -> Self {
75 let samples = &run.samples;
76 let mut columns = Vec::new();
77 columns.push(Column {
78 name: "index".to_owned(),
79 values: Values::Integers(samples.iter().map(|sample| sample.index).collect()),
80 });
81 let mut text = |name: &str, value: &dyn Fn(&Sample) -> String| {
82 columns.push(Column {
83 name: name.to_owned(),
84 values: Values::Text(samples.iter().map(value).collect()),
85 });
86 };
87 text("outcome", &|sample| match sample.outcome {
88 Outcome::Flown { .. } => "flown".to_owned(),
89 Outcome::Failed { .. } => "failed".to_owned(),
90 });
91 text("failed_at", &|sample| match &sample.outcome {
92 Outcome::Flown { .. } => String::new(),
93 Outcome::Failed { at, .. } => match at {
94 FailedAt::Inputs => "inputs".to_owned(),
95 FailedAt::Flight => "flight".to_owned(),
96 },
97 });
98 text("reason", &|sample| match &sample.outcome {
99 Outcome::Flown { .. } => String::new(),
100 Outcome::Failed { reason, .. } => reason.clone(),
101 });
102
103 let mut add = |name: String, value: &dyn Fn(&Sample) -> Option<f64>| {
104 columns.push(Column {
105 name,
106 values: Values::Numbers(samples.iter().map(value).collect()),
107 });
108 };
109 let drawn = |pick: fn(&Draw) -> f64| move |sample: &Sample| Some(pick(&sample.draw));
110 add("drag_scale".to_owned(), &drawn(|d| d.drag_scale));
111 add(
112 "wind_speed_scale".to_owned(),
113 &drawn(|d| d.wind_speed_scale),
114 );
115 add("wind_turn_rad".to_owned(), &drawn(|d| d.wind_turn_rad));
116 add(
117 "rail_elevation_offset_rad".to_owned(),
118 &drawn(|d| d.rail_elevation_offset_rad),
119 );
120 add(
121 "rail_azimuth_offset_rad".to_owned(),
122 &drawn(|d| d.rail_azimuth_offset_rad),
123 );
124 let lists: [(&str, &str, DrawList); 6] = [
125 ("stage", "dry_mass_scale", |d| &d.dry_mass_scale),
126 ("stage", "cg_shift_m", |d| &d.cg_shift_m),
127 ("motor", "impulse_scale", |d| &d.impulse_scale),
128 ("motor", "burn_time_scale", |d| &d.burn_time_scale),
129 ("motor", "ejection_delay_offset_s", |d| {
130 &d.ejection_delay_offset_s
131 }),
132 ("device", "deployment_lag_offset_s", |d| {
133 &d.deployment_lag_offset_s
134 }),
135 ];
136 for kind in ["stage", "motor", "device"] {
138 let of_kind: Vec<_> = lists.iter().filter(|(k, _, _)| *k == kind).collect();
139 let count = of_kind
140 .first()
141 .and_then(|(_, _, list)| samples.first().map(|s| list(&s.draw).len()))
142 .unwrap_or(0);
143 for copy in 0..count {
144 for (_, name, list) in &of_kind {
145 add(format!("{kind}_{}_{name}", copy + 1), &|sample| {
146 list(&sample.draw).get(copy).copied()
147 });
148 }
149 }
150 }
151
152 columns.push(Column {
153 name: "termination".to_owned(),
154 values: Values::Text(
155 samples
156 .iter()
157 .map(|sample| {
158 sample.summary().map_or_else(String::new, |s| {
159 snake_name(&format!("{:?}", s.termination))
160 })
161 })
162 .collect(),
163 ),
164 });
165 let mut flown = |name: &str, value: fn(&FlightSummary) -> Option<f64>| {
166 columns.push(Column {
167 name: name.to_owned(),
168 values: Values::Numbers(
169 samples
170 .iter()
171 .map(|sample| sample.summary().and_then(value))
172 .collect(),
173 ),
174 });
175 };
176 flown("apogee_m", |s| {
177 s.apogee.as_ref().map(|a| a.height_above_ground_m)
178 });
179 flown("apogee_time_s", |s| s.apogee.as_ref().map(|a| a.time_s));
180 flown("rail_exit_speed_m_s", |s| {
181 s.rail_exit_speed_m_s.as_ref().map(|p| p.value)
182 });
183 flown("max_speed_m_s", |s| {
184 s.max_speed_m_s.as_ref().map(|p| p.value)
185 });
186 flown("max_mach", |s| s.max_mach.as_ref().map(|p| p.value));
187 flown("max_dynamic_pressure_pa", |s| {
188 s.max_dynamic_pressure_pa.as_ref().map(|p| p.value)
189 });
190 flown("max_acceleration_m_s2", |s| {
191 s.max_acceleration_m_s2.as_ref().map(|p| p.value)
192 });
193 flown("min_static_margin_cal", |s| {
194 s.min_static_margin_cal.as_ref().map(|p| p.value)
195 });
196 flown("min_flight_margin_cal", |s| {
197 s.min_flight_margin_cal.as_ref().map(|p| p.value)
198 });
199 flown("max_angle_of_attack_rad", |s| {
200 s.max_angle_of_attack_rad.as_ref().map(|p| p.value)
201 });
202 flown("landing_time_s", |s| s.nose_landing().map(|l| l.time_s));
203 flown("landing_east_m", |s| s.nose_landing().map(|l| l.east_m));
204 flown("landing_north_m", |s| s.nose_landing().map(|l| l.north_m));
205 flown("landing_distance_m", |s| {
206 s.nose_landing().map(|l| l.distance_m)
207 });
208 flown("landing_latitude_deg", |s| {
209 s.nose_landing().map(|l| l.latitude_deg)
210 });
211 flown("landing_longitude_deg", |s| {
212 s.nose_landing().map(|l| l.longitude_deg)
213 });
214 flown("ground_hit_speed_m_s", |s| {
215 s.nose_landing().map(|l| l.ground_hit_speed_m_s)
216 });
217
218 let parts = samples
219 .iter()
220 .filter_map(Sample::summary)
221 .flat_map(|s| s.body_landings.iter().filter_map(|l| l.body))
222 .filter(|body| *body > 0)
223 .max()
224 .unwrap_or(0);
225 let fields: [(&str, LandingField); 4] = [
226 ("landing_time_s", |l| l.time_s),
227 ("landing_east_m", |l| l.east_m),
228 ("landing_north_m", |l| l.north_m),
229 ("ground_hit_speed_m_s", |l| l.ground_hit_speed_m_s),
230 ];
231 for part in 1..=parts {
232 for (name, field) in fields {
233 columns.push(Column {
234 name: format!("part_{part}_{name}"),
235 values: Values::Numbers(
236 samples
237 .iter()
238 .map(|sample| {
239 let landing = sample
240 .summary()?
241 .body_landings
242 .iter()
243 .find(|l| l.body == Some(part))?;
244 Some(field(landing))
245 })
246 .collect(),
247 ),
248 });
249 }
250 }
251 columns.push(Column {
252 name: "flags".to_owned(),
253 values: Values::Text(
254 samples
255 .iter()
256 .map(|sample| {
257 sample.summary().map_or_else(String::new, |s| {
258 s.envelope_flags()
259 .iter()
260 .map(flag_name)
261 .collect::<Vec<_>>()
262 .join(";")
263 })
264 })
265 .collect(),
266 ),
267 });
268 Self {
269 rows: samples.len(),
270 columns,
271 }
272 }
273
274 pub fn rows(&self) -> usize {
276 self.rows
277 }
278
279 pub fn columns(&self) -> &[Column] {
281 &self.columns
282 }
283
284 pub fn column(&self, name: &str) -> Option<&Column> {
286 self.columns.iter().find(|column| column.name == name)
287 }
288
289 pub fn csv(&self) -> Result<String, AnalysisError> {
299 let header: Vec<String> = self.columns.iter().map(|c| quoted(&c.name)).collect();
300 let mut out = header.join(",");
301 out.push_str("\r\n");
302 for row in 0..self.rows {
303 let mut cells = Vec::with_capacity(self.columns.len());
304 for column in &self.columns {
305 cells.push(match &column.values {
306 Values::Numbers(values) => match values.get(row).copied().flatten() {
307 Some(value) if value.is_finite() => format!("{value:?}"),
308 Some(value) => {
309 return Err(AnalysisError::Domain {
310 what: "number in a run's table",
311 value,
312 });
313 }
314 None => String::new(),
315 },
316 Values::Integers(values) => {
317 values.get(row).map_or_else(String::new, u64::to_string)
318 }
319 Values::Text(values) => values.get(row).map_or_else(String::new, |v| quoted(v)),
320 });
321 }
322 out.push_str(&cells.join(","));
323 out.push_str("\r\n");
324 }
325 Ok(out)
326 }
327}
328
329fn quoted(text: &str) -> String {
332 if text.contains([',', '"', '\r', '\n']) {
333 format!("\"{}\"", text.replace('"', "\"\""))
334 } else {
335 text.to_owned()
336 }
337}
338
339fn snake_name(debug: &str) -> String {
342 let name = debug
343 .split(|c: char| c == '{' || c == '(' || c.is_whitespace())
344 .next()
345 .unwrap_or_default();
346 let mut out = String::with_capacity(name.len() + 4);
347 for (i, c) in name.chars().enumerate() {
348 if c.is_ascii_uppercase() {
349 if i > 0 {
350 out.push('_');
351 }
352 out.push(c.to_ascii_lowercase());
353 } else {
354 out.push(c);
355 }
356 }
357 out
358}
359
360fn flag_name(flag: &EnvelopeFlag) -> String {
362 flag.name().to_owned()
363}
364
365#[cfg(test)]
366mod tests {
367 #![allow(
368 clippy::unwrap_used,
369 clippy::expect_used,
370 clippy::panic,
371 reason = "tests state their expectations by unwrapping and panicking"
372 )]
373
374 use super::*;
375 use crate::montecarlo::tests::{SEED, every_dispersion, nominal};
376 use crate::montecarlo::{Dispersion, MonteCarlo};
377
378 fn cells(text: &str) -> Vec<Vec<String>> {
380 let mut lines = Vec::new();
381 let mut line = Vec::new();
382 let mut cell = String::new();
383 let mut chars = text.chars().peekable();
384 let mut quoted = false;
385 while let Some(c) = chars.next() {
386 match (quoted, c) {
387 (true, '"') if chars.peek() == Some(&'"') => {
388 chars.next();
389 cell.push('"');
390 }
391 (true, '"') => quoted = false,
392 (true, c) => cell.push(c),
393 (false, '"') => quoted = true,
394 (false, ',') => line.push(std::mem::take(&mut cell)),
395 (false, '\r') => {
396 assert_eq!(chars.next(), Some('\n'), "a line ends in CRLF");
397 line.push(std::mem::take(&mut cell));
398 lines.push(std::mem::take(&mut line));
399 }
400 (false, c) => cell.push(c),
401 }
402 }
403 assert!(cell.is_empty() && line.is_empty(), "the text ends in CRLF");
404 lines
405 }
406
407 fn run() -> Run {
409 let dispersion = Dispersion {
410 dry_mass_sd_fraction: 0.6,
411 ..every_dispersion()
412 };
413 MonteCarlo::new(nominal(), dispersion)
414 .unwrap()
415 .run(SEED, 16)
416 }
417
418 #[test]
421 fn the_csv_reads_back_to_the_runs_bits() {
422 let run = run();
423 let failed = run.failed().count();
424 assert!(failed > 0 && failed < 16, "{failed} failed");
425 let table = RunTable::new(&run);
426 assert_eq!(table.rows(), 16);
427 let lines = cells(&table.csv().unwrap());
428 assert_eq!(lines.len(), 17);
429 let names: Vec<&str> = table.columns().iter().map(|c| c.name.as_str()).collect();
430 assert_eq!(lines[0], names);
431 for (row, line) in lines[1..].iter().enumerate() {
432 assert_eq!(line.len(), names.len());
433 for (column, cell) in table.columns().iter().zip(line) {
434 match &column.values {
435 Values::Numbers(values) => match values[row] {
436 Some(value) => assert_eq!(
437 cell.parse::<f64>().unwrap().to_bits(),
438 value.to_bits(),
439 "{} of row {row}",
440 column.name
441 ),
442 None => assert!(cell.is_empty(), "{} of row {row}", column.name),
443 },
444 Values::Integers(values) => assert_eq!(*cell, values[row].to_string()),
445 Values::Text(values) => assert_eq!(*cell, values[row], "{}", column.name),
446 }
447 }
448 }
449 let number = |name: &str, row: usize| match &table.column(name).unwrap().values {
451 Values::Numbers(values) => values[row],
452 Values::Text(_) | Values::Integers(_) => panic!("{name} isn't numbers"),
453 };
454 let word = |name: &str, row: usize| match &table.column(name).unwrap().values {
455 Values::Text(values) => values[row].clone(),
456 Values::Numbers(_) | Values::Integers(_) => panic!("{name} isn't words"),
457 };
458 for (row, sample) in run.samples.iter().enumerate() {
459 assert_eq!(
460 table.column("index").unwrap().values,
461 Values::Integers((0..16).collect())
462 );
463 assert_eq!(sample.index, row as u64);
464 let draw = &sample.draw;
465 assert_eq!(number("drag_scale", row), Some(draw.drag_scale));
466 assert_eq!(number("wind_turn_rad", row), Some(draw.wind_turn_rad));
467 assert_eq!(
468 number("stage_1_dry_mass_scale", row),
469 Some(draw.dry_mass_scale[0])
470 );
471 assert_eq!(number("stage_1_cg_shift_m", row), Some(draw.cg_shift_m[0]));
472 assert_eq!(
473 number("motor_1_burn_time_scale", row),
474 Some(draw.burn_time_scale[0])
475 );
476 assert_eq!(
477 number("device_1_deployment_lag_offset_s", row),
478 Some(draw.deployment_lag_offset_s[0])
479 );
480 match &sample.outcome {
481 Outcome::Flown { summary } => {
482 assert_eq!(word("outcome", row), "flown");
483 assert_eq!(word("failed_at", row), "");
484 assert_eq!(word("termination", row), "ground_hit");
485 let apogee = summary.apogee.as_ref().unwrap();
486 assert_eq!(number("apogee_m", row), Some(apogee.height_above_ground_m));
487 let landing = summary.landing.as_ref().unwrap();
488 assert_eq!(number("landing_east_m", row), Some(landing.east_m));
489 assert_eq!(number("landing_north_m", row), Some(landing.north_m));
490 assert_eq!(
491 number("ground_hit_speed_m_s", row),
492 Some(landing.ground_hit_speed_m_s)
493 );
494 }
495 Outcome::Failed { at, reason } => {
496 assert_eq!(word("outcome", row), "failed");
497 assert_eq!(word("failed_at", row), "flight");
498 assert_eq!(*at, FailedAt::Flight);
499 assert_eq!(word("reason", row), *reason);
500 assert_eq!(number("apogee_m", row), None);
501 assert_eq!(word("termination", row), "");
502 }
503 }
504 }
505 assert_eq!(names.iter().filter(|n| n.starts_with("stage_")).count(), 2);
507 assert!(!names.iter().any(|n| n.starts_with("part_")));
508 }
509
510 #[derive(Debug, PartialEq)]
512 enum Cell {
513 Integer(u64),
514 Number(Option<u64>),
515 Word(String),
516 }
517
518 fn expected_row(sample: &Sample, parts: usize) -> Vec<(String, Cell)> {
521 use hpr_sim::Termination;
522 let number = |value: Option<f64>| Cell::Number(value.map(f64::to_bits));
523 let word = |text: &str| Cell::Word(text.to_owned());
524 let draw = &sample.draw;
525 let summary = sample.summary();
526 let (outcome, failed_at, reason) = match &sample.outcome {
527 Outcome::Flown { .. } => ("flown", "", String::new()),
528 Outcome::Failed { at, reason } => (
529 "failed",
530 match at {
531 FailedAt::Inputs => "inputs",
532 FailedAt::Flight => "flight",
533 },
534 reason.clone(),
535 ),
536 };
537 let mut row = vec![
538 ("index".to_owned(), Cell::Integer(sample.index)),
539 ("outcome".to_owned(), word(outcome)),
540 ("failed_at".to_owned(), word(failed_at)),
541 ("reason".to_owned(), Cell::Word(reason)),
542 ("drag_scale".to_owned(), number(Some(draw.drag_scale))),
543 (
544 "wind_speed_scale".to_owned(),
545 number(Some(draw.wind_speed_scale)),
546 ),
547 ("wind_turn_rad".to_owned(), number(Some(draw.wind_turn_rad))),
548 (
549 "rail_elevation_offset_rad".to_owned(),
550 number(Some(draw.rail_elevation_offset_rad)),
551 ),
552 (
553 "rail_azimuth_offset_rad".to_owned(),
554 number(Some(draw.rail_azimuth_offset_rad)),
555 ),
556 ];
557 for (i, (mass, cg)) in draw.dry_mass_scale.iter().zip(&draw.cg_shift_m).enumerate() {
558 row.push((
559 format!("stage_{}_dry_mass_scale", i + 1),
560 number(Some(*mass)),
561 ));
562 row.push((format!("stage_{}_cg_shift_m", i + 1), number(Some(*cg))));
563 }
564 for i in 0..draw.impulse_scale.len() {
565 row.push((
566 format!("motor_{}_impulse_scale", i + 1),
567 number(Some(draw.impulse_scale[i])),
568 ));
569 row.push((
570 format!("motor_{}_burn_time_scale", i + 1),
571 number(Some(draw.burn_time_scale[i])),
572 ));
573 row.push((
574 format!("motor_{}_ejection_delay_offset_s", i + 1),
575 number(Some(draw.ejection_delay_offset_s[i])),
576 ));
577 }
578 for (i, lag) in draw.deployment_lag_offset_s.iter().enumerate() {
579 row.push((
580 format!("device_{}_deployment_lag_offset_s", i + 1),
581 number(Some(*lag)),
582 ));
583 }
584 let termination = summary.map_or("", |s| match s.termination {
585 Termination::GroundHit => "ground_hit",
586 Termination::Separated => "separated",
587 other => panic!("{other:?} isn't flown here"),
588 });
589 row.push(("termination".to_owned(), word(termination)));
590 let peak = |pick: fn(&FlightSummary) -> Option<f64>| number(summary.and_then(pick));
591 row.push((
592 "apogee_m".to_owned(),
593 number(summary.and_then(|s| s.apogee.map(|a| a.height_above_ground_m))),
594 ));
595 row.push((
596 "apogee_time_s".to_owned(),
597 number(summary.and_then(|s| s.apogee.map(|a| a.time_s))),
598 ));
599 row.push((
600 "rail_exit_speed_m_s".to_owned(),
601 peak(|s| s.rail_exit_speed_m_s.as_ref().map(|p| p.value)),
602 ));
603 row.push((
604 "max_speed_m_s".to_owned(),
605 peak(|s| s.max_speed_m_s.as_ref().map(|p| p.value)),
606 ));
607 row.push((
608 "max_mach".to_owned(),
609 peak(|s| s.max_mach.as_ref().map(|p| p.value)),
610 ));
611 row.push((
612 "max_dynamic_pressure_pa".to_owned(),
613 peak(|s| s.max_dynamic_pressure_pa.as_ref().map(|p| p.value)),
614 ));
615 row.push((
616 "max_acceleration_m_s2".to_owned(),
617 peak(|s| s.max_acceleration_m_s2.as_ref().map(|p| p.value)),
618 ));
619 row.push((
620 "min_static_margin_cal".to_owned(),
621 peak(|s| s.min_static_margin_cal.as_ref().map(|p| p.value)),
622 ));
623 row.push((
624 "min_flight_margin_cal".to_owned(),
625 peak(|s| s.min_flight_margin_cal.as_ref().map(|p| p.value)),
626 ));
627 row.push((
628 "max_angle_of_attack_rad".to_owned(),
629 peak(|s| s.max_angle_of_attack_rad.as_ref().map(|p| p.value)),
630 ));
631 let landing_of = |body: usize| {
634 summary.and_then(|s| s.body_landings.iter().find(|l| l.body == Some(body)))
635 };
636 let nose = summary.and_then(|s| match s.termination {
637 Termination::Separated => landing_of(0),
638 _ => s.landing.as_ref(),
639 });
640 let field = |pick: fn(&Landing) -> f64| number(nose.map(pick));
641 row.push(("landing_time_s".to_owned(), field(|l| l.time_s)));
642 row.push(("landing_east_m".to_owned(), field(|l| l.east_m)));
643 row.push(("landing_north_m".to_owned(), field(|l| l.north_m)));
644 row.push(("landing_distance_m".to_owned(), field(|l| l.distance_m)));
645 row.push(("landing_latitude_deg".to_owned(), field(|l| l.latitude_deg)));
646 row.push((
647 "landing_longitude_deg".to_owned(),
648 field(|l| l.longitude_deg),
649 ));
650 row.push((
651 "ground_hit_speed_m_s".to_owned(),
652 field(|l| l.ground_hit_speed_m_s),
653 ));
654 for part in 1..=parts {
655 let landing = landing_of(part);
656 let field = |pick: fn(&Landing) -> f64| number(landing.map(pick));
657 row.push((format!("part_{part}_landing_time_s"), field(|l| l.time_s)));
658 row.push((format!("part_{part}_landing_east_m"), field(|l| l.east_m)));
659 row.push((format!("part_{part}_landing_north_m"), field(|l| l.north_m)));
660 row.push((
661 format!("part_{part}_ground_hit_speed_m_s"),
662 field(|l| l.ground_hit_speed_m_s),
663 ));
664 }
665 let flags: Vec<&str> = summary.map_or_else(Vec::new, |s| {
666 let mach = s.max_mach.map_or(0.0, |p| p.value);
667 let aoa = s.max_angle_of_attack_rad.map_or(0.0, |p| p.value);
668 let powered = s.min_powered_static_margin_cal.map_or(0.0, |p| p.value);
669 let moment = s.max_powered_moment_slope_per_rad.map_or(0.0, |p| p.value);
670 let mut flags = Vec::new();
671 if powered < 0.0 {
672 flags.push("unstable_under_power");
673 } else if moment > 0.0 {
674 flags.push("unstable_without_margin");
675 }
676 if mach > hpr_sim::envelope::VALIDATED_MACH {
677 flags.push("beyond_validated_range");
678 }
679 if aoa > hpr_sim::envelope::HIGH_ANGLE_OF_ATTACK_RAD {
680 flags.push("high_angle_of_attack");
681 }
682 if mach > hpr_sim::envelope::CORE_BAND_MACH {
683 flags.push("outside_core_band");
684 }
685 flags
686 });
687 row.push(("flags".to_owned(), Cell::Word(flags.join(";"))));
688 row
689 }
690
691 fn table_row(table: &RunTable, row: usize) -> Vec<(String, Cell)> {
693 table
694 .columns()
695 .iter()
696 .map(|column| {
697 let cell = match &column.values {
698 Values::Integers(values) => Cell::Integer(values[row]),
699 Values::Numbers(values) => Cell::Number(values[row].map(f64::to_bits)),
700 Values::Text(values) => Cell::Word(values[row].clone()),
701 };
702 (column.name.clone(), cell)
703 })
704 .collect()
705 }
706
707 #[test]
712 fn every_column_is_its_own_field() {
713 let mut run = run();
714 let mut flown = 0;
715 for sample in &mut run.samples {
716 let draw = &mut sample.draw;
717 draw.dry_mass_scale.push(1.11);
718 draw.cg_shift_m.push(-0.012);
719 draw.impulse_scale.push(0.97);
720 draw.burn_time_scale.push(1.04);
721 draw.ejection_delay_offset_s.push(0.3);
722 draw.deployment_lag_offset_s.push(0.21);
723 let Outcome::Flown { summary } = &mut sample.outcome else {
724 continue;
725 };
726 flown += 1;
727 let landing = summary.landing.unwrap();
728 let part = |body: usize, shift: f64| Landing {
729 body: Some(body),
730 time_s: landing.time_s + 10.0 * shift,
731 east_m: landing.east_m + shift,
732 north_m: landing.north_m - 2.0 * shift,
733 ground_hit_speed_m_s: landing.ground_hit_speed_m_s + 0.5 * shift,
734 ..landing
735 };
736 summary.body_landings = vec![part(1, 1.0)];
737 if flown % 2 == 0 {
738 summary.termination = hpr_sim::Termination::Separated;
741 summary.landing = None;
742 summary.body_landings = vec![part(0, 3.0), part(1, 1.0), part(2, 2.0)];
743 }
744 if flown == 1 {
745 summary.max_mach.as_mut().unwrap().value = 3.0;
746 }
747 if flown == 3 {
748 summary.max_angle_of_attack_rad.as_mut().unwrap().value = 0.5;
749 }
750 }
751 assert!(flown >= 4, "{flown} flown");
752 let table = RunTable::new(&run);
753 for (row, sample) in run.samples.iter().enumerate() {
754 assert_eq!(table_row(&table, row), expected_row(sample, 2), "row {row}");
755 }
756 let flags = table.column("flags").unwrap();
757 let Values::Text(flags) = &flags.values else {
758 panic!("flags aren't words");
759 };
760 assert!(
761 flags
762 .iter()
763 .any(|f| f == "beyond_validated_range;outside_core_band")
764 );
765 assert!(flags.iter().any(|f| f == "high_angle_of_attack"));
766 }
767
768 #[test]
771 fn words_are_quoted_and_non_finite_numbers_refused() {
772 assert_eq!(quoted("plain words"), "plain words");
773 assert_eq!(quoted("a, b"), "\"a, b\"");
774 assert_eq!(quoted("say \"hi\""), "\"say \"\"hi\"\"\"");
775 assert_eq!(quoted("two\r\nlines"), "\"two\r\nlines\"");
776 let table = RunTable {
777 rows: 1,
778 columns: vec![Column {
779 name: "x".to_owned(),
780 values: Values::Numbers(vec![Some(f64::NAN)]),
781 }],
782 };
783 assert!(matches!(
784 table.csv(),
785 Err(AnalysisError::Domain { what, value }) if what.contains("run's table") && value.is_nan()
786 ));
787 }
788
789 #[test]
791 fn names_are_in_snake_case() {
792 assert_eq!(snake_name("GroundHit"), "ground_hit");
793 assert_eq!(
794 snake_name("BeyondEnvelope { mach: 3.6 }"),
795 "beyond_envelope"
796 );
797 assert_eq!(snake_name("Separated"), "separated");
798 }
799}