Magnetometer and Accelerometer Axes
Firmware rotates each part into the tag frame, close to the part, so the host
can assume one fixed relationship between the two sensors no matter which parts
a tag carries. The host end of that assumption is
CompassProcessor::computeOrientation.
This matters because the parts do not agree among themselves. The AK09940A, BMM350, LIS2DU12 and LSM6DSV16X each define their own axes, and each is placed on its board at whatever rotation the layout wanted. Nothing makes them line up by default.
The tag frame
The tag frame is ENU and right-handed:
| Axis | Direction |
|---|---|
x |
East -- 90 degrees clockwise from y, seen from above |
y |
North -- toward the battery |
z |
Up |
x cross y is z, and an accelerometer at rest face-up reads +z. The
orientation output of
CompassData::eCompass
is in this frame.
The contract
A tag emits magnetometer and accelerometer samples such that the magnetometer's
axes map onto the accelerometer's as +y, -x, -z: the magnetometer's +y
lies along the accelerometer's +x, its -x along +y, and its -z along
+z. The accelerometer is in the tag frame above; the magnetometer is not,
and the host finishes the job.
That residue is not sloppiness in the firmware, and it is not a mounting the firmware forgot to undo. As a matrix the relation is
0 1 0
-1 0 0
0 0 -1
whose determinant is -1. A part placed on a board can only be rotated, and
every rotation has determinant +1, so no placement of any magnetometer can
produce this. It factors as a -90 degree rotation about z -- which a mounting
could account for -- composed with a z flip, which one cannot. The flip is a
handedness difference between how the magnetometer and the accelerometer define
their axes, and it travels with the part, not the board. That is why firmware
cannot finish the job by rotating alone, and why the correction lives in one
place on the host instead of being duplicated per target.
The host encodes that in three lines that each look like an independent sign convention:
accel = QVector3D(accel[0], -accel[1], accel[2]);
mag = QVector3D(mag[0], mag[1], -mag[2]);
qacc = QQuaternion(q0, q2, q1, q3); // note q2 before q1
They are not independent. Composed, they are exactly the +y, -x, -z
relabelling and nothing else — verified sample by sample to within 1e-13
degrees by
check_orientation_frames.py.
In particular the q2/q1 ordering in the quaternion is not a typo, and the
two sign flips are not separate NWU conversions; changing any one of them in
isolation breaks the other two.
What each family applies
| Target | Magnetometer | Accelerometer | Firmware correction |
|---|---|---|---|
CompassTagAT25 |
AK09940A | LIS2DU12 | orient_mag_values: x' = -y, y' = -x, z' = -z; orient_accel_raw: x' = -x, y' = -y |
IMUTagNandBmp581 |
BMM350 | LSM6DSV16X | orient_imu_xyz on the accelerometer: x' = -y, y' = x, z untouched; orient_mag_xyz with the board's IMUTAG_MAG_TAG_* macros on the magnetometer: x' = y, y' = x, z' = -z |
Both are in each family's sensors.c. The corrections differ because the
mountings differ; what they have in common is the frame they produce. Note that
the two families do not even share a magnetometer part, so there is no reason to
expect their corrections to look alike.
IMUTag's magnetometer correction is per board, because the family's boards
need not mount the magnetometer alike. orient_mag_xyz() applies the
IMUTAG_MAG_TAG_X/Y/Z(mx, my, mz) macros a variant defines in its custom.h,
on both the BMM350 and AK09940A paths, and a variant that defines none passes
the part's axes through unchanged. IMUTagNand defines none and has not been
scored; confirm it with a capture -- see below -- before trusting its headings.
Checking a capture
A correct pairing makes the angle between the calibrated field and gravity a property of the site rather than of the sample, so the inclination is nearly constant however the tag was turned. A mismatch shows up as a spread of tens of degrees.
host/libraries/sensoranalysis/tools/check_orientation_frames.py CAPTURE.json
It reports the spread under the host convention, searches all 48 signed axis permutations, and exits non-zero when a capture disagrees. Run it against a capture from any new tag variant before trusting its headings.
Check each target separately. The two current targets share no sensor part and reach the tag frame by different routes, so a capture from one says nothing about the other.
As of 2026-10-28:
| Target | Checked | Result |
|---|---|---|
CompassTagAT25 |
scored, the replay fixture | agrees: inclination 63.65 degrees, spread 4.11 degrees |
IMUTagNandBmp581 |
scored, a capture of 2026-10-28 from firmware carrying the board's IMUTAG_MAG_TAG_* macros (7cef2ba9, 578 samples, 519 within the gate); before that, two captures of 2026-10-11 remapped through the macros by hand |
agrees: inclination 63.71 degrees, spread 2.86 degrees (by hand: 63.8 and 63.5 degrees, spread 3.5 and 3.1). A capture from firmware built before the macros (56e5e6a0, 2026-10-06) disagrees by 33 degrees of spread and agrees once remapped. |
IMUTagNand |
not scored | -- |
Score a capture rather than judging by eye. qtcalibrate's orientation view
shows dip, and dip holding steady while the tag is tumbled is the same
invariance property, but a mismatch can pass unnoticed there:
IMUTagNandBmp581 was judged correct that way without a magnetometer
correction, and its captures then scored 27 and 34 degrees of spread. A score
covers the tilted attitudes where a mismatch is largest, and lets a fixture
from each target serve as a regression check on the firmware helpers.
Two cautions when reading the result. The discrimination lives in the tilted
samples: as a tag approaches level, q1 and q2 fall to zero and every
candidate alignment collapses to the identity, so a capture held flat will look
healthy whatever its axes are. And the measurement needs gravity, so samples are
kept only while the acceleration magnitude is near one g — that gate belongs on
this metric alone and is not a reason to drop a magnetometer reading, which does
not depend on gravity.
Captures from before the correction
A capture taken before a family's firmware correction existed does not satisfy the contract, and replaying one through the current host code gives wrong headings rather than an error.
The qtcalibrate replay fixture captured on 2026-08-26 is such a capture: it disagrees with the host convention by 38.55 degrees of spread, and applying CompassTag's two helpers to it after the fact brings it to 3.78 degrees. Keep replay fixtures on the current firmware convention, and re-check any capture older than the correction with the script above before drawing conclusions from it.