Coordinate frames

Three levels of frame override, where the most specific one wins.

cargo run -p vrobots-examples --bin ex25_frames
./target/cpp-build/ex25_frames
python examples/python/ex25_frames.py

Frames are presentation, never physics

Changing a frame does not move the robot one millimetre differently. The numbers describing it are permuted, and depending on the pair one of them changes sign. Registered ids are unity, frd, fru and cv, plus anything the scene registers at runtime, which is why coord_frame_id (a string) is authoritative and axis_convention (an enum tag) is the convenience beside it.

Three levels

From the examples/rust/src/bin/ex25_frames.rs header:

device override   (srv/frames, per device)      <- most specific
robot override    (srv/frames, robot_frame_id)
robot default     (truck: fru, multirotor: frd, globalhawk: frd -- regardless of the scene)
scene frame       (scene_frame(); every launch starts at fru)

The three robot defaults in that row were measured live against simulator v3.0.0. The remaining robot types are unconfirmed, and no per-robot default exists anywhere in the SDK source, so read State::coord_frame_id off the snapshot rather than assuming a default for them.

srv/frames writes the top two levels. The bottom one is read-only from the SDK.

Reading the scene level

scene_frame() is a payload-less GET that reads and changes nothing. It is scene scope rather than robot scope: the answer is the same for every robot loaded, and the method uses this robot's session only because that is where the wire is.

#![allow(unused)]
fn main() {
    let scene = robot.scene_frame()?;
    println!(
        "scene frame: {:?} (axis_convention {}, {:?})",
        scene.coord_frame_id,
        scene.axis_convention.0,
        scene.axis_convention.name()
    );
}
The same in C++ (examples/cpp/ex25_frames.cpp)
const vrsdk::SceneFrame scene = robot.scene_frame();
std::printf("scene frame: \"%s\" (axis_convention %d)\n", scene.coord_frame_id.c_str(),
            scene.axis_convention);
The same in Python (examples/python/ex25_frames.py)
scene = robot.scene_frame()
print(
    f"scene frame: {scene.coord_frame_id!r} "
    f"(axis_convention {scene.axis_convention}, {scene.axis_convention_name!r})"
)

The query is the same everywhere; what you can print of the answer is not. Rust reads the enum tag's name through Axes::name() and Python through axis_convention_name, while in C++ axis_convention is a plain integer with no name beside it, so the C++ line prints the number alone.

It returns SceneFrame { coord_frame_id: String, axis_convention: Axes }. A frame the scene registered at runtime has no enum value, so axis_convention comes back UNSPECIFIED and the string is the only thing that identifies it.

scene frame: "fru" (axis_convention <n>, <name>)

Note. Nothing persists between launches. The scene frame starts at fru every time the simulator is started, whatever the last session left it at.

Setting the robot and device levels

set_frames(robot_frame_id: Option<&str>, devices: &[DeviceFrame]) takes two independent halves and writes whichever you fill in.

ValueMeaning
None for robot_frame_idleave the robot's level alone
a registered idoverride that level
INHERIT_FRAMEclear the override, so the level below wins again
""untouched; the simulator skips it

INHERIT_FRAME is the string "inherit". The distinction between it and an empty string is the whole of the API here: one erases an override, the other declines to say anything. The SDK refuses an empty frame id in a DeviceFrame entry outright, precisely because the simulator would skip it and the ack would still say ok.

Device names are matched exactly, case included, and live in the device module:

ConstantString
device::ACCELEROMETERaccelerometer
device::GYROSCOPEgyroscope
device::MAGNETOMETERmagnetometer
device::BAROMETERbarometer
device::GPSgps
device::OPTICAL_FLOWoptical_flow
device::camera(name)camera/<name>

Gotcha. The device the frames service matches is gps, while the block it moves is called gnss in the state message. Use the constants rather than a literal.

From examples/rust/src/bin/ex25_frames.rs:

