Skip to main content

hpr_flightdata/
eggtimer.rs

1//! An Eggtimer's flight data, as the flight computer's own download writes it: the Eggtimer
2//! Classic's and TRS's over a serial cable, the Quantum's and Proton's from their WiFi page.
3//!
4//! The file is comma-separated text: one header row naming the columns, then one row per sample,
5//! every column on the row's time. There is no preamble and the header states no units:
6//!
7//! ```text
8//! T,Alt,Veloc,FAlt,FVeloc,LDA,LowV,Apogee,N-O,Drogue,Main
9//! 0,0,0,0,0,0,0,0,0,0,0
10//! 0.05,4,80,0.8,16,0,0,0,0,0,0
11//! 0.1,9,100,2.44,32.8,0,0,0,0,0,0
12//! ```
13//!
14//! **Layouts.** The first column is the time, `T`, and the second the barometric altitude, `Alt`.
15//! Then come the logger's figures, which differ by model and firmware: a raw velocity, a filtered
16//! altitude and velocity (`FAlt`, `Filt_Alt` or `KAlt`, then `FVeloc` or `KVeloc`), and on the
17//! Proton the accelerometer's figures. Last come the event markers, from `LDA` on. The Classic's
18//! manual shows `T,Alt,Veloc,KAlt,KVeloc,LDA,BurnT,LowV,Apogee,N-O,CHA,CHB` (*Eggtimer Flight
19//! Computer User's Manual*, firmware 1.52, board RevD, p37), the TRS's
20//! `T,Alt,Veloc,FAlt,FVeloc,LDA,LowV,Apogee,N-O,Drogue,Main` (*Eggtimer TRS Flight Computer User's
21//! Manual*, firmware 2.07, p37), and an older guide `T`, `Alt`, `VRaw`, `VFilt` (*Using Excel to
22//! Graph Eggtimer Flight Data*, p3). The project's sample logs add a layout without the raw
23//! velocity (`T,Alt,FAlt,FVeloc,…`) and the Proton's 22 columns, whose accelerometer columns are
24//! named `AccelG`, `AccelFPSS`, `VAccel`, `AltAccel` and filtered `FAccel…` where its manual writes
25//! `Accel`, `AccelFPSS`, `VelocAcc` and `AltAcc` (*Eggtimer Proton User's Guide*, software 2.01C,
26//! p45). The Proton lets its owner name each deployment channel, up to 8 characters, and writes
27//! that name in the header (Proton p26; *Eggtimer Proton Release Notes*, version 2.01C, p2), so a
28//! marker column may carry any name.
29//!
30//! A file is this layout if its first line that isn't blank is comma-separated, names `T` then
31//! `Alt`, and names at least one more column this reader knows (the table below). That keeps out a
32//! PerfectFlite export (`Time,Altitude,…`) and an RRC3's (`Time,Altitude,Pressure,…`).
33//!
34//! **Units.** The header states none. Each column, what it is read as, and where that comes from:
35//!
36//! | Column | Read as | From |
37//! |---|---|---|
38//! | `T` | the clock: seconds, or milliseconds when the step says so (below) | the Classic's and TRS's manuals (p37) and the Excel guide (p3): milliseconds; the Quantum's (*Eggtimer Quantum User's Guide*, software 1.09G, p38) and Proton's (p45): seconds |
39//! | `Alt` | [`Quantity::AltitudeAboveGround`], ft | the Classic and TRS manuals (p37) state feet; the Quantum and Proton manuals state no unit for the download, but give every height in feet (Quantum p24: launch detect "50’-500’"), and no Eggtimer manual offers meters |
40//! | `FAlt`, `Filt_Alt`, `KAlt` | [`Quantity::AltitudeAboveGround`], ft: the logger's filtered `Alt` | Quantum p38, Proton p45; Classic p37 ("the two K fields are filtered data") |
41//! | `Veloc`, `VRaw` | [`Quantity::Speed`], ft/s: the change in `Alt` over the step | Classic and TRS p37 ("velocity in ft/sec"); Quantum p38, Proton p45; checked against `Alt` (below) |
42//! | `FVeloc`, `KVeloc`, `VFilt` | [`Quantity::Speed`], ft/s: the change in the filtered altitude over the step | Quantum p38, Proton p46; the Excel guide (p3, p7: "Velocity in ft/sec"); checked against the filtered altitude (below) |
43//! | `Accel` | [`Quantity::Acceleration`], g × 9.806 65 m/s² (standard gravity) | Proton p45 ("in G's") |
44//! | `AccelG`, `FAccelG` | [`Quantity::Acceleration`], g × 9.806 65 m/s² | the name; Proton p45 |
45//! | `AccelFPSS`, `FAccelFPSS` | [`Quantity::Acceleration`], ft/s² | Proton p45 ("ft/sec^2"); the name |
46//! | `VAccel`, `VelocAcc`, `FAccelVel` | [`Quantity::Speed`], ft/s, from the accelerometer | Proton p45 ("the velocity derived from the accelerometer in ft/sec") |
47//! | `AltAccel`, `AltAcc`, `FAccelAlt` | [`Quantity::InertialAltitude`], ft | Proton p45 ("the DISTANCE TRAVELED derived from the accelerometer in ft.") |
48//! | `LDA`, `LowV`, `Apogee`, `N-O`, `Drogue`, `Main`, `AUX`, `BurnT`, `CHA`, `CHB`, `CH1`…, `MaxV`, `MaxA`, `Boost#1`…, `BO#1`…, and any column after `LDA` | [`Quantity::Unrecognized`], as written: an event marker (below) | Quantum p38–39, Proton p46 |
49//!
50//! No Eggtimer manual offers a unit other than feet: the Classic's, TRS's, Quantum's and Proton's
51//! manuals and release notes name no meter or metric setting. Each column whose name doesn't state
52//! its unit gets a note on the record saying how it was read, once per name.
53//!
54//! **Speeds checked.** A speed is read in feet per second only if the rows bear out that it is its
55//! altitude's change over the step: `Veloc` and `VRaw` of `Alt`, `FVeloc`, `KVeloc` and `VFilt` of
56//! the first filtered altitude, within 1 % or 1 ft/s, on at least 90 % of the rows after the first.
57//! A speed that falls short is kept as written, [`Quantity::Unrecognized`], with a note; a file of
58//! one row, or a filtered speed with no filtered altitude, has nothing to check, and is read as the
59//! manual says. As of 2026-10-12, over the 43 Eggtimer files among the project's sample logs, the
60//! least share is 95.9 %, and with the clock read in the wrong unit, a factor of 1,000 off, at
61//! most 9.5 %: the check also guards the time's unit (below).
62//!
63//! **The accelerometer.** As of 2026-10-12, in the 4 of those files with an accelerometer,
64//! `AccelFPSS` is about 32 times `AccelG` (32.17 ft/s² is one g), `VAccel` tracks the sum of
65//! `AccelFPSS` over the steps, and `AccelG`'s median before the start of flight (`T` below 0) is
66//! 0 to 0.2 g. Standing on the pad, an accelerometer that keeps gravity reads 1 g, so the Proton's
67//! acceleration has gravity taken out. Its manual doesn't say so; its release notes have it
68//! calibrate the accelerometer as it is armed, standing up (2.01C, p8). A note on the record says
69//! whether this file's acceleration holds gravity, from its own pad reading: taken out below
70//! 0.5 g, held within 0.5 g of 1 g, not known otherwise or with no rows before the start of
71//! flight. The values are kept as written either way. The `F`-prefixed accelerometer columns are
72//! read as their unfiltered namesakes, as `FAlt` is `Alt` filtered: no manual describes them.
73//!
74//! **The time's unit.** The Classic's and TRS's downloads, and the older guide's, write `T` in
75//! milliseconds; the Quantum's and Proton's in seconds; and the sample logs hold eleven-column
76//! files in both. The logger takes 4 to 33 samples a second going up and 1 to 10 coming down
77//! (Classic p19–20; Quantum p25), so a step between rows is 0.03 to 1 in seconds and 30 to 1,000
78//! in milliseconds. The reader takes the median step between rows: above 5, a factor of 5 clear of
79//! either range, `T` is in milliseconds; otherwise in seconds. A file of one row has no step, and
80//! is read in seconds. A note on the record says which, and why.
81//!
82//! **Event markers.** The manuals put the markers last, from `LDA` on: each is 0 at every sample
83//! but the one that triggers its event, where the Quantum's and Proton's manuals say it holds "the
84//! RAW altitude data", in feet (Quantum p38; Proton p46). `MaxV` and `MaxA` hold the top speed and
85//! acceleration instead, ft/s and ft/s² (*Eggtimer Proton Release Notes*, version 1.02N, p13: "now
86//! referenced to the actual value"); in the sample logs they are the whole part of `VAccel` and
87//! `AccelFPSS` on their row. A marker may also be held from its event to the end of the file, as 3
88//! marker columns among the 43 sample files are (as of 2026-10-12). Each marker column is kept as a
89//! channel as written, [`Quantity::Unrecognized`]: its 0 means "no event", not 0 ft, so converting
90//! it as a height would mislead. One note on the record lists them. Each run of non-zero samples is
91//! an [`Event`] named as the column (a name past 80 characters cut short, as a note quotes it), at
92//! the run's first sample, up to [`MAX_EVENTS_PER_MARKER`] a column; past that, the rises are
93//! counted in a note. An event that happened at a raw altitude of exactly 0 ft can't be told from
94//! no event, and is lost. A column after `LDA` whose name this reader doesn't know, such as a
95//! Proton channel's own name, is read as a marker, with a note.
96//!
97//! **The primary track.** [`FlightLog::altitude_m`] is `Alt`, the barometer's raw altitude above
98//! the logger's zero. The vertical speed is `Veloc` (or `VRaw`), the barometer's; without it, or
99//! with it kept as written because its check failed, the filtered `FVeloc` (or `KVeloc`, `VFilt`),
100//! also the barometer's, with a note. `VAccel` is the
101//! accelerometer's, a channel only. The file names no model, serial number or flight, so the logger
102//! reads `Eggtimer`.
103//!
104//! **Cells.** Lines end in CR, LF, CR LF or CR CR LF (a blank line after every row, which is
105//! skipped). A row whose every cell is empty, as some downloads end with hundreds of, is skipped
106//! and counted in a note. An empty cell, or a row shorter than the header, leaves a gap, `NaN`,
107//! counted per column in a note. A cell that isn't a number, a row longer than the header, an empty
108//! time, and a time that doesn't increase are refused, naming the line. A column this reader
109//! doesn't know, before `LDA`, is kept as written, [`Quantity::Unrecognized`], with a note.
110//!
111//! **Where this comes from.** The layouts and units are the manuals' above; the time-unit rule, the
112//! blank-row rule and the column names they don't show are the sample logs'. Debrief's reader
113//! (`lib/parsers/eggtimer.ts`, MIT, the project owner's own) also tells milliseconds from seconds
114//! by the median step, over the first 60 rows with its line at 3. Where this reader departs from
115//! it: it keeps every column and reads the markers as events, where Debrief maps the time, the
116//! altitude and one velocity; it takes the raw `Veloc` as the vertical speed where Debrief prefers
117//! the filtered one; it reads the layouts without `Veloc`, which Debrief doesn't recognize; and it
118//! refuses what Debrief passes over, naming the line. Debrief's note that the logger can be set to
119//! metric units cites no source, and none of the manuals above offers such a setting.
120
121use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
122
123use crate::error::LogError;
124use crate::log::{Channel, Event, FlightLog, LogFormat, Quantity, Stated};
125use crate::perfectflite::FOOT_M;
126use crate::table::{Notes, number, numbered_lines, short};
127
128/// The format's name in errors.
129const FORMAT: &str = "Eggtimer CSV";
130
131/// The median step between rows above which `T` is read in milliseconds. At the manuals' 1 to 33
132/// samples a second, a step is 0.03 to 1 in seconds and 30 to 1,000 in milliseconds: 5 is a
133/// factor of 5 clear of the first and 6 of the second.
134const MILLISECONDS_ABOVE: f64 = 5.0;
135
136/// How far a speed may be from its altitude's change over the step and still count as that
137/// change, in the file's units: 1 %, or 1 ft/s, whichever is larger. The cells are rounded to
138/// whole feet or hundredths, and a few rows fall across a change of sampling rate.
139const SPEED_MATCH_RELATIVE: f64 = 0.01;
140const SPEED_MATCH_FT_S: f64 = 1.0;
141
142/// The least share of the rows after the first on which a speed must be its altitude's change
143/// over the step for the reader to read it in feet per second; below it, the column is kept as
144/// written. As of 2026-10-12, over the 43 Eggtimer files among the project's sample logs, the
145/// least share is 95.9 %, and with the clock read in the wrong unit, a factor of 1,000 off, the
146/// most is 9.5 %: 90 % sits between the two, near the first.
147const SPEED_MATCH_SHARE: f64 = 0.9;
148
149/// The most events one marker column gives; past it, the column's rises are counted in a note
150/// instead. A marker marks one event of the flight (Quantum p38, Proton p46): as of 2026-10-12, no
151/// marker column among the 43 Eggtimer files in the project's sample logs rises more than once.
152/// The cap keeps a file whose marker flips on every row from filling the record with events.
153pub const MAX_EVENTS_PER_MARKER: usize = 16;
154
155/// The pad reading, in g, below which an acceleration is taken to have gravity out, and within
156/// which of 1 g to have it in: halfway between the two.
157const GRAVITY_READING_G: f64 = 0.5;
158
159/// The most names a note lists; past it, the rest are counted.
160const MAX_LISTED: usize = 6;
161
162/// What a column is.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164enum Kind {
165    /// `Alt`: the barometer's raw altitude, ft.
166    Altitude,
167    /// `FAlt`, `Filt_Alt`, `KAlt`: the barometric altitude filtered, ft.
168    FilteredAltitude,
169    /// `Veloc`, `VRaw`: the change in `Alt` over the step, ft/s.
170    RawSpeed,
171    /// `FVeloc`, `KVeloc`, `VFilt`: the change in the filtered altitude over the step, ft/s.
172    FilteredSpeed,
173    /// The accelerometer in g.
174    AccelerationG,
175    /// The accelerometer in ft/s².
176    AccelerationFtS2,
177    /// The accelerometer's velocity, ft/s.
178    InertialSpeed,
179    /// The accelerometer's distance traveled, ft.
180    InertialAltitude,
181    /// An event marker: 0, or at its event the raw altitude (or, for `MaxV` and `MaxA`, the
182    /// speed or acceleration).
183    Marker,
184    /// A column the reader doesn't know.
185    Unknown,
186}
187
188impl Kind {
189    /// What the column measures, and the factor that takes it to SI.
190    fn quantity(self) -> (Quantity, f64) {
191        match self {
192            Self::Altitude | Self::FilteredAltitude => (Quantity::AltitudeAboveGround, FOOT_M),
193            Self::RawSpeed | Self::FilteredSpeed | Self::InertialSpeed => (Quantity::Speed, FOOT_M),
194            Self::AccelerationG => (Quantity::Acceleration, STANDARD_GRAVITY_MPS2),
195            Self::AccelerationFtS2 => (Quantity::Acceleration, FOOT_M),
196            Self::InertialAltitude => (Quantity::InertialAltitude, FOOT_M),
197            Self::Marker | Self::Unknown => (Quantity::Unrecognized, 1.0),
198        }
199    }
200}
201
202/// The column names the reader knows, but for the time and the markers, as the manuals and the
203/// sample logs write them.
204const KNOWN: [(&str, Kind); 21] = [
205    ("Alt", Kind::Altitude),
206    ("FAlt", Kind::FilteredAltitude),
207    ("Filt_Alt", Kind::FilteredAltitude),
208    ("KAlt", Kind::FilteredAltitude),
209    ("Veloc", Kind::RawSpeed),
210    ("VRaw", Kind::RawSpeed),
211    ("FVeloc", Kind::FilteredSpeed),
212    ("KVeloc", Kind::FilteredSpeed),
213    ("VFilt", Kind::FilteredSpeed),
214    ("Accel", Kind::AccelerationG),
215    ("AccelG", Kind::AccelerationG),
216    ("FAccelG", Kind::AccelerationG),
217    ("AccelFPSS", Kind::AccelerationFtS2),
218    ("FAccelFPSS", Kind::AccelerationFtS2),
219    ("VAccel", Kind::InertialSpeed),
220    ("VelocAcc", Kind::InertialSpeed),
221    ("FAccelVel", Kind::InertialSpeed),
222    ("AltAccel", Kind::InertialAltitude),
223    ("AltAcc", Kind::InertialAltitude),
224    ("FAccelAlt", Kind::InertialAltitude),
225    // The first of the markers: every column after it is one too.
226    ("LDA", Kind::Marker),
227];
228
229/// The marker names the manuals and the sample logs write, `LDA` aside; `CH`, `BO#` and `Boost#`
230/// followed by digits are matched by their shape ([`kind_of`]).
231const MARKERS: [&str; 11] = [
232    "LowV", "Apogee", "N-O", "Drogue", "Main", "AUX", "BurnT", "CHA", "CHB", "MaxV", "MaxA",
233];
234
235/// What a column name is; `None` for one the reader doesn't know.
236fn kind_of(name: &str) -> Option<Kind> {
237    if let Some((_, kind)) = KNOWN.iter().find(|(known, _)| *known == name) {
238        return Some(*kind);
239    }
240    let numbered = |prefix: &str| {
241        name.strip_prefix(prefix)
242            .is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
243    };
244    (MARKERS.contains(&name) || numbered("CH") || numbered("BO#") || numbered("Boost#"))
245        .then_some(Kind::Marker)
246}
247
248/// Whether a header row is an Eggtimer's: comma-separated, `T` then `Alt`, then at least one more
249/// name the reader knows.
250fn is_header(line: &str) -> bool {
251    let mut names = line.split(',').map(str::trim);
252    names.next() == Some("T")
253        && names.next() == Some("Alt")
254        && names.any(|name| kind_of(name).is_some())
255}
256
257/// Whether the text is an Eggtimer's download, judged from its first line that isn't blank, never
258/// the file's name.
259pub(crate) fn recognizes(text: &str) -> bool {
260    numbered_lines(text)
261        .map(|(_, line)| line.trim())
262        .find(|line| !line.is_empty())
263        .is_some_and(is_header)
264}
265
266/// One column of the file, as the header names it.
267struct Column {
268    name: String,
269    kind: Kind,
270}
271
272impl Column {
273    /// Whether the column's name states its unit: `AccelG` and `FAccelG` name g, `AccelFPSS` and
274    /// `FAccelFPSS` ft/s². No other name the reader knows does.
275    fn names_its_unit(&self) -> bool {
276        match self.kind {
277            Kind::AccelerationG => self.name.ends_with('G'),
278            Kind::AccelerationFtS2 => self.name.ends_with("FPSS"),
279            _ => false,
280        }
281    }
282}
283
284/// Reads an Eggtimer's flight data from its text.
285///
286/// # Errors
287///
288/// [`LogError::NotThisFormat`] if the first line that isn't blank isn't an Eggtimer header row
289/// (`T`, `Alt` and a name this reader knows); [`LogError::Syntax`] for a header column with no
290/// name, a row longer than the header, a cell that isn't a number, an empty time, a time that
291/// doesn't increase, or rows that hold too few of the cells the header names;
292/// [`LogError::NoData`] if there are no rows.
293pub fn read(text: &str) -> Result<FlightLog, LogError> {
294    let mut lines = numbered_lines(text).filter(|(_, line)| !line.trim().is_empty());
295    let Some((header_line, header)) = lines.next() else {
296        return Err(LogError::NotThisFormat {
297            format: FORMAT,
298            message: "the file is empty".to_owned(),
299        });
300    };
301    if !is_header(header) {
302        return Err(LogError::NotThisFormat {
303            format: FORMAT,
304            message: format!(
305                "its first line, {:?}, isn't an Eggtimer header row: `T`, `Alt` and the \
306                 Eggtimer's columns, such as `Veloc` or `LDA`",
307                short(header)
308            ),
309        });
310    }
311    let mut notes = Notes::default();
312    let columns = header_columns(header, header_line, &mut notes)?;
313
314    // Each row's cells as numbers, `NaN` where empty; a row leaves out the cells past its end.
315    let mut rows: Vec<(usize, Vec<f64>)> = Vec::new();
316    let mut cells_written = 0_usize;
317    let mut blank_rows = 0_usize;
318    for (number, line) in lines {
319        let cells: Vec<&str> = line.split(',').map(str::trim).collect();
320        if cells.iter().all(|cell| cell.is_empty()) {
321            blank_rows += 1;
322            continue;
323        }
324        if cells.len() > columns.len() + 1 {
325            return Err(syntax(
326                number,
327                format!(
328                    "the row has {} cells and the header names {}",
329                    cells.len(),
330                    columns.len() + 1
331                ),
332            ));
333        }
334        let values = row_values(&cells, &columns, number)?;
335        cells_written = cells_written.saturating_add(cells.len());
336        rows.push((number, values));
337        if crate::log::too_sparse(columns.len() + 1, rows.len(), cells_written) {
338            return Err(syntax(
339                number,
340                format!(
341                    "{} rows hold {cells_written} of the {} cells the header's {} columns name: \
342                     too few for the header to be this file's",
343                    rows.len(),
344                    rows.len().saturating_mul(columns.len() + 1),
345                    columns.len() + 1
346                ),
347            ));
348        }
349    }
350    if rows.is_empty() {
351        return Err(LogError::NoData { format: FORMAT });
352    }
353    build(columns, &rows, blank_rows, notes)
354}
355
356/// The header's columns after the time, each with what it is. The time is `T` and the altitude
357/// `Alt`, checked by [`is_header`].
358fn header_columns(header: &str, line: usize, notes: &mut Notes) -> Result<Vec<Column>, LogError> {
359    let mut columns = Vec::new();
360    let mut after_lda = false;
361    for (index, name) in header.split(',').map(str::trim).enumerate().skip(1) {
362        if name.is_empty() {
363            return Err(syntax(
364                line,
365                format!("the header's column {} has no name", index + 1),
366            ));
367        }
368        let kind = match kind_of(name) {
369            Some(kind) => kind,
370            None if after_lda => {
371                notes.push(format!(
372                    "the column {:?} isn't one this reader knows; it comes after `LDA`, where the \
373                     manuals put only event markers, and was read as one",
374                    short(name)
375                ));
376                Kind::Marker
377            }
378            None => {
379                notes.push(format!(
380                    "the column {:?} isn't one this reader knows, and was kept as written",
381                    short(name)
382                ));
383                Kind::Unknown
384            }
385        };
386        after_lda |= name == "LDA";
387        columns.push(Column {
388            name: name.to_owned(),
389            kind,
390        });
391    }
392    Ok(columns)
393}
394
395/// A row's cells as numbers in the file's units, the time first; `NaN` for an empty cell. The
396/// time must be there.
397fn row_values(cells: &[&str], columns: &[Column], line: usize) -> Result<Vec<f64>, LogError> {
398    let mut values = Vec::with_capacity(cells.len());
399    for (index, cell) in cells.iter().enumerate() {
400        if cell.is_empty() {
401            if index == 0 {
402                return Err(syntax(
403                    line,
404                    "the time, the row's first cell, is empty".to_owned(),
405                ));
406            }
407            values.push(f64::NAN);
408            continue;
409        }
410        let Some(value) = number(cell, false) else {
411            let under = match index.checked_sub(1).and_then(|at| columns.get(at)) {
412                Some(column) => short(&column.name),
413                None => "T".to_owned(),
414            };
415            return Err(syntax(
416                line,
417                format!(
418                    "{:?} in cell {}, under {under:?}, isn't a number",
419                    short(cell),
420                    index + 1
421                ),
422            ));
423        };
424        values.push(value);
425    }
426    Ok(values)
427}
428
429/// The record from the rows read: the clock, every channel, the events and the notes.
430fn build(
431    mut columns: Vec<Column>,
432    rows: &[(usize, Vec<f64>)],
433    blank_rows: usize,
434    mut notes: Notes,
435) -> Result<FlightLog, LogError> {
436    // The time, strictly increasing, in the file's unit.
437    let mut written_time = Vec::with_capacity(rows.len());
438    for (number, values) in rows {
439        // Invariant: `row_values` refuses a row whose time is empty, and a stored row has a cell.
440        let time = values.first().copied().unwrap_or(f64::NAN);
441        if let Some(&last) = written_time.last()
442            && time <= last
443        {
444            return Err(syntax(
445                *number,
446                format!("the time {time} comes at or before the one above it, {last}"),
447            ));
448        }
449        written_time.push(time);
450    }
451    let mut unit_notes = Vec::new();
452    let units_per_second = time_unit(&written_time, &mut unit_notes);
453    let time_s: Vec<f64> = written_time
454        .iter()
455        .map(|time| time / units_per_second)
456        .collect();
457
458    // Each column's values as written; a row that stops short leaves gaps.
459    let written: Vec<Vec<f64>> = (1..=columns.len())
460        .map(|index| {
461            rows.iter()
462                .map(|(_, values)| values.get(index).copied().unwrap_or(f64::NAN))
463                .collect()
464        })
465        .collect();
466    let step_s: Vec<f64> = time_s.windows(2).map(|pair| pair[1] - pair[0]).collect();
467    let checks = speed_checks(&mut columns, &written, &step_s, &mut notes);
468
469    // One note per name decided, so a header repeating a name doesn't repeat its note.
470    unit_notes.push(altitude_note(&columns, &written));
471    let mut noted: Vec<&str> = Vec::new();
472    for (column, check) in columns.iter().zip(&checks) {
473        if noted.contains(&column.name.as_str()) {
474            continue;
475        }
476        if let Some(note) = unit_note(column, *check, &columns) {
477            noted.push(&column.name);
478            unit_notes.push(note);
479        }
480    }
481    unit_notes.extend(gravity_note(&columns, &written, &written_time));
482    unit_notes.extend(marker_note(&columns));
483
484    let mut log = FlightLog {
485        format: LogFormat::EggtimerCsv,
486        logger: "Eggtimer".to_owned(),
487        serial_number: None,
488        firmware: None,
489        flight_number: None,
490        stated: Stated::default(),
491        time_s: time_s.clone(),
492        altitude_m: Vec::new(),
493        vertical_speed_m_s: None,
494        temperature_k: None,
495        battery_v: None,
496        channels: Vec::with_capacity(columns.len()),
497        events: Vec::new(),
498        notes: unit_notes,
499    };
500    for (column, values) in columns.iter().zip(&written) {
501        let gaps = values.iter().filter(|value| value.is_nan()).count();
502        if gaps > 0 {
503            notes.push(format!(
504                "the column {:?} leaves {gaps} cells empty: they are gaps",
505                short(&column.name)
506            ));
507        }
508        let (quantity, factor) = column.kind.quantity();
509        log.channels.push(Channel {
510            column: column.name.clone(),
511            quantity,
512            time_s: time_s.clone(),
513            values: values.iter().map(|value| value * factor).collect(),
514        });
515    }
516    log.events = events(&columns, &written, &time_s, &mut notes);
517
518    // The primary track: `Alt`, and the barometer's raw speed, or its filtered one. Invariant:
519    // `Alt` is the header's name after `T` (`is_header`), so it is the first channel.
520    log.altitude_m = log
521        .channels
522        .first()
523        .map(|channel| channel.values.clone())
524        .unwrap_or_default();
525    let speed = |kind: Kind| {
526        columns
527            .iter()
528            .zip(&log.channels)
529            .find(|(column, _)| column.kind == kind)
530            .map(|(column, channel)| (column.name.as_str(), channel.values.clone()))
531    };
532    if let Some((_, values)) = speed(Kind::RawSpeed) {
533        log.vertical_speed_m_s = Some(values);
534    } else if let Some((name, values)) = speed(Kind::FilteredSpeed) {
535        log.notes.push(format!(
536            "the file has no raw velocity, `Veloc`, read as a speed; the vertical speed is its \
537             filtered barometric {name:?}"
538        ));
539        log.vertical_speed_m_s = Some(values);
540    }
541    if blank_rows > 0 {
542        log.notes.push(format!(
543            "{blank_rows} rows hold only empty cells, and were skipped"
544        ));
545    }
546    log.notes.extend(notes.into_vec());
547    Ok(log)
548}
549
550/// What the rows say of a speed: the column of the altitude it should be the change of, and on
551/// how many of the rows after the first it is, of how many can be checked.
552#[derive(Debug, Clone, Copy)]
553struct SpeedCheck {
554    altitude: usize,
555    matching: usize,
556    rows: usize,
557}
558
559/// Each speed checked against the altitude it should be the change of: `Veloc` and `VRaw` against
560/// `Alt`, `FVeloc`, `KVeloc` and `VFilt` against the file's first filtered altitude. A speed that
561/// matches on fewer than [`SPEED_MATCH_SHARE`] of the rows checked is kept as written, with a
562/// note; the others get their check, `None` for a column that isn't a speed or has no altitude to
563/// check it against.
564fn speed_checks(
565    columns: &mut [Column],
566    written: &[Vec<f64>],
567    step_s: &[f64],
568    notes: &mut Notes,
569) -> Vec<Option<SpeedCheck>> {
570    let filtered = columns
571        .iter()
572        .position(|column| column.kind == Kind::FilteredAltitude);
573    let mut checks = vec![None; columns.len()];
574    for (index, check) in checks.iter_mut().enumerate() {
575        // `Alt` is the first column: the header's name after `T` (`is_header`).
576        let altitude = match columns.get(index).map(|column| column.kind) {
577            Some(Kind::RawSpeed) => Some(0),
578            Some(Kind::FilteredSpeed) => filtered,
579            _ => None,
580        };
581        let (Some(altitude), Some(speed)) = (altitude, written.get(index)) else {
582            continue;
583        };
584        let Some(altitude_values) = written.get(altitude) else {
585            continue;
586        };
587        let (matching, rows) = speed_matches(speed, altitude_values, step_s);
588        // Row counts are far below 2^52, where an `f64` holds every whole number.
589        let enough = rows == 0 || matching as f64 >= SPEED_MATCH_SHARE * rows as f64;
590        if enough {
591            *check = Some(SpeedCheck {
592                altitude,
593                matching,
594                rows,
595            });
596            continue;
597        }
598        let altitude_name = columns
599            .get(altitude)
600            .map(|column| short(&column.name))
601            .unwrap_or_default();
602        if let Some(column) = columns.get_mut(index) {
603            notes.push(format!(
604                "`{}` kept as written, not read as a speed: it is the change in \
605                 `{altitude_name}` over the step, within 1 % or 1 ft/s, in only {matching} of \
606                 {rows} rows after the first, under the 90 % the reader asks",
607                short(&column.name)
608            ));
609            column.kind = Kind::Unknown;
610        }
611    }
612    checks
613}
614
615/// On how many rows after the first a speed is its altitude's change over the step, within 1 %
616/// or 1 ft/s, of how many hold both and a finite change.
617fn speed_matches(speed: &[f64], altitude: &[f64], step_s: &[f64]) -> (usize, usize) {
618    let (mut rows, mut matching) = (0_usize, 0_usize);
619    for (row, step) in step_s.iter().enumerate() {
620        let (Some(&low), Some(&high), Some(&value)) =
621            (altitude.get(row), altitude.get(row + 1), speed.get(row + 1))
622        else {
623            continue;
624        };
625        let change = (high - low) / step;
626        if !(change.is_finite() && value.is_finite()) {
627            continue;
628        }
629        rows += 1;
630        if (value - change).abs() <= (SPEED_MATCH_RELATIVE * change.abs()).max(SPEED_MATCH_FT_S) {
631            matching += 1;
632        }
633    }
634    (matching, rows)
635}
636
637/// The events: each run of non-zero samples in a marker column, at its first sample, row by row
638/// and in the file's order within a row; past [`MAX_EVENTS_PER_MARKER`] in one column, the rest
639/// counted in a note. An event's name is the column's, cut as a note quotes it.
640fn events(
641    columns: &[Column],
642    written: &[Vec<f64>],
643    time_s: &[f64],
644    notes: &mut Notes,
645) -> Vec<Event> {
646    struct Marker<'a> {
647        name: String,
648        values: &'a [f64],
649        kept: usize,
650        left_out: usize,
651    }
652    let mut markers: Vec<Marker> = columns
653        .iter()
654        .zip(written)
655        .filter(|(column, _)| column.kind == Kind::Marker)
656        .map(|(column, values)| Marker {
657            name: short(&column.name),
658            values,
659            kept: 0,
660            left_out: 0,
661        })
662        .collect();
663    let marked = |value: f64| value != 0.0 && !value.is_nan();
664    let mut events = Vec::new();
665    for (row, time) in time_s.iter().enumerate() {
666        for marker in &mut markers {
667            let value = marker.values.get(row).copied().unwrap_or(f64::NAN);
668            let before = row
669                .checked_sub(1)
670                .and_then(|above| marker.values.get(above))
671                .is_some_and(|&above| marked(above));
672            if !marked(value) || before {
673                continue;
674            }
675            if marker.kept < MAX_EVENTS_PER_MARKER {
676                marker.kept += 1;
677                events.push(Event {
678                    time_s: *time,
679                    name: marker.name.clone(),
680                });
681            } else {
682                marker.left_out += 1;
683            }
684        }
685    }
686    for marker in markers.iter().filter(|marker| marker.left_out > 0) {
687        notes.push(format!(
688            "the marker {:?} rises {} more times than the {MAX_EVENTS_PER_MARKER} kept as events",
689            marker.name, marker.left_out
690        ));
691    }
692    events
693}
694
695/// The time's units in a second, 1 or 1,000, decided from the median step between rows, with the
696/// note that says how. Dividing by 1,000 keeps a whole number of milliseconds the nearest `f64`
697/// to its seconds, where multiplying by 0.001 can miss it by one step.
698fn time_unit(written: &[f64], unit_notes: &mut Vec<String>) -> f64 {
699    let mut steps: Vec<f64> = written.windows(2).map(|pair| pair[1] - pair[0]).collect();
700    if steps.is_empty() {
701        unit_notes.push(
702            "`T` read in seconds, assumed: the file holds one row, so there is no step between \
703             rows to tell seconds from milliseconds"
704                .to_owned(),
705        );
706        return 1.0;
707    }
708    steps.sort_by(f64::total_cmp);
709    // Invariant: `steps` isn't empty (above), so `middle` is a place in it, and so is the one
710    // before it when the count is even.
711    let middle = steps.len() / 2;
712    let median = if steps.len().is_multiple_of(2) {
713        (steps[middle - 1] + steps[middle]) / 2.0
714    } else {
715        steps[middle]
716    };
717    let (unit, per_second) = if median > MILLISECONDS_ABOVE {
718        ("milliseconds", 1000.0)
719    } else {
720        ("seconds", 1.0)
721    };
722    unit_notes.push(format!(
723        "`T` read in {unit}, from the step: the median step between rows is {}, and the reader \
724         reads one above 5 as milliseconds (at the manuals' 1 to 33 samples a second, a step is \
725         0.03 to 1 in seconds and 30 to 1,000 in milliseconds)",
726        plain(median, 4)
727    ));
728    per_second
729}
730
731/// The note saying how `Alt` was read in feet; for a file with the accelerometer in both g and
732/// ft/s², what the two say of each other.
733fn altitude_note(columns: &[Column], written: &[Vec<f64>]) -> String {
734    let mut note = "`Alt` read in feet, from the manual: the Classic and TRS manuals (p37) state \
735                    feet; the Quantum and Proton manuals give every height in feet and no \
736                    Eggtimer manual offers meters"
737        .to_owned();
738    let find = |names: &[&str]| {
739        columns
740            .iter()
741            .zip(written)
742            .find(|(column, _)| names.contains(&column.name.as_str()))
743    };
744    if let (Some((g_column, g)), Some((ft_column, ft_s2))) =
745        (find(&["AccelG", "Accel"]), find(&["AccelFPSS"]))
746    {
747        // The least-squares ratio of the two through zero.
748        let (mut product, mut square) = (0.0, 0.0);
749        for (g, ft_s2) in g.iter().zip(ft_s2) {
750            if g.is_finite() && ft_s2.is_finite() {
751                product += g * ft_s2;
752                square += g * g;
753            }
754        }
755        let ratio = product / square;
756        if ratio.is_finite() {
757            note.push_str(&format!(
758                "; the file's `{}` is {} times its `{}`, and one g is 32.17 ft/s²",
759                ft_column.name,
760                plain(ratio, 2),
761                g_column.name
762            ));
763        }
764    }
765    note
766}
767
768/// The note saying how a column other than `Alt` was read, `None` for one whose name states its
769/// unit, or that is kept as written. `check` is what the rows say of a speed.
770fn unit_note(column: &Column, check: Option<SpeedCheck>, columns: &[Column]) -> Option<String> {
771    let name = short(&column.name);
772    let evidence = |what: &str| match check {
773        Some(check) if check.rows > 0 => format!(
774            "it is the change in `{}` over the step, within 1 % or 1 ft/s, in {} of {} rows after \
775             the first, where the reader asks 90 %",
776            columns
777                .get(check.altitude)
778                .map(|altitude| short(&altitude.name))
779                .unwrap_or_default(),
780            check.matching,
781            check.rows
782        ),
783        Some(_) => format!("the file has one row, so no step to check it against {what} on"),
784        None => format!("the file has no {what} to check it against"),
785    };
786    let note = match column.kind {
787        Kind::FilteredAltitude => format!(
788            "`{name}` read in feet, from the manual: it is the logger's filtered `Alt` (Quantum \
789             p38, Proton p45), in the same unit"
790        ),
791        Kind::RawSpeed => format!(
792            "`{name}` read in feet per second, from the manual: the Classic and TRS manuals (p37) \
793             give the velocity in ft/sec, and the Proton's (p45) as the change in `Alt` over the \
794             step; {}",
795            evidence("`Alt`")
796        ),
797        Kind::FilteredSpeed => format!(
798            "`{name}` read in feet per second, from the manual: the Proton's manual (p46) gives \
799             it as the change in the filtered altitude over the step, and the Excel guide (p7) \
800             graphs the velocity in ft/sec; {}",
801            evidence("filtered altitude")
802        ),
803        Kind::AccelerationG if !column.names_its_unit() => format!(
804            "`{name}` read in g, from the manual: the Proton's manual (p45) gives the \
805             accelerometer's raw value in G's"
806        ),
807        Kind::AccelerationFtS2 if !column.names_its_unit() => format!(
808            "`{name}` read in feet per second squared, from the manual: the Proton's manual \
809             (p45) gives the accelerometer's data in ft/sec^2"
810        ),
811        Kind::InertialSpeed => format!(
812            "`{name}` read in feet per second, from the manual: the Proton's manual (p45) gives \
813             the accelerometer's velocity in ft/sec"
814        ),
815        Kind::InertialAltitude => format!(
816            "`{name}` read in feet, from the manual: the Proton's manual (p45) gives the \
817             accelerometer's distance traveled in ft"
818        ),
819        Kind::Altitude
820        | Kind::AccelerationG
821        | Kind::AccelerationFtS2
822        | Kind::Marker
823        | Kind::Unknown => return None,
824    };
825    Some(note)
826}
827
828/// The note saying whether the file's acceleration has gravity in it, judged from its first
829/// acceleration column on the pad: the median of its rows before the start of flight, where `T`
830/// is below 0 (the Proton's `T` counts from the start of flight, p45). Standing on the pad, an
831/// accelerometer that keeps gravity reads 1 g and one that takes it out 0. `None` for a file
832/// without one.
833fn gravity_note(columns: &[Column], written: &[Vec<f64>], written_time: &[f64]) -> Option<String> {
834    let ft_s2_per_g = STANDARD_GRAVITY_MPS2 / FOOT_M;
835    let mut accelerations = columns
836        .iter()
837        .zip(written)
838        .filter_map(|(column, values)| match column.kind {
839            Kind::AccelerationG => Some((column, values, 1.0)),
840            Kind::AccelerationFtS2 => Some((column, values, ft_s2_per_g)),
841            _ => None,
842        });
843    let (column, values, per_g) = accelerations.next()?;
844    let others = if accelerations.next().is_some() {
845        "; the file's other acceleration columns are the same accelerometer's"
846    } else {
847        ""
848    };
849    let name = short(&column.name);
850    let mut pad: Vec<f64> = written_time
851        .iter()
852        .zip(values)
853        .filter(|(time, value)| **time < 0.0 && value.is_finite())
854        .map(|(_, value)| value / per_g)
855        .collect();
856    let source = "the Proton's manual doesn't say, and its release notes (2.01C, p8) have it \
857                  calibrate the accelerometer as it is armed, standing up";
858    if pad.is_empty() {
859        return Some(format!(
860            "`{name}` may or may not hold gravity, not known: the file has no rows before the \
861             start of flight (`T` below 0) to read the pad on; {source}{others}"
862        ));
863    }
864    pad.sort_by(f64::total_cmp);
865    // Invariant: `pad` isn't empty (above), so `middle` is a place in it, and so is the one
866    // before it when the count is even.
867    let middle = pad.len() / 2;
868    let median = if pad.len().is_multiple_of(2) {
869        (pad[middle - 1] + pad[middle]) / 2.0
870    } else {
871        pad[middle]
872    };
873    let reading = format!(
874        "its median over the {} rows before the start of flight (`T` below 0) is {} g, where an \
875         accelerometer that keeps gravity reads 1 g standing still and one that takes it out 0; \
876         {source}{others}",
877        pad.len(),
878        plain(median, 2)
879    );
880    Some(if median.abs() < GRAVITY_READING_G {
881        format!("`{name}` has gravity taken out, from the pad: {reading}")
882    } else if (median - 1.0).abs() < GRAVITY_READING_G {
883        format!("`{name}` holds gravity, from the pad: {reading}")
884    } else {
885        format!("`{name}` may or may not hold gravity, not known: {reading}")
886    })
887}
888
889/// The note listing the marker columns, kept as written; `None` for a file without one.
890fn marker_note(columns: &[Column]) -> Option<String> {
891    let markers: Vec<String> = columns
892        .iter()
893        .filter(|column| column.kind == Kind::Marker)
894        .map(|column| format!("`{}`", short(&column.name)))
895        .take(MAX_LISTED + 1)
896        .collect();
897    let count = columns
898        .iter()
899        .filter(|column| column.kind == Kind::Marker)
900        .count();
901    if count == 0 {
902        return None;
903    }
904    let speeds = if columns
905        .iter()
906        .any(|column| column.name == "MaxV" || column.name == "MaxA")
907    {
908        ", and the Proton's release notes (1.02N, p13) the top speed, ft/s, in `MaxV` and the \
909         top acceleration, ft/s², in `MaxA`"
910    } else {
911        ""
912    };
913    Some(format!(
914        "the marker columns, kept as written: {}; each is 0 but at the sample that marks its \
915         event, where the Quantum's and Proton's manuals (p38, p46) put the raw altitude, in \
916         feet{speeds}",
917        list(&markers, count)
918    ))
919}
920
921/// Names joined as `a`, `a and b`, or `a, b and c`; of more than [`MAX_LISTED`], the first few
922/// and a count of the rest. `count` is how many there are; `names` may hold only the first.
923fn list(names: &[String], count: usize) -> String {
924    if count > MAX_LISTED {
925        let shown = MAX_LISTED - 1;
926        return format!(
927            "{} and {} more columns",
928            names.get(..shown).unwrap_or(names).join(", "),
929            count - shown
930        );
931    }
932    match names {
933        [] => String::new(),
934        [only] => only.clone(),
935        [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
936    }
937}
938
939/// A number as a note writes it: rounded to `decimals` places, trailing zeros dropped.
940fn plain(value: f64, decimals: usize) -> String {
941    let text = format!("{value:.decimals$}");
942    if text.contains('.') {
943        text.trim_end_matches('0').trim_end_matches('.').to_owned()
944    } else {
945        text
946    }
947}
948
949fn syntax(line: usize, message: String) -> LogError {
950    LogError::Syntax {
951        format: FORMAT,
952        line,
953        message,
954    }
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960
961    const TRS: &str = "T,Alt,Veloc,FAlt,FVeloc,LDA,LowV,Apogee,N-O,Drogue,Main";
962
963    fn close(a: f64, b: f64) -> bool {
964        (a - b).abs() <= 1e-12 * b.abs().max(1.0)
965    }
966
967    /// The text before a note's first `: `, which must be the same for every file decided the
968    /// same way.
969    fn prefix(note: &str) -> &str {
970        note.split(": ").next().unwrap_or(note)
971    }
972
973    #[test]
974    fn recognizes_the_eggtimer_headers_and_no_others() {
975        for header in [
976            TRS,
977            "T,Alt,Veloc,Filt_Alt,FVeloc,LDA,LowV,Apogee,N-O,Drogue,Main",
978            "T,Alt,FAlt,FVeloc,LDA,LowV,Apogee,N-O,Drogue,Main,AUX",
979            "T,Alt,Veloc,KAlt,KVeloc,LDA,BurnT,LowV,Apogee,N-O,CHA,CHB",
980            "T, Alt, VRaw, VFilt",
981            "T,Alt,Veloc,AccelG,AccelFPSS,VAccel,AltAccel,FAlt,FVeloc,FAccelG,FAccelFPSS,\
982             FAccelVel,FAccelAlt,LDA,MaxV,MaxA,BO#1,LowV,Apogee,N-O,CH1,CH2",
983            "T,Alt,Apogee",
984        ] {
985            assert!(recognizes(&format!("\r\n{header}\r\n0,0\r\n")), "{header}");
986        }
987        for text in [
988            "",
989            "T,Alt\n",
990            "T,Alt,Foo\n",
991            "T\tAlt\tVeloc\n",
992            "t,alt,veloc\n",
993            "Time,Altitude,Velocity\n",
994            "Time,Altitude,Pressure,Velocity,Temperature,Events,Voltages\n",
995            "Alt,T,Veloc\n",
996            "# T,Alt,Veloc\n",
997        ] {
998            assert!(!recognizes(text), "{text:?}");
999        }
1000        assert_eq!(kind_of("CH12"), Some(Kind::Marker));
1001        assert_eq!(kind_of("Boost#3"), Some(Kind::Marker));
1002        assert_eq!(kind_of("CH"), None);
1003        assert_eq!(kind_of("BO#x"), None);
1004    }
1005
1006    /// The TRS layout in seconds: feet and feet per second to SI, the markers kept as written and
1007    /// read as events, the raw barometric velocity the vertical speed.
1008    #[test]
1009    fn reads_the_layout_in_seconds() {
1010        let text = format!(
1011            "{TRS}\n\
1012             0,0,0,0,0,0,0,0,0,0,0\n\
1013             0.05,10,200,2,40,0,0,0,0,0,0\n\
1014             0.1,25,300,7,100,25,0,0,0,0,0\n\
1015             0.15,30,100,13.5,130,0,0,30,0,0,0\n\
1016             0.2,29,-20,18,90,0,0,0,29,29,0\n"
1017        );
1018        let log = read(&text).unwrap();
1019        assert_eq!(log.format, LogFormat::EggtimerCsv);
1020        assert_eq!(log.logger, "Eggtimer");
1021        assert_eq!(log.time_s, [0.0, 0.05, 0.1, 0.15, 0.2]);
1022        assert!(close(log.altitude_m[2], 25.0 * 0.3048));
1023        let speed = log.vertical_speed_m_s.as_deref().unwrap();
1024        assert!(close(speed[1], 200.0 * 0.3048));
1025        assert_eq!(log.channels.len(), 10);
1026        assert_eq!(log.channels[2].quantity, Quantity::AltitudeAboveGround);
1027        assert!(close(log.channels[2].values[3], 13.5 * 0.3048));
1028        assert_eq!(log.channels[3].quantity, Quantity::Speed);
1029        // The markers as written, in feet: 0 is "no event".
1030        assert_eq!(log.channels[4].column, "LDA");
1031        assert_eq!(log.channels[4].quantity, Quantity::Unrecognized);
1032        assert_eq!(log.channels[4].values, [0.0, 0.0, 25.0, 0.0, 0.0]);
1033        let events: Vec<(f64, &str)> = log
1034            .events
1035            .iter()
1036            .map(|event| (event.time_s, event.name.as_str()))
1037            .collect();
1038        assert_eq!(
1039            events,
1040            [
1041                (0.1, "LDA"),
1042                (0.15, "Apogee"),
1043                (0.2, "N-O"),
1044                (0.2, "Drogue")
1045            ]
1046        );
1047        assert_eq!(log.notes.len(), 6, "{:?}", log.notes);
1048        assert_eq!(
1049            log.notes[0],
1050            "`T` read in seconds, from the step: the median step between rows is 0.05, and the \
1051             reader reads one above 5 as milliseconds (at the manuals' 1 to 33 samples a second, \
1052             a step is 0.03 to 1 in seconds and 30 to 1,000 in milliseconds)"
1053        );
1054        assert_eq!(
1055            log.notes[1],
1056            "`Alt` read in feet, from the manual: the Classic and TRS manuals (p37) state feet; \
1057             the Quantum and Proton manuals give every height in feet and no Eggtimer manual \
1058             offers meters"
1059        );
1060        assert_eq!(
1061            log.notes[2],
1062            "`Veloc` read in feet per second, from the manual: the Classic and TRS manuals (p37) \
1063             give the velocity in ft/sec, and the Proton's (p45) as the change in `Alt` over the \
1064             step; it is the change in `Alt` over the step, within 1 % or 1 ft/s, in 4 of 4 rows \
1065             after the first, where the reader asks 90 %"
1066        );
1067        assert!(log.notes[3].starts_with("`FAlt` read in feet, from the manual: "));
1068        assert!(
1069            log.notes[4].starts_with("`FVeloc` read in feet per second, from the manual: ")
1070                && log.notes[4].contains("the change in `FAlt` over the step")
1071                && log.notes[4].contains("in 4 of 4 rows after the first"),
1072            "{}",
1073            log.notes[4]
1074        );
1075        assert_eq!(
1076            log.notes[5],
1077            "the marker columns, kept as written: `LDA`, `LowV`, `Apogee`, `N-O`, `Drogue` and \
1078             `Main`; each is 0 but at the sample that marks its event, where the Quantum's \
1079             and Proton's manuals (p38, p46) put the raw altitude, in feet"
1080        );
1081    }
1082
1083    /// A median step above 5 is milliseconds; at 5 or below, seconds. The notes' text before the
1084    /// first `: ` is the same for files decided the same way.
1085    #[test]
1086    fn the_step_decides_the_time_unit() {
1087        let rows = |step: f64| -> String {
1088            (0..4)
1089                .map(|i| format!("{},{},{}\n", f64::from(i) * step, i * 10, 0))
1090                .collect()
1091        };
1092        let millis = read(&format!("T,Alt,LDA\n{}", rows(50.0))).unwrap();
1093        assert_eq!(millis.time_s, [0.0, 0.05, 0.1, 0.15]);
1094        assert!(millis.notes[0].starts_with("`T` read in milliseconds, from the step: "));
1095        assert!(millis.notes[0].contains("median step between rows is 50,"));
1096        let slow = read(&format!("T,Alt,LDA\n{}", rows(1000.0))).unwrap();
1097        assert_eq!(prefix(&slow.notes[0]), prefix(&millis.notes[0]));
1098        assert!(slow.notes[0].contains("is 1000,"));
1099
1100        let at_the_line = read(&format!("T,Alt,LDA\n{}", rows(5.0))).unwrap();
1101        assert_eq!(at_the_line.time_s, [0.0, 5.0, 10.0, 15.0]);
1102        let above = read(&format!("T,Alt,LDA\n{}", rows(5.01))).unwrap();
1103        assert!(close(above.time_s[1], 0.00501));
1104        let seconds = read(&format!("T,Alt,LDA\n{}", rows(0.5))).unwrap();
1105        assert_eq!(
1106            prefix(&seconds.notes[0]),
1107            "`T` read in seconds, from the step"
1108        );
1109        assert_eq!(prefix(&at_the_line.notes[0]), prefix(&seconds.notes[0]));
1110
1111        // The median, not the mean: a long pause doesn't turn seconds into milliseconds.
1112        let paused = read("T,Alt,LDA\n0,0,0\n0.05,1,0\n0.1,2,0\n100,3,0\n").unwrap();
1113        assert_eq!(paused.time_s, [0.0, 0.05, 0.1, 100.0]);
1114
1115        let one = read("T,Alt,LDA\n120,4,0\n").unwrap();
1116        assert_eq!(one.time_s, [120.0]);
1117        assert!(one.notes[0].starts_with("`T` read in seconds, assumed: "));
1118    }
1119
1120    /// The Proton's columns: g and ft/s² to m/s², the accelerometer's velocity and distance, the
1121    /// two accelerations' ratio in the altitude's note; `MaxV` and `MaxA` markers as written.
1122    #[test]
1123    fn reads_the_accelerometer_columns() {
1124        let text = "T,Alt,Veloc,AccelG,AccelFPSS,VAccel,AltAccel,LDA,MaxV,MaxA,CH1\r\
1125                    -0.1,0,0,0,0,0,0,0,0,0,0\r\
1126                    0,1,10,2,64.4,0,0,0,0,0,0\r\
1127                    0.1,12,110,10,321.7,40,2,0,0,321,0\r\
1128                    0.2,40,280,5,160.9,80,9,40,80,0,0\r";
1129        let log = read(text).unwrap();
1130        assert_eq!(log.time_s, [-0.1, 0.0, 0.1, 0.2]);
1131        let quantities: Vec<Quantity> = log.channels.iter().map(|c| c.quantity).collect();
1132        assert_eq!(
1133            quantities,
1134            [
1135                Quantity::AltitudeAboveGround,
1136                Quantity::Speed,
1137                Quantity::Acceleration,
1138                Quantity::Acceleration,
1139                Quantity::Speed,
1140                Quantity::InertialAltitude,
1141                Quantity::Unrecognized,
1142                Quantity::Unrecognized,
1143                Quantity::Unrecognized,
1144                Quantity::Unrecognized,
1145            ]
1146        );
1147        assert!(close(log.channels[2].values[2], 10.0 * 9.806_65));
1148        assert!(close(log.channels[3].values[2], 321.7 * 0.3048));
1149        assert!(close(log.channels[4].values[3], 80.0 * 0.3048));
1150        assert!(close(log.channels[5].values[3], 9.0 * 0.3048));
1151        assert_eq!(log.channels[7].values, [0.0, 0.0, 0.0, 80.0]);
1152        let names: Vec<&str> = log.events.iter().map(|event| event.name.as_str()).collect();
1153        assert_eq!(names, ["MaxA", "LDA", "MaxV"]);
1154        assert!(
1155            log.notes[1].ends_with(
1156                "; the file's `AccelFPSS` is 32.17 times its `AccelG`, and one g is 32.17 ft/s²"
1157            ),
1158            "{}",
1159            log.notes[1]
1160        );
1161        // `AccelG` and `AccelFPSS` name their units, so get no unit note.
1162        assert!(log.notes[2].starts_with("`Veloc` read in feet per second, from the manual: "));
1163        assert!(log.notes[3].starts_with("`VAccel` read in feet per second, from the manual: "));
1164        assert!(log.notes[4].starts_with("`AltAccel` read in feet, from the manual: "));
1165        assert_eq!(
1166            log.notes[5],
1167            "`AccelG` has gravity taken out, from the pad: its median over the 1 rows before the \
1168             start of flight (`T` below 0) is 0 g, where an accelerometer that keeps gravity reads \
1169             1 g standing still and one that takes it out 0; the Proton's manual doesn't say, and \
1170             its release notes (2.01C, p8) have it calibrate the accelerometer as it is armed, \
1171             standing up; the file's other acceleration columns are the same accelerometer's"
1172        );
1173        assert!(
1174            log.notes[6].starts_with(
1175                "the marker columns, kept as written: `LDA`, `MaxV`, `MaxA` and `CH1`; "
1176            ) && log.notes[6].ends_with(
1177                ", and the Proton's release notes (1.02N, p13) the top speed, ft/s, in `MaxV` \
1178                     and the top acceleration, ft/s², in `MaxA`"
1179            ),
1180            "{}",
1181            log.notes[6]
1182        );
1183        assert_eq!(log.notes.len(), 7, "{:?}", log.notes);
1184    }
1185
1186    /// The Proton manual's `Accel` doesn't name its unit, so it gets a note, and is the one the
1187    /// altitude's note and the gravity note name, as written. A pad reading near 1 g holds
1188    /// gravity; one near neither, or no rows before the start of flight, leaves it not known.
1189    #[test]
1190    fn the_acceleration_unit_and_gravity_are_noted() {
1191        let log = read(
1192            "T,Alt,Accel,AccelFPSS,LDA
1193-0.1,0,1,32.2,0
11940,0,1.1,35.4,0
11950.1,3,6,193,3
1196",
1197        )
1198        .unwrap();
1199        let notes = log.notes.join("\n");
1200        assert!(
1201            notes.contains(
1202                "`Accel` read in g, from the manual: the Proton's manual (p45) gives the \
1203                 accelerometer's raw value in G's"
1204            ),
1205            "{notes}"
1206        );
1207        assert!(
1208            notes.contains("the file's `AccelFPSS` is 32.17 times its `Accel`"),
1209            "{notes}"
1210        );
1211        assert!(
1212            notes.contains(
1213                "`Accel` holds gravity, from the pad: its median over the 1 rows before the start \
1214                 of flight (`T` below 0) is 1 g"
1215            ),
1216            "{notes}"
1217        );
1218        assert!(!notes.contains("AccelG"), "{notes}");
1219        assert!(close(log.channels[1].values[0], 9.806_65));
1220
1221        let unsure = read(
1222            "T,Alt,AccelG,LDA
1223-0.2,0,3,0
1224-0.1,0,3,0
12250,1,4,0
1226",
1227        )
1228        .unwrap();
1229        assert!(
1230            unsure.notes.iter().any(|note| note.starts_with(
1231                "`AccelG` may or may not hold gravity, not known: its median over the 2 rows \
1232                 before the start of flight (`T` below 0) is 3 g"
1233            )),
1234            "{:?}",
1235            unsure.notes
1236        );
1237        let no_pad = read(
1238            "T,Alt,FAccelFPSS,LDA
12390,0,0,0
12400.1,1,64,0
1241",
1242        )
1243        .unwrap();
1244        assert!(
1245            no_pad.notes.iter().any(|note| note.starts_with(
1246                "`FAccelFPSS` may or may not hold gravity, not known: the file has no rows before \
1247                 the start of flight"
1248            )),
1249            "{:?}",
1250            no_pad.notes
1251        );
1252        // A pad reading in ft/s² is judged in g.
1253        let in_ft = read(
1254            "T,Alt,AccelFPSS,LDA
1255-0.1,0,32.2,0
12560,1,40,0
1257",
1258        )
1259        .unwrap();
1260        assert!(
1261            in_ft
1262                .notes
1263                .iter()
1264                .any(|note| note.starts_with("`AccelFPSS` holds gravity, from the pad: ")),
1265            "{:?}",
1266            in_ft.notes
1267        );
1268    }
1269
1270    /// A speed that isn't its altitude's change over the step on 90 % of the rows is kept as
1271    /// written, and the vertical speed falls to the filtered one; at 90 % it is read.
1272    #[test]
1273    fn a_speed_that_isnt_its_altitudes_change_is_kept_as_written() {
1274        // Every `Veloc` is 1,000 times the change: a clock in milliseconds read as seconds would
1275        // do this.
1276        let text = "T,Alt,Veloc,FAlt,FVeloc,LDA
12770,0,0,0,0,0
12781,10,10000,5,5,0
12792,30,20000,15,10,0
1280";
1281        let log = read(text).unwrap();
1282        assert_eq!(log.channels[1].column, "Veloc");
1283        assert_eq!(log.channels[1].quantity, Quantity::Unrecognized);
1284        assert_eq!(log.channels[1].values, [0.0, 10000.0, 20000.0]);
1285        assert_eq!(log.channels[3].quantity, Quantity::Speed);
1286        assert_eq!(
1287            log.vertical_speed_m_s.as_deref(),
1288            Some(&[0.0, 5.0 * 0.3048, 10.0 * 0.3048][..])
1289        );
1290        let notes = log.notes.join("\n");
1291        assert!(
1292            notes.contains(
1293                "`Veloc` kept as written, not read as a speed: it is the change in `Alt` over the \
1294                 step, within 1 % or 1 ft/s, in only 0 of 2 rows after the first, under the 90 % \
1295                 the reader asks"
1296            ),
1297            "{notes}"
1298        );
1299        assert!(!notes.contains("`Veloc` read in"), "{notes}");
1300        assert!(notes.contains("the file has no raw velocity, `Veloc`, read as a speed"));
1301
1302        // 9 of 10 rows match: read; 8 of 10: kept as written.
1303        let rows = |misses: usize| -> String {
1304            (0..=10)
1305                .map(|i| {
1306                    let speed = if i > 0 && i <= misses {
1307                        99
1308                    } else {
1309                        10 * usize::from(i > 0)
1310                    };
1311                    format!("{i},{},{speed},0\n", 10 * i)
1312                })
1313                .collect()
1314        };
1315        let nine = read(&format!("T,Alt,Veloc,LDA\n{}", rows(1))).unwrap();
1316        assert_eq!(nine.channels[1].quantity, Quantity::Speed);
1317        assert!(
1318            nine.notes
1319                .iter()
1320                .any(|note| note.contains("in 9 of 10 rows after the first"))
1321        );
1322        let eight = read(&format!("T,Alt,Veloc,LDA\n{}", rows(2))).unwrap();
1323        assert_eq!(eight.channels[1].quantity, Quantity::Unrecognized);
1324        assert!(eight.vertical_speed_m_s.is_none());
1325    }
1326
1327    /// A header repeating a name gets that name's unit note once.
1328    #[test]
1329    fn a_repeated_name_is_noted_once() {
1330        let log = read(
1331            "T,Alt,Veloc,Veloc,LDA
13320,0,0,0,0
13330.1,1,10,10,0
1334",
1335        )
1336        .unwrap();
1337        let veloc = log
1338            .notes
1339            .iter()
1340            .filter(|note| note.starts_with("`Veloc` read in"))
1341            .count();
1342        assert_eq!(veloc, 1, "{:?}", log.notes);
1343        assert_eq!(log.channels[2].quantity, Quantity::Speed);
1344    }
1345
1346    /// A marker column with a very long name rising on every other row gives at most
1347    /// [`MAX_EVENTS_PER_MARKER`] events, each naming it cut short, and one note counting the rest:
1348    /// the record stays near the file's size.
1349    #[test]
1350    fn a_long_marker_name_rising_often_is_bounded() {
1351        let name = "X".repeat(100_000);
1352        let rows: String = (0..4000).map(|i| format!("{i},0,0,{}\n", i % 2)).collect();
1353        let text = format!("T,Alt,LDA,{name}\n{rows}");
1354        let log = read(&text).unwrap();
1355        assert_eq!(log.events.len(), MAX_EVENTS_PER_MARKER);
1356        assert!(
1357            log.events
1358                .iter()
1359                .all(|event| event.name.chars().count() <= 80)
1360        );
1361        assert!(log.events[0].name.ends_with('…'));
1362        assert_eq!(log.events[15].time_s, 31.0);
1363        let notes = log.notes.join("\n");
1364        assert!(
1365            notes.contains(&format!(
1366                "rises {} more times than the 16 kept as events",
1367                2000 - MAX_EVENTS_PER_MARKER
1368            )),
1369            "{}",
1370            short(&notes)
1371        );
1372        let bytes: usize = log.notes.iter().map(String::len).sum::<usize>()
1373            + log
1374                .events
1375                .iter()
1376                .map(|event| event.name.len())
1377                .sum::<usize>();
1378        assert!(bytes < 10_000, "{bytes} bytes of notes and event names");
1379        // The channel keeps the name as written.
1380        assert_eq!(log.channels[2].column.len(), 100_000);
1381    }
1382
1383    /// The marker note lists at most a few columns, and counts the rest.
1384    #[test]
1385    fn the_marker_note_stays_short() {
1386        let markers: String = (1..=40).map(|i| format!(",CH{i}")).collect();
1387        let text = format!(
1388            "T,Alt,LDA{markers}
13890,0{}
1390",
1391            ",0".repeat(41)
1392        );
1393        let log = read(&text).unwrap();
1394        let note = log
1395            .notes
1396            .iter()
1397            .find(|note| note.starts_with("the marker columns, kept as written: "))
1398            .unwrap();
1399        assert!(
1400            note.starts_with(
1401                "the marker columns, kept as written: `LDA`, `CH1`, `CH2`, `CH3`, `CH4` and 36 more \
1402                 columns; "
1403            ),
1404            "{note}"
1405        );
1406        assert!(
1407            read(
1408                "T,Alt,Veloc
14090,0,0
1410"
1411            )
1412            .unwrap()
1413            .notes
1414            .iter()
1415            .all(|note| !note.contains("marker columns"))
1416        );
1417    }
1418
1419    /// A marker held over several samples, or to the end, is one event; two runs are two.
1420    #[test]
1421    fn each_run_of_a_marker_is_one_event() {
1422        let text = "T,Alt,Veloc,Apogee,Main\n\
1423                    0,100,0,0,0\n0.5,101,2,101,0\n1,100,-2,101,0\n1.5,99,-2,0,0\n\
1424                    2,98,-2,98,98\n2.5,97,-2,0,98\n";
1425        let log = read(text).unwrap();
1426        let events: Vec<(f64, &str)> = log
1427            .events
1428            .iter()
1429            .map(|event| (event.time_s, event.name.as_str()))
1430            .collect();
1431        assert_eq!(events, [(0.5, "Apogee"), (2.0, "Apogee"), (2.0, "Main")]);
1432    }
1433
1434    /// Without a raw velocity, the filtered one is the vertical speed, with a note; without a
1435    /// filtered altitude, its unit is the manual's.
1436    #[test]
1437    fn the_filtered_velocity_stands_in_for_the_raw_one() {
1438        let log = read("T,Alt,FAlt,FVeloc,LDA\n0,0,0,0,0\n0.05,2,1,20,0\n").unwrap();
1439        assert_eq!(
1440            log.vertical_speed_m_s.as_deref(),
1441            Some(&[0.0, 20.0 * 0.3048][..])
1442        );
1443        assert!(
1444            log.notes
1445                .iter()
1446                .any(|note| note
1447                    .contains("the vertical speed is its filtered barometric \"FVeloc\""))
1448        );
1449        let old = read("T, Alt, VRaw, VFilt\n0, 0, 0, 0\n50, 1, 20, 4\n100, 3, 40, 12\n").unwrap();
1450        assert_eq!(old.time_s, [0.0, 0.05, 0.1]);
1451        assert_eq!(
1452            old.vertical_speed_m_s.as_deref(),
1453            Some(&[0.0, 20.0 * 0.3048, 40.0 * 0.3048][..])
1454        );
1455        assert!(old.notes[2].starts_with("`VRaw` read in feet per second, from the manual: "));
1456        assert!(
1457            old.notes[3].ends_with("the file has no filtered altitude to check it against"),
1458            "{}",
1459            old.notes[3]
1460        );
1461        assert!(old.notes[3].starts_with("`VFilt` read in feet per second, from the manual: "));
1462        assert!(
1463            read("T,Alt,LDA\n0,0,0\n")
1464                .unwrap()
1465                .vertical_speed_m_s
1466                .is_none()
1467        );
1468    }
1469
1470    /// An unknown column before `LDA` is kept as written; after it, it is a marker with events.
1471    #[test]
1472    fn unknown_columns_are_kept_and_named_channels_are_markers() {
1473        let log = read("T,Alt,Veloc,Baro,LDA,Apogee,Drogue.1\n0,0,0,7,0,0,0\n0.05,4,80,8,4,0,4\n")
1474            .unwrap();
1475        assert_eq!(log.channels[2].quantity, Quantity::Unrecognized);
1476        assert_eq!(log.channels[2].values, [7.0, 8.0]);
1477        let names: Vec<&str> = log.events.iter().map(|event| event.name.as_str()).collect();
1478        assert_eq!(names, ["LDA", "Drogue.1"]);
1479        let notes = log.notes.join("\n");
1480        assert!(notes.contains("\"Baro\" isn't one this reader knows, and was kept as written"));
1481        assert!(notes.contains("\"Drogue.1\" isn't one this reader knows; it comes after `LDA`"));
1482    }
1483
1484    /// Rows of empty cells are skipped and counted; an empty cell and a short row leave gaps.
1485    #[test]
1486    fn empty_rows_are_skipped_and_empty_cells_are_gaps() {
1487        let text = format!(
1488            "{TRS}\r\n0,0,0,0,0,0,0,0,0,0,0\r\n0.05,2,,1,20,0,0,0,0,0,0\r\n0.1,5,60\r\n\
1489             ,,,,,,,,,,\r\n,,,,,,,,,,\r\n , ,\r\n"
1490        );
1491        let log = read(&text).unwrap();
1492        assert_eq!(log.time_s, [0.0, 0.05, 0.1]);
1493        assert!(log.channels[1].values[1].is_nan());
1494        assert!(log.channels[2].values[2].is_nan());
1495        let notes = log.notes.join("\n");
1496        assert!(
1497            notes.contains("3 rows hold only empty cells, and were skipped"),
1498            "{notes}"
1499        );
1500        assert!(notes.contains("the column \"Veloc\" leaves 1 cells empty: they are gaps"));
1501        assert!(notes.contains("the column \"Main\" leaves 1 cells empty: they are gaps"));
1502    }
1503
1504    /// Each refusal names its line and what is wrong.
1505    #[test]
1506    fn malformed_files_are_refused_where_they_go_wrong() {
1507        let cases = [
1508            (String::new(), "the file is empty"),
1509            ("\r\n\r\n".to_owned(), "the file is empty"),
1510            (
1511                "Time,Altitude\n0,0\n".to_owned(),
1512                "isn't an Eggtimer header row",
1513            ),
1514            (format!("{TRS}\n"), "no data rows"),
1515            (format!("{TRS}\n,,,,,,,,,,\n"), "no data rows"),
1516            (
1517                "T,Alt,,LDA\n".to_owned(),
1518                "line 1: the header's column 3 has no name",
1519            ),
1520            (
1521                "T,Alt,LDA\n0,0,0,0\n".to_owned(),
1522                "line 2: the row has 4 cells and the header names 3",
1523            ),
1524            (
1525                "T,Alt,LDA\n0,0,0\n0.05,x,0\n".to_owned(),
1526                "line 3: \"x\" in cell 2, under \"Alt\", isn't a number",
1527            ),
1528            (
1529                "T,Alt,LDA\n0,0,0\n0.05,1,NaN\n".to_owned(),
1530                "line 3: \"NaN\" in cell 3, under \"LDA\"",
1531            ),
1532            (
1533                "T,Alt,LDA\nx,0,0\n".to_owned(),
1534                "line 2: \"x\" in cell 1, under \"T\"",
1535            ),
1536            (
1537                "T,Alt,LDA\n,0,0\n".to_owned(),
1538                "line 2: the time, the row's first cell, is empty",
1539            ),
1540            (
1541                "T,Alt,LDA\n0,0,0\n0,1,0\n".to_owned(),
1542                "line 3: the time 0 comes at or before the one above it, 0",
1543            ),
1544            (
1545                "T,Alt,LDA\n\n1,0,0\n\n0.5,1,0\n".to_owned(),
1546                "line 5: the time 0.5 comes at or before",
1547            ),
1548        ];
1549        for (text, expected) in cases {
1550            let error = read(&text).unwrap_err().to_string();
1551            assert!(error.contains(expected), "{text:?}: {error}");
1552        }
1553    }
1554
1555    /// A wide header over short rows is refused before its columns are allocated.
1556    #[test]
1557    fn a_wide_header_over_short_rows_is_refused() {
1558        let names: String = (0..2000).map(|i| format!(",u{i}")).collect();
1559        let rows = "0\n".repeat(200);
1560        let text = format!("T,Alt,LDA{names}\n{rows}");
1561        let error = read(&text).unwrap_err().to_string();
1562        assert!(
1563            error.contains("too few for the header to be this file's"),
1564            "{error}"
1565        );
1566    }
1567
1568    /// CR, LF, CR LF and CR CR LF line ends read the same.
1569    #[test]
1570    fn line_ends() {
1571        let rows = ["T,Alt,Veloc,LDA", "0,0,0,0", "0.05,2,40,0", "0.1,6,80,6"];
1572        let reference = read(&rows.join("\n")).unwrap();
1573        for end in ["\r", "\n", "\r\n", "\r\r\n"] {
1574            let text = format!("{}{end}", rows.join(end));
1575            assert_eq!(read(&text).unwrap(), reference, "{end:?}");
1576        }
1577        assert_eq!(reference.events.len(), 1);
1578    }
1579
1580    /// A header of many unknown columns keeps a short list of notes and counts the rest; the unit
1581    /// notes stay outside the cap.
1582    #[test]
1583    fn notes_stay_few() {
1584        let unknown: String = (0..100).map(|i| format!(",u{i}")).collect();
1585        let text = format!("T,Alt,Veloc{unknown}\n0,0,0{}\n", ",1".repeat(100));
1586        let log = read(&text).unwrap();
1587        assert_eq!(log.channels.len(), 102);
1588        // `T`, `Alt` and `Veloc`'s unit notes, 20 notes about columns, and one counting the rest.
1589        assert_eq!(log.notes.len(), 3 + 20 + 1, "{:?}", log.notes);
1590        assert!(
1591            log.notes[23].starts_with("and 80 more notes"),
1592            "{:?}",
1593            log.notes[23]
1594        );
1595    }
1596
1597    /// A file that isn't text is refused with a short quote of it.
1598    #[test]
1599    fn a_long_line_is_quoted_short() {
1600        let junk = "\u{fffd}".repeat(100_000);
1601        let error = read(&junk).unwrap_err().to_string();
1602        assert!(error.len() < 400, "{} bytes", error.len());
1603    }
1604
1605    #[test]
1606    fn numbers_are_written_plainly() {
1607        assert_eq!(plain(0.050_000_000_000_000_71, 4), "0.05");
1608        assert_eq!(plain(50.0, 4), "50");
1609        assert_eq!(plain(32.166, 2), "32.17");
1610        assert_eq!(plain(-0.0, 2), "-0");
1611    }
1612
1613    proptest::proptest! {
1614        /// Any text is read or refused, never a panic; what is read has increasing times and every
1615        /// channel a value for each.
1616        #[test]
1617        fn any_text_is_read_or_refused(
1618            rows in proptest::collection::vec("[-0-9.,x \r]{0,30}", 0..12)
1619        ) {
1620            let text = format!("{TRS}\n{}", rows.join("\n"));
1621            if let Ok(log) = read(&text) {
1622                proptest::prop_assert!(log.time_s.windows(2).all(|pair| pair[1] > pair[0]));
1623                proptest::prop_assert_eq!(log.time_s.len(), log.altitude_m.len());
1624                for channel in &log.channels {
1625                    proptest::prop_assert_eq!(channel.time_s.len(), channel.values.len());
1626                }
1627                for event in &log.events {
1628                    proptest::prop_assert!(log.time_s.contains(&event.time_s));
1629                }
1630            }
1631        }
1632    }
1633}