PUBLIC · TECHNICAL · AIGP · VADR-TS-002 ANNEX
Program · VADR-VQ1-IFACE

The wire between sim and pilot.

A complete walkthrough of the AI Grand Prix Virtual Qualifier 1 interface — every byte the sim sends, every byte we send back, the geometry that ties them together, and the 50-millisecond budget inside which the loop must close.

Document  ·  VQ1 interface · data contracts · geometry · timing
Issue     ·  2026-05-22 · Rev 1.1
Companion  ·  /vq1-status (readiness board)
Sources   ·  VADR-TS-002 · race/VQ1_RUNBOOK.md · AIGP email 2026-05-21
Contact   ·  blake@trending.fm

1. Executive Summary

VQ1 is a downloaded Windows simulator that exposes a drone-racing course over the network. It streams a 640×360 forward-facing camera feed and a stream of telemetry packets; our process consumes both, runs perception and control, and sends four floats back. The interface is small. The geometry is fixed. The timing budget is ~50 ms per closed-loop iteration.

The AIGP team announced the launch for "late next week" (week of May 25, 2026) in their 2026-05-21 email. Roster lock is T−24h before the binary drops.

▌ Three contracts

What VQ1 does not give you: GPS, depth, NED position, gate locations. This is by design — the same submission must run on the physical stage where none of those are available either.

Sections marked CONF are confirmed in VADR-TS-002. Sections marked ASSM are reasonable assumptions pending the sim binary drop. Sections marked UNK are flagged unknowns that first connect will resolve.

2. Interface Architecture

The sim and the pilot are two processes on the same Windows host. They share nothing but loopback sockets. There is no Python module embedded in the sim, no shared memory, no privileged callback into the renderer — the loop closes entirely through user-space network I/O.

AIGP SIM · *.exe Physics @ 500 Hz Renderer @ ~30 Hz Camera (640×360 fwd) IMU sample Course scorer Control receiver internet ↻ anti-cheat parallel instances OK PILOT · python JPEG decode YOLO11n · 4 corners PnP → gate pose PID controller Command emitter Frame logger (opt) vq1_completion_pilot.py stateless across runs UDP:5600 JPEG video ≈30 Hz UDP:? telemetry ≥100 Hz UDP:? (T, R, P, Y) per-frame three independent sockets · loopback only · no shared memory · no privileged callback
Figure 2.1  Sim ↔ pilot block diagram. Three independent UDP sockets on loopback. The pilot is a normal user-space process that can be killed and restarted without touching the sim.

This is the same pattern as PX4 SITL, AirSim, and Gazebo — there is no AIGP-specific SDK. Any language with a UDP socket and a JPEG decoder can implement a pilot. Our reference uses Python, OpenCV, and ultralytics.

2.1 One-tick sequence

The interaction across one render tick of the sim is best read as a UML-style sequence diagram. Wall-clock time flows top to bottom.

SIM PILOT t_sim = N JPEG frame (~25 KB) → recvfrom() on :5600 telemetry packets (3+ during frame interval) → decode YOLO11n PnP transform → body PID step ≈17 ms total ← ControlCmd (16 bytes) sendto() — throttle, roll, pitch, yaw t_sim = N + Δ
Figure 2.2  One sim render tick. Vision frame arrives, telemetry continues streaming throughout, pilot processes once, single control packet emitted. The pilot is frame-driven — telemetry between frames is buffered but processed on the next vision tick.

3. Video Channel

The forward camera frame arrives as motion-JPEG over UDP. CONF Geometry is fixed by VADR-TS-002 §3.7–3.8. Packet framing details below are TS-002-derived where possible, otherwise marked.

PropertyValueSource
TransportUDP / IPv4 loopbackTS-002 §4.1
Port5600TS-002 §4.1 CONF
CodecJPEG (baseline, RGB, 8-bit)TS-002 §3.8
Resolution640 × 360TS-002 §3.8 CONF
Frame rate (nominal)≈30 HzTS-002 §3.8 ASSM
Datagram framing1 JPEG per datagram, <65 KBUDP MTU limit ASSM
Frame metadatanone in-band (use telemetry t_sim for sync)TS-002 §4.3 UNK

3.1 Receiver pattern

