AIGP CONTROL DOCTRINE
AIGP RACE CONTROL DOCTRINE · v1.0

Race Maneuvers & MAVLink Commands

For each course segment: trigger, reference trajectory, control output, exit condition, failure recovery
What the autonomous controller actually does for each segment of the AIGP Physical Qualifier course. Per-segment doctrine: the perception state that triggers the maneuver, the reference trajectory the planner generates, the exact 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).
course segments
10 documented
MAVLink messages
5 (NED + ATT + IMU + HB + TS)
control mode
OFFBOARD · setpoint streaming
spec
VADR-TS-002 §4.3
§1

Control architecture perception → planner → controller → FC → motors

REF: VADR-TS-002 §4.3

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.

stage 1
JPEG DECODE
30 Hz
640×360 frame from UDP:5600
stage 2
YOLO11n-pose
~30 Hz · ≤ 7 ms
bbox + 4 corner kpts per gate · class id
stage 3
PnP + EKF
~30 Hz · ≤ 2 ms
gate 6-DoF pose in NED · drone pose
stage 4
PLANNER
30 Hz
reference trajectory · waypoint stack
stage 5
CONTROLLER
100 Hz
SET_POS / SET_ATT messages over MAVLink
stage 6
FC (PX4)
1 kHz rate
motor PWM

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.

0 msframe budget · 33 ms @ 30 Hz33 ms
JPEG · 3
YOLO11n · 7
PnP+EKF · 2
PLAN · 1
CTRL · 2
COMMS · 3
SLACK · 15

Each stage detailed below — §1.1 through §1.6 — with reference code, data flow, and failure modes.

1.1

Stage 1 · JPEG decode UDP:5600 → BGR ndarray · 30 Hz · ~3 ms

REF: NVJPEG · VADR-TS-002 §3.8
01
JPEG DECODE
Read 640×360 JPEG-encoded frames from the simulator's RTP/UDP stream, decode to a numpy BGR array for the YOLO pipeline.
30 hz ≤ 3 ms / frame NVJPEG · CUDA python · pybind11

Inputs & outputs

PORT
udp://0.0.0.0:5600 · RTP/JPEG payload type 26
PAYLOAD
JPEG bytes · baseline DCT · YUV420 chroma sub-sampling
DIM (in)
640 × 360 per VADR-TS-002 §3.8 · color space sRGB
OUT
ndarray(360, 640, 3) · BGR uint8 (cv2 convention)
TIMESTAMP
RTP header timestamp · monotonic ns at capture

Capture pipeline · GStreamer + appsink

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.

capture.pypython· thread A · feeds inference queue
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

Why drop-oldest, not block

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.

Failure modes

PACKET LOSS
RTP gap > jitter buffer → rtpjpegdepay emits a corrupted frame. Decoder rejects; queue stays empty for 1 cycle. EKF coasts on IMU.
CORRUPT JPEG
nvv4l2decoder pushes an error event. Wrap pull-sample in try/except and re-queue prev frame.
CUDA OOM
Rare — only if YOLO and JPEG share device memory and a model reload mid-flight. Pin a 50 MB JPEG arena at startup.
CLOCK SKEW
If sim PTS drifts vs. wall-clock, the EKF's measurement-update timestamps get wrong. Resync via TIMESYNC msg every 1 s.
SIM / DRONE JPEG @ 30 Hz UDP:5600 udpsrc RTP / PAYLOAD 26 rtpjitterbuffer latency 10 ms nvv4l2decoder NVJPEG · GPU nvvidconv NV12 → BGR appsink queue (n=2) ⏱ 3.0 ms total · 1 CPU copy · zero unnecessary YUV conversions
GST PIPELINE · ON ORIN NX
1.2

Stage 2 · YOLO11n-pose inference BGR frame → bbox + 4 corner kpts · ≤ 7 ms

REF: ultralytics · TensorRT 10
02
YOLO11n-pose · TensorRT FP16
Single-stage detector + pose head. One forward pass produces per-gate bbox, class id, and 4 corner keypoints — exactly what PnP needs.
≥ 28 Hz 4.2 ms forward · 7 ms total tensorrt 10 · fp16 conf 0.18 · iou 0.45

