OFFBOARD == true AND race_started_event received AND state == ARMED_IDLE.
Transitions to state ← LAUNCH.
MAVLink messages the controller emits (with field-by-field bitmask and units),
the exit condition that releases it, and the failure-recovery path. Built on the
/race-pq course
(1 start · 6 std · 2 double-gate pairs · 1 split-S stacked pair · 1 dive gate · 1 finish = 11 unique gates × 2 laps = 22 crossings, ~170 m).
Pipeline runs onboard the Jetson Orin NX. Each stage emits to the next at a fixed cadence. The flight controller (FC) handles the inner loops (rate, attitude); we communicate via OFFBOARD mode — the FC accepts external setpoints over MAVLink as long as we keep streaming above 2 Hz.
Total budget: JPEG (3) + YOLO (7) + PnP (2) + planner (1) + controller (2) + comms (3) = 18 ms from photon to motor command — leaves ~15 ms slack inside the 33 ms frame budget.
Each stage detailed below — §1.1 through §1.6 — with reference code, data flow, and failure modes.
udp://0.0.0.0:5600 · RTP/JPEG payload type 26ndarray(360, 640, 3) · BGR uint8 (cv2 convention)The sim emits H.264 or raw JPEG over RTP. We use GStreamer with the nvv4l2decoder element on Orin NX to hand off decode to the GPU, then push frames into a Python appsink bound to a queue the perception thread reads from.
import gi, numpy as np, queue, time gi.require_version("Gst", "1.0") from gi.repository import Gst, GLib Gst.init(None) PIPELINE = ( "udpsrc port=5600 caps=\"application/x-rtp,encoding-name=JPEG,payload=26\" " "! rtpjitterbuffer latency=10 " # 10 ms buffer → drops late pkts "! rtpjpegdepay " "! nvv4l2decoder mjpeg=1 " # NVJPEG · GPU-side decode "! nvvidconv ! video/x-raw,format=BGRx " # NV12 → BGRx (CUDA copy) "! videoconvert ! video/x-raw,format=BGR " # BGRx → BGR (one CPU copy) "! appsink name=sink emit-signals=true sync=false drop=true max-buffers=2" ) class FrameSource: def __init__(self, queue_size=2): self.q = queue.Queue(maxsize=queue_size) self.pipe = Gst.parse_launch(PIPELINE) sink = self.pipe.get_by_name("sink") sink.connect("new-sample", self._on_sample) self.pipe.set_state(Gst.State.PLAYING) def _on_sample(self, sink): sample = sink.emit("pull-sample") buf, caps = sample.get_buffer(), sample.get_caps() ok, mi = buf.map(Gst.MapFlags.READ) if not ok: return Gst.FlowReturn.ERROR try: arr = np.frombuffer(mi.data, dtype=np.uint8).reshape(360, 640, 3) ts_ns = buf.pts # RTP-derived presentation ts try: self.q.put_nowait((arr.copy(), ts_ns)) except queue.Full: _ = self.q.get_nowait() # drop oldest, keep latest self.q.put_nowait((arr.copy(), ts_ns)) finally: buf.unmap(mi) return Gst.FlowReturn.OK
The perception thread runs at YOLO's speed (~140 fps capability), but we cap at 30 fps to match the sim feed. If perception ever stalls (a CUDA hiccup, a long GC pause), we'd rather skip a frame than feed the controller stale state. drop=true on the appsink and the explicit drop-oldest in _on_sample guarantee the controller sees the freshest ±33 ms photon.
pull-sample in try/except and re-queue prev frame.BGR uint8 (360,640,3) · 30 Hz from §1.1apex_yolo11n_pose.engine · ~5.4 MB · INT8 weights, FP16 activations[TL, TR, BR, BL] in image px · visibility 0/1/2List[Detection(cls, conf, bbox, kpts)] · max_det=8We bypass the high-level Ultralytics Python loop in production — too much overhead in predict() for the per-frame call. Direct ICudaEngine + execute_async_v3 shaves ~2 ms vs. the wrapped path.
import tensorrt as trt, pycuda.driver as cuda, numpy as np, cv2 class YoloPose: INPUT_SIZE = 640 CONF_THR = 0.18 # Neyman-Pearson: FN cost ≫ FP cost IOU_THR = 0.45 MAX_DET = 8 def __init__(self, engine_path): logger = trt.Logger(trt.Logger.WARNING) with open(engine_path, "rb") as f: self.engine = trt.Runtime(logger).deserialize_cuda_engine(f.read()) self.ctx = self.engine.create_execution_context() self.stream = cuda.Stream() # Pinned host buffers + device bindings — allocated ONCE at startup in_shape = (1, 3, self.INPUT_SIZE, self.INPUT_SIZE) out_shape = (1, 21, 8400) # 5 classes + 4 kpts × (x,y,v) + bbox self.h_in = cuda.pagelocked_empty(int(np.prod(in_shape)), np.float16) self.h_out = cuda.pagelocked_empty(int(np.prod(out_shape)), np.float16) self.d_in = cuda.mem_alloc(self.h_in.nbytes) self.d_out = cuda.mem_alloc(self.h_out.nbytes) self.ctx.set_tensor_address("images", int(self.d_in)) self.ctx.set_tensor_address("output0", int(self.d_out)) def infer(self, bgr): # 1. Letterbox to 640×640, normalize, NCHW, FP16 img, scale, pad = self._letterbox(bgr, self.INPUT_SIZE) rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float16) / 255.0 np.copyto(self.h_in, rgb.transpose(2, 0, 1).reshape(-1)) # 2. Forward (async, single stream) cuda.memcpy_htod_async(self.d_in, self.h_in, self.stream) self.ctx.execute_async_v3(self.stream.handle) cuda.memcpy_dtoh_async(self.h_out, self.d_out, self.stream) self.stream.synchronize() # ~4.2 ms on Orin NX # 3. Decode, NMS, scale-back to original image coords out = self.h_out.reshape(21, 8400).T boxes, scores, classes, kpts = self._decode(out, self.CONF_THR) keep = self._nms(boxes, scores, self.IOU_THR, self.MAX_DET) return self._rescale(boxes[keep], scores[keep], classes[keep], kpts[keep], scale, pad)
YOLO11n-pose extends YOLO11n's backbone (CSPDarknet · 2.6M params) with a parallel pose head sharing the FPN features. The pose head outputs 4 × (x, y, visibility) per detection — for gates we map these to the four corners in clockwise order starting top-left.
FP16 quantization on Orin NX's GPU tensor cores buys us a clean 1.9× over FP32 with < 0.4 mAP50 loss on the synthetic-PQ validation set. INT8 was tested but the post-train-quant version dropped 2.1 mAP — not worth the gain given we're already inside the latency budget.
Default NMS suppresses by class. For our gate classes that's wrong: std and splits-upper can overlap during the split-S pre-stage. We run class-agnostic NMS on bbox IoU but break ties by keypoint visibility average — the box whose corners are most confidently visible wins.
def _nms(boxes, scores, kpts, iou_thr=0.45, max_det=8): order = np.argsort(scores)[::-1] keep = [] while len(order) > 0 and len(keep) < max_det: i = order[0] keep.append(i) ious = box_iou(boxes[i], boxes[order[1:]]) # Tie-break: if IoU is borderline, keep the one with higher kpt visibility vis = kpts[order[1:], :, 2].mean(axis=1) suppress = (ious > iou_thr) & (vis < kpts[i, :, 2].mean()) order = order[1:][~suppress] return np.array(keep)
visibility=0. PnP downgrades to 3-point IPPE — slightly higher reprojection error but stable.Gate corners in the gate's own frame are known to mm precision: a flat square of half-width w/2 (1.5 m std / 2.7 m dgate / split). With 4 coplanar points + intrinsics, OpenCV's SOLVEPNP_IPPE_SQUARE gives us a closed-form solution in ~80 μs · no iteration · single ambiguity (front/back) we resolve with the sign of the projected normal.
import cv2, numpy as np # VADR-TS-002 §3.7 — cam intrinsics, fixed and known K = np.array([[320.0, 0.0, 320.0], [0.0, 320.0, 180.0], [0.0, 0.0, 1.0]], dtype=np.float32) DIST = np.zeros(5, dtype=np.float32) # sim camera is rectilinear GATE_HALF_W = { # meters · per VADR-TS-002 §2.4 "std": 0.75, "dgate": 1.35, "splits": 1.35, "dive": 0.75, } def gate_pose(kpts_px, gate_class): """kpts_px: ndarray(4,2) image pixels · [TL, TR, BR, BL] clockwise. Returns (R_cw, t_cw) — gate-corner frame in camera frame.""" half = GATE_HALF_W[gate_class] obj = np.array([ [-half, half, 0], # TL [ half, half, 0], # TR [ half, -half, 0], # BR [-half, -half, 0], # BL ], dtype=np.float32) ok, rvec, tvec = cv2.solvePnP( obj, kpts_px.astype(np.float32), K, DIST, flags=cv2.SOLVEPNP_IPPE_SQUARE, ) if not ok: return None R, _ = cv2.Rodrigues(rvec) # Reprojection error — drop if > 4 px RMS (degenerate keypoints) proj, _ = cv2.projectPoints(obj, rvec, tvec, K, DIST) err = np.linalg.norm(proj.reshape(-1, 2) - kpts_px, axis=1).mean() if err > 4.0: return None return R, tvec.flatten()
State vector (12-d):
x = [p_n, p_e, p_d, # NED position (m) v_n, v_e, v_d, # NED velocity (m/s) φ, θ, ψ, # roll, pitch, yaw (rad) b_gx, b_gy, b_gz] # gyro bias (rad/s)
Propagation (IMU @ 100 Hz): integrate accel + gyro through the rigid-body model, with attitude update using small-angle quaternion math (no normalization drift inside a single frame).
Update (vision @ 30 Hz): given a gate's known WORLD pose (from the course map loaded at startup) and the camera-frame pose from PnP, we get a constrained measurement of the drone's pose:
def measurement_update(self, R_cw, t_cw, gate_world_pose): """R_cw, t_cw: gate in cam frame (from PnP) gate_world_pose: gate in world NED (from course map)""" R_gw, t_gw = gate_world_pose # Camera ⊕ gate ⊕ world → drone pose in world # T_wd = T_wg @ T_gc @ T_cd (cd = inverse of body→cam = fixed extrinsics) R_cd, t_cd = self.cam_extrinsics # cam→body R_wd = R_gw @ R_cw.T @ R_cd t_wd = R_gw @ (-R_cw.T @ t_cw) + R_gw @ (-R_cw.T @ R_cd @ -t_cd) + t_gw # Innovation in pos + att z = np.concatenate([t_wd, rot2eul(R_wd)]) # 6-vec measurement H = self.measurement_jacobian() # 6×12 y = z - H @ self.x S = H @ self.P @ H.T + self.R_vision K_gain = self.P @ H.T @ np.linalg.inv(S) self.x += K_gain @ y self.P = (np.eye(12) - K_gain @ H) @ self.P
P trace grows past 5 m² with no vision updates for > 500 ms, fall back to dead-reckoning + a SEEK behavior to reacquire.y spikes by 2π.Quadrotors are differentially flat in (x, y, z, ψ) — meaning any smooth-enough trajectory in those four variables uniquely determines all body rates and thrust. Mellinger & Kumar showed that minimizing the integral of jerk (derivative of acceleration) over the trajectory produces flyable, energy-efficient paths. The closed-form solution is a piecewise degree-5 polynomial per segment.
import numpy as np def min_jerk_segment(p0, v0, a0, pf, vf, af, T): """Closed-form min-jerk polynomial p(t), t∈[0,T]. Boundary conditions on p, v, a at both endpoints (6 constraints → degree 5).""" # Solve A @ coeffs = b where coeffs = [c0..c5] A = np.array([ [1, 0, 0, 0, 0, 0 ], [0, 1, 0, 0, 0, 0 ], [0, 0, 2, 0, 0, 0 ], [1, T, T**2, T**3, T**4, T**5 ], [0, 1, 2*T, 3*T**2, 4*T**3, 5*T**4 ], [0, 0, 2, 6*T, 12*T**2, 20*T**3 ], ]) b = np.array([p0, v0, a0, pf, vf, af]) return np.linalg.solve(A, b) # coeffs · 6-vec per axis class RacePlanner: LOOKAHEAD = 3 # gates ahead V_CRUISE = 12.0 # m/s — COMPLETION mode (VQ1) V_ATTACK = 22.0 # m/s — ATTACK mode (when ranked) def replan(self, x_drone, gate_stack, mode="completion"): """Produce next-3-gate reference trajectory.""" v_target = self.V_ATTACK if mode == "attack" else self.V_CRUISE knots = [x_drone.pos] + [g.center for g in gate_stack[:self.LOOKAHEAD]] # Per-segment time from gate spacing / cruise speed (re-time after fitting) segs = [] v_prev, a_prev = x_drone.vel, np.zeros(3) for i in range(len(knots) - 1): d = np.linalg.norm(knots[i+1] - knots[i]) T = d / v_target # first-order time estimate v_dir = (knots[i+1] - knots[i]) / d v_next = v_dir * v_target if i < len(knots) - 2 else np.zeros(3) coeffs = np.array([ min_jerk_segment(knots[i][k], v_prev[k], a_prev[k], knots[i+1][k], v_next[k], 0, T) for k in range(3) ]) # 3 axes × 6 coeffs segs.append(Seg(coeffs, T, t0=sum(s.T for s in segs))) v_prev, a_prev = v_next, np.zeros(3) return Trajectory(segs, knots)
The base min-jerk path goes through gate centers. For three gate types we override this with a pre-positioning leg:
dist < 6 m: insert a knot at (splits-upper.center + 0.5 m above) — sets up the wingover entry.dist < 12 m: insert a knot 4 m above the dive gate's top edge — gives the dive its vertical drop room.Yaw is planned independently of position. Default: point at the next gate center. But when the next gate's expected projected area in the image is < 8% (far/oblique), bias toward the gate-after-next so the camera is already on it when the planner pops the next knot.
def yaw_setpoint(self, drone_pos, g0, g1): proj_frac = self.projected_area_fraction(g0, drone_pos) look_at = g1.center if proj_frac < 0.08 else g0.center dx, dy = look_at[1] - drone_pos[1], look_at[0] - drone_pos[0] return np.arctan2(dx, dy) # NED yaw
The position loop produces a velocity setpoint that the FC's inner loop tracks. Single P gain per axis (no I, no D — the FC handles steady-state, we just inject the slope).
from pymavlink import mavutil import numpy as np, time # SET_POSITION_TARGET_LOCAL_NED bitmask · use position + velocity + yaw TYPEMASK_POSVELYAW = ( 0 # 0–2: use x,y,z | 0 # 3–5: use vx,vy,vz | (0b111 << 6) # 6–8 : ignore afx,afy,afz | 0 # 9 : use yaw | (1 << 10) # 10 : ignore yaw_rate ) class RaceController: KP_POS = np.array([1.4, 1.4, 2.0]) # N, E, D — heavier on altitude V_MAX = np.array([30.0, 30.0, 8.0]) def __init__(self, fc_uri="udp:127.0.0.1:14540"): self.fc = mavutil.mavlink_connection(fc_uri) self.fc.wait_heartbeat() self.boot_t = time.monotonic() def tick(self, drone_state, traj, t): ref_p, ref_v, ref_yaw = traj.sample(t) # Outer P · velocity ff from trajectory err_p = ref_p - drone_state.pos cmd_v = self.KP_POS * err_p + ref_v cmd_v = np.clip(cmd_v, -self.V_MAX, self.V_MAX) self.fc.mav.set_position_target_local_ned_send( int((time.monotonic() - self.boot_t) * 1e3), self.fc.target_system, self.fc.target_component, mavutil.mavlink.MAV_FRAME_LOCAL_NED, TYPEMASK_POSVELYAW, ref_p[0], ref_p[1], ref_p[2], # x, y, z cmd_v[0], cmd_v[1], cmd_v[2], # vx, vy, vz 0, 0, 0, # afx, afy, afz (ignored) ref_yaw, 0, # yaw, yaw_rate (ignored) )
For the wingover at the top of the split-S we leave SET_POSITION_TARGET and switch to ATTITUDE_TARGET (#82) — we need to command an inverted attitude and a specific roll rate that the position controller's solver wouldn't pick on its own. The transition is a single mode-flag change in tick():
def tick_splits_wingover(self, drone_state): # Target: 180° roll over 0.4 s, pitch -10°, full throttle held target_q = quat_from_eul(np.pi, np.deg2rad(-10), drone_state.yaw) roll_rate = np.pi / 0.4 # rad/s · constant during the flip TYPEMASK = 0b00000000 # use q + body rates + thrust self.fc.mav.attitude_target_send( int((time.monotonic() - self.boot_t) * 1e3), self.fc.target_system, self.fc.target_component, TYPEMASK, [target_q[0], target_q[1], target_q[2], target_q[3]], roll_rate, 0.0, 0.0, thrust=0.92, # preserve altitude during inversion )
The trajectory sampler is interpolating polynomial coefficients — cheap. The bottleneck is MAVLink encode + UDP send (~120 μs round trip on Orin). We run at 100 Hz because:
OFFBOARD must be enabled by us, not by the GCS — and PX4 will only accept the mode switch if we're already streaming setpoints. Order matters:
def arm_offboard(fc): """Stream-then-arm sequence per PX4 v1.15 safety check.""" boot_t = time.monotonic() # 1. Pre-stream 1 second of zero setpoints (drone is on the ground) for _ in range(100): fc.mav.set_position_target_local_ned_send( int((time.monotonic() - boot_t) * 1e3), fc.target_system, fc.target_component, mavutil.mavlink.MAV_FRAME_LOCAL_NED, 0b110111111000, # pos only, hold ground 0, 0, 0, # x,y,z 0, 0, 0, # vx,vy,vz 0, 0, 0, # afx,afy,afz 0, 0, # yaw, yaw_rate ) time.sleep(0.01) # 2. Switch to OFFBOARD (custom_mode magic for PX4) fc.mav.command_long_send( fc.target_system, fc.target_component, mavutil.mavlink.MAV_CMD_DO_SET_MODE, 0, mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, 6, # PX4 main_mode OFFBOARD 0, 0, 0, 0, 0, ) # 3. Arm fc.mav.command_long_send( fc.target_system, fc.target_component, mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, 0, 1, 0, 0, 0, 0, 0, 0, ) # 4. Wait for HEARTBEAT.base_mode & MAV_MODE_FLAG_SAFETY_ARMED while not (fc.wait_heartbeat().base_mode & 0x80): time.sleep(0.01)
Inside the FC, our SET_POSITION_TARGET goes through three layers before it becomes PWM:
The mixer then maps these to per-motor PWM duty cycles (one DSHOT600 packet per motor per cycle). Total latency from our setpoint to PWM edge: ~6 ms.
# Rate controller — tighter than stock, this is a racing drone MC_ROLLRATE_P = 0.18 # default 0.15 — sharper roll for split-S MC_PITCHRATE_P = 0.18 # default 0.15 MC_YAWRATE_P = 0.22 # default 0.20 — pop yaw faster for s-turns # Position controller MPC_XY_P = 1.4 # default 0.95 — sharper position tracking MPC_Z_P = 2.0 # default 1.0 — altitude matters in dive/splits MPC_XY_VEL_MAX = 30.0 # default 12.0 — match V_ATTACK MPC_TILTMAX_AIR = 75 # default 45 — needed for high-G turns # Safety COM_OBL_RC_ACT = 3 # OFFBOARD loss → LAND (not RETURN, no GPS) COM_OF_LOSS_T = 0.5 # default 0.5 s — re-fail if our 100 Hz stream stalls
COM_OF_LOSS_T (0.5 s), PX4 falls back to COM_OBL_RC_ACT. We set it to LAND (param 3) — not RETURN because we have no GPS in the racing hall.BAT_LOW_THR=0.10 and BAT_CRIT_THR=0.05 — we'd rather crash than land mid-course (no scoring credit for either).POSCTL safe mode. We can't recover from this on-board — pilot takeover or aborted lap.
We use a minimal MAVLink subset. The FC must already be in OFFBOARD mode (set during arming sequence).
The single most important message is SET_POSITION_TARGET_LOCAL_NED (#84) — it's how 90% of the
race is flown. ATTITUDE_TARGET (#82) is reserved for the split-S where we need direct attitude control.
OFFBOARD == true AND race_started_event received AND state == ARMED_IDLE.
Transitions to state ← LAUNCH.
G2.pos − bf · 4 m over 0.4 s (where bf is the unit
tangent G1→G2). Velocity profile cosine-eased to peak 8 m/s at t = 0.4 s.
‖pos − G1‖ > 2 m AND speed > 6 m/s → transitions to next segment based on G2.kind.
HOVER at current pose and re-enter
SEEK: spiral yaw scan ±90° while ascending 0.5 m/s.
tracked_target.kind == 'std' AND distance > 4 m AND prev_segment ≠ split-s exit.
[G_n-1, G_n, G_n+1, G_n+2]. Speed target from
the curvature-aware solver:
v(s) = SPEED_MIN + (SPEED_CAP − SPEED_MIN)·(1 − κ̃(s)^0.7)
where κ̃ is normalized curvature.
gate.normal on both sides of
gate.plane within 30 ms. Increments state.gateIndex, dispatches next segment.
yaw_rate = 0.8 rad/s) until re-acquire.
v_ref and bank-reverses on each gate-cross event;
chasing peak speed corrupts the cycle.
3+ consecutive std gates with |lateral_offset| > 3 m alternating sign.
Activate SLALOM_MODE: clamp v_ref ≤ 0.7 · SPEED_CAP.
v_ref(s) capped uniformly to maintain rhythm.
|lateral_offset| drops below 2 m for 2+ gates.
Restore v_ref = SPEED_CAP.
v_slalom_cap by 15% for
the next 3 gates. Three consecutive misses → revert to APPROACH single-gate mode.
next_gate > 14 m AND next gate is std AND racing line straightness
(max bank along path) < 15°. Enter STRAIGHT_MODE.
tangent toward next gate, at min(SPEED_CAP, time_to_brake).
Brake distance: d_brake = v²/(2·a_max) where a_max ≈ 12 m/s² (T/W limit).
d_brake + 1.5 m → transition back to APPROACH mode, drop to pos+vel.
v down to 8 m/s. If still no detection, revert to SEEK.
next_gate.kind == 'dgate' AND distance to its paired gate is < 8 m.
The system reads the PAIR as one feature, plans a 2-gate trajectory.
gate_A.center → gate_B.center → gate_C.center.
Speed at A constrained by max a_lat through the apex between A and B:
v_A ≤ √(a_lat_max · R_apex).
v_ref.
v_A > v_A_target + 20% at distance_to_A < 2 m → emergency yaw 15° toward
inside of pair (sacrifice A miss to save B). Better to lose 1 gate than 2.
prev_segment == PINCH_ENTRY AND gate A just crossed (within last 50 ms).
v_ref
toward SPEED_CAP using available T/W headroom.
SEEK immediately (don't fly blind).
next_gate.kind == 'sturn' AND it's the UPPER (higher Y) of the stacked pair.
At distance < 6 m, transition to SPLITS_PRE: pre-stage at altitude upper.y + 0.5 m.
v ≈ 12 m/s, level.upper.y and lower.y + 1 m
→ transition to SPLIT-S DIVE for lower gate threading.
lower_gate.center. Total altitude drop ~4.5 m. Predicted gate-cross speed
v_exit = √(v_entry² + 2·g·Δh) ≈ 14 m/s.
next_gate.kind == 'dive'. Climb-stage activates at distance < 12 m:
insert a waypoint at (gate.x, gate.y + 2, gate.z) 2 m above the gate.
next_gate.kind == 'finish'.
SPEED_CAP through finish gate, with 2 m post-finish overshoot before
transitioning to either lap-2 LAUNCH or race-end controlled hover.
lap_count++. Branch on lap_count < LAPS_REQUIRED.
| FAILURE | DETECTION | RESPONSE |
|---|---|---|
| Detector loss | no gate detection for > 0.4 s |
Hold last velocity setpoint for 0.4 s (dead reckoning), then transition to SEEK: yaw scan ±90° while ascending 0.5 m/s. Re-acquire on detection at conf > 0.4. |
| OFFBOARD mode drop | HEARTBEAT outbound dropped > 500 ms |
FC switches to AUTO_LOITER. Controller MUST re-arm OFFBOARD before sending another setpoint. Treat as race-DNF. |
| Gate mis-classification | tracker.class flips on consecutive frames |
Weight by detection confidence. If oscillating between e.g. sturn and dgate → favor the kind that matches expected course position, downgrade conf threshold to 0.5. |
| PnP solution divergent | ‖pose_residual‖ > 0.5 m or rotation jump > 0.5 rad |
Reject pose, fall back to bbox-only velocity-control (steer toward bbox centroid). Lose PnP for the gate; do NOT lose detection. |
| Split-S wingover stall | drone vertical velocity > +2 m/s at planned apex |
ABORT — restore upright attitude (q → identity), full thrust, climb to safe altitude. Re-attempt split-S OR skip both stacked gates. |
| Gate-cross missed | passed gate plane outside aperture bounds |
Increment state.gateIndex anyway, accept the time penalty (DQ in time-trial but recoverable in completion mode), continue to next gate. |
| FPV camera frozen | 2+ identical frames detected by frame-hash |
Continue last setpoint for 0.2 s, then trigger camera reset over MAVLink (CAMERA_CONTROL #76). If unresponsive: hover + disarm. |
| PARAMETER | VALUE | NOTES |
|---|---|---|
| POS_LOOKAHEAD_S | 0.30 s | Time-ahead the planner samples on the spline for the position setpoint. Lower = more responsive but laggy on tight curves. 0.3 s works for SPEED_CAP up to 25 m/s. |
| SPEED_CAP (attack) | 30 m/s | Realistic peak for our 14 m longest straight. Cap before braking distance becomes the bottleneck. |
| SPEED_MIN | 10 m/s | Floor through tightest corners. Below this, FC stability margins shrink (PID gains tuned at hover). |
| A_LAT_MAX | 12 m/s² (1.2 g) | Comfortable lateral G. Above this we're at the bank-angle limit. Conservative — physics allows 30+ m/s². |
| A_BRAKE_MAX | 10 m/s² | Sets the brake-distance integral. d_brake = v² / (2·A_BRAKE_MAX). |
| DETECTION_CONF_THRESHOLD | 0.18 | Neyman-Pearson optimum. FN cost >> FP cost in racing. Don't use Ultralytics default 0.5. |
| TRACKER_AGE_LIMIT | 0.4 s | Max time a tracked target can persist without re-detection before being declared lost. |
| SPLITS_INVERT_DURATION | 0.4 s | Time to complete the half-roll in ATTITUDE_TARGET mode. Faster = sharper but more transient. |
| SPLITS_DIVE_THRUST | 0.55 → 0.85 | Throttle during wingover apex (reduced for fast inversion), then climbs as the drone pulls out. |
| STREAM_RATE_POS | 30 Hz | POS_TARGET to FC. Must stay > 2 Hz or FC drops OFFBOARD. |
| STREAM_RATE_ATT | 100 Hz | ATTITUDE_TARGET during split-S. Higher rate = smoother attitude tracking. |