#![allow(unused)]
fn main() {
    robot.set_frames(
        Some("frd"),
        &[
            // Keep the gyro reading the way it was, while the robot moves to frd.
            DeviceFrame::new(device::GYROSCOPE, "fru"),
            // Clear any override this device had: fall back to the robot's level.
            DeviceFrame::new(device::GPS, INHERIT_FRAME),
            // Deliberate miss: this truck has no camera called "front". The entry
            // is skipped with a log line and an `ok` ack; the others still apply.
            DeviceFrame::new(device::camera("front"), "cv"),
        ],
    )?;
}
The same in C++ (examples/cpp/ex25_frames.cpp)
robot.set_frames("frd",
                 {
                     // Keep the gyro reading the way it was, while the
                     // robot moves to frd.
                     {vrsdk::device::GYROSCOPE, "fru"},
                     // Clear any override this device had: fall back to
                     // the robot's level.
                     {vrsdk::device::GPS, vrsdk::INHERIT_FRAME},
                     // Deliberate miss: this truck has no camera called
                     // "front". The entry is skipped with a log line and
                     // an `ok` ack; the others still apply.
                     {vrsdk::device::camera("front"), "cv"},
                 });
The same in Python (examples/python/ex25_frames.py)
robot.set_frames(
    "frd",
    [
        # Keep the gyro reading the way it was, while the robot moves to frd.
        DeviceFrame(device.GYROSCOPE, "fru"),
        # Clear any override this device had: fall back to the robot's level.
        DeviceFrame(device.GPS, vrsdk.INHERIT_FRAME),
        # Deliberate miss: this truck has no camera called "front". The entry
        # is skipped with a log line and an `ok` ack; the others still apply.
        DeviceFrame(device.camera("front"), "cv"),
    ],
)

The device names and INHERIT_FRAME are spelled the same in all three. Only the entry differs: C++ brace-initialises each pair inline, Python constructs a DeviceFrame, and Rust calls DeviceFrame::new. The robot half is Some("frd") in Rust and a plain "frd" in the other two.

The robot's header frame changes on the next state sample, the gyro keeps its own, and the missing camera entry is dropped without disturbing the other two:

default    robot=<default> (...) pos=(...)  gyro frame=<default>  gnss frame=<default>
overridden robot="frd"     (frd) pos=(...)  gyro frame="fru"      gnss frame="frd"

Unknown ids are skipped entry by entry, not request by request. That is different from the rotor list, where a single bad entry drops everything, and it is why a typo in one device name is invisible: the other entries land, the ack says ok, and the only symptom is one block still reporting in the old frame.

Clearing an override

Passing INHERIT_FRAME at both levels puts everything back where it started.

#![allow(unused)]
fn main() {
    robot.set_frames(
        Some(INHERIT_FRAME),
        &[DeviceFrame::new(device::GYROSCOPE, INHERIT_FRAME)],
    )?;
}
The same in C++ (examples/cpp/ex25_frames.cpp)
robot.set_frames(vrsdk::INHERIT_FRAME,
                 {{vrsdk::device::GYROSCOPE, vrsdk::INHERIT_FRAME}});
The same in Python (examples/python/ex25_frames.py)
robot.set_frames(
    vrsdk.INHERIT_FRAME,
    [DeviceFrame(device.GYROSCOPE, vrsdk.INHERIT_FRAME)],
)

INHERIT_FRAME clears an override on every surface. What differs is how each says "leave the robot's level alone": None in Rust and Python, an empty string in C++, which the binding turns into the same absent field. C++ also offers a devices-only overload for that case.

The robot falls back to its own default, and the gyro falls back to the robot:

cleared    robot=<default> (...) pos=(...)  gyro frame=<default>  gnss frame=<default>

If the truck's own default and the scene frame happen to be the same id, that one run cannot tell you which level answered. The robot default outranks the scene either way.

What the SDK refuses

Three requests never reach the wire: nothing set at all, an entry with an empty device name, and an entry with an empty frame id. Each returns VrError::InvalidArgument naming what was wrong.

-- refused before anything reaches the wire --
  nothing set                      [<code>] <message>
  an entry with an empty device    [<code>] <message>
  an entry with an empty frame id  [<code>] <message>
(use INHERIT_FRAME to clear an override; "" would be skipped sim-side)

Where the confirmation is

Two places, and neither is the ack: the coord_frame_id stamped on every subsequent state header, and the robot's z/frames topic, which republishes the full definition of each frame, basis matrix included, on change and then at 1 Hz.

Next: The truck drivetrain

See also: Frames, axes and units, Sensor noise, Appendix A: Topic reference