Inputs & outputs

IN
BGR uint8 (360,640,3) · 30 Hz from §1.1
PRE-PROC
letterbox 640×640, BGR→RGB, /255, NCHW, FP16
ENGINE
apex_yolo11n_pose.engine · ~5.4 MB · INT8 weights, FP16 activations
CLASSES
0 = std, 1 = dgate, 2 = splits-upper, 3 = splits-lower, 4 = dive
KPTS
4 per gate · [TL, TR, BR, BL] in image px · visibility 0/1/2
OUT
List[Detection(cls, conf, bbox, kpts)] · max_det=8

Inference loop · zero-copy TRT bindings

We 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.

perception.pypython· thread B · 30 Hz pipeline tick
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)

Architecture · what the engine looks like

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.

NMS — why class-agnostic + pose-aware

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.

nms.pypython
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)
640×640×3 RGB FP16 letterboxed BACKBONE CSPDarknet · 2.6M P3 80² · P4 40² · P5 20² FPN/PAN 3-scale multi-scale fusion DETECT HEAD cls + bbox · 5 classes anchor-free POSE HEAD 4 kpts × (x,y,v) corner regression [1, 21, 8400] cls(5) + bbox(4) + kpts(12) × 8400 anchors NMS · max 8 det
YOLO11n-POSE · SHARED FPN, TWO HEADS

Failure modes & mitigations

MOTION BLUR > 25 m/s
Effective per-frame translation ≥ 30 px at 30 Hz · keypoints smear. Mitigate by training on motion-blur-augmented synth (kernel σ ∝ velocity).
OCCLUDED CORNER
Pose head still emits a guess with visibility=0. PnP downgrades to 3-point IPPE — slightly higher reprojection error but stable.
DOUBLE-DETECTION (DGATE)
The two gates of a double pair often share IoU 0.3–0.4. Class-agnostic NMS handles it; the planner sees both and orders by depth.
SUN GLARE / OVER-EXPOSURE
conf drops below 0.18 — gate becomes invisible. EKF coasts ≤ 200 ms; perception-aware reward biases yaw toward last-seen gate.
1.3

Stage 3 · PnP + EKF state estimation 4 kpts → gate 6-DoF pose · IMU + vision → drone state · ≤ 2 ms

REF: OpenCV solvePnP · IPPE_SQUARE
03
PnP & 12-state Extended Kalman filter
Lift 2D keypoints into 3D using known gate geometry; fuse with IMU to estimate the drone's full 6-DoF state in NED.
~30 hz vision update 100 hz IMU prop opencv ippe_square 12-state EKF

Step 1 · PnP per gate

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.

pose_estimator.pypython
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()

Step 2 · EKF fusion · IMU @ 100 Hz + vision @ 30 Hz

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:

ekf.pypython
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
N E D GATE (world) DRONE IMAGE 640×360 PnP: 4 px → (R_cw, t_cw) EKF: ⊕ IMU → drone (x, P)
PNP + EKF · CAM → GATE → WORLD

Why IPPE_SQUARE (not P3P or iterative)

  • Closed-form · ~80 μs vs. iterative SOLVEPNP's 800 μs · saves us 700 μs per gate, multiplied by up to 8 gates per frame.
  • Designed for planar 4-point sets · exactly our case · disambiguates the two PnP solutions using the planar prior.
  • Lower jitter at near-perpendicular viewing angles (where iterative methods get unstable).

Failure modes

REPROJ ERROR > 4 px
Keypoints not actually a square — usually a duplicate/misclassified gate. Drop this pose; EKF skips the update.
EKF DIVERGENCE
If P trace grows past 5 m² with no vision updates for > 500 ms, fall back to dead-reckoning + a SEEK behavior to reacquire.
YAW WRAP DISCONTINUITY
EKF uses unwrapped Euler; if drone yaws past ±π between frames, force-wrap before innovation else y spikes by 2π.
1.4

Stage 4 · Planner EKF state + gate stack → time-parameterized reference trajectory · 30 Hz

REF: Mellinger & Kumar 2011 · min-jerk
04
Minimum-jerk trajectory planner
Given the current drone state and the next 3 gates, produce a smooth reference position + velocity + yaw the controller can track at 100 Hz.
30 hz replan ~1 ms / cycle polynomial · degree 5 analytic gradient

