1use hpr_atmos::ussa76::Ussa76;
153
154use crate::error::LogError;
155use crate::log::{Channel, Event, FlightLog, LogFormat, Quantity, Stated};
156use crate::perfectflite::FOOT_M;
157use crate::table::{self, Notes, numbered_lines, short};
158
159const FORMAT: &str = "RRC3 CSV";
161
162const HPA_PA: f64 = 100.0;
164
165const PRESSURE_RANGE_HPA: (f64, f64) = (10.0, 1_200.0);
168
169const PAD_RANGE_HPA: (f64, f64) = (500.0, 1_100.0);
174
175const PRESSURE_STEP_S: f64 = 0.05;
177
178const STEP_MARGIN: f64 = 0.05;
181
182const SETTLING_CLIMB_M: f64 = 10.0;
184
185const ALTITUDE_MARGIN: f64 = 0.25;
187
188const VELOCITY_CLIMB_M: f64 = 50.0;
190
191const VELOCITY_MARGIN: f64 = 0.03;
193
194const DELAY_SEARCH_S: (f64, f64, f64) = (-2.0, 4.0, 0.05);
197
198const END_HEIGHT: f64 = 0.5;
201
202const END_ERROR: f64 = 0.001;
205
206const SPEEDS: [(&str, &[&str], f64); 4] = [
209 ("feet per second", &["ft/s", "fps"], FOOT_M),
210 ("miles per hour", &["mph"], 0.447_04),
211 ("meters per second", &["m/s", "mps"], 1.0),
212 ("kilometers per hour", &["km/h", "kph"], 1.0 / 3.6),
213];
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217enum Kind {
218 Time,
219 Altitude,
220 Pressure,
221 Velocity,
222 Temperature,
223 Events,
224 Voltages,
225}
226
227impl Kind {
228 fn from_name(name: &str) -> Option<Kind> {
229 [
230 ("time", Kind::Time),
231 ("altitude", Kind::Altitude),
232 ("pressure", Kind::Pressure),
233 ("velocity", Kind::Velocity),
234 ("temperature", Kind::Temperature),
235 ("events", Kind::Events),
236 ("voltages", Kind::Voltages),
237 ]
238 .into_iter()
239 .find(|(known, _)| name.eq_ignore_ascii_case(known))
240 .map(|(_, kind)| kind)
241 }
242
243 fn is_slow(self) -> bool {
245 matches!(self, Kind::Temperature | Kind::Voltages)
246 }
247}
248
249struct Column {
251 kind: Kind,
252 written: String,
253 unit: Option<String>,
254}
255
256fn split_name(cell: &str) -> (&str, Option<&str>) {
258 let cell = cell.trim();
259 match (cell.find('['), cell.strip_suffix(']')) {
260 (Some(open), Some(inner)) if open < inner.len() => {
261 (cell[..open].trim(), Some(inner[open + 1..].trim()))
262 }
263 _ => (cell, None),
264 }
265}
266
267fn separator(header: &str) -> char {
269 if header.contains(';') {
270 ';'
271 } else if header.contains('\t') {
272 '\t'
273 } else {
274 ','
275 }
276}
277
278fn header_columns(header: &str) -> Option<(char, Vec<Column>)> {
280 let separator = separator(header);
281 let mut columns: Vec<Column> = Vec::new();
282 for cell in header.split(separator) {
283 let (name, unit) = split_name(cell);
284 let kind = Kind::from_name(name)?;
285 if columns.iter().any(|column| column.kind == kind) {
286 return None;
287 }
288 let first = [Kind::Time, Kind::Altitude, Kind::Pressure, Kind::Velocity];
289 match first.get(columns.len()) {
290 Some(expected) if *expected != kind => return None,
291 _ => {}
292 }
293 columns.push(Column {
294 kind,
295 written: cell.trim().to_owned(),
296 unit: unit.map(str::to_owned),
297 });
298 }
299 (columns.len() >= 4).then_some((separator, columns))
300}
301
302pub(crate) fn recognizes(text: &str) -> bool {
305 numbered_lines(text)
306 .map(|(_, line)| line)
307 .find(|line| !line.trim().is_empty())
308 .is_some_and(|header| header_columns(header).is_some())
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313enum Length {
314 Feet,
315 Meters,
316}
317
318impl Length {
319 fn meters(self) -> f64 {
320 match self {
321 Length::Feet => FOOT_M,
322 Length::Meters => 1.0,
323 }
324 }
325
326 fn words(self) -> &'static str {
327 match self {
328 Length::Feet => "feet",
329 Length::Meters => "meters",
330 }
331 }
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336enum Temperature {
337 Fahrenheit,
338 Celsius,
339}
340
341impl Temperature {
342 fn kelvin(self, value: f64) -> f64 {
343 match self {
344 Temperature::Fahrenheit => (value - 32.0) * 5.0 / 9.0 + 273.15,
345 Temperature::Celsius => value + 273.15,
346 }
347 }
348}
349
350#[derive(Default)]
352struct HeaderUnits {
353 altitude: Option<Length>,
354 pressure_hpa: bool,
355 velocity: Option<usize>,
357 temperature: Option<Temperature>,
358}
359
360fn header_units(columns: &[Column], line: usize) -> Result<HeaderUnits, LogError> {
362 let mut units = HeaderUnits::default();
363 for column in columns {
364 let Some(unit) = column.unit.as_deref() else {
365 continue;
366 };
367 let lower = unit.to_ascii_lowercase();
368 let known = match column.kind {
369 Kind::Time => lower == "s",
370 Kind::Altitude => {
371 units.altitude = match lower.as_str() {
372 "ft" => Some(Length::Feet),
373 "m" => Some(Length::Meters),
374 _ => None,
375 };
376 units.altitude.is_some()
377 }
378 Kind::Pressure => {
379 units.pressure_hpa = lower == "mbar" || lower == "hpa";
380 units.pressure_hpa
381 }
382 Kind::Velocity => {
383 units.velocity = SPEEDS
384 .iter()
385 .position(|(_, spellings, _)| spellings.contains(&lower.as_str()));
386 units.velocity.is_some()
387 }
388 Kind::Temperature => {
389 units.temperature = match lower.trim_start_matches('°') {
390 "f" => Some(Temperature::Fahrenheit),
391 "c" => Some(Temperature::Celsius),
392 _ => None,
393 };
394 units.temperature.is_some()
395 }
396 Kind::Voltages => lower == "v",
397 Kind::Events => false,
398 };
399 if !known {
400 return Err(LogError::Unit {
401 format: FORMAT,
402 line,
403 message: format!(
404 "the column {:?} states a unit this reader doesn't know for it",
405 short(&column.written)
406 ),
407 });
408 }
409 }
410 Ok(units)
411}
412
413struct Rows {
416 lines: Vec<usize>,
417 time_s: Vec<f64>,
418 values: Vec<Vec<f64>>,
420 events: Vec<Event>,
421 short: usize,
422 padded: usize,
423 first_short_line: Option<usize>,
424 skipped: usize,
425 first_skipped_line: Option<usize>,
426}
427
428pub fn read(text: &str) -> Result<FlightLog, LogError> {
438 let mut lines = numbered_lines(text).filter(|(_, line)| !line.trim().is_empty());
439 let Some((header_line, header)) = lines.next() else {
440 return Err(LogError::NotThisFormat {
441 format: FORMAT,
442 message: "the file is empty".to_owned(),
443 });
444 };
445 let Some((separator, columns)) = header_columns(header) else {
446 return Err(LogError::NotThisFormat {
447 format: FORMAT,
448 message: format!(
449 "its first line, {:?}, isn't an mDACS export's header: `Time`, `Altitude`, \
450 `Pressure`, `Velocity`, then any of `Temperature`, `Events` and `Voltages`",
451 short(header)
452 ),
453 });
454 };
455 let units = header_units(&columns, header_line)?;
456 let mut notes = Notes::default();
457 let rows = read_rows(lines, separator, &columns, &mut notes)?;
458 if rows.time_s.is_empty() {
459 return Err(LogError::NoData { format: FORMAT });
460 }
461 build(&columns, &units, rows, header_line, notes)
462}
463
464fn read_rows<'a>(
466 lines: impl Iterator<Item = (usize, &'a str)>,
467 separator: char,
468 columns: &[Column],
469 notes: &mut Notes,
470) -> Result<Rows, LogError> {
471 let decimal_comma = separator != ',';
472 let width = columns.len();
473 let fast: Vec<usize> = (0..width).filter(|&i| !columns[i].kind.is_slow()).collect();
475 let has_slow = fast.len() < width;
476 let mut rows = Rows {
477 lines: Vec::new(),
478 time_s: Vec::new(),
479 values: columns.iter().map(|_| Vec::new()).collect(),
480 events: Vec::new(),
481 short: 0,
482 padded: 0,
483 first_short_line: None,
484 skipped: 0,
485 first_skipped_line: None,
486 };
487 let mut placed: Vec<Option<&str>> = vec![None; width];
488 for (number, line) in lines {
489 let cells: Vec<&str> = line.split(separator).map(str::trim).collect();
490 let empty_from = |from: usize| {
491 cells
492 .get(from..)
493 .is_some_and(|rest| rest.iter().all(|c| c.is_empty()))
494 };
495 if cells.first().is_none_or(|time| time.is_empty()) {
496 let only_altitude = cells
498 .iter()
499 .enumerate()
500 .all(|(index, cell)| index == 1 || cell.is_empty());
501 if cells.len() <= width && only_altitude {
502 rows.skipped += 1;
503 rows.first_skipped_line.get_or_insert(number);
504 continue;
505 }
506 return Err(syntax(number, "the row has no time".to_owned()));
507 }
508 placed.fill(None);
509 if cells.len() == width && !(has_slow && empty_from(fast.len())) {
510 for (slot, cell) in placed.iter_mut().zip(&cells) {
511 *slot = Some(*cell);
512 }
513 } else if has_slow
514 && (cells.len() == fast.len() || (cells.len() == width && empty_from(fast.len())))
515 {
516 for (&index, cell) in fast.iter().zip(&cells) {
517 if let Some(slot) = placed.get_mut(index) {
518 *slot = Some(*cell);
519 }
520 }
521 rows.short += 1;
522 if cells.len() == width {
523 rows.padded += 1;
524 }
525 rows.first_short_line.get_or_insert(number);
526 } else {
527 let without = if has_slow {
528 format!(", and a row without its 1 Hz cells has {}", fast.len())
529 } else {
530 String::new()
531 };
532 return Err(syntax(
533 number,
534 format!(
535 "the row has {} cells: the header names {width}{without}",
536 cells.len()
537 ),
538 ));
539 }
540 read_row(&mut rows, columns, &placed, number, decimal_comma, notes)?;
541 }
542 Ok(rows)
543}
544
545fn read_row(
547 rows: &mut Rows,
548 columns: &[Column],
549 placed: &[Option<&str>],
550 number: usize,
551 decimal_comma: bool,
552 notes: &mut Notes,
553) -> Result<(), LogError> {
554 let mut time_s = f64::NAN;
555 let mut event = None;
556 let mut values = Vec::with_capacity(columns.len());
557 for (column, cell) in columns.iter().zip(placed) {
558 let Some(cell) = *cell else {
559 values.push(f64::NAN);
560 continue;
561 };
562 match column.kind {
563 Kind::Events => {
564 if !(cell.is_empty() || cell == "-") {
565 if !cell.bytes().all(|b| b.is_ascii_alphabetic()) {
566 return Err(syntax(
567 number,
568 format!(
569 "{:?} under {:?} isn't an event, `-` or a word such as `Drogue`",
570 short(cell),
571 short(&column.written)
572 ),
573 ));
574 }
575 event = Some(cell.to_owned());
576 }
577 values.push(f64::NAN);
578 }
579 _ => {
580 let value = table::number(cell, decimal_comma).ok_or_else(|| {
581 syntax(
582 number,
583 format!(
584 "{:?} under {:?} isn't a number",
585 short(cell),
586 short(&column.written)
587 ),
588 )
589 })?;
590 if column.kind == Kind::Time {
591 time_s = value;
592 }
593 values.push(value);
594 }
595 }
596 }
597 if let Some(&last) = rows.time_s.last()
598 && time_s <= last
599 {
600 return Err(syntax(
601 number,
602 format!("the time {time_s} s doesn't come after the one above it, {last} s"),
603 ));
604 }
605 rows.lines.push(number);
606 rows.time_s.push(time_s);
607 for ((column, store), value) in columns.iter().zip(&mut rows.values).zip(values) {
608 if !matches!(column.kind, Kind::Time | Kind::Events) {
609 store.push(value);
610 }
611 }
612 if let Some(name) = event {
613 if !["Drogue", "Main", "Aux"].contains(&name.as_str()) {
614 notes.push(format!(
615 "line {number}'s event {:?} isn't one of the three mDACS charts, `Drogue`, \
616 `Main` and `Aux`; it was kept as written",
617 short(&name)
618 ));
619 }
620 rows.events.push(Event { time_s, name });
621 }
622 Ok(())
623}
624
625struct Decided {
627 quantity: Quantity,
628 values: Vec<f64>,
629}
630
631fn build(
634 columns: &[Column],
635 units: &HeaderUnits,
636 rows: Rows,
637 header_line: usize,
638 mut notes: Notes,
639) -> Result<FlightLog, LogError> {
640 let mut log = FlightLog {
641 format: LogFormat::Rrc3Csv,
642 logger: "Missile Works RRC3".to_owned(),
643 serial_number: None,
644 firmware: None,
645 flight_number: None,
646 stated: Stated::default(),
647 time_s: Vec::new(),
648 altitude_m: Vec::new(),
649 vertical_speed_m_s: None,
650 temperature_k: None,
651 battery_v: None,
652 channels: Vec::new(),
653 events: rows.events,
654 notes: Vec::new(),
655 };
656 let column_of = |kind: Kind| columns.iter().position(|column| column.kind == kind);
657 let values_of = |kind: Kind| column_of(kind).and_then(|index| rows.values.get(index));
658 let written_of = |kind: Kind| {
659 column_of(kind)
660 .map(|index| columns[index].written.as_str())
661 .unwrap_or_default()
662 };
663 let (Some(altitude), Some(pressure), Some(velocity)) = (
665 values_of(Kind::Altitude),
666 values_of(Kind::Pressure),
667 values_of(Kind::Velocity),
668 ) else {
669 return Err(syntax(header_line, "the header lacks a column".to_owned()));
670 };
671
672 let pressure = decide_pressure(pressure, units, written_of(Kind::Pressure), &mut log.notes);
673 let (length, length_from_evidence) = decide_altitude(
674 altitude,
675 &pressure,
676 units,
677 &rows.lines,
678 written_of(Kind::Altitude),
679 header_line,
680 &mut log.notes,
681 )?;
682 let altitude_m: Vec<f64> = altitude
683 .iter()
684 .map(|value| value * length.meters())
685 .collect();
686 let track = Track {
687 time_s: &rows.time_s,
688 altitude_m: &altitude_m,
689 known: length_from_evidence,
690 };
691 let velocity = decide_velocity(
692 velocity,
693 &track,
694 units,
695 written_of(Kind::Velocity),
696 header_line,
697 &mut log.notes,
698 )?;
699
700 for (column, values) in columns.iter().zip(&rows.values) {
701 let decided = match column.kind {
702 Kind::Time => {
703 if column.unit.is_none() {
704 log.notes.push(time_note(&column.written, &rows.time_s));
705 }
706 continue;
707 }
708 Kind::Events => continue,
709 Kind::Altitude => Decided {
710 quantity: Quantity::AltitudeAboveGround,
711 values: altitude_m.clone(),
712 },
713 Kind::Pressure => Decided {
714 quantity: pressure.quantity,
715 values: pressure.values.clone(),
716 },
717 Kind::Velocity => Decided {
718 quantity: velocity.quantity,
719 values: velocity.values.clone(),
720 },
721 Kind::Temperature => {
722 let decided =
723 decide_temperature(values, units, length, &column.written, &mut log.notes);
724 log.temperature_k = Some(decided.values.clone());
725 decided
726 }
727 Kind::Voltages => {
728 if column.unit.is_none() {
729 log.notes.push(format!(
730 "`{}` read in volts, assumed: the RRC3 records its battery's voltage \
731 (mDACS manual p. 2) and takes a battery of at most 10 V (RRC3 manual \
732 p. 6); its values, as written, run {}",
733 short(&column.written),
734 span(values)
735 ));
736 }
737 log.battery_v = Some(values.clone());
738 Decided {
739 quantity: Quantity::Voltage,
740 values: values.clone(),
741 }
742 }
743 };
744 log.channels.push(Channel {
745 column: column.written.clone(),
746 quantity: decided.quantity,
747 time_s: rows.time_s.clone(),
748 values: decided.values,
749 });
750 }
751
752 if let Some(&first) = altitude_m.first()
753 && first.abs() > SETTLING_CLIMB_M
754 {
755 notes.push(format!(
756 "the altitude starts at {first:.1} m, not at the pad's zero the RRC3 measures from"
757 ));
758 }
759 if rows.short > 0 {
760 log.notes.push(format!(
761 "{} rows, the first on line {}, leave out the 1 Hz {}: they are gaps{}",
762 rows.short,
763 rows.first_short_line.unwrap_or_default(),
764 slow_names(columns),
765 if rows.padded > 0 {
766 format!(
767 "; {} of them pad the row to the header's width with empty cells after the \
768 others",
769 rows.padded
770 )
771 } else {
772 String::new()
773 }
774 ));
775 }
776 if rows.skipped > 0 {
777 log.notes.push(format!(
778 "{} rows with no time and nothing but an altitude cell, the first on line {}, were \
779 skipped",
780 rows.skipped,
781 rows.first_skipped_line.unwrap_or_default()
782 ));
783 }
784 log.time_s = rows.time_s;
785 log.altitude_m = altitude_m;
786 if velocity.quantity == Quantity::Speed {
787 log.vertical_speed_m_s = Some(velocity.values);
788 }
789 log.notes.extend(notes.into_vec());
790 Ok(log)
791}
792
793fn time_note(written: &str, time_s: &[f64]) -> String {
796 let mut steps: Vec<f64> = time_s.windows(2).map(|pair| pair[1] - pair[0]).collect();
797 steps.sort_by(f64::total_cmp);
798 let chart = "mDACS's chart is timed in seconds (mDACS manual p. 8)";
799 match steps.get(steps.len() / 2) {
800 Some(&median) if (median / PRESSURE_STEP_S - 1.0).abs() <= STEP_MARGIN => format!(
801 "`{}` read in seconds, from the step: the median step between rows, {median} as \
802 written, is within {:.0}% of the {PRESSURE_STEP_S} s of the 20 Hz the RRC3 records \
803 its pressure at (RRC3 manual p. 23)",
804 short(written),
805 STEP_MARGIN * 100.0
806 ),
807 Some(&median) => format!(
808 "`{}` read in seconds, assumed: the median step between rows, {median} as written, \
809 isn't within {:.0}% of the {PRESSURE_STEP_S} s of the 20 Hz the RRC3 records its \
810 pressure at (RRC3 manual p. 23); {chart}",
811 short(written),
812 STEP_MARGIN * 100.0
813 ),
814 None => format!(
815 "`{}` read in seconds, assumed: a single row has no step to measure; {chart}",
816 short(written)
817 ),
818 }
819}
820
821fn slow_names(columns: &[Column]) -> String {
823 let names: Vec<String> = columns
824 .iter()
825 .filter(|column| column.kind.is_slow())
826 .map(|column| format!("`{}`", short(&column.written)))
827 .collect();
828 match names.as_slice() {
829 [one] => format!("{one} cell"),
830 _ => format!("{} cells", names.join(" and ")),
831 }
832}
833
834fn span(values: &[f64]) -> String {
836 let (low, high) = values
837 .iter()
838 .filter(|value| value.is_finite())
839 .fold((f64::INFINITY, f64::NEG_INFINITY), |(low, high), &value| {
840 (low.min(value), high.max(value))
841 });
842 if low.is_finite() {
843 format!("from {low} to {high}")
844 } else {
845 "to no number".to_owned()
846 }
847}
848
849fn decide_pressure(
852 values: &[f64],
853 units: &HeaderUnits,
854 written: &str,
855 notes: &mut Vec<String>,
856) -> Decided {
857 let in_hpa = Decided {
858 quantity: Quantity::Pressure,
859 values: values.iter().map(|value| value * HPA_PA).collect(),
860 };
861 if units.pressure_hpa {
862 return in_hpa;
863 }
864 let highest = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
865 let pad = values.first().copied().unwrap_or(f64::NAN);
866 let (low, high) = PRESSURE_RANGE_HPA;
867 let (pad_low, pad_high) = PAD_RANGE_HPA;
868 let measured = (low..=high).contains(&highest);
869 let on_a_pad = (pad_low..=pad_high).contains(&pad);
870 let ranges = format!(
871 "the first row's, {pad}, {} the {pad_low} to {pad_high} hPa of any launch site, and the \
872 highest, {highest}, {} the {low} to {high} mbar the RRC3 measures (RRC3 manual p. 9)",
873 if on_a_pad {
874 "lies within"
875 } else {
876 "lies outside"
877 },
878 if measured { "within" } else { "outside" }
879 );
880 if measured && on_a_pad {
881 notes.push(format!(
882 "`{}` read in hectopascals, from the values: {ranges}",
883 short(written)
884 ));
885 in_hpa
886 } else {
887 notes.push(format!(
888 "`{}` kept as written, its unit unknown: {ranges}",
889 short(written)
890 ));
891 Decided {
892 quantity: Quantity::Unrecognized,
893 values: values.to_vec(),
894 }
895 }
896}
897
898fn pressure_climb(pressure: &Decided) -> Option<(usize, f64)> {
902 if pressure.quantity != Quantity::Pressure {
903 return None;
904 }
905 let atmosphere = Ussa76::standard();
906 let first = *pressure.values.first()?;
907 let (lowest, lowest_pa) = pressure.values.iter().copied().enumerate().fold(
908 (0, first),
909 |(at, low), (index, value)| {
910 if value < low {
911 (index, value)
912 } else {
913 (at, low)
914 }
915 },
916 );
917 let climb_m = atmosphere.pressure_altitude_m(lowest_pa).ok()?
918 - atmosphere.pressure_altitude_m(first).ok()?;
919 Some((lowest, climb_m))
920}
921
922fn decide_altitude(
925 altitude: &[f64],
926 pressure: &Decided,
927 units: &HeaderUnits,
928 lines: &[usize],
929 written: &str,
930 header_line: usize,
931 notes: &mut Vec<String>,
932) -> Result<(Length, bool), LogError> {
933 let climb = pressure_climb(pressure);
934 let Some((lowest, climb_m)) = climb.filter(|(_, climb_m)| *climb_m >= SETTLING_CLIMB_M) else {
935 if let Some(stated) = units.altitude {
936 return Ok((stated, true));
937 }
938 let why = match climb {
939 _ if pressure.quantity != Quantity::Pressure => {
940 "the pressure's unit is unknown, so it can't tell feet from meters".to_owned()
941 }
942 Some((_, climb_m)) => format!(
943 "the pressure climbs {climb_m:.1} m, under the {SETTLING_CLIMB_M} m from which \
944 it tells feet from meters"
945 ),
946 None => "a pressure isn't positive, so it gives no climb".to_owned(),
947 };
948 notes.push(format!(
949 "`{}` read in feet, assumed: {why}; feet are the RRC3's default unit (RRC3 manual p. \
950 12)",
951 short(written)
952 ));
953 return Ok((Length::Feet, false));
954 };
955 let (Some(&first), Some(&at_lowest)) = (altitude.first(), altitude.get(lowest)) else {
956 return Ok((Length::Feet, false));
957 };
958 let written_climb = at_lowest - first;
959 let miss = |length: Length| written_climb * length.meters() / climb_m - 1.0;
960 let (feet, meters) = (miss(Length::Feet), miss(Length::Meters));
961 let evidence = format!(
962 "from line {} to line {} the pressure falls from {:.2} hPa to {:.2} hPa, a climb of \
963 {:.1} m in the U.S. Standard Atmosphere (1976), and the altitude climbs {:.2} as \
964 written, {:+.2}% off it read in feet and {:+.1}% read in meters",
965 lines.first().copied().unwrap_or_default(),
966 lines.get(lowest).copied().unwrap_or_default(),
967 pressure.values.first().copied().unwrap_or_default() / HPA_PA,
968 pressure.values.get(lowest).copied().unwrap_or_default() / HPA_PA,
969 climb_m,
970 written_climb,
971 feet * 100.0,
972 meters * 100.0
973 );
974 let fits = |miss: f64| miss.abs() <= ALTITUDE_MARGIN;
975 if let Some(stated) = units.altitude {
976 let stated_miss = miss(stated);
977 if !fits(stated_miss) {
978 return Err(LogError::Unit {
979 format: FORMAT,
980 line: header_line,
981 message: format!(
982 "{:?} states {}, which the pressure contradicts: {evidence}",
983 short(written),
984 stated.words()
985 ),
986 });
987 }
988 return Ok((stated, true));
989 }
990 let length = if fits(feet) {
991 Length::Feet
992 } else if fits(meters) {
993 Length::Meters
994 } else {
995 return Err(LogError::Unit {
996 format: FORMAT,
997 line: header_line,
998 message: format!(
999 "{:?} is in neither feet nor meters by the pressure, within {:.0}%: {evidence}",
1000 short(written),
1001 ALTITUDE_MARGIN * 100.0
1002 ),
1003 });
1004 };
1005 notes.push(format!(
1006 "`{}` read in {}, from the pressure: {evidence}",
1007 short(written),
1008 length.words()
1009 ));
1010 Ok((length, true))
1011}
1012
1013#[derive(Debug, Clone, Copy)]
1015struct Scale {
1016 by_area: f64,
1019 by_fit: f64,
1022 delay_s: f64,
1024 end_height: f64,
1026 end_error: f64,
1029}
1030
1031fn running_integral(time_s: &[f64], velocity: &[f64]) -> Vec<f64> {
1033 let mut integral = 0.0;
1034 let mut out = Vec::with_capacity(time_s.len().min(velocity.len()));
1035 let mut previous: Option<(f64, f64)> = None;
1036 for (&time, &value) in time_s.iter().zip(velocity) {
1037 if let Some((before_s, before)) = previous {
1038 integral += 0.5 * (value + before) * (time - before_s);
1039 }
1040 out.push(integral);
1041 previous = Some((time, value));
1042 }
1043 out
1044}
1045
1046fn altitude_at(time_s: &[f64], above_m: &[f64], time: f64, cursor: &mut usize) -> f64 {
1050 while time_s.get(*cursor + 1).is_some_and(|next| *next <= time) {
1051 *cursor += 1;
1052 }
1053 match (
1054 time_s.get(*cursor),
1055 time_s.get(*cursor + 1),
1056 above_m.get(*cursor),
1057 above_m.get(*cursor + 1),
1058 ) {
1059 (Some(&from_s), _, Some(&from), _) if time <= from_s => from,
1060 (Some(&from_s), Some(&to_s), Some(&from), Some(&to)) => {
1061 from + (to - from) * (time - from_s) / (to_s - from_s)
1062 }
1063 _ => above_m.last().copied().unwrap_or_default(),
1064 }
1065}
1066
1067fn velocity_scale(time_s: &[f64], altitude_m: &[f64], velocity: &[f64]) -> Option<Scale> {
1070 let first = *altitude_m.first()?;
1071 let above: Vec<f64> = altitude_m.iter().map(|value| value - first).collect();
1072 let integral = running_integral(time_s, velocity);
1073 let rows = integral.len().min(above.len());
1074 let (time_s, above, integral) = (&time_s[..rows], &above[..rows], &integral[..rows]);
1075
1076 let (earliest, latest, step) = DELAY_SEARCH_S;
1077 let steps = ((latest - earliest) / step).round() as i32;
1078 let mut best: Option<(f64, f64, f64)> = None;
1079 for at in 0..=steps {
1080 let delay_s = earliest + f64::from(at) * step;
1081 let (mut product, mut square) = (0.0, 0.0);
1082 let mut cursor = 0;
1083 for (&time, &value) in time_s.iter().zip(integral) {
1084 let earlier = altitude_at(time_s, above, time - delay_s, &mut cursor);
1085 product += value * earlier;
1086 square += earlier * earlier;
1087 }
1088 let explained = product * product / square;
1090 if square > 0.0 && best.is_none_or(|(most, _, _)| explained > most) {
1091 best = Some((explained, product / square, delay_s));
1092 }
1093 }
1094 let (_, by_fit, delay_s) = best?;
1095 let mut cursor = 0;
1098 let earlier: Vec<f64> = time_s
1099 .iter()
1100 .map(|time| altitude_at(time_s, above, time - delay_s, &mut cursor))
1101 .collect();
1102 let (mut integral_area, mut altitude_area) = (0.0, 0.0);
1103 for index in 1..rows {
1104 let step_s = time_s[index] - time_s[index - 1];
1105 integral_area += 0.5 * (integral[index] + integral[index - 1]) * step_s;
1106 altitude_area += 0.5 * (earlier[index] + earlier[index - 1]) * step_s;
1107 }
1108 let by_area = integral_area / altitude_area;
1109 let climb_m = above.iter().copied().fold(0.0, f64::max);
1110 let (&end_s, &end_m) = (time_s.last()?, above.last()?);
1111 let span_s = (end_s - time_s.first()?).min(1.0);
1114 let rate_to = |to_s: f64| {
1115 (altitude_at(time_s, above, to_s, &mut 0)
1116 - altitude_at(time_s, above, to_s - span_s, &mut 0))
1117 / span_s
1118 };
1119 let end_rate = rate_to(end_s).abs().max(rate_to(end_s - delay_s).abs());
1120 let end_error = delay_s * delay_s * end_rate / (2.0 * altitude_area);
1121 (altitude_area > 0.0 && by_area.is_finite() && by_fit.is_finite()).then_some(Scale {
1122 by_area,
1123 by_fit,
1124 delay_s,
1125 end_height: end_m / climb_m,
1126 end_error: if span_s > 0.0 {
1127 end_error
1128 } else {
1129 f64::INFINITY
1130 },
1131 })
1132}
1133
1134fn speed_misses(slope: f64) -> Vec<(usize, f64)> {
1136 let mut misses: Vec<(usize, f64)> = SPEEDS
1137 .iter()
1138 .enumerate()
1139 .map(|(index, (_, _, m_s))| (index, slope * m_s - 1.0))
1140 .collect();
1141 misses.sort_by(|a, b| (a.1 + 1.0).ln().abs().total_cmp(&(b.1 + 1.0).ln().abs()));
1142 misses
1143}
1144
1145struct Track<'a> {
1148 time_s: &'a [f64],
1149 altitude_m: &'a [f64],
1150 known: bool,
1151}
1152
1153fn agreed_unit(scale: &Scale) -> Option<usize> {
1157 if !(scale.end_height <= END_HEIGHT && scale.end_error <= END_ERROR) {
1158 return None;
1159 }
1160 let (area_unit, area_miss) = *speed_misses(scale.by_area).first()?;
1161 let (fit_unit, fit_miss) = *speed_misses(scale.by_fit).first()?;
1162 (area_unit == fit_unit
1163 && area_miss.abs() <= VELOCITY_MARGIN
1164 && fit_miss.abs() <= VELOCITY_MARGIN)
1165 .then_some(area_unit)
1166}
1167
1168fn unit_misses(index: usize, scale: &Scale) -> (&'static str, f64, f64) {
1170 let (words, _, m_s) = SPEEDS.get(index).copied().unwrap_or(SPEEDS[0]);
1171 (words, scale.by_area * m_s - 1.0, scale.by_fit * m_s - 1.0)
1172}
1173
1174fn decide_velocity(
1176 values: &[f64],
1177 track: &Track,
1178 units: &HeaderUnits,
1179 written: &str,
1180 header_line: usize,
1181 notes: &mut Vec<String>,
1182) -> Result<Decided, LogError> {
1183 let Track {
1184 time_s,
1185 altitude_m,
1186 known: altitude_known,
1187 } = *track;
1188 let first = altitude_m.first().copied().unwrap_or_default();
1189 let climb_m = altitude_m.iter().copied().fold(first, f64::max) - first;
1190 let scale = (altitude_known && climb_m >= VELOCITY_CLIMB_M)
1191 .then(|| velocity_scale(time_s, altitude_m, values))
1192 .flatten();
1193 let in_si = |index: usize| {
1194 let m_s = SPEEDS.get(index).map_or(1.0, |(_, _, m_s)| *m_s);
1195 Decided {
1196 quantity: Quantity::Speed,
1197 values: values.iter().map(|value| value * m_s).collect(),
1198 }
1199 };
1200 let nearest = scale.map(|scale| {
1202 let by_area = speed_misses(scale.by_area);
1203 let by_fit = speed_misses(scale.by_fit);
1204 (scale, by_area, by_fit)
1205 });
1206 let decided = nearest.as_ref().and_then(|(scale, by_area, _)| {
1207 let index = agreed_unit(scale)?;
1208 Some((*scale, index, by_area.get(1).map(|(other, _)| *other)))
1209 });
1210 let measures = |scale: &Scale| {
1211 format!(
1212 "against the altitude above the first row, in meters, as it was {:.2} s earlier, \
1213 the area under its running integral is {:.4} times the altitude's, and the \
1214 integral's least-squares slope is {:.4}",
1215 scale.delay_s, scale.by_area, scale.by_fit
1216 )
1217 };
1218 if let Some(stated) = units.velocity {
1219 if let Some((scale, index, _)) = decided
1220 && index != stated
1221 {
1222 return Err(LogError::Unit {
1223 format: FORMAT,
1224 line: header_line,
1225 message: format!(
1226 "{:?} states {}, but {}, which is {}",
1227 short(written),
1228 SPEEDS.get(stated).map_or("", |unit| unit.0),
1229 measures(&scale),
1230 SPEEDS.get(index).map_or("", |unit| unit.0)
1231 ),
1232 });
1233 }
1234 return Ok(in_si(stated));
1235 }
1236 match decided {
1237 Some((scale, index, runner_up)) => {
1238 let runner_up = runner_up
1239 .map(|other| {
1240 let (words, by_area, by_fit) = unit_misses(other, &scale);
1241 format!(
1242 "; the next nearest, {words}, would miss them by {:+.1}% and {:+.1}%",
1243 by_area * 100.0,
1244 by_fit * 100.0
1245 )
1246 })
1247 .unwrap_or_default();
1248 let (words, by_area, by_fit) = unit_misses(index, &scale);
1249 notes.push(format!(
1250 "`{}` read in {words}, from the altitude: over {} rows, {}; {words} would give \
1251 {:.4} per m/s, {:+.2}% off the first and {:+.2}% off the second{runner_up}",
1252 short(written),
1253 time_s.len(),
1254 measures(&scale),
1255 1.0 / SPEEDS.get(index).map_or(1.0, |unit| unit.2),
1256 by_area * 100.0,
1257 by_fit * 100.0
1258 ));
1259 Ok(in_si(index))
1260 }
1261 None => {
1262 let why = if !altitude_known {
1263 "the altitude's unit is only assumed, so the velocity can't be measured against \
1264 it"
1265 .to_owned()
1266 } else if climb_m < VELOCITY_CLIMB_M {
1267 format!(
1268 "the altitude climbs {climb_m:.1} m, under the {VELOCITY_CLIMB_M} m over which \
1269 the velocity is measured against it"
1270 )
1271 } else {
1272 match &nearest {
1273 Some((scale, _, _))
1274 if scale.end_height > END_HEIGHT || scale.end_height.is_nan() =>
1275 {
1276 format!(
1277 "the record ends {:.0}% of the way up the climb, above the {:.0}% it must \
1278 come down to for the velocity to be measured against the altitude",
1279 scale.end_height * 100.0,
1280 END_HEIGHT * 100.0
1281 )
1282 }
1283 Some((scale, _, _))
1284 if scale.end_error > END_ERROR || scale.end_error.is_nan() =>
1285 {
1286 format!(
1287 "the record ends with the altitude still moving, which could move \
1288 the area under the velocity's running integral by about {:.2}%, \
1289 over the {:.1}% allowed",
1290 scale.end_error * 100.0,
1291 END_ERROR * 100.0
1292 )
1293 }
1294 Some((scale, by_area, by_fit)) => {
1295 let nearest_of = |misses: &[(usize, f64)]| {
1296 misses.first().map_or(String::new(), |(index, miss)| {
1297 format!(
1298 "{} {:+.1}%",
1299 SPEEDS.get(*index).map_or("", |unit| unit.0),
1300 miss * 100.0
1301 )
1302 })
1303 };
1304 format!(
1305 "{}; the two don't both land within {:.0}% of one of ft/s, mph, \
1306 m/s and km/h (the nearest {} and {})",
1307 measures(scale),
1308 VELOCITY_MARGIN * 100.0,
1309 nearest_of(by_area),
1310 nearest_of(by_fit)
1311 )
1312 }
1313 None => {
1314 "its running integral can't be measured against the altitude".to_owned()
1315 }
1316 }
1317 };
1318 notes.push(format!(
1319 "`{}` kept as written, its unit unknown: {why}",
1320 short(written)
1321 ));
1322 Ok(Decided {
1323 quantity: Quantity::Unrecognized,
1324 values: values.to_vec(),
1325 })
1326 }
1327 }
1328}
1329
1330fn decide_temperature(
1332 values: &[f64],
1333 units: &HeaderUnits,
1334 length: Length,
1335 written: &str,
1336 notes: &mut Vec<String>,
1337) -> Decided {
1338 let unit = units.temperature.unwrap_or_else(|| {
1339 let (unit, words) = match length {
1340 Length::Feet => (Temperature::Fahrenheit, "degrees Fahrenheit"),
1341 Length::Meters => (Temperature::Celsius, "degrees Celsius"),
1342 };
1343 notes.push(format!(
1344 "`{}` read in {words}, assumed: the altitude is in {}, and the RRC3's unit settings \
1345 pair feet with °F and meters with °C (RRC3 manual p. 12), though mDACS sets each by \
1346 its own preference (mDACS manual p. 5) and the file states neither; its values, as \
1347 written, run {}",
1348 short(written),
1349 length.words(),
1350 span(values)
1351 ));
1352 unit
1353 });
1354 Decided {
1355 quantity: Quantity::Temperature,
1356 values: values.iter().map(|value| unit.kelvin(*value)).collect(),
1357 }
1358}
1359
1360fn syntax(line: usize, message: String) -> LogError {
1361 LogError::Syntax {
1362 format: FORMAT,
1363 line,
1364 message,
1365 }
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370 use super::*;
1371
1372 const HEADER: &str = "Time,Altitude,Pressure,Velocity,Temperature,Events,Voltages";
1373
1374 fn pressure_hpa(height_m: f64) -> f64 {
1377 1_013.25 * (1.0 - height_m / 44_330.8).powf(1.0 / 0.190_263)
1378 }
1379
1380 fn flight(top_m: f64, climb_m_s: f64, length: f64, per_m_s: f64, tail: usize) -> String {
1384 let mut text = format!("{HEADER}\n");
1385 let mut heights = vec![0.0_f64];
1386 let mut climbing = true;
1387 while climbing || heights.last().is_some_and(|height| *height > 0.0) {
1388 let height = heights.last().copied().unwrap_or_default();
1389 climbing &= height < top_m;
1390 let speed = if climbing {
1391 climb_m_s
1392 } else {
1393 -climb_m_s / 3.0
1394 };
1395 heights.push((height + speed * 0.5).clamp(0.0, top_m));
1396 }
1397 heights.push(0.0);
1398 let count = heights.len();
1399 let speed = |index: usize| {
1402 let before = index.saturating_sub(1);
1403 let after = (index + 1).min(count - 1);
1404 (heights[after] - heights[before]) / ((after - before) as f64 * 0.5)
1405 };
1406 for (index, &height) in heights.iter().enumerate() {
1407 let (time, speed) = (index as f64 * 0.5, speed(index));
1408 let event = if index == 3 { "Drogue" } else { "-" };
1409 let common = format!(
1410 "{time:.2},{:.3},{:.2},{:.3}",
1411 height / length,
1412 pressure_hpa(height),
1413 speed * per_m_s
1414 );
1415 if index + tail >= count {
1416 text.push_str(&format!("{common}, {event}\n"));
1417 } else {
1418 text.push_str(&format!("{common},70.00, {event},9.20\n"));
1419 }
1420 }
1421 text
1422 }
1423
1424 fn close(a: f64, b: f64) -> bool {
1425 (a - b).abs() <= 1e-9 * b.abs().max(1.0)
1426 }
1427
1428 fn notes_starting<'a>(log: &'a FlightLog, start: &str) -> Vec<&'a String> {
1429 log.notes.iter().filter(|n| n.starts_with(start)).collect()
1430 }
1431
1432 #[test]
1433 fn recognizes_only_an_mdacs_header() {
1434 let yes = [
1435 HEADER,
1436 "\u{feff}\r\n\r\nTime,Altitude,Pressure,Velocity,Events",
1437 "Time,Altitude,Pressure,Velocity,Temperature",
1438 "Time,Altitude,Pressure,Velocity,Voltages",
1439 "time,ALTITUDE,pressure,Velocity",
1440 "Time [s],Altitude [ft],Pressure [mbar],Velocity [ft/s],Temperature [F],Events,Voltages [V]",
1441 "Time;Altitude;Pressure;Velocity;Temperature;Events;Voltages",
1442 "Time\tAltitude\tPressure\tVelocity\tEvents",
1443 "Time,Altitude [yd],Pressure,Velocity",
1445 ];
1446 for header in yes {
1447 assert!(recognizes(header), "{header:?}");
1448 }
1449 let no = [
1450 "",
1451 "Time,Altitude,Velocity,Temperature (F),Voltage",
1453 "Time, Altitude, Velocity",
1454 "Time,Altitude,Pressure",
1455 "Time,Pressure,Altitude,Velocity",
1456 "Time,Altitude,Pressure,Velocity,Temperature,Temperature",
1457 "Time,Altitude,Pressure,Velocity,Accel",
1458 "Time,Altitude,Pressure,Velocity,",
1459 "# Time,Altitude,Pressure,Velocity",
1460 "Time,Altitude [ft,Pressure,Velocity",
1461 ];
1462 for header in no {
1463 assert!(!recognizes(header), "{header:?}");
1464 }
1465 assert!(!recognizes("x\nTime,Altitude,Pressure,Velocity\n"));
1467 }
1468
1469 #[test]
1472 fn decides_feet_and_feet_per_second_from_the_file() {
1473 let text = flight(300.0, 60.0, FOOT_M, 1.0 / FOOT_M, 2);
1474 let log = read(&text).unwrap();
1475 assert_eq!(log.format, LogFormat::Rrc3Csv);
1476 let quantities: Vec<Quantity> = log.channels.iter().map(|c| c.quantity).collect();
1477 assert_eq!(
1478 quantities,
1479 [
1480 Quantity::AltitudeAboveGround,
1481 Quantity::Pressure,
1482 Quantity::Speed,
1483 Quantity::Temperature,
1484 Quantity::Voltage
1485 ]
1486 );
1487 assert!(close(log.altitude_m[1], 98.425 * FOOT_M));
1489 assert!(close(
1490 log.vertical_speed_m_s.as_deref().unwrap()[1],
1491 196.85 * FOOT_M
1492 ));
1493 let temperature = log.temperature_k.as_deref().unwrap();
1494 assert!(
1496 (temperature[0] - 294.261).abs() < 1e-3,
1497 "{}",
1498 temperature[0]
1499 );
1500 let n = log.time_s.len();
1501 assert!(temperature[n - 2..].iter().all(|t| t.is_nan()));
1502 assert!(temperature[n - 3].is_finite());
1503 assert_eq!(log.events.len(), 1);
1504 assert_eq!(
1505 (log.events[0].time_s, log.events[0].name.as_str()),
1506 (1.5, "Drogue")
1507 );
1508 for start in [
1509 "`Altitude` read in feet, from the pressure: ",
1510 "`Velocity` read in feet per second, from the altitude: ",
1511 "`Pressure` read in hectopascals, from the values: ",
1512 "`Temperature` read in degrees Fahrenheit, assumed: ",
1513 "`Voltages` read in volts, assumed: ",
1514 "`Time` read in seconds, assumed: ",
1515 "2 rows, the first on line ",
1516 ] {
1517 assert_eq!(
1518 notes_starting(&log, start).len(),
1519 1,
1520 "{start}: {:?}",
1521 log.notes
1522 );
1523 }
1524 }
1525
1526 #[test]
1528 fn each_velocity_unit_is_told_apart() {
1529 for (words, _, m_s) in SPEEDS {
1530 let text = flight(300.0, 60.0, FOOT_M, 1.0 / m_s, 0);
1531 let log = read(&text).unwrap();
1532 let start = format!("`Velocity` read in {words}, from the altitude: ");
1533 assert_eq!(
1534 notes_starting(&log, &start).len(),
1535 1,
1536 "{words}: {:?}",
1537 log.notes
1538 );
1539 assert!(
1541 (log.vertical_speed_m_s.as_deref().unwrap()[1] - 60.0).abs() < 5e-4 * m_s.max(1.0)
1542 );
1543 }
1544 }
1545
1546 #[test]
1548 fn decides_meters_from_the_pressure() {
1549 let text = flight(300.0, 60.0, 1.0, 1.0, 0);
1550 let log = read(&text).unwrap();
1551 assert!(close(log.altitude_m[1], 30.0));
1552 assert_eq!(
1553 notes_starting(&log, "`Altitude` read in meters, from the pressure: ").len(),
1554 1
1555 );
1556 assert!(close(log.temperature_k.as_deref().unwrap()[0], 343.15));
1557 assert_eq!(
1558 notes_starting(&log, "`Temperature` read in degrees Celsius, assumed: ").len(),
1559 1
1560 );
1561 }
1562
1563 #[test]
1566 fn a_velocity_that_cant_be_told_is_kept_as_written() {
1567 let full = flight(300.0, 60.0, FOOT_M, 1.0 / FOOT_M, 0);
1569 let cut: String = full
1570 .lines()
1571 .take(14)
1572 .map(|line| format!("{line}\n"))
1573 .collect();
1574 let cases = [
1575 (
1576 flight(300.0, 60.0, FOOT_M, 3.0, 0),
1577 "don't both land within 3% of one",
1578 ),
1579 (
1580 flight(40.0, 20.0, FOOT_M, 1.0 / FOOT_M, 0),
1581 "under the 50 m",
1582 ),
1583 (flight(8.0, 4.0, FOOT_M, 1.0 / FOOT_M, 0), "only assumed"),
1584 (cut, "of the way up the climb, above the 50%"),
1585 ];
1586 for (text, why) in cases {
1587 let log = read(&text).unwrap();
1588 assert_eq!(log.channels[2].quantity, Quantity::Unrecognized);
1589 assert!(log.vertical_speed_m_s.is_none());
1590 let kept = notes_starting(&log, "`Velocity` kept as written, its unit unknown: ");
1591 assert_eq!(kept.len(), 1, "{:?}", log.notes);
1592 assert!(kept[0].contains(why), "{why}: {kept:?}");
1593 }
1594 let log = read(&flight(8.0, 4.0, FOOT_M, 1.0 / FOOT_M, 0)).unwrap();
1596 let assumed = notes_starting(&log, "`Altitude` read in feet, assumed: ");
1597 assert!(assumed[0].contains("climbs 8.0 m"), "{assumed:?}");
1598 }
1599
1600 #[test]
1602 fn a_pressure_of_unknown_unit_is_kept_as_written() {
1603 let text = format!("{HEADER}\n0,0,101325,0,70, -,9.2\n0.05,1,101310,1,70, -,9.2\n");
1604 let log = read(&text).unwrap();
1605 assert_eq!(log.channels[1].quantity, Quantity::Unrecognized);
1606 assert_eq!(log.channels[1].values, [101_325.0, 101_310.0]);
1607 assert_eq!(
1608 notes_starting(&log, "`Pressure` kept as written, its unit unknown: ").len(),
1609 1
1610 );
1611 let assumed = notes_starting(&log, "`Altitude` read in feet, assumed: ");
1612 assert!(assumed[0].contains("unit is unknown"), "{assumed:?}");
1613 }
1614
1615 #[test]
1617 fn bracketed_units() {
1618 let text = flight(300.0, 60.0, 1.0, 3.6, 0).replacen(
1619 HEADER,
1620 "Time [s],Altitude [m],Pressure [hPa],Velocity [kph],Temperature [°F],Events,Voltages [V]",
1621 1,
1622 );
1623 let log = read(&text).unwrap();
1624 assert!(close(log.vertical_speed_m_s.as_deref().unwrap()[1], 60.0));
1625 assert!((log.temperature_k.as_deref().unwrap()[0] - 294.261).abs() < 1e-3);
1627 assert!(
1628 log.notes.iter().all(|n| !n.contains(" read in ")),
1629 "{:?}",
1630 log.notes
1631 );
1632
1633 let wrong_altitude = text.replacen("Altitude [m]", "Altitude [ft]", 1);
1635 let error = read(&wrong_altitude).unwrap_err();
1636 assert!(
1637 matches!(&error, LogError::Unit { line: 1, message, .. } if message.contains("states feet, which the pressure contradicts")),
1638 "{error:?}"
1639 );
1640 let wrong_speed = text.replacen("Velocity [kph]", "Velocity [m/s]", 1);
1641 let error = read(&wrong_speed).unwrap_err();
1642 assert!(
1643 matches!(&error, LogError::Unit { line: 1, message, .. } if message.contains("\"Velocity [m/s]\" states meters per second, but against the altitude") && message.contains("which is kilometers per hour")),
1644 "{error:?}"
1645 );
1646 for header in [
1647 "Time [ms],Altitude,Pressure,Velocity",
1648 "Time,Altitude [yd],Pressure,Velocity",
1649 "Time,Altitude,Pressure [psi],Velocity",
1650 "Time,Altitude,Pressure,Velocity [knots]",
1651 "Time,Altitude,Pressure,Velocity,Temperature [K]",
1652 "Time,Altitude,Pressure,Velocity,Events [x]",
1653 "Time,Altitude,Pressure,Velocity,Voltages [mV]",
1654 ] {
1655 let error = read(&format!("{header}\n0,0,1000,0\n")).unwrap_err();
1656 assert!(
1657 matches!(&error, LogError::Unit { line: 1, message, .. } if message.contains("states a unit this reader doesn't know")),
1658 "{header}: {error:?}"
1659 );
1660 }
1661 }
1662
1663 #[test]
1665 fn an_altitude_in_neither_unit_is_refused() {
1666 let text = flight(300.0, 60.0, 1.0 / 1.6, 1.0, 0);
1667 let error = read(&text).unwrap_err();
1668 assert!(
1669 matches!(&error, LogError::Unit { line: 1, message, .. } if message.contains("neither feet nor meters")),
1670 "{error:?}"
1671 );
1672 }
1673
1674 #[test]
1676 fn semicolons_and_tabs_take_decimal_commas() {
1677 let comma = "0,00;0;1013,25;0;21,5; -;9,2\n0,05;1,5;1013,07;2,5;21,5; Main;9,1\n";
1678 let log = read(&format!("{}\n{comma}", HEADER.replace(',', ";"))).unwrap();
1679 assert_eq!(log.time_s, [0.0, 0.05]);
1680 assert_eq!(log.events[0].name, "Main");
1681 assert!(close(log.channels[1].values[1], 101_307.0));
1682 let tabbed = comma.replace(';', "\t");
1683 let log = read(&format!("{}\n{tabbed}", HEADER.replace(',', "\t"))).unwrap();
1684 assert_eq!(log.battery_v.as_deref(), Some(&[9.2, 9.1][..]));
1685 let error = read(&format!("{HEADER}\n0,0,1013,25,0,70, -,9,2\n")).unwrap_err();
1686 assert!(
1687 error.to_string().contains("line 2: the row has 9 cells"),
1688 "{error}"
1689 );
1690 }
1691
1692 #[test]
1694 fn short_rows_leave_out_the_one_hertz_cells() {
1695 let text =
1696 format!("{HEADER}\n0,0,1000,0,70, -,9.2\n0.05,1,999.9,1, Drogue\n0.1,1,999.9,1, -,,\n");
1697 let log = read(&text).unwrap();
1698 assert_eq!(log.events.len(), 1);
1699 assert_eq!(log.events[0].time_s, 0.05);
1700 let temperature = log.temperature_k.as_deref().unwrap();
1701 assert!(temperature[0].is_finite() && temperature[1].is_nan() && temperature[2].is_nan());
1702 assert!(log.notes.iter().any(|n| n.starts_with(
1703 "2 rows, the first on line 3, leave out the 1 Hz `Temperature` and `Voltages` cells: \
1704 they are gaps; 1 of them pad"
1705 )));
1706 let log = read("Time,Altitude,Pressure,Velocity,Temperature\n0,0,1000,0,70\n1,1,999.9,1\n")
1708 .unwrap();
1709 assert!(log.temperature_k.as_deref().unwrap()[1].is_nan());
1710 let error = read("Time,Altitude,Pressure,Velocity,Events\n0,0,1000,0\n").unwrap_err();
1712 assert!(
1713 error
1714 .to_string()
1715 .contains("line 2: the row has 4 cells: the header names 5"),
1716 "{error}"
1717 );
1718 let error = read(&format!("{HEADER}\n0,0,1000,0, -, -,9.2\n")).unwrap_err();
1720 assert!(
1721 error
1722 .to_string()
1723 .contains("line 2: \"-\" under \"Temperature\" isn't a number"),
1724 "{error}"
1725 );
1726 let error = read(&format!("{HEADER}\n0,0,1000,0,70,,\n")).unwrap_err();
1728 assert!(
1729 error
1730 .to_string()
1731 .contains("line 2: \"70\" under \"Events\" isn't an event"),
1732 "{error}"
1733 );
1734 }
1735
1736 #[test]
1739 fn rows_with_no_time() {
1740 let text = format!("{HEADER}\n0,0,1000,0,70, -,9.2\n,4,,,,,\n,4\n");
1741 let log = read(&text).unwrap();
1742 assert_eq!(log.time_s.len(), 1);
1743 assert!(log.notes.iter().any(|n| n.starts_with(
1744 "2 rows with no time and nothing but an altitude cell, the first on line 3"
1745 )));
1746 let error = read(&format!("{HEADER}\n,4,1000,,,,\n")).unwrap_err();
1747 assert!(
1748 error.to_string().contains("line 2: the row has no time"),
1749 "{error}"
1750 );
1751 }
1752
1753 #[test]
1755 fn malformed_files_are_refused_where_they_go_wrong() {
1756 let cases = [
1757 (String::new(), "the file is empty"),
1758 (
1759 "Time,Altitude,Velocity\n0,0,0\n".to_owned(),
1760 "isn't an mDACS export's header",
1761 ),
1762 (format!("{HEADER}\n"), "no data rows"),
1763 (format!("{HEADER}\n,4\n"), "no data rows"),
1764 (
1765 format!("{HEADER}\n0,0,1000,0,70, -,9.2\n0,0,1000,0,70, -,9.2\n"),
1766 "line 3: the time 0 s doesn't come after the one above it, 0 s",
1767 ),
1768 (
1769 format!("{HEADER}\n1,0,1000,0,70, -,9.2\n0.5,0,1000,0,70, -,9.2\n"),
1770 "line 3: the time 0.5 s doesn't come after",
1771 ),
1772 (
1773 format!("{HEADER}\nx,0,1000,0,70, -,9.2\n"),
1774 "line 2: \"x\" under \"Time\" isn't a number",
1775 ),
1776 (
1777 format!("{HEADER}\n0,,1000,0,70, -,9.2\n"),
1778 "line 2: \"\" under \"Altitude\" isn't a number",
1779 ),
1780 (
1781 format!("{HEADER}\n0,0,1000,0,70, Main 2,9.2\n"),
1782 "line 2: \"Main 2\" under \"Events\"",
1783 ),
1784 (
1785 format!("{HEADER}\n0,0,1000,0,70, -,9.2,1\n"),
1786 "line 2: the row has 8 cells",
1787 ),
1788 (
1789 format!("{HEADER}\n0,0,1000\n"),
1790 "line 2: the row has 3 cells",
1791 ),
1792 ];
1793 for (text, expected) in cases {
1794 let error = read(&text).unwrap_err().to_string();
1795 assert!(error.contains(expected), "{text:?}: {error}");
1796 }
1797 }
1798
1799 #[test]
1801 fn unknown_events_are_kept_and_notes_stay_few() {
1802 let mut text = format!("{HEADER}\n");
1803 for index in 0..30 {
1804 text.push_str(&format!("{index},0,1000,0,70, Apogee,9.2\n"));
1805 }
1806 let log = read(&text).unwrap();
1807 assert_eq!(log.events.len(), 30);
1808 assert_eq!(log.events[0].name, "Apogee");
1809 let about_events = log
1810 .notes
1811 .iter()
1812 .filter(|n| n.contains("\"Apogee\""))
1813 .count();
1814 assert_eq!(about_events, 20);
1815 assert!(
1816 log.notes.iter().any(|n| n.starts_with("and 10 more notes")),
1817 "{:?}",
1818 log.notes
1819 );
1820 }
1821
1822 #[test]
1825 fn line_ends_and_quotes() {
1826 let rows = ["0,0,1000,0,70, -,9.2", "0.05,0.5,999.98,1,70, -,9.2"];
1827 for end in ["\r", "\n", "\r\n"] {
1828 let text = format!("\u{feff}{HEADER}{end}{}{end}{end}{}{end}", rows[0], rows[1]);
1829 let log = read(&text).unwrap();
1830 assert_eq!(log.time_s, [0.0, 0.05], "{end:?}");
1831 }
1832 let error = read(&format!("{HEADER}\n{}\n", "9".repeat(10_000))).unwrap_err();
1833 assert!(error.to_string().chars().count() < 400, "{error}");
1834 let error = read(&"\u{fffd}".repeat(100_000)).unwrap_err();
1835 assert!(error.to_string().chars().count() < 400, "{error}");
1836 }
1837
1838 fn arc(top_m: f64, descent_m_s: f64) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
1842 let (boost, coast, gravity) = (80.0, 12.0, 9.8);
1843 let boost_s = (top_m / (boost / 2.0 + boost * boost / (2.0 * coast))).sqrt();
1844 let burnout_m_s = boost * boost_s;
1845 let apogee_s = boost_s + burnout_m_s / coast;
1846 let fall_s = descent_m_s / gravity;
1847 let (mut times, mut heights, mut rates) = (vec![], vec![], vec![]);
1848 let mut landed_s = None;
1849 for row in 0_u32.. {
1850 let t = f64::from(row) * 0.05 - 1.0;
1851 let (height, rate): (f64, f64) = if t < 0.0 {
1852 (0.0, 0.0)
1853 } else if t < boost_s {
1854 (boost * t * t / 2.0, boost * t)
1855 } else if t < apogee_s {
1856 let u = t - boost_s;
1857 (
1858 boost * boost_s * boost_s / 2.0 + burnout_m_s * u - coast * u * u / 2.0,
1859 burnout_m_s - coast * u,
1860 )
1861 } else if t < apogee_s + fall_s {
1862 let u = t - apogee_s;
1863 (top_m - gravity * u * u / 2.0, -gravity * u)
1864 } else {
1865 let u = t - apogee_s - fall_s;
1866 (
1867 top_m - gravity * fall_s * fall_s / 2.0 - descent_m_s * u,
1868 -descent_m_s,
1869 )
1870 };
1871 let (height, rate) = if height <= 0.0 && t > 0.0 {
1872 (0.0, 0.0)
1873 } else {
1874 (height, rate)
1875 };
1876 if t > 0.0 && height == 0.0 {
1877 landed_s.get_or_insert(t);
1878 }
1879 if landed_s.is_some_and(|landed| t > landed + 3.0) {
1880 break;
1881 }
1882 times.push(t + 1.0);
1883 heights.push(height);
1884 rates.push(rate);
1885 }
1886 (times, heights, rates)
1887 }
1888
1889 #[test]
1895 fn the_velocity_unit_is_never_decided_wrong() {
1896 let atmosphere = Ussa76::standard();
1897 let height_of = |hpa: f64| {
1898 atmosphere
1899 .pressure_altitude_m(hpa * HPA_PA)
1900 .unwrap_or(f64::NAN)
1901 };
1902 let pad_m = height_of((pressure_hpa(0.0) * 10.0).round() / 10.0);
1903 let lags = [
1904 ("late", 0.0),
1905 ("late", 1.0),
1906 ("late", 2.0),
1907 ("late", 3.0),
1908 ("first-order", 0.5),
1909 ("first-order", 1.0),
1910 ("first-order", 2.0),
1911 ("first-order", 3.0),
1912 ("moving average", 1.0),
1913 ("moving average", 2.0),
1914 ("moving average", 3.0),
1915 ];
1916 let (mut decided, mut cases) = (0, 0);
1917 for top_m in [20.0, 30.0, 50.0, 100.0, 200.0, 300.0, 1_000.0] {
1918 for descent_m_s in [10.0, 25.0, 40.0] {
1919 let (time_s, height_m, rate_m_s) = arc(top_m, descent_m_s);
1920 let altitude_m: Vec<f64> = height_m
1921 .iter()
1922 .map(|height| {
1923 let hpa = (pressure_hpa(*height) * 10.0).round() / 10.0;
1924 ((height_of(hpa) - pad_m) * 100.0).round() / 100.0
1925 })
1926 .collect();
1927 for (kind, lag_s) in lags {
1928 let steps = (lag_s / 0.05_f64).round() as usize;
1929 let lagged: Vec<f64> = match kind {
1930 "late" => (0..rate_m_s.len())
1931 .map(|i| i.checked_sub(steps).map_or(0.0, |j| rate_m_s[j]))
1932 .collect(),
1933 "first-order" => {
1934 let mut smoothed = 0.0;
1935 rate_m_s
1936 .iter()
1937 .map(|rate| {
1938 smoothed += (rate - smoothed) * 0.05 / (lag_s + 0.05);
1939 smoothed
1940 })
1941 .collect()
1942 }
1943 _ => (0..rate_m_s.len())
1944 .map(|i| {
1945 let from = (i + 1).saturating_sub(steps);
1946 rate_m_s[from..=i].iter().sum::<f64>() / steps as f64
1947 })
1948 .collect(),
1949 };
1950 for (unit, (words, _, m_s)) in SPEEDS.iter().enumerate() {
1951 let velocity: Vec<f64> = lagged
1952 .iter()
1953 .map(|rate| (rate / m_s * 100.0).round() / 100.0)
1954 .collect();
1955 for tenths in 3..=10 {
1956 let rows = time_s.len() * tenths / 10;
1957 let Some(scale) =
1958 velocity_scale(&time_s[..rows], &altitude_m[..rows], &velocity)
1959 else {
1960 continue;
1961 };
1962 cases += 1;
1963 let agreed = agreed_unit(&scale);
1964 if kind == "late" && lag_s <= 1.0 && tenths == 10 && top_m >= 50.0 {
1966 assert_eq!(
1967 agreed,
1968 Some(unit),
1969 "{top_m} m, {descent_m_s} m/s, {lag_s} s late, {words}: \
1970 {scale:?}"
1971 );
1972 }
1973 if let Some(index) = agreed {
1974 decided += 1;
1975 assert_eq!(
1976 index, unit,
1977 "{top_m} m, {descent_m_s} m/s, {kind} {lag_s} s, {words}, \
1978 {tenths}0% of the record: {scale:?}"
1979 );
1980 }
1981 }
1982 }
1983 }
1984 }
1985 }
1986 assert!(decided * 10 > cases, "{decided} of {cases}");
1988 }
1989
1990 #[test]
1993 fn the_velocity_scale_takes_out_a_delay() {
1994 let time_s: Vec<f64> = (0..800).map(|i| f64::from(i) * 0.05).collect();
1995 let rate = |t: f64| match t {
1997 t if t < 1.0 => 0.0,
1998 t if t < 6.0 => 50.0,
1999 t if t < 26.0 => -12.5,
2000 _ => 0.0,
2001 };
2002 let mut altitude_m = vec![0.0];
2003 for pair in time_s.windows(2) {
2004 let last = altitude_m.last().copied().unwrap_or_default();
2005 altitude_m.push(last + 0.5 * (rate(pair[0]) + rate(pair[1])) * (pair[1] - pair[0]));
2006 }
2007 let velocity: Vec<f64> = time_s.iter().map(|t| rate(t - 1.5) * 2.0).collect();
2008 let scale = velocity_scale(&time_s, &altitude_m, &velocity).unwrap();
2009 assert!((scale.delay_s - 1.5).abs() < 1e-9, "{scale:?}");
2010 assert!((scale.by_area / 2.0 - 1.0).abs() < 1e-3, "{scale:?}");
2011 assert!((scale.by_fit / 2.0 - 1.0).abs() < 1e-3, "{scale:?}");
2012 assert!(
2013 scale.end_height.abs() < 1e-9 && scale.end_error < 1e-9,
2014 "{scale:?}"
2015 );
2016 assert!(velocity_scale(&[0.0, 1.0], &[5.0, 5.0], &[1.0, 1.0]).is_none());
2017 }
2018
2019 #[test]
2022 fn the_time_is_read_in_seconds_from_the_step_or_assumed() {
2023 let rows = |step: f64, count: usize| -> String {
2024 (0..count)
2025 .map(|i| format!("{:.2},0,1000,0\n", i as f64 * step))
2026 .collect()
2027 };
2028 let header = "Time,Altitude,Pressure,Velocity\n";
2029 let log = read(&format!("{header}{}", rows(0.05, 5))).unwrap();
2030 let from_step = notes_starting(&log, "`Time` read in seconds, from the step: ");
2031 assert!(
2032 from_step[0].contains("step between rows, 0.05"),
2033 "{from_step:?}"
2034 );
2035 let log = read(&format!("{header}{}", rows(0.5, 5))).unwrap();
2036 let assumed = notes_starting(&log, "`Time` read in seconds, assumed: ");
2037 assert!(
2038 assumed[0].contains("0.5 as written, isn't within 5%"),
2039 "{assumed:?}"
2040 );
2041 let log = read(&format!("{header}{}", rows(0.05, 1))).unwrap();
2042 assert_eq!(
2043 notes_starting(&log, "`Time` read in seconds, assumed: a single row").len(),
2044 1
2045 );
2046 let log = read(&format!(
2047 "Time [s],Altitude,Pressure,Velocity\n{}",
2048 rows(0.05, 5)
2049 ))
2050 .unwrap();
2051 assert!(
2052 log.notes.iter().all(|n| !n.contains("`Time")),
2053 "{:?}",
2054 log.notes
2055 );
2056 }
2057
2058 #[test]
2062 fn a_pressure_off_the_pad_range_is_kept_as_written() {
2063 for (pad, next) in [
2064 (101.3, 101.2),
2065 (29.92, 29.9),
2066 (499.0, 498.9),
2067 (1_101.0, 1_100.9),
2068 ] {
2069 let text = format!("{HEADER}\n0,0,{pad},0,70, -,9.2\n0.05,1,{next},1,70, -,9.2\n");
2070 let log = read(&text).unwrap();
2071 assert_eq!(log.channels[1].quantity, Quantity::Unrecognized, "{pad}");
2072 assert_eq!(log.channels[1].values, [pad, next]);
2073 let kept = notes_starting(&log, "`Pressure` kept as written, its unit unknown: ");
2074 assert!(
2075 kept[0].contains(&format!(
2076 "the first row's, {pad}, lies outside the 500 to 1100"
2077 )),
2078 "{kept:?}"
2079 );
2080 let assumed = notes_starting(&log, "`Altitude` read in feet, assumed: ");
2081 assert!(assumed[0].contains("unit is unknown"), "{assumed:?}");
2082 }
2083 for (pad, next) in [(500.0, 499.9), (1_100.0, 1_099.9)] {
2085 let text = format!("{HEADER}\n0,0,{pad},0,70, -,9.2\n0.05,1,{next},1,70, -,9.2\n");
2086 let log = read(&text).unwrap();
2087 assert_eq!(log.channels[1].quantity, Quantity::Pressure, "{pad}");
2088 }
2089 }
2090
2091 proptest::proptest! {
2092 #[test]
2095 fn any_text_is_read_or_refused(rows in proptest::collection::vec("[-0-9.,; DrogueMainx]{0,32}", 0..12)) {
2096 let text = format!("{HEADER}\n{}", rows.join("\n"));
2097 if let Ok(log) = read(&text) {
2098 proptest::prop_assert!(log.time_s.windows(2).all(|pair| pair[1] > pair[0]));
2099 proptest::prop_assert_eq!(log.time_s.len(), log.altitude_m.len());
2100 for channel in &log.channels {
2101 proptest::prop_assert_eq!(&channel.time_s, &log.time_s);
2102 proptest::prop_assert_eq!(channel.values.len(), log.time_s.len());
2103 }
2104 }
2105 }
2106 }
2107}