Skip to content

Conventions

The rules for the components' contracts and for the project's names, then the structure of every shipped vehicle. Concepts defines each term.

Where a contract lives

A contract lives at or below its consumer: in the caller's own package, or lower in the import layers .importlinter fixes. The loop drives Clock, Physics, Sensor, Controller, Renderer and Recorder, and the stages a command element or a force element states, and sits at the bottom layer, so those contracts live in core/. A contract that sits higher than its consumer ends up written twice: once where the consumer can import it, once beside the implementations. That's how one role's contract came to exist in two files before this rule.

Who declares a part

A vehicle declares a component in its Universal Scene Description (USD) when the component is a property of the machine: every role but the guidance. The estimator is one of them. The guidance and the controller read its estimate in place of the physics state. Until the example controllers fly as variants of the vehicle, each example's assembly constructs it. What a scene contributes is the scene's. The guidance is a property of the run, not of either, so it stays an argument: a flight constructs it and hands it to Sim. It holds no controller: it writes the setpoint, a signal the controller reads. PX4 takes none, because its own navigator is its guidance, and no estimator, because its own estimator runs in its peer. The Renderer follows from the vehicle: a sensor whose class requires the Kit render peer starts it. The clock, the physics, the Recorder, and the Logger stay fixed: one implementation each, configured by settings rather than swapped, so none gets a resolver or a published Protocol. Fixed is about publishing no resolver, not about which directory the implementation sits in: _src/physics/ imports newton, which .importlinter keeps out of core/, and no module imports Kit, which runs only in the Kit peer.

A declared component with no geometry, the controller, the estimator and the guidance, sits on a prim of its own under the vehicle's root prim. That prim is a Scope, OpenUSD's grouping prim with no transform, as an asset keeps its materials under a Looks scope. By convention its name is its role, such as /astro_max/Controller and /astro_max/Estimator: the build finds a component by its schema, not by its path. A declared peer sits on a Scope of its own too, /astro_max/Px4Sitl for PX4's, so a run's override layer drops it by deactivating its scope. PX4's schemas, NexusPx4API_1 and NexusPx4SitlAPI, apply to a Scope alone. A sensor sits on its mount prim, a geometry prim under the body it rides, which Newton loads as a site.

How a peer enters a run

A peer enters a run in one of two ways, and one question separates them: does the peer have a counterpart on the real vehicle?

  • A declared peer stands in for a part of the vehicle, so the vehicle's USD declares it with a schema. PX4 Software In The Loop (SITL) stands in for the flight controller, and NexusPx4SitlAPI on its own scope declares it, with the airframe PX4 boots. The run starts it, and a run's override layer deactivates that scope to attach to a process started elsewhere.
  • A required peer is part of the model that replaces a real component, so the component's class requires it, and no asset names it. The real camera is the component, and the Kit render peer is how its model computes an image. A camera's schema says what the camera is: its resolution and its rate. The class the component registry maps that schema to states requires = ("kit",), and the build starts the peer once for all the sensors that require it.

The split is a design choice. A Kit schema on the vehicle would put a renderer into the description of a vehicle, and a second renderer would then need a layer on every run to drop it. With a required peer, another renderer is another class for the same camera schema, one registry entry.

Who owns an address

The run owns every address. A link runs over a socket between two processes, and one side has to pick the port. The run picks, because only the run knows what else flies on the machine. It claims the lowest PX4 instance free there and numbers every link from it, so two runs on one machine never collide.

Where an address goes depends on which side of the run the link's end sits:

  • An end inside the run: the builder builds it from the run's addresses. The PX4 controller's Hardware In The Loop (HIL) server is one, and the builder hands the controller its port.
  • An end outside the run: it reads its address from the run's port map, sim.ports. PX4's offboard link is one. A script opens its own client, nexus_uav.px4.OffboardClient, on sim.ports["offboard"], which holds the port and PX4's MAVLink system id.

The split is a design choice, and it follows from a second rule: no generic part names PX4. The loop, the roles' contracts and Sim carry no PX4 class, port or verb, and an import contract in .importlinter holds that. A client that Sim built for the script would put a PX4 class into Sim. A method on the peer object would ask the peer for a port the run owns. It would also miss a run attached to a PX4 started elsewhere, which holds no peer object while the link is live. So the script that commands PX4 names it, and the port map serves every link that leaves the run alike.

A link with nothing behind it stays out of the map. A run against the fake PX4 lists no offboard link, so the lookup fails at once and names the fake, and no client waits on a link nothing answers.

Names

The project's name takes three forms, each for one kind of thing:

Form What takes it Example
nexus-uav The distribution on PyPI uv pip install nexus-uav
nexus_uav The Python import package and its folder: every name Python resolves import nexus_uav as nx, nexus_uav/_src/
nexus The project, and every name outside Python's import system the nexus command, the nexus: USD attributes, the nexus.registry and nexus.catalog entry-point groups, the nexus logger, the nexus.peer Docker label, nexus.catalog.yaml, ~/.cache/nexus

The reinforcement learning project follows the same split: nexus-rl is its folder and project, and nexus_rl is its import package.

Prose writes the name nexus, in lowercase, as the logo does, and no sentence starts with it: reword the sentence instead, so that it starts with "The framework" or "With nexus," for example. A heading that's only the name, such as # nexus, is exempt. Only an identifier whose own case rule needs a capital carries one, such as the NexusImuAPI schema or the Nexus-AstroMax-GoTo-Direct-v0 task. The Project Vale style in .vale/styles/Project/ checks both rules.

The vehicle model

The loop names no vehicle type: the controls signal carries one command per actuator, and the base body is whatever body the rotor joints share. Only the shipped rotor chain, the example controllers, and the vehicle USDs are quad-X. Every shipped vehicle is a USD authored to the structure and frames below, because the framework only ever builds models from USDs and never synthesizes one in code. Supporting a different quad is a new USD authored this way plus a retune of the cost weights and the motor map, with no code changes. In particular you must author the base body as Forward Right Down (FRD), as body_frd with body +z down, with four rotor revolute joints, because:

  • the shipped rotor chain, the example controllers and the PX4 path all assume thrust along −body-z, the FRD convention. It's a hardcoded invariant, not a per-call parameter.
  • the sampling Model Predictive Control (MPC) planner builds its single-body model by fixing the rotor revolute joints and collapsing them with ModelBuilder.collapse_fixed_joints. So the rotors must be revolute joints off the base, or the collapse can't find and merge them.

Rotor indexing

Astro rotor indices and directions

Model structure

  • World
    • Body, body_frd: a link on a free joint, a floating base, with FRD axes
      • Rotor 1, rotor_1: a link on a revolute joint, positive rotation along the \(z\) axis
      • Rotor 2 and Rotor 3: the same
      • Rotor 4, rotor_4: a link on a revolute joint, positive rotation along the \(z\) axis

Each rotor link applies NexusPropellerAPI, which declares its propeller, and NexusEscAPI, which declares its ESC and the entry of the controls it reads, and a NewtonActuator prim drives each rotor joint as its motor. Each rotor flies its own propeller's values. A revolute joint whose link declares neither is no rotor. The schema reference lists the attributes.

Frames

Frame Symbol Origin Axes
Body \(\mathcal{B}\) Vehicle center of gravity \(x\) forward, \(y\) right, \(z\) down
Rotor \(i\) \(\mathcal{R}_i\) Rotor center of gravity \(x\) forward, \(z\) according to rotation

Frames Rotation axes