Skip to main content

hpr_flightdata/
blueraven.rs

1//! The Featherweight Blue Raven's flight logs, as its phone app exports them to comma-separated
2//! files.
3//!
4//! The Blue Raven records three sets of data for each flight: a summary, a low-rate set "recorded
5//! at 50 Hz" and a high-rate set "recorded at 500 Hz" (Featherweight, *Blue Raven User's Guide*,
6//! updated May 12 2026, p18); the Featherweight UI app downloads them over Bluetooth and exports
7//! each as a file (p19). This reader reads the two data files, each on its own: a header line
8//! naming the columns, then one row per sample, every column on the row's time:
9//!
10//! ```text
11//! Year,Month,Day,Time,Flight_Time_(s),Sync,Gyro_X,Gyro_Y,Gyro_Z,Accel_X,Accel_Y,Accel_Z,Quat_1,…
12//! 2026,5,23,10:15:00.120,-2.000,0,0.10,-0.20,0.05,0.01,-0.02,1.00,0.7071,…
13//! ```
14//!
15//! A file is the app's when its first line that isn't blank opens with the six columns
16//! `Year,Month,Day,Time,Flight_Time_(s),Sync`, as both files do. The high-rate file has 18 columns,
17//! the low-rate file 101; columns are mapped by name, not by place, so the low-rate file's
18//! variants read alike (in some, `Axial Accel (Gs),Lateral Accel (Gs)` stand where others have
19//! `3rd_Volts,4th_Volts`, and one names the fifth FER column `Apo_FER_H  ex`, with two spaces).
20//! A name is matched with its spaces taken out and without regard to case, and kept as written.
21//! The low-rate file repeats some names (`Reserved`, and the four output channels' condition
22//! flags such as `LT_AGL1`): each is its own channel, in the file's order.
23//!
24//! **Channels.** Every column but the clock is a channel, read into the record's SI unit
25//! ([`Quantity::unit`]):
26//!
27//! | Column | Quantity | From |
28//! |---|---|---|
29//! | `Flight_Time_(s)` | the clock, s, zero at liftoff | the header |
30//! | `Time` | [`Quantity::TimeOfDay`], s after the logger's midnight | written `HH:MM:SS.fff`; no zone is stated |
31//! | `Year`, `Month`, `Day`, `Sync` | [`Quantity::Number`] | the date the logger's clock gives; `Sync` is "a millisecond counter … recorded in both the low-rate and high-rate data so that the two data sets can be aligned", which "rolls over every 250 msec" (p23) |
32//! | `Temperature_(F)` | [`Quantity::Temperature`] | the header's °F |
33//! | `Baro_Press_(atm)` | [`Quantity::Pressure`] | the header's atm, × 101,325 Pa |
34//! | `Baro_Altitude_AGL_(feet)`, `Baro_Altitude_ASL_(feet)` | [`Quantity::AltitudeAboveGround`], [`Quantity::AltitudeMsl`] | the header's feet |
35//! | `Batt_Volts`, `Apo_Volts`, `Main_Volts`, `3rd_Volts`, `4th_Volts`, `Aux_Volts` | [`Quantity::Voltage`] | the header's volts |
36//! | `Axial Accel (Gs)`, `Lateral Accel (Gs)` | [`Quantity::Acceleration`] | the header's g, × 9.806 65 m/s² (standard gravity) |
37//! | `Velocity_Up`, `Velocity_DR`, `Velocity_CR` | [`Quantity::Speed`] | ft/s: the manual and the summary (below) |
38//! | `Inertial_Altitude` | [`Quantity::InertialAltitude`] | ft: the manual and the summary (below) |
39//! | `Inertial_DR_Position`, `Inertial_CR_position` | [`Quantity::Distance`] | ft: the manual (below) |
40//! | `Tilt_Angle_(deg)`, `Future_Angle_(deg)`, `Roll_Angle_(deg)` | [`Quantity::Angle`] | the header's degrees |
41//! | `Gyro_X`, `Gyro_Y`, `Gyro_Z` | [`Quantity::AngularRate`] | °/s: the manual and the summary (below) |
42//! | `Accel_X`, `Accel_Y`, `Accel_Z` | [`Quantity::Acceleration`] | g, × 9.806 65 m/s²: the manual and the pad (below) |
43//! | `Quat_1` … `Quat_4` | [`Quantity::Number`] | the attitude quaternion, "the rotation axis first and the magnitude term fourth" (p24), of unit norm |
44//! | `Current` | [`Quantity::Unrecognized`], as written | no source states its unit (below) |
45//! | `Rocket_FER_Hex`, `Apo_FER_Hex` and the other `…_FER_Hex` | [`Quantity::Number`]: the register's bits | hexadecimal in the file (below) |
46//! | `Liftoff`, `Apogee` and the other flags | [`Quantity::Number`], 0 or 1 | below |
47//! | `Reserved`, `Reserved_1` … | [`Quantity::Unrecognized`], as written | the manual describes no reserved column |
48//!
49//! A column the reader doesn't know is kept as written, [`Quantity::Unrecognized`], with a note.
50//!
51//! **Units no header states.** Thirteen columns carry no unit in their names. The manual states
52//! each one's unit for the logger's USB download, which carries the same figures (p23–24):
53//! velocities in ft/sec, the inertial altitude and the down-range and cross-range positions in
54//! feet, gyro rates in deg/s × 100, accelerations in G × 100, current in mA. The app writes the
55//! gyro rates and accelerations unscaled by the hundred, so its file's units are decided from what
56//! the app's own files show. The counts below are, as of 2026-10-12, over the 80 Blue Raven files
57//! among the project's sample logs (45 low-rate, 35 high-rate), most of them in the private
58//! collection of flights this project checks against:
59//!
60//! - *Velocities and the inertial altitude, in feet.* Beside a flight, the app's summary file
61//!   states "Max velocity" in "feet/sec" and "Inertial navigation max alt" in "feet". Of the 32
62//!   summaries that sit beside a low-rate file, 25 state an inertial maximum within 0.5 % of a
63//!   low-rate file's highest `Inertial_Altitude`, and in those the highest `Velocity_Up` is 0.69
64//!   to 1.0 times the summary's maximum velocity, which counts every direction; in meters per
65//!   second it would be 0.3 times. Debrief's public pair agrees: its file's highest speed and
66//!   inertial altitude are within 0.5 % of its summary's, in feet. The barometer agrees too: integrated over
67//!   the climb to the barometer's highest sample, `Velocity_Up` gives 0.90 to 1.20 times the
68//!   rise of `Baro_Altitude_AGL_(feet)` in 43 of the 45 low-rate files. The down-range and
69//!   cross-range positions take the altitude's unit, as the manual gives them.
70//! - *Gyro rates in degrees per second.* The summary states "Pad gyro rates (deg/sec)", and the
71//!   manual gives the gyros' range as ±2000 deg/sec (p23): in 21 of the 35 high-rate files the
72//!   largest rate sits at a common ceiling of 2,292 to 2,294, just past that range, as a
73//!   saturated gyro reads.
74//! - *Accelerations in g.* On the pad, the median magnitude of the three axes is 0.990 to 1.014 in
75//!   34 of the 35 high-rate files, as a logger at rest reads 1 g.
76//! - *Current, kept as written.* No document states the app's unit. The column reads 0 in every
77//!   row of 6 high-rate files, and in the other 29 but for brief runs, peaking at 0.05 to 13, 13
78//!   of them between 2 and 3.5. Amperes would fit the manual's word that the Blue Raven
79//!   "internally limits the current to around 4 Amps" (p5), and the USB download's milliamperes
80//!   would put a firing output's current at a few thousandths of an ampere; but the files show a
81//!   size, not a unit, so the column stays [`Quantity::Unrecognized`] until a source states it,
82//!   with a note giving this file's figures.
83//!
84//! Each file's record carries one note per kind decided this way, the decision and this file's
85//! own evidence, such as `` `Gyro_X`, `Gyro_Y` and `Gyro_Z` read in degrees per second, from the
86//! manual and the summary: … ``.
87//!
88//! **Flags, event registers and events.** The manual's five flight event registers hold "one bit
89//! for each event", written in hexadecimal (p24): the rocket's (`Rocket_FER_Hex`) and each output
90//! channel's (`Apo_FER_Hex`, `Main_FER_Hex`, `3rd_FER_Hex`, `4th_FER_Hex`). The cells are lowercase
91//! hexadecimal digits without `0x`, read as the number they write, `1a5` as 421. The files name
92//! the bits: the 0/1 columns after the registers spell them out, and in every row of the 45
93//! low-rate files read, `Rocket_FER_Hex` equals the 13 flags `Liftoff` … `Reserved` read with
94//! `Liftoff` as bit 0, and each channel's register equals its 14 condition flags `LT_AGL1` …
95//! `Reserved`, `LT_AGL1` as bit 0. Each flag is kept as a channel, 0 or 1. The manual's tables of
96//! deployment settings list the rocket's events as liftoff, apogee, pressure increasing, burnout
97//! coast and each output fired (p26–28); the channel flags are the conditions an output's logic
98//! checks, such as `LT_AGL1`, "AGL < AGL1" (p25).
99//!
100//! Seven flags mark a moment and become an [`Event`], named as the column, at each row where the
101//! flag goes from 0 to 1: `Liftoff`, `Apogee`, `Burnout_Coast`, `Apo_fired`, `Main_fired`,
102//! `3rd_fired` and `4th_fired`. As of 2026-10-12, in the 45 low-rate files among the project's
103//! sample logs, each rises at most 15 times in a file, and in most once or twice (twice in a file
104//! holding two flights, or a stage's second burn). The others stay channels only: `Press_Increasing`,
105//! `Normal_Ascent`, `Accel_Vel_LE_0`, `ECI_Vvel_le_0` and `Tilt Exceeded 90deg` state a condition
106//! that comes and goes (`ECI_Vvel_le_0` rises up to 34 times in a file), and the channel flags
107//! are a deployment logic's conditions, not what happened. A flag already 1 on the first row
108//! rose before the file began, so it gives no event, with a note; past [`MAX_EVENTS_PER_FLAG`]
109//! rises in one column, the rest are counted in a note instead.
110//!
111//! **Clocks.** `Flight_Time_(s)` is the clock: strictly increasing in every file read, from a
112//! negative time on the pad in all but one. A time that doesn't increase is refused, naming its
113//! line. The wall clock `Time` is a channel only: as of 2026-10-12, among the sample logs, it
114//! holds one value for several rows at a time in 5 of the high-rate files, and steps back in 4 of
115//! the low-rate files, from once to hundreds of times, by up to about 14.5 hours. It is kept as
116//! written, and a note says when it goes back. `Sync` steps by 2 a row in the high-rate files and
117//! by 20 in the low-rate ones (40 where the file is logged every 0.04 s), in milliseconds, wrapping
118//! at 250.
119//!
120//! **The primary track.** For a low-rate file, [`FlightLog::time_s`] is `Flight_Time_(s)` and
121//! [`FlightLog::altitude_m`] is `Baro_Altitude_AGL_(feet)`, the barometer's altitude above the
122//! pad; the temperature and battery are `Temperature_(F)` and `Batt_Volts`. The vertical speed is
123//! `None`: `Velocity_Up` is the inertial solution's, which "lose\[s\] track of which way is up"
124//! when the rocket turns faster than the gyros measure (p23), not the barometer's, so it is a
125//! channel only. As of 2026-10-12, in 10 of the 45 low-rate files `Inertial_Altitude` steps by
126//! more than 32,768 ft between two rows, as a 16-bit count of feet does when it wraps; it is kept
127//! as written, with a note. A high-rate file holds no altitude: it is read with an empty primary track and every
128//! column as a channel, with a note that the low-rate file of the same flight holds the altitude.
129//!
130//! **Cells and rows.** A cell that is empty, or isn't a number (or a time of day, or hexadecimal
131//! digits, as its column needs) is a gap, `NaN`, counted per column in a note; none of the files
132//! read has one. A row with fewer cells than the header has the rest as gaps, counted in a note;
133//! a row with more is refused, as is a row whose clock cell isn't a number. A file whose rows, down
134//! to any row, hold under a quarter of the cells its header names (past the first 65,536) is
135//! refused at that row, so a wide header over short rows can't make the reader allocate
136//! far more than the file holds. Blank lines are skipped;
137//! lines may end in CR, LF or both; a byte-order mark is dropped; cells may be padded with spaces.
138//!
139//! **What the file says about itself.** The logger is a `Featherweight Blue Raven`; the data
140//! files state no serial number, firmware, flight number or apogee (the summary file does).
141//!
142//! **Where this comes from.** The layout, from the files themselves and Featherweight's manual as
143//! cited; the column names and the gyro and acceleration units agree with Debrief's reader
144//! (`lib/parsers/blueraven.ts`, MIT, the project owner's own; see `THIRD-PARTY-NOTICES.md`). Where
145//! this reader departs from Debrief's: it keeps every column, where Debrief keeps a chosen few; it
146//! reads a high-rate file on its own, where Debrief refuses it and points to the low-rate file; it
147//! keeps `Inertial_Altitude` whole, with a note, where Debrief cuts it off where it wraps or
148//! leaves the barometer's altitude by more than the flight's height.
149
150use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
151
152use crate::error::LogError;
153use crate::log::{Channel, Event, FlightLog, LogFormat, Quantity, Stated, too_sparse};
154use crate::perfectflite::FOOT_M;
155use crate::table::{Notes, number, numbered_lines, short};
156
157/// The format's name in errors.
158const FORMAT: &str = "Blue Raven CSV";
159
160/// The columns both of the app's files open with, in order.
161const LEAD: [&str; 6] = ["Year", "Month", "Day", "Time", "Flight_Time_(s)", "Sync"];
162
163/// The place of the clock, `Flight_Time_(s)`, among [`LEAD`]'s columns (0-based).
164const CLOCK_INDEX: usize = 4;
165
166/// Pascals in a standard atmosphere, exactly (ISO 2533).
167const ATM_PA: f64 = 101_325.0;
168
169/// The most events one flag column gives; past it, its rises are counted in a note. The files
170/// read rise at most 15 times in a column.
171pub const MAX_EVENTS_PER_FLAG: usize = 16;
172
173/// How far `Inertial_Altitude` may step between two rows, ft, before the step is counted as a
174/// wrap: half the span of a 16-bit count of feet.
175const WRAP_STEP_FT: f64 = 32_768.0;
176
177/// The most rows from the file's start the acceleration's pad magnitude is taken over when the
178/// file has no row before liftoff.
179const PAD_ROWS: usize = 50;
180
181/// Whether the text is in this reader's layout, judged from its header, never the file's name:
182/// its first line that isn't blank opens with [`LEAD`]'s six columns and names more.
183pub(crate) fn recognizes(text: &str) -> bool {
184    numbered_lines(text)
185        .map(|(_, line)| line)
186        .find(|line| !line.trim().is_empty())
187        .is_some_and(opens_with_lead)
188}
189
190/// Whether a header line opens with [`LEAD`]'s columns and names at least one more.
191fn opens_with_lead(header: &str) -> bool {
192    let mut cells = header.split(',').map(str::trim);
193    LEAD.iter().all(|name| cells.next() == Some(*name)) && cells.next().is_some()
194}
195
196/// How a value becomes SI.
197#[derive(Debug, Clone, Copy, PartialEq)]
198enum Scale {
199    /// Multiplied by a factor.
200    By(f64),
201    /// Degrees Fahrenheit to kelvin.
202    Fahrenheit,
203}
204
205/// What a column is to this reader.
206#[derive(Debug, Clone, Copy, PartialEq)]
207enum Role {
208    /// `Flight_Time_(s)`: the clock.
209    Clock,
210    /// `Time`: the wall clock, `HH:MM:SS.fff`.
211    TimeOfDay,
212    /// A number in a quantity, made SI by a scale.
213    Value(Quantity, Scale),
214    /// An event register, hexadecimal digits.
215    Hex,
216    /// A 0/1 flag; `event` if its rise marks a moment.
217    Flag { event: bool },
218}
219
220/// The kinds of column whose unit no header states, each decided once, with a note.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222enum Decided {
223    Velocity,
224    Inertial,
225    Gyro,
226    Accel,
227}
228
229/// A column's name with its spaces taken out, in lower case, as [`role`] matches it.
230fn key(name: &str) -> String {
231    name.chars()
232        .filter(|c| !c.is_whitespace())
233        .collect::<String>()
234        .to_ascii_lowercase()
235}
236
237/// What a column is, and which decided kind it falls under; `None` for a name the reader doesn't
238/// know. The clock is matched only in its place ([`CLOCK_INDEX`]).
239fn role(name: &str) -> Option<(Role, Option<Decided>)> {
240    use Quantity as Q;
241    let key = key(name);
242    let value = |quantity, scale| Some((Role::Value(quantity, scale), None));
243    let decided = |quantity, scale, kind| Some((Role::Value(quantity, scale), Some(kind)));
244    let feet = Scale::By(FOOT_M);
245    let gravities = Scale::By(STANDARD_GRAVITY_MPS2);
246    let one = Scale::By(1.0);
247    match key.as_str() {
248        "time" => Some((Role::TimeOfDay, None)),
249        "year" | "month" | "day" | "sync" | "quat_1" | "quat_2" | "quat_3" | "quat_4" => {
250            value(Q::Number, one)
251        }
252        "temperature_(f)" => value(Q::Temperature, Scale::Fahrenheit),
253        "baro_press_(atm)" => value(Q::Pressure, Scale::By(ATM_PA)),
254        "baro_altitude_agl_(feet)" => value(Q::AltitudeAboveGround, feet),
255        "baro_altitude_asl_(feet)" => value(Q::AltitudeMsl, feet),
256        "axialaccel(gs)" | "lateralaccel(gs)" => value(Q::Acceleration, gravities),
257        "tilt_angle_(deg)" | "future_angle_(deg)" | "roll_angle_(deg)" => value(Q::Angle, one),
258        "velocity_up" | "velocity_dr" | "velocity_cr" => decided(Q::Speed, feet, Decided::Velocity),
259        "inertial_altitude" => decided(Q::InertialAltitude, feet, Decided::Inertial),
260        "inertial_dr_position" | "inertial_cr_position" => {
261            decided(Q::Distance, feet, Decided::Inertial)
262        }
263        "gyro_x" | "gyro_y" | "gyro_z" => decided(Q::AngularRate, one, Decided::Gyro),
264        "accel_x" | "accel_y" | "accel_z" => decided(Q::Acceleration, gravities, Decided::Accel),
265        "current" => value(Q::Unrecognized, one),
266        "liftoff" | "apogee" | "burnout_coast" | "apo_fired" | "main_fired" | "3rd_fired"
267        | "4th_fired" => Some((Role::Flag { event: true }, None)),
268        "press_increasing" | "normal_ascent" | "accel_vel_le_0" | "eci_vvel_le_0"
269        | "tiltexceeded90deg" | "lt_agl1" | "gt_agl2" | "lt_fang1" | "gt_fang2" | "gt_futang"
270        | "lt_tval1" | "gt_tval2" | "lt_vel1" | "gt_vel2" | "lt_bvel1" | "gt_burn" | "armed" => {
271            Some((Role::Flag { event: false }, None))
272        }
273        _ if key.ends_with("_volts") => value(Q::Voltage, one),
274        _ if key.ends_with("_fer_hex") => Some((Role::Hex, None)),
275        _ => None,
276    }
277}
278
279/// One column of the file, as read so far.
280struct Column {
281    name: String,
282    key: String,
283    role: Role,
284    decided: Option<Decided>,
285    values: Vec<f64>,
286    /// Cells that were empty or not what the column holds, and the first of them as written.
287    gaps: usize,
288    gap_example: Option<String>,
289    /// For an event flag: the events it gave, rises past the cap, and whether the first row was
290    /// already set.
291    events_kept: usize,
292    rises_left_out: usize,
293    set_on_first_row: bool,
294}
295
296impl Column {
297    fn quantity(&self) -> Quantity {
298        match self.role {
299            Role::Value(quantity, _) => quantity,
300            Role::TimeOfDay => Quantity::TimeOfDay,
301            Role::Clock | Role::Hex | Role::Flag { .. } => Quantity::Number,
302        }
303    }
304}
305
306/// Reads a Featherweight Blue Raven's low-rate or high-rate CSV, as its phone app exports it.
307///
308/// # Errors
309///
310/// [`LogError::NotThisFormat`] if the first line isn't the app's header; [`LogError::Syntax`] for
311/// a header column with no name, a row with more cells than the header, a row whose
312/// `Flight_Time_(s)` is missing, isn't a number or doesn't come after the row above's, or rows
313/// holding under a quarter of the cells the header names; [`LogError::NoData`] if no row follows
314/// the header.
315pub fn read(text: &str) -> Result<FlightLog, LogError> {
316    let mut lines = numbered_lines(text).filter(|(_, line)| !line.trim().is_empty());
317    let Some((header_line, header)) = lines.next() else {
318        return Err(LogError::NotThisFormat {
319            format: FORMAT,
320            message: "the file is empty".to_owned(),
321        });
322    };
323    if !opens_with_lead(header) {
324        return Err(LogError::NotThisFormat {
325            format: FORMAT,
326            message: format!(
327                "its first line, {:?}, isn't the app's header, which opens `{}`",
328                short(header),
329                LEAD.join(",")
330            ),
331        });
332    }
333    // Notes about single columns, capped; notes about the whole file, one each at most.
334    let mut notes = Notes::default();
335    let mut file_notes = Vec::new();
336    let mut columns = header_columns(header, header_line, &mut notes, &mut file_notes)?;
337
338    let mut time_s: Vec<f64> = Vec::new();
339    let mut events: Vec<Event> = Vec::new();
340    let (mut rows, mut cells_written) = (0usize, 0usize);
341    let mut short_rows = 0usize;
342    let mut first_short_row = 0usize;
343    for (number, line) in lines {
344        let cells = read_row(&mut columns, &mut time_s, &mut events, line, number)?;
345        rows += 1;
346        cells_written += cells;
347        // Checked row by row, so that a wide header over short rows is refused before the gaps
348        // it would leave grow past a quarter more than the cells the file holds.
349        if too_sparse(columns.len(), rows, cells_written) {
350            return Err(syntax(
351                number,
352                format!(
353                    "the header names {} columns, and the {rows} rows down to this one hold only \
354                     {cells_written} cells",
355                    columns.len()
356                ),
357            ));
358        }
359        if cells < columns.len() {
360            if short_rows == 0 {
361                first_short_row = number;
362            }
363            short_rows += 1;
364        }
365    }
366    if rows == 0 {
367        return Err(LogError::NoData { format: FORMAT });
368    }
369    if short_rows > 0 {
370        file_notes.push(format!(
371            "{short_rows} rows, the first on line {first_short_row}, have fewer cells than the \
372             header's {}: the cells they leave out are gaps",
373            columns.len()
374        ));
375    }
376    Ok(build(columns, time_s, events, notes, file_notes))
377}
378
379/// The columns the header names, each with its role; unknown names noted.
380fn header_columns(
381    header: &str,
382    line: usize,
383    notes: &mut Notes,
384    file_notes: &mut Vec<String>,
385) -> Result<Vec<Column>, LogError> {
386    let mut columns = Vec::new();
387    let mut reserved = 0usize;
388    for (index, name) in header.split(',').map(str::trim).enumerate() {
389        if name.is_empty() {
390            return Err(syntax(
391                line,
392                format!("the header's column {} has no name", index + 1),
393            ));
394        }
395        let (role, decided) = if index == CLOCK_INDEX {
396            (Role::Clock, None)
397        } else if key(name).starts_with("reserved") {
398            reserved += 1;
399            (Role::Value(Quantity::Unrecognized, Scale::By(1.0)), None)
400        } else {
401            role(name).unwrap_or_else(|| {
402                notes.push(format!(
403                    "the column {:?} isn't one this reader knows, and was kept as written",
404                    short(name)
405                ));
406                (Role::Value(Quantity::Unrecognized, Scale::By(1.0)), None)
407            })
408        };
409        columns.push(Column {
410            name: name.to_owned(),
411            key: key(name),
412            role,
413            decided,
414            values: Vec::new(),
415            gaps: 0,
416            gap_example: None,
417            events_kept: 0,
418            rises_left_out: 0,
419            set_on_first_row: false,
420        });
421    }
422    if reserved > 0 {
423        file_notes.push(format!(
424            "{reserved} columns named `Reserved…` hold what the manual doesn't describe, and were \
425             kept as written"
426        ));
427    }
428    Ok(columns)
429}
430
431/// Reads one row into the clock and the columns, and any event its flags mark; returns how many
432/// cells it has.
433fn read_row(
434    columns: &mut [Column],
435    time_s: &mut Vec<f64>,
436    events: &mut Vec<Event>,
437    line: &str,
438    number: usize,
439) -> Result<usize, LogError> {
440    let mut cells = line.split(',').map(str::trim);
441    // The clock comes before every flag, so a flag's event takes the row's time.
442    let mut row_time = f64::NAN;
443    let mut count = 0;
444    for (index, column) in columns.iter_mut().enumerate() {
445        let Some(cell) = cells.next() else {
446            if column.role == Role::Clock {
447                return Err(syntax(
448                    number,
449                    format!(
450                        "the row ends after {count} cells, before its {:?}",
451                        short(&column.name)
452                    ),
453                ));
454            }
455            column.values.push(f64::NAN);
456            continue;
457        };
458        count += 1;
459        if column.role == Role::Clock {
460            let time = number_cell(cell).ok_or_else(|| {
461                syntax(
462                    number,
463                    format!(
464                        "the time {:?} in cell {} isn't a finite number",
465                        short(cell),
466                        index + 1
467                    ),
468                )
469            })?;
470            if let Some(&last) = time_s.last()
471                && time <= last
472            {
473                return Err(syntax(
474                    number,
475                    format!("the time {time} s doesn't come after the one above it, {last} s"),
476                ));
477            }
478            row_time = time;
479            time_s.push(time);
480            continue;
481        }
482        let value = match column.role {
483            Role::Value(_, scale) => number_cell(cell).map(|value| si(scale, value)),
484            Role::TimeOfDay => time_of_day(cell),
485            Role::Hex => hex(cell),
486            Role::Flag { .. } => number_cell(cell),
487            Role::Clock => None,
488        };
489        let value = value.unwrap_or_else(|| {
490            column.gaps += 1;
491            if column.gap_example.is_none() {
492                column.gap_example = Some(short(cell));
493            }
494            f64::NAN
495        });
496        if let Role::Flag { event: true } = column.role {
497            flag_event(column, value, row_time, events);
498        }
499        column.values.push(value);
500    }
501    if cells.next().is_some() {
502        return Err(syntax(
503            number,
504            format!(
505                "the row has {} cells, more than the header's {}",
506                line.split(',').count(),
507                columns.len()
508            ),
509        ));
510    }
511    Ok(count)
512}
513
514/// An event when a flag rises from 0 to 1, up to [`MAX_EVENTS_PER_FLAG`] per column; a flag set
515/// on the first row is marked instead. Called before the value is stored.
516fn flag_event(column: &mut Column, value: f64, time_s: f64, events: &mut Vec<Event>) {
517    let risen = value == 1.0;
518    match column.values.last() {
519        None => column.set_on_first_row = risen,
520        Some(&before) if before == 0.0 && risen => {
521            if column.events_kept < MAX_EVENTS_PER_FLAG {
522                column.events_kept += 1;
523                events.push(Event {
524                    time_s,
525                    name: column.name.clone(),
526                });
527            } else {
528                column.rises_left_out += 1;
529            }
530        }
531        Some(_) => {}
532    }
533}
534
535/// A cell as a finite number, `None` if it isn't one.
536fn number_cell(cell: &str) -> Option<f64> {
537    number(cell, false)
538}
539
540/// A value in SI.
541fn si(scale: Scale, value: f64) -> f64 {
542    match scale {
543        Scale::By(factor) => value * factor,
544        Scale::Fahrenheit => (value - 32.0) * 5.0 / 9.0 + 273.15,
545    }
546}
547
548/// A wall-clock cell, `H:MM:SS` or `HH:MM:SS` with any decimals, as seconds after midnight;
549/// `None` for anything else, or a time past 23:59:60.999….
550fn time_of_day(cell: &str) -> Option<f64> {
551    let mut parts = cell.split(':');
552    let (hours, minutes, seconds) = (parts.next()?, parts.next()?, parts.next()?);
553    if parts.next().is_some() {
554        return None;
555    }
556    let digits = |text: &str| !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit());
557    let (whole, fraction) = seconds.split_once('.').unwrap_or((seconds, "0"));
558    if !(digits(hours) && hours.len() <= 2)
559        || !(digits(minutes) && minutes.len() == 2)
560        || !(digits(whole) && whole.len() == 2)
561        || !digits(fraction)
562    {
563        return None;
564    }
565    let hours: u32 = hours.parse().ok()?;
566    let minutes: u32 = minutes.parse().ok()?;
567    let seconds: f64 = seconds.parse().ok()?;
568    (hours < 24 && minutes < 60 && seconds < 61.0)
569        .then(|| f64::from(hours * 3_600 + minutes * 60) + seconds)
570}
571
572/// An event register's cell, hexadecimal digits with or without `0x`, as the number they write;
573/// `None` for anything else, or more digits than an `f64` holds exactly.
574fn hex(cell: &str) -> Option<f64> {
575    let digits = cell
576        .strip_prefix("0x")
577        .or_else(|| cell.strip_prefix("0X"))
578        .unwrap_or(cell);
579    if digits.is_empty() || digits.len() > 13 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
580        return None;
581    }
582    // At most 13 hexadecimal digits, 52 bits, so the value is below 2⁵² and exact as an f64.
583    u64::from_str_radix(digits, 16).ok().map(|bits| bits as f64)
584}
585
586/// The record from the columns read: every channel, the primary track, the events and the notes.
587fn build(
588    columns: Vec<Column>,
589    time_s: Vec<f64>,
590    events: Vec<Event>,
591    mut notes: Notes,
592    mut file_notes: Vec<String>,
593) -> FlightLog {
594    let mut log = FlightLog {
595        format: LogFormat::BlueRavenCsv,
596        logger: "Featherweight Blue Raven".to_owned(),
597        serial_number: None,
598        firmware: None,
599        flight_number: None,
600        stated: Stated::default(),
601        time_s: Vec::new(),
602        altitude_m: Vec::new(),
603        vertical_speed_m_s: None,
604        temperature_k: None,
605        battery_v: None,
606        channels: Vec::new(),
607        events,
608        notes: decided_notes(&columns, &time_s),
609    };
610
611    for column in &columns {
612        if column.gaps > 0 {
613            notes.push(format!(
614                "the column {:?} holds {} cells that are empty or not what it holds, such as \
615                 {:?}: they are gaps",
616                short(&column.name),
617                column.gaps,
618                column.gap_example.as_deref().unwrap_or_default()
619            ));
620        }
621        if column.set_on_first_row {
622            notes.push(format!(
623                "the flag {:?} is already 1 on the first row: it rose before the file begins, so \
624                 the file doesn't say when",
625                short(&column.name)
626            ));
627        }
628        if column.rises_left_out > 0 {
629            notes.push(format!(
630                "the flag {:?} rises {} more times than the {MAX_EVENTS_PER_FLAG} kept as events",
631                short(&column.name),
632                column.rises_left_out
633            ));
634        }
635        match column.role {
636            Role::TimeOfDay => {
637                let (backs, largest) = steps_back(&column.values);
638                if backs > 0 {
639                    notes.push(format!(
640                        "the logger's time of day, {:?}, goes back {backs} times, by up to \
641                         {largest:.3} s: it is kept as written, and `Flight_Time_(s)` is the clock",
642                        short(&column.name)
643                    ));
644                }
645            }
646            Role::Value(Quantity::InertialAltitude, _) => {
647                let wraps = column
648                    .values
649                    .windows(2)
650                    .filter(|pair| (pair[1] - pair[0]).abs() / FOOT_M > WRAP_STEP_FT)
651                    .count();
652                if wraps > 0 {
653                    notes.push(format!(
654                        "{:?} steps by more than 32,768 ft between two rows {wraps} times, as a \
655                         16-bit count of feet does when it wraps: it is kept as written",
656                        short(&column.name)
657                    ));
658                }
659            }
660            _ => {}
661        }
662    }
663
664    let altitude = columns.iter().find(|c| c.key == "baro_altitude_agl_(feet)");
665    if let Some(altitude) = altitude {
666        log.time_s.clone_from(&time_s);
667        log.altitude_m.clone_from(&altitude.values);
668        log.temperature_k = columns
669            .iter()
670            .find(|c| c.key == "temperature_(f)")
671            .map(|c| c.values.clone());
672        log.battery_v = columns
673            .iter()
674            .find(|c| c.key == "batt_volts")
675            .map(|c| c.values.clone());
676    } else {
677        file_notes.push(
678            "the file has no `Baro_Altitude_AGL_(feet)`, so the record's altitude track is empty: \
679             a Blue Raven's high-rate file holds its inertial sensors only, and the low-rate file \
680             of the same flight holds the altitude"
681                .to_owned(),
682        );
683    }
684
685    for column in columns {
686        if column.role == Role::Clock {
687            continue;
688        }
689        log.channels.push(Channel {
690            quantity: column.quantity(),
691            column: column.name,
692            time_s: time_s.clone(),
693            values: column.values,
694        });
695    }
696    log.notes.extend(file_notes);
697    log.notes.extend(notes.into_vec());
698    log
699}
700
701/// How many times a list of times of day goes back, and the largest step back, s.
702fn steps_back(values: &[f64]) -> (usize, f64) {
703    values
704        .windows(2)
705        .map(|pair| pair[0] - pair[1])
706        .filter(|back| *back > 0.0)
707        .fold((0, 0.0), |(count, largest), back| {
708            (count + 1, f64::max(largest, back))
709        })
710}
711
712/// The notes on units no header states: one per decided kind the file holds, its columns, the
713/// decision, and this file's evidence.
714fn decided_notes(columns: &[Column], time_s: &[f64]) -> Vec<String> {
715    let find = |key: &str| columns.iter().find(|c| c.key == key);
716    let altitude = find("baro_altitude_agl_(feet)");
717    let kinds = [
718        (
719            Decided::Velocity,
720            "feet per second",
721            "the manual and the summary",
722        ),
723        (Decided::Inertial, "feet", "the manual and the summary"),
724        (
725            Decided::Gyro,
726            "degrees per second",
727            "the manual and the summary",
728        ),
729        (
730            Decided::Accel,
731            "standard gravities",
732            "the manual and the pad",
733        ),
734    ];
735    let mut notes = Vec::new();
736    for (kind, unit, source) in kinds {
737        let names: Vec<String> = columns
738            .iter()
739            .filter(|c| c.decided == Some(kind))
740            .map(|c| format!("`{}`", short(&c.name)))
741            .collect();
742        if names.is_empty() {
743            continue;
744        }
745        let evidence = match kind {
746            Decided::Velocity => velocity_evidence(find("velocity_up"), altitude, time_s),
747            Decided::Inertial => inertial_evidence(find("inertial_altitude"), altitude),
748            Decided::Gyro => gyro_evidence(columns),
749            Decided::Accel => {
750                accel_evidence([find("accel_x"), find("accel_y"), find("accel_z")], time_s)
751            }
752        };
753        notes.push(format!(
754            "{} read in {unit}, from {source}: {evidence}",
755            list(&names)
756        ));
757    }
758    if let Some(current) = find("current") {
759        notes.push(current_note(current));
760    }
761    notes
762}
763
764/// The most names a decided-unit note lists; past it, the rest are counted.
765const MAX_LISTED: usize = 6;
766
767/// Names joined as `a`, `a and b`, or `a, b and c`; past [`MAX_LISTED`], the first few and a
768/// count of the rest, so that a header repeating a name many times keeps its note short.
769fn list(names: &[String]) -> String {
770    if names.len() > MAX_LISTED {
771        let shown = MAX_LISTED - 1;
772        return format!(
773            "{} and {} more columns",
774            names[..shown].join(", "),
775            names.len() - shown
776        );
777    }
778    match names {
779        [] => String::new(),
780        [only] => only.clone(),
781        [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
782    }
783}
784
785/// The highest finite value of a list and its place.
786fn highest(values: &[f64]) -> Option<(usize, f64)> {
787    values
788        .iter()
789        .copied()
790        .enumerate()
791        .filter(|(_, value)| value.is_finite())
792        .fold(None, |best, (index, value)| match best {
793            Some((_, top)) if top >= value => best,
794            _ => Some((index, value)),
795        })
796}
797
798/// `Velocity_Up` integrated over the climb to the barometer's highest sample, against the
799/// barometer's rise over the same rows, both in feet.
800fn velocity_evidence(up: Option<&Column>, altitude: Option<&Column>, time_s: &[f64]) -> String {
801    let manual = "the manual gives the USB download's velocities in ft/sec";
802    let (Some(up), Some(altitude)) = (up, altitude) else {
803        return format!(
804            "{manual}; the file has no `Velocity_Up` and barometric altitude to compare"
805        );
806    };
807    let Some((peak, top)) = highest(&altitude.values) else {
808        return format!("{manual}; the file's barometric altitude has no value");
809    };
810    let first = altitude.values.iter().find(|value| value.is_finite());
811    let rise_ft = first.map_or(f64::NAN, |first| (top - first) / FOOT_M);
812    let climbed_ft: f64 = (1..=peak)
813        .filter_map(|index| {
814            let (v0, v1) = (up.values.get(index - 1)?, up.values.get(index)?);
815            let dt = time_s.get(index)? - time_s.get(index - 1)?;
816            (v0.is_finite() && v1.is_finite()).then(|| 0.5 * (v0 + v1) * dt / FOOT_M)
817        })
818        .sum();
819    if rise_ft.is_finite() && rise_ft > 0.0 {
820        format!(
821            "over the climb to the barometer's highest sample, `Velocity_Up` integrates to \
822             {climbed_ft:.0} ft against the barometer's rise of {rise_ft:.0} ft, a ratio of {:.2}",
823            climbed_ft / rise_ft
824        )
825    } else {
826        format!("{manual}; the barometric altitude doesn't rise to compare with")
827    }
828}
829
830/// `Inertial_Altitude`'s peak against the barometer's, both in feet.
831fn inertial_evidence(inertial: Option<&Column>, altitude: Option<&Column>) -> String {
832    let manual = "the manual gives the USB download's inertial altitude and ranges in feet";
833    let peaks = inertial
834        .and_then(|c| highest(&c.values))
835        .zip(altitude.and_then(|c| highest(&c.values)));
836    match peaks {
837        Some(((_, inertial), (_, baro))) => format!(
838            "`Inertial_Altitude` peaks at {:.0} ft against the barometric altitude's {:.0} ft",
839            inertial / FOOT_M,
840            baro / FOOT_M
841        ),
842        None => format!("{manual}; the file has no inertial and barometric peaks to compare"),
843    }
844}
845
846/// The largest gyro rate in the file, against the gyros' stated range.
847fn gyro_evidence(columns: &[Column]) -> String {
848    let largest = columns
849        .iter()
850        .filter(|c| c.decided == Some(Decided::Gyro))
851        .flat_map(|c| c.values.iter())
852        .filter(|value| value.is_finite())
853        .fold(0.0_f64, |largest, value| largest.max(value.abs()));
854    format!(
855        "the largest rate in the file is {largest:.0}, against the gyros' range of ±2000 deg/sec \
856         the manual gives"
857    )
858}
859
860/// The median magnitude of the three accelerations on the pad, in g: over the rows before
861/// liftoff (a negative `Flight_Time_(s)`), or the first [`PAD_ROWS`] rows if there are none.
862fn accel_evidence(axes: [Option<&Column>; 3], time_s: &[f64]) -> String {
863    let [Some(x), Some(y), Some(z)] = axes else {
864        return "the file lacks an axis to take the magnitude from; the manual gives the USB \
865                download's accelerations in G"
866            .to_owned();
867    };
868    let before = time_s.iter().take_while(|time| **time < 0.0).count();
869    let (rows, which) = if before > 0 {
870        (before, format!("the {before} rows before liftoff"))
871    } else {
872        let rows = time_s.len().min(PAD_ROWS);
873        (rows, format!("the first {rows} rows"))
874    };
875    let mut magnitudes: Vec<f64> = (0..rows)
876        .filter_map(|i| {
877            let g =
878                (x.values.get(i)?.powi(2) + y.values.get(i)?.powi(2) + z.values.get(i)?.powi(2))
879                    .sqrt()
880                    / STANDARD_GRAVITY_MPS2;
881            g.is_finite().then_some(g)
882        })
883        .collect();
884    magnitudes.sort_by(f64::total_cmp);
885    match magnitudes.get(magnitudes.len() / 2) {
886        Some(median) => format!(
887            "the median magnitude of the three axes over {which} is {median:.3}, where a logger \
888             at rest reads 1 g"
889        ),
890        None => format!("the three axes have no value over {which} to take the magnitude from"),
891    }
892}
893
894/// The note on `Current`, kept as written: how often it reads other than 0, and its largest value.
895fn current_note(current: &Column) -> String {
896    let values = &current.values[..];
897    let finite = values.iter().filter(|value| value.is_finite()).count();
898    let nonzero = values
899        .iter()
900        .filter(|value| value.is_finite() && **value != 0.0)
901        .count();
902    let largest = values
903        .iter()
904        .filter(|value| value.is_finite())
905        .fold(0.0_f64, |largest, value| largest.max(value.abs()));
906    format!(
907        "`{}` kept as written, in a unit no source states: it is other than 0 in {nonzero} of \
908         {finite} rows, at most {largest:.3}; amperes would fit the manual's limit of about 4 A on \
909         the board's current, the USB download's milliamperes would not",
910        short(&current.name)
911    )
912}
913
914fn syntax(line: usize, message: String) -> LogError {
915    LogError::Syntax {
916        format: FORMAT,
917        line,
918        message,
919    }
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925
926    /// The low-rate file's first 45 columns, with the fifth register's name as most files write it.
927    const LOW_LEAD: &str = "Year,Month,Day,Time,Flight_Time_(s),Sync,Temperature_(F),Baro_Press_(atm),\
928        Baro_Altitude_ASL_(feet),Baro_Altitude_AGL_(feet),Batt_Volts,Apo_Volts,Main_Volts,3rd_Volts,\
929        4th_Volts,Velocity_Up,Velocity_DR,Velocity_CR,Inertial_Altitude,Inertial_DR_Position,\
930        Inertial_CR_position,Tilt_Angle_(deg),Future_Angle_(deg),Roll_Angle_(deg),Reserved_1,\
931        Reserved_2,Reserved_3,Rocket_FER_Hex,Apo_FER_H  ex,Main_FER_Hex,3rd_FER_Hex,4th_FER_Hex,\
932        Liftoff,Apogee,Press_Increasing,Burnout_Coast,Apo_fired,Main_fired,3rd_fired,4th_fired,\
933        Normal_Ascent,Accel_Vel_LE_0,ECI_Vvel_le_0,Tilt Exceeded 90deg,Reserved";
934
935    /// One output channel's condition flags, repeated for each of the four.
936    const CHANNEL_FLAGS: &str = "LT_AGL1,GT_AGL2,LT_FANG1,GT_FANG2,GT_FUTANG,LT_TVAL1,GT_TVAL2,\
937        LT_VEL1,GT_VEL2,LT_BVEL1,GT_BURN,Armed,Reserved,Reserved";
938
939    const HIGH_HEADER: &str = "Year,Month,Day,Time,Flight_Time_(s),Sync,Gyro_X,Gyro_Y,Gyro_Z,\
940        Accel_X,Accel_Y,Accel_Z,Quat_1,Quat_2,Quat_3,Quat_4,Aux_Volts,Current";
941
942    fn low_header() -> String {
943        format!("{LOW_LEAD}{}", format!(",{CHANNEL_FLAGS}").repeat(4))
944    }
945
946    /// A low-rate row: its time, time of day, altitude above the pad in feet, upward velocity in
947    /// ft/s, and the rocket's 13 flags, `Liftoff` first; the registers written from the flags.
948    fn low_row(time: f64, of_day: &str, agl_ft: f64, up_ft_s: f64, flags: [u8; 13]) -> String {
949        let register: u32 = flags
950            .iter()
951            .enumerate()
952            .map(|(bit, flag)| u32::from(*flag) << bit)
953            .sum();
954        let flags: Vec<String> = flags.iter().map(u8::to_string).collect();
955        format!(
956            "2026,4,18,{of_day},{time},0,50,0.95,{},{agl_ft},4.1,4.0,4.0,0,0,{up_ft_s},2,-1,{agl_ft},\
957             10,-3,2.5,3,40,0,0,0,{register:x},0,0,0,0,{}{}",
958            1000.0 + agl_ft,
959            flags.join(","),
960            ",0".repeat(56)
961        )
962    }
963
964    fn high_row(time: f64, of_day: &str, current: &str) -> String {
965        format!("2026,4,18,{of_day},{time},0,1.5,-2,300,0.6,0,0.8,0,0,0.6,0.8,0.02,{current}")
966    }
967
968    fn close(a: f64, b: f64) -> bool {
969        (a - b).abs() <= 1e-9 * b.abs().max(1.0)
970    }
971
972    fn channel<'a>(log: &'a FlightLog, column: &str) -> &'a Channel {
973        log.channels
974            .iter()
975            .find(|channel| channel.column == column)
976            .unwrap()
977    }
978
979    const NO_FLAGS: [u8; 13] = [0; 13];
980    const LIFTOFF: [u8; 13] = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
981    const APOGEE: [u8; 13] = [1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0];
982
983    #[test]
984    fn the_app_header_is_recognized_by_its_six_first_columns() {
985        assert!(recognizes(&format!("\u{feff}\r\n{}\n", low_header())));
986        assert!(recognizes(HIGH_HEADER));
987        assert!(recognizes(
988            "Year, Month, Day, Time, Flight_Time_(s), Sync, Gyro_X\n"
989        ));
990        for other in [
991            "",
992            "Year,Month,Day,Time,Flight_Time_(s),Sync\n",
993            "Year,Month,Day,Time,Flight_Time_(s),Axial Accel (Gs)\n",
994            "Time@Axial Accel (Gs),Axial Accel (Gs),bILBA\n",
995            "Time,Altitude,Velocity\n",
996            "year,month,day,time,flight_time_(s),sync,x\n",
997        ] {
998            assert!(!recognizes(other), "{other:?}");
999        }
1000    }
1001
1002    /// The Raven's FIP check runs first in the importer, on a header that opens with `Time` and
1003    /// names `Axial Accel (Gs)`: a Blue Raven low-rate header naming that column still reads as
1004    /// a Blue Raven's.
1005    #[test]
1006    fn a_low_rate_header_naming_the_ravens_axial_accel_is_a_blue_ravens() {
1007        let header =
1008            low_header().replace("3rd_Volts,4th_Volts", "Axial Accel (Gs),Lateral Accel (Gs)");
1009        let text = format!(
1010            "{header}\n{}\n",
1011            low_row(0.0, "10:00:00.000", 0.0, 0.0, NO_FLAGS)
1012        );
1013        assert_eq!(
1014            crate::import::detect(text.as_bytes()),
1015            Some(LogFormat::BlueRavenCsv)
1016        );
1017        let log = crate::import::read(text.as_bytes()).unwrap();
1018        let axial = channel(&log, "Axial Accel (Gs)");
1019        assert_eq!(axial.quantity, Quantity::Acceleration);
1020        assert_eq!(axial.values, [0.0]);
1021    }
1022
1023    /// The low-rate file: every column but the clock a channel in the file's order and in SI,
1024    /// the primary track the barometer's, the registers read as hexadecimal, and events where
1025    /// a moment's flag rises.
1026    #[test]
1027    fn reads_a_low_rate_file() {
1028        let text = format!(
1029            "{}\n{}\n{}\n{}\n{}\n",
1030            low_header(),
1031            low_row(-0.04, "10:00:00.000", 0.0, 0.0, NO_FLAGS),
1032            low_row(-0.02, "10:00:00.020", 0.5, 0.0, NO_FLAGS),
1033            low_row(0.0, "10:00:00.040", 1.0, 30.0, LIFTOFF),
1034            low_row(0.02, "10:00:00.060", 2.0, 60.0, APOGEE),
1035        );
1036        let log = read(&text).unwrap();
1037        assert_eq!(log.format, LogFormat::BlueRavenCsv);
1038        assert_eq!(log.logger, "Featherweight Blue Raven");
1039        assert_eq!(log.channels.len(), 100);
1040        assert_eq!(log.channels[4].column, "Sync", "the clock is no channel");
1041        assert_eq!(log.time_s, [-0.04, -0.02, 0.0, 0.02]);
1042        assert!(close(log.altitude_m[3], 2.0 * FOOT_M));
1043        assert!(log.vertical_speed_m_s.is_none());
1044        assert!(close(log.temperature_k.as_ref().unwrap()[0], 283.15));
1045        assert_eq!(log.battery_v.as_deref(), Some(&[4.1; 4][..]));
1046
1047        let of_day = channel(&log, "Time");
1048        assert_eq!(of_day.quantity, Quantity::TimeOfDay);
1049        assert!(close(of_day.values[1], 36_000.02));
1050        assert!(close(
1051            channel(&log, "Baro_Press_(atm)").values[0],
1052            0.95 * 101_325.0
1053        ));
1054        let up = channel(&log, "Velocity_Up");
1055        assert_eq!(up.quantity, Quantity::Speed);
1056        assert!(close(up.values[3], 60.0 * FOOT_M));
1057        assert_eq!(
1058            channel(&log, "Inertial_DR_Position").quantity,
1059            Quantity::Distance
1060        );
1061        assert_eq!(channel(&log, "Roll_Angle_(deg)").values[0], 40.0);
1062        assert_eq!(channel(&log, "Reserved_1").quantity, Quantity::Unrecognized);
1063        // `1` | `2` | `16` = 0x13: Liftoff, Apogee and Apo_fired.
1064        let register = channel(&log, "Rocket_FER_Hex");
1065        assert_eq!(register.quantity, Quantity::Number);
1066        assert_eq!(register.values, [0.0, 0.0, 1.0, 19.0]);
1067        assert_eq!(channel(&log, "Apo_FER_H  ex").quantity, Quantity::Number);
1068        let names: Vec<(&str, f64)> = log
1069            .events
1070            .iter()
1071            .map(|event| (event.name.as_str(), event.time_s))
1072            .collect();
1073        assert_eq!(
1074            names,
1075            [("Liftoff", 0.0), ("Apogee", 0.02), ("Apo_fired", 0.02)]
1076        );
1077        assert_eq!(
1078            log.channels
1079                .iter()
1080                .filter(|c| c.column == "LT_AGL1")
1081                .count(),
1082            4
1083        );
1084
1085        assert!(
1086            log.notes[0].starts_with(
1087                "`Velocity_Up`, `Velocity_DR` and `Velocity_CR` read in feet per second, from the \
1088                 manual and the summary: over the climb to the barometer's highest sample, \
1089                 `Velocity_Up` integrates to 1 ft against the barometer's rise of 2 ft"
1090            ),
1091            "{:?}",
1092            log.notes
1093        );
1094        assert!(log.notes[1].starts_with(
1095            "`Inertial_Altitude`, `Inertial_DR_Position` and `Inertial_CR_position` read in feet, \
1096             from the manual and the summary: `Inertial_Altitude` peaks at 2 ft against the \
1097             barometric altitude's 2 ft"
1098        ));
1099        assert_eq!(
1100            log.notes[2],
1101            "12 columns named `Reserved…` hold what the manual doesn't describe, and were kept as \
1102             written"
1103        );
1104        assert_eq!(log.notes.len(), 3, "{:?}", log.notes);
1105    }
1106
1107    /// The high-rate file: no altitude, so an empty primary track with a note, and the inertial
1108    /// sensors in SI with a note on each kind's unit.
1109    #[test]
1110    fn reads_a_high_rate_file_with_an_empty_track() {
1111        let text = format!(
1112            "{HIGH_HEADER}\r\n{}\r\n{}\r\n{}\r\n",
1113            high_row(-0.004, "10:00:00.000", "0.000"),
1114            high_row(-0.002, "10:00:00.000", "0.000"),
1115            high_row(0.0, "10:00:00.004", "2.5"),
1116        );
1117        let log = read(&text).unwrap();
1118        assert!(log.time_s.is_empty() && log.altitude_m.is_empty());
1119        assert!(log.temperature_k.is_none() && log.battery_v.is_none());
1120        assert_eq!(log.channels.len(), 17);
1121        let gyro = channel(&log, "Gyro_Z");
1122        assert_eq!(gyro.quantity, Quantity::AngularRate);
1123        assert_eq!(gyro.time_s, [-0.004, -0.002, 0.0]);
1124        assert_eq!(gyro.values, [300.0; 3]);
1125        let accel = channel(&log, "Accel_Z");
1126        assert!(close(accel.values[0], 0.8 * STANDARD_GRAVITY_MPS2));
1127        assert_eq!(channel(&log, "Quat_4").quantity, Quantity::Number);
1128        assert_eq!(channel(&log, "Aux_Volts").quantity, Quantity::Voltage);
1129        let current = channel(&log, "Current");
1130        assert_eq!(current.quantity, Quantity::Unrecognized);
1131        assert_eq!(current.values, [0.0, 0.0, 2.5]);
1132        assert_eq!(
1133            log.notes,
1134            [
1135                "`Gyro_X`, `Gyro_Y` and `Gyro_Z` read in degrees per second, from the manual and \
1136                 the summary: the largest rate in the file is 300, against the gyros' range of \
1137                 ±2000 deg/sec the manual gives",
1138                "`Accel_X`, `Accel_Y` and `Accel_Z` read in standard gravities, from the manual \
1139                 and the pad: the median magnitude of the three axes over the 2 rows before \
1140                 liftoff is 1.000, where a logger at rest reads 1 g",
1141                "`Current` kept as written, in a unit no source states: it is other than 0 in 1 of \
1142                 3 rows, at most 2.500; amperes would fit the manual's limit of about 4 A on the \
1143                 board's current, the USB download's milliamperes would not",
1144                "the file has no `Baro_Altitude_AGL_(feet)`, so the record's altitude track is \
1145                 empty: a Blue Raven's high-rate file holds its inertial sensors only, and the \
1146                 low-rate file of the same flight holds the altitude",
1147            ]
1148        );
1149        assert!(log.events.is_empty());
1150        // The readings withhold on a Blue Raven's record, so an empty track reaches nothing that
1151        // would index it.
1152        let readings = crate::readings::read(&log);
1153        assert!(readings.apogee.value().is_none());
1154    }
1155
1156    /// Each decided kind's note keeps the same words before its colon whatever the file's
1157    /// numbers, so a survey can count them; without the rows to compare, the note says so.
1158    #[test]
1159    fn decided_notes_keep_their_prefix() {
1160        let text = format!("{HIGH_HEADER}\n{}\n", high_row(0.5, "10:00:00.000", "0"));
1161        let log = read(&text).unwrap();
1162        assert!(log.notes[1].starts_with(
1163            "`Accel_X`, `Accel_Y` and `Accel_Z` read in standard gravities, from the manual and \
1164             the pad: the median magnitude of the three axes over the first 1 rows is 1.000"
1165        ));
1166        let header =
1167            "Year,Month,Day,Time,Flight_Time_(s),Sync,Velocity_Up,Inertial_Altitude,Accel_X";
1168        let log = read(&format!("{header}\n2026,1,1,0:00:00,0,0,1,1,1\n")).unwrap();
1169        assert_eq!(
1170            log.notes[..3],
1171            [
1172                "`Velocity_Up` read in feet per second, from the manual and the summary: the \
1173                 manual gives the USB download's velocities in ft/sec; the file has no \
1174                 `Velocity_Up` and barometric altitude to compare",
1175                "`Inertial_Altitude` read in feet, from the manual and the summary: the manual \
1176                 gives the USB download's inertial altitude and ranges in feet; the file has no \
1177                 inertial and barometric peaks to compare",
1178                "`Accel_X` read in standard gravities, from the manual and the pad: the file lacks \
1179                 an axis to take the magnitude from; the manual gives the USB download's \
1180                 accelerations in G",
1181            ]
1182        );
1183    }
1184
1185    /// A flag already set on the first row gives no event, with a note; a flag that rises more
1186    /// often than the cap gives the first [`MAX_EVENTS_PER_FLAG`] and counts the rest; a
1187    /// condition flag gives none.
1188    #[test]
1189    fn flags_give_events_only_where_they_rise() {
1190        let mut text = low_header();
1191        let mut main = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
1192        for row in 0..40 {
1193            main[5] = u8::from(row % 2 == 1);
1194            main[10] = u8::from(row % 2 == 1);
1195            text.push('\n');
1196            text.push_str(&low_row(f64::from(row), "10:00:00", 0.0, 0.0, main));
1197        }
1198        let log = read(&text).unwrap();
1199        assert_eq!(log.events.len(), MAX_EVENTS_PER_FLAG);
1200        assert!(log.events.iter().all(|event| event.name == "Main_fired"));
1201        assert_eq!(log.events[0].time_s, 1.0);
1202        assert_eq!(log.events[15].time_s, 31.0);
1203        let notes = log.notes.join("\n");
1204        assert!(
1205            notes.contains("the flag \"Liftoff\" is already 1 on the first row"),
1206            "{notes}"
1207        );
1208        assert!(
1209            notes.contains("the flag \"Main_fired\" rises 4 more times than the 16 kept"),
1210            "{notes}"
1211        );
1212    }
1213
1214    #[test]
1215    fn a_time_of_day_that_goes_back_is_kept_with_a_note() {
1216        let text = format!(
1217            "{HIGH_HEADER}\n{}\n{}\n{}\n{}\n",
1218            high_row(0.0, "23:59:59.990", "0"),
1219            high_row(0.1, "0:00:00.000", "0"),
1220            high_row(0.2, "0:00:00.010", "0"),
1221            high_row(0.3, "0:00:00.005", "0"),
1222        );
1223        let log = read(&text).unwrap();
1224        let of_day = channel(&log, "Time");
1225        assert!(close(of_day.values[0], 86_399.99));
1226        assert_eq!(of_day.values[1], 0.0);
1227        assert!(
1228            log.notes.contains(
1229                &"the logger's time of day, \"Time\", goes back 2 times, by up to 86399.990 s: it \
1230                  is kept as written, and `Flight_Time_(s)` is the clock"
1231                    .to_owned()
1232            ),
1233            "{:?}",
1234            log.notes
1235        );
1236    }
1237
1238    #[test]
1239    fn a_note_lists_a_few_names() {
1240        let names: Vec<String> = (0..9).map(|i| format!("`g{i}`")).collect();
1241        assert_eq!(list(&names[..1]), "`g0`");
1242        assert_eq!(list(&names[..3]), "`g0`, `g1` and `g2`");
1243        assert_eq!(list(&names[..6]), "`g0`, `g1`, `g2`, `g3`, `g4` and `g5`");
1244        assert_eq!(
1245            list(&names),
1246            "`g0`, `g1`, `g2`, `g3`, `g4` and 4 more columns"
1247        );
1248    }
1249
1250    #[test]
1251    fn times_of_day_and_registers_are_read_strictly() {
1252        assert_eq!(time_of_day("00:00:00"), Some(0.0));
1253        assert!(close(time_of_day("9:05:07.25").unwrap(), 32_707.25));
1254        assert!(close(time_of_day("23:59:60.5").unwrap(), 86_400.5));
1255        for bad in [
1256            "",
1257            "12:00",
1258            "12:00:00:00",
1259            "24:00:00",
1260            "12:60:00",
1261            "12:5:00",
1262            "12:00:61",
1263            "123:00:00",
1264            "12:00:00.",
1265            "12:00:00.x",
1266            "-1:00:00",
1267            "12:00:+1",
1268            "1e1:00:00",
1269        ] {
1270            assert_eq!(time_of_day(bad), None, "{bad:?}");
1271        }
1272        assert_eq!(hex("0"), Some(0.0));
1273        assert_eq!(hex("1a5"), Some(421.0));
1274        assert_eq!(hex("0x1A5"), Some(421.0));
1275        assert_eq!(hex("fffffffffffff"), Some(4_503_599_627_370_495.0));
1276        for bad in ["", "0x", "g", "-1", "1.5", "ffffffffffffff"] {
1277            assert_eq!(hex(bad), None, "{bad:?}");
1278        }
1279    }
1280
1281    /// An empty cell, or one that isn't what its column holds, is a gap counted in a note; a
1282    /// short row's missing cells are gaps, counted.
1283    #[test]
1284    fn gaps_and_short_rows_are_counted() {
1285        let text = format!(
1286            "{HIGH_HEADER}\n\
1287             2026,4,18,noon,0,0,x,,300,0.6,0,0.8,0,0,0.6,0.8,0.02,0\n\
1288             2026,4,18,10:00:00,0.002,0,1,2,3\n\
1289             2026,4,18,10:00:00,0.004\n"
1290        );
1291        let log = read(&text).unwrap();
1292        assert!(channel(&log, "Time").values[0].is_nan());
1293        let gyro_x = channel(&log, "Gyro_X");
1294        assert!(gyro_x.values[0].is_nan());
1295        assert_eq!(gyro_x.values[1], 1.0);
1296        assert!(gyro_x.values[2].is_nan());
1297        assert!(channel(&log, "Current").values[1].is_nan());
1298        let notes = log.notes.join("\n");
1299        for expected in [
1300            "the column \"Time\" holds 1 cells that are empty or not what it holds, such as \
1301             \"noon\": they are gaps",
1302            "the column \"Gyro_X\" holds 1 cells that are empty or not what it holds, such as \
1303             \"x\"",
1304            "the column \"Gyro_Y\" holds 1 cells that are empty or not what it holds, such as \
1305             \"\"",
1306            "2 rows, the first on line 3, have fewer cells than the header's 18: the cells they \
1307             leave out are gaps",
1308        ] {
1309            assert!(notes.contains(expected), "{expected}\n{notes}");
1310        }
1311    }
1312
1313    /// `Inertial_Altitude` stepping by more than half a 16-bit span of feet is noted, and kept.
1314    #[test]
1315    fn an_inertial_wrap_is_noted() {
1316        let header = "Year,Month,Day,Time,Flight_Time_(s),Sync,Inertial_Altitude";
1317        let text = format!("{header}\n2026,1,1,0:00:00,0,0,32767\n2026,1,1,0:00:00,1,0,-32768\n");
1318        let log = read(&text).unwrap();
1319        assert!(close(
1320            channel(&log, "Inertial_Altitude").values[1],
1321            -32_768.0 * FOOT_M
1322        ));
1323        assert!(log.notes.contains(
1324            &"\"Inertial_Altitude\" steps by more than 32,768 ft between two rows 1 times, as a \
1325              16-bit count of feet does when it wraps: it is kept as written"
1326                .to_owned()
1327        ));
1328    }
1329
1330    /// Each refusal names its line and what is wrong.
1331    #[test]
1332    fn malformed_files_are_refused_where_they_go_wrong() {
1333        let row = |time: &str| format!("2026,4,18,10:00:00,{time},0,1,1,1,1,1,1,0,0,0,1,0,0");
1334        let cases = [
1335            (String::new(), "the file is empty"),
1336            ("\n\r\n".to_owned(), "the file is empty"),
1337            ("Time,Altitude\n0,0\n".to_owned(), "isn't the app's header"),
1338            (format!("{HIGH_HEADER}\n"), "no data rows"),
1339            (
1340                "Year,Month,Day,Time,Flight_Time_(s),Sync,,x\n".to_owned(),
1341                "line 1: the header's column 7 has no name",
1342            ),
1343            (
1344                format!("{HIGH_HEADER}\n{},9\n", row("0")),
1345                "line 2: the row has 19 cells, more than the header's 18",
1346            ),
1347            (
1348                format!("{HIGH_HEADER}\n{}\nx,y\n", row("0")),
1349                "line 3: the row ends after 2 cells, before its \"Flight_Time_(s)\"",
1350            ),
1351            (
1352                format!("{HIGH_HEADER}\n{}\n", row("soon")),
1353                "line 2: the time \"soon\" in cell 5 isn't a finite number",
1354            ),
1355            (
1356                format!("{HIGH_HEADER}\n{}\n", row("")),
1357                "line 2: the time \"\" in cell 5",
1358            ),
1359            (
1360                format!("{HIGH_HEADER}\n{}\n\n{}\n", row("1"), row("1")),
1361                "line 4: the time 1 s doesn't come after the one above it, 1 s",
1362            ),
1363            (
1364                format!("{HIGH_HEADER}\n{}\n{}\n", row("1"), row("0.5")),
1365                "line 3: the time 0.5 s doesn't come after",
1366            ),
1367        ];
1368        for (text, expected) in cases {
1369            let error = read(&text).unwrap_err().to_string();
1370            assert!(error.contains(expected), "{text:?}: {error}");
1371        }
1372        let LogError::NotThisFormat { format, .. } = read("x").unwrap_err() else {
1373            panic!("refused for another reason");
1374        };
1375        assert_eq!(format, FORMAT);
1376    }
1377
1378    /// A header naming far more columns than its rows fill is refused before the reader allocates
1379    /// a gap for every cell left out.
1380    #[test]
1381    fn a_wide_header_over_short_rows_is_refused() {
1382        let wide: String = (0..20_000).map(|i| format!(",c{i}")).collect();
1383        let header = format!("Year,Month,Day,Time,Flight_Time_(s),Sync{wide}");
1384        let rows: String = (0..100).map(|i| format!("0,0,0,0,{i},0\n")).collect();
1385        let error = read(&format!("{header}\n{rows}")).unwrap_err().to_string();
1386        assert!(
1387            error.contains("line 5: the header names 20006 columns, and the 4 rows down to this one hold only 24 cells"),
1388            "{error}"
1389        );
1390    }
1391
1392    /// Lines end in CR, LF or both; a byte-order mark is dropped; blank lines are skipped.
1393    #[test]
1394    fn line_ends_and_marks() {
1395        let rows = [
1396            high_row(0.0, "10:00:00", "0"),
1397            high_row(0.002, "10:00:00", "0"),
1398        ];
1399        for end in ["\r", "\n", "\r\n"] {
1400            let text = format!(
1401                "\u{feff}{end}{HIGH_HEADER}{end}{}{end}{end}{}{end}",
1402                rows[0], rows[1]
1403            );
1404            let log = read(&text).unwrap();
1405            assert_eq!(log.channels[0].time_s, [0.0, 0.002], "{end:?}");
1406        }
1407    }
1408
1409    /// A header of many unknown columns keeps a short list of notes and counts the rest; the
1410    /// unit notes stay outside the cap.
1411    #[test]
1412    fn notes_stay_few() {
1413        let unknown: String = (0..100).map(|i| format!(",u{i}")).collect();
1414        let header = format!("Year,Month,Day,Time,Flight_Time_(s),Sync,Gyro_X{unknown}");
1415        let row = format!("2026,1,1,0:00:00,0,0,1{}", ",1".repeat(100));
1416        let log = read(&format!("{header}\n{row}\n")).unwrap();
1417        assert!(log.notes[0].starts_with("`Gyro_X` read in degrees per second"));
1418        assert_eq!(log.notes.len(), 1 + 1 + 20 + 1, "{:?}", log.notes);
1419        assert!(log.notes[1].starts_with("the file has no `Baro_Altitude_AGL_(feet)`"));
1420        assert!(
1421            log.notes[22].starts_with("and 80 more notes"),
1422            "{:?}",
1423            log.notes
1424        );
1425        assert_eq!(log.channels.len(), 106);
1426        assert_eq!(log.channels[105].quantity, Quantity::Unrecognized);
1427    }
1428
1429    proptest::proptest! {
1430        /// Any text is read or refused, never a panic; what is read has increasing times and a
1431        /// value for each on every channel.
1432        #[test]
1433        fn any_text_is_read_or_refused(
1434            rows in proptest::collection::vec("[-0-9.,:xaf ]{0,40}", 0..10),
1435            low in proptest::bool::ANY,
1436        ) {
1437            let header = if low { low_header() } else { HIGH_HEADER.to_owned() };
1438            let text = format!("{header}\n{}", rows.join("\n"));
1439            if let Ok(log) = read(&text) {
1440                proptest::prop_assert_eq!(log.time_s.len(), log.altitude_m.len());
1441                for channel in &log.channels {
1442                    proptest::prop_assert!(channel.time_s.windows(2).all(|pair| pair[1] > pair[0]));
1443                    proptest::prop_assert_eq!(channel.time_s.len(), channel.values.len());
1444                }
1445                for event in &log.events {
1446                    proptest::prop_assert!(event.time_s.is_finite());
1447                }
1448            }
1449        }
1450    }
1451}