Skip to content

Commit 1afc639

Browse files
authored
Flight Data Parser (#308)
1 parent aacd0c2 commit 1afc639

2 files changed

Lines changed: 216 additions & 1 deletion

File tree

app/samples/benchmark_littlefs_datalogger/.idea/benchmark_littlefs_datalogger.iml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/fdp.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import struct
2+
import yaml
3+
import json
4+
import csv
5+
import argparse
6+
from pathlib import Path
7+
from typing import Dict, List, Any, Tuple
8+
9+
TYPE_SIZES = {
10+
"float": "f",
11+
"double": "d",
12+
"uint8_t": "B",
13+
"int8_t": "b",
14+
"uint16_t": "H",
15+
"int16_t": "h",
16+
"uint32_t": "I",
17+
"int32_t": "i",
18+
"uint64_t": "Q",
19+
"int64_t": "q"
20+
}
21+
22+
def load_yaml_files(paths: List[str]) -> Dict[str, Any]:
23+
"""Loads all schema files into a single dictionary."""
24+
schema = {}
25+
for path in paths:
26+
with open(path, "r") as f:
27+
data = yaml.safe_load(f)
28+
schema.update(data)
29+
return schema
30+
31+
def get_struct_format(typename: str, schema: Dict[str, Any]) -> str:
32+
"""Recursively generates a struct format string from a type definition."""
33+
if typename in TYPE_SIZES:
34+
return TYPE_SIZES[typename]
35+
36+
if typename not in schema:
37+
raise ValueError(f"Unknown type: {typename}")
38+
39+
type_def = schema[typename]
40+
fmt = ""
41+
if type_def.get("timestamp"):
42+
fmt += TYPE_SIZES["uint32_t"]
43+
44+
for field in type_def["fields"]:
45+
if "array_size" in field:
46+
count = int(field["array_size"])
47+
elem_fmt = get_struct_format(field["type"], schema)
48+
fmt += elem_fmt * count
49+
else:
50+
fmt += get_struct_format(field["type"], schema)
51+
return fmt
52+
53+
def get_size(typename: str, schema: Dict[str, Any]) -> int:
54+
"""Returns the total size in bytes of the given type."""
55+
fmt = get_struct_format(typename, schema)
56+
return struct.calcsize(fmt)
57+
58+
def unpack_fields(data: bytes, typename: str, schema: Dict[str, Any], offset=0) -> Dict[str, Any]:
59+
"""Recursively unpacks binary data into a dict."""
60+
result = {}
61+
type_def = schema[typename]
62+
index = offset
63+
64+
if type_def.get("timestamp"):
65+
result["timestamp"] = struct.unpack_from("I", data, index)[0]
66+
index += 4
67+
68+
for field in type_def["fields"]:
69+
field_type = field["type"]
70+
if "array_size" in field:
71+
count = int(field["array_size"])
72+
fmt = TYPE_SIZES[field_type] * count
73+
size = struct.calcsize(fmt)
74+
values = struct.unpack_from(fmt, data, index)
75+
result[field["name"]] = list(values)
76+
index += size
77+
elif field_type in TYPE_SIZES:
78+
fmt = TYPE_SIZES[field_type]
79+
value = struct.unpack_from(fmt, data, index)[0]
80+
result[field["name"]] = value
81+
index += struct.calcsize(fmt)
82+
else:
83+
sub_result = unpack_fields(data, field_type, schema, index)
84+
result[field["name"]] = sub_result
85+
index += get_size(field_type, schema)
86+
87+
return result
88+
89+
def flatten(d: Dict[str, Any], parent_key='', sep='.') -> Dict[str, Any]:
90+
"""Flattens nested dict for CSV output."""
91+
items = []
92+
for k, v in d.items():
93+
new_key = f"{parent_key}{sep}{k}" if parent_key else k
94+
if isinstance(v, dict):
95+
items.extend(flatten(v, new_key, sep=sep).items())
96+
else:
97+
items.append((new_key, v))
98+
return dict(items)
99+
100+
def parse_records(binary_path: Path, typename: str, schema: dict) -> List[Dict[str, Any]]:
101+
"""Reads and unpacks all records from the binary file."""
102+
records = []
103+
type_size = get_size(typename, schema)
104+
105+
with open(binary_path, "rb") as f:
106+
while chunk := f.read(type_size):
107+
if len(chunk) < type_size:
108+
print("Skipping incomplete record at end")
109+
break
110+
record = unpack_fields(chunk, typename, schema)
111+
records.append(record)
112+
113+
return records
114+
115+
def try_compile_flat_format(typename: str, schema: Dict[str, Any]) -> Tuple[str, List[str]]:
116+
"""Returns struct format string and flat CSV headers if type is flat."""
117+
fmt_parts = []
118+
headers = []
119+
120+
def walk(tname: str, prefix=""):
121+
if tname not in schema:
122+
if tname in TYPE_SIZES:
123+
fmt_parts.append(TYPE_SIZES[tname])
124+
headers.append(prefix.rstrip('.'))
125+
return
126+
raise ValueError(f"Unsupported type: {tname}")
127+
128+
tdef = schema[tname]
129+
if tdef.get("timestamp"):
130+
fmt_parts.append(TYPE_SIZES["uint32_t"])
131+
headers.append(prefix + "timestamp")
132+
133+
for field in tdef["fields"]:
134+
count = int(field.get("array_size", 1))
135+
name = f"{prefix}{field['name']}"
136+
137+
if field["type"] in TYPE_SIZES:
138+
for i in range(count):
139+
fmt_parts.append(TYPE_SIZES[field["type"]])
140+
suffix = f"[{i}]" if count > 1 else ""
141+
headers.append(name + suffix)
142+
elif field["type"] in schema:
143+
if count > 1:
144+
raise ValueError("Arrays of nested structs not supported in fast CSV mode")
145+
walk(field["type"], name + ".")
146+
else:
147+
raise ValueError(f"Unknown field type: {field['type']}")
148+
149+
walk(typename)
150+
return "<" + "".join(fmt_parts), headers
151+
152+
def try_compile_flat_csv(bin_path: Path, typename: str, schema: dict, out_path: str) -> bool:
153+
"""Attempt fast CSV write using struct.iter_unpack."""
154+
try:
155+
fmt, headers = try_compile_flat_format(typename, schema)
156+
with open(bin_path, "rb") as f, open(out_path, "w", newline="") as out:
157+
writer = csv.writer(out)
158+
writer.writerow(headers)
159+
for row in struct.iter_unpack(fmt, f.read()):
160+
writer.writerow(row)
161+
print(f"[fast] CSV output written to {out_path}")
162+
return True
163+
except Exception as e:
164+
print(f"[fallback] Fast CSV mode failed: {e}")
165+
return False
166+
167+
def write_csv_flat(records: List[Dict[str, Any]], out_path: str):
168+
"""Writes flattened dicts to a CSV file."""
169+
with open(out_path, "w", newline="") as cf:
170+
writer = csv.DictWriter(cf, fieldnames=list(flatten(records[0]).keys()))
171+
writer.writeheader()
172+
for r in records:
173+
writer.writerow(flatten(r))
174+
print(f"[slow] CSV output written to {out_path}")
175+
176+
def write_json(records: List[Dict[str, Any]], out_path: str):
177+
"""Writes records to a JSON file."""
178+
with open(out_path, "w") as jf:
179+
json.dump(records, jf, indent=2)
180+
print(f"JSON output written to {out_path}")
181+
182+
def parse_args():
183+
"""Parses command line arguments using argparse."""
184+
parser = argparse.ArgumentParser(
185+
description="Parse a binary file using a YAML-defined schema and output to JSON and/or CSV."
186+
)
187+
parser.add_argument("binary_file", help="Path to the binary file to parse.")
188+
parser.add_argument("typename", help="Top-level struct name (e.g., SensorData).")
189+
parser.add_argument("yaml_files", nargs="+", help="YAML schema file(s) describing data layout.")
190+
parser.add_argument("--csv", metavar="CSV_PATH", help="Path to output CSV file.")
191+
parser.add_argument("--json", metavar="JSON_PATH", help="Path to output JSON file.")
192+
return parser.parse_args()
193+
194+
def main():
195+
args = parse_args()
196+
schema = load_yaml_files(args.yaml_files)
197+
198+
# Fast CSV optimization path
199+
if args.csv and not args.json:
200+
if try_compile_flat_csv(Path(args.binary_file), args.typename, schema, args.csv):
201+
return # Skip slow path if fast write succeeds
202+
203+
# Fallback / JSON / generic CSV
204+
records = parse_records(Path(args.binary_file), args.typename, schema)
205+
206+
if args.json:
207+
write_json(records, args.json)
208+
if args.csv:
209+
write_csv_flat(records, args.csv)
210+
if not args.json and not args.csv:
211+
for r in records:
212+
print(json.dumps(r, indent=2))
213+
214+
if __name__ == "__main__":
215+
main()

0 commit comments

Comments
 (0)