The pilot binds UDP:5600, receives one datagram per recvfrom(), and decodes with cv2.imdecode. No GStreamer pipeline, no RTP unpacking, no reassembly — keep it simple.

# vq1_completion_pilot.py — reference receiver
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2_000_000)
sock.bind(('127.0.0.1', 5600))

while running:
    payload, _ = sock.recvfrom(65535)
    frame = cv2.imdecode(np.frombuffer(payload, np.uint8), cv2.IMREAD_COLOR)
    if frame is not None:
        on_frame(frame)            # 640×360 BGR ndarray

3.2 What a frame looks like

A representative annotated frame, showing the detector output, principal point, horizon line, and the 20° upward tilt offset that biases everything in image space toward the lower half of the frame.

true horizon · y = 180 + tan(20°)·f ≈ 296px (off-screen due to up-tilt) (cx, cy) = (320, 180) TL TR BR BL u_g=473 v_g=210 → PnP gate 0.94 gate 0.62 (filtered: not nearest) (0, 0) (640, 0) (0, 360) (640, 360) detector → (bbox, 4 kpts, conf) per gate · pilot picks the nearest plausible gate (largest area · conf > 0.5)
Figure 3.1  Single decoded frame annotated. The detector emits bbox and four corner keypoints per gate; the pilot selects the largest-area gate above confidence threshold and feeds its keypoints to PnP. Far gate is detected but ignored until the near gate is cleared.

3.3 Camera intrinsics

The intrinsic matrix is fixed and given in TS-002. The principal point sits at image center; pixels are square. Focal length is identical on both axes.

HORIZONTAL · top-down camera +Z (fwd) 90° 2·atan(320/320) = 90° VERTICAL · side camera body horizon optical axis 58° +20° vFoV = 2·atan(180/320) ≈ 58°
Figure 3.2  Field of view from the intrinsics. 90° horizontal × 58° vertical. The optical axis is pitched 20° above the body horizon — a gate at the drone's altitude lands in the lower half of the image, not the center.
# Intrinsics — VADR-TS-002 §3.7
K = np.array([
    [320.0,   0.0, 320.0],
    [  0.0, 320.0, 180.0],
    [  0.0,   0.0,   1.0],
])
dist = np.zeros(5)                        # pinhole, no distortion
camera_tilt_rad = math.radians(20.0)     # +pitch up

4. Telemetry Channel

Telemetry is the privileged input we do get. It contains everything an on-board IMU + AHRS would produce — orientation, body angular rates, body linear acceleration — plus a simulator-time stamp for cross-channel synchronization. It does not contain absolute position, velocity, or any course landmarks.

4.1 Field schema (per Telemetry dataclass)

attitude_q
float32[4]
Orientation quaternion, body-relative-to-world, in (w, x, y, z) order. Unit-normalized.
body_rates
float32[3]
Angular velocity in body frame, radians per second. (p, q, r) about body X/Y/Z.
body_accel
float32[3]
Specific force in body frame, m/s². Includes gravity component projected into body axes.
t_sim
float64
Simulation time in seconds since run start. Monotonic. Use as the canonical timestamp for sync.

4.2 Wire layout — best-guess byte map

VADR-TS-002 fixes the field set but not the byte order. The most plausible packing is naturally-aligned little-endian — 48 bytes total, single datagram. Confirm against the first recvfrom().

TELEMETRY DATAGRAM · 48 bytes · little-endian · float32 / float64 0 4 8 12 16 20 24 28 …48 q.w q.x q.y q.z attitude_q : 16B ω.x ω.y ω.z body_rates : 12B a.x a.y a.z body_accel : 12B t_sim (float64) t_sim : 8B struct.unpack('<4f 3f 3f d', payload)
Figure 4.1  Plausible 48-byte telemetry layout (little-endian). Three float32 vectors plus one float64 timestamp. If the real packet has a header or different order, only the unpack format string changes — the field semantics in §4.1 are fixed.

4.3 Rate, ordering, derivation

PropertyValueNotes
Rate≥100 Hz UNKLikely 100–500 Hz. Confirmed on first connect.
Ordering guaranteemonotonic t_simDrop packets with non-increasing t_sim.
Loss tolerancegracefulUDP — assume up to ~1% packet loss; never block on it.
Coordinate conventionbody NED ASSMX-forward, Y-right, Z-down. Confirmed by first-flight inspection of accel under hover.

