Skip to main content

datui_lib/
dataflash.rs

1//! ArduPilot DataFlash logs (`.bin`).
2//!
3//! A DataFlash log describes its own messages: each `FMT` record gives a message
4//! type's id, length, name, format characters and field labels; every other record is
5//! `A3 95`, its type's id, and its fields packed end to end. One pass records where
6//! each type's records start ([`crate::indexed`]) and reads the units and multipliers
7//! of `FMTU`, `UNIT` and `MULT` when the log has them; each type is then a table
8//! decoded from a map of the file, its columns typed by their format characters.
9//!
10//! Every length is bounded: a record by its type's length and the file's end. A byte
11//! that does not start a record is passed over to the next `A3 95` and counted.
12
13use std::collections::{BTreeMap, HashMap};
14use std::path::Path;
15use std::sync::Arc;
16
17use color_eyre::Result;
18use color_eyre::eyre::eyre;
19
20use crate::error_display::{FileError, in_file};
21use polars::prelude::*;
22
23use crate::fixed_records::{Bytes, ColumnLayout, Logical, Physical};
24use crate::indexed::{IndexedRecords, Offsets};
25use crate::model_files::MetaValue;
26use crate::sqlite::Table;
27use crate::text_formats::Detail;
28
29/// What datui does with a DataFlash log: see [`crate::readers`].
30pub(crate) const READER: crate::readers::Reader = crate::readers::Reader {
31    scan,
32    signatures: &[crate::readers::Signature {
33        says: |head, _| looks_like(head),
34        kind: crate::readers::Kind::Magic,
35        trusted: crate::readers::Trusted {
36            tables: true,
37            ..crate::readers::EVERYWHERE
38        },
39    }],
40    tables: Some(listed),
41    ..crate::readers::BASE
42};
43
44const HEAD: [u8; 2] = [0xA3, 0x95];
45/// The type id of `FMT`, the record that defines the others.
46const FMT: u8 = 0x80;
47/// An `FMT` record's length: header, type, length, name, format, labels.
48const FMT_LEN: usize = 89;
49
50/// Whether `head`, the first bytes of a file, begins a DataFlash log: an `FMT` record
51/// that defines `FMT` itself.
52pub fn looks_like(head: &[u8]) -> bool {
53    head.len() >= 8
54        && head[..3] == [0xA3, 0x95, FMT]
55        && head[3] == FMT
56        && head[4] as usize == FMT_LEN
57        && &head[5..8] == b"FMT"
58}
59
60/// A format character's storage, width, values, and the scale its value carries.
61fn format_char(c: u8) -> Option<(Physical, usize, usize, Option<f64>)> {
62    Some(match c {
63        b'b' => (Physical::Signed(1), 1, 1, None),
64        b'B' | b'M' => (Physical::Unsigned(1), 1, 1, None),
65        b'h' => (Physical::Signed(2), 2, 1, None),
66        b'H' => (Physical::Unsigned(2), 2, 1, None),
67        b'i' => (Physical::Signed(4), 4, 1, None),
68        b'I' => (Physical::Unsigned(4), 4, 1, None),
69        b'q' => (Physical::Signed(8), 8, 1, None),
70        b'Q' => (Physical::Unsigned(8), 8, 1, None),
71        b'f' => (Physical::Float(4), 4, 1, None),
72        b'd' => (Physical::Float(8), 8, 1, None),
73        b'g' => (Physical::Float(2), 2, 1, None),
74        b'n' => (Physical::Text, 4, 1, None),
75        b'N' => (Physical::Text, 16, 1, None),
76        b'Z' => (Physical::Text, 64, 1, None),
77        b'c' => (Physical::Signed(2), 2, 1, Some(0.01)),
78        b'C' => (Physical::Unsigned(2), 2, 1, Some(0.01)),
79        b'e' => (Physical::Signed(4), 4, 1, Some(0.01)),
80        b'E' => (Physical::Unsigned(4), 4, 1, Some(0.01)),
81        // Latitude and longitude in 1e-7 degrees.
82        b'L' => (Physical::Signed(4), 4, 1, Some(1e-7)),
83        b'a' => (Physical::Signed(2), 2, 32, None),
84        _ => return None,
85    })
86}
87
88/// One message type, as its `FMT` record defines it.
89#[derive(Debug)]
90pub struct MessageType {
91    pub id: u8,
92    pub name: String,
93    /// The whole record's length, header included.
94    pub length: usize,
95    pub format: String,
96    pub labels: Vec<String>,
97    pub offsets: Arc<Offsets>,
98    /// Unit and multiplier ids per field, from `FMTU`.
99    pub unit_ids: Option<String>,
100    pub mult_ids: Option<String>,
101}
102
103/// What one pass over a DataFlash log found.
104#[derive(Debug, Default)]
105pub struct Index {
106    pub types: BTreeMap<u8, MessageType>,
107    pub units: HashMap<u8, String>,
108    pub mults: HashMap<u8, f64>,
109    pub skipped: usize,
110    pub damaged: usize,
111    pub past_limit: usize,
112    /// `FMT` records whose format does not add up to their length, or has a character
113    /// datui does not know.
114    pub bad_formats: Vec<String>,
115    pub cut_short: bool,
116}
117
118impl Index {
119    /// The tables with records, by name.
120    pub fn names(&self) -> Vec<(String, u8)> {
121        let mut names: Vec<(String, u8)> = self
122            .types
123            .values()
124            .filter(|t| !t.offsets.is_empty() && t.id != FMT)
125            .map(|t| (t.name.clone(), t.id))
126            .collect();
127        names.sort();
128        // Two types of one name (a log that redefines one) keep their ids apart.
129        let mut seen: HashMap<String, usize> = HashMap::new();
130        for (name, _) in &names {
131            *seen.entry(name.clone()).or_default() += 1;
132        }
133        for (name, id) in &mut names {
134            if seen[name.as_str()] > 1 {
135                *name = format!("{name}.{id}");
136            }
137        }
138        names
139    }
140}
141
142/// Text from a fixed-width field.
143fn text(bytes: &[u8]) -> String {
144    crate::fixed_records::text(bytes)
145}
146
147/// The size a format's characters add up to, `None` for one datui does not know.
148fn format_size(format: &str) -> Option<usize> {
149    format.bytes().try_fold(0usize, |n, c| {
150        let (_, width, count, _) = format_char(c)?;
151        n.checked_add(width * count)
152    })
153}
154
155/// The fields of a record of `t` read as plain values, for `FMTU`, `UNIT` and `MULT`.
156fn fields<'a>(t: &MessageType, record: &'a [u8]) -> Vec<(&'a [u8], u8)> {
157    let mut at = 3;
158    let mut out = Vec::new();
159    for c in t.format.bytes() {
160        let Some((_, width, count, _)) = format_char(c) else {
161            break;
162        };
163        let len = width * count;
164        let Some(bytes) = record.get(at..at + len) else {
165            break;
166        };
167        out.push((bytes, c));
168        at += len;
169    }
170    out
171}
172
173/// Index the DataFlash log in `data`: one pass, start to end.
174pub fn index(data: &[u8]) -> std::result::Result<Index, String> {
175    if !looks_like(data) {
176        return Err("not a DataFlash log: it does not start with an FMT record".into());
177    }
178    let mut index = Index::default();
179    let mut records = 0usize;
180    let mut fmtu: Vec<(u8, String, String)> = Vec::new();
181    let mut building: HashMap<u8, Offsets> = HashMap::new();
182    let mut at = 0usize;
183    while at < data.len() {
184        if data.get(at..at + 2) != Some(&HEAD[..]) {
185            // Not a record: the next header, if there is one.
186            match memchr::memmem::find(&data[at + 1..], &HEAD) {
187                Some(i) => {
188                    index.skipped += i + 1;
189                    index.damaged += 1;
190                    at += i + 1;
191                    continue;
192                }
193                None => {
194                    index.skipped += data.len() - at;
195                    index.damaged += 1;
196                    break;
197                }
198            }
199        }
200        let Some(&id) = data.get(at + 2) else {
201            index.cut_short = true;
202            break;
203        };
204        if id == FMT {
205            let Some(record) = data.get(at..at + FMT_LEN) else {
206                index.cut_short = true;
207                break;
208            };
209            let defined = record[3];
210            let length = record[4] as usize;
211            let name = text(&record[5..9]);
212            let format = text(&record[9..25]);
213            let labels: Vec<String> = text(&record[25..89])
214                .split(',')
215                .map(str::to_string)
216                .collect();
217            match format_size(&format) {
218                Some(size) if size + 3 == length && length >= 3 => {
219                    // A type redefined alike keeps the records it had; one redefined
220                    // otherwise starts again, as its records are read differently.
221                    let same = index
222                        .types
223                        .get(&defined)
224                        .is_some_and(|t| t.length == length && t.format == format);
225                    if !same {
226                        building.insert(defined, Offsets::for_file(data.len()));
227                    }
228                    index.types.insert(
229                        defined,
230                        MessageType {
231                            id: defined,
232                            name,
233                            length,
234                            format,
235                            labels,
236                            offsets: Arc::new(Offsets::Narrow(Vec::new())),
237                            unit_ids: None,
238                            mult_ids: None,
239                        },
240                    );
241                }
242                _ => {
243                    if index.bad_formats.len() < 1000 {
244                        index.bad_formats.push(format!("{name} ({format})"));
245                    }
246                }
247            }
248            at += FMT_LEN;
249            continue;
250        }
251        let Some(t) = index.types.get_mut(&id) else {
252            // A type no FMT defined: its length is unknown, so look for the next header.
253            match memchr::memmem::find(&data[at + 2..], &HEAD) {
254                Some(i) => {
255                    index.skipped += i + 2;
256                    index.damaged += 1;
257                    at += i + 2;
258                    continue;
259                }
260                None => {
261                    index.skipped += data.len() - at;
262                    index.damaged += 1;
263                    break;
264                }
265            }
266        };
267        let Some(record) = data.get(at..at + t.length) else {
268            index.cut_short = true;
269            break;
270        };
271        match t.name.as_str() {
272            "FMTU" => {
273                let f = fields(t, record);
274                if let [_, (ty, _), (units, _), (mults, _), ..] = f.as_slice()
275                    && let Some(&ty) = ty.first()
276                    && fmtu.len() < 1000
277                {
278                    fmtu.push((ty, text(units), text(mults)));
279                }
280            }
281            "UNIT" => {
282                let f = fields(t, record);
283                if let [_, (unit_id, _), (label, _), ..] = f.as_slice()
284                    && let Some(&unit_id) = unit_id.first()
285                {
286                    index.units.insert(unit_id, text(label));
287                }
288            }
289            "MULT" => {
290                let f = fields(t, record);
291                if let [_, (mult_id, _), (mult, b'd'), ..] = f.as_slice()
292                    && let Some(&mult_id) = mult_id.first()
293                    && let Ok(raw) = <[u8; 8]>::try_from(*mult)
294                {
295                    index.mults.insert(mult_id, f64::from_le_bytes(raw));
296                }
297            }
298            _ => {}
299        }
300        if records < crate::indexed::MAX_RECORDS {
301            building
302                .entry(id)
303                .or_insert_with(|| Offsets::for_file(data.len()))
304                .push(at);
305            records += 1;
306        } else {
307            index.past_limit += 1;
308        }
309        at += t.length;
310    }
311    for (ty, units, mults) in fmtu {
312        if let Some(t) = index.types.get_mut(&ty) {
313            t.unit_ids = Some(units);
314            t.mult_ids = Some(mults);
315        }
316    }
317    for (id, mut offsets) in building {
318        if let Some(t) = index.types.get_mut(&id) {
319            offsets.shrink();
320            t.offsets = Arc::new(offsets);
321        }
322    }
323    Ok(index)
324}
325
326/// The columns of a message type, and each one's unit.
327pub fn columns(index: &Index, t: &MessageType) -> (Vec<ColumnLayout>, Vec<(String, String)>) {
328    let mut columns = Vec::new();
329    let mut units = Vec::new();
330    let mut at = 3;
331    for (i, c) in t.format.bytes().enumerate() {
332        let Some((physical, width, count, scale)) = format_char(c) else {
333            break;
334        };
335        let mut name = t
336            .labels
337            .get(i)
338            .filter(|l| !l.is_empty())
339            .cloned()
340            .unwrap_or_else(|| format!("field{i}"));
341        // A label a log repeats keeps both columns, the later by its place.
342        if columns
343            .iter()
344            .any(|c: &ColumnLayout| c.name == name.as_str())
345        {
346            name = format!("{name}_{i}");
347        }
348        let mut column = match physical {
349            Physical::Text => ColumnLayout::new(&name, at, 0, physical, width),
350            _ => {
351                let mut c = ColumnLayout::new(&name, at, 0, physical, width);
352                c.count = count;
353                c
354            }
355        };
356        let mult = t
357            .mult_ids
358            .as_ref()
359            .and_then(|m| m.as_bytes().get(i))
360            .and_then(|id| index.mults.get(id))
361            .copied();
362        if (name == "TimeUS" && c == b'Q') || (name == "TimeMS" && c == b'I') {
363            column.logical = Logical::Duration {
364                unit: if c == b'Q' {
365                    TimeUnit::Microseconds
366                } else {
367                    TimeUnit::Milliseconds
368                },
369                multiplier: 1,
370            };
371        } else if let Some(scale) = scale {
372            column.logical = Logical::Linear {
373                factor: scale,
374                offset: 0.0,
375            };
376        } else if physical.is_integer()
377            && count == 1
378            && let Some(mult) = mult.filter(|m| *m != 1.0 && *m != 0.0 && m.is_finite())
379        {
380            // An integer the log scales: FMTU says by what.
381            column.logical = Logical::Linear {
382                factor: mult,
383                offset: 0.0,
384            };
385        }
386        if let Some(unit) = t
387            .unit_ids
388            .as_ref()
389            .and_then(|u| u.as_bytes().get(i))
390            .and_then(|id| index.units.get(id))
391            .filter(|u| !u.is_empty())
392        {
393            units.push((name.clone(), unit.clone()));
394        }
395        columns.push(column);
396        at += width * count;
397    }
398    (columns, units)
399}
400
401/// The log's tables as its indexing pass found them: listed once it has been opened,
402/// and not read here, where the home screen waits.
403pub fn listed(file: &Path) -> Result<Vec<Table>> {
404    crate::indexed::peek::<Index>(file)
405        .map(|index| tables(&index))
406        .ok_or_else(|| eyre!("Open the log to list its tables."))
407}
408
409/// The tables of an indexed log, for the home screen and `--table`.
410pub fn tables(index: &Index) -> Vec<Table> {
411    index
412        .names()
413        .into_iter()
414        .map(|(name, id)| Table {
415            name,
416            kind: "message".to_string(),
417            internal: false,
418            columns: columns(index, &index.types[&id])
419                .0
420                .iter()
421                .map(|c| (c.name.to_string(), String::new()))
422                .collect(),
423        })
424        .collect()
425}
426
427/// What the Info panel's DataFlash tab says.
428pub fn detail(index: &Index) -> Detail {
429    let names = index.names();
430    let records: usize = names
431        .iter()
432        .map(|(_, id)| index.types[id].offsets.len())
433        .sum();
434    let lines = vec![
435        format!(
436            "Message types: {} with records, {} defined",
437            names.len(),
438            index.types.len()
439        ),
440        format!("Records: {}", crate::numfmt::group_chrome(records)),
441        format!(
442            "Units: {}",
443            if index.units.is_empty() {
444                "none in the log".to_string()
445            } else {
446                format!("{} (FMTU, UNIT, MULT)", index.units.len())
447            }
448        ),
449    ];
450    let list = names.iter().map(|(name, id)| {
451        let t = &index.types[id];
452        (
453            name.clone(),
454            MetaValue::Text(format!(
455                "{} records, format {}, {} bytes",
456                crate::numfmt::group_chrome(t.offsets.len()),
457                t.format,
458                t.length
459            )),
460        )
461    });
462    Detail {
463        tab: crate::text_formats::tab(crate::FileFormat::Dataflash),
464        lines,
465        list_title: "Messages",
466        list: crate::text_formats::capped_list(list, names.len()),
467        first: false,
468        ..Default::default()
469    }
470}
471
472/// What the Notes tab says of the pass.
473pub fn notes(index: &Index) -> Vec<String> {
474    let group = |n: usize| crate::numfmt::group_chrome(n);
475    let mut notes = Vec::new();
476    if index.damaged > 0 {
477        notes.push(format!(
478            "{} damaged stretches skipped ({} bytes)",
479            group(index.damaged),
480            group(index.skipped)
481        ));
482    }
483    if index.cut_short {
484        notes.push("log cut short mid-record".to_string());
485    }
486    if index.past_limit > 0 {
487        notes.push(format!(
488            "{} records left out: past the first {}",
489            group(index.past_limit),
490            group(crate::indexed::MAX_RECORDS)
491        ));
492    }
493    if !index.bad_formats.is_empty() {
494        notes.push(format!(
495            "{} message types not read, format unreadable: {}",
496            index.bad_formats.len(),
497            index
498                .bad_formats
499                .iter()
500                .take(10)
501                .cloned()
502                .collect::<Vec<_>>()
503                .join(", ")
504        ));
505    }
506    notes
507}
508
509/// The index of the DataFlash log at `path`, made by one pass or kept from one.
510pub fn indexed(path: &Path) -> Result<(Arc<Bytes>, Arc<Index>)> {
511    let bytes = Arc::new(Bytes::map(path).map_err(|e| in_file(path, e.into()))?);
512    let index = crate::indexed::cached(path, || index(bytes.as_slice()))
513        .map_err(|e| FileError::new(path, e))?;
514    Ok((bytes, index))
515}
516
517/// What opening a DataFlash log finds.
518pub enum Open {
519    Table {
520        lf: Box<LazyFrame>,
521        opened: Box<crate::members::Opened>,
522    },
523    Several(Vec<String>),
524}
525
526/// Open the DataFlash log at `path`: the table `wanted` names, its one table, or the
527/// list.
528pub fn open(path: &Path, wanted: Option<&str>) -> Result<Open> {
529    let (bytes, index) = indexed(path)?;
530    let tables = tables(&index);
531    let picked = match crate::members::pick(
532        tables.clone(),
533        wanted,
534        path,
535        " The log has no records but its formats.",
536    )? {
537        crate::sqlite::Pick::One(table) => table.name,
538        crate::sqlite::Pick::Several(tables) => {
539            return Ok(Open::Several(tables.into_iter().map(|t| t.name).collect()));
540        }
541    };
542    let id = index
543        .names()
544        .into_iter()
545        .find(|(n, _)| *n == picked)
546        .map(|(_, id)| id)
547        .expect("picked from the names");
548    let t = &index.types[&id];
549    let (columns, units) = columns(&index, t);
550    let records = Arc::new(
551        IndexedRecords::new(bytes, t.offsets.clone(), columns)
552            .map_err(|e| FileError::new(path, format!("table \"{picked}\": {e}")))?,
553    );
554    let opened = crate::members::Opened {
555        window: Some((records.clone(), records.rows())),
556        detail: Some(Arc::new(detail(&index))),
557        other_tables: crate::members::others(&tables, &picked),
558        notes: notes(&index)
559            .into_iter()
560            .map(|n| crate::text_formats::note(n, "the log".to_string()))
561            .collect(),
562        units,
563        indexing: None,
564        numbering: None,
565    };
566    Ok(Open::Table {
567        lf: Box::new(records.lazy()),
568        opened: Box::new(opened),
569    })
570}
571
572/// The scan of a DataFlash log: the table `--table` names, or its only one, decoded from
573/// the file where it is shown; or none yet when it has several. The pass that indexes
574/// the log is kept, so a table chosen from the list reads nothing again.
575fn scan(input: crate::readers::ScanIn<'_>) -> Result<crate::scan::Scan> {
576    let file = input.path();
577    Ok(match open(file, input.options.table.as_deref())? {
578        Open::Table { lf, opened } => {
579            input.report.opened = Some(Arc::new(*opened));
580            (*lf).into()
581        }
582        Open::Several(tables) => crate::scan::Scan::Tables {
583            file: file.to_path_buf(),
584            tables,
585            format: input.format,
586        },
587    })
588}
589
590#[cfg(test)]
591pub(crate) mod tests {
592    use super::*;
593
594    /// A file that is not a DataFlash log names itself, in the one shape.
595    #[test]
596    fn errors_name_the_file() {
597        crate::readers::bad_input::each_names_its_file(
598            crate::FileFormat::Dataflash,
599            &[(
600                "text.bin",
601                b"hello there, this is text",
602                "Not a DataFlash log",
603            )],
604        );
605    }
606
607    fn fmt(id: u8, name: &str, format: &str, labels: &str) -> Vec<u8> {
608        let length = if id == FMT {
609            FMT_LEN
610        } else {
611            format_size(format).unwrap() + 3
612        };
613        let mut r = vec![0xA3, 0x95, FMT, id, length as u8];
614        let pad = |s: &str, n: usize| {
615            let mut b = s.as_bytes().to_vec();
616            b.resize(n, 0);
617            b
618        };
619        r.extend(pad(name, 4));
620        r.extend(pad(format, 16));
621        r.extend(pad(labels, 64));
622        r
623    }
624
625    /// A small log: FMT, the unit tables, two message types interleaved, a scaled
626    /// field, units, damage, and a record cut off at the end.
627    pub(crate) fn tiny() -> Vec<u8> {
628        let mut log = fmt(FMT, "FMT", "BBnNZ", "Type,Length,Name,Format,Columns");
629        log.extend(fmt(129, "UNIT", "QbZ", "TimeUS,Id,Label"));
630        log.extend(fmt(130, "MULT", "Qbd", "TimeUS,Id,Mult"));
631        log.extend(fmt(131, "FMTU", "QBNN", "TimeUS,FmtType,UnitIds,MultIds"));
632        log.extend(fmt(140, "ATT", "QccH", "TimeUS,Roll,Pitch,Yaw"));
633        log.extend(fmt(141, "BARO", "QfiL", "TimeUS,Alt,Press,Lat"));
634        let unit = |id: u8, label: &str| {
635            let mut r = vec![0xA3, 0x95, 129];
636            r.extend(0u64.to_le_bytes());
637            r.push(id);
638            let mut l = label.as_bytes().to_vec();
639            l.resize(64, 0);
640            r.extend(l);
641            r
642        };
643        log.extend(unit(b'd', "deg"));
644        log.extend(unit(b'm', "m"));
645        log.extend(unit(b'P', "Pa"));
646        let mut mult = vec![0xA3, 0x95, 130];
647        mult.extend(0u64.to_le_bytes());
648        mult.push(b'2');
649        mult.extend(100.0f64.to_le_bytes());
650        log.extend(mult);
651        let fmtu = |ty: u8, units: &str, mults: &str| {
652            let mut r = vec![0xA3, 0x95, 131];
653            r.extend(0u64.to_le_bytes());
654            r.push(ty);
655            for s in [units, mults] {
656                let mut b = s.as_bytes().to_vec();
657                b.resize(16, 0);
658                r.extend(b);
659            }
660            r
661        };
662        log.extend(fmtu(141, "-mPd", "-02?"));
663        for i in 0..3u64 {
664            let mut att = vec![0xA3, 0x95, 140];
665            att.extend((1000 + i * 10).to_le_bytes());
666            att.extend((150i16 + i as i16).to_le_bytes());
667            att.extend((-250i16).to_le_bytes());
668            att.extend(9000u16.to_le_bytes());
669            log.extend(att);
670            let mut baro = vec![0xA3, 0x95, 141];
671            baro.extend((1005 + i * 10).to_le_bytes());
672            baro.extend((12.5f32 + i as f32).to_le_bytes());
673            baro.extend(101_325i32.to_le_bytes());
674            baro.extend(473_977_418i32.to_le_bytes());
675            log.extend(baro);
676            if i == 1 {
677                log.extend([0x00, 0xA3, 0x11]);
678            }
679        }
680        log.extend([0xA3, 0x95, 140, 1, 2]);
681        log
682    }
683
684    #[test]
685    fn types_records_units_and_damage() {
686        let data = tiny();
687        assert!(looks_like(&data));
688        let index = index(&data).unwrap();
689        let names: Vec<String> = index.names().into_iter().map(|(n, _)| n).collect();
690        assert_eq!(names, ["ATT", "BARO", "FMTU", "MULT", "UNIT"]);
691        assert_eq!(index.types[&140].offsets.len(), 3);
692        assert_eq!(index.damaged, 1);
693        assert!(index.cut_short);
694        let (columns, units) = columns(&index, &index.types[&141]);
695        assert_eq!(
696            units,
697            [
698                ("Alt".to_string(), "m".to_string()),
699                ("Press".to_string(), "Pa".to_string()),
700                ("Lat".to_string(), "deg".to_string())
701            ]
702        );
703        let records = Arc::new(
704            IndexedRecords::new(
705                Arc::new(Bytes::Owned(data)),
706                index.types[&141].offsets.clone(),
707                columns,
708            )
709            .unwrap(),
710        );
711        let df = records.lazy().collect().unwrap();
712        assert_eq!(
713            df.column("TimeUS").unwrap().dtype(),
714            &DataType::Duration(TimeUnit::Microseconds)
715        );
716        // The format scales latitude; FMTU's multiplier scales the pressure.
717        let lat = df.column("Lat").unwrap().f64().unwrap().get(0).unwrap();
718        assert!((lat - 47.3977418).abs() < 1e-9);
719        assert_eq!(
720            df.column("Press").unwrap().f64().unwrap().get(0),
721            Some(10_132_500.0)
722        );
723    }
724
725    #[test]
726    fn scaled_characters() {
727        let data = tiny();
728        let index = index(&data).unwrap();
729        let (columns, _) = columns(&index, &index.types[&140]);
730        let records = Arc::new(
731            IndexedRecords::new(
732                Arc::new(Bytes::Owned(data)),
733                index.types[&140].offsets.clone(),
734                columns,
735            )
736            .unwrap(),
737        );
738        let df = records.lazy().collect().unwrap();
739        assert_eq!(
740            df.column("Roll").unwrap().f64().unwrap().to_vec(),
741            [Some(1.5), Some(1.51), Some(1.52)]
742        );
743        assert_eq!(df.column("Yaw").unwrap().u16().unwrap().get(0), Some(9000));
744    }
745
746    #[test]
747    fn garbage_is_bounded() {
748        assert!(index(b"nope").is_err());
749        let mut data = fmt(FMT, "FMT", "BBnNZ", "Type,Length,Name,Format,Columns");
750        data.extend([0xA3, 0x95, 0x80, 7, 200]);
751        data.extend([0xA3; 50]);
752        let index = index(&data).unwrap();
753        assert!(index.names().is_empty());
754    }
755}