Skip to main content

hpr_flightdata/
raven.rs

1//! The Featherweight Raven's flight logs, as the Featherweight Interface Program (FIP) exports
2//! them to a comma-separated file.
3//!
4//! The Raven samples its sensors at different rates, so the export gives each channel its own
5//! clock: a `Time@` column, then the channel's values, then, in most exports, a status column
6//! named `bILBA`. One header line names the columns; each row below holds the next sample of every
7//! channel, so a row's cells for different channels were taken at different times:
8//!
9//! ```text
10//! Time@Axial Accel (Gs),Axial Accel (Gs),bILBA,Time@Baro (Atm),Baro (Atm),bILBA,…
11//! 0,1.02,b0000,0.04375,0.97,b0000,…
12//! 0.0025,1.01,b0000,0.09375,0.97,b0000,…
13//! ```
14//!
15//! **Channels.** The names the reader knows, each read into the record's SI unit
16//! ([`Quantity::unit`]); the column keeps the file's name, brackets and all:
17//!
18//! | Column | Quantity | From |
19//! |---|---|---|
20//! | `Axial Accel (Gs)`, `Lateral Accel (Gs)` | acceleration | g, × 9.80665 m/s² (standard gravity) |
21//! | `Baro (Atm)` | pressure | atm, × 101,325 Pa |
22//! | `Current Draw (A)` | current | A |
23//! | `Temperature (F)` | temperature | °F |
24//! | `Volts Battery (V)`, `Volts Pyro Apogee (V)` and the other `Volts … (V)` | voltage | V |
25//! | `Velocity (Accel-Ft/Sec)`, `[Velocity (Accel-Ft/Sec)]` | speed | ft/s |
26//! | `[Velocity (Accel-MPH)]` | speed | mph, × 0.44704 m/s |
27//! | `[Altitude (Accel-Ft)]` | inertial altitude | ft |
28//! | `[Altitude (Baro-Ft-AGL)]`, `Altitude (Ft-AGL)` | altitude above the pad | ft |
29//! | `[Altitude (Baro-Ft-ASL)]` | altitude above sea level | ft |
30//! | `LiftOff` | a number, as written | |
31//! | `bILBA` | a number: the status bits | |
32//!
33//! A column the reader doesn't know is kept as written, as [`Quantity::Unrecognized`], with a
34//! note. The bracketed names are figures the FIP works out from what the Raven recorded rather
35//! than recordings: their names say so (`Accel`, `Baro`), and each sits on the clock of the sensor
36//! it comes from.
37//!
38//! **The status column.** Each `bILBA` cell is `b` and four binary digits, such as `b1100`, and is
39//! kept as a [`Quantity::Number`] channel, beside the channel it follows, holding the digits'
40//! value read with the leftmost digit the most significant: `b0101` is 5, `b1100` is 12. No
41//! published source says what the digits mean. Featherweight's Raven manual (*Raven User's
42//! Manual, updated for the Raven4*, 2020-08-15) says the Raven records "all flight events used for
43//! deployment logic" at 20 Hz and that the FIP can plot them, but it describes neither the export
44//! nor this column; Debrief uses the column only to recognize the format. The letters `I`, `L`,
45//! `B` and `A` may name flight events (`L` liftoff, `B` burnout and `A` apogee would fit the
46//! manual's words), but that is a guess, so the reader states no events from them.
47//!
48//! **Rows of different lengths.** The channels run for different spans, and a channel with no
49//! sample in a row is written as two empty cells, its time's and its value's, *without* its status
50//! cell. In an export with status columns, a row is therefore one cell shorter for each channel
51//! that has stopped, and every channel after it is shifted one cell to the left of its header; an
52//! export without them keeps every row as wide as its header. The reader walks each row channel by
53//! channel: two empty cells are a channel with no sample in that row, anything else a sample with
54//! its status, and the row must end where the last channel does. This rule is the files', not a
55//! document's: Debrief's reader assumes every row as wide as the header, which in an export with
56//! status columns reads every channel after the first one to stop from the wrong cells. Debrief's
57//! public sample of a Raven flight bears the rule out row for row, as do the 79 Raven exports in
58//! the private collection of flights this project checks against.
59//!
60//! **Layouts.** Four were seen: each channel's clock and status (16 channels); each channel's clock
61//! without status columns (13, 16 or 18 channels); a two-channel export headed `Time-Axial Accel
62//! (Gs), Axial Accel (Gs), bILBA, Time (s), Altitude (Ft-AGL), bILBA`, with a space after each
63//! comma; and an export with one `Time` column for every channel, which in the one such file read
64//! repeats whole rows. A column whose name starts with `Time` starts a clock; the columns up to the
65//! next clock are its channels, each followed by its status column if it has one.
66//!
67//! **The primary track.** The record's time and altitude are `[Altitude (Baro-Ft-AGL)]`'s, on its
68//! own clock: the Raven's barometric altitude above the pad. The two-channel layout has no such
69//! column; its `Altitude (Ft-AGL)` names no sensor, and is taken instead, with a note: it is the
70//! only altitude above the pad the file holds, and in the one such file read it runs at 20 Hz, the
71//! rate the manual gives for the barometer (the accelerometer's is 400 Hz). A file with neither is
72//! refused: working an altitude out of `Baro (Atm)` is a reading, not this reader's to make. No
73//! column is a vertical speed from the barometer, so [`FlightLog::vertical_speed_m_s`] is `None`;
74//! the velocities are the accelerometer's, kept as channels. The temperature and battery voltage
75//! fill [`FlightLog::temperature_k`] and [`FlightLog::battery_v`] only when they share the
76//! altitude's clock, as they do in the one-clock layout; otherwise they are channels only.
77//!
78//! **Cells.** `-1.#QNAN` and the other ways Windows writes a value that isn't a number, as some
79//! exports write the accelerometer's derived channels before or after their data, are gaps,
80//! `NaN`, counted in a note; so is an empty value in a sample that has a time. Within a channel,
81//! a time that repeats the one before is dropped with a note saying how many of the repeats
82//! differed from the sample they repeat (the one-clock layout's repeated rows are identical
83//! copies); a time that goes back is refused, naming the line. A byte-order mark is dropped, lines
84//! may end in CR, LF or both, and the cells may be padded with spaces.
85//!
86//! **Where this comes from.** The layout, the `Time@`/`bILBA` header and the units follow the
87//! reader in Debrief (`lib/parsers/featherweightFip.ts` and `lib/parsers/multiTimebase.ts`, MIT,
88//! the project owner's own; see `THIRD-PARTY-NOTICES.md`), which was written from exported files
89//! and cites no document; the clocks' rates are Featherweight's manual's. Where this reader departs
90//! from Debrief's: it keeps every channel on its own clock where Debrief resamples them onto the
91//! densest; it walks the rows as above; it reads the layouts without status columns or with one
92//! clock, which Debrief doesn't recognize; it takes the altitude above the pad from the export's
93//! own column where Debrief works one out from the pressure; and it refuses a time that goes back
94//! where Debrief sorts the samples.
95
96use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
97
98use crate::error::LogError;
99use crate::log::{Channel, FlightLog, LogFormat, Quantity, Stated};
100use crate::perfectflite::FOOT_M;
101
102/// The format's name in errors.
103const FORMAT: &str = "Raven FIP CSV";
104
105/// Pascals in a standard atmosphere, exactly (ISO 2533).
106const ATM_PA: f64 = 101_325.0;
107
108/// Meters per second in a mile per hour, exactly (the international mile, 1,609.344 m, per 3,600 s).
109const MPH_M_S: f64 = 0.447_04;
110
111/// The name of a status column, as the FIP writes it (compared without regard to case).
112const STATUS: &str = "bilba";
113
114/// The name of the barometric altitude above the pad, the primary track, brackets stripped.
115const BARO_AGL: &str = "altitude (baro-ft-agl)";
116
117/// The two-channel layout's altitude above the pad, which names no sensor.
118const BARE_AGL: &str = "altitude (ft-agl)";
119
120/// How a known column's values become SI.
121#[derive(Debug, Clone, Copy, PartialEq)]
122enum Scale {
123    /// Multiplied by a factor.
124    By(f64),
125    /// Degrees Fahrenheit to kelvin.
126    Fahrenheit,
127}
128
129/// The columns the reader knows, brackets stripped and in lower case, with what each measures and
130/// how its values become SI. The `Volts … (V)` columns are matched by their shape instead
131/// ([`known`]).
132const KNOWN: [(&str, Quantity, Scale); 12] = [
133    (
134        "axial accel (gs)",
135        Quantity::Acceleration,
136        Scale::By(STANDARD_GRAVITY_MPS2),
137    ),
138    (
139        "lateral accel (gs)",
140        Quantity::Acceleration,
141        Scale::By(STANDARD_GRAVITY_MPS2),
142    ),
143    ("baro (atm)", Quantity::Pressure, Scale::By(ATM_PA)),
144    ("current draw (a)", Quantity::Current, Scale::By(1.0)),
145    ("temperature (f)", Quantity::Temperature, Scale::Fahrenheit),
146    (
147        "velocity (accel-ft/sec)",
148        Quantity::Speed,
149        Scale::By(FOOT_M),
150    ),
151    ("velocity (accel-mph)", Quantity::Speed, Scale::By(MPH_M_S)),
152    (
153        "altitude (accel-ft)",
154        Quantity::InertialAltitude,
155        Scale::By(FOOT_M),
156    ),
157    (BARO_AGL, Quantity::AltitudeAboveGround, Scale::By(FOOT_M)),
158    (BARE_AGL, Quantity::AltitudeAboveGround, Scale::By(FOOT_M)),
159    (
160        "altitude (baro-ft-asl)",
161        Quantity::AltitudeMsl,
162        Scale::By(FOOT_M),
163    ),
164    ("liftoff", Quantity::Number, Scale::By(1.0)),
165];
166
167/// A column name brackets stripped, trimmed and in lower case, as [`KNOWN`] lists them.
168fn key(name: &str) -> String {
169    let name = name.trim();
170    let name = name
171        .strip_prefix('[')
172        .and_then(|inner| inner.strip_suffix(']'))
173        .unwrap_or(name);
174    name.trim().to_ascii_lowercase()
175}
176
177/// What a known column measures and how it becomes SI; `None` for a column the reader doesn't know.
178fn known(name: &str) -> Option<(Quantity, Scale)> {
179    let key = key(name);
180    if key.starts_with("volts ") && key.ends_with("(v)") {
181        return Some((Quantity::Voltage, Scale::By(1.0)));
182    }
183    KNOWN
184        .iter()
185        .find(|(known, _, _)| *known == key)
186        .map(|(_, quantity, scale)| (*quantity, *scale))
187}
188
189/// Whether a header cell starts a clock: `Time@…`, `Time-…`, `Time (s)` or `Time`.
190fn is_clock(name: &str) -> bool {
191    name.get(..4)
192        .is_some_and(|start| start.eq_ignore_ascii_case("time"))
193}
194
195/// One data column of a clock: its value cells, and its status cells if it has a status column.
196struct Column {
197    name: String,
198    quantity: Quantity,
199    scale: Scale,
200    values: Vec<f64>,
201    status: Option<Vec<f64>>,
202    /// Cells left empty in samples that have a time.
203    empty: usize,
204    /// Cells that aren't a number, such as `-1.#QNAN`, and the first of them as written.
205    not_numbers: usize,
206    not_number_example: String,
207}
208
209/// A clock and the columns on it.
210struct Clock {
211    name: String,
212    time_s: Vec<f64>,
213    columns: Vec<Column>,
214    /// Repeated times dropped, and how many of them differed from the sample they repeat.
215    repeats: usize,
216    differing_repeats: usize,
217}
218
219impl Clock {
220    /// Cells a sample takes: the time, each value and each status.
221    fn sample_width(&self) -> usize {
222        1 + self.columns.len() + self.columns.iter().filter(|c| c.status.is_some()).count()
223    }
224
225    /// Cells a row gives the clock when it has no sample: the time and each value, empty.
226    fn empty_width(&self) -> usize {
227        1 + self.columns.len()
228    }
229}
230
231/// Reads a Featherweight Raven's FIP CSV export from its text.
232///
233/// # Errors
234///
235/// [`LogError::NotThisFormat`] if the first line isn't a FIP export's header (a clock column first,
236/// and a channel name the Raven writes); [`LogError::Syntax`] for a header the reader can't lay
237/// out, a file with no altitude above the pad, a row whose cells don't fall into its channels, a
238/// time or value that isn't a number, or a time that goes back; [`LogError::NoData`] if the
239/// altitude has no samples.
240pub fn read(text: &str) -> Result<FlightLog, LogError> {
241    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
242    let mut lines = numbered_lines(text).filter(|(_, line)| !line.trim().is_empty());
243    let Some((header_line, header)) = lines.next() else {
244        return Err(LogError::NotThisFormat {
245            format: FORMAT,
246            message: "the file is empty".to_owned(),
247        });
248    };
249    let mut notes = Notes::default();
250    let mut clocks = header_clocks(header, header_line, &mut notes)?;
251
252    for (number, line) in lines {
253        read_row(&mut clocks, line, number)?;
254    }
255    build(clocks, header_line, notes)
256}
257
258/// The file's lines, numbered from 1, ended by CR, LF or CR LF.
259fn numbered_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
260    let mut rest = Some(text);
261    let mut number = 0;
262    std::iter::from_fn(move || {
263        let current = rest?;
264        number += 1;
265        match current.find(['\r', '\n']) {
266            Some(end) => {
267                let skip = if current[end..].starts_with("\r\n") {
268                    2
269                } else {
270                    1
271                };
272                rest = Some(&current[end + skip..]);
273                Some((number, &current[..end]))
274            }
275            None => {
276                rest = None;
277                (!current.is_empty()).then_some((number, current))
278            }
279        }
280    })
281}
282
283/// The clocks the header lays out, each with its columns.
284fn header_clocks(header: &str, line: usize, notes: &mut Notes) -> Result<Vec<Clock>, LogError> {
285    let names: Vec<&str> = header.split(',').map(str::trim).collect();
286    let is_raven = names.first().is_some_and(|first| is_clock(first))
287        && names.iter().any(|name| known(name).is_some());
288    if !is_raven {
289        return Err(LogError::NotThisFormat {
290            format: FORMAT,
291            message: format!(
292                "its first line, {:?}, isn't a FIP export's header, a clock such as \
293                 `Time@Axial Accel (Gs)` and the Raven's channels",
294                short(header)
295            ),
296        });
297    }
298    let mut clocks: Vec<Clock> = Vec::new();
299    for (index, name) in names.iter().enumerate() {
300        let column_number = index + 1;
301        if name.is_empty() {
302            return Err(syntax(
303                line,
304                format!("the header's column {column_number} has no name"),
305            ));
306        }
307        if is_clock(name) {
308            if let Some(last) = clocks.last()
309                && last.columns.is_empty()
310            {
311                return Err(syntax(
312                    line,
313                    format!("the clock {:?} has no channel", short(&last.name)),
314                ));
315            }
316            clocks.push(Clock {
317                name: (*name).to_owned(),
318                time_s: Vec::new(),
319                columns: Vec::new(),
320                repeats: 0,
321                differing_repeats: 0,
322            });
323            continue;
324        }
325        // Invariant: the first name is a clock's, checked above, so a clock is open here.
326        let Some(clock) = clocks.last_mut() else {
327            continue;
328        };
329        if name.eq_ignore_ascii_case(STATUS) {
330            match clock.columns.last_mut() {
331                Some(column) if column.status.is_none() => column.status = Some(Vec::new()),
332                _ => {
333                    return Err(syntax(
334                        line,
335                        format!("the status column {column_number} follows no channel of its own"),
336                    ));
337                }
338            }
339            continue;
340        }
341        let (quantity, scale) = known(name).unwrap_or_else(|| {
342            notes.push(format!(
343                "the column {:?} isn't one this reader knows, and was kept as written",
344                short(name)
345            ));
346            (Quantity::Unrecognized, Scale::By(1.0))
347        });
348        clock.columns.push(Column {
349            name: (*name).to_owned(),
350            quantity,
351            scale,
352            values: Vec::new(),
353            status: None,
354            empty: 0,
355            not_numbers: 0,
356            not_number_example: String::new(),
357        });
358    }
359    if let Some(last) = clocks.last()
360        && last.columns.is_empty()
361    {
362        return Err(syntax(
363            line,
364            format!("the clock {:?} has no channel", short(&last.name)),
365        ));
366    }
367    Ok(clocks)
368}
369
370/// Reads one row into the clocks: each clock either has a sample in it, its time, values and
371/// statuses, or two or more empty cells, its time's and its values', without its status cells.
372fn read_row(clocks: &mut [Clock], line: &str, number: usize) -> Result<(), LogError> {
373    let cells: Vec<&str> = line.split(',').map(str::trim).collect();
374    let mut at = 0;
375    for clock in clocks.iter_mut() {
376        let empty_width = clock.empty_width();
377        let Some(head) = cells.get(at..at + empty_width) else {
378            return Err(syntax(
379                number,
380                format!(
381                    "the row ends at cell {} before the clock {:?}'s cells",
382                    cells.len(),
383                    short(&clock.name)
384                ),
385            ));
386        };
387        if head.iter().all(|cell| cell.is_empty()) {
388            at += empty_width;
389            continue;
390        }
391        let width = clock.sample_width();
392        let Some(sample) = cells.get(at..at + width) else {
393            return Err(syntax(
394                number,
395                format!(
396                    "the row ends at cell {} inside the clock {:?}'s sample",
397                    cells.len(),
398                    short(&clock.name)
399                ),
400            ));
401        };
402        read_sample(clock, sample, number, at)?;
403        at += width;
404    }
405    if at != cells.len() {
406        return Err(syntax(
407            number,
408            format!(
409                "the row has {} cells and its channels' samples take {at}",
410                cells.len()
411            ),
412        ));
413    }
414    Ok(())
415}
416
417/// Reads one clock's sample, its cells starting at the row's cell `first` (0-based).
418fn read_sample(
419    clock: &mut Clock,
420    sample: &[&str],
421    number: usize,
422    first: usize,
423) -> Result<(), LogError> {
424    let time = sample[0];
425    let time_s = time
426        .parse::<f64>()
427        .ok()
428        .filter(|value| value.is_finite())
429        .ok_or_else(|| {
430            syntax(
431                number,
432                format!(
433                    "the time {:?} in cell {} isn't a finite number",
434                    short(time),
435                    first + 1
436                ),
437            )
438        })?;
439    // Each column's value, and its status where it has one, as they stand in the row.
440    let mut parsed: Vec<(f64, Option<f64>)> = Vec::with_capacity(clock.columns.len());
441    let mut at = 1;
442    for column in &mut clock.columns {
443        let cell = sample[at];
444        let value = value_cell(column, cell).ok_or_else(|| {
445            syntax(
446                number,
447                format!(
448                    "{:?} in cell {}, under {:?}, isn't a number",
449                    short(cell),
450                    first + at + 1,
451                    short(&column.name)
452                ),
453            )
454        })?;
455        at += 1;
456        let status = if column.status.is_some() {
457            let cell = sample[at];
458            let status = status_cell(cell).ok_or_else(|| {
459                syntax(
460                    number,
461                    format!(
462                        "the status {:?} in cell {} isn't `b` and binary digits",
463                        short(cell),
464                        first + at + 1
465                    ),
466                )
467            })?;
468            at += 1;
469            Some(status)
470        } else {
471            None
472        };
473        parsed.push((value, status));
474    }
475    if let Some(&last) = clock.time_s.last() {
476        if time_s < last {
477            return Err(syntax(
478                number,
479                format!(
480                    "the time {time_s} s on the clock {:?} comes before the one above it, {last} s",
481                    short(&clock.name)
482                ),
483            ));
484        }
485        if time_s == last {
486            clock.repeats += 1;
487            let same = clock
488                .columns
489                .iter()
490                .zip(&parsed)
491                .all(|(column, (value, status))| {
492                    same_bits(column.values.last(), Some(value))
493                        && same_bits(
494                            column.status.as_ref().and_then(|statuses| statuses.last()),
495                            status.as_ref(),
496                        )
497                });
498            if !same {
499                clock.differing_repeats += 1;
500            }
501            return Ok(());
502        }
503    }
504    clock.time_s.push(time_s);
505    for (column, (value, status)) in clock.columns.iter_mut().zip(parsed) {
506        column.values.push(value);
507        if let (Some(statuses), Some(status)) = (column.status.as_mut(), status) {
508            statuses.push(status);
509        }
510    }
511    Ok(())
512}
513
514/// Whether two optional values are the same number, `NaN` matching `NaN`.
515fn same_bits(a: Option<&f64>, b: Option<&f64>) -> bool {
516    match (a, b) {
517        (Some(a), Some(b)) => a.to_bits() == b.to_bits(),
518        (None, None) => true,
519        _ => false,
520    }
521}
522
523/// A value cell in SI; `NaN`, counted, for an empty cell or one that isn't a number in the way
524/// Windows writes them; `None` for anything else that isn't a number.
525fn value_cell(column: &mut Column, cell: &str) -> Option<f64> {
526    if cell.is_empty() {
527        column.empty += 1;
528        return Some(f64::NAN);
529    }
530    match cell.parse::<f64>() {
531        Ok(value) if value.is_finite() => Some(si(column.scale, value)),
532        Ok(_) => Some(not_a_number(column, cell)),
533        Err(_) if is_windows_non_number(cell) => Some(not_a_number(column, cell)),
534        Err(_) => None,
535    }
536}
537
538/// Counts a cell that isn't a number and returns the gap it leaves.
539fn not_a_number(column: &mut Column, cell: &str) -> f64 {
540    if column.not_numbers == 0 {
541        column.not_number_example = short(cell);
542    }
543    column.not_numbers += 1;
544    f64::NAN
545}
546
547/// Whether a cell is how Microsoft's C runtime writes a value that isn't a finite number:
548/// `1.#QNAN`, `1.#SNAN`, `1.#IND` or `1.#INF`, perhaps signed and followed by digits.
549fn is_windows_non_number(cell: &str) -> bool {
550    let unsigned = cell.trim_start_matches(['-', '+']);
551    let Some(rest) = unsigned.strip_prefix("1.#") else {
552        return false;
553    };
554    let word = rest.trim_end_matches(|c: char| c.is_ascii_digit());
555    ["QNAN", "SNAN", "IND", "INF"]
556        .iter()
557        .any(|known| word.eq_ignore_ascii_case(known))
558}
559
560/// A status cell's value: `b` and binary digits, the leftmost the most significant, so `b0101`
561/// is 5; `NaN` for an empty cell; `None` for anything else, or more digits than an `f64` holds
562/// exactly.
563fn status_cell(cell: &str) -> Option<f64> {
564    if cell.is_empty() {
565        return Some(f64::NAN);
566    }
567    let digits = cell.strip_prefix(['b', 'B'])?;
568    if digits.is_empty() || digits.len() > 52 || !digits.bytes().all(|b| b == b'0' || b == b'1') {
569        return None;
570    }
571    // At most 52 binary digits, so the value is below 2⁵² and exact as an f64.
572    u64::from_str_radix(digits, 2).ok().map(|bits| bits as f64)
573}
574
575/// A value in SI.
576fn si(scale: Scale, value: f64) -> f64 {
577    match scale {
578        Scale::By(factor) => value * factor,
579        Scale::Fahrenheit => (value - 32.0) * 5.0 / 9.0 + 273.15,
580    }
581}
582
583/// The record from the clocks read: every channel, the primary track, and the notes.
584fn build(clocks: Vec<Clock>, header_line: usize, mut notes: Notes) -> Result<FlightLog, LogError> {
585    let mut log = FlightLog {
586        format: LogFormat::RavenFipCsv,
587        logger: "Featherweight Raven".to_owned(),
588        serial_number: None,
589        firmware: None,
590        flight_number: None,
591        stated: Stated::default(),
592        time_s: Vec::new(),
593        altitude_m: Vec::new(),
594        vertical_speed_m_s: None,
595        temperature_k: None,
596        battery_v: None,
597        channels: Vec::new(),
598        events: Vec::new(),
599        notes: Vec::new(),
600    };
601    for clock in clocks {
602        if clock.repeats > 0 {
603            notes.push(format!(
604                "the clock {:?} repeats the time before it {} times; the repeats were dropped, \
605                 {} of them differing from the sample they repeat",
606                short(&clock.name),
607                clock.repeats,
608                clock.differing_repeats
609            ));
610        }
611        for column in clock.columns {
612            if column.not_numbers > 0 {
613                notes.push(format!(
614                    "the column {:?} holds {} cells that aren't a number, such as {:?}: they are \
615                     gaps",
616                    short(&column.name),
617                    column.not_numbers,
618                    column.not_number_example
619                ));
620            }
621            if column.empty > 0 {
622                notes.push(format!(
623                    "the column {:?} leaves {} samples' values empty: they are gaps",
624                    short(&column.name),
625                    column.empty
626                ));
627            }
628            log.channels.push(Channel {
629                column: column.name,
630                quantity: column.quantity,
631                time_s: clock.time_s.clone(),
632                values: column.values,
633            });
634            if let Some(status) = column.status {
635                log.channels.push(Channel {
636                    column: "bILBA".to_owned(),
637                    quantity: Quantity::Number,
638                    time_s: clock.time_s.clone(),
639                    values: status,
640                });
641            }
642        }
643    }
644
645    let primary = log
646        .channels
647        .iter()
648        .find(|channel| key(&channel.column) == BARO_AGL)
649        .or_else(|| {
650            let bare = log
651                .channels
652                .iter()
653                .find(|channel| key(&channel.column) == BARE_AGL);
654            if let Some(channel) = bare {
655                log.notes.push(format!(
656                    "the file has no `[Altitude (Baro-Ft-AGL)]`; its {:?}, which names no sensor, \
657                     was taken as the altitude above the pad",
658                    channel.column
659                ));
660            }
661            bare
662        });
663    let Some(primary) = primary else {
664        return Err(syntax(
665            header_line,
666            "the file has no altitude above the pad, `[Altitude (Baro-Ft-AGL)]` or `Altitude \
667             (Ft-AGL)`"
668                .to_owned(),
669        ));
670    };
671    if primary.time_s.is_empty() {
672        return Err(LogError::NoData { format: FORMAT });
673    }
674    log.time_s.clone_from(&primary.time_s);
675    log.altitude_m.clone_from(&primary.values);
676    // The temperature and battery voltage fill the primary track only on the altitude's clock.
677    let on_primary_clock = |channel: &&Channel| channel.time_s == log.time_s;
678    let temperature = log
679        .channels_of(Quantity::Temperature)
680        .find(on_primary_clock)
681        .map(|channel| channel.values.clone());
682    let battery = log
683        .channels_of(Quantity::Voltage)
684        .filter(|channel| key(&channel.column).contains("battery"))
685        .find(on_primary_clock)
686        .map(|channel| channel.values.clone());
687    log.temperature_k = temperature;
688    log.battery_v = battery;
689
690    log.notes.extend(notes.kept);
691    if notes.left_out > 0 {
692        log.notes.push(format!(
693            "and {} more notes, left out so that a broken file's list stays short",
694            notes.left_out
695        ));
696    }
697    Ok(log)
698}
699
700/// The most notes a log keeps; past it, one more note counts the rest.
701const MAX_NOTES: usize = 20;
702
703/// Notes as the reader meets them: the first [`MAX_NOTES`] kept, the rest only counted, so a
704/// header of many unknown columns can't fill memory with them.
705#[derive(Default)]
706struct Notes {
707    kept: Vec<String>,
708    left_out: usize,
709}
710
711impl Notes {
712    fn push(&mut self, note: String) {
713        if self.kept.len() < MAX_NOTES {
714            self.kept.push(note);
715        } else {
716            self.left_out += 1;
717        }
718    }
719}
720
721fn syntax(line: usize, message: String) -> LogError {
722    LogError::Syntax {
723        format: FORMAT,
724        line,
725        message,
726    }
727}
728
729/// Text from the file as an error or note quotes it: cut to [`QUOTED_CHARS`] characters, so a
730/// binary file's "first line" doesn't fill the message.
731fn short(text: &str) -> String {
732    if text.chars().count() <= QUOTED_CHARS {
733        text.to_owned()
734    } else {
735        let mut cut: String = text.chars().take(QUOTED_CHARS - 1).collect();
736        cut.push('…');
737        cut
738    }
739}
740
741/// The most characters of the file an error or note quotes.
742const QUOTED_CHARS: usize = 80;
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    const HEADER: &str = "Time@Axial Accel (Gs),Axial Accel (Gs),bILBA,\
749                          Time@Baro (Atm),Baro (Atm),bILBA,\
750                          Time@[Altitude (Baro-Ft-AGL)],[Altitude (Baro-Ft-AGL)],bILBA";
751
752    fn close(a: f64, b: f64) -> bool {
753        (a - b).abs() <= 1e-12 * b.abs().max(1.0)
754    }
755
756    /// Each channel on its own clock, in SI, the statuses read as binary numbers, the primary
757    /// track the barometric altitude's.
758    #[test]
759    fn reads_each_channel_on_its_own_clock() {
760        let text = format!(
761            "{HEADER}\r\n\
762             0,1,b0000,0.01,1,b0000,0.01,0,b0000\r\n\
763             0.0025,2,b0101,0.06,0.5,b1100,0.06,100,b1100\r\n"
764        );
765        let log = read(&text).unwrap();
766        assert_eq!(log.format, LogFormat::RavenFipCsv);
767        assert_eq!(log.logger, "Featherweight Raven");
768        let columns: Vec<&str> = log.channels.iter().map(|c| c.column.as_str()).collect();
769        assert_eq!(
770            columns,
771            [
772                "Axial Accel (Gs)",
773                "bILBA",
774                "Baro (Atm)",
775                "bILBA",
776                "[Altitude (Baro-Ft-AGL)]",
777                "bILBA"
778            ]
779        );
780        assert_eq!(log.channels[0].time_s, [0.0, 0.0025]);
781        assert_eq!(log.channels[0].values, [9.806_65, 2.0 * 9.806_65]);
782        assert_eq!(log.channels[1].values, [0.0, 5.0]);
783        assert_eq!(log.channels[2].values, [101_325.0, 50_662.5]);
784        assert_eq!(log.channels[3].values, [0.0, 12.0]);
785        assert_eq!(log.time_s, [0.01, 0.06]);
786        assert_eq!(log.altitude_m, [0.0, 30.48]);
787        assert!(log.vertical_speed_m_s.is_none());
788        assert!(log.events.is_empty());
789        assert!(log.notes.is_empty(), "{:?}", log.notes);
790    }
791
792    /// A channel with no sample in a row is two empty cells without its status, and the channels
793    /// after it move one cell left; the row must end where its channels do.
794    #[test]
795    fn a_stopped_channel_drops_its_status_cell() {
796        let text = format!(
797            "{HEADER}\n\
798             0,1,b0000,0.01,1,b0000,0.01,0,b0000\n\
799             0.0025,1,b0000,,,0.06,10,b1000\n\
800             0.005,1,b0000,,,,\n\
801             ,,,,,\n"
802        );
803        let log = read(&text).unwrap();
804        assert_eq!(log.channels[0].time_s, [0.0, 0.0025, 0.005]);
805        assert_eq!(log.channels[2].time_s, [0.01]);
806        assert_eq!(log.time_s, [0.01, 0.06]);
807        assert!(close(log.altitude_m[1], 3.048));
808        assert_eq!(log.channels[5].values, [0.0, 8.0]);
809
810        // The same row with the status cell kept is one cell too long.
811        let text = format!("{HEADER}\n0.0025,1,b0000,,,,0.06,10,b1000\n");
812        let error = read(&text).unwrap_err().to_string();
813        assert!(error.contains("line 2:"), "{error}");
814    }
815
816    /// Without status columns every row is as wide as the header; a stopped channel is two empty
817    /// cells in place.
818    #[test]
819    fn without_status_columns_rows_keep_their_width() {
820        let text = "Time@Axial Accel (Gs),Axial Accel (Gs),Time@Temperature (F),Temperature (F),\
821                    Time@[Altitude (Baro-Ft-AGL)],[Altitude (Baro-Ft-AGL)]\n\
822                    0,1,0.01,32,0.01,0\n\
823                    0.0025,1,,,0.06,1\n";
824        let log = read(text).unwrap();
825        assert_eq!(log.channels.len(), 3);
826        assert_eq!(log.channels[1].values, [273.15]);
827        assert!(log.temperature_k.is_none(), "not on the altitude's clock");
828        assert_eq!(log.time_s, [0.01, 0.06]);
829    }
830
831    /// One clock for every channel: the temperature and battery share the altitude's clock and
832    /// fill the primary track; a repeated row is dropped with a note.
833    #[test]
834    fn one_clock_layout_fills_the_track() {
835        let text = "Time,LiftOff,Axial Accel (Gs),Temperature (F),Volts Battery (V),\
836                    Volts Pyro Main (V),[Altitude (Baro-Ft-AGL)],[Velocity (Accel-MPH)]\n\
837                    0,0,1,212,8.1,0.1,0,0\n\
838                    0.001,0,1,212,8.1,0.1,0,0\n\
839                    0.001,0,1,212,8.1,0.1,0,0\n\
840                    0.002,1,5,212,8.0,0.1,1,10\n";
841        let log = read(text).unwrap();
842        assert_eq!(log.time_s, [0.0, 0.001, 0.002]);
843        let temperature = log.temperature_k.as_deref().unwrap();
844        assert!(close(temperature[0], 373.15));
845        assert_eq!(log.battery_v.as_deref(), Some(&[8.1, 8.1, 8.0][..]));
846        assert_eq!(log.channels[0].quantity, Quantity::Number);
847        assert!(close(log.channels[6].values[2], 4.4704));
848        assert_eq!(
849            log.notes,
850            [
851                "the clock \"Time\" repeats the time before it 1 times; the repeats were dropped, 0 \
852              of them differing from the sample they repeat"
853            ]
854        );
855    }
856
857    /// The two-channel layout's spaced header and bare `Altitude (Ft-AGL)`, taken with a note.
858    #[test]
859    fn the_bare_altitude_is_taken_with_a_note() {
860        let text = "Time-Axial Accel (Gs), Axial Accel (Gs), bILBA, Time (s), Altitude (Ft-AGL), \
861                    bILBA\n0, 1, b0000, 0, 10, b1000\n";
862        let log = read(text).unwrap();
863        assert_eq!(log.altitude_m, [3.048]);
864        assert!(log.notes[0].contains("names no sensor"), "{:?}", log.notes);
865    }
866
867    /// Windows' `-1.#QNAN` and an empty value are gaps, counted; other junk is refused.
868    #[test]
869    fn windows_non_numbers_are_gaps() {
870        let text = format!(
871            "{HEADER}\n0,-1.#QNAN,b0000,0.01,,b0000,0.01,0,b0000\n\
872             0.0025,1.#INF,b0000,0.06,1,b0000,0.06,0,b0000\n"
873        );
874        let log = read(&text).unwrap();
875        assert!(log.channels[0].values.iter().all(|v| v.is_nan()));
876        assert!(log.channels[2].values[0].is_nan());
877        assert_eq!(log.notes.len(), 2, "{:?}", log.notes);
878        assert!(log.notes[0].contains("2 cells that aren't a number, such as \"-1.#QNAN\""));
879        assert!(log.notes[1].contains("leaves 1 samples' values empty"));
880        assert!(is_windows_non_number("-1.#IND00"));
881        assert!(!is_windows_non_number("1.#X"));
882        let error = read(&format!("{HEADER}\n0,x,b0000,,,,\n")).unwrap_err();
883        assert!(
884            error.to_string().contains("line 2: \"x\" in cell 2"),
885            "{error}"
886        );
887    }
888
889    /// The status digits: the leftmost the most significant; anything but `b` and binary digits
890    /// refused.
891    #[test]
892    fn status_cells_are_binary_numbers() {
893        assert_eq!(status_cell("b0101"), Some(5.0));
894        assert_eq!(status_cell("b1000"), Some(8.0));
895        assert_eq!(status_cell("b1111"), Some(15.0));
896        assert!(status_cell("").unwrap().is_nan());
897        for bad in ["0101", "b", "b012", "bx", &format!("b{}", "1".repeat(53))] {
898            assert_eq!(status_cell(bad), None, "{bad}");
899        }
900    }
901
902    /// Each refusal names its line and what is wrong.
903    #[test]
904    fn malformed_files_are_refused_where_they_go_wrong() {
905        let cases = [
906            (String::new(), "the file is empty"),
907            (
908                "Time,Altitude\n0,0\n".to_owned(),
909                "isn't a FIP export's header",
910            ),
911            (
912                "Axial Accel (Gs),Time\n".to_owned(),
913                "isn't a FIP export's header",
914            ),
915            (format!("{HEADER}\n"), "no data rows"),
916            (
917                "Time@Axial Accel (Gs),Axial Accel (Gs)\n0,1\n".to_owned(),
918                "line 1: the file has no altitude above the pad",
919            ),
920            (
921                "Time@x,Time@Axial Accel (Gs),Axial Accel (Gs)\n".to_owned(),
922                "line 1: the clock \"Time@x\" has no channel",
923            ),
924            (
925                "Time@Axial Accel (Gs),bILBA,Axial Accel (Gs)\n".to_owned(),
926                "line 1: the status column 2 follows no channel",
927            ),
928            (
929                "Time@Axial Accel (Gs),Axial Accel (Gs),,x\n".to_owned(),
930                "line 1: the header's column 3 has no name",
931            ),
932            (
933                format!("{HEADER}\n0,1,b0,0.5,1,b0,0.5,0,b0\n0,1,b0,0.4,1,b0,0.6,0,b0\n"),
934                "line 3: the time 0.4 s on the clock \"Time@Baro (Atm)\" comes before",
935            ),
936            (
937                format!("{HEADER}\nx,1,b0,,,,\n"),
938                "line 2: the time \"x\" in cell 1",
939            ),
940            (
941                format!("{HEADER}\n0,1,b0,0,1,b0,0,1,b2\n"),
942                "line 2: the status \"b2\" in cell 9",
943            ),
944            (
945                format!("{HEADER}\n0,1,b0,0,1\n"),
946                "line 2: the row ends at cell 5",
947            ),
948            (
949                format!("{HEADER}\n0,1,b0,,,0.1,1,b0,7\n"),
950                "line 2: the row has 9 cells and its channels' samples take 8",
951            ),
952            (format!("{HEADER}\n0,1,b0,,,,\n"), "no data rows"),
953        ];
954        for (text, expected) in cases {
955            let error = read(&text).unwrap_err().to_string();
956            assert!(error.contains(expected), "{text:?}: {error}");
957        }
958    }
959
960    /// A byte-order mark is dropped; CR-only line ends read as lines; blank lines are skipped.
961    #[test]
962    fn marks_and_line_ends() {
963        let text = format!("\u{feff}\r{HEADER}\r0,1,b0,0,1,b0,0,1,b0\r\r");
964        let log = read(&text).unwrap();
965        assert_eq!(log.altitude_m, [FOOT_M]);
966        let lines: Vec<(usize, &str)> = numbered_lines("a\r\nb\rc\n\nd").collect();
967        assert_eq!(lines, [(1, "a"), (2, "b"), (3, "c"), (4, ""), (5, "d")]);
968    }
969
970    /// A header of many unknown columns keeps a short list of notes and counts the rest.
971    #[test]
972    fn notes_stay_few() {
973        let unknown: String = (0..100).map(|i| format!(",u{i}")).collect();
974        let text = format!("Time@[Altitude (Baro-Ft-AGL)],[Altitude (Baro-Ft-AGL)]{unknown}\n");
975        let row = format!("0{}\n", ",1".repeat(101));
976        let log = read(&format!("{text}{row}")).unwrap();
977        assert_eq!(log.notes.len(), MAX_NOTES + 1);
978        assert!(
979            log.notes[MAX_NOTES].starts_with("and 80 more notes"),
980            "{:?}",
981            log.notes
982        );
983        assert_eq!(log.channels.len(), 101);
984        assert_eq!(log.channels[100].quantity, Quantity::Unrecognized);
985    }
986
987    /// A file that isn't text is refused with a short quote of it.
988    #[test]
989    fn a_long_line_is_quoted_short() {
990        let junk = "\u{fffd}".repeat(100_000);
991        let error = read(&junk).unwrap_err().to_string();
992        assert!(error.len() < 400, "{} bytes", error.len());
993    }
994
995    proptest::proptest! {
996        /// Any text is read or refused, never a panic; what is read has increasing times and an
997        /// altitude for each.
998        #[test]
999        fn any_text_is_read_or_refused(rows in proptest::collection::vec("[-0-9.b,x#QNA]{0,24}", 0..10)) {
1000            let text = format!("{HEADER}\n{}", rows.join("\n"));
1001            if let Ok(log) = read(&text) {
1002                proptest::prop_assert!(log.time_s.windows(2).all(|pair| pair[1] > pair[0]));
1003                proptest::prop_assert_eq!(log.time_s.len(), log.altitude_m.len());
1004                for channel in &log.channels {
1005                    proptest::prop_assert!(channel.time_s.windows(2).all(|pair| pair[1] > pair[0]));
1006                    proptest::prop_assert_eq!(channel.time_s.len(), channel.values.len());
1007                }
1008            }
1009        }
1010    }
1011}