1use crate::error::LogError;
102use crate::log::{Channel, Event, FlightLog, LogFormat, Quantity, Stated};
103
104const FORMAT: &str = "AltOS CSV";
106
107const MAX_NOTES: usize = 20;
109
110const QUOTED_CHARS: usize = 80;
112
113#[derive(Debug, Clone, Copy, PartialEq)]
115enum Role {
116 Clock,
118 Text,
120 Value(Quantity),
122 Celsius,
124 Pressure,
126}
127
128fn 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
161fn 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#[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#[derive(Default)]
188struct Preamble {
189 product: Option<String>,
190 serial: Option<String>,
191 flight: Option<String>,
192}
193
194pub 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
262fn 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 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#[derive(Debug, Clone, Copy)]
295struct Column {
296 role: Role,
297 known: bool,
298}
299
300fn 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 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
332struct Table {
334 time_s: Vec<f64>,
336 values: Vec<Vec<f64>>,
339 gaps: Vec<usize>,
341 states: Vec<String>,
343 clock: usize,
345 state_name: Option<usize>,
347 serial: Option<usize>,
349 flight: Option<usize>,
350 first_serial: Option<String>,
352 first_flight: Option<String>,
353 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 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 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 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
460fn number_in(cell: &str) -> Option<f64> {
462 cell.parse::<f64>().ok().filter(|value| value.is_finite())
463}
464
465const PASCAL_LOW: f64 = 10_000.0;
468const PASCAL_HIGH: f64 = 120_000.0;
470
471fn 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
488fn 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 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 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 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 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
703fn count(n: usize, noun: &str) -> String {
705 if n == 1 {
706 format!("1 {noun}")
707 } else {
708 format!("{n} {noun}s")
709 }
710}
711
712fn 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 #[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 #[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 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 assert!(log.vertical_speed_m_s.is_none() && log.temperature_k.is_none());
850 assert!(log.battery_v.is_none());
851 }
852
853 #[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 let names: Vec<&str> = log.events.iter().map(|e| e.name.as_str()).collect();
887 assert_eq!(names, ["pad", "boost"]);
888 }
889
890 #[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 #[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 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 #[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 assert_eq!(log.notes.len(), expected.len(), "{:?}", log.notes);
974 }
975
976 #[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 #[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 #[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}