Why minimum-jerk

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.

planner.pypython· thread C · 30 Hz replan
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)

Special modes — pre-stage, climb-stage, dive-stage

The base min-jerk path goes through gate centers. For three gate types we override this with a pre-positioning leg:

  • Split-S pre-stage at dist < 6 m: insert a knot at (splits-upper.center + 0.5 m above) — sets up the wingover entry.
  • Dive climb-stage at dist < 12 m: insert a knot 4 m above the dive gate's top edge — gives the dive its vertical drop room.
  • D-gate pair: don't replan between the two — fit one wider polynomial so we don't pitch up between them.

Yaw planning · perception-aware

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.

planner.py · yawpython
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
drone G+1 G+2 G+3 dashed = piecewise linear · solid = degree-5 min-jerk
3-GATE LOOKAHEAD · MIN-JERK SPLINE
1.5

Stage 5 · Controller reference trajectory → MAVLink setpoints · 100 Hz · cascaded loops

REF: cascaded PID · PX4 OFFBOARD
05
Cascaded PID · OFFBOARD streamer
Sample the trajectory at 100 Hz, compute the outer position loop, and emit MAVLink setpoints to PX4. ATTITUDE_TARGET takes over during split-S wingover.
100 hz ≤ 2 ms / tick pymavlink SET_POS · ATT

Outer loop · position → velocity

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).

controller.pypython· thread D · 100 Hz tick
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)
        )

Split-S override · ATTITUDE_TARGET path

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():

controller.py · split-S branchpython
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
    )

Loop cadence · why 100 Hz

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:

  • PX4 OFFBOARD requires > 2 Hz setpoint streaming or it drops the mode after 500 ms.
  • Going above 100 Hz starves the EKF and YOLO threads (they share the same CCPLEX cluster).
  • At 100 Hz the FC's rate controller (1 kHz internal) gets a fresh setpoint every 10 of its own loops — feels continuous.
TRAJ ref_p, ref_v, ψ P pos→vel Kp · (ref - x) SET_POS_NED UDP:14540 PX4 · OFFBOARD rate ctrl + mixer + ESC EKF state 12-d · 100 Hz feedback single outer P loop · inner rate loop is on the FC
OUTER P · FC HANDLES INNER LOOPS
1.6

Stage 6 · Flight controller (PX4) rate controller → mixer → motor PWM · 1 kHz · what we tune, not what we write

REF: PX4 v1.15 · mc_rate_control
06
PX4 inner-loop autopilot
We don't write this code — it ships on the Pixhawk. But we need OFFBOARD enabled, the right safety params, and to know which PX4 parameters our doctrine tunes.
no app code · params only 1 kHz rate ctrl PWM @ 400 Hz PX4 v1.15+

Enabling OFFBOARD · the arming sequence

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:

  1. Open MAVLink, wait for HEARTBEAT.
  2. Start streaming SET_POSITION_TARGET_LOCAL_NED at ≥ 10 Hz (we use 100).
  3. Wait until 1 s of stream is buffered (PX4's safety check).
  4. Send MAV_CMD_DO_SET_MODE → custom_mode = OFFBOARD.
  5. Send MAV_CMD_COMPONENT_ARM_DISARM with param1 = 1.
fc_setup.pypython
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)

PX4 internal stages we never see

Inside the FC, our SET_POSITION_TARGET goes through three layers before it becomes PWM:

  • mc_pos_control — converts pos+vel setpoint into thrust + attitude setpoint. Runs at 250 Hz.
  • mc_att_control — converts attitude setpoint into body rate setpoint. Runs at 250 Hz.
  • mc_rate_control — converts body rate setpoint into actuator commands (per-axis torque). Runs at 1 kHz — this is the loop that actually fights wind and gyroscopic forces.

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.

The 9 PX4 parameters our doctrine actually touches

# 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
SET_POSITION @ 100 Hz from §1.5 mc_pos_control 250 Hz · MPC_XY_P mc_att_control 250 Hz mc_rate_control 1 kHz · MC_*RATE_P mixer + ESC HIGHRES_IMU 100 Hz to §1.3 gyro · accel on-chip 8 kHz 3 nested loops we tune params, not code
PX4 INTERNAL · 3 NESTED LOOPS, 1 KHZ INNER

