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