Running a SITL flight¶
The first end-to-end nexus workflow: fly the simulated vehicle from a ground station, QGroundControl with virtual joysticks, over PX4 Software In The Loop (SITL).
Everything runs on 127.0.0.1. The nexus sim runs the physics, hosts the Rerun
recording, and starts PX4 SITL as a container that connects back over TCP
4560 for lockstep Hardware In The Loop (HIL). PX4 talks MAVLink over the User Datagram
Protocol (UDP) to QGroundControl on UDP 14550. An optional Rerun viewer watches
the live recording over gRPC 9876. PX4 is a peer of the run: the run starts it, stops it,
and hands it its ports, see PX4 as a peer.
Prerequisites¶
uv synchas run. It installs the framework, including the Rerun viewer bundled withrerun-sdk, so you need no separate Rerun install.- QGroundControl installed, with Virtual Joystick enabled under
Application Settings→General→Virtual Joystick. - Docker. The first PX4 run on a machine builds the small
px4-sitlimage from the Dockerfile the package ships, and fetches and builds the PX4 tree, see PX4 as a peer. - For a vehicle with a camera or lidar: an NVIDIA GPU and the NVIDIA Container Toolkit, since its sensors render in the Kit container. See RTX cameras and lidar.
Terminals, in order¶
1. QGroundControl. Launch it. It listens on UDP 14550 and auto-connects once PX4 sends a heartbeat.
2. The nexus sim:
uv run nexus run --vehicle astro_max_base --scene empty
Builds PX4 SITL, starts the PX4 container, then the physics sim, and stops the container again
on exit. Add --view to serve the Rerun recording on gRPC :9876, see Observability in Rerun. The astro_max_base vehicle's
Universal Scene Description (USD) file declares PX4 and its airframe. Every file a run writes lands in one folder for the run,
~/.cache/nexus/logs/<stamp>-<pid>/, or the folder a script names with Sim(run_dir=). It holds the recording, nexus.rrd, and each peer's files under a folder named for it. PX4's console is px4_sitl/console.log, and its .ulg flight log lands beside it, px4_sitl/<date>/<time>.ulg.
A run that never starts, or that loses PX4 or the Kit container before it ends, stops with an error
and exits non-zero. The message names each peer's console log and quotes the peer's own error lines,
so the output alone says why. A PX4 tree without the vehicle's airframe, for example, gives
ERROR [init] Unknown model none_astro_max. A PX4 that exits while it boots fails the run at once.
PX4 counts as lost when it sends no answer for 2 s. A script catches the same error as
nexus_uav.RunError, which separates a broken run from its own check failing.
3. Rerun viewer, optional:
uv run rerun --connect
Attaches as a client to nexus's recording. --connect defaults to
rerun+http://127.0.0.1:9876/proxy. Start it after a sim run with --view, which must own
:9876 first. uv run launches the viewer bundled with the framework, so its
version matches the logger.
RTX cameras and lidar¶
The vehicle Universal Scene Description (USD) file decides, with no flags. A Camera or
OmniLidar prim under the vehicle's root is an RTX sensor, and a run with one starts the Kit
render peer. That container renders the sensors while the loop flies on the host. The run sends it
the poses at each frame and takes the frames back a tick later. A vehicle with no such prim starts
no container.
- The first RTX run on a machine pulls NVIDIA's
nvcr.io/nvidia/isaac-sim:6.0.1, about 21 GB, with no NGC login. Later runs reuse it, and nexus builds nothing. The container runs the image as pulled. The peer program ships in the package, innexus_uav/_src/peers/kit/peer-src/. It mounts read-only, so an update to the peer takes effect on the next run. - A scene that declares a Cesium tileset, such as
--scene cesium, fetches Cesium for Omniverse into the asset cache on its first run. No other scene fetches it. - Kit boots in the background while PX4 builds and the physics compiles. Its shader cache lives in
~/.cache/nexus/kit/, so the first boot is the slow one. The container's console goes tokit/console.login the run's folder, next to the run's recording. - The container runs as you and reads the asset cache and the folder of a local
--vehicleor--scenefile at their host paths, read-only, so everything it writes under~/.cache/nexusstays yours. -
A run whose Kit container can't start fails and names the cause, for example an unreachable Docker daemon. A Kit container that dies mid-flight fails the run the way a lost autopilot does.
-
--vehicleaccepts a catalog vehicle name, such asastro_max_fpv, or a local.usd/.usdzpath.--sceneaccepts a catalog scene name, such asempty, or a local scene USD path. A run names both. Every Astro Max vehicle carries the analytic PX4 suite, an Inertial Measurement Unit (IMU), mag, barometer, and Global Positioning System (GPS). Each is a prim under the body it rides that applies its sensor schema, such asNexusImuAPI. The vehicle USD is the single authority for all sensors, and the schema reference lists the attributes of each schema. Each sensor declares the rate PX4's gzx500model flies, 250 Hz on the IMU, 100 on the mag, 50 on the barometer and 30 on the GPS, and PX4 receives each at its rate. - A camera logs JPEG frames and a
Pinholefrustum tosim/vehicle/sensors/<name>. A lidar logs world-framePoints3Dtosim/vehicle/sensors/<name>too.--debugrecords the axes-only scene, which gives small.rrds, and is the default for verification flights.
Profiling¶
Every run carries an always-on loop profiler: integer-nanosecond phase marks, at negligible cost.
The end-of-run profile [...] line reports the sliding-window Real Time Factor (RTF), the tick p50
and p95, and the per-phase partition, plus the CUDA-event-timed GPU batch. In the partition,
exchange is the PX4 lockstep wait, gpu+read is the device batch plus the D2H sync,
sensors.host is the RTX render link, and other is an explicit residual. The same numbers land
in Loop.run_stats["profile"].
Every entry point shares the deep-diagnostics flags below. nexus run and the
examples launcher, uv run -m nexus_uav.examples <name> --profile, spell them identically:
--profile: periodic reports every 5 s. On an RTX run the detail spansrender.sendandrender.waitshow what a frame costs the loop: the pose send, and the wait for a frame the peer hasn't finished.--trace <path>: buffers spans and writes a Chrome or Perfetto trace on exit. Drag it onto ui.perfetto.dev.--benchmark, on an RTX run: the Kit peer runs theisaacsim.benchmark.servicesrecorders from Isaac, which capture the system envelope the loop profiler can't see. They record render GPU frame time fromHydraEngineStats, GPU memory plus host Resident Set Size (RSS), process CPU%, app-update frame time, and windowed-RTF stability. It writes one summaryINFOline plus a fullkit/benchmark.jsonin the run's folder, beside its console. It complements the profiler: the profiler partitions the tick, and this measures Kit and the system. The PhysX-bound recorders stay out, because physics here is Newton.
Fly¶
Once PX4 logs Ready for takeoff! in px4_sitl/console.log in the run's folder,
QGroundControl auto-connects. Arm and fly with the on-screen virtual joysticks.
Sim physics and PX4 state both appear in the Rerun viewer, because they share the
nexus recording.
Worlds for the FPV camera¶
A vehicle whose USD declares RTX sensors, such as the astro_max_fpv variant's FpvCam with its
NexusCameraAPI schema, renders them in the Kit peer. The FPV camera's world comes from the catalog scene:
# photoreal geolocated globe: Google Photorealistic 3D Tiles streamed live at
# the scene's origin, via Cesium for Omniverse v0.29.0:
export CESIUM_ION_TOKEN=<your ion access token> # cesium.com/ion → Access Tokens
uv run nexus run --vehicle astro_max_fpv --scene cesium --view
Every scene states where it sits on Earth. Its PhysicsScene prim, /World/PhysicsScene in each
shipped scene, applies NexusSceneAPI and authors nexus:geodeticOrigin: the latitude, the
longitude and the altitude of the world origin. For the cesium globe the altitude is the WGS84
ellipsoidal height of the street, which sets the globe so the street sits on the physics ground. A
run fails on a scene whose PhysicsScene authors no origin.
An override layer moves the origin, as it changes any other declared value. To fly the globe over
San Francisco, write a layer that authors the new origin on the scene's PhysicsScene:
#usda 1.0
over "World"
{
over "PhysicsScene"
{
double3 nexus:geodeticOrigin = (37.7942, -122.3954, -29)
}
}
uv run nexus run --vehicle astro_max_fpv --scene cesium --layer sf.usda --view
If the street sits off, change the altitude in the layer. Tile selection runs one hidden-cost viewport per camera, and the RTX lidar reflects off the tiles. Benchmarking lists the measured real-time factor of each vehicle on this scene. Without a token the run warns and falls back to a plain sky, and the flight continues. The Cesium ion and Google Maps Platform terms govern the streamed tiles: see the license page.
PX4 as a peer¶
The PX4 autopilot is a peer of the run: a process the run starts, speaks to over MAVLink, and
stops. The vehicle's USD declares it: NexusPx4SitlAPI on a scope of its own, /astro_max/Px4Sitl,
with the airframe PX4 boots in nexus:airframe. PX4's controller, NexusPx4API_1, sits on the
controller's scope, /astro_max/Controller. A run of a vehicle that declares the peer starts the PX4
SITL container and stops it on exit.
An autopilot started elsewhere. To fly a PX4 SITL of your own, or a real autopilot on a bench, drop the declaration with an override layer, a small USD file the run composes over the vehicle and the scene, which deactivates the peer's scope:
#usda 1.0
over "astro_max"
{
over "Px4Sitl" (
active = false
)
{
}
}
Name the vehicle's root prim and the peer's scope in the overs, and pass the file with
--layer, or Sim(layer=):
uv run nexus run --vehicle astro_max_base --scene empty --layer external_px4.usda
The run starts nothing and waits on instance 0's HIL port, 4560, which a stock px4 -i 0 dials. The
run's receipt records the layer's sha256 beside the vehicle's. The same kind of layer changes a
declared value or selects a variant without re-authoring the hosted vehicle.
Parameters and missions across runs. PX4 keeps what it stores, the parameters set from a ground
station and the last mission it accepted, across runs, as a real vehicle keeps them across a reboot.
Set a parameter in QGroundControl, stop the sim, start it again, and PX4 boots with it. PX4 keeps one
store per airframe and instance, in ~/.cache/nexus/px4_sitl/store/<airframe>/<instance>/. A
vehicle on another airframe keeps its own. A run takes the lowest free instance, so a restart finds
the store only on the instance it flew on, which another run on the machine can hold.
To start from the values of the airframe alone, ask the run to start clean: --clean, or
Sim(clean=True). Every peer that keeps a store empties it first. NEXUS_CLEAN=1 makes clean the
default for every run in the shell. The test suite sets it, and the examples start clean, so no test
or example flies on what an earlier run stored.
The PX4 tree. The PX4 SITL peer pins the PX4-Autopilot commit it flies, in
nexus_uav/_src/peers/px4_sitl/pin.ref, which ships in the package. The first managed run on
a machine fetches that commit into ~/.cache/nexus/px4_sitl/<commit>/ and builds it there, minutes
once. Later runs rebuild only what changed. Every peer resolves its pin the same way, so PX4 has
the two overrides every peer has:
- A project that keeps its own catalog,
nexus.catalog.yaml, keeps its own pin beside it innexus.px4_sitl.ref, of the same form,owner/repo@<commit>. Every vehicle of the project then flies that tree, and a bump is one edit. An installed package that adds a catalog ships its pin the same way, and a project's file overrides a package's. NEXUS_PX4_SITL_REFoverrides every pin file and touches no tracked file. It takes a pin of the same form, or the absolute path of a checkout of your own, for work on PX4 itself. The run builds and flies that checkout and fetches nothing.
The Kit render peer pins its image the same way: nexus_uav/_src/peers/kit/pin.ref names
nvcr.io/nvidia/isaac-sim:6.0.1, and nexus.kit.ref or NEXUS_KIT_REF names another, a local tag
among them. The file and the variable take the peer's folder name. A pin file beside a catalog
that names no peer fails the run, and so does a pin not in its peer's form. The run's receipt records
each peer's pin under peers, with the file or the variable it came from, and for a checkout its
checked-out commit.
The image. The first run builds the px4-sitl image, the PX4 build toolchain, from the
Dockerfile the package ships, and tags it with a hash of that folder. An update rebuilds it only
when the Dockerfile changes.
A script in a container. The daemon that starts PX4 reads each folder the PX4 container mounts
on its own host. Take a script that runs in a container of its own and starts PX4 on the host's
daemon through the docker socket. It binds two folders at the same path inside as on the host: the
PX4 tree, which NEXUS_PX4_SITL_REF can name, and the run's folder, which the script names with
Sim(run_dir=). The script's HOME stays the container's own, so its caches stay out of the run's
folder.
services:
test:
network_mode: host
environment:
- NEXUS_PX4_SITL_REF=${PX4_TREE}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${PX4_TREE}:${PX4_TREE}
- ${RESULTS_DIR}:${RESULTS_DIR}
with Sim("astro_max_base", scene="empty", log=True, run_dir=f"{os.environ['RESULTS_DIR']}/run") as sim:
...
The run creates the folder, and refuses one that holds files already. It writes its recording,
PX4's console and PX4's .ulg flight log there and nothing else. A folder the host doesn't have fails
the run before it flies: docker reports bind source path does not exist and the path.
Ports. PX4 SITL numbers every link from its instance N. It dials the sim's HIL server on
4560 + N, streams its offboard link to 14540 + N, and takes N + 1 as its MAVLink system id. The run
picks the lowest instance free on this machine and derives its own addresses from the same number,
so two runs on one machine take two instances and never collide. A script reads the address of a
link it opens itself from the run's port map: sim.ports["offboard"] holds the offboard link's
port and PX4's system id, which nexus_uav.px4.OffboardClient takes.
| Port | Protocol | Link |
|---|---|---|
| 4560 + N | TCP | PX4 → nexus, lockstep HIL: sensors in, actuators back |
| 14540 + N | UDP | PX4 → a script's nexus_uav.px4.OffboardClient, the offboard link sim.ports["offboard"] names |
| 14550 | UDP | MAVLink telemetry and commands from PX4 to QGroundControl, every instance |
| 9876 | gRPC | Rerun recording, which nexus serves and the viewer connects to |
Observability in Rerun¶
Everything lands in one recording, app ID nexus and recording ID
nexus, and every producer writes it from the sim's own process:
- the sim scene: logged by nexus itself. The blueprint hides the ground plane,
/sim/model/shapes/shape_0, by default because it occludes the vehicle. - framework events: the
nexuslogger, atsim/logs/<module>. - PX4's own view of the flight: not in the recording. PX4 keeps it in its console log,
px4_sitl/console.log, and in itsULog,px4_sitl/<date>/<time>.ulg, both in the run's folder and both artifacts of the run. - test and driver stages: a driver in the sim's process logs each stage with
nx.logger.info("…"), which writes to the console and, when recording, the Logs pane.
Serve or file, never both: two flags, --view and --log, both off. A run can't produce both
a live gRPC server and a complete .rrd from one process, because rerun's serve and file sinks are
mutually exclusive, so:
--viewon the command line, orSim(view=True): serve the recording live on:9876and connect a viewer withuv run rerun --connect rerun+http://127.0.0.1:9876/proxy. It writes no file, so save it from the viewer to keep an.rrd.--logon the command line, orSim(log=True): write the full.rrdto disk.uv run nexus runlogs its path, and a driver reads it fromsim.artifacts(). Use it for CI, or when something else holds:9876.
The tool refuses --view and --log together, and Sim raises ValueError. A run with neither
records nothing: it serves nothing and writes no .rrd, so it runs at full headless speed.
Both modes carry the same content, PX4 included: there is no second recording and no merge step.
Gotchas¶
One PX4 per instance
Each run's container carries the run's own name, nexus-px4_sitl-<pid>-<instance>, and the run removes it on
exit, so a second run stops nothing of the first. A second run takes the next free instance. A
run killed without its teardown, a closed terminal or a harness's timeout, leaves its PX4
running, and the next run on that instance removes it. Two runs that fly autopilots started
elsewhere both need port 4560, so the second fails and names the process that holds it.
Use uv run rerun --connect …, never a bare rerun
Two reasons. First, nexus hosts the Rerun gRPC server on 9876, and the viewer is
a client. A bare rerun opens its own server on 9876 and wins the
port, so nexus's sim data goes nowhere. Second, Rerun
couples the viewer and SDK versions: uv run rerun launches the bundled,
version-matched viewer, whereas a global rerun might be a different version.
Agent-driven viewer over the Model Context Protocol (MCP)
The checked-in .mcp.json registers Rerun's MCP server, uv run rerun viewer-mcp,
with Claude Code. An agent can then open recordings, seek the timeline, and take
screenshots itself. The MCP server controls a separately running viewer over gRPC.
On a machine without a display, start one with uv run rerun --headless.
Preflight: strong magnetic interference
The none_astro_max SITL airframe ships generic parameters, with no real
mag calibration, so QGroundControl shows a Strong magnetic interference preflight
warning. Expected in SITL. It doesn't block flying.