What we derive from it:

  1. Heading-hold — yaw from attitude_q as a setpoint reference between gate sightings.
  2. Lost-sight propagation — when the detector misses a frame, propagate the last gate pose by integrating body_rates and body_accel for up to 30 frames.
  3. Pitch / altitude trim — gravity vector projected through the attitude quaternion gives true-down; bias the altitude PID against that.
  4. Control loop pacingt_sim drives the PID integral term, not wall-clock.

5. Coordinate Frames

Three frames are in play. Getting these consistent is the single most common source of sign-flip bugs in a racing stack, so the convention is fixed here and referenced from every PnP and control call.

WORLD · NED N (X) E (Y) D (Z) used only conceptually — no absolute pos in VQ1 BODY · NED +X fwd +Y right +Z down attitude_q rotates body → world CAMERA · OPTICAL +Z optical −Y up (img) +X right (img) tilted +20° up about body Y axis R_world←body (from attitude_q) R_body←cam (fixed +20° pitch)
Figure 5.1  Three frames and the rotations between them. The pilot only ever measures in the camera frame (PnP output); we transform into the body frame to combine with telemetry, then issue body-frame commands.
TransformSourceStatic / dynamicInverse needed?
R_world←bodyattitude_q (telemetry)dynamic, ≥100 HzYes — for gravity-down vector
R_body←camfixed 20° pitch upstatic (compile-time)Yes — PnP output is in cam frame
T_world←camcomposeddynamicNever needed in VQ1

6. Gate Geometry & PnP

Two gate sizes exist in the AIGP catalog. VQ1 uses the smaller of the two exclusively. CONF

GateInner spanMaterialVQ1VQ2
Standard square1.5 m × 1.5 mpainted PVC
Wide gate2.7 m × 2.7 mpainted PVC

The detector emits four keypoints per gate, ordered TL → TR → BR → BL (image space). Combined with known metric corner coordinates and the camera intrinsic matrix, cv2.solvePnP with the IPPE square solver returns the gate's SE(3) pose in the camera frame.

GATE · 4-CORNER MODEL TL (−.75,−.75,0) TR (+.75,−.75,0) BR (+.75,+.75,0) BL (−.75,+.75,0) origin +Z_gate (normal) 1.5 m PnP CALL · cv2.solvePnP gate3d = np.array([ [-.75,-.75,0], [+.75,-.75,0], [+.75,+.75,0], [-.75,+.75,0]], dtype=float32) ok, rvec, tvec = cv2.solvePnP( gate3d, kpts_2d, K, dist, flags=cv2.SOLVEPNP_IPPE_SQUARE) # tvec.shape == (3,1) # gate center in CAMERA frame # tvec[2] > 0 ⇒ gate ahead # tvec[0] > 0 ⇒ gate to right # tvec[1] > 0 ⇒ gate below IPPE_SQUARE is exact for coplanar squares — single call, no RANSAC needed, <1ms
Figure 6.1  Gate model with corners in metric coordinates and the PnP call. Coordinates are in the gate's own frame; tvec returned by solvePnP gives the gate origin's position in the camera frame.

7. Control Channel

Four floats, sent back to the sim. The body of the entire control surface is this small.

7.1 ControlCmd schema

throttle
float32
Normalized collective thrust, 0.0 (off) to 1.0 (max). Hover ≈ 0.55 at the SimDrone weight.
roll
float32
Roll rate setpoint, normalized −1.0 (max left) to +1.0 (max right). Body X axis.
pitch
float32
Pitch rate setpoint, normalized −1.0 (max nose-down) to +1.0 (max nose-up). Body Y axis.
yaw
float32
Yaw rate setpoint, normalized −1.0 (max CCW) to +1.0 (max CW). Body Z axis.

7.2 Throttle envelope

Throttle is the only non-symmetric axis. The thrust map is roughly affine in the operational range, but the hover point sits well above 0.5 — this is the single quirk most likely to bite a naïve controller.

