1use 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
157const FORMAT: &str = "Blue Raven CSV";
159
160const LEAD: [&str; 6] = ["Year", "Month", "Day", "Time", "Flight_Time_(s)", "Sync"];
162
163const CLOCK_INDEX: usize = 4;
165
166const ATM_PA: f64 = 101_325.0;
168
169pub const MAX_EVENTS_PER_FLAG: usize = 16;
172
173const WRAP_STEP_FT: f64 = 32_768.0;
176
177const PAD_ROWS: usize = 50;
180
181pub(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
190fn 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#[derive(Debug, Clone, Copy, PartialEq)]
198enum Scale {
199 By(f64),
201 Fahrenheit,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq)]
207enum Role {
208 Clock,
210 TimeOfDay,
212 Value(Quantity, Scale),
214 Hex,
216 Flag { event: bool },
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222enum Decided {
223 Velocity,
224 Inertial,
225 Gyro,
226 Accel,
227}
228
229fn key(name: &str) -> String {
231 name.chars()
232 .filter(|c| !c.is_whitespace())
233 .collect::<String>()
234 .to_ascii_lowercase()
235}
236
237fn 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
279struct Column {
281 name: String,
282 key: String,
283 role: Role,
284 decided: Option<Decided>,
285 values: Vec<f64>,
286 gaps: usize,
288 gap_example: Option<String>,
289 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
306pub 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 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 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
379fn 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
431fn 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 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
514fn 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
535fn number_cell(cell: &str) -> Option<f64> {
537 number(cell, false)
538}
539
540fn 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
548fn 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
572fn 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 u64::from_str_radix(digits, 16).ok().map(|bits| bits as f64)
584}
585
586fn 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
701fn 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
712fn 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
764const MAX_LISTED: usize = 6;
766
767fn 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
785fn 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
798fn 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
830fn 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
846fn 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
860fn 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
894fn current_note(current: &Column) -> String {
896 let values = ¤t.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(¤t.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 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 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 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 #[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 #[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 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 #[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 let readings = crate::readings::read(&log);
1153 assert!(readings.apogee.value().is_none());
1154 }
1155
1156 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}