Racing drone framework architecture
Status: Draft Date: 2026-10-23 Related: requirements, state of the art, ADRs
1. Idea: a racing drone is a baseline plugged into fixed slots
Section titled “1. Idea: a racing drone is a baseline plugged into fixed slots”racing-drone contains no algorithms (README: “Algorithms do not live here”). It defines:
- Slots: C++20 concepts for each swappable component role: estimator, gate detector, planner, controller, safety filter, DAA tracker, and FC bridge (ADR 0001 is the registry). The mode manager and pilot arbiter is not a slot. It is fixed FC-resident safety logic (§7).
- Data contracts: the plain structs passed between slots, with timestamps and frames defined once.
- Descriptions: vehicle, course, and scenario files.
- Baselines: named compositions that fill each slot with an implementation from cortex, reflex, or synapse, plus their parameters.
- Harnesses: SITL, replay, HIL, and the flight executable. They run any baseline unchanged (SYS-07).
- Scoreboard: one metrics schema, so every baseline is compared on the same scenarios.
A new racing baseline (a new estimator, a learned controller, an extra sensor) is a new composition plus, where needed, a new component in a platform repo. Nothing else changes.
2. Runtime data flow
Section titled “2. Runtime data flow” nav camera 90 Hz IMU 1-2 kHz RC (CRSF) FPV cam | | | | COMPANION v | | v +--------------+-----------+ | | +-----------+ | perception (cortex + | | | | FPV VTX |--> pilot | synapse) | | | | (+OSD) | goggles | GateNet + DroneNet | | | +-----^-----+ | -> gate corners | | | | MSP | -> intruder detections | | | | DisplayPort +-----+--------------+-----+ | | | | | | | | v v v | | +-----+-----+ +-----+------+ IMU (relayed by FC) | | | estimator | | DAA tracker|<------+ | | | VIO + gate| | IMM-EKF | | | | | PnP + EKF | +-----+------+ | | | +-----+-----+ | intruder | | | | state | predictions | | | v v | | | +-----+--------------+------+ | | | | planner (cortex) | | | | | time-optimal reference, | | | | | perception-aware | | | | +-----+---------------------+ | | | | reference | | | v | | | +-----+---------------------+ | | | | MPCC (reflex) 100-200 Hz | | | | | + DAA separation | | | | | constraints | | | | +-----+---------------------+ | | | | thrust + body rates | | | v | | | +-----+---------------------+ | | | | safety filter (reflex) | | | | | CBF, geofence, envelope | | | | +-----+---------------------+ | | | | serial >= 500 Hz | | | ======|=============================|========================|============|===== FC v (MCU, hard real time) | | | +-----+-----------------------------+------------------------+------------+--+ | mode manager / pilot arbiter (reflex): MANUAL | ASSISTED | AUTONOMOUS | KILL| | attitude estimate, INDI + rate loop 2-8 kHz, mixer, DShot (RPM telemetry) | | failsafes: companion heartbeat, RC loss, kill switch | +-----------------------------------------------------------------------------+ | 4 x ESCRules:
- Hot path. Camera → perception → estimator → MPC → safety filter → FC. It runs in one companion process with pre-allocated, lock-free single-producer queues. There is no middleware hop on the hot path (ADR 0006).
- Off the hot path. Telemetry, logs, OSD content, the ground station, and parameter upload go over Zenoh (cortex ADR 0014).
- The FC never depends on the companion to stay stable (reflex principle). Kill, RC takeover, arming, and the loss-of-companion failsafe are FC-only paths. The geofence, health ladder, and DAA filter need the companion’s estimate, so they run on the companion. Losing the companion triggers the FC failsafe (ADR 0002).
3. Slots
Section titled “3. Slots”Every slot is a concept in namespace branes::racing, declared in <branes/racing/slots/*.hpp> (the same registry as ADR 0001), templated on the scalar type (cortex ADR 0015). Implementations live in platform repos. A sketch:
namespace branes::racing {
template <class E, class T>concept Estimator = requires(E e, const ImuSample<T>& imu, const GateObservations<T>& gates, const CameraFrame& frame, Timestamp now) { e.process_imu(imu); e.process_frame(frame, gates); // gates may be empty (no detection / corrupted frame) { e.state_at(now) } -> std::same_as<StateEstimate<T>>; // delay-compensated (EST-06) { e.health() } -> std::same_as<EstimatorHealth>; // NIS, staleness (SAF-03)};
template <class D, class T>concept GateDetector = requires(D d, const CameraFrame& frame, const CourseMap<T>& map, const StateEstimate<T>& prior) { { d.detect(frame, map, prior) } -> std::same_as<GateObservations<T>>;};
template <class P, class T>concept Planner = requires(P p, const CourseMap<T>& map, const StateEstimate<T>& x) { { p.plan(map, x) } -> std::same_as<Reference<T>>; // may reuse the previous plan};
template <class C, class T>concept Controller = requires(C c, const StateEstimate<T>& x, const Reference<T>& ref, const ConflictSet<T>& conflicts) { { c.step(x, ref, conflicts) } -> std::same_as<ThrustRates<T>>; // MPCC or learned policy};
template <class F, class T>concept SafetyFilter = requires(F f, const StateEstimate<T>& x, const ThrustRates<T>& u, const ConflictSet<T>& conflicts, const Geofence<T>& fence) { { f.filter(x, u, conflicts, fence) } -> std::same_as<ThrustRates<T>>;};
template <class A, class T>concept DaaTracker = requires(A a, const IntruderDetections<T>& det, const StateEstimate<T>& x) { a.update(det, x); { a.conflicts(x) } -> std::same_as<ConflictSet<T>>; // predictions over the horizon};
template <class B, class T>concept FcBridge = requires(B b, const ThrustRates<T>& u, Timestamp now) { b.send(u, now); // also serves as the companion heartbeat { b.poll_imu() } -> std::same_as<std::optional<ImuSample<T>>>; // FC-relayed, FC-timestamped { b.fc_status() } -> std::same_as<FcStatus>; // mode, arming, RC state, link health};
} // namespace branes::racing| Slot | First implementations | Repo |
|---|---|---|
Estimator | GroundTruthEstimator (SITL only), VioGateEkf (MSCKF + gate PnP + drag model), GateEkf (MonoRace-style) | racing-drone (GT), cortex |
GateDetector | OracleGates (SITL projection), GateNetCorners | racing-drone, cortex + synapse |
Planner | WaypointPlanner, TimeOptimalSampler, OfflineCpc | racing-drone (WaypointPlanner, until the cortex planners land in Phase 2), cortex |
Controller | CascadedPid, Mpcc, TrackingNmpc, LearnedPolicy | reflex (+ synapse for the policy); CascadedPid is a racing-drone adapter around the reflex loops |
SafetyFilter | PassThrough, CbfFilter | reflex |
DaaTracker | None, OracleIntruders (SITL), ImmTracker | racing-drone, cortex |
FcBridge | BetaflightBridge (interim), ReflexFcBridge | racing-drone, reflex |
Slots are built by Stack::create() from a SlotContext (the vehicle file, the baseline file, and the course). A slot with a from_context() factory is built from it, and any other slot is default-constructed. A slot that declares process_truth() receives the simulated true state from the SITL log’s truth channel. Only SITL logs carry truth, so such a slot is SITL-only by construction (SYS-02); B0’s GroundTruthEstimator is one (B0 plan).
Each control tick produces three outputs, which the harness logs in pipeline order: the state estimate (state), the planner’s reference (reference), and the command after the safety filter (command). replay --verify compares all three, so it checks the planner directly, not only through the command (reference-log plan).
In SITL the baseline’s FcBridge stays unchanged (SYS-07). The SITL harness takes each tick’s command and hands it to sim::SimFc, the simulated flight controller on the far side of the link, which runs the reflex rate loop and mixer on the plant (SITL plan). SimFc is therefore part of the simulator, not a slot implementation.
A learned controller is a Controller, so it receives the same state and reference and is scored the same way. MonoRace’s motor-level policy bypasses the FC rate loop. It is supported as a Controller with a motor_commands output variant that the FC accepts only in a configured mode.
4. Data contracts
Section titled “4. Data contracts”Defined once in branes::racing::msg. Every struct except CameraFrame is a plain repr(C)-compatible value type, so it can cross the Rust core and Zenoh unchanged (cortex ADR 0010).
CameraFrame is an in-process view only. It holds a non-owning pointer into a driver or Resource Manager buffer, so it never crosses a process boundary or Zenoh, and it is never written to a log as-is. Its wire form is CameraFrameRecord: an owning header (the same timing fields) plus encoded image bytes (raw or compressed, as declared). The explicit conversions are to_record(const CameraFrame&, Encoder&) for logging and telemetry, and view_of(const CameraFrameRecord&) for replay.
| Struct | Contents | Frame / time |
|---|---|---|
ImuSample | Gyro, accelerometer, saturation flags, temperature | Body frame. FC timestamp, mapped to companion time |
CameraFrame | Image view (non-owning, in-process only), exposure start and duration, per-row readout time, corruption flag | Sensor timestamp at mid-exposure |
CameraFrameRecord | Owning wire and log form of CameraFrame: the same header plus encoded bytes | Same |
GateObservations | Per gate: id hypothesis, 4 corners (px) + covariance, mask quality | Image frame |
StateEstimate | p, v, q, ω, biases, covariance, validity time | Course frame (gate-map origin) |
Reference | Time- or progress-parameterized states, gate passing times | Course frame |
ThrustRates | Collective thrust, body rates (or per-motor commands) | Body frame |
IntruderDetections / ConflictSet | Bearing, size, range estimate; predicted tubes over the horizon | Course frame |
5. Descriptions (files)
Section titled “5. Descriptions (files)”vehicles/rp1.yaml mass, inertia, arm geometry, rotor thrust/torque maps, motor time constant, drag coefficients, IMU noise and ranges, camera intrinsics, extrinsics, shuttercourses/a2rl-2025.yaml gates (pose, inner size, order), laps, start pad, flight volume, geofencescenarios/*.yaml course + vehicle + randomization (gate pose noise, lighting, wind, sensor faults, intruder trajectories) + seedbaselines/b2-vio-gate-mpcc.yaml composition: slot -> implementation (compile time) parameters: parameter files (load time)Each baseline file becomes its own build targets (flight_stack_<name>, replay_<name>, sitl_<name>). The composition hash is checked at startup. ADR 0001 has the mapping rules.
One loader validates every file against a schema at startup. A config change means a restart, with no live reconfiguration (cortex ADR 0005).
6. Compute and timing map
Section titled “6. Compute and timing map”| Domain | Task | Rate | Hardware |
|---|---|---|---|
| FC | IMU sampling, gyro filtering, INDI + rate loop, mixer, DShot, RC, failsafes, attitude estimate | 2–8 kHz | STM32H7 |
| Companion RT core | IMU propagation, estimator, MPC, safety filter, FC link | IMU rate / 100–200 Hz | CPU (isolated core, SCHED_FIFO) |
| Companion accelerator | GateNet + DroneNet (shared backbone) | 90 Hz | GPU / KPU via synapse |
| Companion best effort | VIO front end, planner replan, DAA tracker | 30–90 Hz | CPU |
| Companion background | Replay recorder (drains the per-channel rings to NVMe, ADR 0006), Zenoh telemetry, OSD composition | 10–50 Hz | CPU |
7. Mode manager and pilot arbitration
Section titled “7. Mode manager and pilot arbitration”The arbiter lives on the FC (reflex), because it must work when the companion is dead. It is configured with a profile. In the development profile everything below is enabled. In the league profile, stick takeover and ASSISTED are disabled during an autonomous heat, and only the kill switch is honored (FPV-08).
arm switch autonomy switch + preflight OKDISARMED ----------------> MANUAL ------------------------------------> AUTONOMOUS ^ ^ ^ | | | kill (any state) | +---- pilot takeover (switch or stick) ----+ | +-------------------------+ | +--- HOLD <--- estimator unhealthy / geofence ---+ | v LAND --> DISARMED- Takeover. Moving the stick beyond the deadband in
AUTONOMOUShands control to the pilot (configurable). The rate-loop integrators are carried over, so the transfer is bumpless (FPV-04). - ASSISTED. Pilot setpoints pass through the companion’s safety filter, so the envelope and DAA apply to human flight.
8. DAA layering
Section titled “8. DAA layering”camera --> DroneNet --> ImmTracker --> ConflictSet --+--> MPCC constraints (strategic: keep racing) | +--> CBF safety filter (tactical: last-resort separation) | +--> OSD alert to pilot (situational awareness)The CBF filter runs every control cycle in bounded time. It changes the MPC output only when the predicted separation would fall below the threshold, so a stalled or diverged MPC does not disable the tactical layer.
There is no runtime guarantee of separation. The target is statistical (DAA-05, measured in simulation), and it holds only under the assumptions in ADR 0005.
Conflict-data contract
Section titled “Conflict-data contract”The tracker runs best-effort at camera rate (30–90 Hz); the safety filter runs on the RT core at 100–200 Hz. So every ConflictSet carries its valid_at timestamp and a tracker_ok flag, and the filter applies these rules:
| Condition | Safety filter behavior |
|---|---|
No ConflictSet received yet (startup, or after a tracker restart), in baselines with a DaaTracker other than None | Initial state DAA_INIT, handled like a stale empty set (below): speed capped at v_daa_unavailable, DAA reported unavailable. The AUTONOMOUS launch is held on the pad until 3 consecutive fresh tracker_ok sets have arrived (a SAF-06 preflight item). After a restart in flight, the stale-data rules apply from the moment of the restart |
Age ≤ 25 ms (≈ 2 camera frames) and tracker_ok == true | Use as is. Predictions are propagated to the current time |
25 ms < age ≤ 100 ms, or age ≤ 100 ms with tracker_ok == false (the tracker missed its budget) | Use the last set, with each intruder tube inflated by ½·a_max·age² (a_max from the intruder model). Raise the OSD alert. Log the event |
| Age > 100 ms with conflicts present in the last fresh set | Conservative mode. Cap the commanded speed so that the ownship can stop before the last-known conflict region. Request HOLD if no fresh set arrives within 0.5 s |
| Age > 100 ms with an empty last set | Conservative mode as well. A stale empty set does not show that no intruder is present; it can come from a tracker failure. Cap the commanded speed at v_daa_unavailable (configured; default 5 m/s). Capping the ownship’s speed does not bound the closing speed of a crossing intruder. So while DAA is unavailable, the DAA-06 envelope and the DAA-05 target do not apply (DAA-06 says so explicitly), and the cap only reduces the ownship’s share of any closing speed. Report DAA as unavailable on the OSD and in telemetry. Request HOLD if no fresh set arrives within 0.5 s |
| A fresh set arrives after conservative mode | Leave conservative mode, provided the tracker has been tracker_ok for at least 3 consecutive sets |
| Fresh, empty set | No known intruders. This says nothing about undetected intruders |
Precedence. The rows are evaluated top to bottom, and the age-over-100 ms rows take precedence whatever the value of tracker_ok, so exactly one row applies. A tracker deadline miss counts as stale data. It never blocks the safety filter. Baselines whose DaaTracker is None (B0–B3) have no ConflictSet and skip this table entirely: no DAA_INIT, no launch hold.
9. Repository layout
Section titled “9. Repository layout”racing-drone/├── cmake/deps.cmake # pins: cortex, reflex, synapse, MTL5, Universal, Catch2 (same pattern as siblings)├── sdk/include/branes/racing/│ ├── slots/ # the concepts in §3│ ├── msg/ # the data contracts in §4│ ├── config/ # vehicle / course / scenario / baseline loaders│ ├── mission/ # race state machine: preflight, arm, start, gate sequence, finish, land│ └── compose/ # baseline wiring (compile-time composition)├── baselines/ # one YAML + optional glue per baseline (b0 … b4)│ └── glue/ # slot adapters around platform components (CascadedPid → reflex)├── vehicles/ courses/ scenarios/├── sim/ # SITL harness: reflex dynamics + renderer bridge + sensor models + fault injection├── apps/│ ├── flight_stack/ # companion executable (runs any baseline)│ ├── replay/ # log replay through any baseline│ └── ground_station/ # Zenoh telemetry, parameter upload├── fc/ # FC integration: Betaflight bridge first, reflex firmware later├── tools/scoreboard/ # metrics → comparison tables / plots├── tests/ # unit, component, scenario (Catch2; scenario tests drive the SITL harness)└── docs/ # requirements, assessments, arch, plan, adr, sessionsNamespace branes::racing, CMake target branes::racing. C++20, the same CMake presets and CI pattern as the sibling repos.