Status: Accepted Date: 2026-02-08 Updated: 2026-02-23 Context: Black box logging for PID tuning, BNO085 driver development, and post-experiment analysis
The test bench needs persistent telemetry logging for two use cases:
-
PID tuning and control loop analysis — Recording angle, PID terms (P/I/D contributions), and motor throttles over time to diagnose oscillation, overshoot, and steady-state error. Without logs, tuning is guesswork.
-
BNO085 driver development — The IMU driver needs characterization before it can replace the AS5600 encoder as the primary control input. Logging both sensors simultaneously with real-time timestamps enables measuring IMU lag, angle error, drift, and report rate consistency.
Currently there is no persistent logging — the only output is the display and serial print. Data is lost when the experiment ends.
- Real-time timestamps (wall clock, not just
ticks_ms) for correlating logs across experiments - Persistent storage that survives power cycles and holds multiple experiment runs
- Minimal impact on the 50 Hz control loop — logging must not starve the PID
- CSV format for easy analysis with Python/pandas on PC
Two discrete breakout boards replace the all-in-one PiCowbell used previously:
| Board | Adafruit | Component | Details |
|---|---|---|---|
| Micro SD SPI Breakout | #4682 | MicroSD | SPI, 3V only — MOSI=GPIO 19, MISO=GPIO 16, SCK=GPIO 18, CS=GPIO 17 |
| PCF8523 RTC Breakout | #5189 | RTC | PCF8523, CR1220 coin cell, I2C address 0x68, shared I2C Bus 0 (GPIO 0/1) |
Separate boards remove the fixed-wiring constraint of the PiCowbell and allow independent placement.
Conflict — SPI0 vs Pimoroni Display Pack (resolved — see DR-004): The SD card breakout and the Pimoroni Pico Display Pack both claim SPI0 on GPIO 16–19 with incompatible pin roles:
| GPIO | SD card breakout | Pimoroni Display |
|---|---|---|
| 16 | SPI0 MISO (data from card) | DC (data/command select) |
| 17 | SPI0 CS (chip select) | CS (chip select) |
| 18 | SPI0 SCK | SPI0 SCK |
| 19 | SPI0 MOSI | SPI0 MOSI |
SPI buses can host multiple devices sharing SCK/MOSI/MISO with separate CS lines. However, these two boards cannot coexist because: (1) GPIO 17 is CS for both — no way to select one without selecting the other, and (2) GPIO 16 serves fundamentally different electrical functions (bidirectional data vs unidirectional control signal). When SdSink initializes SPI0 for SD card access, it reconfigures the bus the display depends on, freezing the LCD.
SD card detect on GPIO 15 — skipped. The SD library detects the card by attempting to mount. Button Y (GPIO 15) is more valuable for the UI.
The PCF8523 RTC shares I2C Bus 0 (GPIO 0/1) with AS5600 (0x36) and BNO085 (0x4A). No address conflict (0x68). The RTC is read once per session before the control loop starts, so it does not add any runtime traffic to the latency-sensitive sensor bus.
SD card detect on GPIO 15 — skip it. The SD library can detect the card by attempting to mount. Button Y (GPIO 15) is more valuable for the UI.
I2C Bus 0 (GPIO 0/1, 400 kHz)
├── AS5600 encoder [0x36]
├── BNO085 IMU [0x4A]
└── PCF8523 RTC [0x68] ← read once at session start, no runtime traffic
SPI Bus 0 (GPIO 16/17/18/19) ← conflict resolved: LCD disconnected (DR-004)
└── MicroSD card breakout
SPI0 conflict status: Resolved in DR-004. The display pack LCD is disconnected; only buttons (GPIO 12–15) and RGB LED (GPIO 6/7/8) are wired. SD card has exclusive use of SPI0. Motors moved from GPIO 6/7 to GPIO 10/11 to free RGB LED pins.
The RTC is used only once per session to generate a human-readable log filename. All row timestamps use time.ticks_ms() (Pico's internal microsecond timer), which is a near-free register read. This avoids the cost of an I2C transaction per log row and provides the precise inter-sample timing needed for PID analysis. The RTC wall-clock time in the filename provides session correlation across days.
CSV with comma delimiter (standard CSV):
T_MS,ENC_DEG,IMU_DEG,ERR,P,I,D,PID_OUT,M1,M2
12345,+0.5,,+0.5,2.50,0.10,0.00,2.60,303,297
Fields:
T_MS—ticks_mssince bootENC_DEG— AS5600 angle in degrees (ground truth)IMU_DEG— BNO085 angle in degrees (empty until M2)ERR— PID error termP,I,D— Individual PID contributionsPID_OUT— Raw PID output before clamping to motor range (shows PID saturation)M1,M2— Motor throttle values (integers)
Started with direct writes (no RAM buffering) for simplicity. Each record() call writes one line directly to the open file. This is the simplest approach and avoids buffer management complexity. RAM buffering can be added later if SD write latency causes measurable PID jitter.
SD card root/
└── blackbox/
├── log_2026-02-14_15-30-00.csv
├── log_2026-02-14_16-45-12.csv
└── ...
Files use the pattern log_YYYY-MM-DD_hh-mm-ss.csv with the RTC timestamp captured at session start. The blackbox/ subdirectory keeps telemetry logs separate from other data that may be stored on the SD card. Each file gets a CSV header row on creation.
The telemetry pipeline separates data collection from I/O through a facade pattern:
TelemetryRecorder— facade called from the main loop. Accepts all telemetry fields per cycle, handles decimation, and delegates output to a pluggable sink.PrintSink— prints CSV rows to REPL serial console. No buffering needed.SdSink— owns the full SD card lifecycle (mount, file create, write, unmount). Constructed with SPI and RTC pin numbers; reads the RTC once to generate the filename; mounts the SD card; creates the log file. On close, flushes the file and unmounts the SD card.
The SdSink encapsulates all hardware interaction (SPI, SoftI2C, SD card driver, filesystem mount/unmount) so the main loop only deals with pin constants and the sink interface.
Configurable decimation via TELEMETRY_SAMPLE_EVERY constant in main.py:
- Set high (e.g., 10) for REPL output to avoid serial flood
- Set to 1 for SD card logging to capture full-rate data
- Decimation is cycle-count based: every N-th call to
record()emits a row
The upstream sdcard.py from micropython-lib has a bug: the CRC byte's stop bit (bit 0) is not set for commands after CMD0/CMD8. The SD SPI command frame requires the end bit to always be 1 (see SD SPI command format). Most cards are lenient, but some (e.g., Philips SDHC) strictly enforce it and refuse all commands after CMD8. Fix: buf[5] = crc | 0x01 in sdcard.py.
Session telemetry (SD card) is unavailable the moment the code crashes — the SD card may itself be the cause of the failure, or SPI may not yet be initialised. To preserve the exception for diagnosis, a separate crash log is written to the Pico's onboard flash.
- Onboard flash only — SD card is an external peripheral and may be unavailable. Onboard flash is always present and requires no initialisation beyond the standard filesystem already mounted by MicroPython.
- Overwrite strategy — A fixed path
/crash.logis opened in write mode ("w") on every fault, replacing any previous log. This bounds storage consumption to a single file regardless of how many crashes occur. - No external dependencies — The crash handler uses only
sys(forsys.print_exception) andutime(forticks_mstimestamp). No I2C, SPI, GPIO, or sensor access. - Silent failure — The handler wraps its own I/O in a bare
try/except. If flash is corrupt or full, the crash handler does nothing rather than raising a secondary exception that would obscure the original.
ticks_ms: <ms since boot>
Traceback (most recent call last):
File "main.py", line N, in <function>
<ExceptionType>: <message>
ticks_ms gives an approximate indication of how far into the boot sequence or run the crash occurred.
crash_log.py is a standalone module (deployed to Pico root alongside main.py). The top-level entry point in main.py wraps the main() call:
try:
main()
except Exception as e:
write_crash_log(e)
raiseThis catches all exceptions regardless of where they originate — including failures during hardware initialisation before the inner try block inside main(). The exception is still re-raised so the REPL/serial console shows the normal traceback.
- RTC timestamps in filenames enable correlating experiments across days/sessions
- SD card provides practically unlimited storage for extended experiments
- PCF8523 shares the sensor I2C bus — no extra pins needed; read once before loop starts, so no runtime latency impact
- CSV format works directly with pandas, matplotlib, and the existing
analyse_report_rate.pyfrom BNO085 repo SdSinkencapsulates all SD/RTC hardware — main loop stays clean- Direct writes keep the implementation simple; buffering can be added if needed
Telemetry I/O errors (SD write failure, card ejection, filesystem full) are not caught inside the control loop. An exception in SdSink.write() propagates up, exits the PID loop, and the finally block in main() stops the motors.
This is intentional: the test bench exists to collect data for analysis and tuning. If telemetry is not being captured, continuing the run produces no useful output — the lever stabilizing without data is wasted effort. Failing fast also ensures the operator notices the problem immediately rather than discovering a truncated log file later.
A production flight controller would isolate telemetry failures from the control loop. That trade-off does not apply here.
SPI0 pin conflict— Resolved in DR-004 (LCD disconnected, SD card has exclusive SPI0 access).- SPI bandwidth — SD writes take time. If PID jitter is observed after the SPI conflict is resolved, add RAM buffering.
- Power loss — With direct writes, the last few lines may be lost. Acceptable for a test bench.
- SD card reliability — Frequent small writes can wear SD cards. Log files are small (few MB per run), so this is not a practical concern.
- PCF8523 drift — RTC accuracy is ±2 ppm (~1 minute/year). Adequate for filename timestamps; not a precision time source.
sdcard.pyMicroPython driver (from micropython-lib, with stop bit fix applied)- Motor pin reassignment in
main.py(final assignment GPIO 10/11 — see DR-004)