Test bench for learning flight control systems, built around a Raspberry Pi Pico 2. Experiment with sensor fusion, control loops, and motor control in a controlled single-axis environment before applying concepts to real drones.
- Raspberry Pi Pico 2 — Main microcontroller (RP2350, dual-core)
- AS5600 Magnetic Encoder — 12-bit absolute position at pivot (ground truth reference, ~0.088° resolution)
- BNO085 IMU — 9-axis IMU with onboard sensor fusion; game rotation vector (GRV, 0x08) for outer angle loop, calibrated gyroscope (0x02) for inner rate loop
- 2x Drone Motors + ESCs — DShot600 protocol via PIO, mounted on opposite ends of lever
- Pimoroni Pico Display Pack — Buttons + RGB LED only; LCD disconnected (see DR-004)
- Adafruit Micro SD SPI Breakout (#4682) + Adafruit PCF8523 RTC Breakout (#5189) — MicroSD + RTC for black box telemetry logging
- Power Distribution Board — Motor power supply
Carbon fiber drone frame (10 g) on a 3D-printed PETG subframe (8 g) forming a swinging lever pivoting around a central axis. Motor shaft separation: 6.5 cm. Frame has 4 motor mounts; currently 2 motors installed (one each end), 2 more planned. Two drone motors produce downward thrust (inverted for safety — bench can't fly off the desk). Differential thrust creates torque to control lever angle. Mechanical range is approximately ±50°.
Previous frame (retired 2026-04): 125 g aluminum profile + PLA parts, 18.5 cm motor separation. ~7× heavier, ~3× longer arm.
Single PI(D) loop at 50 Hz using AS5600 encoder feedback. Differential thrust mixer for 2 motors. Lever holds at 0° within ±3°. See DR-001.
PID input switched from AS5600 encoder to BNO085 game rotation vector (gyro+accel, no magnetometer). Roll angle extracted from quaternion via single atan2. AS5600 encoder retained as telemetry-only ground truth. Telemetry stores encoder angle as a direct scalar (degrees) and raw IMU quaternion for offline analysis. See DR-005.
BNO085 sensor calibration (accel, gyro, magnetometer) and all-axes tare completed and saved to flash. Calibration + tare reduced MAE from 22 deg to 2.87 deg and eliminated limit cycle oscillation. See BNO085 ADR-004.
Timestamped CSV logging to SD card via SD card breakout + PCF8523 RTC breakout. Each run creates a folder (/sd/flights/YYYY-MM-DD_hh-mm-ss/) containing log.csv and config.json (raw copy of the deployed config — source of truth for analysis). ticks_ms provides precise row timing. See DR-002.
Motor pins reassigned from GPIO 4/5 → 6/7 → 10/11 (freeing GPIO 4/5 for PCF8523 RTC, then RGB LED pins).
Extracted base ± output motor mapping into LeverMixer class (mixer.py). Makes code drone-topology-agnostic — swap a mix table to support different frame types (2-motor lever, quadcopter X-frame, etc.). Also reorganized telemetry into telemetry/ package.
Depends on: M1 (pure code refactor, no hardware dependency).
Two nested PID loops replacing the single angle PID. Outer angle loop (50 Hz) computes a desired rotation rate from GRV quaternion (game rotation vector, drift-free); inner rate loop (200 Hz) tracks that rate using calibrated gyroscope angular velocity. Initial sensor choice (GIRV) was replaced after hardware testing revealed ~1.5°/min gyro integration drift; see DR-010. Both loops run in a single main loop with iteration-counter gating.
Baseline result (2026-02-22, 77s run with active disturbance): 0.36° MAE, 0.09 Hz oscillation frequency, zero windup events, Pearson r=0.999. 3× improvement over prior baseline (1.12° MAE, 2026-02-20) following BNO085 re-calibration, correct tare procedure, and AXIS_CENTER correction (422→411→406).
Post-baseline improvements (2026-02-22): precision 3D-printed jig established true AXIS_CENTER=406, lever mechanically balanced, ki=0.05 with iterm_limit=5 added to angle PID to compensate for residual imbalance and slow GRV drift. Result: 0.00 Hz oscillation, symmetric motor output, lever holds true horizontal.
Depends on: M2 (IMU as input), M2a (telemetry to validate improvement), M3 (clean mixer).
Replaced 125 g aluminum profile + PLA parts (18.5 cm arm) with a 10 g carbon fiber drone frame + 8 g PETG subframe (6.5 cm motor separation). ~7× mass reduction, ~3× shorter arm. Frame has 4 motor mounts; 2 installed, 2 more planned.
First post-rebuild test run (2026-04-03): MAE=30.09°, Pearson r=−0.87 (inverted tracking). Root cause: IMU axis reorientation with new frame. AXIS_CENTER recalibrated to 275.
Depends on: hardware fabrication.
Full retuning required after mechanical rebuild invalidated all previous gains. Key finding: the controller must satisfy a force budget — angle_kp × start_angle_error ≥ ANGLE_RATE_LIMIT — or the outer loop never saturates the rate limit and produces insufficient differential thrust to lift from the restrictor. The frame is precisely balanced; the ~18 g of resistance at −59° is wire tension (cables routed outside the rotation axis, no slip ring) plus bearing friction — a roughly constant force, not gravity-dependent. Thrust slope: ~0.147 g/DShot unit at BASE=600.
Empirical thrust data (BetaFPV Lava 1104 7200KV, single motor):
| DShot value | Throttle % | Thrust |
|---|---|---|
| 200 | ~24% | 11 g |
| 500 | ~30% | 31 g |
| 600 | ~35% | 45 g |
| 800 | ~47% | 75 g |
Systematic tuning steps: angle_kp 1.0→3.5, angle_kd 0.1→0.3 (reduced oscillation 0.27→0.05 Hz), iterm_limit 30→100 (max I contribution 1.5→5 deg/s). BASE=500 tested and rejected — thrust curve asymmetry below BASE=600 reduces available differential from 19.5 g to 13.5 g.
Baseline (run 2026-04-07_16-19-21, 141.8s):
| Metric | Value |
|---|---|
| Time to reach horizontal | 1.3 s |
| HoldMAE | 6.90° |
| Time at horizontal | 124.8 s |
| Oscillation freq | 0.05 Hz |
| Gains | angle kp=3.5 ki=0.05 kd=0.3 iterm_limit=100; rate kp=0.5 kd=0.003 |
Additional findings: IMU tare quality with precision jig + bubble level is ~0.10° residual — not the limiting factor. GRV dynamic lag (~0.8°) is the sensor floor independent of tare quality. Power supply limit (30W) requires LiHV batteries at BASE≥600; ~25s per charge.
See decision/DR-008-cascaded-pid.md Amendment 2026-04-07.
A bimodal settling mode was present in ~1/3 of runs: the lever reached setpoint but then ring-down took 79–91s (goal: ≤60s hold). Root cause: the angle PID I-term accumulated 10–29 deg/s during the approach with iterm_limit=100; at setpoint crossing where P≈0, the residual drove overshoot ≥19%, triggering a slow ring-down. Fix: angle_kd 0.3→0.5, angle_pid.iterm_limit 100→5. Hold I-term equilibrium (1–3 deg/s) is well below 5, so hold quality is unaffected. Expo=0.3 was tested and rejected (worsened overshoot by removing P-term approach braking without touching the I-term).
Confirmed baseline (3 consecutive flights, 2026-05-14):
| Metric | Value |
|---|---|
| T_s (settling time) | 17–26 s |
| Overshoot | 11–14% |
| HoldMAE_s (post-settle) | 1.9–3.3° |
| Gains | angle kp=3.0 ki=0.05 kd=0.5 iterm_limit=5; rate kp=0.5 kd=0.009 |
See decision/DR-008-cascaded-pid.md Amendment 2026-05-14 and tuning/2026-05-14-reduce-oscillation-hold-60s.md.
Depends on: mechanical rebuild, M4.
Add roll and/or yaw axes. Requires either mechanical modifications to the bench or moving to an actual drone frame.
Depends on: M3 (mixer), M4 (cascaded PID), mechanical rebuild.
Rare OSError: [Errno 5] EIO on imu.update_sensors() during a run. First captured 2026-02-23 (ticks_ms=163752, ~2 min 44 s into run). Likely cause: BNO085 internal firmware assert or watchdog reset leaving the I2C bus in an inconsistent state. Crash log written to /crash.log on onboard flash. Needs dedicated uptime / stability test runs to reproduce reliably before a fix is designed.
PID.compute now accepts an optional measurement parameter. When provided, D is computed
from −d(measurement)/dt instead of d(error)/dt, avoiding derivative kick when the setpoint
steps. The inner rate loop passes measurement=gyro_x. The outer angle loop is unaffected
(setpoint is a constant 0°, so error derivative already equals measurement derivative).
Expo was tested twice (2026-04-10 at expo=0.1; 2026-05-02 and 2026-05-14 at expo=0.3). Both attempts worsened hold quality. The root cause: expo reduces P-term authority near setpoint (approach braking) without affecting the I-term. When the slow-settle mode was I-term driven (the actual root cause identified 2026-05-14), expo removed the one mechanism that could slow the lever before crossing — net effect was more overshoot, not less. See decision/DR-012-thrust-expo.md.
Actuator nonlinearity compensation (per-motor thrust curve inversion) remains a valid future item once DR-007 thrust measurements exist, but is separate from gain scheduling.
The symptom this item described — post-disturbance overshoot from I-term accumulation — was the root cause of the bimodal slow-settle mode. Resolved by reducing iterm_limit from 100 to 5, which caps approach-phase accumulation at ≤5 deg/s without freezing integration. Hold I-term equilibrium (1–3 deg/s) is well within the new limit. No code change required. See tuning/2026-05-14-reduce-oscillation-hold-60s.md.
When an unhandled exception exits the control loop, disarm() is never reached and telemetry.end_session() is not called. The SD file is left open/unflushed; the last buffered rows may be lost. Fix: call end_session() from the crash path in main(), guarded so it does not raise if telemetry was never initialised.
See DR-001, "Test Bench vs Real Drone" section for a detailed comparison covering: single axis vs three axes, single PID vs cascaded PIDs, encoder vs IMU, and fixed pivot vs free flight.
├── src/ # Authored flight control source (deployed to Pico)
│ ├── main.py # Entry point — runs on boot
│ ├── pid.py # PID controller with anti-windup and term introspection
│ ├── mixer.py # LeverMixer — differential thrust for 2-motor lever
│ ├── ui.py # Operator interface — buttons + RGB LED
│ └── telemetry/
│ ├── recorder.py # TelemetryRecorder, SdSink
│ └── sdcard.py # SD card SPI driver (micropython-lib, with stop bit fix)
├── dependencies/
│ ├── AS5600/ # Git submodule: github.com/c0ffee2code/AS5600
│ ├── BNO085/ # Git submodule: github.com/c0ffee2code/BNO085
│ ├── DShot/ # Git submodule: github.com/c0ffee2code/DShot
│ └── PCF8523/ # Git submodule: github.com/c0ffee2code/PCF8523
├── .claude/
│ └── skills/
│ └── analyse-flight/ # Analysis pipeline for a single flight run
│ ├── scripts/
│ │ ├── smoke.py # Validate run folder before analysis
│ │ ├── plots.py # Generate all diagnostic figures
│ │ ├── score_flight.py # KPI pass/fail gate
│ │ └── profile_flight.py # Deep-dive statistical profile
│ └── templates/
│ └── flight_analysis.md # Structured report template
├── tools/ # Pico utilities (upload to Pico, not used on desktop)
│ └── tare_and_measure.py # IMU tare calibration with before/after comparison
├── test_runs/ # Copied run folders from SD card for offline analysis
├── decision/ # Architecture Decision Records
└── resources/ # Datasheets, protocol docs
Clone with submodules:
git clone --recurse-submodules <repo-url>Upload to Pico root (flat structure — no subdirectories on Pico):
src/main.pysrc/pid.pysrc/mixer.pysrc/ui.pysrc/telemetry/recorder.py(asrecorder.py)src/telemetry/sdcard.py(assdcard.py)dependencies/AS5600/driver/as5600.pydependencies/BNO085/driver/bno08x.py+dependencies/BNO085/driver/i2c.pydependencies/DShot/driver/dshot_pio.py+dependencies/DShot/driver/motor_throttle_group.py
See individual submodule repositories for their licenses.