# /// script # requires-python = ">=3.10" # dependencies = ["pyserial", "numpy", "matplotlib"] # /// """Download a recording from CrashRecorder, convert it to CSV and plot it. uv run tools/crash_dump.py download, save and plot uv run tools/crash_dump.py --png save the plot as PNG instead of opening a window uv run tools/crash_dump.py --from-bin FILE.bin re-parse and plot a saved recording Keep the record format in sync with RecordTag in CrashRecorder/CrashRecorder.ino. """ import argparse import csv import struct import sys import time from datetime import datetime from pathlib import Path import numpy as np TAG_HEADER = 0x01 TAG_IMU = 0x02 TAG_MAG = 0x03 TAG_INPUTS = 0x04 TAG_MARKER = 0x05 TAG_OVERFLOW = 0x06 TAG_END = 0xFF PAYLOAD_LENGTHS = { TAG_HEADER: 12, TAG_IMU: 12, TAG_MAG: 6, TAG_INPUTS: 1, TAG_MARKER: 1, TAG_OVERFLOW: 0, } INPUT_BITS = {"pedal": 0, "forward": 1, "reverse": 2, "mode": 3, "left": 4, "right": 5} MAG_UT_PER_LSB = 0.15 # 16-bit mode, without the per-chip sensitivity adjustment # from the tilt test on 2026-10-21: subtracting these brings |a| to 1g in every pose within ~2mg, # cross-checked against gyro-integrated tilt angles ACCEL_OFFSET = np.array([0.490, -0.277, -0.004]) # g, sensor x y z # sensor mounted on its side: +x up, +y left, +z backward # vehicle frame accel: forward, left, up (up reads +1g at rest) # vehicle frame rotation, aviation style: roll + right side down, pitch + nose up, yaw + turning right def to_vehicle(accel: np.ndarray, gyro: np.ndarray) -> tuple[np.ndarray, np.ndarray]: corrected = accel - ACCEL_OFFSET vehicle_accel = np.column_stack([-corrected[:, 2], corrected[:, 1], corrected[:, 0]]) vehicle_rotation = np.column_stack([-gyro[:, 2], -gyro[:, 1], -gyro[:, 0]]) return vehicle_accel, vehicle_rotation DEFAULT_OUT = Path(__file__).resolve().parent.parent / "recordings" def download(port: str) -> bytes: import serial with serial.Serial(port, timeout=1) as connection: time.sleep(0.2) connection.reset_input_buffer() connection.write(b"i") while True: line = connection.readline().decode(errors="replace").strip() if not line: sys.exit("no response from the board, is CrashRecorder running?") if line == "INFO END": break print(f" {line}") connection.write(b"d") while True: line = connection.readline().decode(errors="replace").strip() if not line: sys.exit("no response to the dump command") if line == "DUMP UNAVAILABLE": sys.exit("board is busy, stop the recording first") if line.startswith("DUMP "): length = int(line.split()[1]) break print(f"downloading {length} bytes") connection.timeout = 10 data = connection.read(length) if len(data) != length: sys.exit(f"download incomplete, got {len(data)} of {length} bytes") return data def parse(data: bytes) -> dict: header = None imu = [] # raw int16 rows: ax ay az gx gy gz mag_at = [] # (imu index, mx, my, mz) inputs_at = [] # (imu index, raw levels) markers = [] # (imu index, marker) overflows = [] # imu index position = 0 while position < len(data): tag = data[position] if tag == TAG_END: break length = PAYLOAD_LENGTHS.get(tag) if length is None: print(f"warning: unknown tag 0x{tag:02X} at byte {position}, stopping", file=sys.stderr) break payload = data[position + 1 : position + 1 + length] if len(payload) < length: break # cut off by power loss or storage full position += 1 + length if tag == TAG_HEADER: if payload[:3] != b"QLR": sys.exit("not a CrashRecorder recording") version, who_am_i, mag_ok, rate, accel_lsb, gyro_lsb_x10 = struct.unpack("6h", payload)) elif tag == TAG_MAG: mag_at.append((len(imu), *struct.unpack("<3h", payload))) elif tag == TAG_INPUTS: inputs_at.append((len(imu), payload[0])) elif tag == TAG_MARKER: markers.append((len(imu), payload[0])) elif tag == TAG_OVERFLOW: overflows.append(len(imu)) if header is None: sys.exit("recording has no header") raw = np.array(imu, dtype=np.float64).reshape(-1, 6) count = len(raw) # mag and inputs are stored when they change, hold them until the next record mag = np.full((count, 3), np.nan) for (index, *values), following in zip(mag_at, mag_at[1:] + [(count,)]): mag[index : following[0]] = np.array(values) * MAG_UT_PER_LSB inputs = np.zeros((count, len(INPUT_BITS)), dtype=np.int8) for (index, levels), following in zip(inputs_at, inputs_at[1:] + [(count,)]): for column, bit in enumerate(INPUT_BITS.values()): inputs[index : following[0], column] = 0 if levels >> bit & 1 else 1 # low is active accel = raw[:, :3] / header["accel_lsb_per_g"] gyro = raw[:, 3:] / header["gyro_lsb_per_dps"] vehicle_accel, vehicle_rotation = to_vehicle(accel, gyro) return { "header": header, "t": np.arange(count) / header["rate"], "accel": accel, "accel_magnitude": np.linalg.norm(accel, axis=1), "gyro": gyro, "vehicle_accel": vehicle_accel, "vehicle_magnitude": np.linalg.norm(vehicle_accel, axis=1), "horizontal": np.linalg.norm(vehicle_accel[:, :2], axis=1), "vehicle_rotation": vehicle_rotation, "mag": mag, "inputs": inputs, "markers": markers, "overflows": overflows, } def write_csv(recording: dict, path: Path) -> None: marker_column = np.zeros(len(recording["t"]), dtype=np.int8) for index, marker in recording["markers"]: marker_column[min(index, len(marker_column) - 1)] = marker overflow_column = np.zeros(len(recording["t"]), dtype=np.int8) for index in recording["overflows"]: overflow_column[min(index, len(overflow_column) - 1)] = 1 with path.open("w", newline="") as file: writer = csv.writer(file) writer.writerow( [ "t", "a_forward", "a_left", "a_up", "a_vehicle_magnitude", "a_horizontal", "roll_rate", "pitch_rate", "yaw_rate", "ax", "ay", "az", "a_magnitude", "gx", "gy", "gz", "mx", "my", "mz", *INPUT_BITS, "marker", "overflow", ] ) for i, t in enumerate(recording["t"]): writer.writerow( [ f"{t:.3f}", *(f"{v:.4f}" for v in recording["vehicle_accel"][i]), f"{recording['vehicle_magnitude'][i]:.4f}", f"{recording['horizontal'][i]:.4f}", *(f"{v:.2f}" for v in recording["vehicle_rotation"][i]), *(f"{v:.4f}" for v in recording["accel"][i]), f"{recording['accel_magnitude'][i]:.4f}", *(f"{v:.2f}" for v in recording["gyro"][i]), *("" if np.isnan(v) else f"{v:.2f}" for v in recording["mag"][i]), *recording["inputs"][i], marker_column[i], overflow_column[i], ] ) def plot(recording: dict, title: str, png: Path | None) -> None: import matplotlib if png: matplotlib.use("Agg") import matplotlib.pyplot as plt t = recording["t"] figure, axes = plt.subplots(4, 1, sharex=True, figsize=(16, 11), height_ratios=[3, 2, 1, 1]) figure.suptitle(title) accel_axis, gyro_axis, mag_axis, inputs_axis = axes for column, name in enumerate(["forward", "left", "up"]): accel_axis.plot(t, recording["vehicle_accel"][:, column], linewidth=0.7, label=name) accel_axis.plot(t, recording["horizontal"], linewidth=0.7, color="black", alpha=0.6, label="horizontal") accel_axis.set_ylabel("acceleration (g)") for column, name in enumerate(["roll (+ right down)", "pitch (+ nose up)", "yaw (+ right)"]): gyro_axis.plot(t, recording["vehicle_rotation"][:, column], linewidth=0.7, label=name) gyro_axis.set_ylabel("rotation (°/s)") for column, name in enumerate("xyz"): mag_axis.plot(t, recording["mag"][:, column], linewidth=0.7, label=f"m{name}") mag_axis.set_ylabel("magnetic (µT)") # one row per input, filled while active for column, name in enumerate(INPUT_BITS): inputs_axis.fill_between( t, column + 0.1, column + 0.1 + recording["inputs"][:, column] * 0.8, step="post", linewidth=0, label=name ) inputs_axis.set_yticks(np.arange(len(INPUT_BITS)) + 0.5, list(INPUT_BITS)) inputs_axis.set_ylim(0, len(INPUT_BITS)) inputs_axis.set_xlabel("time (s)") for axis in axes: for index, marker in recording["markers"]: axis.axvline(index / recording["header"]["rate"], color="red" if marker == 1 else "orange", linestyle="--", linewidth=1) for index in recording["overflows"]: axis.axvline(index / recording["header"]["rate"], color="grey", linestyle=":", linewidth=1) axis.grid(alpha=0.3) axis.legend(loc="upper right", fontsize="small", ncol=7) figure.tight_layout() if png: figure.savefig(png, dpi=120) print(f"plot saved to {png}") else: plt.show() def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--port", default="/dev/ttyACM0") parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help="directory for downloaded recordings") parser.add_argument("--from-bin", type=Path, help="parse a saved recording instead of downloading") parser.add_argument("--png", action="store_true", help="save the plot as PNG instead of opening a window") parser.add_argument("--no-plot", action="store_true") arguments = parser.parse_args() if arguments.from_bin: bin_path = arguments.from_bin data = bin_path.read_bytes() else: data = download(arguments.port) arguments.out.mkdir(parents=True, exist_ok=True) bin_path = arguments.out / f"crash-{datetime.now():%Y%m%d-%H%M%S}.bin" bin_path.write_bytes(data) print(f"recording saved to {bin_path}") recording = parse(data) header = recording["header"] duration = len(recording["t"]) / header["rate"] print( f"{len(recording['t'])} samples ({duration:.1f} s at {header['rate']} Hz), " f"WHO_AM_I 0x{header['who_am_i']:02X}, {len(recording['markers'])} markers, " f"{len(recording['overflows'])} fifo overflows" ) csv_path = bin_path.with_suffix(".csv") write_csv(recording, csv_path) print(f"csv saved to {csv_path}") if not arguments.no_plot: plot(recording, bin_path.stem, bin_path.with_suffix(".png") if arguments.png else None) if __name__ == "__main__": main()