Failure modes & mitigations · what PX4 does without us

OFFBOARD STREAM LOST
If our 100 Hz stream stops for > 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.
BATTERY LOW
PX4 raises a warning at 18% and forces RTL at 12%. For racing we set BAT_LOW_THR=0.10 and BAT_CRIT_THR=0.05 — we'd rather crash than land mid-course (no scoring credit for either).
IMU GLITCH
If gyro variance spikes > 50 rad²/s², PX4 enters POSCTL safe mode. We can't recover from this on-board — pilot takeover or aborted lap.
MOTOR FAIL
PX4 detects via current draw asymmetry. No graceful degradation for a 4-rotor — emergency cut + free-fall. We hope the cage protects the gates.
§2

MAVLink message reference the 5 messages the controller sends · per VADR-TS-002 §4.3

REF: mavlink.io · common.xml

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.

TYPE_MASK BITS · #84
bit 0–2 ignore x,y,z · bit 3–5 ignore vx,vy,vz · bit 6–8 ignore afx,afy,afz · bit 9 ignore yaw · bit 10 ignore yaw_rate · bit 11 force_setpoint
§3

Per-segment maneuvers trigger · trajectory · MAVLink output · exit · failure

REF: /race-pq course
LAUNCH
G1 · start gate · kind: start
01
Drone is armed, OFFBOARD active, hovering 0.5 m off the floor inside the start volume. On the start signal, accelerate cleanly toward G2 without banking — establish the racing line.

① Trigger

OFFBOARD == true AND race_started_event received AND state == ARMED_IDLE. Transitions to state ← LAUNCH.

② Reference trajectory

Linear ramp from current pose to 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.

③ MAVLink command stream · 30 Hz

// type_mask: position + velocity + yaw (ignore accel, ignore yaw_rate) // = 0b0000_0111_1100_0000 = 0x07C0 → ignore afx,afy,afz,yaw_rate SET_POSITION_TARGET_LOCAL_NED: coordinate_frame = MAV_FRAME_LOCAL_NED // 1 type_mask = 0x07C0 x, y, z = G2.x, G2.y, G2.z // straight-line target vx, vy, vz = 8.0 * tangent.x, 8.0 * tangent.y, 8.0 * tangent.z yaw = atan2(tangent.x, -tangent.z) // face G2

④ Exit condition

‖pos − G1‖ > 2 m AND speed > 6 m/s → transitions to next segment based on G2.kind.

⑤ Failure recovery

If G2 not detected within 0.6 s after launch → revert to HOVER at current pose and re-enter SEEK: spiral yaw scan ±90° while ascending 0.5 m/s.
APPROACH (single std gate)
G2, G5, etc. · kind: std
02
Smooth gate entry between features. Pure position-control through a Catmull-Rom spline built from the previous, current, and next gate centers. No bank-reversal, no aerobatics.

① Trigger

tracked_target.kind == 'std' AND distance > 4 m AND prev_segment ≠ split-s exit.

② Reference trajectory

