Skip to main content

hpr_flightdata/
altos.rs

1//! AltOS's CSV flight logs, as AltosUI exports a TeleMetrum's, TeleMega's, EasyMega's or
2//! EasyMini's flight.
3//!
4//! The file is text: perhaps a few comment lines starting with `#`, then one header line naming
5//! the columns, then one row per sample, the cells separated by a comma and spaces:
6//!
7//! ```text
8//! #version,serial,flight,call,time,rssi,lqi,state,state_name,acceleration,pressure,altitude,height,speed,temperature,…
9//! 6, 1234, 3,N0CALL,    -1.00, -80,  40, 2,     pad,    0.10,  98000.00,  250.00,    0.00,    0.00, 21.0,…
10//! ```
11//!
12//! The columns differ by device and by AltosUI's version: the header says which there are, and
13//! this reader maps them by name. `call` is there when the flight came in by radio, and `rssi`
14//! and `lqi` when it was received as telemetry; a GPS-equipped unit adds `latitude`, `longitude`,
15//! a second `altitude`, the time of day, the distance and bearing to the pad and the receiver's
16//! satellite columns `sat01`…`sat32`; a TeleMega or EasyMega adds its inertial and magnetic
17//! sensors. One of the files read has no `pressure` or barometric `altitude` column at all.
18//!
19//! **Units.** The AltOS manual says the file holds "all of the sensor values … converted to
20//! standard units, with the barometric data reported in both pressure, altitude and height above
21//! pad units", and that its header and comment lines start with `#` (Altus Metrum, *AltOS*
22//! manual, §12.5.1 "Comma Separated Value Format"). It names no column's unit; these are each
23//! one's reading, and where it comes from:
24//!
25//! | columns | read as | from |
26//! |---|---|---|
27//! | `time` | the clock, s, from the logger's zero at boost: negative on the pad | Debrief's reader |
28//! | `height` | [`Quantity::AltitudeAboveGround`], m above the pad | Debrief's reader; the manual's "height above pad" |
29//! | `altitude` (the first) | [`Quantity::AltitudeMsl`], m, the barometric altitude | the manual; Debrief's reader |
30//! | `altitude` after `longitude` | [`Quantity::GnssAltitude`], m, the GPS receiver's | Debrief's reader, which finds it by its place |
31//! | `pressure` | [`Quantity::Pressure`], Pa; hPa if the column never reaches 1,200 (below) | the files read: about 98,000 on a pad |
32//! | `acceleration`, `accel_x`/`_y`/`_z` | [`Quantity::Acceleration`], m/s² | Debrief's reader and its proof from the data (below) |
33//! | `speed`, `accel_speed`, `baro_speed` | [`Quantity::Speed`], m/s, up positive | Debrief's reader |
34//! | `temperature` | [`Quantity::Temperature`], °C in the file, K here | Debrief's reader |
35//! | `battery_voltage`, `drogue_voltage`, `main_voltage`, `pyro`, `igniter_*` | [`Quantity::Voltage`], V | Debrief's reader (battery); the manual's §13.9 states the igniter and battery readings in volts |
36//! | `latitude`, `longitude` | [`Quantity::Latitude`], [`Quantity::Longitude`], degrees | Debrief's reader |
37//! | `tilt`, `pad_az`, `pad_el` | [`Quantity::Angle`], degrees | the manual (its pyro condition's "angle from vertical", in degrees; §13.10's bearing "in degrees"); the files read stay within 0–180 and 0–360 |
38//! | `pad_dist`, `pad_range` | [`Quantity::Distance`], m | only the manual's "standard units": no document states their unit |
39//! | `version`, `serial`, `flight`, `rssi`, `lqi`, `state`, `connected`, `locked`, `nsat`, `year`…`second`, `pdop`, `hdop`, `vdop`, `sat01`…`sat32` | [`Quantity::Number`], as written | counts, codes, flags, signal strengths and dilutions of precision |
40//! | `gyro_roll`/`_pitch`/`_yaw`, `mag_x`/`_y`/`_z` | [`Quantity::Unrecognized`], as written | the record has no quantity for a rotation rate or a magnetic field, and no document states their unit |
41//!
42//! Every other column is kept as written, [`Quantity::Unrecognized`], with a note. `call` (the
43//! radio call sign) and `state_name` (the flight computer's state in words) are text, not
44//! channels: each state the file changes to is an [`Event`] instead, at the time of the row that
45//! changes it, the first row's state included. The numeric `state` column is kept as a channel.
46//!
47//! **The primary track.** [`FlightLog::altitude_m`] is `height`; the vertical speed is
48//! `baro_speed`, the barometer's, and absent without it: `accel_speed` integrates the
49//! accelerometer and `speed` is the flight computer's own estimate, which leans on the
50//! accelerometer on the way up, so both stay channels only (Debrief's reader took `accel_speed`
51//! first). The temperature is `temperature` and the battery `battery_voltage`. Both `time` and
52//! `height` are required.
53//!
54//! **Quirks, and what this reader does about them.**
55//!
56//! - *Repeated times.* AltosUI writes rows that share a time with the row before, hundreds in a
57//!   flight; in every one of the files read, such rows also repeat the height. The primary track
58//!   must have strictly increasing times, so it keeps the first row at each time, and the rows it
59//!   leaves out are counted in a note. Rows out of time order (none in the files read) are put in
60//!   order on the primary track, again with a note. This is what Debrief does (its
61//!   `lib/flight/build.ts`: sort by time, keep the first row at each time). The channels keep
62//!   every row, in the file's order, each on its own time.
63//! - *Acceleration net of gravity.* AltOS writes `acceleration` along the rocket's axis with
64//!   gravity taken out: it reads about 0 at rest, not 1 g, while the TeleMega's body-axis
65//!   `accel_x` on the same row reads about 9.8 m/s² (Debrief's reader, from the data). The
66//!   channel is kept as written, and a note says so. The pad's reading of 1 g along the axis is
67//!   what comes out, so the channel is the rocket's own acceleration only while it points
68//!   straight up; tilted, it is off by the difference between g and g's share along the axis.
69//! - *GPS before a fix.* AltOS writes a position of exactly (0, 0) before its receiver has a fix
70//!   (Debrief's reader). A row with latitude and longitude both 0 has its latitude, longitude and
71//!   GPS altitude read as gaps, counted in a note. None of the files read has such a row; the
72//!   rule is Debrief's, kept so that a track can't run to the equator.
73//! - *Pressure in hectopascals.* Debrief's own sample file writes pressure in hPa (about 983 on
74//!   its pad), where AltosUI's exports write Pa. No launch site's pressure is below 10 kPa nor
75//!   above 120 kPa, and the two units are a hundredfold apart, so the column's highest value
76//!   settles it: from 10,000 to 120,000, Pa; from 100 to 1,200, hPa, converted with a note;
77//!   anything else is kept as written, [`Quantity::Unrecognized`], with a note.
78//! - *Gaps.* AltosUI writes `NaN` where it has no value (a TeleMega's `tilt` before it has an
79//!   attitude). A cell that is empty or not a finite number is a gap, `NaN`, counted per column
80//!   in a note.
81//! - *Short and long rows.* A row with fewer cells than the header (a file cut off mid-row) has
82//!   the rest as gaps, with a note; a row with more cells is refused, naming its line, unless the
83//!   extra cells are empty (a trailing comma). A row whose time isn't a number can't be placed on
84//!   the clock and is left out, with a note.
85//!
86//! **What the file says about itself.** The serial number and flight number come from the comment
87//! lines `# serial …` and `# flight …` when the file has them, and from the first row's `serial`
88//! and `flight` cells otherwise; `# product …` names the device, as in `AltOS TeleMetrum`. The
89//! file states no firmware version (its `version` column is the data's, kept as a channel), no
90//! apogee and no sample count.
91//!
92//! **Where this comes from.** Ported from Debrief's reader (`lib/parsers/altusmetrum.ts`, MIT, the
93//! project owner's own; see `THIRD-PARTY-NOTICES.md`), which was written from exported files, and
94//! the AltOS manual's description of the export. No AltOS or AltosUI source code was read. Where
95//! this reader departs from Debrief's: it keeps every column as a channel where Debrief keeps a
96//! chosen few; it keeps the file's clock, where Debrief moves the zero to the first row; it reads a
97//! hectopascal pressure as such; it refuses a long row; it requires the header first (after
98//! comment lines), where Debrief searches the first 60 lines for it. AltOS's radio-telemetry log
99//! (`tick`, `ptype`, …) is a different file and isn't read here.
100
101use crate::error::LogError;
102use crate::log::{Channel, Event, FlightLog, LogFormat, Quantity, Stated};
103
104/// The format's name in errors.
105const FORMAT: &str = "AltOS CSV";
106
107/// The most notes about single lines a log keeps; past it, one more note counts the rest.
108const MAX_NOTES: usize = 20;
109
110/// The most characters of the file an error or note quotes.
111const QUOTED_CHARS: usize = 80;
112
113/// What a column of the file is to this reader.
114#[derive(Debug, Clone, Copy, PartialEq)]
115enum Role {
116    /// The `time` column: the clock.
117    Clock,
118    /// Text, not a channel: `call` and `state_name`.
119    Text,
120    /// A channel of this quantity, the file's values as SI.
121    Value(Quantity),
122    /// A temperature in °C, a channel in K.
123    Celsius,
124    /// A pressure, Pa or hPa as the column's values settle.
125    Pressure,
126}
127
128/// A column's role from its name, and whether the reader knows the name. `after_longitude` says
129/// whether a `longitude` column comes before it, which makes an `altitude` the GPS receiver's.
130fn role(name: &str, after_longitude: bool) -> (Role, bool) {
131    use Quantity as Q;
132    let lower = name.to_ascii_lowercase();
133    let value = |quantity| (Role::Value(quantity), true);
134    match lower.as_str() {
135        "time" => (Role::Clock, true),
136        "call" | "state_name" => (Role::Text, true),
137        "height" => value(Q::AltitudeAboveGround),
138        "altitude" if after_longitude => value(Q::GnssAltitude),
139        "altitude" => value(Q::AltitudeMsl),
140        "pressure" => (Role::Pressure, true),
141        "acceleration" | "accel_x" | "accel_y" | "accel_z" => value(Q::Acceleration),
142        "speed" | "accel_speed" | "baro_speed" => value(Q::Speed),
143        "temperature" => (Role::Celsius, true),
144        "battery_voltage" | "drogue_voltage" | "main_voltage" | "pyro" => value(Q::Voltage),
145        "latitude" => value(Q::Latitude),
146        "longitude" => value(Q::Longitude),
147        "tilt" | "pad_az" | "pad_el" => value(Q::Angle),
148        "pad_dist" | "pad_range" => value(Q::Distance),
149        "version" | "serial" | "flight" | "rssi" | "lqi" | "state" | "connected" | "locked"
150        | "nsat" | "year" | "month" | "day" | "hour" | "minute" | "second" | "pdop" | "hdop"
151        | "vdop" => value(Q::Number),
152        "gyro_roll" | "gyro_pitch" | "gyro_yaw" | "mag_x" | "mag_y" | "mag_z" => {
153            value(Q::Unrecognized)
154        }
155        _ if lower.starts_with("igniter_") => value(Q::Voltage),
156        _ if is_satellite(&lower) => value(Q::Number),
157        _ => (Role::Value(Q::Unrecognized), false),
158    }
159}
160
161/// Whether a column is one of the receiver's satellite columns, `sat01` to `sat32`.
162fn is_satellite(lower: &str) -> bool {
163    lower
164        .strip_prefix("sat")
165        .is_some_and(|digits| digits.len() == 2 && digits.bytes().all(|b| b.is_ascii_digit()))
166}
167
168/// Notes about single lines as the reader meets them: the first [`MAX_NOTES`] kept, the rest
169/// only counted, so a file of many broken lines can't fill memory with them.
170#[derive(Default)]
171struct Notes {
172    kept: Vec<String>,
173    left_out: usize,
174}
175
176impl Notes {
177    fn push(&mut self, note: String) {
178        if self.kept.len() < MAX_NOTES {
179            self.kept.push(note);
180        } else {
181            self.left_out += 1;
182        }
183    }
184}
185
186/// What the comment lines before the header state.
187#[derive(Default)]
188struct Preamble {
189    product: Option<String>,
190    serial: Option<String>,
191    flight: Option<String>,
192}
193
194/// Reads an AltOS CSV flight log from its text.
195///
196/// # Errors
197///
198/// [`LogError::NotThisFormat`] if the first line that isn't a `#` comment isn't AltOS's header
199/// (`version,serial,flight,…`, with or without a leading `#`); [`LogError::Syntax`] for a header
200/// without a `time` or a `height` column, or a row with more cells than the header names;
201/// [`LogError::NoData`] if no row has a time.
202pub fn read(text: &str) -> Result<FlightLog, LogError> {
203    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
204    let text = text.replace("\r\n", "\n").replace('\r', "\n");
205    let mut lines = text
206        .lines()
207        .enumerate()
208        .map(|(index, line)| (index + 1, line.trim()));
209
210    let mut preamble = Preamble::default();
211    let (header_line, header) = loop {
212        match lines.next() {
213            Some((_, "")) => {}
214            Some((number, line)) => {
215                let bare = line.trim_start_matches('#').trim();
216                if is_header(bare) {
217                    break (number, bare.split(',').map(|cell| cell.trim().to_owned()));
218                }
219                if line.starts_with('#') {
220                    preamble.read(bare);
221                    continue;
222                }
223                return Err(LogError::NotThisFormat {
224                    format: FORMAT,
225                    message: format!(
226                        "line {number}, {:?}, isn't AltOS's header, `version,serial,flight,…`",
227                        short(line)
228                    ),
229                });
230            }
231            None => {
232                return Err(LogError::NotThisFormat {
233                    format: FORMAT,
234                    message: "the file has no `version,serial,flight,…` header".to_owned(),
235                });
236            }
237        }
238    };
239    let names: Vec<String> = header.collect();
240    let columns = header_columns(&names, header_line)?;
241
242    let mut notes = Notes::default();
243    let mut table = Table::new(&names, &columns);
244    for (number, line) in lines {
245        if line.is_empty() {
246            continue;
247        }
248        if line.starts_with('#') {
249            notes.push(format!(
250                "line {number}, a `#` line among the rows, was skipped"
251            ));
252            continue;
253        }
254        table.row(&columns, line, number, &mut notes)?;
255    }
256    if table.time_s.is_empty() {
257        return Err(LogError::NoData { format: FORMAT });
258    }
259    Ok(assemble(&names, &columns, table, &preamble, notes))
260}
261
262/// Whether a line, its `#` taken off, is AltOS's header.
263fn is_header(bare: &str) -> bool {
264    let mut cells = bare.split(',').map(str::trim);
265    ["version", "serial", "flight"].iter().all(|expected| {
266        cells
267            .next()
268            .is_some_and(|cell| cell.eq_ignore_ascii_case(expected))
269    })
270}
271
272impl Preamble {
273    /// Reads a comment line such as `product TeleMetrum`, keeping what names the logger and the
274    /// flight; the rest (`call`, a title) is passed over.
275    fn read(&mut self, bare: &str) {
276        let Some((key, value)) = bare.split_once(char::is_whitespace) else {
277            return;
278        };
279        let value = value.trim();
280        if value.is_empty() {
281            return;
282        }
283        let slot = match key.to_ascii_lowercase().as_str() {
284            "product" => &mut self.product,
285            "serial" => &mut self.serial,
286            "flight" => &mut self.flight,
287            _ => return,
288        };
289        slot.get_or_insert_with(|| short(value));
290    }
291}
292
293/// A column of the header: its role, and whether the reader knows its name.
294#[derive(Debug, Clone, Copy)]
295struct Column {
296    role: Role,
297    known: bool,
298}
299
300/// Each header cell's column; refuses a header without `time` or `height`.
301fn header_columns(names: &[String], number: usize) -> Result<Vec<Column>, LogError> {
302    let mut after_longitude = false;
303    let mut altitudes = 0;
304    let columns: Vec<Column> = names
305        .iter()
306        .map(|name| {
307            let lower = name.to_ascii_lowercase();
308            // A second `altitude` is the GPS receiver's even if no `longitude` precedes it.
309            let gnss = after_longitude || (lower == "altitude" && altitudes > 0);
310            if lower == "altitude" {
311                altitudes += 1;
312            }
313            if lower == "longitude" {
314                after_longitude = true;
315            }
316            let (role, known) = role(name, gnss);
317            Column { role, known }
318        })
319        .collect();
320    for required in ["time", "height"] {
321        if !names.iter().any(|name| name.eq_ignore_ascii_case(required)) {
322            return Err(LogError::Syntax {
323                format: FORMAT,
324                line: number,
325                message: format!("the header names no `{required}` column"),
326            });
327        }
328    }
329    Ok(columns)
330}
331
332/// The rows read so far, column by column.
333struct Table {
334    /// Each kept row's time, in the file's order.
335    time_s: Vec<f64>,
336    /// Each column's values, row by row, as written (no unit converted yet); empty for the clock
337    /// and text columns.
338    values: Vec<Vec<f64>>,
339    /// Each column's count of cells that were empty or not a finite number.
340    gaps: Vec<usize>,
341    /// Each kept row's `state_name`, if the file has the column.
342    states: Vec<String>,
343    /// The index of the `time` column.
344    clock: usize,
345    /// The index of the `state_name` column.
346    state_name: Option<usize>,
347    /// The indices of the `serial` and `flight` columns.
348    serial: Option<usize>,
349    flight: Option<usize>,
350    /// The first kept row's `serial` and `flight` cells, as written.
351    first_serial: Option<String>,
352    first_flight: Option<String>,
353    /// The rows read so far, and the cells they wrote, for [`crate::log::too_sparse`].
354    rows: usize,
355    cells_written: usize,
356}
357
358impl Table {
359    fn new(names: &[String], columns: &[Column]) -> Self {
360        let find = |wanted: &str| {
361            names
362                .iter()
363                .position(|name| name.eq_ignore_ascii_case(wanted))
364        };
365        Self {
366            time_s: Vec::new(),
367            values: vec![Vec::new(); columns.len()],
368            gaps: vec![0; columns.len()],
369            states: Vec::new(),
370            // `header_columns` has refused a header without a `time` column.
371            clock: find("time").unwrap_or(0),
372            state_name: find("state_name"),
373            serial: find("serial"),
374            flight: find("flight"),
375            first_serial: None,
376            first_flight: None,
377            rows: 0,
378            cells_written: 0,
379        }
380    }
381
382    /// Reads one data row.
383    fn row(
384        &mut self,
385        columns: &[Column],
386        line: &str,
387        number: usize,
388        notes: &mut Notes,
389    ) -> Result<(), LogError> {
390        let cells: Vec<&str> = line.split(',').map(str::trim).collect();
391        if cells.len() > columns.len() && cells[columns.len()..].iter().any(|c| !c.is_empty()) {
392            return Err(LogError::Syntax {
393                format: FORMAT,
394                line: number,
395                message: format!(
396                    "the row has {} cells and the header names {}",
397                    cells.len(),
398                    columns.len()
399                ),
400            });
401        }
402        let cell = |index: Option<usize>| index.and_then(|index| cells.get(index).copied());
403        let Some(time) = cell(Some(self.clock)).and_then(number_in) else {
404            notes.push(format!(
405                "line {number} has no time and was left out: {:?}",
406                short(line)
407            ));
408            return Ok(());
409        };
410        self.rows += 1;
411        self.cells_written += cells.len().min(columns.len());
412        if crate::log::too_sparse(columns.len(), self.rows, self.cells_written) {
413            return Err(LogError::Syntax {
414                format: FORMAT,
415                line: number,
416                message: format!(
417                    "{} rows hold {} of the {} cells the header's {} columns name: too few for \
418                     the header to be this file's",
419                    self.rows,
420                    self.cells_written,
421                    self.rows.saturating_mul(columns.len()),
422                    columns.len()
423                ),
424            });
425        }
426        if cells.len() < columns.len() {
427            notes.push(format!(
428                "line {number} has {} cells of the header's {}; the rest read as gaps",
429                cells.len(),
430                columns.len()
431            ));
432        }
433        if self.time_s.is_empty() {
434            self.first_serial = cell(self.serial).filter(|s| !s.is_empty()).map(short);
435            self.first_flight = cell(self.flight).filter(|s| !s.is_empty()).map(short);
436        }
437        self.time_s.push(time);
438        for (index, column) in columns.iter().enumerate() {
439            if matches!(column.role, Role::Clock | Role::Text) {
440                continue;
441            }
442            // A cell a short row leaves out is a gap its row's note counts, not the column's.
443            let value = match cells.get(index) {
444                Some(cell) => number_in(cell).unwrap_or_else(|| {
445                    self.gaps[index] += 1;
446                    f64::NAN
447                }),
448                None => f64::NAN,
449            };
450            self.values[index].push(value);
451        }
452        if self.state_name.is_some() {
453            self.states
454                .push(cell(self.state_name).map(short).unwrap_or_default());
455        }
456        Ok(())
457    }
458}
459
460/// A cell's value: a finite number, or `None`.
461fn number_in(cell: &str) -> Option<f64> {
462    cell.parse::<f64>().ok().filter(|value| value.is_finite())
463}
464
465/// The lowest of a pad's pressures, Pa, that this reader takes a column for: below any launch
466/// site, and a hundred times the highest pressure in hPa below.
467const PASCAL_LOW: f64 = 10_000.0;
468/// The highest pressure, Pa, that this reader takes a column for: above any on Earth's surface.
469const PASCAL_HIGH: f64 = 120_000.0;
470
471/// How a pressure column's values read, from its highest: Pa (a factor of 1), hPa (100), or
472/// neither (`None`).
473fn pressure_factor(values: &[f64]) -> Option<f64> {
474    let highest = values
475        .iter()
476        .copied()
477        .filter(|value| value.is_finite())
478        .fold(f64::NEG_INFINITY, f64::max);
479    if (PASCAL_LOW..=PASCAL_HIGH).contains(&highest) {
480        Some(1.0)
481    } else if (PASCAL_LOW / 100.0..=PASCAL_HIGH / 100.0).contains(&highest) {
482        Some(100.0)
483    } else {
484        None
485    }
486}
487
488/// Builds the log from the rows.
489fn assemble(
490    names: &[String],
491    columns: &[Column],
492    mut table: Table,
493    preamble: &Preamble,
494    line_notes: Notes,
495) -> FlightLog {
496    let mut notes: Vec<String> = Vec::new();
497    let mut column_notes = Notes::default();
498    let n = table.time_s.len();
499    let find = |wanted: &str| {
500        names
501            .iter()
502            .position(|name| name.eq_ignore_ascii_case(wanted))
503    };
504
505    // GPS positions of exactly (0, 0) are the receiver's before a fix.
506    if let (Some(lat), Some(lon)) = (find("latitude"), find("longitude")) {
507        let gnss: Vec<usize> = (0..columns.len())
508            .filter(|&index| columns[index].role == Role::Value(Quantity::GnssAltitude))
509            .collect();
510        let mut blanked = 0;
511        for row in 0..n {
512            if table.values[lat][row] == 0.0 && table.values[lon][row] == 0.0 {
513                blanked += 1;
514                for &index in gnss.iter().chain([&lat, &lon]) {
515                    table.values[index][row] = f64::NAN;
516                }
517            }
518        }
519        if blanked > 0 {
520            notes.push(format!(
521                "{} placing the GPS at latitude 0, longitude 0 (AltOS's position before its \
522                 receiver has a fix): their latitude, longitude and GPS altitude read as gaps",
523                count(blanked, "row")
524            ));
525        }
526    }
527
528    let mut channels = Vec::with_capacity(columns.len());
529    let mut converted: Vec<Option<Vec<f64>>> = vec![None; columns.len()];
530    for (index, column) in columns.iter().enumerate() {
531        let name = &names[index];
532        let mut values = std::mem::take(&mut table.values[index]);
533        let quantity = match column.role {
534            Role::Clock | Role::Text => continue,
535            Role::Value(quantity) => quantity,
536            Role::Celsius => {
537                for value in &mut values {
538                    *value += 273.15;
539                }
540                Quantity::Temperature
541            }
542            Role::Pressure => match pressure_factor(&values) {
543                Some(factor) => {
544                    if factor != 1.0 {
545                        for value in &mut values {
546                            *value *= factor;
547                        }
548                        notes.push(format!(
549                            "the `{}` column never reaches 1,200 and was read as hPa, not Pa",
550                            short(name)
551                        ));
552                    }
553                    Quantity::Pressure
554                }
555                None => {
556                    notes.push(format!(
557                        "the `{}` column's highest value isn't a pad's pressure in Pa or hPa; it \
558                         is kept as written",
559                        short(name)
560                    ));
561                    Quantity::Unrecognized
562                }
563            },
564        };
565        if !column.known {
566            column_notes.push(format!(
567                "the column `{}` isn't one this reader knows; its values are kept as written",
568                short(name)
569            ));
570        }
571        if table.gaps[index] > 0 {
572            column_notes.push(format!(
573                "the `{}` column has {} empty or not a number (AltosUI writes `NaN` where it \
574                 has no value), read as gaps",
575                short(name),
576                count(table.gaps[index], "cell")
577            ));
578        }
579        converted[index] = Some(values.clone());
580        channels.push(Channel {
581            column: name.clone(),
582            quantity,
583            time_s: table.time_s.clone(),
584            values,
585        });
586    }
587    if find("acceleration").is_some() {
588        notes.push(
589            "AltOS writes `acceleration` with gravity taken out: it reads about 0 at rest, not \
590             1 g; the channel is kept as written"
591                .to_owned(),
592        );
593    }
594
595    // The primary track: in time order, the first row at each time.
596    let mut order: Vec<usize> = (0..n).collect();
597    order.sort_by(|&a, &b| table.time_s[a].total_cmp(&table.time_s[b]));
598    let mut latest = f64::NEG_INFINITY;
599    let out_of_order = table
600        .time_s
601        .iter()
602        .filter(|&&time| {
603            let early = time < latest;
604            latest = latest.max(time);
605            early
606        })
607        .count();
608    let mut kept: Vec<usize> = Vec::with_capacity(n);
609    for row in order {
610        match kept.last() {
611            Some(&last) if table.time_s[last] == table.time_s[row] => {}
612            _ => kept.push(row),
613        }
614    }
615    if out_of_order > 0 {
616        notes.push(format!(
617            "{} coming before an earlier row's time: the primary track takes the rows in time \
618             order; the channels keep the file's",
619            count(out_of_order, "row")
620        ));
621    }
622    if kept.len() < n {
623        notes.push(format!(
624            "{} repeating an earlier row's time: the primary track keeps the first row at each \
625             time; the channels keep every row",
626            count(n - kept.len(), "row")
627        ));
628    }
629    let primary = |index: Option<usize>| {
630        index
631            .and_then(|index| converted[index].as_ref())
632            .map(|values| kept.iter().map(|&row| values[row]).collect::<Vec<f64>>())
633    };
634    // The primary track is barometric: `accel_speed` integrates the accelerometer, and `speed`
635    // is the flight computer's own estimate, which uses its accelerometer on the way up.
636    let speed = find("baro_speed");
637
638    let mut events: Vec<Event> = Vec::new();
639    let mut state: Option<&str> = None;
640    for (row, name) in table.states.iter().enumerate() {
641        if !name.is_empty() && state != Some(name.as_str()) {
642            events.push(Event {
643                time_s: table.time_s[row],
644                name: name.clone(),
645            });
646            state = Some(name.as_str());
647        }
648    }
649
650    let serial_number = preamble
651        .serial
652        .clone()
653        .or_else(|| table.first_serial.clone());
654    if let (Some(stated), Some(written)) = (&preamble.serial, &table.first_serial)
655        && stated != written
656    {
657        notes.push(format!(
658            "the comment lines give the serial number as {stated:?} and the rows as {written:?}; \
659             the comment lines' is kept"
660        ));
661    }
662    let flight = preamble.flight.as_ref().or(table.first_flight.as_ref());
663    let flight_number = flight.and_then(|flight| flight.parse().ok());
664    if flight.is_some() && flight_number.is_none() {
665        notes.push(format!(
666            "the flight number {:?} isn't a whole number and was left out",
667            flight.map(String::as_str).unwrap_or_default()
668        ));
669    }
670
671    notes.extend(column_notes.kept);
672    notes.extend(line_notes.kept);
673    let left_out = column_notes.left_out + line_notes.left_out;
674    if left_out > 0 {
675        notes.push(format!(
676            "and {left_out} more notes about single columns and lines, left out so that a \
677             broken file's list stays short"
678        ));
679    }
680
681    FlightLog {
682        format: LogFormat::AltosCsv,
683        logger: match &preamble.product {
684            Some(product) => format!("AltOS {product}"),
685            None => "AltOS".to_owned(),
686        },
687        serial_number,
688        firmware: None,
689        flight_number,
690        stated: Stated::default(),
691        time_s: kept.iter().map(|&row| table.time_s[row]).collect(),
692        // `header_columns` has refused a header without a `height` column.
693        altitude_m: primary(find("height")).unwrap_or_default(),
694        vertical_speed_m_s: primary(speed),
695        temperature_k: primary(find("temperature")),
696        battery_v: primary(find("battery_voltage")),
697        channels,
698        events,
699        notes,
700    }
701}
702
703/// A count and its noun, such as `1 row` or `3 rows`.
704fn count(n: usize, noun: &str) -> String {
705    if n == 1 {
706        format!("1 {noun}")
707    } else {
708        format!("{n} {noun}s")
709    }
710}
711
712/// Text from the file as an error or note quotes it: cut to [`QUOTED_CHARS`] characters, so a
713/// binary file's "first line" doesn't fill the message.
714fn short(text: &str) -> String {
715    if text.chars().count() <= QUOTED_CHARS {
716        text.to_owned()
717    } else {
718        let mut cut: String = text.chars().take(QUOTED_CHARS - 1).collect();
719        cut.push('…');
720        cut
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    const HEAD: &str = "#version,serial,flight,call,time,rssi,lqi,state,state_name,acceleration,\
729                        pressure,altitude,height,baro_speed,temperature,battery_voltage\n";
730
731    fn row(time: f64, state: &str, height: f64) -> String {
732        format!(
733            "6, 77, 4,N0CALL, {time:8.2}, -80, 40, 2, {state:>7}, 0.10, 98000.00, \
734             {:.2}, {height:.2}, 1.50, 20.0, 3.90\n",
735            250.0 + height
736        )
737    }
738
739    /// Every column a channel in the file's order, but the clock and the text; the primary
740    /// track from `height`, `baro_speed`, `temperature` and `battery_voltage`, in SI.
741    #[test]
742    fn reads_columns_and_the_primary_track() {
743        let text = format!("{HEAD}{}{}", row(-0.5, "pad", 0.0), row(0.0, "boost", 1.25));
744        let log = read(&text).unwrap();
745        assert_eq!(log.format, LogFormat::AltosCsv);
746        assert_eq!(log.logger, "AltOS");
747        assert_eq!(log.serial_number.as_deref(), Some("77"));
748        assert_eq!(log.flight_number, Some(4));
749        assert_eq!(log.firmware, None);
750        assert_eq!(log.time_s, [-0.5, 0.0]);
751        assert_eq!(log.altitude_m, [0.0, 1.25]);
752        assert_eq!(log.vertical_speed_m_s.as_deref(), Some(&[1.5, 1.5][..]));
753        assert_eq!(log.temperature_k.as_deref(), Some(&[293.15, 293.15][..]));
754        assert_eq!(log.battery_v.as_deref(), Some(&[3.9, 3.9][..]));
755        let names: Vec<&str> = log.channels.iter().map(|c| c.column.as_str()).collect();
756        assert_eq!(
757            names,
758            [
759                "version",
760                "serial",
761                "flight",
762                "rssi",
763                "lqi",
764                "state",
765                "acceleration",
766                "pressure",
767                "altitude",
768                "height",
769                "baro_speed",
770                "temperature",
771                "battery_voltage"
772            ]
773        );
774        let pressure = log.channels_of(Quantity::Pressure).next().unwrap();
775        assert_eq!(pressure.values, [98000.0, 98000.0]);
776        assert_eq!(log.channels_of(Quantity::AltitudeMsl).count(), 1);
777        assert_eq!(
778            log.events,
779            [
780                Event {
781                    time_s: -0.5,
782                    name: "pad".to_owned()
783                },
784                Event {
785                    time_s: 0.0,
786                    name: "boost".to_owned()
787                }
788            ]
789        );
790        assert_eq!(log.notes.len(), 1, "{:?}", log.notes);
791        assert!(log.notes[0].contains("gravity taken out"));
792    }
793
794    /// The second `altitude`, after `longitude`, is the GPS receiver's; a (0, 0) position is
795    /// no fix, and its row's latitude, longitude and GPS altitude are gaps.
796    #[test]
797    fn the_second_altitude_is_the_gps_and_zero_zero_is_no_fix() {
798        let text = "#version,serial,flight,time,state_name,altitude,height,latitude,longitude,\
799                    altitude,nsat,sat01,pad_dist,pad_az,tilt,gyro_roll\n\
800                    6,1,1,0.0,pad,250,0,0,0,0,0,0,0,0,NaN,1\n\
801                    6,1,1,0.5,pad,250,0,40.5,-105.25,251.5,7,31,3,90,2.5,1\n";
802        let log = read(text).unwrap();
803        let quantities: Vec<Quantity> = log.channels.iter().map(|c| c.quantity).collect();
804        use Quantity as Q;
805        assert_eq!(
806            quantities,
807            [
808                Q::Number,
809                Q::Number,
810                Q::Number,
811                Q::AltitudeMsl,
812                Q::AltitudeAboveGround,
813                Q::Latitude,
814                Q::Longitude,
815                Q::GnssAltitude,
816                Q::Number,
817                Q::Number,
818                Q::Distance,
819                Q::Angle,
820                Q::Angle,
821                Q::Unrecognized
822            ]
823        );
824        let gnss = log.channels_of(Q::GnssAltitude).next().unwrap();
825        assert_eq!(gnss.column, "altitude");
826        assert!(gnss.values[0].is_nan());
827        assert_eq!(gnss.values[1], 251.5);
828        let latitude = log.channels_of(Q::Latitude).next().unwrap();
829        assert!(latitude.values[0].is_nan());
830        assert_eq!(latitude.values[1], 40.5);
831        // The barometric altitude on the same row is untouched.
832        let baro = log.channels_of(Q::AltitudeMsl).next().unwrap();
833        assert_eq!(baro.values, [250.0, 250.0]);
834        assert!(
835            log.notes
836                .iter()
837                .any(|n| n.starts_with("1 row placing the GPS")),
838            "{:?}",
839            log.notes
840        );
841        assert!(
842            log.notes
843                .iter()
844                .any(|n| n.contains("`tilt` column has 1 cell empty")),
845            "{:?}",
846            log.notes
847        );
848        // No speed, temperature or battery column: none on the primary track.
849        assert!(log.vertical_speed_m_s.is_none() && log.temperature_k.is_none());
850        assert!(log.battery_v.is_none());
851    }
852
853    /// Repeated times: the primary track keeps the first row at each, the channels every row.
854    /// Rows out of order are put in order on the primary track, and both are noted.
855    #[test]
856    fn repeated_and_reordered_times() {
857        let text = format!(
858            "{HEAD}{}{}{}{}",
859            row(0.0, "pad", 0.0),
860            row(0.5, "boost", 5.0),
861            row(0.5, "boost", 6.0),
862            row(0.25, "boost", 2.0)
863        );
864        let log = read(&text).unwrap();
865        assert_eq!(log.time_s, [0.0, 0.25, 0.5]);
866        assert_eq!(log.altitude_m, [0.0, 2.0, 5.0]);
867        let height = log
868            .channels_of(Quantity::AltitudeAboveGround)
869            .next()
870            .unwrap();
871        assert_eq!(height.time_s, [0.0, 0.5, 0.5, 0.25]);
872        assert_eq!(height.values, [0.0, 5.0, 6.0, 2.0]);
873        assert!(
874            log.notes
875                .iter()
876                .any(|n| n.starts_with("1 row coming before")),
877            "{:?}",
878            log.notes
879        );
880        assert!(
881            log.notes.iter().any(|n| n.starts_with("1 row repeating")),
882            "{:?}",
883            log.notes
884        );
885        // One event per change of state, in the file's order.
886        let names: Vec<&str> = log.events.iter().map(|e| e.name.as_str()).collect();
887        assert_eq!(names, ["pad", "boost"]);
888    }
889
890    /// Comment lines before a header without `#`: product, serial and flight from them.
891    #[test]
892    fn comment_lines_name_the_logger() {
893        let text = "\u{feff}# Altus Metrum flight log\r\n# product TeleMetrum\r\n# serial 2001\r\n\
894                    # flight 3\r\n# call N0CALL\r\nversion,serial,flight,time,state_name,height\r\n\
895                    5,2002,3,0.00,pad,0.0\r\n";
896        let log = read(text).unwrap();
897        assert_eq!(log.logger, "AltOS TeleMetrum");
898        assert_eq!(log.serial_number.as_deref(), Some("2001"));
899        assert_eq!(log.flight_number, Some(3));
900        assert!(
901            log.notes
902                .iter()
903                .any(|n| n.contains("\"2001\" and the rows as \"2002\"")),
904            "{:?}",
905            log.notes
906        );
907    }
908
909    /// A pressure column in hPa is converted; one that is neither Pa nor hPa is kept as written.
910    #[test]
911    fn pressure_unit_from_its_highest_value() {
912        let head = "#version,serial,flight,time,state_name,pressure,height\n";
913        let log = read(&format!(
914            "{head}5,1,1,0,pad,983.25,0\n5,1,1,1,pad,950.5,10\n"
915        ))
916        .unwrap();
917        let pressure = log.channels_of(Quantity::Pressure).next().unwrap();
918        assert_eq!(pressure.values, [98325.0, 95050.0]);
919        assert!(
920            log.notes.iter().any(|n| n.contains("read as hPa")),
921            "{:?}",
922            log.notes
923        );
924
925        let log = read(&format!("{head}5,1,1,0,pad,98.3,0\n")).unwrap();
926        assert_eq!(log.channels_of(Quantity::Pressure).count(), 0);
927        let kept = log
928            .channels
929            .iter()
930            .find(|c| c.column == "pressure")
931            .unwrap();
932        assert_eq!(
933            (kept.quantity, kept.values[0]),
934            (Quantity::Unrecognized, 98.3)
935        );
936
937        // The boundaries: 1,200 hPa and 10,000 Pa are each still their unit.
938        assert_eq!(pressure_factor(&[1200.0, f64::NAN]), Some(100.0));
939        assert_eq!(pressure_factor(&[10_000.0]), Some(1.0));
940        assert_eq!(pressure_factor(&[120_000.0]), Some(1.0));
941        assert_eq!(pressure_factor(&[1200.5]), None);
942        assert_eq!(pressure_factor(&[120_000.5]), None);
943        assert_eq!(pressure_factor(&[f64::NAN]), None);
944    }
945
946    /// Short rows have gaps; a long row of empty cells is a trailing comma; a row with no time
947    /// is left out; an unknown column is kept and noted.
948    #[test]
949    fn odd_rows_and_columns() {
950        let text = "#version,serial,flight,time,state_name,height,widget\n\
951                    6,1,1,0.0,pad,0,5,,\n\
952                    6,1,1,,pad,0,5\n\
953                    6,1,1,1.0,boost,3\n";
954        let log = read(text).unwrap();
955        assert_eq!(log.time_s, [0.0, 1.0]);
956        let widget = log.channels.iter().find(|c| c.column == "widget").unwrap();
957        assert_eq!(widget.quantity, Quantity::Unrecognized);
958        assert_eq!(widget.values[0], 5.0);
959        assert!(widget.values[1].is_nan());
960        let expected = [
961            "the column `widget` isn't one this reader knows",
962            "line 3 has no time and was left out",
963            "line 4 has 6 cells of the header's 7",
964        ];
965        for text in expected {
966            assert!(
967                log.notes.iter().any(|n| n.starts_with(text)),
968                "{text}: {:?}",
969                log.notes
970            );
971        }
972        // The short row's missing cell is counted by its line's note, not as the column's gap.
973        assert_eq!(log.notes.len(), expected.len(), "{:?}", log.notes);
974    }
975
976    /// A file of many broken rows keeps a short list of notes and counts the rest.
977    #[test]
978    fn notes_stay_few() {
979        let text = format!(
980            "#version,serial,flight,time,height\n{}0,1,1,0,0\n",
981            "0,1,1,x,0\n".repeat(1000)
982        );
983        let log = read(&text).unwrap();
984        assert_eq!(log.notes.len(), MAX_NOTES + 1);
985        assert!(
986            log.notes[MAX_NOTES].starts_with("and 980 more notes"),
987            "{:?}",
988            log.notes
989        );
990    }
991
992    /// Each refusal says what is wrong, and where.
993    #[test]
994    fn malformed_files_are_refused() {
995        let cases = [
996            ("", "not read as AltOS CSV: the file has no"),
997            ("# just a comment\n", "has no `version,serial,flight"),
998            (
999                "time,height\n0,0\n",
1000                "line 1, \"time,height\", isn't AltOS's header",
1001            ),
1002            ("PerfectFlite Pnut\n", "isn't AltOS's header"),
1003            (
1004                "#version,serial,flight,time\n0,0,0,0\n",
1005                "line 1: the header names no `height`",
1006            ),
1007            (
1008                "#version,serial,flight,height\n0,0,0,0\n",
1009                "line 1: the header names no `time`",
1010            ),
1011            ("#version,serial,flight,time,height\n", "no data rows"),
1012            (
1013                "#version,serial,flight,time,height\n0,0,0,x,0\n",
1014                "no data rows",
1015            ),
1016            (
1017                "#version,serial,flight,time,height\n0,0,0,0,0,7\n",
1018                "line 2: the row has 6 cells and the header names 5",
1019            ),
1020        ];
1021        for (text, expected) in cases {
1022            let error = read(text).unwrap_err().to_string();
1023            assert!(error.contains(expected), "{text:?}: {error}");
1024        }
1025        let junk = "\u{fffd}".repeat(100_000);
1026        let error = read(&junk).unwrap_err().to_string();
1027        assert!(error.len() < 400 && error.contains('…'), "{error}");
1028    }
1029
1030    proptest::proptest! {
1031        /// Any text is read or refused, never a panic; what is read has a strictly increasing
1032        /// primary track, and every channel as many times as values.
1033        #[test]
1034        fn any_text_is_read_or_refused(
1035            text in "(#version,serial,flight,time,height,state_name,altitude,longitude,altitude\n)?\
1036                     ([-0-9., NaN#a-z]{0,24}\n){0,12}"
1037        ) {
1038            if let Ok(log) = read(&text) {
1039                proptest::prop_assert!(log.time_s.windows(2).all(|pair| pair[1] > pair[0]));
1040                proptest::prop_assert_eq!(log.time_s.len(), log.altitude_m.len());
1041                for channel in &log.channels {
1042                    proptest::prop_assert_eq!(channel.time_s.len(), channel.values.len());
1043                }
1044            }
1045        }
1046    }
1047}