Skip to content

PX4 software-in-the-loop flight on newton.actuators rotor motors

The Astro Max flown closed-loop by PX4 over the decoupled SITL link, on the core rotor chain. Each rotor motor is a USD-authored newton.actuators composition: a NewtonActuator prim on the real rotor joint, made of a ControllerPID velocity servo and the ClampingDCMotor four-quadrant envelope. So the rotor speed Ω is a solver-integrated joint state with physical lag and saturation, and the props spin for real. The aerodynamics stay a Warp body_f kernel over that solver-integrated Ω: airflow-aware thrust, H-force, and reaction drag, from the propeller each rotor's rigid body declares with NexusPropellerAPI. PX4 runs its real flight stack. The only thing it talks to is the Hardware In The Loop (HIL) link on :4560.

Key stats

Metric Value
Flight PX4 closed-loop: AUTO.TAKEOFF → arm → climb → 4 yaw sweeps, the one flight profile
Takeoff confirmed, climbed +5.0 m, yawed 90/180/270/0°
PX4 warnings 0, gated: the run fails on any WARN or ERROR line about the sim
Rotor chain Each rotor's ESC from its body's NexusEscAPI, USD-authored newton.actuators DC-motor servos on the real rotor joints, one NewtonActuator prim each, and each rotor's propeller from its body's NexusPropellerAPI
Sim speed with PX4 ~3.7× real-time on the GPU with the captured strategy, RTX 5080, for the full takeoff+yaw profile with recording on

Clean log gate. The flight asserts zero PX4 warnings, the px4_warnings metric gated to 0. A healthy closed-loop SITL run must not log a single WARN or ERROR line about the sim. Three known-benign lines are content-allowlisted. The first is the boot-time Parameter <name> not found. line for a parameter the airframe sets and this PX4 build lacks, and every other param failure still gates. The second is the pre-arm "no heading reference" transient while PX4's EKF2 estimator converges, and the takeoff script's failure-free wait rides it out. The third is the logger's Too many subscriptions, failed to add: <topic> line. PX4's default logging in SITL asks for more topics than the logger's 255-entry cap. The logger then refuses the topics it adds last, and which ones changes from boot to boot. A refused topic is a logging limit, not a sim problem, so the gate forgives the line whatever topic it names. The gate caught a real fidelity bug. The environment fed the magnetometer a Zurich World Magnetic Model (WMM) field while the Global Positioning System (GPS) origin is Seattle, so PX4's strict mag check, EKF2_MAG_CHK_STR, intermittently failed with Strong magnetic interference. The fix computes the field at the GPS origin from PX4's own coarse WMM table, nexus_uav._src.core.geomag, ported from PX4's geo_mag_declination.cpp as PegasusSimulator and PX4-SITL_gazebo do, plus a realistic magnetometer σ. The warning is now gone and arming is faster.

DC-motor servos are effectively free. The newton.actuators motor step plus aero kernel cost ~2% Real Time Factor (RTF) versus the old lumped first-order-lag wrench model. The mujoco-warp solver dominates the step. The fidelity gain is real: the rotor speed is a solver-integrated joint state, and the props spin physically in every renderer. The tracking error of the acados Nonlinear Model Predictive Control (NMPC) example halved when it switched to the same actuator.

The sim speed is the real-time factor of the flight itself: sim-time advanced divided by wall-time, with PX4 in the lockstep loop. The loop reports it on exit. The loop runs on the GPU, which --device auto takes when one is present: one CUDA graph replays the device stages, and PX4's truth and exchange host stages run between replays. The per-tick budget is roughly 53% GPU step, 20% PX4 MAVLink exchange, and 27% .rrd logging. The GPU step is mujoco-warp at batch=1: a single drone is latency-bound, not throughput-bound, so this is the floor for one env on this solver. Logging decimates to 50 Hz. Logging the full scene at the 250 Hz sim rate was the dominant cost before and capped the loop at ~2.3×. For reference, the earlier CPU-eager default ran at ~1.03×. The Proportional Integral Derivative (PID) loop has no host stage. It captures the whole tick and runs far faster still, ~19× on quad_x. Pass --profile to log the per-tick breakdown. See Execution for the stage model.

Throughput compared to flight pacing. --no-rerun drops all recording and the raw loop hits ~6.3×, then 71% GPU, 23% PX4, and 6% actuator write: pure compute. But the closed-loop takeoff doesn't complete that fast. PX4's intermittent "magnetic interference" pre-arm check needs a sustained wall-clock window of being clear for the external Ground Control Station (GCS) to command arm. Faster than ~5×, sim-time races past those windows so it never arms. So ~6.3× is the throughput ceiling and ~4.7× is the reliable closed-loop-flight ceiling, gated by the GCS and PX4 arming handshake, not compute. The example keeps recording on: it flies and produces the .rrd. Use --no-rerun only for non-flight throughput runs.

Run it

This is the decoupled workflow, headless and end-to-end. Running a SITL flight has the interactive QGroundControl version:

# closed-loop flight, one nx.Sim run: nexus serves the HIL :4560 itself + records the .rrd +
# reports the with-PX4 RTF; PX4 SITL connects; the harness flies THE one PX4 flight profile
# (a script against nexus_uav.px4.OffboardClient over MAVLink :14540: arm + AUTO.TAKEOFF + yaw sweeps). Zero-arg.
uv run -m nexus_uav.examples px4_sitl

# or the example + the evaluation/regression gates at once
uv run --group ci python scripts/ci/evaluate_examples.py --only px4_sitl

Prerequisites, per Running a SITL flight: docker and a CUDA host. The first run fetches and builds the PX4 tree the peer pins, and NEXUS_PX4_SITL_REF names a checkout of your own instead.

Regression gate

scripts/ci/evaluate_examples.py runs this example on a GPU box of its own, one box per example, in the gpu-examples workflow via the shared gpu-runner.yml. The box fetches and builds the PX4 tree from the pin the peer ships, nexus_uav/_src/peers/px4_sitl/pin.ref, the way a user's first run does, and caches the tree and the px4-sitl image under that pin. The script then gates the fresh run against scripts/ci/examples_baselines.json. It fails if the closed-loop takeoff stops working, checked by takeoff_confirmed and climb_m, or if PX4 warns about the sim, checked by px4_warnings. It also fails if the with-PX4 sim falls behind its recorded speed, checked by rtf. The same harness evaluates every example. See Benchmarking.

CI-measured on the pinned runner:

Metric Value
climb_m 5.040 m
time_to_takeoff_s 22.8 s
px4_warnings 0
rtf 1.94×

Measured on NVIDIA A10G · 2026-08-16 · commit b7d6504465c6