Skip to main content

hpr_flightdata/
rrc3.rs

1//! Missile Works' RRC3 flight logs, as its mDACS software exports a flight to a CSV file.
2//!
3//! The RRC3 is a barometric dual-deploy altimeter (*RRC3+ / RRC3 User Manual* v1.71, p. 2): it
4//! records its pressure at 20 Hz, and its temperature and battery voltage at 1 Hz (p. 23).
5//! mDACS works the altitude and the "displacement velocity" out of the pressure (*mDACS User
6//! Manual* Rev 1.71, May 2024, p. 2) and exports "a standard Excel .CSV format", the columns shown
7//! on its chart; "Time, Altitude and Pressure are always exported" (p. 10). One header line names
8//! the columns; each row below is one sample:
9//!
10//! ```text
11//! Time,Altitude,Pressure,Velocity,Temperature,Events,Voltages
12//! 0.00,0,1013.20,0,71.60, -,9.20
13//! 0.05,2.71,1013.10,1.30,71.60, -,9.20
14//! …
15//! 52.30,0,1013.20,0, -
16//! ```
17//!
18//! **Recognition.** A header whose names, compared without regard to case and with any bracketed
19//! unit taken off, start `Time`, `Altitude`, `Pressure`, `Velocity`, and go on with any of
20//! `Temperature`, `Events` and `Voltages`, each at most once. A PerfectFlite export, `Time,
21//! Altitude, Velocity, …`, has no pressure and isn't one. The cells are separated by commas, or by
22//! semicolons or tabs, in which case a number may be written with a decimal comma: Debrief's notes
23//! describe a European-locale export, semicolon-separated with decimal commas
24//! (`lib/parsers/missileworksRrc3.ts`, MIT, the project owner's own; see
25//! `THIRD-PARTY-NOTICES.md`). As of 2026-10-12, every one of the 50 RRC3 files among the
26//! project's sample logs is comma-separated.
27//!
28//! **Units.** Most exports name none; some name each in brackets, as `Time [s],Altitude
29//! [ft],Pressure [mbar],Velocity [ft/s],Temperature [F],Events,Voltages [V]`. A bracketed unit is
30//! taken as written, and one the reader doesn't know is refused ([`LogError::Unit`]). A unit the
31//! header doesn't name is decided as the table says, and a note on the record states how and on
32//! what evidence:
33//!
34//! | Column | Quantity | Read from | When the header names no unit |
35//! |---|---|---|---|
36//! | `Time` | the clock, s | `[s]` | seconds, from the step: the median step between rows within 5% of 0.05 s, the RRC3's 20 Hz pressure rate (RRC3 manual p. 23); otherwise assumed, as mDACS's chart is timed in seconds (mDACS manual p. 8) |
37//! | `Altitude` | [`Quantity::AltitudeAboveGround`] | `[ft]`, `[m]` | feet or meters, from the pressure (below); feet assumed if the pressure can't tell |
38//! | `Pressure` | [`Quantity::Pressure`] | `[mbar]`, `[hPa]`, × 100 Pa | hectopascals if the first row's value, on the pad, lies within 500 to 1,100 hPa (below) and the highest within the 10 to 1,200 mbar the RRC3 measures (RRC3 manual p. 9); otherwise kept as written |
39//! | `Velocity` | [`Quantity::Speed`], up positive | `[ft/s]`, `[mph]`, `[m/s]`, `[km/h]` (or the RRC3's `fps`, `mps`, `kph`) | measured against the altitude (below); otherwise kept as written |
40//! | `Temperature` | [`Quantity::Temperature`] | `[F]`, `[C]` | assumed: °F if the altitude is in feet, °C if in meters, as the RRC3's unit settings pair them (RRC3 manual p. 12) |
41//! | `Voltages` | [`Quantity::Voltage`] | `[V]` | assumed volts: the RRC3's battery voltage (mDACS manual p. 2), from a battery of at most 10 V (RRC3 manual p. 6) |
42//! | `Events` | [`FlightLog::events`] | | |
43//!
44//! mDACS sets the altitude's, the velocity's and the temperature's units by preference (mDACS
45//! manual p. 5), and the velocity's apart from the altitude's: as of 2026-10-12, 8 of the 50 RRC3
46//! files among the project's sample logs write their velocity in miles per hour beside an
47//! altitude in feet. The RRC3's own settings name four sets: feet, °F and ft/s; meters, °C and
48//! km/h; feet, °F and mph; meters, °C and m/s, the first the default (RRC3 manual p. 12).
49//!
50//! *The pressure on the pad.* The RRC3 measures 10 to 1,200 mbar, but kilopascals (about 100 at
51//! sea level) and inches of mercury (about 30) lie within that too, so the first row's pressure,
52//! taken on the pad, must also be a launch site's: at least 500 hPa, the U.S. Standard
53//! Atmosphere's pressure about 5,570 m up, above any launch site, and at most 1,100 hPa, above
54//! both the highest sea-level pressure on record below 750 m of elevation, 1,083.8 hPa (WMO,
55//! *Records of Weather and Climate Extremes*, as of 31 July 2025), and the standard atmosphere's
56//! 1,066 hPa at the lowest dry land, the Dead Sea's shore about 430 m below sea level. A pressure
57//! outside either range is kept as written, with a note, and the altitude's unit is then assumed.
58//!
59//! *The altitude from the pressure.* mDACS converts pressure to altitude by NOAA's pressure
60//! altitude, the standard atmosphere's (RRC3 manual p. 23), or by a geopotential method that
61//! brings in the day's temperature and humidity (mDACS manual p. 5). Between the first row, on
62//! the pad (the RRC3 keeps a second of samples from before launch, p. 23), and the row of lowest
63//! pressure, the reader works out the climb the pressure implies in the U.S. Standard Atmosphere,
64//! 1976 ([`hpr_atmos::ussa76`]) and compares the altitude column's climb between the same rows,
65//! read in feet and in meters. The unit within 25% of the pressure's climb is taken: a day's air
66//! from −40 °C to +50 °C is within about 20% of the standard's temperature, which is how far the
67//! geopotential method can move the altitude, and feet and meters are 3.28 times apart, so at
68//! most one unit can be within 25%. As of 2026-10-12, over the 50 RRC3 files among the project's
69//! sample logs, feet came within 0.05% every time and meters at least 227% off. Neither within 25%
70//! is refused ([`LogError::Unit`]), as is a stated unit the
71//! pressure contradicts by more. Below a climb of 10 m the pressure can't settle it: a step of the
72//! pressure, 0.1 hPa in every sample log, is 0.83 m of altitude at sea level, more than 8% of
73//! such a climb. Then, or with a pressure of unknown unit, feet are assumed, the RRC3's default.
74//!
75//! *The velocity against the altitude.* The velocity is mDACS's rate of the altitude, in its own
76//! unit: ft/s, mph, m/s and km/h are 3.2808, 2.2369, 1 and 3.6 times a speed in m/s. The RRC3
77//! works it out through a 21-element filter at 20 Hz (RRC3 manual p. 23), so it runs behind the
78//! altitude, and mDACS may smooth both for its viewer (mDACS manual p. 5). The reader integrates
79//! the velocity by trapezoids from zero at the first row, on the pad, and measures the running
80//! integral against the altitude above the first row, in meters, two ways:
81//!
82//! - *By the fit:* the least-squares slope, through zero, of the integral against the altitude as
83//!   it was a delay earlier, the delay from −2 to 4 s, in steps of 0.05 s, that fits best. A
84//!   velocity that is the altitude's rate a delay late integrates to the altitude that delay late,
85//!   times the unit, so the delay is fitted out rather than read as a smaller slope. Smoothing
86//!   still rounds off the integral's apogee, which moves the slope.
87//! - *By the areas:* the area under the integral over the area under the delayed altitude, both
88//!   over the record. A filter that passes a steady speed unchanged (one of unit gain) keeps the
89//!   area of what it filters, however it delays or spreads it, so this measure holds exactly when
90//!   the record runs from rest on the pad to rest on the ground, as the RRC3's does: it stops
91//!   after the velocity has stayed above −3 ft/s for 2 s (RRC3 manual p. 10). A record that ends
92//!   with the altitude still moving at a rate `v` leaves out about `σ²·v / 2` of the area, `σ²`
93//!   the filter's variance. The reader takes `σ` as the delay (a first-order filter's spread
94//!   equals its delay, a moving average's is 0.58 times it, a pure delay's is zero), `v` as the
95//!   faster of the altitude's rates over the last second and over the second a delay earlier, and
96//!   requires the share left out to be at most 0.1%; and the record must end at most halfway up
97//!   the climb, as near apogee this estimate fails.
98//!
99//! The unit is taken only when both measures land within 3% of the same one. ft/s and km/h, the
100//! nearest two, are 9.7% apart, so the runner-up is then at least 6.4% off. As of 2026-10-12, over
101//! the 50 RRC3 files among the project's sample logs, 48 records end low and still enough, and on
102//! each both measures came within 0.26% of the unit the file is in (ft/s or mph; 4 of them state
103//! it in brackets) and put the runner-up at least 8.8% off; the other 2 records stop before
104//! coming halfway down, so their velocity is kept as written. The 0.1% allowed is more than ten
105//! times the largest share any of those records' ends could leave out. On invented flights 20 m
106//! to 1 km up, coming down at 10 to 40 m/s, their velocity late by up to 3 s or smoothed by a
107//! first-order filter or a trailing moving average over up to 3 s, in each unit, and cut off at
108//! each tenth of the way through from 30%, none is decided in the wrong unit, and a whole record
109//! up to 1 s late is decided from 50 m up (the module's tests; `tests/rrc3_csv.rs` adds km/h and
110//! ft/s flights 1 and 2 s late, 100 to 300 m up). These filters only look back. A smoothing that
111//! also looks ahead, centered on each sample, starts the velocity above rest at the record's
112//! first row, so the integral misses the start of the climb: over a window of 3 to 5 s, a few in
113//! a thousand such invented flights read about 8% low, enough to take km/h for ft/s. None of the
114//! sample files starts that way. Otherwise the column is kept as written,
115//! [`Quantity::Unrecognized`], with a note: also
116//! when the altitude climbs under 50 m, where half a step of the pressure, 0.42 m at sea level,
117//! is 0.8% of the climb and a larger share of the area of a flight that spends its time low; and
118//! when the altitude's own unit was only assumed. A stated velocity unit that both measures
119//! place within 3% of another unit is refused.
120//!
121//! **Events.** The `Events` column marks a deployment: ` -` for none, ` Drogue`, ` Main` or ` Aux`
122//! at the sample it fired, the three events mDACS charts (mDACS manual p. 8). It holds words, not
123//! numbers, so it isn't a channel: each word is an [`Event`] at its row's time, and with the
124//! events the record keeps every column. A word other than those three is kept too, with a note;
125//! a cell that isn't a word is refused.
126//!
127//! **Short rows.** The temperature and battery voltage are written on every row (their 1 Hz
128//! snapshot repeated), but as of 2026-10-12, in 45 of the 50 RRC3 files among the project's sample
129//! logs, the last 11 to 30 rows leave those cells out and close up the rest: a 7-column file's
130//! tail rows hold 5 cells, `Time`, `Altitude`, `Pressure`, `Velocity`, `Events`, and a file with
131//! one of the two columns, one cell fewer than its header. Two more pad those rows back to the
132//! header's width with empty cells after `Events` instead; the 3 files with neither column have
133//! no short rows. The reader takes a row as wide as the header, cell for cell; a row as wide as
134//! the header without `Temperature` and `Voltages`, or as wide as the header but empty from that
135//! width on, as the other columns in order, its temperature and voltage gaps (`NaN`), counted in
136//! a note. So `-`, an event cell, can't land in the temperature. Some exports end with rows that
137//! have no time and only an altitude cell; such rows are skipped, counted in a note. Any other
138//! row of another width, a cell that isn't a number, or a time that doesn't increase, is refused,
139//! naming its line. Lines may end in CR, LF or both, and a byte-order mark is dropped.
140//!
141//! **The primary track.** The time, the altitude, which is above the pad (the RRC3 tracks its
142//! "floor" altitude on the pad, RRC3 manual p. 23), and the velocity as the vertical speed when
143//! its unit was decided: it is the barometer's, as the RRC3 has no other sensor of motion. The
144//! temperature and battery voltage share the clock, with gaps where a row leaves them out.
145//!
146//! **Where this comes from.** The columns, the units and the rules above are the manuals' and the
147//! files'. Debrief's reader decides feet or meters from the pressure too, and is followed in
148//! that; where this reader departs from it: it reads the velocity's unit from the data where
149//! Debrief ties it to the altitude's (which reads a file in mph 32% low), it reads the short rows
150//! by the rule above, and it refuses what Debrief passes over.
151
152use hpr_atmos::ussa76::Ussa76;
153
154use crate::error::LogError;
155use crate::log::{Channel, Event, FlightLog, LogFormat, Quantity, Stated};
156use crate::perfectflite::FOOT_M;
157use crate::table::{self, Notes, numbered_lines, short};
158
159/// The format's name in errors.
160const FORMAT: &str = "RRC3 CSV";
161
162/// Pascals in a hectopascal, a millibar.
163const HPA_PA: f64 = 100.0;
164
165/// The pressure the RRC3 measures, mbar: below 10 or above 1,200 is a sensor fault (RRC3 manual
166/// p. 9).
167const PRESSURE_RANGE_HPA: (f64, f64) = (10.0, 1_200.0);
168
169/// The pressure, hPa, the first row (on the pad) may hold for the pressure to be read in
170/// hectopascals: 500 hPa is the standard atmosphere's at about 5,570 m, above any launch site, and
171/// 1,100 hPa is above the highest sea-level pressure on record and the air at the lowest dry land
172/// (module docs).
173const PAD_RANGE_HPA: (f64, f64) = (500.0, 1_100.0);
174
175/// The RRC3's pressure rate, 20 Hz, as a step between rows, s (RRC3 manual p. 23).
176const PRESSURE_STEP_S: f64 = 0.05;
177
178/// How far, as a fraction, the median step between rows may be from [`PRESSURE_STEP_S`] for the
179/// time to be read in seconds from it.
180const STEP_MARGIN: f64 = 0.05;
181
182/// The least climb, m, from which the pressure tells feet from meters (module docs).
183const SETTLING_CLIMB_M: f64 = 10.0;
184
185/// How far, as a fraction, the altitude's climb may be from the pressure's in its unit.
186const ALTITUDE_MARGIN: f64 = 0.25;
187
188/// The least climb, m, over which the velocity's unit is measured against the altitude.
189const VELOCITY_CLIMB_M: f64 = 50.0;
190
191/// How far, as a fraction, each of the velocity's two measures may be from its unit's.
192const VELOCITY_MARGIN: f64 = 0.03;
193
194/// The earliest and latest delay, s, of the velocity behind the altitude that the fit tries, and
195/// its step: the RRC3's own filter runs about half a second behind (module docs).
196const DELAY_SEARCH_S: (f64, f64, f64) = (-2.0, 4.0, 0.05);
197
198/// The highest the record may end, as a fraction of the climb above the first row, for the
199/// velocity to be measured: it must have come down at least halfway (module docs).
200const END_HEIGHT: f64 = 0.5;
201
202/// The most, as a fraction, that the record ending with the altitude still moving may move the
203/// velocity's measure by the areas (module docs).
204const END_ERROR: f64 = 0.001;
205
206/// The velocity units mDACS may write: the unit's words, its bracketed spellings in lower case,
207/// and meters per second in one of it. A mile is 1,609.344 m and a foot 0.3048 m, exactly.
208const SPEEDS: [(&str, &[&str], f64); 4] = [
209    ("feet per second", &["ft/s", "fps"], FOOT_M),
210    ("miles per hour", &["mph"], 0.447_04),
211    ("meters per second", &["m/s", "mps"], 1.0),
212    ("kilometers per hour", &["km/h", "kph"], 1.0 / 3.6),
213];
214
215/// What a header column holds.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217enum Kind {
218    Time,
219    Altitude,
220    Pressure,
221    Velocity,
222    Temperature,
223    Events,
224    Voltages,
225}
226
227impl Kind {
228    fn from_name(name: &str) -> Option<Kind> {
229        [
230            ("time", Kind::Time),
231            ("altitude", Kind::Altitude),
232            ("pressure", Kind::Pressure),
233            ("velocity", Kind::Velocity),
234            ("temperature", Kind::Temperature),
235            ("events", Kind::Events),
236            ("voltages", Kind::Voltages),
237        ]
238        .into_iter()
239        .find(|(known, _)| name.eq_ignore_ascii_case(known))
240        .map(|(_, kind)| kind)
241    }
242
243    /// Whether the RRC3 logs it at 1 Hz, so that the last rows may leave it out.
244    fn is_slow(self) -> bool {
245        matches!(self, Kind::Temperature | Kind::Voltages)
246    }
247}
248
249/// A header column: what it holds, its name as written, and its bracketed unit if any.
250struct Column {
251    kind: Kind,
252    written: String,
253    unit: Option<String>,
254}
255
256/// A header cell's name and its bracketed unit: `Altitude [ft]` is `Altitude` and `ft`.
257fn split_name(cell: &str) -> (&str, Option<&str>) {
258    let cell = cell.trim();
259    match (cell.find('['), cell.strip_suffix(']')) {
260        (Some(open), Some(inner)) if open < inner.len() => {
261            (cell[..open].trim(), Some(inner[open + 1..].trim()))
262        }
263        _ => (cell, None),
264    }
265}
266
267/// The separator a header line uses: a semicolon or a tab if it has one, else a comma.
268fn separator(header: &str) -> char {
269    if header.contains(';') {
270        ';'
271    } else if header.contains('\t') {
272        '\t'
273    } else {
274        ','
275    }
276}
277
278/// The header's separator and columns, if it is an RRC3 export's (module docs, *Recognition*).
279fn header_columns(header: &str) -> Option<(char, Vec<Column>)> {
280    let separator = separator(header);
281    let mut columns: Vec<Column> = Vec::new();
282    for cell in header.split(separator) {
283        let (name, unit) = split_name(cell);
284        let kind = Kind::from_name(name)?;
285        if columns.iter().any(|column| column.kind == kind) {
286            return None;
287        }
288        let first = [Kind::Time, Kind::Altitude, Kind::Pressure, Kind::Velocity];
289        match first.get(columns.len()) {
290            Some(expected) if *expected != kind => return None,
291            _ => {}
292        }
293        columns.push(Column {
294            kind,
295            written: cell.trim().to_owned(),
296            unit: unit.map(str::to_owned),
297        });
298    }
299    (columns.len() >= 4).then_some((separator, columns))
300}
301
302/// Whether the text is in this reader's layout, judged from its first line that isn't blank,
303/// never the file's name.
304pub(crate) fn recognizes(text: &str) -> bool {
305    numbered_lines(text)
306        .map(|(_, line)| line)
307        .find(|line| !line.trim().is_empty())
308        .is_some_and(|header| header_columns(header).is_some())
309}
310
311/// A length unit.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313enum Length {
314    Feet,
315    Meters,
316}
317
318impl Length {
319    fn meters(self) -> f64 {
320        match self {
321            Length::Feet => FOOT_M,
322            Length::Meters => 1.0,
323        }
324    }
325
326    fn words(self) -> &'static str {
327        match self {
328            Length::Feet => "feet",
329            Length::Meters => "meters",
330        }
331    }
332}
333
334/// A temperature unit.
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336enum Temperature {
337    Fahrenheit,
338    Celsius,
339}
340
341impl Temperature {
342    fn kelvin(self, value: f64) -> f64 {
343        match self {
344            Temperature::Fahrenheit => (value - 32.0) * 5.0 / 9.0 + 273.15,
345            Temperature::Celsius => value + 273.15,
346        }
347    }
348}
349
350/// The units the header states in brackets.
351#[derive(Default)]
352struct HeaderUnits {
353    altitude: Option<Length>,
354    pressure_hpa: bool,
355    /// An index into [`SPEEDS`].
356    velocity: Option<usize>,
357    temperature: Option<Temperature>,
358}
359
360/// The bracketed units, each checked against what its column can be in.
361fn header_units(columns: &[Column], line: usize) -> Result<HeaderUnits, LogError> {
362    let mut units = HeaderUnits::default();
363    for column in columns {
364        let Some(unit) = column.unit.as_deref() else {
365            continue;
366        };
367        let lower = unit.to_ascii_lowercase();
368        let known = match column.kind {
369            Kind::Time => lower == "s",
370            Kind::Altitude => {
371                units.altitude = match lower.as_str() {
372                    "ft" => Some(Length::Feet),
373                    "m" => Some(Length::Meters),
374                    _ => None,
375                };
376                units.altitude.is_some()
377            }
378            Kind::Pressure => {
379                units.pressure_hpa = lower == "mbar" || lower == "hpa";
380                units.pressure_hpa
381            }
382            Kind::Velocity => {
383                units.velocity = SPEEDS
384                    .iter()
385                    .position(|(_, spellings, _)| spellings.contains(&lower.as_str()));
386                units.velocity.is_some()
387            }
388            Kind::Temperature => {
389                units.temperature = match lower.trim_start_matches('°') {
390                    "f" => Some(Temperature::Fahrenheit),
391                    "c" => Some(Temperature::Celsius),
392                    _ => None,
393                };
394                units.temperature.is_some()
395            }
396            Kind::Voltages => lower == "v",
397            Kind::Events => false,
398        };
399        if !known {
400            return Err(LogError::Unit {
401                format: FORMAT,
402                line,
403                message: format!(
404                    "the column {:?} states a unit this reader doesn't know for it",
405                    short(&column.written)
406                ),
407            });
408        }
409    }
410    Ok(units)
411}
412
413/// The rows read: each column's values as written (`NaN` where a short row leaves it out), and
414/// what the reader noticed.
415struct Rows {
416    lines: Vec<usize>,
417    time_s: Vec<f64>,
418    /// Per header column; empty for the time and events columns.
419    values: Vec<Vec<f64>>,
420    events: Vec<Event>,
421    short: usize,
422    padded: usize,
423    first_short_line: Option<usize>,
424    skipped: usize,
425    first_skipped_line: Option<usize>,
426}
427
428/// Reads an RRC3's mDACS CSV export from its text.
429///
430/// # Errors
431///
432/// [`LogError::NotThisFormat`] if the first line that isn't blank isn't an mDACS export's header;
433/// [`LogError::Unit`] for a bracketed unit the reader doesn't know, or a unit the pressure or the
434/// altitude contradicts (module docs); [`LogError::Syntax`] for a row of the wrong width, a cell
435/// that isn't a number or an event, or a time that doesn't increase, naming the line;
436/// [`LogError::NoData`] if no row has a time.
437pub fn read(text: &str) -> Result<FlightLog, LogError> {
438    let mut lines = numbered_lines(text).filter(|(_, line)| !line.trim().is_empty());
439    let Some((header_line, header)) = lines.next() else {
440        return Err(LogError::NotThisFormat {
441            format: FORMAT,
442            message: "the file is empty".to_owned(),
443        });
444    };
445    let Some((separator, columns)) = header_columns(header) else {
446        return Err(LogError::NotThisFormat {
447            format: FORMAT,
448            message: format!(
449                "its first line, {:?}, isn't an mDACS export's header: `Time`, `Altitude`, \
450                 `Pressure`, `Velocity`, then any of `Temperature`, `Events` and `Voltages`",
451                short(header)
452            ),
453        });
454    };
455    let units = header_units(&columns, header_line)?;
456    let mut notes = Notes::default();
457    let rows = read_rows(lines, separator, &columns, &mut notes)?;
458    if rows.time_s.is_empty() {
459        return Err(LogError::NoData { format: FORMAT });
460    }
461    build(&columns, &units, rows, header_line, notes)
462}
463
464/// Reads the data rows (module docs, *Short rows*).
465fn read_rows<'a>(
466    lines: impl Iterator<Item = (usize, &'a str)>,
467    separator: char,
468    columns: &[Column],
469    notes: &mut Notes,
470) -> Result<Rows, LogError> {
471    let decimal_comma = separator != ',';
472    let width = columns.len();
473    // The columns a short row holds, in order.
474    let fast: Vec<usize> = (0..width).filter(|&i| !columns[i].kind.is_slow()).collect();
475    let has_slow = fast.len() < width;
476    let mut rows = Rows {
477        lines: Vec::new(),
478        time_s: Vec::new(),
479        values: columns.iter().map(|_| Vec::new()).collect(),
480        events: Vec::new(),
481        short: 0,
482        padded: 0,
483        first_short_line: None,
484        skipped: 0,
485        first_skipped_line: None,
486    };
487    let mut placed: Vec<Option<&str>> = vec![None; width];
488    for (number, line) in lines {
489        let cells: Vec<&str> = line.split(separator).map(str::trim).collect();
490        let empty_from = |from: usize| {
491            cells
492                .get(from..)
493                .is_some_and(|rest| rest.iter().all(|c| c.is_empty()))
494        };
495        if cells.first().is_none_or(|time| time.is_empty()) {
496            // A row with no time and only an altitude, as one sample log ends with.
497            let only_altitude = cells
498                .iter()
499                .enumerate()
500                .all(|(index, cell)| index == 1 || cell.is_empty());
501            if cells.len() <= width && only_altitude {
502                rows.skipped += 1;
503                rows.first_skipped_line.get_or_insert(number);
504                continue;
505            }
506            return Err(syntax(number, "the row has no time".to_owned()));
507        }
508        placed.fill(None);
509        if cells.len() == width && !(has_slow && empty_from(fast.len())) {
510            for (slot, cell) in placed.iter_mut().zip(&cells) {
511                *slot = Some(*cell);
512            }
513        } else if has_slow
514            && (cells.len() == fast.len() || (cells.len() == width && empty_from(fast.len())))
515        {
516            for (&index, cell) in fast.iter().zip(&cells) {
517                if let Some(slot) = placed.get_mut(index) {
518                    *slot = Some(*cell);
519                }
520            }
521            rows.short += 1;
522            if cells.len() == width {
523                rows.padded += 1;
524            }
525            rows.first_short_line.get_or_insert(number);
526        } else {
527            let without = if has_slow {
528                format!(", and a row without its 1 Hz cells has {}", fast.len())
529            } else {
530                String::new()
531            };
532            return Err(syntax(
533                number,
534                format!(
535                    "the row has {} cells: the header names {width}{without}",
536                    cells.len()
537                ),
538            ));
539        }
540        read_row(&mut rows, columns, &placed, number, decimal_comma, notes)?;
541    }
542    Ok(rows)
543}
544
545/// Reads one row's cells, placed under their columns (`None` for a cell the row leaves out).
546fn read_row(
547    rows: &mut Rows,
548    columns: &[Column],
549    placed: &[Option<&str>],
550    number: usize,
551    decimal_comma: bool,
552    notes: &mut Notes,
553) -> Result<(), LogError> {
554    let mut time_s = f64::NAN;
555    let mut event = None;
556    let mut values = Vec::with_capacity(columns.len());
557    for (column, cell) in columns.iter().zip(placed) {
558        let Some(cell) = *cell else {
559            values.push(f64::NAN);
560            continue;
561        };
562        match column.kind {
563            Kind::Events => {
564                if !(cell.is_empty() || cell == "-") {
565                    if !cell.bytes().all(|b| b.is_ascii_alphabetic()) {
566                        return Err(syntax(
567                            number,
568                            format!(
569                                "{:?} under {:?} isn't an event, `-` or a word such as `Drogue`",
570                                short(cell),
571                                short(&column.written)
572                            ),
573                        ));
574                    }
575                    event = Some(cell.to_owned());
576                }
577                values.push(f64::NAN);
578            }
579            _ => {
580                let value = table::number(cell, decimal_comma).ok_or_else(|| {
581                    syntax(
582                        number,
583                        format!(
584                            "{:?} under {:?} isn't a number",
585                            short(cell),
586                            short(&column.written)
587                        ),
588                    )
589                })?;
590                if column.kind == Kind::Time {
591                    time_s = value;
592                }
593                values.push(value);
594            }
595        }
596    }
597    if let Some(&last) = rows.time_s.last()
598        && time_s <= last
599    {
600        return Err(syntax(
601            number,
602            format!("the time {time_s} s doesn't come after the one above it, {last} s"),
603        ));
604    }
605    rows.lines.push(number);
606    rows.time_s.push(time_s);
607    for ((column, store), value) in columns.iter().zip(&mut rows.values).zip(values) {
608        if !matches!(column.kind, Kind::Time | Kind::Events) {
609            store.push(value);
610        }
611    }
612    if let Some(name) = event {
613        if !["Drogue", "Main", "Aux"].contains(&name.as_str()) {
614            notes.push(format!(
615                "line {number}'s event {:?} isn't one of the three mDACS charts, `Drogue`, \
616                 `Main` and `Aux`; it was kept as written",
617                short(&name)
618            ));
619        }
620        rows.events.push(Event { time_s, name });
621    }
622    Ok(())
623}
624
625/// What a column was decided to be: its quantity, and its values in SI.
626struct Decided {
627    quantity: Quantity,
628    values: Vec<f64>,
629}
630
631/// The record from the rows read: each unit decided, every channel, the primary track and the
632/// notes.
633fn build(
634    columns: &[Column],
635    units: &HeaderUnits,
636    rows: Rows,
637    header_line: usize,
638    mut notes: Notes,
639) -> Result<FlightLog, LogError> {
640    let mut log = FlightLog {
641        format: LogFormat::Rrc3Csv,
642        logger: "Missile Works RRC3".to_owned(),
643        serial_number: None,
644        firmware: None,
645        flight_number: None,
646        stated: Stated::default(),
647        time_s: Vec::new(),
648        altitude_m: Vec::new(),
649        vertical_speed_m_s: None,
650        temperature_k: None,
651        battery_v: None,
652        channels: Vec::new(),
653        events: rows.events,
654        notes: Vec::new(),
655    };
656    let column_of = |kind: Kind| columns.iter().position(|column| column.kind == kind);
657    let values_of = |kind: Kind| column_of(kind).and_then(|index| rows.values.get(index));
658    let written_of = |kind: Kind| {
659        column_of(kind)
660            .map(|index| columns[index].written.as_str())
661            .unwrap_or_default()
662    };
663    // Invariant: the header has `Altitude`, `Pressure` and `Velocity` (header_columns).
664    let (Some(altitude), Some(pressure), Some(velocity)) = (
665        values_of(Kind::Altitude),
666        values_of(Kind::Pressure),
667        values_of(Kind::Velocity),
668    ) else {
669        return Err(syntax(header_line, "the header lacks a column".to_owned()));
670    };
671
672    let pressure = decide_pressure(pressure, units, written_of(Kind::Pressure), &mut log.notes);
673    let (length, length_from_evidence) = decide_altitude(
674        altitude,
675        &pressure,
676        units,
677        &rows.lines,
678        written_of(Kind::Altitude),
679        header_line,
680        &mut log.notes,
681    )?;
682    let altitude_m: Vec<f64> = altitude
683        .iter()
684        .map(|value| value * length.meters())
685        .collect();
686    let track = Track {
687        time_s: &rows.time_s,
688        altitude_m: &altitude_m,
689        known: length_from_evidence,
690    };
691    let velocity = decide_velocity(
692        velocity,
693        &track,
694        units,
695        written_of(Kind::Velocity),
696        header_line,
697        &mut log.notes,
698    )?;
699
700    for (column, values) in columns.iter().zip(&rows.values) {
701        let decided = match column.kind {
702            Kind::Time => {
703                if column.unit.is_none() {
704                    log.notes.push(time_note(&column.written, &rows.time_s));
705                }
706                continue;
707            }
708            Kind::Events => continue,
709            Kind::Altitude => Decided {
710                quantity: Quantity::AltitudeAboveGround,
711                values: altitude_m.clone(),
712            },
713            Kind::Pressure => Decided {
714                quantity: pressure.quantity,
715                values: pressure.values.clone(),
716            },
717            Kind::Velocity => Decided {
718                quantity: velocity.quantity,
719                values: velocity.values.clone(),
720            },
721            Kind::Temperature => {
722                let decided =
723                    decide_temperature(values, units, length, &column.written, &mut log.notes);
724                log.temperature_k = Some(decided.values.clone());
725                decided
726            }
727            Kind::Voltages => {
728                if column.unit.is_none() {
729                    log.notes.push(format!(
730                        "`{}` read in volts, assumed: the RRC3 records its battery's voltage \
731                         (mDACS manual p. 2) and takes a battery of at most 10 V (RRC3 manual \
732                         p. 6); its values, as written, run {}",
733                        short(&column.written),
734                        span(values)
735                    ));
736                }
737                log.battery_v = Some(values.clone());
738                Decided {
739                    quantity: Quantity::Voltage,
740                    values: values.clone(),
741                }
742            }
743        };
744        log.channels.push(Channel {
745            column: column.written.clone(),
746            quantity: decided.quantity,
747            time_s: rows.time_s.clone(),
748            values: decided.values,
749        });
750    }
751
752    if let Some(&first) = altitude_m.first()
753        && first.abs() > SETTLING_CLIMB_M
754    {
755        notes.push(format!(
756            "the altitude starts at {first:.1} m, not at the pad's zero the RRC3 measures from"
757        ));
758    }
759    if rows.short > 0 {
760        log.notes.push(format!(
761            "{} rows, the first on line {}, leave out the 1 Hz {}: they are gaps{}",
762            rows.short,
763            rows.first_short_line.unwrap_or_default(),
764            slow_names(columns),
765            if rows.padded > 0 {
766                format!(
767                    "; {} of them pad the row to the header's width with empty cells after the \
768                     others",
769                    rows.padded
770                )
771            } else {
772                String::new()
773            }
774        ));
775    }
776    if rows.skipped > 0 {
777        log.notes.push(format!(
778            "{} rows with no time and nothing but an altitude cell, the first on line {}, were \
779             skipped",
780            rows.skipped,
781            rows.first_skipped_line.unwrap_or_default()
782        ));
783    }
784    log.time_s = rows.time_s;
785    log.altitude_m = altitude_m;
786    if velocity.quantity == Quantity::Speed {
787        log.vertical_speed_m_s = Some(velocity.values);
788    }
789    log.notes.extend(notes.into_vec());
790    Ok(log)
791}
792
793/// The note on the time read in seconds when the header names no unit: from the step between
794/// rows if it is the RRC3's 20 Hz, else assumed (module docs).
795fn time_note(written: &str, time_s: &[f64]) -> String {
796    let mut steps: Vec<f64> = time_s.windows(2).map(|pair| pair[1] - pair[0]).collect();
797    steps.sort_by(f64::total_cmp);
798    let chart = "mDACS's chart is timed in seconds (mDACS manual p. 8)";
799    match steps.get(steps.len() / 2) {
800        Some(&median) if (median / PRESSURE_STEP_S - 1.0).abs() <= STEP_MARGIN => format!(
801            "`{}` read in seconds, from the step: the median step between rows, {median} as \
802             written, is within {:.0}% of the {PRESSURE_STEP_S} s of the 20 Hz the RRC3 records \
803             its pressure at (RRC3 manual p. 23)",
804            short(written),
805            STEP_MARGIN * 100.0
806        ),
807        Some(&median) => format!(
808            "`{}` read in seconds, assumed: the median step between rows, {median} as written, \
809             isn't within {:.0}% of the {PRESSURE_STEP_S} s of the 20 Hz the RRC3 records its \
810             pressure at (RRC3 manual p. 23); {chart}",
811            short(written),
812            STEP_MARGIN * 100.0
813        ),
814        None => format!(
815            "`{}` read in seconds, assumed: a single row has no step to measure; {chart}",
816            short(written)
817        ),
818    }
819}
820
821/// The 1 Hz columns the header names, as a note lists them.
822fn slow_names(columns: &[Column]) -> String {
823    let names: Vec<String> = columns
824        .iter()
825        .filter(|column| column.kind.is_slow())
826        .map(|column| format!("`{}`", short(&column.written)))
827        .collect();
828    match names.as_slice() {
829        [one] => format!("{one} cell"),
830        _ => format!("{} cells", names.join(" and ")),
831    }
832}
833
834/// The least and greatest of a column's values that are numbers, as a note writes them.
835fn span(values: &[f64]) -> String {
836    let (low, high) = values
837        .iter()
838        .filter(|value| value.is_finite())
839        .fold((f64::INFINITY, f64::NEG_INFINITY), |(low, high), &value| {
840            (low.min(value), high.max(value))
841        });
842    if low.is_finite() {
843        format!("from {low} to {high}")
844    } else {
845        "to no number".to_owned()
846    }
847}
848
849/// The pressure's unit: hectopascals if stated, or if the first row's value is a launch site's
850/// and the highest is within what the RRC3 measures; otherwise kept as written (module docs).
851fn decide_pressure(
852    values: &[f64],
853    units: &HeaderUnits,
854    written: &str,
855    notes: &mut Vec<String>,
856) -> Decided {
857    let in_hpa = Decided {
858        quantity: Quantity::Pressure,
859        values: values.iter().map(|value| value * HPA_PA).collect(),
860    };
861    if units.pressure_hpa {
862        return in_hpa;
863    }
864    let highest = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
865    let pad = values.first().copied().unwrap_or(f64::NAN);
866    let (low, high) = PRESSURE_RANGE_HPA;
867    let (pad_low, pad_high) = PAD_RANGE_HPA;
868    let measured = (low..=high).contains(&highest);
869    let on_a_pad = (pad_low..=pad_high).contains(&pad);
870    let ranges = format!(
871        "the first row's, {pad}, {} the {pad_low} to {pad_high} hPa of any launch site, and the \
872         highest, {highest}, {} the {low} to {high} mbar the RRC3 measures (RRC3 manual p. 9)",
873        if on_a_pad {
874            "lies within"
875        } else {
876            "lies outside"
877        },
878        if measured { "within" } else { "outside" }
879    );
880    if measured && on_a_pad {
881        notes.push(format!(
882            "`{}` read in hectopascals, from the values: {ranges}",
883            short(written)
884        ));
885        in_hpa
886    } else {
887        notes.push(format!(
888            "`{}` kept as written, its unit unknown: {ranges}",
889            short(written)
890        ));
891        Decided {
892            quantity: Quantity::Unrecognized,
893            values: values.to_vec(),
894        }
895    }
896}
897
898/// The climb the pressure implies between the first row and the row of lowest pressure: that
899/// row's index and the climb, m, in the standard atmosphere; `None` if the pressure's unit isn't
900/// known or a pressure isn't positive.
901fn pressure_climb(pressure: &Decided) -> Option<(usize, f64)> {
902    if pressure.quantity != Quantity::Pressure {
903        return None;
904    }
905    let atmosphere = Ussa76::standard();
906    let first = *pressure.values.first()?;
907    let (lowest, lowest_pa) = pressure.values.iter().copied().enumerate().fold(
908        (0, first),
909        |(at, low), (index, value)| {
910            if value < low {
911                (index, value)
912            } else {
913                (at, low)
914            }
915        },
916    );
917    let climb_m = atmosphere.pressure_altitude_m(lowest_pa).ok()?
918        - atmosphere.pressure_altitude_m(first).ok()?;
919    Some((lowest, climb_m))
920}
921
922/// The altitude's unit, and whether it was stated or decided from the pressure rather than
923/// assumed (module docs).
924fn decide_altitude(
925    altitude: &[f64],
926    pressure: &Decided,
927    units: &HeaderUnits,
928    lines: &[usize],
929    written: &str,
930    header_line: usize,
931    notes: &mut Vec<String>,
932) -> Result<(Length, bool), LogError> {
933    let climb = pressure_climb(pressure);
934    let Some((lowest, climb_m)) = climb.filter(|(_, climb_m)| *climb_m >= SETTLING_CLIMB_M) else {
935        if let Some(stated) = units.altitude {
936            return Ok((stated, true));
937        }
938        let why = match climb {
939            _ if pressure.quantity != Quantity::Pressure => {
940                "the pressure's unit is unknown, so it can't tell feet from meters".to_owned()
941            }
942            Some((_, climb_m)) => format!(
943                "the pressure climbs {climb_m:.1} m, under the {SETTLING_CLIMB_M} m from which \
944                 it tells feet from meters"
945            ),
946            None => "a pressure isn't positive, so it gives no climb".to_owned(),
947        };
948        notes.push(format!(
949            "`{}` read in feet, assumed: {why}; feet are the RRC3's default unit (RRC3 manual p. \
950             12)",
951            short(written)
952        ));
953        return Ok((Length::Feet, false));
954    };
955    let (Some(&first), Some(&at_lowest)) = (altitude.first(), altitude.get(lowest)) else {
956        return Ok((Length::Feet, false));
957    };
958    let written_climb = at_lowest - first;
959    let miss = |length: Length| written_climb * length.meters() / climb_m - 1.0;
960    let (feet, meters) = (miss(Length::Feet), miss(Length::Meters));
961    let evidence = format!(
962        "from line {} to line {} the pressure falls from {:.2} hPa to {:.2} hPa, a climb of \
963         {:.1} m in the U.S. Standard Atmosphere (1976), and the altitude climbs {:.2} as \
964         written, {:+.2}% off it read in feet and {:+.1}% read in meters",
965        lines.first().copied().unwrap_or_default(),
966        lines.get(lowest).copied().unwrap_or_default(),
967        pressure.values.first().copied().unwrap_or_default() / HPA_PA,
968        pressure.values.get(lowest).copied().unwrap_or_default() / HPA_PA,
969        climb_m,
970        written_climb,
971        feet * 100.0,
972        meters * 100.0
973    );
974    let fits = |miss: f64| miss.abs() <= ALTITUDE_MARGIN;
975    if let Some(stated) = units.altitude {
976        let stated_miss = miss(stated);
977        if !fits(stated_miss) {
978            return Err(LogError::Unit {
979                format: FORMAT,
980                line: header_line,
981                message: format!(
982                    "{:?} states {}, which the pressure contradicts: {evidence}",
983                    short(written),
984                    stated.words()
985                ),
986            });
987        }
988        return Ok((stated, true));
989    }
990    let length = if fits(feet) {
991        Length::Feet
992    } else if fits(meters) {
993        Length::Meters
994    } else {
995        return Err(LogError::Unit {
996            format: FORMAT,
997            line: header_line,
998            message: format!(
999                "{:?} is in neither feet nor meters by the pressure, within {:.0}%: {evidence}",
1000                short(written),
1001                ALTITUDE_MARGIN * 100.0
1002            ),
1003        });
1004    };
1005    notes.push(format!(
1006        "`{}` read in {}, from the pressure: {evidence}",
1007        short(written),
1008        length.words()
1009    ));
1010    Ok((length, true))
1011}
1012
1013/// The velocity's unit per m/s of the altitude's rate, measured two ways (module docs).
1014#[derive(Debug, Clone, Copy)]
1015struct Scale {
1016    /// The area under the velocity's running integral over the area under the altitude above
1017    /// the first row, both over the whole record.
1018    by_area: f64,
1019    /// The least-squares slope, through zero, of the running integral against the altitude above
1020    /// the first row taken `delay_s` earlier, the delay that fits best.
1021    by_fit: f64,
1022    /// How far, s, the velocity runs behind the altitude, as the fit finds it.
1023    delay_s: f64,
1024    /// The last row's altitude above the first row's, as a fraction of the climb.
1025    end_height: f64,
1026    /// About the most a filter as spread as the delay can move `by_area`, as a fraction, given
1027    /// the altitude's rate at the record's end (module docs).
1028    end_error: f64,
1029}
1030
1031/// The velocity's running integral by trapezoids, zero at the first row.
1032fn running_integral(time_s: &[f64], velocity: &[f64]) -> Vec<f64> {
1033    let mut integral = 0.0;
1034    let mut out = Vec::with_capacity(time_s.len().min(velocity.len()));
1035    let mut previous: Option<(f64, f64)> = None;
1036    for (&time, &value) in time_s.iter().zip(velocity) {
1037        if let Some((before_s, before)) = previous {
1038            integral += 0.5 * (value + before) * (time - before_s);
1039        }
1040        out.push(integral);
1041        previous = Some((time, value));
1042    }
1043    out
1044}
1045
1046/// The altitude above the first row at a time, linear between rows, and the first or the last
1047/// row's before or after the record. `cursor` is the row at or before the time last asked for,
1048/// so that asking in increasing order walks the rows once.
1049fn altitude_at(time_s: &[f64], above_m: &[f64], time: f64, cursor: &mut usize) -> f64 {
1050    while time_s.get(*cursor + 1).is_some_and(|next| *next <= time) {
1051        *cursor += 1;
1052    }
1053    match (
1054        time_s.get(*cursor),
1055        time_s.get(*cursor + 1),
1056        above_m.get(*cursor),
1057        above_m.get(*cursor + 1),
1058    ) {
1059        (Some(&from_s), _, Some(&from), _) if time <= from_s => from,
1060        (Some(&from_s), Some(&to_s), Some(&from), Some(&to)) => {
1061            from + (to - from) * (time - from_s) / (to_s - from_s)
1062        }
1063        _ => above_m.last().copied().unwrap_or_default(),
1064    }
1065}
1066
1067/// The velocity's unit per m/s, by the areas and by the fit with a delay (module docs); `None`
1068/// if the altitude never leaves the first row's or a measure isn't a number.
1069fn velocity_scale(time_s: &[f64], altitude_m: &[f64], velocity: &[f64]) -> Option<Scale> {
1070    let first = *altitude_m.first()?;
1071    let above: Vec<f64> = altitude_m.iter().map(|value| value - first).collect();
1072    let integral = running_integral(time_s, velocity);
1073    let rows = integral.len().min(above.len());
1074    let (time_s, above, integral) = (&time_s[..rows], &above[..rows], &integral[..rows]);
1075
1076    let (earliest, latest, step) = DELAY_SEARCH_S;
1077    let steps = ((latest - earliest) / step).round() as i32;
1078    let mut best: Option<(f64, f64, f64)> = None;
1079    for at in 0..=steps {
1080        let delay_s = earliest + f64::from(at) * step;
1081        let (mut product, mut square) = (0.0, 0.0);
1082        let mut cursor = 0;
1083        for (&time, &value) in time_s.iter().zip(integral) {
1084            let earlier = altitude_at(time_s, above, time - delay_s, &mut cursor);
1085            product += value * earlier;
1086            square += earlier * earlier;
1087        }
1088        // The residual is Σ I² − (Σ I h)² / Σ h²: the best delay explains the most of it.
1089        let explained = product * product / square;
1090        if square > 0.0 && best.is_none_or(|(most, _, _)| explained > most) {
1091            best = Some((explained, product / square, delay_s));
1092        }
1093    }
1094    let (_, by_fit, delay_s) = best?;
1095    // The areas, both over the record, under the integral and under the altitude as it was
1096    // `delay_s` earlier, by trapezoids.
1097    let mut cursor = 0;
1098    let earlier: Vec<f64> = time_s
1099        .iter()
1100        .map(|time| altitude_at(time_s, above, time - delay_s, &mut cursor))
1101        .collect();
1102    let (mut integral_area, mut altitude_area) = (0.0, 0.0);
1103    for index in 1..rows {
1104        let step_s = time_s[index] - time_s[index - 1];
1105        integral_area += 0.5 * (integral[index] + integral[index - 1]) * step_s;
1106        altitude_area += 0.5 * (earlier[index] + earlier[index - 1]) * step_s;
1107    }
1108    let by_area = integral_area / altitude_area;
1109    let climb_m = above.iter().copied().fold(0.0, f64::max);
1110    let (&end_s, &end_m) = (time_s.last()?, above.last()?);
1111    // The altitude's rate over the record's last second, and over the second the velocity's
1112    // last row reflects, `delay_s` earlier: the faster of the two.
1113    let span_s = (end_s - time_s.first()?).min(1.0);
1114    let rate_to = |to_s: f64| {
1115        (altitude_at(time_s, above, to_s, &mut 0)
1116            - altitude_at(time_s, above, to_s - span_s, &mut 0))
1117            / span_s
1118    };
1119    let end_rate = rate_to(end_s).abs().max(rate_to(end_s - delay_s).abs());
1120    let end_error = delay_s * delay_s * end_rate / (2.0 * altitude_area);
1121    (altitude_area > 0.0 && by_area.is_finite() && by_fit.is_finite()).then_some(Scale {
1122        by_area,
1123        by_fit,
1124        delay_s,
1125        end_height: end_m / climb_m,
1126        end_error: if span_s > 0.0 {
1127            end_error
1128        } else {
1129            f64::INFINITY
1130        },
1131    })
1132}
1133
1134/// Each speed unit's miss from a slope, as a fraction, nearest first.
1135fn speed_misses(slope: f64) -> Vec<(usize, f64)> {
1136    let mut misses: Vec<(usize, f64)> = SPEEDS
1137        .iter()
1138        .enumerate()
1139        .map(|(index, (_, _, m_s))| (index, slope * m_s - 1.0))
1140        .collect();
1141    misses.sort_by(|a, b| (a.1 + 1.0).ln().abs().total_cmp(&(b.1 + 1.0).ln().abs()));
1142    misses
1143}
1144
1145/// The altitude a velocity is measured against: its clock, its values in meters, and whether its
1146/// unit was stated or decided rather than assumed.
1147struct Track<'a> {
1148    time_s: &'a [f64],
1149    altitude_m: &'a [f64],
1150    known: bool,
1151}
1152
1153/// The speed unit both measures land nearest, each within [`VELOCITY_MARGIN`], as an index into
1154/// [`SPEEDS`]; `None` if they don't, or if the record ends too high or still moving for the
1155/// measure by the areas to hold (module docs).
1156fn agreed_unit(scale: &Scale) -> Option<usize> {
1157    if !(scale.end_height <= END_HEIGHT && scale.end_error <= END_ERROR) {
1158        return None;
1159    }
1160    let (area_unit, area_miss) = *speed_misses(scale.by_area).first()?;
1161    let (fit_unit, fit_miss) = *speed_misses(scale.by_fit).first()?;
1162    (area_unit == fit_unit
1163        && area_miss.abs() <= VELOCITY_MARGIN
1164        && fit_miss.abs() <= VELOCITY_MARGIN)
1165        .then_some(area_unit)
1166}
1167
1168/// A speed unit's words and its miss from each measure, as a note writes them.
1169fn unit_misses(index: usize, scale: &Scale) -> (&'static str, f64, f64) {
1170    let (words, _, m_s) = SPEEDS.get(index).copied().unwrap_or(SPEEDS[0]);
1171    (words, scale.by_area * m_s - 1.0, scale.by_fit * m_s - 1.0)
1172}
1173
1174/// The velocity's unit, from the header or measured against the altitude (module docs).
1175fn decide_velocity(
1176    values: &[f64],
1177    track: &Track,
1178    units: &HeaderUnits,
1179    written: &str,
1180    header_line: usize,
1181    notes: &mut Vec<String>,
1182) -> Result<Decided, LogError> {
1183    let Track {
1184        time_s,
1185        altitude_m,
1186        known: altitude_known,
1187    } = *track;
1188    let first = altitude_m.first().copied().unwrap_or_default();
1189    let climb_m = altitude_m.iter().copied().fold(first, f64::max) - first;
1190    let scale = (altitude_known && climb_m >= VELOCITY_CLIMB_M)
1191        .then(|| velocity_scale(time_s, altitude_m, values))
1192        .flatten();
1193    let in_si = |index: usize| {
1194        let m_s = SPEEDS.get(index).map_or(1.0, |(_, _, m_s)| *m_s);
1195        Decided {
1196            quantity: Quantity::Speed,
1197            values: values.iter().map(|value| value * m_s).collect(),
1198        }
1199    };
1200    // The unit both measures land nearest, each within the margin, if there is one.
1201    let nearest = scale.map(|scale| {
1202        let by_area = speed_misses(scale.by_area);
1203        let by_fit = speed_misses(scale.by_fit);
1204        (scale, by_area, by_fit)
1205    });
1206    let decided = nearest.as_ref().and_then(|(scale, by_area, _)| {
1207        let index = agreed_unit(scale)?;
1208        Some((*scale, index, by_area.get(1).map(|(other, _)| *other)))
1209    });
1210    let measures = |scale: &Scale| {
1211        format!(
1212            "against the altitude above the first row, in meters, as it was {:.2} s earlier, \
1213             the area under its running integral is {:.4} times the altitude's, and the \
1214             integral's least-squares slope is {:.4}",
1215            scale.delay_s, scale.by_area, scale.by_fit
1216        )
1217    };
1218    if let Some(stated) = units.velocity {
1219        if let Some((scale, index, _)) = decided
1220            && index != stated
1221        {
1222            return Err(LogError::Unit {
1223                format: FORMAT,
1224                line: header_line,
1225                message: format!(
1226                    "{:?} states {}, but {}, which is {}",
1227                    short(written),
1228                    SPEEDS.get(stated).map_or("", |unit| unit.0),
1229                    measures(&scale),
1230                    SPEEDS.get(index).map_or("", |unit| unit.0)
1231                ),
1232            });
1233        }
1234        return Ok(in_si(stated));
1235    }
1236    match decided {
1237        Some((scale, index, runner_up)) => {
1238            let runner_up = runner_up
1239                .map(|other| {
1240                    let (words, by_area, by_fit) = unit_misses(other, &scale);
1241                    format!(
1242                        "; the next nearest, {words}, would miss them by {:+.1}% and {:+.1}%",
1243                        by_area * 100.0,
1244                        by_fit * 100.0
1245                    )
1246                })
1247                .unwrap_or_default();
1248            let (words, by_area, by_fit) = unit_misses(index, &scale);
1249            notes.push(format!(
1250                "`{}` read in {words}, from the altitude: over {} rows, {}; {words} would give \
1251                 {:.4} per m/s, {:+.2}% off the first and {:+.2}% off the second{runner_up}",
1252                short(written),
1253                time_s.len(),
1254                measures(&scale),
1255                1.0 / SPEEDS.get(index).map_or(1.0, |unit| unit.2),
1256                by_area * 100.0,
1257                by_fit * 100.0
1258            ));
1259            Ok(in_si(index))
1260        }
1261        None => {
1262            let why = if !altitude_known {
1263                "the altitude's unit is only assumed, so the velocity can't be measured against \
1264                 it"
1265                .to_owned()
1266            } else if climb_m < VELOCITY_CLIMB_M {
1267                format!(
1268                    "the altitude climbs {climb_m:.1} m, under the {VELOCITY_CLIMB_M} m over which \
1269                     the velocity is measured against it"
1270                )
1271            } else {
1272                match &nearest {
1273                    Some((scale, _, _))
1274                        if scale.end_height > END_HEIGHT || scale.end_height.is_nan() =>
1275                    {
1276                        format!(
1277                            "the record ends {:.0}% of the way up the climb, above the {:.0}% it must \
1278                         come down to for the velocity to be measured against the altitude",
1279                            scale.end_height * 100.0,
1280                            END_HEIGHT * 100.0
1281                        )
1282                    }
1283                    Some((scale, _, _))
1284                        if scale.end_error > END_ERROR || scale.end_error.is_nan() =>
1285                    {
1286                        format!(
1287                            "the record ends with the altitude still moving, which could move \
1288                             the area under the velocity's running integral by about {:.2}%, \
1289                             over the {:.1}% allowed",
1290                            scale.end_error * 100.0,
1291                            END_ERROR * 100.0
1292                        )
1293                    }
1294                    Some((scale, by_area, by_fit)) => {
1295                        let nearest_of = |misses: &[(usize, f64)]| {
1296                            misses.first().map_or(String::new(), |(index, miss)| {
1297                                format!(
1298                                    "{} {:+.1}%",
1299                                    SPEEDS.get(*index).map_or("", |unit| unit.0),
1300                                    miss * 100.0
1301                                )
1302                            })
1303                        };
1304                        format!(
1305                            "{}; the two don't both land within {:.0}% of one of ft/s, mph, \
1306                             m/s and km/h (the nearest {} and {})",
1307                            measures(scale),
1308                            VELOCITY_MARGIN * 100.0,
1309                            nearest_of(by_area),
1310                            nearest_of(by_fit)
1311                        )
1312                    }
1313                    None => {
1314                        "its running integral can't be measured against the altitude".to_owned()
1315                    }
1316                }
1317            };
1318            notes.push(format!(
1319                "`{}` kept as written, its unit unknown: {why}",
1320                short(written)
1321            ));
1322            Ok(Decided {
1323                quantity: Quantity::Unrecognized,
1324                values: values.to_vec(),
1325            })
1326        }
1327    }
1328}
1329
1330/// The temperature's unit, from the header or assumed from the altitude's (module docs).
1331fn decide_temperature(
1332    values: &[f64],
1333    units: &HeaderUnits,
1334    length: Length,
1335    written: &str,
1336    notes: &mut Vec<String>,
1337) -> Decided {
1338    let unit = units.temperature.unwrap_or_else(|| {
1339        let (unit, words) = match length {
1340            Length::Feet => (Temperature::Fahrenheit, "degrees Fahrenheit"),
1341            Length::Meters => (Temperature::Celsius, "degrees Celsius"),
1342        };
1343        notes.push(format!(
1344            "`{}` read in {words}, assumed: the altitude is in {}, and the RRC3's unit settings \
1345             pair feet with °F and meters with °C (RRC3 manual p. 12), though mDACS sets each by \
1346             its own preference (mDACS manual p. 5) and the file states neither; its values, as \
1347             written, run {}",
1348            short(written),
1349            length.words(),
1350            span(values)
1351        ));
1352        unit
1353    });
1354    Decided {
1355        quantity: Quantity::Temperature,
1356        values: values.iter().map(|value| unit.kelvin(*value)).collect(),
1357    }
1358}
1359
1360fn syntax(line: usize, message: String) -> LogError {
1361    LogError::Syntax {
1362        format: FORMAT,
1363        line,
1364        message,
1365    }
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370    use super::*;
1371
1372    const HEADER: &str = "Time,Altitude,Pressure,Velocity,Temperature,Events,Voltages";
1373
1374    /// The pressure, hPa, at a height above the pad, m, in the standard atmosphere's troposphere
1375    /// (the altimeter formula of [`Ussa76::pressure_altitude_m`], inverted), the pad at sea level.
1376    fn pressure_hpa(height_m: f64) -> f64 {
1377        1_013.25 * (1.0 - height_m / 44_330.8).powf(1.0 / 0.190_263)
1378    }
1379
1380    /// An invented flight, one row every half second: a climb at `climb_m_s` to `top_m`, then a
1381    /// descent at a third of it, the altitude written in `length`, the velocity's m/s multiplied by
1382    /// `per_m_s`, the temperature 70 and the battery 9.2; `tail` short rows at the end.
1383    fn flight(top_m: f64, climb_m_s: f64, length: f64, per_m_s: f64, tail: usize) -> String {
1384        let mut text = format!("{HEADER}\n");
1385        let mut heights = vec![0.0_f64];
1386        let mut climbing = true;
1387        while climbing || heights.last().is_some_and(|height| *height > 0.0) {
1388            let height = heights.last().copied().unwrap_or_default();
1389            climbing &= height < top_m;
1390            let speed = if climbing {
1391                climb_m_s
1392            } else {
1393                -climb_m_s / 3.0
1394            };
1395            heights.push((height + speed * 0.5).clamp(0.0, top_m));
1396        }
1397        heights.push(0.0);
1398        let count = heights.len();
1399        // The speed at each row: the altitude's rate across its neighbors, or to its one
1400        // neighbor at either end.
1401        let speed = |index: usize| {
1402            let before = index.saturating_sub(1);
1403            let after = (index + 1).min(count - 1);
1404            (heights[after] - heights[before]) / ((after - before) as f64 * 0.5)
1405        };
1406        for (index, &height) in heights.iter().enumerate() {
1407            let (time, speed) = (index as f64 * 0.5, speed(index));
1408            let event = if index == 3 { "Drogue" } else { "-" };
1409            let common = format!(
1410                "{time:.2},{:.3},{:.2},{:.3}",
1411                height / length,
1412                pressure_hpa(height),
1413                speed * per_m_s
1414            );
1415            if index + tail >= count {
1416                text.push_str(&format!("{common}, {event}\n"));
1417            } else {
1418                text.push_str(&format!("{common},70.00, {event},9.20\n"));
1419            }
1420        }
1421        text
1422    }
1423
1424    fn close(a: f64, b: f64) -> bool {
1425        (a - b).abs() <= 1e-9 * b.abs().max(1.0)
1426    }
1427
1428    fn notes_starting<'a>(log: &'a FlightLog, start: &str) -> Vec<&'a String> {
1429        log.notes.iter().filter(|n| n.starts_with(start)).collect()
1430    }
1431
1432    #[test]
1433    fn recognizes_only_an_mdacs_header() {
1434        let yes = [
1435            HEADER,
1436            "\u{feff}\r\n\r\nTime,Altitude,Pressure,Velocity,Events",
1437            "Time,Altitude,Pressure,Velocity,Temperature",
1438            "Time,Altitude,Pressure,Velocity,Voltages",
1439            "time,ALTITUDE,pressure,Velocity",
1440            "Time [s],Altitude [ft],Pressure [mbar],Velocity [ft/s],Temperature [F],Events,Voltages [V]",
1441            "Time;Altitude;Pressure;Velocity;Temperature;Events;Voltages",
1442            "Time\tAltitude\tPressure\tVelocity\tEvents",
1443            // An unknown unit is recognized, then refused by `read` as a unit.
1444            "Time,Altitude [yd],Pressure,Velocity",
1445        ];
1446        for header in yes {
1447            assert!(recognizes(header), "{header:?}");
1448        }
1449        let no = [
1450            "",
1451            // PerfectFlite's exports: no pressure.
1452            "Time,Altitude,Velocity,Temperature (F),Voltage",
1453            "Time, Altitude, Velocity",
1454            "Time,Altitude,Pressure",
1455            "Time,Pressure,Altitude,Velocity",
1456            "Time,Altitude,Pressure,Velocity,Temperature,Temperature",
1457            "Time,Altitude,Pressure,Velocity,Accel",
1458            "Time,Altitude,Pressure,Velocity,",
1459            "# Time,Altitude,Pressure,Velocity",
1460            "Time,Altitude [ft,Pressure,Velocity",
1461        ];
1462        for header in no {
1463            assert!(!recognizes(header), "{header:?}");
1464        }
1465        // Only the first line that isn't blank is looked at.
1466        assert!(!recognizes("x\nTime,Altitude,Pressure,Velocity\n"));
1467    }
1468
1469    /// The common file: feet and ft/s decided from the file, °F and volts assumed, events at
1470    /// their rows, the short tail's 1 Hz cells gaps.
1471    #[test]
1472    fn decides_feet_and_feet_per_second_from_the_file() {
1473        let text = flight(300.0, 60.0, FOOT_M, 1.0 / FOOT_M, 2);
1474        let log = read(&text).unwrap();
1475        assert_eq!(log.format, LogFormat::Rrc3Csv);
1476        let quantities: Vec<Quantity> = log.channels.iter().map(|c| c.quantity).collect();
1477        assert_eq!(
1478            quantities,
1479            [
1480                Quantity::AltitudeAboveGround,
1481                Quantity::Pressure,
1482                Quantity::Speed,
1483                Quantity::Temperature,
1484                Quantity::Voltage
1485            ]
1486        );
1487        // Written to three decimals: 98.425 ft and 196.850 ft/s.
1488        assert!(close(log.altitude_m[1], 98.425 * FOOT_M));
1489        assert!(close(
1490            log.vertical_speed_m_s.as_deref().unwrap()[1],
1491            196.85 * FOOT_M
1492        ));
1493        let temperature = log.temperature_k.as_deref().unwrap();
1494        // 70 °F is 294.261 K (21.111 °C).
1495        assert!(
1496            (temperature[0] - 294.261).abs() < 1e-3,
1497            "{}",
1498            temperature[0]
1499        );
1500        let n = log.time_s.len();
1501        assert!(temperature[n - 2..].iter().all(|t| t.is_nan()));
1502        assert!(temperature[n - 3].is_finite());
1503        assert_eq!(log.events.len(), 1);
1504        assert_eq!(
1505            (log.events[0].time_s, log.events[0].name.as_str()),
1506            (1.5, "Drogue")
1507        );
1508        for start in [
1509            "`Altitude` read in feet, from the pressure: ",
1510            "`Velocity` read in feet per second, from the altitude: ",
1511            "`Pressure` read in hectopascals, from the values: ",
1512            "`Temperature` read in degrees Fahrenheit, assumed: ",
1513            "`Voltages` read in volts, assumed: ",
1514            "`Time` read in seconds, assumed: ",
1515            "2 rows, the first on line ",
1516        ] {
1517            assert_eq!(
1518                notes_starting(&log, start).len(),
1519                1,
1520                "{start}: {:?}",
1521                log.notes
1522            );
1523        }
1524    }
1525
1526    /// Each velocity unit is told from the others, km/h from ft/s too, 9.7% apart.
1527    #[test]
1528    fn each_velocity_unit_is_told_apart() {
1529        for (words, _, m_s) in SPEEDS {
1530            let text = flight(300.0, 60.0, FOOT_M, 1.0 / m_s, 0);
1531            let log = read(&text).unwrap();
1532            let start = format!("`Velocity` read in {words}, from the altitude: ");
1533            assert_eq!(
1534                notes_starting(&log, &start).len(),
1535                1,
1536                "{words}: {:?}",
1537                log.notes
1538            );
1539            // Written to three decimals, within half a thousandth of the unit.
1540            assert!(
1541                (log.vertical_speed_m_s.as_deref().unwrap()[1] - 60.0).abs() < 5e-4 * m_s.max(1.0)
1542            );
1543        }
1544    }
1545
1546    /// Meters decided from the pressure; the temperature then assumed in °C.
1547    #[test]
1548    fn decides_meters_from_the_pressure() {
1549        let text = flight(300.0, 60.0, 1.0, 1.0, 0);
1550        let log = read(&text).unwrap();
1551        assert!(close(log.altitude_m[1], 30.0));
1552        assert_eq!(
1553            notes_starting(&log, "`Altitude` read in meters, from the pressure: ").len(),
1554            1
1555        );
1556        assert!(close(log.temperature_k.as_deref().unwrap()[0], 343.15));
1557        assert_eq!(
1558            notes_starting(&log, "`Temperature` read in degrees Celsius, assumed: ").len(),
1559            1
1560        );
1561    }
1562
1563    /// A velocity in none of the four units, or measured over too short a climb, or against an
1564    /// altitude whose unit is only assumed, is kept as written.
1565    #[test]
1566    fn a_velocity_that_cant_be_told_is_kept_as_written() {
1567        // Cut off before the descent: the last row stands above half the climb.
1568        let full = flight(300.0, 60.0, FOOT_M, 1.0 / FOOT_M, 0);
1569        let cut: String = full
1570            .lines()
1571            .take(14)
1572            .map(|line| format!("{line}\n"))
1573            .collect();
1574        let cases = [
1575            (
1576                flight(300.0, 60.0, FOOT_M, 3.0, 0),
1577                "don't both land within 3% of one",
1578            ),
1579            (
1580                flight(40.0, 20.0, FOOT_M, 1.0 / FOOT_M, 0),
1581                "under the 50 m",
1582            ),
1583            (flight(8.0, 4.0, FOOT_M, 1.0 / FOOT_M, 0), "only assumed"),
1584            (cut, "of the way up the climb, above the 50%"),
1585        ];
1586        for (text, why) in cases {
1587            let log = read(&text).unwrap();
1588            assert_eq!(log.channels[2].quantity, Quantity::Unrecognized);
1589            assert!(log.vertical_speed_m_s.is_none());
1590            let kept = notes_starting(&log, "`Velocity` kept as written, its unit unknown: ");
1591            assert_eq!(kept.len(), 1, "{:?}", log.notes);
1592            assert!(kept[0].contains(why), "{why}: {kept:?}");
1593        }
1594        // Under 10 m of climb, feet are assumed and said so.
1595        let log = read(&flight(8.0, 4.0, FOOT_M, 1.0 / FOOT_M, 0)).unwrap();
1596        let assumed = notes_starting(&log, "`Altitude` read in feet, assumed: ");
1597        assert!(assumed[0].contains("climbs 8.0 m"), "{assumed:?}");
1598    }
1599
1600    /// A pressure outside the RRC3's range is kept as written, and the altitude assumed in feet.
1601    #[test]
1602    fn a_pressure_of_unknown_unit_is_kept_as_written() {
1603        let text = format!("{HEADER}\n0,0,101325,0,70, -,9.2\n0.05,1,101310,1,70, -,9.2\n");
1604        let log = read(&text).unwrap();
1605        assert_eq!(log.channels[1].quantity, Quantity::Unrecognized);
1606        assert_eq!(log.channels[1].values, [101_325.0, 101_310.0]);
1607        assert_eq!(
1608            notes_starting(&log, "`Pressure` kept as written, its unit unknown: ").len(),
1609            1
1610        );
1611        let assumed = notes_starting(&log, "`Altitude` read in feet, assumed: ");
1612        assert!(assumed[0].contains("unit is unknown"), "{assumed:?}");
1613    }
1614
1615    /// Units in brackets are taken as written, cross-checked, and an unknown one refused.
1616    #[test]
1617    fn bracketed_units() {
1618        let text = flight(300.0, 60.0, 1.0, 3.6, 0).replacen(
1619            HEADER,
1620            "Time [s],Altitude [m],Pressure [hPa],Velocity [kph],Temperature [°F],Events,Voltages [V]",
1621            1,
1622        );
1623        let log = read(&text).unwrap();
1624        assert!(close(log.vertical_speed_m_s.as_deref().unwrap()[1], 60.0));
1625        // 70 °F is 294.261 K.
1626        assert!((log.temperature_k.as_deref().unwrap()[0] - 294.261).abs() < 1e-3);
1627        assert!(
1628            log.notes.iter().all(|n| !n.contains(" read in ")),
1629            "{:?}",
1630            log.notes
1631        );
1632
1633        // A stated unit the file contradicts is refused as a unit.
1634        let wrong_altitude = text.replacen("Altitude [m]", "Altitude [ft]", 1);
1635        let error = read(&wrong_altitude).unwrap_err();
1636        assert!(
1637            matches!(&error, LogError::Unit { line: 1, message, .. } if message.contains("states feet, which the pressure contradicts")),
1638            "{error:?}"
1639        );
1640        let wrong_speed = text.replacen("Velocity [kph]", "Velocity [m/s]", 1);
1641        let error = read(&wrong_speed).unwrap_err();
1642        assert!(
1643            matches!(&error, LogError::Unit { line: 1, message, .. } if message.contains("\"Velocity [m/s]\" states meters per second, but against the altitude") && message.contains("which is kilometers per hour")),
1644            "{error:?}"
1645        );
1646        for header in [
1647            "Time [ms],Altitude,Pressure,Velocity",
1648            "Time,Altitude [yd],Pressure,Velocity",
1649            "Time,Altitude,Pressure [psi],Velocity",
1650            "Time,Altitude,Pressure,Velocity [knots]",
1651            "Time,Altitude,Pressure,Velocity,Temperature [K]",
1652            "Time,Altitude,Pressure,Velocity,Events [x]",
1653            "Time,Altitude,Pressure,Velocity,Voltages [mV]",
1654        ] {
1655            let error = read(&format!("{header}\n0,0,1000,0\n")).unwrap_err();
1656            assert!(
1657                matches!(&error, LogError::Unit { line: 1, message, .. } if message.contains("states a unit this reader doesn't know")),
1658                "{header}: {error:?}"
1659            );
1660        }
1661    }
1662
1663    /// An altitude whose climb matches the pressure's in neither unit is refused.
1664    #[test]
1665    fn an_altitude_in_neither_unit_is_refused() {
1666        let text = flight(300.0, 60.0, 1.0 / 1.6, 1.0, 0);
1667        let error = read(&text).unwrap_err();
1668        assert!(
1669            matches!(&error, LogError::Unit { line: 1, message, .. } if message.contains("neither feet nor meters")),
1670            "{error:?}"
1671        );
1672    }
1673
1674    /// Semicolons or tabs with decimal commas; a comma file doesn't take them.
1675    #[test]
1676    fn semicolons_and_tabs_take_decimal_commas() {
1677        let comma = "0,00;0;1013,25;0;21,5; -;9,2\n0,05;1,5;1013,07;2,5;21,5; Main;9,1\n";
1678        let log = read(&format!("{}\n{comma}", HEADER.replace(',', ";"))).unwrap();
1679        assert_eq!(log.time_s, [0.0, 0.05]);
1680        assert_eq!(log.events[0].name, "Main");
1681        assert!(close(log.channels[1].values[1], 101_307.0));
1682        let tabbed = comma.replace(';', "\t");
1683        let log = read(&format!("{}\n{tabbed}", HEADER.replace(',', "\t"))).unwrap();
1684        assert_eq!(log.battery_v.as_deref(), Some(&[9.2, 9.1][..]));
1685        let error = read(&format!("{HEADER}\n0,0,1013,25,0,70, -,9,2\n")).unwrap_err();
1686        assert!(
1687            error.to_string().contains("line 2: the row has 9 cells"),
1688            "{error}"
1689        );
1690    }
1691
1692    /// Short rows: the 1 Hz cells left out, or padded back empty; `-` never a temperature.
1693    #[test]
1694    fn short_rows_leave_out_the_one_hertz_cells() {
1695        let text =
1696            format!("{HEADER}\n0,0,1000,0,70, -,9.2\n0.05,1,999.9,1, Drogue\n0.1,1,999.9,1, -,,\n");
1697        let log = read(&text).unwrap();
1698        assert_eq!(log.events.len(), 1);
1699        assert_eq!(log.events[0].time_s, 0.05);
1700        let temperature = log.temperature_k.as_deref().unwrap();
1701        assert!(temperature[0].is_finite() && temperature[1].is_nan() && temperature[2].is_nan());
1702        assert!(log.notes.iter().any(|n| n.starts_with(
1703            "2 rows, the first on line 3, leave out the 1 Hz `Temperature` and `Voltages` cells: \
1704             they are gaps; 1 of them pad"
1705        )));
1706        // One 1 Hz column alone: four cells.
1707        let log = read("Time,Altitude,Pressure,Velocity,Temperature\n0,0,1000,0,70\n1,1,999.9,1\n")
1708            .unwrap();
1709        assert!(log.temperature_k.as_deref().unwrap()[1].is_nan());
1710        // A file without 1 Hz columns has no short rows.
1711        let error = read("Time,Altitude,Pressure,Velocity,Events\n0,0,1000,0\n").unwrap_err();
1712        assert!(
1713            error
1714                .to_string()
1715                .contains("line 2: the row has 4 cells: the header names 5"),
1716            "{error}"
1717        );
1718        // `-` where the temperature stands, in a full row, isn't a number.
1719        let error = read(&format!("{HEADER}\n0,0,1000,0, -, -,9.2\n")).unwrap_err();
1720        assert!(
1721            error
1722                .to_string()
1723                .contains("line 2: \"-\" under \"Temperature\" isn't a number"),
1724            "{error}"
1725        );
1726        // A number where a short row's event stands isn't an event.
1727        let error = read(&format!("{HEADER}\n0,0,1000,0,70,,\n")).unwrap_err();
1728        assert!(
1729            error
1730                .to_string()
1731                .contains("line 2: \"70\" under \"Events\" isn't an event"),
1732            "{error}"
1733        );
1734    }
1735
1736    /// Rows with no time and only an altitude are skipped and counted; other rows with no time
1737    /// are refused.
1738    #[test]
1739    fn rows_with_no_time() {
1740        let text = format!("{HEADER}\n0,0,1000,0,70, -,9.2\n,4,,,,,\n,4\n");
1741        let log = read(&text).unwrap();
1742        assert_eq!(log.time_s.len(), 1);
1743        assert!(log.notes.iter().any(|n| n.starts_with(
1744            "2 rows with no time and nothing but an altitude cell, the first on line 3"
1745        )));
1746        let error = read(&format!("{HEADER}\n,4,1000,,,,\n")).unwrap_err();
1747        assert!(
1748            error.to_string().contains("line 2: the row has no time"),
1749            "{error}"
1750        );
1751    }
1752
1753    /// Each refusal names its line and what is wrong.
1754    #[test]
1755    fn malformed_files_are_refused_where_they_go_wrong() {
1756        let cases = [
1757            (String::new(), "the file is empty"),
1758            (
1759                "Time,Altitude,Velocity\n0,0,0\n".to_owned(),
1760                "isn't an mDACS export's header",
1761            ),
1762            (format!("{HEADER}\n"), "no data rows"),
1763            (format!("{HEADER}\n,4\n"), "no data rows"),
1764            (
1765                format!("{HEADER}\n0,0,1000,0,70, -,9.2\n0,0,1000,0,70, -,9.2\n"),
1766                "line 3: the time 0 s doesn't come after the one above it, 0 s",
1767            ),
1768            (
1769                format!("{HEADER}\n1,0,1000,0,70, -,9.2\n0.5,0,1000,0,70, -,9.2\n"),
1770                "line 3: the time 0.5 s doesn't come after",
1771            ),
1772            (
1773                format!("{HEADER}\nx,0,1000,0,70, -,9.2\n"),
1774                "line 2: \"x\" under \"Time\" isn't a number",
1775            ),
1776            (
1777                format!("{HEADER}\n0,,1000,0,70, -,9.2\n"),
1778                "line 2: \"\" under \"Altitude\" isn't a number",
1779            ),
1780            (
1781                format!("{HEADER}\n0,0,1000,0,70, Main 2,9.2\n"),
1782                "line 2: \"Main 2\" under \"Events\"",
1783            ),
1784            (
1785                format!("{HEADER}\n0,0,1000,0,70, -,9.2,1\n"),
1786                "line 2: the row has 8 cells",
1787            ),
1788            (
1789                format!("{HEADER}\n0,0,1000\n"),
1790                "line 2: the row has 3 cells",
1791            ),
1792        ];
1793        for (text, expected) in cases {
1794            let error = read(&text).unwrap_err().to_string();
1795            assert!(error.contains(expected), "{text:?}: {error}");
1796        }
1797    }
1798
1799    /// An event word the manual doesn't name is kept, with a note; the notes stay few.
1800    #[test]
1801    fn unknown_events_are_kept_and_notes_stay_few() {
1802        let mut text = format!("{HEADER}\n");
1803        for index in 0..30 {
1804            text.push_str(&format!("{index},0,1000,0,70, Apogee,9.2\n"));
1805        }
1806        let log = read(&text).unwrap();
1807        assert_eq!(log.events.len(), 30);
1808        assert_eq!(log.events[0].name, "Apogee");
1809        let about_events = log
1810            .notes
1811            .iter()
1812            .filter(|n| n.contains("\"Apogee\""))
1813            .count();
1814        assert_eq!(about_events, 20);
1815        assert!(
1816            log.notes.iter().any(|n| n.starts_with("and 10 more notes")),
1817            "{:?}",
1818            log.notes
1819        );
1820    }
1821
1822    /// Lines end in CR, LF or both; a byte-order mark and blank lines are dropped; a long line
1823    /// is quoted short.
1824    #[test]
1825    fn line_ends_and_quotes() {
1826        let rows = ["0,0,1000,0,70, -,9.2", "0.05,0.5,999.98,1,70, -,9.2"];
1827        for end in ["\r", "\n", "\r\n"] {
1828            let text = format!("\u{feff}{HEADER}{end}{}{end}{end}{}{end}", rows[0], rows[1]);
1829            let log = read(&text).unwrap();
1830            assert_eq!(log.time_s, [0.0, 0.05], "{end:?}");
1831        }
1832        let error = read(&format!("{HEADER}\n{}\n", "9".repeat(10_000))).unwrap_err();
1833        assert!(error.to_string().chars().count() < 400, "{error}");
1834        let error = read(&"\u{fffd}".repeat(100_000)).unwrap_err();
1835        assert!(error.to_string().chars().count() < 400, "{error}");
1836    }
1837
1838    /// An invented flight sampled at 20 Hz, as (time, height, climb rate): a second on the pad, a
1839    /// boost at 80 m/s² and a coast at 12 m/s² to `top_m`, a fall at 9.8 m/s² to
1840    /// `descent_m_s`, that speed to the ground, and three seconds there.
1841    fn arc(top_m: f64, descent_m_s: f64) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
1842        let (boost, coast, gravity) = (80.0, 12.0, 9.8);
1843        let boost_s = (top_m / (boost / 2.0 + boost * boost / (2.0 * coast))).sqrt();
1844        let burnout_m_s = boost * boost_s;
1845        let apogee_s = boost_s + burnout_m_s / coast;
1846        let fall_s = descent_m_s / gravity;
1847        let (mut times, mut heights, mut rates) = (vec![], vec![], vec![]);
1848        let mut landed_s = None;
1849        for row in 0_u32.. {
1850            let t = f64::from(row) * 0.05 - 1.0;
1851            let (height, rate): (f64, f64) = if t < 0.0 {
1852                (0.0, 0.0)
1853            } else if t < boost_s {
1854                (boost * t * t / 2.0, boost * t)
1855            } else if t < apogee_s {
1856                let u = t - boost_s;
1857                (
1858                    boost * boost_s * boost_s / 2.0 + burnout_m_s * u - coast * u * u / 2.0,
1859                    burnout_m_s - coast * u,
1860                )
1861            } else if t < apogee_s + fall_s {
1862                let u = t - apogee_s;
1863                (top_m - gravity * u * u / 2.0, -gravity * u)
1864            } else {
1865                let u = t - apogee_s - fall_s;
1866                (
1867                    top_m - gravity * fall_s * fall_s / 2.0 - descent_m_s * u,
1868                    -descent_m_s,
1869                )
1870            };
1871            let (height, rate) = if height <= 0.0 && t > 0.0 {
1872                (0.0, 0.0)
1873            } else {
1874                (height, rate)
1875            };
1876            if t > 0.0 && height == 0.0 {
1877                landed_s.get_or_insert(t);
1878            }
1879            if landed_s.is_some_and(|landed| t > landed + 3.0) {
1880                break;
1881            }
1882            times.push(t + 1.0);
1883            heights.push(height);
1884            rates.push(rate);
1885        }
1886        (times, heights, rates)
1887    }
1888
1889    /// The velocity's unit is never decided wrong on invented flights 20 m to 1 km up, coming
1890    /// down at 10 to 40 m/s, the pressure written to 0.1 hPa and the altitude worked back from
1891    /// it, the velocity late by up to 3 s or smoothed over up to 3 s, in each of the four units,
1892    /// and each record cut off anywhere from 30% of the way through to its end. Many are left
1893    /// unknown; none is decided in another unit.
1894    #[test]
1895    fn the_velocity_unit_is_never_decided_wrong() {
1896        let atmosphere = Ussa76::standard();
1897        let height_of = |hpa: f64| {
1898            atmosphere
1899                .pressure_altitude_m(hpa * HPA_PA)
1900                .unwrap_or(f64::NAN)
1901        };
1902        let pad_m = height_of((pressure_hpa(0.0) * 10.0).round() / 10.0);
1903        let lags = [
1904            ("late", 0.0),
1905            ("late", 1.0),
1906            ("late", 2.0),
1907            ("late", 3.0),
1908            ("first-order", 0.5),
1909            ("first-order", 1.0),
1910            ("first-order", 2.0),
1911            ("first-order", 3.0),
1912            ("moving average", 1.0),
1913            ("moving average", 2.0),
1914            ("moving average", 3.0),
1915        ];
1916        let (mut decided, mut cases) = (0, 0);
1917        for top_m in [20.0, 30.0, 50.0, 100.0, 200.0, 300.0, 1_000.0] {
1918            for descent_m_s in [10.0, 25.0, 40.0] {
1919                let (time_s, height_m, rate_m_s) = arc(top_m, descent_m_s);
1920                let altitude_m: Vec<f64> = height_m
1921                    .iter()
1922                    .map(|height| {
1923                        let hpa = (pressure_hpa(*height) * 10.0).round() / 10.0;
1924                        ((height_of(hpa) - pad_m) * 100.0).round() / 100.0
1925                    })
1926                    .collect();
1927                for (kind, lag_s) in lags {
1928                    let steps = (lag_s / 0.05_f64).round() as usize;
1929                    let lagged: Vec<f64> = match kind {
1930                        "late" => (0..rate_m_s.len())
1931                            .map(|i| i.checked_sub(steps).map_or(0.0, |j| rate_m_s[j]))
1932                            .collect(),
1933                        "first-order" => {
1934                            let mut smoothed = 0.0;
1935                            rate_m_s
1936                                .iter()
1937                                .map(|rate| {
1938                                    smoothed += (rate - smoothed) * 0.05 / (lag_s + 0.05);
1939                                    smoothed
1940                                })
1941                                .collect()
1942                        }
1943                        _ => (0..rate_m_s.len())
1944                            .map(|i| {
1945                                let from = (i + 1).saturating_sub(steps);
1946                                rate_m_s[from..=i].iter().sum::<f64>() / steps as f64
1947                            })
1948                            .collect(),
1949                    };
1950                    for (unit, (words, _, m_s)) in SPEEDS.iter().enumerate() {
1951                        let velocity: Vec<f64> = lagged
1952                            .iter()
1953                            .map(|rate| (rate / m_s * 100.0).round() / 100.0)
1954                            .collect();
1955                        for tenths in 3..=10 {
1956                            let rows = time_s.len() * tenths / 10;
1957                            let Some(scale) =
1958                                velocity_scale(&time_s[..rows], &altitude_m[..rows], &velocity)
1959                            else {
1960                                continue;
1961                            };
1962                            cases += 1;
1963                            let agreed = agreed_unit(&scale);
1964                            // A whole record, its velocity up to 1 s late, is decided from 50 m up.
1965                            if kind == "late" && lag_s <= 1.0 && tenths == 10 && top_m >= 50.0 {
1966                                assert_eq!(
1967                                    agreed,
1968                                    Some(unit),
1969                                    "{top_m} m, {descent_m_s} m/s, {lag_s} s late, {words}: \
1970                                     {scale:?}"
1971                                );
1972                            }
1973                            if let Some(index) = agreed {
1974                                decided += 1;
1975                                assert_eq!(
1976                                    index, unit,
1977                                    "{top_m} m, {descent_m_s} m/s, {kind} {lag_s} s, {words}, \
1978                                     {tenths}0% of the record: {scale:?}"
1979                                );
1980                            }
1981                        }
1982                    }
1983                }
1984            }
1985        }
1986        // The rule decides a fair share, not only the whole records.
1987        assert!(decided * 10 > cases, "{decided} of {cases}");
1988    }
1989
1990    /// The two measures of the velocity's unit, on a flight whose velocity runs 1.5 s behind
1991    /// its altitude: the delay found, and neither measure moved by it.
1992    #[test]
1993    fn the_velocity_scale_takes_out_a_delay() {
1994        let time_s: Vec<f64> = (0..800).map(|i| f64::from(i) * 0.05).collect();
1995        // Up at 50 m/s for 5 s, down at 12.5 m/s to the pad, then at rest.
1996        let rate = |t: f64| match t {
1997            t if t < 1.0 => 0.0,
1998            t if t < 6.0 => 50.0,
1999            t if t < 26.0 => -12.5,
2000            _ => 0.0,
2001        };
2002        let mut altitude_m = vec![0.0];
2003        for pair in time_s.windows(2) {
2004            let last = altitude_m.last().copied().unwrap_or_default();
2005            altitude_m.push(last + 0.5 * (rate(pair[0]) + rate(pair[1])) * (pair[1] - pair[0]));
2006        }
2007        let velocity: Vec<f64> = time_s.iter().map(|t| rate(t - 1.5) * 2.0).collect();
2008        let scale = velocity_scale(&time_s, &altitude_m, &velocity).unwrap();
2009        assert!((scale.delay_s - 1.5).abs() < 1e-9, "{scale:?}");
2010        assert!((scale.by_area / 2.0 - 1.0).abs() < 1e-3, "{scale:?}");
2011        assert!((scale.by_fit / 2.0 - 1.0).abs() < 1e-3, "{scale:?}");
2012        assert!(
2013            scale.end_height.abs() < 1e-9 && scale.end_error < 1e-9,
2014            "{scale:?}"
2015        );
2016        assert!(velocity_scale(&[0.0, 1.0], &[5.0, 5.0], &[1.0, 1.0]).is_none());
2017    }
2018
2019    /// The time read in seconds from a 20 Hz step, assumed at another step or with one row, and
2020    /// with no note when the header states `[s]`.
2021    #[test]
2022    fn the_time_is_read_in_seconds_from_the_step_or_assumed() {
2023        let rows = |step: f64, count: usize| -> String {
2024            (0..count)
2025                .map(|i| format!("{:.2},0,1000,0\n", i as f64 * step))
2026                .collect()
2027        };
2028        let header = "Time,Altitude,Pressure,Velocity\n";
2029        let log = read(&format!("{header}{}", rows(0.05, 5))).unwrap();
2030        let from_step = notes_starting(&log, "`Time` read in seconds, from the step: ");
2031        assert!(
2032            from_step[0].contains("step between rows, 0.05"),
2033            "{from_step:?}"
2034        );
2035        let log = read(&format!("{header}{}", rows(0.5, 5))).unwrap();
2036        let assumed = notes_starting(&log, "`Time` read in seconds, assumed: ");
2037        assert!(
2038            assumed[0].contains("0.5 as written, isn't within 5%"),
2039            "{assumed:?}"
2040        );
2041        let log = read(&format!("{header}{}", rows(0.05, 1))).unwrap();
2042        assert_eq!(
2043            notes_starting(&log, "`Time` read in seconds, assumed: a single row").len(),
2044            1
2045        );
2046        let log = read(&format!(
2047            "Time [s],Altitude,Pressure,Velocity\n{}",
2048            rows(0.05, 5)
2049        ))
2050        .unwrap();
2051        assert!(
2052            log.notes.iter().all(|n| !n.contains("`Time")),
2053            "{:?}",
2054            log.notes
2055        );
2056    }
2057
2058    /// A pressure whose first row isn't a launch site's, in hectopascals, is kept as written:
2059    /// kilopascals (about 100) and inches of mercury (about 30) lie within what the RRC3 measures,
2060    /// so only the pad tells them apart; the altitude is then assumed in feet.
2061    #[test]
2062    fn a_pressure_off_the_pad_range_is_kept_as_written() {
2063        for (pad, next) in [
2064            (101.3, 101.2),
2065            (29.92, 29.9),
2066            (499.0, 498.9),
2067            (1_101.0, 1_100.9),
2068        ] {
2069            let text = format!("{HEADER}\n0,0,{pad},0,70, -,9.2\n0.05,1,{next},1,70, -,9.2\n");
2070            let log = read(&text).unwrap();
2071            assert_eq!(log.channels[1].quantity, Quantity::Unrecognized, "{pad}");
2072            assert_eq!(log.channels[1].values, [pad, next]);
2073            let kept = notes_starting(&log, "`Pressure` kept as written, its unit unknown: ");
2074            assert!(
2075                kept[0].contains(&format!(
2076                    "the first row's, {pad}, lies outside the 500 to 1100"
2077                )),
2078                "{kept:?}"
2079            );
2080            let assumed = notes_starting(&log, "`Altitude` read in feet, assumed: ");
2081            assert!(assumed[0].contains("unit is unknown"), "{assumed:?}");
2082        }
2083        // At either edge of the pad's range, hectopascals.
2084        for (pad, next) in [(500.0, 499.9), (1_100.0, 1_099.9)] {
2085            let text = format!("{HEADER}\n0,0,{pad},0,70, -,9.2\n0.05,1,{next},1,70, -,9.2\n");
2086            let log = read(&text).unwrap();
2087            assert_eq!(log.channels[1].quantity, Quantity::Pressure, "{pad}");
2088        }
2089    }
2090
2091    proptest::proptest! {
2092        /// Any text is read or refused, never a panic; what is read has increasing times, every
2093        /// channel on them.
2094        #[test]
2095        fn any_text_is_read_or_refused(rows in proptest::collection::vec("[-0-9.,; DrogueMainx]{0,32}", 0..12)) {
2096            let text = format!("{HEADER}\n{}", rows.join("\n"));
2097            if let Ok(log) = read(&text) {
2098                proptest::prop_assert!(log.time_s.windows(2).all(|pair| pair[1] > pair[0]));
2099                proptest::prop_assert_eq!(log.time_s.len(), log.altitude_m.len());
2100                for channel in &log.channels {
2101                    proptest::prop_assert_eq!(&channel.time_s, &log.time_s);
2102                    proptest::prop_assert_eq!(channel.values.len(), log.time_s.len());
2103                }
2104            }
2105        }
2106    }
2107}