Centripetal Catmull-Rom curve through [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.

③ MAVLink command stream · 30 Hz

// type_mask: pos + vel · ignore accel and yaw_rate SET_POSITION_TARGET_LOCAL_NED: type_mask = 0x07C0 x, y, z = spline(s_lookahead) // 0.3 s ahead on path vx, vy, vz = v_ref(s) * spline_tangent(s) yaw = atan2(tangent.x, -tangent.z)

④ Exit condition

Gate-cross event: drone has positive component along gate.normal on both sides of gate.plane within 30 ms. Increments state.gateIndex, dispatches next segment.

⑤ Failure recovery

Detector loss > 0.4 s → hold last setpoint, drop speed by 20% per frame, switch yaw to scan mode (yaw_rate = 0.8 rad/s) until re-acquire.
SLALOM TRAVERSE
G2–G7 (if course is slalom-heavy) · kind: std alternating
03
Series of 3+ consecutive std gates with alternating lateral offset. Rhythm over speed. The controller commits to a constant v_ref and bank-reverses on each gate-cross event; chasing peak speed corrupts the cycle.

① Trigger

3+ consecutive std gates with |lateral_offset| > 3 m alternating sign. Activate SLALOM_MODE: clamp v_ref ≤ 0.7 · SPEED_CAP.

② Reference trajectory

Same Catmull-Rom spline as APPROACH but with lookahead increased to 0.5 s (anticipate next gate's opposite bank). v_ref(s) capped uniformly to maintain rhythm.

③ MAVLink command stream · 30 Hz

// type_mask: pos + vel + yaw_rate (so we can pre-bank for next gate) SET_POSITION_TARGET_LOCAL_NED: type_mask = 0x0BC0 // ignore accel + yaw (use yaw_rate instead) x, y, z = spline(s_lookahead_extended) // 0.5 s ahead vx, vy, vz = v_slalom_cap * tangent yaw_rate = curvature(s) * v_ref // match path rate

④ Exit condition

Next gate is NOT std (i.e., dgate, sturn, dive, finish), OR |lateral_offset| drops below 2 m for 2+ gates. Restore v_ref = SPEED_CAP.

⑤ Failure recovery

If gate-cross misses lateral tolerance (> 0.3 m off-axis) → drop v_slalom_cap by 15% for the next 3 gates. Three consecutive misses → revert to APPROACH single-gate mode.
FAST STRAIGHT
straight segments > 14 m · kind: std spaced wide
04
Long unobstructed segments between gates. Switch from position-control to velocity-control — request a speed target along the tangent and let the FC push. Faster acceleration than position mode.

① Trigger

Distance to next_gate > 14 m AND next gate is std AND racing line straightness (max bank along path) < 15°. Enter STRAIGHT_MODE.

② Reference trajectory

Pure velocity setpoint along 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).

③ MAVLink command stream · 30 Hz

// type_mask: velocity only · ignore position, accel, yaw_rate SET_POSITION_TARGET_LOCAL_NED: type_mask = 0x07C7 // ignore x,y,z + accel + yaw_rate vx, vy, vz = v_target * tangent // v_target up to SPEED_CAP yaw = atan2(tangent.x, -tangent.z) // FC's velocity controller takes over — much more aggressive than pos-mode

④ Exit condition

Distance to next gate < d_brake + 1.5 m → transition back to APPROACH mode, drop to pos+vel.

⑤ Failure recovery

Detector loss during straight → continue current velocity setpoint for 0.5 s (dead reckoning), then ramp v down to 8 m/s. If still no detection, revert to SEEK.
PINCH ENTRY (double-gate first member)
G3, G6 (per AIGP PQ) · kind: dgate · first of pair
05
The drone must clear gate A AND immediately be in a viable approach line for gate B (~5 m away). The exit speed from A is sized to gate B's geometry, not gate A's own approach. Controller throttles down approaching A to set up the geometric apex for B.

① Trigger

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.

② Reference trajectory

Bezier curve from current → gate_A.centergate_B.centergate_C.center. Speed at A constrained by max a_lat through the apex between A and B: v_A ≤ √(a_lat_max · R_apex).

③ MAVLink command stream · 30 Hz

// Pre-brake: position + velocity + accel (for predicted deceleration) SET_POSITION_TARGET_LOCAL_NED: type_mask = 0x0000 // all fields active x, y, z = gate_A.center + tangent_A * 0.5 vx, vy, vz = v_A_target * tangent_A afx, afy, afz = a_brake.x, a_brake.y, 0 // scrub for B setup yaw = atan2(tangent_A.x, -tangent_A.z)

④ Exit condition

Gate-A cross detected → immediately transition to STITCH EXIT for gate B with elevated v_ref.

⑤ Failure recovery

If 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.
STITCH EXIT (double-gate second member)
G4, G7 (per AIGP PQ) · kind: dgate · second of pair
06
Accelerate aggressively through gate B. Re-acquire the next gate's detection LOCK as you cross — the ~5 m gap to B leaves < 250 ms at 20 m/s, so the perception pipeline must be ready before A is even cleared.

① Trigger

prev_segment == PINCH_ENTRY AND gate A just crossed (within last 50 ms).

② Reference trajectory

Straight-line segment from gate-A exit to gate B, then onward to gate C tangent. Push v_ref toward SPEED_CAP using available T/W headroom.

③ MAVLink command stream · 30 Hz

// Velocity dominant — let FC max accel SET_POSITION_TARGET_LOCAL_NED: type_mask = 0x07C0 x, y, z = gate_C.center // look past B to C vx, vy, vz = SPEED_CAP * tangent_C yaw = atan2(tangent_C.x, -tangent_C.z)

④ Exit condition

Gate-B cross detected → restore APPROACH mode for whatever C is.

⑤ Failure recovery

If gate C detector confidence < 0.3 by gate-B cross → enter SEEK immediately (don't fly blind).
SPLIT-S · WINGOVER (upper gate)
G8 (per AIGP PQ) · kind: sturn · UPPER stacked
07
The only place we switch from position-control to direct attitude-control. The drone enters going east at altitude ~8 m, must end up going west at altitude ~3.5 m (the lower stacked gate). Position-mode is too slow for the bank-reversal at apex; we go to ATTITUDE_TARGET for ~600 ms.

① Trigger

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.

② Reference trajectory

Three sub-phases:
(a) Approach — fly into upper gate at v ≈ 12 m/s, level.
(b) Wingover — at gate cross, switch to ATTITUDE_TARGET. Command roll → +π/2, pitch up, hold for 0.4 s while the drone arcs over the apex.
(c) Inverted dive — invert (roll continues to π), pitch nose to ground, accelerate down.

③ MAVLink command stream · 100 Hz during phase (b) + (c)

// Phase (a) — POS_TARGET, same as APPROACH (30 Hz) SET_POSITION_TARGET_LOCAL_NED: x, y, z = upper_gate.center vx, vy, vz = 12 * tangent // At upper-gate cross → ATTITUDE_TARGET @ 100 Hz for 600 ms ATTITUDE_TARGET: type_mask = 0x00 // honor all (q + rates + thrust) q = slerp(q_now, q_inverted, t/T) // invert over 0.4s body_pitch_rate = +8.0 rad/s // pull through body_roll_rate = +12.0 rad/s // drive the half-roll body_yaw_rate = 0 thrust = 0.55 // reduce so dive accelerates

④ Exit condition

Drone roll passes 5π/4 AND altitude is between upper.y and lower.y + 1 m → transition to SPLIT-S DIVE for lower gate threading.

⑤ Failure recovery

If lower gate not detected by start of phase (c) → ABORT split-S. Re-attitude to level (roll → 0, pitch → 0, full thrust), regain altitude, re-attempt by orbiting back to upper gate's approach. Cost: ~2 s, salvageable.
SPLIT-S · DIVE (lower gate)
G9 (per AIGP PQ) · kind: sturn · LOWER stacked
08
Continuation of #07. Drone exits wingover inverted, dives through lower gate going west. Course direction has reversed. The dive itself uses free-fall + thrust — gravity provides ~5 m/s² of "free" acceleration since the drone is pointed mostly down.

① Trigger

Phase (c) of split-S WINGOVER complete (roll > 5π/4, altitude in dive window).

② Reference trajectory

Inverted dive aimed at lower_gate.center. Total altitude drop ~4.5 m. Predicted gate-cross speed v_exit = √(v_entry² + 2·g·Δh) ≈ 14 m/s.

③ MAVLink command stream · 100 Hz

ATTITUDE_TARGET: type_mask = 0x00 q = q_inverted_west // drone is upside-down, pointing west body_roll_rate = +15.0 rad/s // finish the half-roll right-side-up body_pitch_rate = −6.0 rad/s // pull out thrust = 0.85 // add thrust once nose comes up // At lower-gate cross → IMMEDIATELY restore POS_TARGET SET_POSITION_TARGET_LOCAL_NED: type_mask = 0x07C0 x, y, z = G14.center // next gate, going west vx, vy, vz = 14.0 * tangent_west

④ Exit condition

Lower gate cross detected AND drone is right-side-up (roll within ±π/8 of 0) → switch back to position control, target next gate.

⑤ Failure recovery

If altitude drops below 1 m before reaching lower gate → max thrust + level attitude (emergency pull-up), accept the missed gate. Re-acquire next gate by orbital scan at safe altitude.
DIVE BOMB (elevated gate)
G10 (per AIGP PQ) · kind: dive · elevated ~5.5 m
09
The drone climbs above the dive gate, then enters a sustained-thrust descent through it. Unlike split-S, this is position-controlled the whole way — the gate's elevation is the only oddity.

① Trigger

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.

② Reference trajectory

Three waypoints: (a) climb above gate, (b) approach apex 2 m up, (c) gate center. Speed profile: hold 12 m/s through (a)+(b), let it bleed to ~10 m/s through gate, recover on exit.

③ MAVLink command stream · 30 Hz

SET_POSITION_TARGET_LOCAL_NED: // climb phase type_mask = 0x07C0 x, y, z = dive_gate.x, dive_gate.y + 2.0, dive_gate.z vx, vy, vz = 12 * tangent_to_apex yaw = atan2(tangent.x, -tangent.z) // At apex → descend through gate SET_POSITION_TARGET_LOCAL_NED: // dive phase x, y, z = next_gate.center // look PAST the dive gate vx, vy, vz = 12 * tangent_through_dive afz = −2.0 // favor downward accel (gravity-assist) type_mask = 0x07B0 // activate afz only

④ Exit condition

Dive-gate cross detected. Restore standard APPROACH for next gate.

⑤ Failure recovery

If gate detection lost during climb phase → continue climb to apex via dead-reckoning (last known position), re-attempt detect. Max 1 retry then revert to SEEK.
FINISH TRACE
G11 · kind: finish
10
Carry every bit of speed across the finish plane. No bank, no pitch-up, no aerobatics. If this is lap 1, there's a transparent reset to LAUNCH for lap 2.

① Trigger

next_gate.kind == 'finish'.

② Reference trajectory

Straight-line at SPEED_CAP through finish gate, with 2 m post-finish overshoot before transitioning to either lap-2 LAUNCH or race-end controlled hover.

③ MAVLink command stream · 30 Hz

SET_POSITION_TARGET_LOCAL_NED: type_mask = 0x07C7 // vel only · ignore pos + accel + yaw_rate vx, vy, vz = SPEED_CAP * tangent yaw = atan2(tangent.x, -tangent.z) // On gate cross, if lap < LAPS_REQUIRED → re-enter LAUNCH (segment #01) // Else → hover at G11 + 2m for 2 s, then disarm.

④ Exit condition

Gate-cross detected. lap_count++. Branch on lap_count < LAPS_REQUIRED.

⑤ Failure recovery

If finish gate not detected → hold last velocity for 0.4 s (we're definitely close), then transition to SEEK. Better to overshoot the finish line than miss it entirely.
§4

Failure modes & recovery when perception or control breaks · per-mode response

REF: FM-FPV-001.4
FAILUREDETECTIONRESPONSE
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.
§5

Tuning guidelines controller gains & thresholds you'll likely touch

REF: FM-FPV-001.5
PARAMETERVALUENOTES
POS_LOOKAHEAD_S0.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_MIN10 m/s
Floor through tightest corners. Below this, FC stability margins shrink (PID gains tuned at hover).
A_LAT_MAX12 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_MAX10 m/s²
Sets the brake-distance integral. d_brake = v² / (2·A_BRAKE_MAX).
DETECTION_CONF_THRESHOLD0.18
Neyman-Pearson optimum. FN cost >> FP cost in racing. Don't use Ultralytics default 0.5.
TRACKER_AGE_LIMIT0.4 s
Max time a tracked target can persist without re-detection before being declared lost.
SPLITS_INVERT_DURATION0.4 s
Time to complete the half-roll in ATTITUDE_TARGET mode. Faster = sharper but more transient.
SPLITS_DIVE_THRUST0.55 → 0.85
Throttle during wingover apex (reduced for fast inversion), then climbs as the drone pulls out.
STREAM_RATE_POS30 Hz
POS_TARGET to FC. Must stay > 2 Hz or FC drops OFFBOARD.
STREAM_RATE_ATT100 Hz
ATTITUDE_TARGET during split-S. Higher rate = smoother attitude tracking.