1use crate::error::LogError;
39use crate::log::{FlightLog, LogFormat, Stated};
40
41const FORMAT: &str = ".pf2";
43
44pub const FOOT_M: f64 = 0.3048;
46
47const DEFAULT_COLUMNS: [Column; 5] = [
50 Column::Time,
51 Column::Altitude,
52 Column::Velocity,
53 Column::Temperature(TemperatureUnit::Fahrenheit),
54 Column::Voltage,
55];
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum Column {
60 Time,
61 Altitude,
62 Velocity,
63 Temperature(TemperatureUnit),
64 Voltage,
65 Unknown,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum TemperatureUnit {
71 Fahrenheit,
72 Celsius,
73}
74
75impl TemperatureUnit {
76 fn kelvin(self, value: f64) -> f64 {
77 match self {
78 Self::Fahrenheit => (value - 32.0) * 5.0 / 9.0 + 273.15,
79 Self::Celsius => value + 273.15,
80 }
81 }
82}
83
84pub fn read(text: &str) -> Result<FlightLog, LogError> {
93 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
94 let text = text.replace("\r\n", "\n").replace('\r', "\n");
95 let mut lines = text
96 .lines()
97 .enumerate()
98 .map(|(index, line)| (index + 1, line.trim()));
99 let logger = loop {
100 match lines.next() {
101 Some((_, "")) => {}
102 Some((_, line)) if line.to_ascii_lowercase().contains("perfectflite") => {
103 break line.to_owned();
104 }
105 Some((_, line)) => {
106 return Err(LogError::NotThisFormat {
107 format: FORMAT,
108 message: format!(
109 "its first line, {:?}, doesn't name PerfectFlite",
110 short(line)
111 ),
112 });
113 }
114 None => {
115 return Err(LogError::NotThisFormat {
116 format: FORMAT,
117 message: "the file is empty".to_owned(),
118 });
119 }
120 }
121 };
122
123 let mut log = FlightLog {
124 format: LogFormat::PerfectFlitePf2,
125 logger,
126 serial_number: None,
127 firmware: None,
128 flight_number: None,
129 stated: Stated::default(),
130 time_s: Vec::new(),
131 altitude_m: Vec::new(),
132 vertical_speed_m_s: None,
133 temperature_k: None,
134 battery_v: None,
135 notes: Vec::new(),
136 };
137 let mut notes = Notes::default();
138 let mut columns: Option<Vec<Column>> = None;
139 let mut rows: Vec<(usize, Vec<f64>)> = Vec::new();
140
141 for (number, line) in lines {
142 if line.is_empty() {
143 continue;
144 }
145 if is_data_row(line) {
146 rows.push((number, data_row(line, number)?));
147 continue;
148 }
149 if !rows.is_empty() {
150 return Err(syntax(
151 number,
152 format!(
153 "{:?} after the data rows began isn't a row of numbers",
154 short(line)
155 ),
156 ));
157 }
158 let Some((key, value)) = line.split_once(':') else {
159 notes.push(format!(
160 "line {number}, {:?}, isn't a `Key: value` line and was skipped",
161 short(line)
162 ));
163 continue;
164 };
165 let value = value.trim();
166 match key.trim().to_ascii_lowercase().as_str() {
167 "data" => columns = Some(data_columns(value, number, &mut notes)?),
168 "apogee" => log.stated.apogee_m = stated_feet(value, number, "apogee", &mut notes)?,
169 "ground elevation" => {
170 log.stated.ground_elevation_msl_m =
171 stated_feet(value, number, "ground elevation", &mut notes)?;
172 }
173 "numsamps" => log.stated.samples = value.parse().ok(),
174 "flight number" => log.flight_number = value.parse().ok(),
175 "serial number" if !value.is_empty() => log.serial_number = Some(value.to_owned()),
176 "firmware" if !value.is_empty() => log.firmware = Some(value.to_owned()),
177 _ => {}
179 }
180 }
181
182 if rows.is_empty() {
183 return Err(LogError::NoData { format: FORMAT });
184 }
185 let columns = columns.unwrap_or_else(|| {
186 log.notes.push(
187 "the file has no `Data:` line; its columns were read as time, altitude, velocity, \
188 temperature (°F) and voltage"
189 .to_owned(),
190 );
191 DEFAULT_COLUMNS.to_vec()
192 });
193 fill(&mut log, &columns, &rows)?;
194 if let Some(stated) = log.stated.samples
195 && stated != log.time_s.len()
196 {
197 log.notes.push(format!(
198 "the file states {stated} samples and holds {}",
199 log.time_s.len()
200 ));
201 }
202 log.notes.extend(notes.kept);
204 if notes.left_out > 0 {
205 log.notes.push(format!(
206 "and {} more notes about single lines, left out so that a broken file's list stays \
207 short",
208 notes.left_out
209 ));
210 }
211 Ok(log)
212}
213
214const MAX_NOTES: usize = 20;
216
217#[derive(Default)]
220struct Notes {
221 kept: Vec<String>,
222 left_out: usize,
223}
224
225impl Notes {
226 fn push(&mut self, note: String) {
227 if self.kept.len() < MAX_NOTES {
228 self.kept.push(note);
229 } else {
230 self.left_out += 1;
231 }
232 }
233}
234
235fn is_data_row(line: &str) -> bool {
237 let first = line.trim_start_matches(['-', '+']);
238 first.starts_with(|c: char| c.is_ascii_digit() || c == '.') && line.contains(',')
239}
240
241fn syntax(line: usize, message: String) -> LogError {
242 LogError::Syntax {
243 format: FORMAT,
244 line,
245 message,
246 }
247}
248
249fn data_row(line: &str, number: usize) -> Result<Vec<f64>, LogError> {
251 line.split(',')
252 .map(|cell| {
253 let cell = cell.trim();
254 cell.parse::<f64>()
255 .ok()
256 .filter(|value| value.is_finite())
257 .ok_or_else(|| syntax(number, format!("{:?} isn't a finite number", short(cell))))
258 })
259 .collect()
260}
261
262fn data_columns(value: &str, number: usize, notes: &mut Notes) -> Result<Vec<Column>, LogError> {
264 let inner = value.strip_prefix('(').unwrap_or(value);
265 let inner = inner.strip_suffix(')').unwrap_or(inner);
266 let columns: Vec<Column> = inner
267 .split(',')
268 .map(|name| {
269 let name = name.trim().to_ascii_lowercase();
270 let column = if name.starts_with("time") {
271 Column::Time
272 } else if name.starts_with("altitude") {
273 Column::Altitude
274 } else if name.starts_with("velocity") {
275 Column::Velocity
276 } else if name.starts_with("temperature") && name.contains("(f)") {
277 Column::Temperature(TemperatureUnit::Fahrenheit)
278 } else if name.starts_with("temperature") && name.contains("(c)") {
279 Column::Temperature(TemperatureUnit::Celsius)
280 } else if name.starts_with("voltage") {
281 Column::Voltage
282 } else {
283 Column::Unknown
284 };
285 if column == Column::Unknown {
286 notes.push(format!(
287 "the column {:?} isn't one this reader knows, and was left out",
288 short(&name)
289 ));
290 }
291 column
292 })
293 .collect();
294 for required in [Column::Time, Column::Altitude] {
295 if columns.iter().filter(|column| **column == required).count() != 1 {
296 return Err(syntax(
297 number,
298 format!(
299 "the `Data:` line must name one {required:?} column: {:?}",
300 short(value)
301 ),
302 ));
303 }
304 }
305 Ok(columns)
306}
307
308fn stated_feet(
312 value: &str,
313 number: usize,
314 what: &str,
315 notes: &mut Notes,
316) -> Result<Option<f64>, LogError> {
317 let end = value
318 .find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+')))
319 .unwrap_or(value.len());
320 let (digits, rest) = value.split_at(end);
321 let Some(feet) = digits.parse::<f64>().ok().filter(|feet| feet.is_finite()) else {
322 notes.push(format!(
323 "the file states its {what} as {:?}, which isn't a height",
324 short(value)
325 ));
326 return Ok(None);
327 };
328 let rest = rest.trim_start();
329 let word: String = rest
330 .chars()
331 .take_while(char::is_ascii_alphabetic)
332 .collect::<String>()
333 .to_ascii_lowercase();
334 if rest.starts_with('\'') || word == "ft" || word == "feet" {
335 return Ok(Some(feet * FOOT_M));
336 }
337 if word.is_empty() || word == "agl" || word == "msl" {
339 notes.push(format!(
340 "the file states its {what} as {:?}, with no unit hpr can read",
341 short(value)
342 ));
343 return Ok(None);
344 }
345 Err(LogError::Unit {
346 format: FORMAT,
347 line: number,
348 message: format!(
349 "the {what} {:?} is in {:?}, not feet ('), the only unit this reader knows",
350 short(value),
351 short(&word)
352 ),
353 })
354}
355
356fn short(text: &str) -> String {
359 if text.chars().count() <= QUOTED_CHARS {
360 text.to_owned()
361 } else {
362 let mut cut: String = text.chars().take(QUOTED_CHARS - 1).collect();
363 cut.push('…');
364 cut
365 }
366}
367
368const QUOTED_CHARS: usize = 80;
370
371fn fill(
373 log: &mut FlightLog,
374 columns: &[Column],
375 rows: &[(usize, Vec<f64>)],
376) -> Result<(), LogError> {
377 let has = |wanted: fn(&Column) -> bool| columns.iter().any(wanted);
378 let mut speed = has(|c| *c == Column::Velocity).then(Vec::new);
379 let mut temperature = has(|c| matches!(c, Column::Temperature(_))).then(Vec::new);
380 let mut battery = has(|c| *c == Column::Voltage).then(Vec::new);
381 for (number, cells) in rows {
382 if cells.len() > columns.len() {
383 return Err(syntax(
384 *number,
385 format!(
386 "the row has {} values and the columns are {}",
387 cells.len(),
388 columns.len()
389 ),
390 ));
391 }
392 let mut time = None;
393 let mut altitude = None;
394 let (mut v, mut t, mut u) = (f64::NAN, f64::NAN, f64::NAN);
395 for (column, value) in columns.iter().zip(cells) {
396 match column {
397 Column::Time => time = Some(*value),
398 Column::Altitude => altitude = Some(*value * FOOT_M),
399 Column::Velocity => v = *value * FOOT_M,
400 Column::Temperature(unit) => t = unit.kelvin(*value),
401 Column::Voltage => u = *value,
402 Column::Unknown => {}
403 }
404 }
405 let (Some(time), Some(altitude)) = (time, altitude) else {
406 return Err(syntax(
407 *number,
408 "the row leaves out its time or its altitude".to_owned(),
409 ));
410 };
411 if let Some(&last) = log.time_s.last()
412 && time <= last
413 {
414 return Err(syntax(
415 *number,
416 format!("the time {time} s doesn't come after the row before's {last} s"),
417 ));
418 }
419 log.time_s.push(time);
420 log.altitude_m.push(altitude);
421 for (channel, value) in [(&mut speed, v), (&mut temperature, t), (&mut battery, u)] {
422 if let Some(channel) = channel {
423 channel.push(value);
424 }
425 }
426 }
427 log.vertical_speed_m_s = speed;
428 log.temperature_k = temperature;
429 log.battery_v = battery;
430 Ok(())
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 const HEAD: &str = "PerfectFlite Pnut\r\nFirmware: 1.0\r\nSerial Number: 17\r\n\
438 Apogee: 1280' AGL\r\nGround Elevation: 600' MSL\r\nNumSamps: 3\r\n\
439 Flight Number: 2\r\nComments: \r\n\r\n\
440 Data: (Time, Altitude, Velocity, Temperature (F), Voltage)\r\n";
441
442 #[test]
444 fn reads_the_preamble_and_rows_in_si() {
445 let text =
446 format!("{HEAD}0.00, 0, 0, 32.00, 4.20\r\n0.05, -1, 10\r\n0.10, 10, 20, 212, 4.1\r\n");
447 let log = read(&text).unwrap();
448 assert_eq!(log.format, LogFormat::PerfectFlitePf2);
449 assert_eq!(log.logger, "PerfectFlite Pnut");
450 assert_eq!(log.serial_number.as_deref(), Some("17"));
451 assert_eq!(log.firmware.as_deref(), Some("1.0"));
452 assert_eq!(log.flight_number, Some(2));
453 assert_eq!(log.stated.apogee_m, Some(1280.0 * 0.3048));
454 assert_eq!(log.stated.ground_elevation_msl_m, Some(600.0 * 0.3048));
455 assert_eq!(log.stated.samples, Some(3));
456 assert_eq!(log.time_s, [0.0, 0.05, 0.10]);
457 assert_eq!(log.altitude_m, [0.0, -0.3048, 3.048]);
458 assert_eq!(
459 log.vertical_speed_m_s.as_deref(),
460 Some(&[0.0, 3.048, 6.096][..])
461 );
462 let temperature = log.temperature_k.unwrap();
463 assert_eq!(temperature[0], 273.15);
464 assert!(temperature[1].is_nan());
465 assert!((temperature[2] - 373.15).abs() < 1e-12);
466 let battery = log.battery_v.unwrap();
467 assert_eq!((battery[0], battery[2]), (4.2, 4.1));
468 assert!(battery[1].is_nan());
469 assert!(log.notes.is_empty(), "{:?}", log.notes);
470 }
471
472 #[test]
475 fn columns_follow_the_data_line() {
476 let text = "PerfectFlite StratoLogger\nData: (Altitude, Time, Pressure)\n100, 0.5, 9\n";
477 let log = read(text).unwrap();
478 assert_eq!((log.time_s[0], log.altitude_m[0]), (0.5, 30.48));
479 assert!(log.vertical_speed_m_s.is_none() && log.temperature_k.is_none());
480 assert!(log.notes[0].contains("\"pressure\""), "{:?}", log.notes);
481
482 let log = read("PerfectFlite Pnut\n0.0, 5, 1, 50, 4\n").unwrap();
483 assert_eq!(log.altitude_m, [5.0 * 0.3048]);
484 assert!(log.notes[0].contains("no `Data:` line"), "{:?}", log.notes);
485 }
486
487 #[test]
490 fn stated_heights_are_feet_or_nothing() {
491 let log = read("PerfectFlite Pnut\nApogee: PWRLOSS\n0, 0, 0\n").unwrap();
492 assert_eq!(log.stated.apogee_m, None);
493 assert!(
494 log.notes.iter().any(|note| note.contains("PWRLOSS")),
495 "{:?}",
496 log.notes
497 );
498 let error = read("PerfectFlite Pnut\nApogee: 390 m AGL\n0, 0, 0\n").unwrap_err();
499 assert!(
500 matches!(&error, LogError::Unit { line: 2, message, .. } if message.contains("\"390 m AGL\" is in \"m\"")),
501 "{error}"
502 ); let log = read("PerfectFlite Pnut\nApogee: 1009 AGL\n0, 0, 0\n").unwrap();
504 assert_eq!(log.stated.apogee_m, None);
505 assert!(
506 log.notes.iter().any(|note| note.contains("no unit")),
507 "{:?}",
508 log.notes
509 );
510 }
511
512 #[test]
515 fn notes_stay_few() {
516 let text = format!("PerfectFlite Pnut\n{}0, 0, 0\n", "x\n".repeat(1000));
517 let log = read(&text).unwrap();
518 assert_eq!(log.notes.len(), MAX_NOTES + 2);
519 assert!(log.notes[0].contains("no `Data:` line"), "{:?}", log.notes);
520 assert!(
521 log.notes[MAX_NOTES + 1].starts_with("and 980 more notes"),
522 "{:?}",
523 log.notes
524 );
525 }
526
527 #[test]
529 fn a_long_line_is_quoted_short() {
530 let junk = "\u{fffd}".repeat(100_000);
531 let error = read(&junk).unwrap_err().to_string();
532 assert!(error.len() < 400, "{} bytes", error.len());
533 assert!(error.contains('…'), "{error}");
534 }
535
536 #[test]
539 fn marks_line_ends_and_odd_heights() {
540 let log = read(
541 "\u{feff}PerfectFlite Pnut\rApogee: 1,009' AGL\rGround Elevation: 600 ft\r0, 0, 0\r",
542 )
543 .unwrap();
544 assert_eq!(log.logger, "PerfectFlite Pnut");
545 assert_eq!(log.time_s.len(), 1);
546 assert_eq!(log.stated.apogee_m, None);
547 assert!(
548 log.notes.iter().any(|note| note.contains("\"1,009' AGL\"")),
549 "{:?}",
550 log.notes
551 );
552 assert_eq!(log.stated.ground_elevation_msl_m, Some(600.0 * FOOT_M));
553 }
554
555 #[test]
557 fn malformed_files_are_refused_where_they_go_wrong() {
558 let cases = [
559 ("", "the file is empty"),
560 ("Time, Altitude\n0, 0\n", "doesn't name PerfectFlite"),
561 ("PerfectFlite Pnut\nApogee: 5'\n", "no data rows"),
562 (
563 "PerfectFlite Pnut\n0, 0, 0\n0, 1, 0\n",
564 "line 3: the time 0 s doesn't come after",
565 ),
566 (
567 "PerfectFlite Pnut\n0, 0, x\n",
568 "line 2: \"x\" isn't a finite number",
569 ),
570 (
571 "PerfectFlite Pnut\n0, 0, 0\nEnd\n",
572 "line 3: \"End\" after the data rows",
573 ),
574 (
575 "PerfectFlite Pnut\n0, 0, 0, 1, 2, 3\n",
576 "line 2: the row has 6 values",
577 ),
578 ("PerfectFlite Pnut\n0\n", "no data rows"),
579 (
580 "PerfectFlite Pnut\nData: (Time, Velocity)\n0, 0\n",
581 "line 2: the `Data:` line",
582 ),
583 (
584 "PerfectFlite Pnut\nData: (Time, Altitude)\n0.5\n",
585 "no data rows",
586 ),
587 (
588 "PerfectFlite Pnut\nData: (Time, Altitude)\n0.5,\n",
589 "line 3: \"\" isn't",
590 ),
591 (
592 "PerfectFlite Pnut\n0, inf, 0\n",
593 "line 2: \"inf\" isn't a finite number",
594 ),
595 ];
596 for (text, expected) in cases {
597 let error = read(text).unwrap_err().to_string();
598 assert!(error.contains(expected), "{text:?}: {error}");
599 }
600 let error =
602 read("PerfectFlite Pnut\nData: (Time, Velocity, Altitude)\n0, 5\n").unwrap_err();
603 assert!(
604 error.to_string().contains("line 3: the row leaves out"),
605 "{error}"
606 );
607 }
608
609 #[test]
611 fn a_sample_count_that_differs_is_noted() {
612 let log = read("PerfectFlite Pnut\nNumSamps: 3\n0, 0, 0\n0.05, 0, 0\n").unwrap();
613 assert_eq!(
614 log.notes.last().unwrap(),
615 "the file states 3 samples and holds 2"
616 );
617 }
618
619 proptest::proptest! {
620 #[test]
622 fn any_text_is_read_or_refused(text in "(PerfectFlite Pnut\n)?([-0-9., A-Za-z:'()]{0,20}\n){0,12}") {
623 if let Ok(log) = read(&text) {
624 proptest::prop_assert!(log.time_s.windows(2).all(|pair| pair[1] > pair[0]));
625 proptest::prop_assert_eq!(log.time_s.len(), log.altitude_m.len());
626 }
627 }
628 }
629}