Skip to content

Bringing your own component

A project beside nexus adds a component the way the framework adds its own. It ships three things. A schema declares the component in a vehicle's Universal Scene Description (USD) file. A class fills the one component contract. An entry maps the schema to the class. Nothing in nexus changes, and the project patches nothing. PX4 is the worked example throughout. It's the one controller nexus ships, and it lives in its own folders as a project's component would.

The schema

A component is one applied API schema on the prim it belongs to. The schema comes from a USD schema plugin with no compiled code: a plugInfo.json and a generatedSchema.usda at the root of your package, as Newton's schema package ships its own. The attributes of the schema, in the nexus: namespace, are the keyword arguments of your class. The schema includes its role schema as a built-in, NexusControllerRoleAPI for a controller, so the builder places the component by its role and never by the methods of its class. The schema reference lists the shipped schemas and their attributes, and nexus_uav/_src/usd/generatedSchema.usda is the file to copy from.

NexusPx4API_1 is PX4's. It applies to a Scope under the vehicle's root prim, states the controller role, and authors no attribute. The airframe PX4 boots belongs to its peer's schema, NexusPx4SitlAPI, on a Scope of its own.

The class

Every role fills the same contract, Component. Subclass it, state your per-tick work in stages() as a list of Stage, and override the lifecycle members you have. Each stage declares the Signals it reads and writes. A device stage joins the captured CUDA graph, and a host stage runs between replays. The loop wires the signals before any stage runs, so a reader takes what a writer of the same name writes. A pair that disagrees on the type or the shape stops the run before it starts.

import numpy as np
import warp as wp

from nexus_uav import Component, ImuSample, Signal, Stage


class HoverController(Component):
    """Reads the IMU and writes one command per rotor on the host."""

    def __init__(self, *, throttle: float = 0.5):
        self.throttle = throttle
        self.imu = Signal("imu", ImuSample, shape=(1,))
        self.controls = Signal("controls", wp.float32, shape=(1, 4))

    def stages(self) -> list[Stage]:
        return [Stage("act", "host", self._act, reads=(self.imu,), writes=(self.controls,))]

    def _act(self, tick) -> None:
        self.controls.write(np.full((1, 4), self.throttle, dtype=np.float32))

The keyword arguments are the attributes of the schema, nexus:throttle here. The controls signal of a controller is as wide as its actuators, and the command elements read the leading part of it.

The contract has eight more members. connect opens what the component needs for the run, a port or a model, and takes the link of each peer the class requires, keyed by the peer's name. close releases what connect opened. attached reads false until the peer the component waits on has dialed in, so the loop connects it after the capture and holds the sim clock until then. requires names the peers the class needs, which the builder starts. needs_instance asks for the run's instance, the number the run's peers number their ports from, as the keyword instance. ports names the links the component knows of that leave the run, which sim.ports lists. artifacts names the files the component leaves for a reader. set_logger takes the Logger the loop hands over, scoped to the component's path, for a value with no fixed width such as a camera's frame. The Recorder keeps every fixed-width signal itself, and a script reads it by path, sim["vehicle/controllers/<name>/controls"].

Px4MavlinkController, PX4's controller, is the shape to copy for a component with a peer. It binds the Hardware In The Loop (HIL) port in connect and reads attached from the link. One host stage per tick exchanges the samples of the sensors for the controls of PX4. It names its ULog in artifacts and closes the socket in close.

A sensor's class takes the model and its site, the path of the prim that declares it. Then come the attributes of its schema as keywords, and seed, the noise seed the builder derives from the run's seed and the prim. It reads the body it rides, its mount and the world's values from the model, as Newton's own sensors do. A sensor whose class requires a peer, as the RTX cameras require the Kit render peer, takes the peer's link in connect.

class RangeSensor(Component):
    def __init__(self, model, site: str, *, seed: int = 0, noise: float = 0.01, rate: float = 0.0): ...

The registration

Installing your package registers it. One entry per schema in the nexus.registry group of your pyproject.toml names the class by import path. import nexus_uav then registers your schema plugin with OpenUSD, and the registry resolves the schema to your class:

[project.entry-points."nexus.registry"]
HoverAPI = "my_project.hover:HoverController"

A class a script defines rather than installs goes in through register("HoverAPI", HoverController). A test maps a schema to a stand-in without touching the table of the install. It hands the run a registry of its own, Sim("my_quad", scene="empty", registry=Registry({"HoverAPI": StandIn})). The rows of that registry answer first, and the install answers the rest.

The peer and its fake

A peer is a process outside the process of the loop, which the run starts and stops: a container, a virtual machine, or a bare process. It fills the Peer contract. start blocks until the process runs, or raises naming why. stop is idempotent. alive says whether the process still runs, and artifacts names the files it leaves. The run owns every address a peer uses and hands the peer its ports, so two runs on one machine never collide. A link that leaves the run shows in sim.ports, and the death of a peer reaches the loop through its link.