THROTTLE → THRUST · SIMDRONE ENVELOPE 0.0 0.25 0.50 0.75 1.0 throttle command 0 m·g 2 m·g hover = m·g hover = 0.55 descent aggressive climb
Figure 7.1  Throttle-to-thrust envelope. Max thrust is roughly 2·m·g, so the hover point sits at 0.55 rather than 0.5. A PID that biases around 0.5 will slowly sink — set the hover offset, then add the controller delta.

7.3 Send pattern

Send one command per inbound frame — the pilot is frame-driven, not clock-driven. If the detector loses sight, the pilot still emits a fresh command derived from propagated state; never let the control channel go silent.

# vq1_completion_pilot.py:ControlCmd → wire
@dataclass
class ControlCmd:
    throttle: float            # [0, 1]
    roll:     float            # [-1, +1]
    pitch:    float            # [-1, +1]
    yaw:      float            # [-1, +1]

def send(cmd: ControlCmd):
    payload = struct.pack('<ffff', cmd.throttle, cmd.roll, cmd.pitch, cmd.yaw)
    sock.sendto(payload, sim_addr)   # <= 16 bytes
▌ SimDrone physics quirk

The reference SimDrone hovers at throttle = 0.55 (not 0.5), implying the model uses 2·m·g max thrust rather than 1·m·g. Max usable pitch is around 15° before lift collapses. Conservative VQ1 gains stay well inside this envelope.

8. Closed-Loop Timing Budget

The pilot must complete photon-to-PWM in under 50 ms to track a 6 m/s drone through a 1.5 m gate with margin. Measured per-stage on the training rig (RTX 5080, i9, Windows), the budget breaks down as follows:

0 ms 25 ms 50 ms PER-FRAME PIPELINE · ≈17 ms typical · 50 ms hard cap recv + decode 5 ms YOLO11n 8 ms kpt 1ms PnP PID ≈17 ms · typical headroom · GPU contention · logging 33 ms 50 ms · cap (next frame arrives in ~33 ms @ 30 Hz)
Figure 8.1  Per-frame compute on RTX 5080. Vision dominates; PnP and PID are nearly free. Headroom absorbs occasional GC pauses, logging, and TensorRT warm-up jitter.
StageTypicalP99Hot pathNotes
recv() + cv2.imdecode5 ms9 msCPUlibjpeg-turbo via OpenCV
YOLO11n forward pass8 ms12 msGPUFP16 TensorRT engine, batch 1
Keypoint extraction1 ms2 msCPUargmax over heads
cv2.solvePnP (IPPE_SQUARE)1 ms1 msCPUclosed-form, no RANSAC
Frame transforms + PID<1 ms1 msCPUnumpy quaternion math
sendto() to sim<1 ms1 mskernel16-byte payload
Total per frame≈17 ms≈26 ms~33 ms headroom to cap

9. Pilot State Machine

VQ1 doesn't need a planner — the next gate is whichever gate is in front of us. The state machine is correspondingly small: two states and one timer.

TRACK PnP → PID toward gate target speed 6 m/s heading · altitude · pitch PIDs SEARCH slow yaw rotation hover-altitude hold until next detection 30 frames w/o detection any detection · conf > 0.5 start
Figure 9.1  Two states. TRACK is the entire happy-path. SEARCH is a graceful fallback that buys the perception stack ~1 second of fresh observation angles.
StateActionExit condition
TRACKPnP yields gate pose → three PIDs (heading, altitude, pitch) → forward thrust30 consecutive frames with no detection above confidence 0.5
SEARCHHover at last commanded altitude, slow yaw rate 0.3 rad/s, throttle held at hoverAny single detection with confidence >0.5

10. What the Sim Drop Resolves

VADR-TS-002 pins almost the entire interface, but three details are not real until the binary is in hand. The pilot is structured so that all three are local edits, not redesigns.

#UnknownWhere to lookCode site
1Telemetry packet byte order & rateFirst recvfrom() on telemetry port; print(len(payload))StubSimClient.next_frame() ↓
2Sim's control input portAIGP setup README in the downloadStubSimClient.send() ↓
3Submission packaging formatAIGP submission instructions emailsubmit_check.py
▌ Lead time, day-zero

All three resolve in well under a day of work, in parallel. Schemas and the PID-driven control loop are already wired; the stub only needs three real implementations to replace three stubbed methods. The contracts in this document become the unit tests.