Skip to main content

hpr_analysis/
table.rs

1//! A Monte Carlo [`Run`] as a table: one row a sample, in index order, and one column an input it
2//! drew or a number its flight came to. `hpr mc` writes it as CSV ([`RunTable::csv`]), each
3//! number in the shortest form that reads back to the same bits, so a program reading the file
4//! gets the run's own numbers.
5//!
6//! The columns, in order:
7//!
8//! - `index`, a whole number, then `outcome` (`flown` or `failed`), `failed_at` (`inputs` or `flight`, empty for
9//!   a flown sample) and `reason` (the error, empty for a flown sample): [`Sample`], [`Outcome`].
10//! - What the sample drew ([`Draw`]): `drag_scale`, `wind_speed_scale`, `wind_turn_rad`,
11//!   `rail_elevation_offset_rad` and `rail_azimuth_offset_rad`; then for each stage `n`, counted
12//!   from 1 at the nose, `stage_n_dry_mass_scale` and `stage_n_cg_shift_m`; for each motor of the
13//!   flown configuration, `motor_n_impulse_scale`, `motor_n_burn_time_scale` and
14//!   `motor_n_ejection_delay_offset_s`; for each recovery device, `device_n_deployment_lag_offset_s`.
15//! - What a flown sample's flight came to ([`FlightSummary`]), empty for a failed sample or a
16//!   number its flight doesn't have: `termination`; `apogee_m` (height above the ground) and
17//!   `apogee_time_s`; the peaks' values `rail_exit_speed_m_s`, `max_speed_m_s`, `max_mach`,
18//!   `max_dynamic_pressure_pa`, `max_acceleration_m_s2`, `min_static_margin_cal`,
19//!   `min_flight_margin_cal` and `max_angle_of_attack_rad`; the landing of the part that keeps
20//!   the nose ([`FlightSummary::nose_landing`]), `landing_time_s`,
21//!   `landing_east_m`, `landing_north_m`, `landing_distance_m`, `landing_latitude_deg`,
22//!   `landing_longitude_deg` and `ground_hit_speed_m_s`; for each part `n` that a separation
23//!   dropped, `part_n_landing_time_s`, `part_n_landing_east_m`, `part_n_landing_north_m` and
24//!   `part_n_ground_hit_speed_m_s`; and `flags`, the flags the flight raised ([`EnvelopeFlag`]:
25//!   the operating envelope's, and one for a rocket unstable under power), by name, separated by
26//!   `;`.
27
28use 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
35/// One of a draw's lists, by stage, motor or device.
36type DrawList = fn(&Draw) -> &Vec<f64>;
37
38/// One number of a landing.
39type LandingField = fn(&Landing) -> f64;
40
41/// One column of a [`RunTable`]: its name, with its unit where it has one, and a value for each
42/// sample.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct Column {
45    /// The column's name, such as `apogee_m`.
46    pub name: String,
47    /// Its values, one a sample in index order.
48    pub values: Values,
49}
50
51/// A column's values.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(tag = "kind", content = "values", rename_all = "snake_case")]
54pub enum Values {
55    /// Whole numbers: the samples' indices.
56    Integers(Vec<u64>),
57    /// Numbers, `None` where a sample has none.
58    Numbers(Vec<Option<f64>>),
59    /// Words, empty where a sample has none.
60    Text(Vec<String>),
61}
62
63/// A run as a table: the module's docs list its columns.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct RunTable {
66    rows: usize,
67    columns: Vec<Column>,
68}
69
70impl RunTable {
71    /// `run` as a table. The lists of a draw (stages, motors, devices) take their lengths from the
72    /// first sample, as every sample of a run draws the same; the parts' landings run to the
73    /// highest part that landed in any sample.
74    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        // Stage by stage, each stage's two lists together; then motor by motor.
137        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    /// The number of rows: the run's samples.
275    pub fn rows(&self) -> usize {
276        self.rows
277    }
278
279    /// The columns, in the module docs' order.
280    pub fn columns(&self) -> &[Column] {
281        &self.columns
282    }
283
284    /// The column called `name`, if there is one.
285    pub fn column(&self, name: &str) -> Option<&Column> {
286        self.columns.iter().find(|column| column.name == name)
287    }
288
289    /// The table as CSV (RFC 4180): a header of the column names, then one line a sample, lines
290    /// ending in CRLF. A number is written in the shortest form that reads back to the same
291    /// bits (Rust's `{:?}`, which Python's `float` and every IEEE-correct reader read exactly);
292    /// a missing number is an empty cell. Words are quoted where they hold a comma, a quote or
293    /// a line break, a quote doubled.
294    ///
295    /// # Errors
296    ///
297    /// [`AnalysisError::Domain`] for a number that isn't finite.
298    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
329/// `text` as a CSV cell: quoted, its quotes doubled, where it holds a comma, a quote or a line
330/// break (RFC 4180 ยง2).
331fn quoted(text: &str) -> String {
332    if text.contains([',', '"', '\r', '\n']) {
333        format!("\"{}\"", text.replace('"', "\"\""))
334    } else {
335        text.to_owned()
336    }
337}
338
339/// A Rust name in snake case: `GroundHit` is `ground_hit`. Only the name before any `{` or `(`
340/// of a variant's fields is kept.
341fn 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
360/// A flag's name, as it is serialized.
361fn 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    /// The cells of RFC 4180 `text`, line by line: quoted cells unquoted, their quotes undoubled.
379    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    /// A run with flown and failed samples, its every dispersion on.
408    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    /// M4.6a's bullet: every number of the run reads back from the CSV to the same bits, a
419    /// missing one is an empty cell, and every word reads back as it was, quoted or not.
420    #[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        // The values are the run's own.
450        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        // One stage, one motor, one device, and no part dropped.
506        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    /// A cell as the table should hold it, worked out from the sample's own fields.
511    #[derive(Debug, PartialEq)]
512    enum Cell {
513        Integer(u64),
514        Number(Option<u64>),
515        Word(String),
516    }
517
518    /// Every column of `sample`'s row, by name, read field by field from the sample, not through
519    /// the table's code; `parts` is how many dropped parts the run has.
520    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        // A split with nothing left to burn leaves the flight's landing empty: part 0's is the
632        // nose's.
633        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    /// The table's row `row`, by name.
692    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    /// M4.6a, found in review: every column holds its own field, in the documented order, on a
708    /// run with two stages, two motors, two devices, two dropped parts with their own landings,
709    /// a flight split with nothing left to burn (its landing part 0's), envelope flags, and
710    /// failed flights. Each part's and each list's values differ, so a swap of two columns fails.
711    #[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                // Split with nothing left to burn: part 0's landing is the nose's, and the
739                // second part landed too.
740                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    /// A cell with a comma, a quote or a line break is quoted, its quotes doubled; a number that
769    /// isn't finite is refused, not written.
770    #[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    /// A Rust name in snake case, its fields dropped.
790    #[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}