A vehicle declares a peer with a schema of its own, on a Scope of its own under the root prim. The registry maps the schema to the peer's class, as it maps a component's. The schema states no role: its class is a peer, and so the build sorts it from a component. Its attributes are the keyword arguments of the class, as nexus:airframe is the PX4 peer's. The run's values join them as keywords: pin, instance, log_dir, files, the USD files the run reads, cache_dir, and clean, whether the run starts clean. A peer that keeps a store across runs, as a companion computer keeps its disk, keeps it under ~/.cache/nexus/<name>/ and empties it first when clean is true. A peer a class requires, as an RTX camera requires kit, the registry maps by that name. The class states its name, the folder name its pin file takes, and check_pin, which fails a pin not in its form and returns the pin it checked. It states clock, how it keeps time: stepped, on_request or free, per Peer. A run that declares a free-running peer needs --rtf 1, and fails before any peer starts without it. It can state instance_free, which says whether an instance is free for it, and ports, the links it knows of that leave the run. A peer that talks to another peer over a serial line, as a companion computer talks to PX4, declares the line in its schema as a relationship nexus:serial:<line>. The vehicle targets the prim of the peer at the other end. The run then hands it the path of its end in the keyword serial, by the line's name, per Link. A peer that takes a component's output, as a companion computer takes a camera's frames over a USB cable, declares each input in its schema as a relationship nexus:inputs:<signal>. The vehicle targets one or more prims whose components write that signal. The loop hands the peer each writer's value once per tick, after the writers, through take(signal, writer, value), with the writer's instance name. The peer's take is its end of the link, and the peer decides what it makes of a value and how it carries it. The loop hands a host value once, on the write that produced it, so the peer takes each frame once at the camera's rate. It hands a device value every tick, since a kernel writes it every tick. A take that raises fails the run naming the peer. A run's override layer drops a declared peer by deactivating its scope, active = false.

Every peer ships a fake: a class on the same contract that speaks the links of the peer in-process and starts no process. A test flies the component against it in seconds. PX4's is Px4Fake, which answers the HIL link with a fixed command. A fake of a peer with an input offers the same take and drops what it takes. A test hands the row of the fake to the run through the registry, as it hands a stand-in component.

Every peer pins what it runs, a commit or an image, in pin.ref in its own folder, with the pin on line one. A project overrides it with nexus.<peer>.ref beside its catalog, in the same form, and the variable NEXUS_<PEER>_REF overrides both, where <peer> is the folder name of the peer. The run's receipt records each pin and where it came from. A peer of your own follows the same rule: its package ships nexus.<peer>.ref beside its catalog. A peer that no source pins runs with none. Every run fails on a pin file that names no peer the install knows, so a typo can't go unseen. The install knows a peer by its schema: its type in plugInfo.json names it, "nexusPeer": "radio", as NexusPx4SitlAPI names px4_sitl, and its nexus.registry entry maps the schema. A run that doesn't start your peer then accepts its pin file and imports nothing of its class.

A peer whose process is one container subclasses ContainerPeer, as the shipped peers do. The subclass states its name, the peer's kind, and run_options, what its container runs: the image, the command and the other keywords of the docker SDK's containers.run. The class does the rest. It removes a leftover container of the peer's name, then runs the container under that name with the label nexus.peer=<name>. It streams the console into <name>/console.log in the run's folder. It removes the container at stop, and reads alive from the daemon. A peer that ships its own build context builds its image with ensure_image. That tags the image by a hash of the folder, so it builds once per machine and again when the folder changes.

from pathlib import Path

from nexus_uav import ContainerPeer, ensure_image

IMAGE_DIR = Path(__file__).with_name("image")


class Radio(ContainerPeer):
    name = "radio"

    def __init__(self, *, instance, log_dir, **run):
        super().__init__(instance=instance, log_dir=log_dir)

    def run_options(self):
        return {"image": ensure_image(IMAGE_DIR, "radio"), "command": ["radio", "--serve"]}

Every peer's files live under a folder named for it in the run's folder, its console as console.log there. PX4's are under px4_sitl/, its ULog beside the console, and Kit's under kit/.

What an integration exports

A project that ships a component also ships the names a script needs to command it, in a namespace of its own. PX4's is nexus_uav.px4. It holds OffboardClient, the client a script opens on the offboard link, and Plan, MissionItem and read_plan, the mission a script uploads, with the NAV_* and FRAME_* constants a mission item names. Each lives in PX4's own folder, and the namespace only re-exports them, so the generic surface, nexus_uav, names nothing of PX4. That list is the whole of what an integration exposes. Its classes stay where they live, and a script imports the few names it spells.