Sensors

Every device in the measured block, its fields, its units, and the two common fields that reveal the rate it actually runs at.

What is in the block, and what is deliberately not

State::sensors is everything the robot can observe about itself. There are six devices: accelerometer, gyroscope, magnetometer, barometer, GNSS receiver and optical flow. Each is a separate struct with its own fields.

Two absences are deliberate. There is no imu grouping, because the accelerometer and the gyroscope are separate devices with separate clocks and separate noise models, and bundling them invites code that assumes they updated together. There is no attitude field anywhere in sensors, because attitude is never measured, only fused: if you want the robot's belief about its orientation, that is estimate, and if you want the simulator's, that is kin.quat.

Every device carries the same two fields, and they matter more than the readings do.

FieldTypeUnitsDefaultNotes
timestampf64s since the unix epoch0.0the sensor's own capture clock, not the snapshot header's
validboolfalsefalse until the device has produced a usable reading

valid = false has three distinct causes that look identical from the client: the device is not mounted on this robot, it is mounted but has not produced a first reading, or it has lost its fix. Treat it as "do not use this number" and check the robot's configured sensor set if you expected otherwise.

The devices

Accelerometer

FieldTypeUnitsFrameDefaultNotes
linear_acceleration[f64; 3]m/s²body[0.0; 3]specific force, so +1 g at rest and 0 in free fall
axis_conventionAxesUNSPECIFIEDmounting convention, when it differs from the robot's
coord_frame_idString""mounting frame id, when it differs from the robot's

Specific force is the field people misread. It is not kin.lin_acc: subtracting gravity is your job, and doing it needs an attitude you do not have from this device alone.

Gyroscope

FieldTypeUnitsFrameDefaultNotes
angular_velocity[f64; 3]rad/sbody[0.0; 3]body rates, directly comparable to kin.ang_vel
axis_conventionAxesUNSPECIFIEDmounting override
coord_frame_idString""mounting override

Magnetometer

FieldTypeUnitsFrameDefaultNotes
magnetic_field[f64; 3]see belowbody[0.0; 3]the sources disagree on the unit
axis_conventionAxesUNSPECIFIEDmounting override
coord_frame_idString""mounting override

Note. The unit of magnetic_field is not documented in state.rs. The tour example labels its printout gauss and says so explicitly; the sensor-noise service documents the magnetometer's noise parameter in tesla. Until this is checked against a running simulator, do not assume the reading and the noise setting share a unit (1 T is 10 000 G).

Barometer

FieldTypeUnitsDefaultNotes
pressuref64Pa0.0static pressure; diff against env.air_pressure for the error
altitudef64m0.0pressure altitude computed against qnh, so it drifts with the weather
qnhf64Pa0.0reference sea-level pressure used for altitude

The barometer is the one device with no axis_convention and no coord_frame_id, because it is a scalar sensor with nothing to orient.

GNSS

FieldTypeUnitsDefaultNotes
geo_pointGeoPointdeg, deg, mzeroedreported geodetic position
velocity[f64; 3]m/s[0.0; 3]reported velocity
ephf64m0.0horizontal position accuracy estimate
epvf64m0.0vertical position accuracy estimate
fix_typeu320fix quality, receiver-defined
axis_conventionAxesUNSPECIFIEDmounting override
coord_frame_idString""mounting override

GeoPoint is latitude and longitude in degrees and altitude in metres, and the same struct appears in env.geo_point as the true position.

Gotcha. The receiver runs at roughly 5 Hz against a 25 Hz state stream, so the same fix is republished in about five consecutive snapshots. Presence is not freshness. To detect an update boundary, compare gnss.timestamp with the value you saw last, and only then treat the reading as new. Code that differentiates GNSS position once per state sample instead of once per fix produces a velocity that is zero four samples out of five and then spikes.

Optical flow

FieldTypeUnitsFrameDefaultNotes
velocity[f64; 3]m/sbody[0.0; 3]estimated velocity from a downward-looking sensor
axis_conventionAxesUNSPECIFIEDmounting override
coord_frame_idString""mounting override

Optical flow is optional and deliberately a poor sensor: valid goes false over featureless ground. Robots mount it only when asked, so valid = false on a default robot usually means the device is not fitted rather than that it failed.

The per-device frame override

Five of the six devices carry their own axis_convention and coord_frame_id. They exist for one reason: a device whose mounting frame differs from the robot's, for example an IMU rotated in its bracket or a receiver quoting velocity in NED while the body publishes frd. When they are set, they win for that device's vectors only.

The practical rule is that a diff between a sensor vector and a truth vector is only meaningful once both are in the same frame. Read the device's coord_frame_id first; when it is empty, the device is in the robot's frame from the snapshot header.

Reading the measured block

The tour example prints each device with its reading, its validity and its own clock side by side, which is what makes the differing rates visible.

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

#![allow(unused)]
fn main() {
println!(
    "  gnss      lat={:.6} lon={:.6} alt={:.2} m  vel={} m/s (NED)",
    n.gnss.geo_point.latitude,
    n.gnss.geo_point.longitude,
    n.gnss.geo_point.altitude,
    v3(n.gnss.velocity)
);
println!(
    "            fix={} eph={:.2} epv={:.2} m  {}   [slowest device, ~5 Hz]",
    n.gnss.fix_type,
    n.gnss.eph,
    n.gnss.epv,
    stamp(n.gnss.valid, n.gnss.timestamp)
);
}
The same in C++ (examples/cpp/ex10_sensors_tour.cpp)
std::printf("  gnss      lat=%.6f lon=%.6f alt=%.2f m  vel=(%+.3f,%+.3f,%+.3f) m/s (NED)\n",
            n.gnss.geo_point.latitude, n.gnss.geo_point.longitude,
            n.gnss.geo_point.altitude, n.gnss.velocity[0], n.gnss.velocity[1],
            n.gnss.velocity[2]);
std::printf("            fix=%u eph=%.2f epv=%.2f m  ", n.gnss.fix_type, n.gnss.eph,
            n.gnss.epv);
stamp(n.gnss.valid, n.gnss.timestamp);
std::printf("   [slowest device, ~5 Hz]\n");
The same in Python (examples/python/ex10_sensors_tour.py)
g = n.gnss.geo_point
print(
    f"  gnss      lat={g.latitude:.6f} lon={g.longitude:.6f} alt={g.altitude:.2f} m  "
    f"vel={v3(n.gnss.velocity)} m/s (NED)"
)
print(
    f"            fix={n.gnss.fix_type} eph={n.gnss.eph:.2f} epv={n.gnss.epv:.2f} m  "
    f"{stamp(n.gnss.valid, n.gnss.timestamp)}   [slowest device, ~5 Hz]"
)

The device path is the same in all three: sensors.gnss.geo_point.latitude, reached through s.raw.sensors in C++ and s.sensors in the other two. So is the valid and timestamp pair every device carries, which is what the shared stamp helper prints.

Run the tour at 1 Hz and the GNSS stamp advances once per printed block; run it at the state rate and the same stamp repeats:

  gnss      lat=37.400000 lon=-122.100000 alt=12.34 m  vel=(  +0.000,  +0.000,  +0.000) m/s (NED)
            fix=3 eph=1.20 epv=1.80 m  [valid t=1770000000.200]   [slowest device, ~5 Hz]

The helper stamp that formats the bracketed field is two lines long and is quoted in A tour of the whole snapshot.

Next: The environment block

See also: Sensor noise, Coordinate frames, Timestamps and sequence numbers